openclaw-channel-dmwork 0.5.12 → 0.5.13
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.
- package/package.json +1 -1
- package/src/api-fetch.test.ts +80 -0
- package/src/api-fetch.ts +37 -0
- package/src/channel.ts +29 -6
- package/src/inbound.ts +48 -6
- package/src/mention-utils.test.ts +72 -0
- package/src/mention-utils.ts +41 -3
package/package.json
CHANGED
package/src/api-fetch.test.ts
CHANGED
|
@@ -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";
|
|
@@ -155,7 +155,8 @@ function cleanupStaleCaches(): void {
|
|
|
155
155
|
if (lastAccess < cutoff) {
|
|
156
156
|
_historyMaps.get(accountId)?.delete(groupId);
|
|
157
157
|
_memberMaps.get(accountId)?.delete(groupId);
|
|
158
|
-
|
|
158
|
+
// Note: uidToNameMap is a flat uid→name map (not keyed by groupId),
|
|
159
|
+
// so we don't delete from it here — names remain valid across groups.
|
|
159
160
|
_groupCacheTimestamps.get(accountId)?.delete(groupId);
|
|
160
161
|
activityMap.delete(groupId);
|
|
161
162
|
}
|
|
@@ -593,27 +594,49 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
593
594
|
// Check for updates in background (fire-and-forget)
|
|
594
595
|
checkForUpdates(account.config.apiUrl, log).catch(() => {});
|
|
595
596
|
|
|
596
|
-
// Prefetch GROUP.md for all groups (fire-and-forget)
|
|
597
|
+
// Prefetch GROUP.md and group members for all groups (fire-and-forget)
|
|
597
598
|
const groupMdCache = getOrCreateGroupMdCache(account.accountId);
|
|
598
599
|
(async () => {
|
|
599
600
|
try {
|
|
600
601
|
const groups = await fetchBotGroups({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, log });
|
|
601
602
|
registerBotGroupIds(groups.map(g => g.group_no));
|
|
603
|
+
let mdCount = 0;
|
|
604
|
+
let memberCount = 0;
|
|
602
605
|
for (const g of groups) {
|
|
606
|
+
// Prefetch GROUP.md
|
|
603
607
|
try {
|
|
604
608
|
const md = await getGroupMd({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
|
|
605
609
|
if (md.content) {
|
|
606
610
|
groupMdCache.set(g.group_no, { content: md.content, version: md.version });
|
|
611
|
+
mdCount++;
|
|
607
612
|
}
|
|
608
613
|
} catch {
|
|
609
614
|
// Ignore per-group failures (group may not have GROUP.md)
|
|
610
615
|
}
|
|
616
|
+
// Prefetch group members → fill uidToNameMap for SenderName resolution
|
|
617
|
+
try {
|
|
618
|
+
const members = await getGroupMembers({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
|
|
619
|
+
const prefetchMemberMap = getOrCreateMemberMap(account.accountId);
|
|
620
|
+
const prefetchUidMap = getOrCreateUidToNameMap(account.accountId);
|
|
621
|
+
for (const m of members) {
|
|
622
|
+
if (m.uid && m.name) {
|
|
623
|
+
prefetchMemberMap.set(m.name, m.uid);
|
|
624
|
+
prefetchUidMap.set(m.uid, m.name);
|
|
625
|
+
memberCount++;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
} catch {
|
|
629
|
+
// Ignore per-group failures
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (mdCount > 0) {
|
|
633
|
+
log?.info?.(`dmwork: prefetched GROUP.md for ${mdCount} groups`);
|
|
611
634
|
}
|
|
612
|
-
if (
|
|
613
|
-
log?.info?.(`dmwork: prefetched
|
|
635
|
+
if (memberCount > 0) {
|
|
636
|
+
log?.info?.(`dmwork: prefetched ${memberCount} member names from ${groups.length} groups`);
|
|
614
637
|
}
|
|
615
638
|
} catch (err) {
|
|
616
|
-
log?.error?.(`dmwork:
|
|
639
|
+
log?.error?.(`dmwork: group prefetch failed: ${String(err)}`);
|
|
617
640
|
}
|
|
618
641
|
})();
|
|
619
642
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
});
|
package/src/mention-utils.ts
CHANGED
|
@@ -319,13 +319,51 @@ export function convertContentForLLM(
|
|
|
319
319
|
// ── Sender prefix utility ────────────────────────────────────────────────────
|
|
320
320
|
|
|
321
321
|
/**
|
|
322
|
-
|
|
323
|
-
*
|
|
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 =
|
|
367
|
+
const name = resolveSenderName(fromUid, uidToNameMap);
|
|
330
368
|
return name ? `${name}(${fromUid})` : fromUid;
|
|
331
369
|
}
|