openclaw-channel-dmwork 0.2.17 → 0.2.18

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.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/src/api-fetch.ts CHANGED
@@ -147,6 +147,47 @@ export async function fetchBotGroups(params: {
147
147
  return resp.json();
148
148
  }
149
149
 
150
+ /**
151
+ * 获取群成员列表
152
+ */
153
+ export interface GroupMember {
154
+ uid: string;
155
+ name: string;
156
+ role?: string; // admin/member
157
+ robot?: boolean; // 是否是机器人
158
+ }
159
+
160
+ export async function getGroupMembers(params: {
161
+ apiUrl: string;
162
+ botToken: string;
163
+ groupNo: string; // 群 ID (channel_id)
164
+ }): Promise<GroupMember[]> {
165
+ const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/groups/${params.groupNo}/members`;
166
+ try {
167
+ const resp = await fetch(url, {
168
+ method: "GET",
169
+ headers: {
170
+ "Authorization": `Bearer ${params.botToken}`,
171
+ },
172
+ });
173
+ if (!resp.ok) {
174
+ console.log(`[dmwork] getGroupMembers failed: ${resp.status}`);
175
+ return [];
176
+ }
177
+ const data = await resp.json();
178
+ // Normalize to strict array to prevent silent failures
179
+ const members = Array.isArray(data?.members)
180
+ ? data.members
181
+ : Array.isArray(data)
182
+ ? data
183
+ : [];
184
+ return members as GroupMember[];
185
+ } catch (err) {
186
+ console.log(`[dmwork] getGroupMembers error: ${err}`);
187
+ return [];
188
+ }
189
+ }
190
+
150
191
  /**
151
192
  * 获取频道历史消息(用于注入上下文)
152
193
  * @param params.log - Optional logger for consistent logging with OpenClaw log system
package/src/channel.ts CHANGED
@@ -30,6 +30,41 @@ function getOrCreateHistoryMap(accountId: string): Map<string, any[]> {
30
30
  return m;
31
31
  }
32
32
 
33
+ // Module-level member mapping: displayName -> uid
34
+ // Used to resolve @mentions in AI replies
35
+ const _memberMaps = new Map<string, Map<string, string>>();
36
+ function getOrCreateMemberMap(accountId: string): Map<string, string> {
37
+ let m = _memberMaps.get(accountId);
38
+ if (!m) {
39
+ m = new Map<string, string>();
40
+ _memberMaps.set(accountId, m);
41
+ }
42
+ return m;
43
+ }
44
+
45
+ // Module-level reverse mapping: uid -> displayName
46
+ // Used to show display names instead of uids in replies
47
+ const _uidToNameMaps = new Map<string, Map<string, string>>();
48
+ function getOrCreateUidToNameMap(accountId: string): Map<string, string> {
49
+ let m = _uidToNameMaps.get(accountId);
50
+ if (!m) {
51
+ m = new Map<string, string>();
52
+ _uidToNameMaps.set(accountId, m);
53
+ }
54
+ return m;
55
+ }
56
+
57
+ // Group member cache timestamps: groupId -> lastFetchedAt (ms)
58
+ const _groupCacheTimestamps = new Map<string, Map<string, number>>();
59
+ function getOrCreateGroupCacheTimestamps(accountId: string): Map<string, number> {
60
+ let m = _groupCacheTimestamps.get(accountId);
61
+ if (!m) {
62
+ m = new Map<string, number>();
63
+ _groupCacheTimestamps.set(accountId, m);
64
+ }
65
+ return m;
66
+ }
67
+
33
68
  const meta = {
34
69
  id: "dmwork",
35
70
  label: "DMWork",
@@ -104,6 +139,22 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
104
139
  channelId = groupPart;
105
140
  }
106
141
  channelType = ChannelType.Group;
142
+
143
+ // Parse @mentions from message content (e.g., "@chenpipi_bot" -> "chenpipi_bot")
144
+ // Match @username where username is alphanumeric with underscores (typical uid format)
145
+ const contentMentions = content.match(/@([a-zA-Z0-9_]+)/g);
146
+ if (contentMentions) {
147
+ for (const mention of contentMentions) {
148
+ const uid = mention.slice(1); // Remove @ prefix
149
+ if (uid && !mentionUids.includes(uid)) {
150
+ mentionUids.push(uid);
151
+ console.log(`[dmwork] parsed @mention from content: ${uid}`);
152
+ }
153
+ }
154
+ }
155
+ if (mentionUids.length > 0) {
156
+ console.log(`[dmwork] sending message with mentionUids: ${mentionUids.join(", ")}`);
157
+ }
107
158
  }
108
159
 
109
160
  await sendMessage({
@@ -207,6 +258,15 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
207
258
 
208
259
  // 4. Group history map — persists across auto-restarts (module-level)
209
260
  const groupHistories = getOrCreateHistoryMap(account.accountId);
261
+
262
+ // 4b. Member name->uid map — for resolving @mentions in replies
263
+ const memberMap = getOrCreateMemberMap(account.accountId);
264
+
265
+ // 4c. Reverse map uid->name — for showing display names in replies
266
+ const uidToNameMap = getOrCreateUidToNameMap(account.accountId);
267
+
268
+ // 4d. Group cache timestamps — track when each group's members were last fetched
269
+ const groupCacheTimestamps = getOrCreateGroupCacheTimestamps(account.accountId);
210
270
 
211
271
  // 5. Token refresh state — detect stale cached token
212
272
  let hasRefreshedToken = false;
@@ -232,6 +292,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
232
292
  message: msg,
233
293
  botUid: credentials.robot_id,
234
294
  groupHistories,
295
+ memberMap,
296
+ uidToNameMap,
297
+ groupCacheTimestamps,
235
298
  log,
236
299
  statusSink,
237
300
  }).catch((err) => {
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 } from "./api-fetch.js";
2
+ import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers } 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";
@@ -51,15 +51,21 @@ function resolveContent(payload: BotMessage["payload"]): string {
51
51
  return "";
52
52
  }
53
53
 
54
+ // Cache expiry time: 1 hour
55
+ const GROUP_CACHE_EXPIRY_MS = 60 * 60 * 1000;
56
+
54
57
  export async function handleInboundMessage(params: {
55
58
  account: ResolvedDmworkAccount;
56
59
  message: BotMessage;
57
60
  botUid: string;
58
61
  groupHistories: Map<string, any[]>;
62
+ memberMap: Map<string, string>; // displayName -> uid mapping
63
+ uidToNameMap: Map<string, string>; // uid -> displayName mapping (reverse)
64
+ groupCacheTimestamps: Map<string, number>; // groupId -> lastFetchedAt
59
65
  log?: ChannelLogSink;
60
66
  statusSink?: DmworkStatusSink;
61
67
  }) {
62
- const { account, message, botUid, groupHistories, log, statusSink } = params;
68
+ const { account, message, botUid, groupHistories, memberMap, uidToNameMap, groupCacheTimestamps, log, statusSink } = params;
63
69
 
64
70
  await ensureSdkLoaded();
65
71
 
@@ -96,11 +102,97 @@ export async function handleInboundMessage(params: {
96
102
  // --- Mention gating for group messages ---
97
103
  const requireMention = account.config.requireMention !== false;
98
104
  let historyPrefix = "";
105
+
106
+ // Save original mention uids for reply (exclude bot itself)
107
+ const originalMentionUids: string[] = (message.payload?.mention?.uids ?? []).filter((uid: string) => uid !== botUid);
108
+
109
+ // Helper function to refresh group member cache
110
+ async function refreshGroupMemberCache(forceRefresh = false): Promise<boolean> {
111
+ if (!isGroup) return false;
112
+
113
+ const lastFetched = groupCacheTimestamps.get(sessionId) ?? 0;
114
+ const now = Date.now();
115
+ const isExpired = (now - lastFetched) > GROUP_CACHE_EXPIRY_MS;
116
+
117
+ if (!forceRefresh && !isExpired && lastFetched > 0) {
118
+ return false; // Cache is still valid
119
+ }
120
+
121
+ log?.info?.(`dmwork: [CACHE] ${forceRefresh ? 'Force refreshing' : 'Refreshing expired'} group member cache for ${sessionId}`);
122
+
123
+ try {
124
+ const members = await getGroupMembers({
125
+ apiUrl: account.config.apiUrl,
126
+ botToken: account.config.botToken ?? "",
127
+ groupNo: sessionId,
128
+ });
129
+
130
+ if (members.length > 0) {
131
+ for (const m of members) {
132
+ if (m.name && m.uid) {
133
+ memberMap.set(m.name, m.uid);
134
+ uidToNameMap.set(m.uid, m.name);
135
+ }
136
+ }
137
+ groupCacheTimestamps.set(sessionId, now);
138
+ log?.info?.(`dmwork: [CACHE] Loaded ${members.length} members, memberMap size: ${memberMap.size}`);
139
+ return true;
140
+ } else {
141
+ // Set a short backoff (30s) to prevent retry storms on empty responses
142
+ groupCacheTimestamps.set(sessionId, now - GROUP_CACHE_EXPIRY_MS + 30000);
143
+ log?.warn?.(`dmwork: [CACHE] No members returned for group ${sessionId}, backoff 30s`);
144
+ return false;
145
+ }
146
+ } catch (err) {
147
+ // Set a short backoff (30s) to prevent retry storms on errors
148
+ groupCacheTimestamps.set(sessionId, now - GROUP_CACHE_EXPIRY_MS + 30000);
149
+ log?.error?.(`dmwork: [CACHE] Failed to fetch group members: ${err}, backoff 30s`);
150
+ return false;
151
+ }
152
+ }
153
+
154
+ // Refresh group member cache if needed (on first message or after expiry)
155
+ if (isGroup) {
156
+ await refreshGroupMemberCache();
157
+ }
158
+
159
+ // Build displayName -> uid mapping from message content + mention.uids
160
+ // When user sends "@陈皮皮 @托马斯.福 xxx", the @ names in content correspond to mention.uids in order
161
+ if (isGroup) {
162
+ const allMentionUids: string[] = message.payload?.mention?.uids ?? [];
163
+ // Match all @xxx patterns (including Chinese characters and dots)
164
+ const contentMentions = rawBody.match(/@[\w\u4e00-\u9fa5.]+/g) ?? [];
165
+
166
+ if (contentMentions.length > 0 && allMentionUids.length > 0) {
167
+ log?.debug?.(`dmwork: [MAPPING] content @names: ${JSON.stringify(contentMentions)}, mention.uids: ${JSON.stringify(allMentionUids)}`);
168
+
169
+ // Pair them in order
170
+ const pairCount = Math.min(contentMentions.length, allMentionUids.length);
171
+ for (let i = 0; i < pairCount; i++) {
172
+ const displayName = contentMentions[i].slice(1); // Remove @ prefix
173
+ const uid = allMentionUids[i];
174
+ if (displayName && uid) {
175
+ // Update both mappings
176
+ if (!memberMap.has(displayName)) {
177
+ memberMap.set(displayName, uid);
178
+ log?.debug?.(`dmwork: [MAPPING] learned name->uid mapping`);
179
+ }
180
+ if (!uidToNameMap.has(uid)) {
181
+ uidToNameMap.set(uid, displayName);
182
+ log?.debug?.(`dmwork: [MAPPING] learned uid->name mapping`);
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
99
188
 
100
189
  if (isGroup && requireMention) {
101
190
  const mentionUids: string[] = message.payload?.mention?.uids ?? [];
102
191
  const mentionAll: boolean = message.payload?.mention?.all === true;
103
192
  const isMentioned = mentionAll || mentionUids.includes(botUid);
193
+
194
+ // Debug: log received mention info
195
+ log?.debug?.(`dmwork: [RECV] mention payload: uidsCount=${mentionUids.length}, all=${mentionAll}, originalCount=${originalMentionUids.length}`);
104
196
 
105
197
  if (!isMentioned) {
106
198
  // Record as pending history context (manual — avoids SDK format incompatibility)
@@ -130,10 +222,10 @@ export async function handleInboundMessage(params: {
130
222
  // Take last N entries (sliding window)
131
223
  if (entries.length > historyLimit) {
132
224
  entries = entries.slice(-historyLimit);
133
- groupHistories.set(sessionId, entries); // Persist trimmed entries
225
+ groupHistories.set(sessionId, entries); // Persist trimmed array to prevent unbounded growth
134
226
  }
135
227
  const historyCountBefore = entries.length;
136
- log?.info?.(`dmwork: [MENTION] 收到@消息 | from=${message.from_uid} | 缓存=${historyCountBefore}条 | historyLimit=${historyLimit} | session=${sessionId}`);
228
+ log?.info?.(`dmwork: [MENTION] 收到@消息 | 缓存=${historyCountBefore}条 | historyLimit=${historyLimit}`);
137
229
 
138
230
  // If memory cache is empty, try fetching from API
139
231
  if (entries.length === 0 && account.config.botToken) {
@@ -156,7 +248,6 @@ export async function handleInboundMessage(params: {
156
248
  body: m.content,
157
249
  timestamp: m.timestamp * 1000,
158
250
  }));
159
- groupHistories.set(sessionId, entries); // Persist API-fetched entries
160
251
  log?.info?.(`dmwork: [MENTION] 从API获取到 ${entries.length} 条历史消息`);
161
252
  } catch (err) {
162
253
  log?.error?.(`dmwork: [MENTION] 从API获取历史失败: ${err}`);
@@ -299,14 +390,110 @@ export async function handleInboundMessage(params: {
299
390
  const content = contentParts.join("\n").trim();
300
391
  if (!content) return;
301
392
 
393
+ // Build mentionUids from @mentions in content, using memberMap to resolve displayName -> uid
394
+ // The order of mentionUids MUST match the order of @xxx in content for correct linking!
395
+ let replyMentionUids: string[] = [];
396
+ let finalContent = content;
397
+
398
+ if (isGroup) {
399
+ // Parse all @mentions from content (support Chinese, English, dots, underscores, hex uids)
400
+ const contentMentions = content.match(/@[\w\u4e00-\u9fa5.]+/g) ?? [];
401
+
402
+ log?.debug?.(`dmwork: [REPLY] content @mentions count: ${contentMentions.length}`);
403
+ log?.debug?.(`dmwork: [REPLY] memberMap size: ${memberMap.size}, uidToNameMap size: ${uidToNameMap.size}`);
404
+
405
+ // Track if we need to retry after cache refresh
406
+ let unresolvedNames: { name: string; index: number }[] = [];
407
+
408
+ // Helper to resolve a single mention
409
+ const resolveMention = (name: string): { uid: string | null; newContent: string } => {
410
+ // First try memberMap (displayName -> uid)
411
+ let uid = memberMap.get(name);
412
+ let newContent = finalContent;
413
+
414
+ if (uid) {
415
+ log?.debug?.(`dmwork: [REPLY] resolved displayName to uid`);
416
+ return { uid, newContent };
417
+ } else if (/^[a-f0-9]{32}$/i.test(name)) {
418
+ // Looks like a hex uid (32 chars) - try to find display name
419
+ const displayName = uidToNameMap.get(name);
420
+ if (displayName) {
421
+ newContent = newContent.replace(`@${name}`, `@${displayName}`);
422
+ log?.debug?.(`dmwork: [REPLY] replaced uid with displayName`);
423
+ return { uid: name, newContent };
424
+ } else {
425
+ log?.warn?.(`dmwork: [REPLY] unknown hex uid, no displayName found`);
426
+ return { uid: name, newContent };
427
+ }
428
+ } else if (/^[a-zA-Z0-9_]+$/.test(name)) {
429
+ // Looks like a uid format (alphanumeric + underscore)
430
+ const displayName = uidToNameMap.get(name);
431
+ if (displayName) {
432
+ newContent = newContent.replace(`@${name}`, `@${displayName}`);
433
+ log?.debug?.(`dmwork: [REPLY] replaced uid with displayName`);
434
+ return { uid: name, newContent };
435
+ } else {
436
+ log?.debug?.(`dmwork: [REPLY] using mention as uid directly`);
437
+ return { uid: name, newContent };
438
+ }
439
+ } else {
440
+ // Chinese name not found - track for retry
441
+ return { uid: null, newContent };
442
+ }
443
+ };
444
+
445
+ // First pass: try to resolve all mentions, tracking indices for order preservation
446
+ const resolvedUids: (string | null)[] = [];
447
+ for (const mention of contentMentions) {
448
+ const name = mention.slice(1);
449
+ const result = resolveMention(name);
450
+ finalContent = result.newContent;
451
+ resolvedUids.push(result.uid); // null if unresolved
452
+ if (!result.uid) {
453
+ unresolvedNames.push({ name, index: resolvedUids.length - 1 });
454
+ }
455
+ }
456
+
457
+ // If we have unresolved names, try refreshing the cache and retry
458
+ if (unresolvedNames.length > 0) {
459
+ log?.info?.(`dmwork: [REPLY] ${unresolvedNames.length} unresolved names, force refreshing cache...`);
460
+ const refreshed = await refreshGroupMemberCache(true);
461
+
462
+ if (refreshed) {
463
+ // Retry unresolved names and insert at original positions
464
+ for (const { name, index } of unresolvedNames) {
465
+ const uid = memberMap.get(name);
466
+ if (uid) {
467
+ resolvedUids[index] = uid; // Insert at original position
468
+ log?.debug?.(`dmwork: [REPLY] after refresh: resolved @${name}`);
469
+ } else {
470
+ log?.warn?.(`dmwork: [REPLY] after refresh: still cannot resolve @${name}`);
471
+ }
472
+ }
473
+ }
474
+ }
475
+
476
+ // Build final mention UIDs array preserving original order
477
+ replyMentionUids = resolvedUids.filter((uid): uid is string => uid !== null);
478
+
479
+ // Always include the original sender so they get notified of the reply
480
+ if (message.from_uid && !replyMentionUids.includes(message.from_uid)) {
481
+ replyMentionUids.unshift(message.from_uid);
482
+ }
483
+
484
+ if (replyMentionUids.length > 0) {
485
+ log?.debug?.(`dmwork: [REPLY] final mentionUids count: ${replyMentionUids.length}`);
486
+ log?.debug?.(`dmwork: [REPLY] final content length: ${finalContent.length}`);
487
+ }
488
+ }
489
+
302
490
  await sendMessage({
303
491
  apiUrl: account.config.apiUrl,
304
492
  botToken: account.config.botToken ?? "",
305
493
  channelId: replyChannelId,
306
494
  channelType: replyChannelType,
307
- content,
308
- // In group replies, @mention the original sender
309
- ...(isGroup ? { mentionUids: [message.from_uid] } : {}),
495
+ content: finalContent,
496
+ ...(replyMentionUids.length > 0 ? { mentionUids: replyMentionUids } : {}),
310
497
  });
311
498
 
312
499
  statusSink?.({ lastOutboundAt: Date.now(), lastError: null });