Skip to content

feat: add SenderName, WasMentioned to inbound context; preload group members#141

Merged
yujiawei merged 5 commits intomainfrom
fix/sender-identity
Mar 31, 2026
Merged

feat: add SenderName, WasMentioned to inbound context; preload group members#141
yujiawei merged 5 commits intomainfrom
fix/sender-identity

Conversation

@yujiawei
Copy link
Copy Markdown
Collaborator

@yujiawei yujiawei commented Mar 31, 2026

Problem

DMWork adapter only sets SenderId in finalizeInboundContext. AI sees raw UIDs, not display names.

Changes (+163/-12, 4 files)

Commit 1: Core identity fields + preload

  • Add SenderName/SenderUsername/WasMentioned to finalizeInboundContext
  • Cache reply.from_name and MultipleForward.users
  • Preload group members on startup
  • Fix cleanupStaleCaches bug

Commit 2: Cross-space DM resolution

  • extractBaseUid() strips space prefix
  • resolveSenderName() 3-tier lookup
  • 12 new tests

296 tests pass. Fixes #142

DevBot added 2 commits March 31, 2026 08:19
…members

- Add SenderName (from uidToNameMap) and SenderUsername (from_uid)
  to finalizeInboundContext so AI knows who's talking
- Add WasMentioned flag for proper elevated/exec permission gating
  in group chats (OpenClaw framework uses this to control access)
- Hoist isMentioned computation outside requireMention block
- Opportunistic uid→name cache fill from reply.from_name and
  MultipleForward.users payloads (previously wasted data)
- Preload group members on bot startup (alongside GROUP.md prefetch)
  for immediate SenderName resolution including DM from group users
- Fix cleanupStaleCaches bug: remove incorrect uidToNameMap.delete(groupId)
  (uidToNameMap is a flat uid→name map, not keyed by groupId)

Fixes sender identity gap where AI only saw raw UIDs.
Group chat: immediate resolution via uidToNameMap.
DM: resolved if user appeared in any group with the bot.
- Add extractBaseUid() to strip space prefix (s14_abc → abc)
- Add resolveSenderName() with 3-tier lookup:
  1. Direct match: uidToNameMap.get(from_uid)
  2. Base uid fallback: strip space prefix → lookup
  3. Cross-space scan: find any s{X}_abc matching base uid
- Update buildSenderPrefix to use resolveSenderName (affects
  history context display too)
- 12 new tests for extractBaseUid, resolveSenderName, and
  cross-space buildSenderPrefix

DM coverage: user known in Space A group → resolved when DM
arrives from Space B (different prefix, same base uid).
DevBot added 3 commits March 31, 2026 09:04
…ervice_bot)

- Use regex /^s(\d+)_(.+)$/ to validate space prefix format s{digits}_{baseUid}
- Remove orphaned JSDoc comment
- Add test: service_bot, support_team should not be stripped
- Add fetchUserInfo() in api-fetch.ts — calls GET /v1/bot/user/info?uid=xxx
- Graceful degradation: returns null on 404 (endpoint not yet implemented),
  500 errors, or network failures — no crash, just falls back to raw uid
- Wire into inbound.ts: for DM messages where resolveSenderName() misses,
  call fetchUserInfo() and cache result in uidToNameMap
- 5 new tests covering success, 404, 500, network error, and empty response

This closes the last SenderName blind spot: DM users who never appeared
in any group will be resolved once the backend implements the endpoint.
Until then, the adapter silently falls back to raw uid (existing behavior).
When fetchUserInfo returns null (user not found or endpoint unavailable),
cache empty string as sentinel so subsequent DMs from the same unknown UID
skip the API call. resolveSenderName already treats empty string as no-match.
Copy link
Copy Markdown
Collaborator

@Jerry-Xin Jerry-Xin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work adding SenderName, WasMentioned, and the member-preloading infrastructure. The overall approach is solid — opportunistic cache filling from multiple sources (MultipleForward payloads, reply metadata, prefetch) is a smart pattern that makes the name resolution robust without adding extra API calls in the hot path.

A few items to address below, one is a definite bug (malformed JSDoc), and the rest are suggestions / observations.

/**
* Build a sender label in the format "displayName(uid)" for history context.
* Falls back to just uid if no name is found.
/**
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: There's a duplicate /** here. Line 321 has a stray /** left over from the old buildSenderPrefix JSDoc block that was removed. The result is malformed JSDoc:

/**
/**
 * Extract the base uid ...

The first /** on line 321 should be removed.

if (baseHit) return baseHit;

// Scan for any space-prefixed variant with the same base uid
for (const [uid, name] of uidToNameMap) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit / future concern: This for...of scan is O(n) over the entire uidToNameMap. Right now the map is probably small enough that it doesn't matter, but if the bot joins many large groups and the prefetch fills thousands of entries, this could become noticeable — it runs on every inbound message where the direct lookup misses.

Worth either:

  • Adding a brief comment documenting the O(n) trade-off so future readers know it's intentional, or
  • Building a small reverse index (baseUid → name) alongside uidToNameMap when entries are inserted, so this lookup stays O(1).

No rush — just something to keep in mind if the map grows.

uidToNameMap.set(message.from_uid, userInfo.name);
} else {
// Negative cache — prevent repeated API calls for unknown UIDs
uidToNameMap.set(message.from_uid, "");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case worth noting: the negative cache uses "" (empty string) as a sentinel, and resolveSenderName treats any falsy value from uidToNameMap.get() as "not found" (line 345: if (direct) return direct). This works correctly end-to-end because the caller checks cached === undefined (line 1327) before the fetchUserInfo path.

However, there's a subtle interaction: if resolveSenderName is called for a uid with a "" sentinel, the direct lookup falls through (falsy), and the cross-space scan could match the same entry and return "". The caller sees falsy senderName, enters the DM block, and cached === undefined correctly prevents re-fetching — so it's fine.

But it's a bit fragile. Consider using a dedicated sentinel (e.g. a separate Set<string> of failed UIDs) to make the negative-cache intent explicit and decouple it from resolveSenderName's truthiness checks. Not blocking, just a resilience suggestion.

} catch {
// Ignore per-group failures (group may not have GROUP.md)
}
// Prefetch group members → fill uidToNameMap for SenderName resolution
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — prefetching group members on startup combined with the opportunistic fills from MultipleForward payloads and reply metadata in inbound.ts means the name cache will be well-populated by the time messages arrive. Solid pattern.

if (isGroup && requireMention) {
// Debug: log received mention info
log?.debug?.(`dmwork: [RECV] mention payload: uidsCount=${mentionUids.length}, all=${mentionAll}, originalCount=${originalMentionUids.length}`);
log?.debug?.(`dmwork: [RECV] mention payload: isMentioned=${isMentioned}, originalCount=${originalMentionUids.length}`);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the old debug log included uidsCount and all which was useful for diagnosing mention-gating edge cases (e.g. "why was this message gated even though all=true?"). The new log with just isMentioned loses that granularity.

Since mentionUids and mentionAll are now scoped inside the if (isGroup) block above, they're not accessible here. Consider either hoisting them or re-referencing from the payload:

log?.debug?.(`dmwork: [RECV] mention payload: isMentioned=${isMentioned}, all=${message.payload?.mention?.all}, originalCount=${originalMentionUids.length}`);

@yujiawei yujiawei merged commit 4657e52 into main Mar 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: SenderName/WasMentioned missing in DMWork adapter inbound context

2 participants