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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
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";
@@ -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
- _uidToNameMaps.get(accountId)?.delete(groupId);
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 (groupMdCache.size > 0) {
613
- log?.info?.(`dmwork: prefetched GROUP.md for ${groupMdCache.size} groups`);
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: GROUP.md prefetch failed: ${String(err)}`);
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
- 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
  }