openclaw-channel-dmwork 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +1 -1
  2. package/src/api-fetch.test.ts +80 -0
  3. package/src/api-fetch.ts +37 -0
  4. package/src/channel.ts +46 -10
  5. package/src/inbound.ts +48 -6
  6. package/src/mention-utils.test.ts +72 -0
  7. package/src/mention-utils.ts +41 -3
  8. package/src/multi-bot-isolation.test.ts +208 -0
  9. package/node_modules/crypto-js/CONTRIBUTING.md +0 -28
  10. package/node_modules/crypto-js/LICENSE +0 -24
  11. package/node_modules/crypto-js/README.md +0 -275
  12. package/node_modules/crypto-js/aes.js +0 -234
  13. package/node_modules/crypto-js/blowfish.js +0 -471
  14. package/node_modules/crypto-js/bower.json +0 -39
  15. package/node_modules/crypto-js/cipher-core.js +0 -895
  16. package/node_modules/crypto-js/core.js +0 -807
  17. package/node_modules/crypto-js/crypto-js.js +0 -6657
  18. package/node_modules/crypto-js/docs/QuickStartGuide.wiki +0 -470
  19. package/node_modules/crypto-js/enc-base64.js +0 -136
  20. package/node_modules/crypto-js/enc-base64url.js +0 -148
  21. package/node_modules/crypto-js/enc-hex.js +0 -18
  22. package/node_modules/crypto-js/enc-latin1.js +0 -18
  23. package/node_modules/crypto-js/enc-utf16.js +0 -149
  24. package/node_modules/crypto-js/enc-utf8.js +0 -18
  25. package/node_modules/crypto-js/evpkdf.js +0 -134
  26. package/node_modules/crypto-js/format-hex.js +0 -66
  27. package/node_modules/crypto-js/format-openssl.js +0 -18
  28. package/node_modules/crypto-js/hmac-md5.js +0 -18
  29. package/node_modules/crypto-js/hmac-ripemd160.js +0 -18
  30. package/node_modules/crypto-js/hmac-sha1.js +0 -18
  31. package/node_modules/crypto-js/hmac-sha224.js +0 -18
  32. package/node_modules/crypto-js/hmac-sha256.js +0 -18
  33. package/node_modules/crypto-js/hmac-sha3.js +0 -18
  34. package/node_modules/crypto-js/hmac-sha384.js +0 -18
  35. package/node_modules/crypto-js/hmac-sha512.js +0 -18
  36. package/node_modules/crypto-js/hmac.js +0 -143
  37. package/node_modules/crypto-js/index.js +0 -18
  38. package/node_modules/crypto-js/lib-typedarrays.js +0 -76
  39. package/node_modules/crypto-js/md5.js +0 -268
  40. package/node_modules/crypto-js/mode-cfb.js +0 -80
  41. package/node_modules/crypto-js/mode-ctr-gladman.js +0 -116
  42. package/node_modules/crypto-js/mode-ctr.js +0 -58
  43. package/node_modules/crypto-js/mode-ecb.js +0 -40
  44. package/node_modules/crypto-js/mode-ofb.js +0 -54
  45. package/node_modules/crypto-js/package.json +0 -42
  46. package/node_modules/crypto-js/pad-ansix923.js +0 -49
  47. package/node_modules/crypto-js/pad-iso10126.js +0 -44
  48. package/node_modules/crypto-js/pad-iso97971.js +0 -40
  49. package/node_modules/crypto-js/pad-nopadding.js +0 -30
  50. package/node_modules/crypto-js/pad-pkcs7.js +0 -18
  51. package/node_modules/crypto-js/pad-zeropadding.js +0 -47
  52. package/node_modules/crypto-js/pbkdf2.js +0 -145
  53. package/node_modules/crypto-js/rabbit-legacy.js +0 -190
  54. package/node_modules/crypto-js/rabbit.js +0 -192
  55. package/node_modules/crypto-js/rc4.js +0 -139
  56. package/node_modules/crypto-js/ripemd160.js +0 -267
  57. package/node_modules/crypto-js/sha1.js +0 -150
  58. package/node_modules/crypto-js/sha224.js +0 -80
  59. package/node_modules/crypto-js/sha256.js +0 -199
  60. package/node_modules/crypto-js/sha3.js +0 -326
  61. package/node_modules/crypto-js/sha384.js +0 -83
  62. package/node_modules/crypto-js/sha512.js +0 -326
  63. package/node_modules/crypto-js/tripledes.js +0 -779
  64. package/node_modules/crypto-js/x64-core.js +0 -304
  65. package/node_modules/curve25519-js/.prettierrc +0 -6
  66. package/node_modules/curve25519-js/LICENSE +0 -19
  67. package/node_modules/curve25519-js/README.md +0 -111
  68. package/node_modules/curve25519-js/lib/index.d.ts +0 -69
  69. package/node_modules/curve25519-js/lib/index.js +0 -1669
  70. package/node_modules/curve25519-js/package.json +0 -34
  71. package/node_modules/curve25519-js/tsconfig.json +0 -20
  72. package/node_modules/curve25519-js/tslint.json +0 -10
  73. package/node_modules/md5-typescript/.idea/Md5-typescript.iml +0 -13
  74. package/node_modules/md5-typescript/.idea/jsLibraryMappings.xml +0 -6
  75. package/node_modules/md5-typescript/.idea/libraries/tsconfig_roots.xml +0 -13
  76. package/node_modules/md5-typescript/.idea/modules.xml +0 -8
  77. package/node_modules/md5-typescript/.idea/vcs.xml +0 -6
  78. package/node_modules/md5-typescript/.idea/workspace.xml +0 -345
  79. package/node_modules/md5-typescript/LICENSE +0 -21
  80. package/node_modules/md5-typescript/README.md +0 -13
  81. package/node_modules/md5-typescript/dist/bundles/bundle.umd.js +0 -208
  82. package/node_modules/md5-typescript/dist/bundles/bundle.umd.min.js +0 -1
  83. package/node_modules/md5-typescript/dist/index.d.ts +0 -43
  84. package/node_modules/md5-typescript/dist/index.js +0 -198
  85. package/node_modules/md5-typescript/dist/index.js.map +0 -1
  86. package/node_modules/md5-typescript/dist/index.metadata.json +0 -1
  87. package/node_modules/md5-typescript/package.json +0 -31
  88. package/node_modules/md5-typescript/rollup.config.js +0 -7
  89. package/node_modules/md5-typescript/tsconfig.json +0 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -794,3 +794,83 @@ describe("uploadFileToCOS putParams ContentType", () => {
794
794
  expect(capturedParams.ContentType).toBe("text/plain; charset=utf-8");
795
795
  });
796
796
  });
797
+
798
+ // --- fetchUserInfo ---
799
+ import { fetchUserInfo } from "./api-fetch.js";
800
+
801
+ describe("fetchUserInfo", () => {
802
+ it("returns user info on success", async () => {
803
+ globalThis.fetch = vi.fn().mockResolvedValue({
804
+ ok: true,
805
+ status: 200,
806
+ json: () => Promise.resolve({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" }),
807
+ }) as any;
808
+
809
+ const result = await fetchUserInfo({
810
+ apiUrl: "http://localhost:8090",
811
+ botToken: "tok",
812
+ uid: "s14_abc",
813
+ });
814
+ expect(result).toEqual({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" });
815
+ expect(globalThis.fetch).toHaveBeenCalledWith(
816
+ "http://localhost:8090/v1/bot/user/info?uid=s14_abc",
817
+ expect.objectContaining({ method: "GET" }),
818
+ );
819
+ });
820
+
821
+ it("returns null on 404 (endpoint not implemented)", async () => {
822
+ globalThis.fetch = vi.fn().mockResolvedValue({
823
+ ok: false,
824
+ status: 404,
825
+ }) as any;
826
+
827
+ const result = await fetchUserInfo({
828
+ apiUrl: "http://localhost:8090",
829
+ botToken: "tok",
830
+ uid: "s14_abc",
831
+ });
832
+ expect(result).toBeNull();
833
+ });
834
+
835
+ it("returns null on 500 error", async () => {
836
+ globalThis.fetch = vi.fn().mockResolvedValue({
837
+ ok: false,
838
+ status: 500,
839
+ }) as any;
840
+
841
+ const result = await fetchUserInfo({
842
+ apiUrl: "http://localhost:8090",
843
+ botToken: "tok",
844
+ uid: "s14_abc",
845
+ log: { error: vi.fn() },
846
+ });
847
+ expect(result).toBeNull();
848
+ });
849
+
850
+ it("returns null on network error", async () => {
851
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as any;
852
+
853
+ const result = await fetchUserInfo({
854
+ apiUrl: "http://localhost:8090",
855
+ botToken: "tok",
856
+ uid: "s14_abc",
857
+ log: { error: vi.fn() },
858
+ });
859
+ expect(result).toBeNull();
860
+ });
861
+
862
+ it("returns null when response has no name", async () => {
863
+ globalThis.fetch = vi.fn().mockResolvedValue({
864
+ ok: true,
865
+ status: 200,
866
+ json: () => Promise.resolve({ uid: "s14_abc" }),
867
+ }) as any;
868
+
869
+ const result = await fetchUserInfo({
870
+ apiUrl: "http://localhost:8090",
871
+ botToken: "tok",
872
+ uid: "s14_abc",
873
+ });
874
+ expect(result).toBeNull();
875
+ });
876
+ });
package/src/api-fetch.ts CHANGED
@@ -649,3 +649,40 @@ export async function editMessage(params: {
649
649
  content_edit: params.contentEdit,
650
650
  }, params.signal);
651
651
  }
652
+
653
+ /**
654
+ * Fetch user info by UID. Requires backend `/v1/bot/user/info` endpoint.
655
+ * Returns null if the endpoint is unavailable (404) or returns an error,
656
+ * so callers can gracefully degrade.
657
+ */
658
+ export async function fetchUserInfo(params: {
659
+ apiUrl: string;
660
+ botToken: string;
661
+ uid: string;
662
+ log?: { info?: (msg: string) => void; error?: (msg: string) => void };
663
+ }): Promise<{ uid: string; name: string; avatar?: string } | null> {
664
+ const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/user/info?uid=${encodeURIComponent(params.uid)}`;
665
+ try {
666
+ const resp = await fetch(url, {
667
+ method: "GET",
668
+ headers: { Authorization: `Bearer ${params.botToken}` },
669
+ signal: AbortSignal.timeout(5000),
670
+ });
671
+ if (resp.status === 404) {
672
+ // Endpoint not implemented yet — silent degrade
673
+ return null;
674
+ }
675
+ if (!resp.ok) {
676
+ params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) failed: ${resp.status}`);
677
+ return null;
678
+ }
679
+ const data = await resp.json() as { uid?: string; name?: string; avatar?: string };
680
+ if (data?.name) {
681
+ return { uid: data.uid ?? params.uid, name: data.name, avatar: data.avatar };
682
+ }
683
+ return null;
684
+ } catch (err) {
685
+ params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) error: ${String(err)}`);
686
+ return null;
687
+ }
688
+ }
package/src/channel.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  resolveDmworkAccount,
12
12
  type ResolvedDmworkAccount,
13
13
  } from "./accounts.js";
14
- import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, ensureTextCharset, fetchBotGroups, getGroupMd, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
14
+ import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, ensureTextCharset, fetchBotGroups, getGroupMd, getGroupMembers, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
15
15
  import { WKSocket } from "./socket.js";
16
16
  import { handleInboundMessage, type DmworkStatusSink } from "./inbound.js";
17
17
  import { ChannelType, MessageType, type BotMessage, type MessagePayload } from "./types.js";
@@ -125,16 +125,26 @@ function getOrCreateGroupCacheTimestamps(accountId: string): Map<string, number>
125
125
  }
126
126
 
127
127
 
128
- // --- Group → Account mapping: tracks which account each group was received from ---
128
+ // --- Group → Account mapping: tracks which accounts are active in each group ---
129
129
  // Used by handleAction to resolve the correct account when framework passes wrong accountId
130
- const _groupToAccount = new Map<string, string>(); // groupNo accountId
130
+ // A group may have multiple bots (1:N), so we store a Set of accountIds per group.
131
+ const _groupToAccount = new Map<string, Set<string>>(); // groupNo → Set<accountId>
131
132
 
132
133
  export function registerGroupToAccount(groupNo: string, accountId: string): void {
133
- _groupToAccount.set(groupNo, accountId);
134
+ let accounts = _groupToAccount.get(groupNo);
135
+ if (!accounts) {
136
+ accounts = new Set<string>();
137
+ _groupToAccount.set(groupNo, accounts);
138
+ }
139
+ accounts.add(accountId);
134
140
  }
135
141
 
136
142
  export function resolveAccountForGroup(groupNo: string): string | undefined {
137
- return _groupToAccount.get(groupNo);
143
+ const accounts = _groupToAccount.get(groupNo);
144
+ if (!accounts || accounts.size === 0) return undefined;
145
+ // Only resolve when exactly one bot owns the group; multi-bot → ambiguous
146
+ if (accounts.size === 1) return accounts.values().next().value;
147
+ return undefined;
138
148
  }
139
149
 
140
150
  // --- Cache cleanup: evict groups inactive for >4 hours ---
@@ -155,7 +165,8 @@ function cleanupStaleCaches(): void {
155
165
  if (lastAccess < cutoff) {
156
166
  _historyMaps.get(accountId)?.delete(groupId);
157
167
  _memberMaps.get(accountId)?.delete(groupId);
158
- _uidToNameMaps.get(accountId)?.delete(groupId);
168
+ // Note: uidToNameMap is a flat uid→name map (not keyed by groupId),
169
+ // so we don't delete from it here — names remain valid across groups.
159
170
  _groupCacheTimestamps.get(accountId)?.delete(groupId);
160
171
  activityMap.delete(groupId);
161
172
  }
@@ -252,11 +263,14 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
252
263
  handleAction: async (ctx: any) => {
253
264
  // Resolve correct accountId: framework may pass wrong one when agent has multiple accounts.
254
265
  // Use currentChannelId to look up which account actually owns the group.
266
+ // When multiple bots share the same group, do NOT correct — the caller's accountId is authoritative.
255
267
  let accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
256
268
  const currentChannelId = ctx.toolContext?.currentChannelId;
257
269
  if (currentChannelId) {
258
270
  const rawGroupNo = currentChannelId.replace(/^dmwork:/, '');
259
271
  const correctAccountId = resolveAccountForGroup(rawGroupNo);
272
+ // Only correct when resolveAccountForGroup returns a definitive answer
273
+ // (exactly one bot owns the group); multi-bot → undefined → no correction
260
274
  if (correctAccountId && correctAccountId !== accountId) {
261
275
  ctx.log?.info?.(`dmwork: handleAction accountId corrected: ${accountId} → ${correctAccountId} (group=${rawGroupNo})`);
262
276
  accountId = correctAccountId;
@@ -593,27 +607,49 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
593
607
  // Check for updates in background (fire-and-forget)
594
608
  checkForUpdates(account.config.apiUrl, log).catch(() => {});
595
609
 
596
- // Prefetch GROUP.md for all groups (fire-and-forget)
610
+ // Prefetch GROUP.md and group members for all groups (fire-and-forget)
597
611
  const groupMdCache = getOrCreateGroupMdCache(account.accountId);
598
612
  (async () => {
599
613
  try {
600
614
  const groups = await fetchBotGroups({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, log });
601
615
  registerBotGroupIds(groups.map(g => g.group_no));
616
+ let mdCount = 0;
617
+ let memberCount = 0;
602
618
  for (const g of groups) {
619
+ // Prefetch GROUP.md
603
620
  try {
604
621
  const md = await getGroupMd({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
605
622
  if (md.content) {
606
623
  groupMdCache.set(g.group_no, { content: md.content, version: md.version });
624
+ mdCount++;
607
625
  }
608
626
  } catch {
609
627
  // Ignore per-group failures (group may not have GROUP.md)
610
628
  }
629
+ // Prefetch group members → fill uidToNameMap for SenderName resolution
630
+ try {
631
+ const members = await getGroupMembers({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
632
+ const prefetchMemberMap = getOrCreateMemberMap(account.accountId);
633
+ const prefetchUidMap = getOrCreateUidToNameMap(account.accountId);
634
+ for (const m of members) {
635
+ if (m.uid && m.name) {
636
+ prefetchMemberMap.set(m.name, m.uid);
637
+ prefetchUidMap.set(m.uid, m.name);
638
+ memberCount++;
639
+ }
640
+ }
641
+ } catch {
642
+ // Ignore per-group failures
643
+ }
644
+ }
645
+ if (mdCount > 0) {
646
+ log?.info?.(`dmwork: prefetched GROUP.md for ${mdCount} groups`);
611
647
  }
612
- if (groupMdCache.size > 0) {
613
- log?.info?.(`dmwork: prefetched GROUP.md for ${groupMdCache.size} groups`);
648
+ if (memberCount > 0) {
649
+ log?.info?.(`dmwork: prefetched ${memberCount} member names from ${groups.length} groups`);
614
650
  }
615
651
  } catch (err) {
616
- log?.error?.(`dmwork: GROUP.md prefetch failed: ${String(err)}`);
652
+ log?.error?.(`dmwork: group prefetch failed: ${String(err)}`);
617
653
  }
618
654
  })();
619
655
 
package/src/inbound.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChannelLogSink, OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
2
+ import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS, fetchUserInfo } from "./api-fetch.js";
3
3
  import type { ResolvedDmworkAccount } from "./accounts.js";
4
4
  import type { BotMessage } from "./types.js";
5
5
  import { ChannelType, MessageType } from "./types.js";
@@ -10,6 +10,7 @@ import {
10
10
  extractMentionUids,
11
11
  convertContentForLLM,
12
12
  buildSenderPrefix,
13
+ resolveSenderName,
13
14
  parseStructuredMentions,
14
15
  convertStructuredMentions,
15
16
  buildEntitiesFromFallback,
@@ -1035,6 +1036,14 @@ export async function handleInboundMessage(params: {
1035
1036
  const resolved = resolveContent(message.payload, account.config.apiUrl, log, account.config.cdnUrl);
1036
1037
  let rawBody = resolved.text;
1037
1038
  let inboundMediaUrl = resolved.mediaUrl;
1039
+
1040
+ // Opportunistic uid→name cache fill from MultipleForward payloads
1041
+ if (message.payload?.type === MessageType.MultipleForward && Array.isArray(message.payload.users)) {
1042
+ for (const u of message.payload.users as Array<{ uid?: string; name?: string }>) {
1043
+ if (u.uid && u.name) uidToNameMap.set(u.uid, u.name);
1044
+ }
1045
+ }
1046
+
1038
1047
  // For Image/GIF/Voice/Video: download media to local temp file so Core reads
1039
1048
  // local files instead of remote URLs (avoids hang on large/slow downloads in Core)
1040
1049
  const mediaDownloadTypes = [MessageType.Image, MessageType.GIF, MessageType.Voice, MessageType.Video];
@@ -1091,6 +1100,10 @@ export async function handleInboundMessage(params: {
1091
1100
  quotePrefix = `[Quoted message from ${replyFrom}]: ${replyContent}\n---\n`;
1092
1101
  log?.info?.(`dmwork: message quotes a reply (${quotePrefix.length} chars)`);
1093
1102
  }
1103
+ // Cache reply sender name for uid→name resolution (opportunistic fill)
1104
+ if (replyData.from_uid && replyData.from_name) {
1105
+ uidToNameMap.set(replyData.from_uid, replyData.from_name);
1106
+ }
1094
1107
  }
1095
1108
 
1096
1109
  // --- Mention gating for group messages ---
@@ -1105,15 +1118,18 @@ export async function handleInboundMessage(params: {
1105
1118
  await refreshGroupMemberCache({ sessionId, memberMap, uidToNameMap, groupCacheTimestamps, apiUrl: account.config.apiUrl, botToken: account.config.botToken ?? "", log });
1106
1119
  }
1107
1120
 
1108
- if (isGroup && requireMention) {
1121
+ // Compute isMentioned at top level so it's available for WasMentioned in finalizeInboundContext
1122
+ let isMentioned = false;
1123
+ if (isGroup) {
1109
1124
  const mentionUids = extractMentionUids(message.payload?.mention);
1110
- // mention.all can be boolean `true` or numeric `1` depending on API version
1111
1125
  const mentionAllRaw = message.payload?.mention?.all;
1112
1126
  const mentionAll: boolean = mentionAllRaw === true || mentionAllRaw === 1;
1113
- const isMentioned = mentionAll || mentionUids.includes(botUid);
1114
-
1127
+ isMentioned = mentionAll || mentionUids.includes(botUid);
1128
+ }
1129
+
1130
+ if (isGroup && requireMention) {
1115
1131
  // Debug: log received mention info
1116
- log?.debug?.(`dmwork: [RECV] mention payload: uidsCount=${mentionUids.length}, all=${mentionAll}, originalCount=${originalMentionUids.length}`);
1132
+ log?.debug?.(`dmwork: [RECV] mention payload: isMentioned=${isMentioned}, originalCount=${originalMentionUids.length}`);
1117
1133
 
1118
1134
  if (!isMentioned) {
1119
1135
  // Record as pending history context (manual — avoids SDK format incompatibility)
@@ -1302,6 +1318,29 @@ export async function handleInboundMessage(params: {
1302
1318
  // Inject GROUP.md as GroupSystemPrompt for group messages
1303
1319
  const groupSystemPrompt = isGroup && groupMdCache ? groupMdCache.get(message.channel_id!)?.content : undefined;
1304
1320
 
1321
+ // Resolve sender display name — async fallback for DM users not in cache
1322
+ let senderName = resolveSenderName(message.from_uid, uidToNameMap);
1323
+ if (!senderName && !isGroup) {
1324
+ // DM user not in any group cache — try backend user info API
1325
+ // Skip if we already tried and failed (negative cache sentinel "")
1326
+ const cached = uidToNameMap.get(message.from_uid);
1327
+ if (cached === undefined) {
1328
+ const userInfo = await fetchUserInfo({
1329
+ apiUrl: account.config.apiUrl,
1330
+ botToken: account.config.botToken ?? "",
1331
+ uid: message.from_uid,
1332
+ log,
1333
+ });
1334
+ if (userInfo?.name) {
1335
+ senderName = userInfo.name;
1336
+ uidToNameMap.set(message.from_uid, userInfo.name);
1337
+ } else {
1338
+ // Negative cache — prevent repeated API calls for unknown UIDs
1339
+ uidToNameMap.set(message.from_uid, "");
1340
+ }
1341
+ }
1342
+ }
1343
+
1305
1344
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1306
1345
  Body: body,
1307
1346
  BodyForAgent: body, // ← 关键!AI 实际读取的是这个字段!
@@ -1321,6 +1360,9 @@ export async function handleInboundMessage(params: {
1321
1360
  ChatType: isGroup ? "group" : "direct",
1322
1361
  ConversationLabel: fromLabel,
1323
1362
  SenderId: message.from_uid,
1363
+ SenderName: senderName,
1364
+ SenderUsername: message.from_uid,
1365
+ WasMentioned: isGroup ? isMentioned : undefined,
1324
1366
  MessageSid: String(message.message_id),
1325
1367
  Timestamp: message.timestamp ? message.timestamp * 1000 : undefined,
1326
1368
  GroupSubject: isGroup ? message.channel_id : undefined,
@@ -486,3 +486,75 @@ describe("边界情况", () => {
486
486
  expect(allEntities[1]).toEqual({ uid: "uid_bob", offset: 13, length: 4 });
487
487
  });
488
488
  });
489
+
490
+ // --- extractBaseUid & resolveSenderName ---
491
+ import { extractBaseUid, resolveSenderName } from "./mention-utils.js";
492
+
493
+ describe("extractBaseUid", () => {
494
+ it("strips space prefix", () => {
495
+ expect(extractBaseUid("s14_abc123")).toBe("abc123");
496
+ });
497
+
498
+ it("handles multi-digit space id", () => {
499
+ expect(extractBaseUid("s1234_user456")).toBe("user456");
500
+ });
501
+
502
+ it("returns uid unchanged when no space prefix", () => {
503
+ expect(extractBaseUid("abc123")).toBe("abc123");
504
+ });
505
+
506
+ it("returns uid unchanged for 's' without underscore", () => {
507
+ expect(extractBaseUid("system")).toBe("system");
508
+ });
509
+
510
+ it("does not strip non-numeric space prefix (e.g. service_bot)", () => {
511
+ expect(extractBaseUid("service_bot")).toBe("service_bot");
512
+ expect(extractBaseUid("support_team")).toBe("support_team");
513
+ });
514
+ });
515
+
516
+ describe("resolveSenderName", () => {
517
+ it("returns direct match", () => {
518
+ const map = new Map([["s14_abc", "Alice"]]);
519
+ expect(resolveSenderName("s14_abc", map)).toBe("Alice");
520
+ });
521
+
522
+ it("returns undefined when no match", () => {
523
+ const map = new Map([["s14_abc", "Alice"]]);
524
+ expect(resolveSenderName("s14_xyz", map)).toBeUndefined();
525
+ });
526
+
527
+ it("falls back to base uid (non-space entry)", () => {
528
+ const map = new Map([["abc", "Alice"]]);
529
+ expect(resolveSenderName("s14_abc", map)).toBe("Alice");
530
+ });
531
+
532
+ it("falls back to cross-space variant", () => {
533
+ // User known as s10_abc in one space, DM from s14_abc
534
+ const map = new Map([["s10_abc", "Alice"]]);
535
+ expect(resolveSenderName("s14_abc", map)).toBe("Alice");
536
+ });
537
+
538
+ it("does not cross-space fallback for non-prefixed uid", () => {
539
+ // uid "abc" without space prefix should not scan
540
+ const map = new Map([["s10_abc", "Alice"]]);
541
+ expect(resolveSenderName("abc", map)).toBeUndefined();
542
+ });
543
+
544
+ it("prefers direct match over cross-space", () => {
545
+ const map = new Map([["s14_abc", "Alice-14"], ["s10_abc", "Alice-10"]]);
546
+ expect(resolveSenderName("s14_abc", map)).toBe("Alice-14");
547
+ });
548
+ });
549
+
550
+ describe("buildSenderPrefix with cross-space", () => {
551
+ it("shows name(uid) for cross-space hit", () => {
552
+ const map = new Map([["s10_abc", "Alice"]]);
553
+ expect(buildSenderPrefix("s14_abc", map)).toBe("Alice(s14_abc)");
554
+ });
555
+
556
+ it("shows raw uid when no match", () => {
557
+ const map = new Map([["s10_xyz", "Bob"]]);
558
+ expect(buildSenderPrefix("s14_abc", map)).toBe("s14_abc");
559
+ });
560
+ });
@@ -319,13 +319,51 @@ export function convertContentForLLM(
319
319
  // ── Sender prefix utility ────────────────────────────────────────────────────
320
320
 
321
321
  /**
322
- * Build a sender label in the format "displayName(uid)" for history context.
323
- * Falls back to just uid if no name is found.
322
+ /**
323
+ * Extract the base uid from a space-prefixed uid.
324
+ * "s14_abc123" → "abc123", "abc123" → "abc123"
325
+ */
326
+ export function extractBaseUid(uid: string): string {
327
+ // Space-prefixed format: s{digits}_{baseUid}
328
+ const match = uid.match(/^s(\d+)_(.+)$/);
329
+ if (match) return match[2];
330
+ return uid;
331
+ }
332
+
333
+ /**
334
+ * Resolve sender display name from uidToNameMap with cross-space fallback.
335
+ * 1. Direct lookup: uidToNameMap.get(from_uid)
336
+ * 2. Base uid fallback: strip space prefix and scan map for matching base uid
337
+ * (covers DM users who appear in groups under a different space prefix)
324
338
  */
339
+ export function resolveSenderName(
340
+ fromUid: string,
341
+ uidToNameMap: Map<string, string>,
342
+ ): string | undefined {
343
+ // Direct hit (same space or no space prefix)
344
+ const direct = uidToNameMap.get(fromUid);
345
+ if (direct) return direct;
346
+
347
+ // Cross-space fallback: extract base uid and scan
348
+ const baseUid = extractBaseUid(fromUid);
349
+ if (baseUid !== fromUid) {
350
+ // Check if the base uid itself is in the map (non-space account)
351
+ const baseHit = uidToNameMap.get(baseUid);
352
+ if (baseHit) return baseHit;
353
+
354
+ // Scan for any space-prefixed variant with the same base uid
355
+ for (const [uid, name] of uidToNameMap) {
356
+ if (extractBaseUid(uid) === baseUid) return name;
357
+ }
358
+ }
359
+
360
+ return undefined;
361
+ }
362
+
325
363
  export function buildSenderPrefix(
326
364
  fromUid: string,
327
365
  uidToNameMap: Map<string, string>,
328
366
  ): string {
329
- const name = uidToNameMap.get(fromUid);
367
+ const name = resolveSenderName(fromUid, uidToNameMap);
330
368
  return name ? `${name}(${fromUid})` : fromUid;
331
369
  }