openclaw-channel-dmwork 0.2.18 → 0.2.19

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.18",
3
+ "version": "0.2.19",
4
4
  "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -11,7 +11,9 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "tsc",
14
- "type-check": "tsc --noEmit"
14
+ "type-check": "tsc --noEmit",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest"
15
17
  },
16
18
  "dependencies": {
17
19
  "axios": "^1.7.0",
@@ -24,7 +26,8 @@
24
26
  "devDependencies": {
25
27
  "@types/ws": "^8.5.10",
26
28
  "openclaw": "2026.3.1",
27
- "typescript": "^5.9.3"
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^3.0.0"
28
31
  },
29
32
  "openclaw": {
30
33
  "id": "openclaw-channel-dmwork",
package/src/api-fetch.ts CHANGED
@@ -200,7 +200,7 @@ export async function getChannelMessages(params: {
200
200
  limit?: number;
201
201
  signal?: AbortSignal;
202
202
  log?: { info?: (...args: any[]) => void; error?: (...args: any[]) => void };
203
- }): Promise<Array<{ from_uid: string; content: string; timestamp: number }>> {
203
+ }): Promise<Array<{ from_uid: string; content: string; timestamp: number; type?: number; url?: string; name?: string }>> {
204
204
  try {
205
205
  const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/channel/messages`;
206
206
  const response = await fetch(url, {
@@ -225,6 +225,9 @@ export async function getChannelMessages(params: {
225
225
  const data = await response.json();
226
226
  return (data.messages ?? data ?? []).map((m: any) => ({
227
227
  from_uid: m.from_uid ?? m.sender_id ?? "unknown",
228
+ type: m.payload?.type ?? undefined,
229
+ url: m.payload?.url ?? undefined,
230
+ name: m.payload?.name ?? undefined,
228
231
  content: m.payload?.content ?? m.content ?? "",
229
232
  timestamp: m.timestamp ?? Math.floor(Date.now() / 1000), // API timestamps are in seconds
230
233
  }));
package/src/channel.ts CHANGED
@@ -80,7 +80,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
80
80
  meta,
81
81
  capabilities: {
82
82
  chatTypes: ["direct", "group"],
83
- media: false,
83
+ media: true,
84
84
  reactions: false,
85
85
  threads: false,
86
86
  },
@@ -280,8 +280,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
280
280
  onMessage: (msg: BotMessage) => {
281
281
  // Skip self messages
282
282
  if (msg.from_uid === credentials.robot_id) return;
283
- // Skip non-text for now
284
- if (!msg.payload || msg.payload.type !== MessageType.Text) return;
283
+ // Skip unsupported message types (Location, Card)
284
+ const supportedTypes = [MessageType.Text, MessageType.Image, MessageType.GIF, MessageType.Voice, MessageType.Video, MessageType.File];
285
+ if (!msg.payload || !supportedTypes.includes(msg.payload.type)) return;
285
286
 
286
287
  log?.info?.(
287
288
  `dmwork: recv message from=${msg.from_uid} channel=${msg.channel_id ?? "DM"} type=${msg.channel_type ?? 1}`,
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ChannelType, MessageType, type MentionPayload } from "./types.js";
3
+
4
+ /**
5
+ * Tests for mention.all detection logic.
6
+ *
7
+ * The API can return mention.all as either:
8
+ * - boolean `true` (newer API versions)
9
+ * - number `1` (older API versions / WuKongIM native format)
10
+ *
11
+ * Both should be treated as "mention all".
12
+ */
13
+ describe("mention.all detection", () => {
14
+ // Helper to simulate the detection logic from inbound.ts
15
+ function isMentionAll(mention?: MentionPayload): boolean {
16
+ const mentionAllRaw = mention?.all;
17
+ return mentionAllRaw === true || mentionAllRaw === 1;
18
+ }
19
+
20
+ it("should detect mention.all when all is boolean true", () => {
21
+ const mention: MentionPayload = { all: true };
22
+ expect(isMentionAll(mention)).toBe(true);
23
+ });
24
+
25
+ it("should detect mention.all when all is numeric 1", () => {
26
+ const mention: MentionPayload = { all: 1 };
27
+ expect(isMentionAll(mention)).toBe(true);
28
+ });
29
+
30
+ it("should NOT detect mention.all when all is false", () => {
31
+ const mention: MentionPayload = { all: false as unknown as boolean | number };
32
+ expect(isMentionAll(mention)).toBe(false);
33
+ });
34
+
35
+ it("should NOT detect mention.all when all is 0", () => {
36
+ const mention: MentionPayload = { all: 0 };
37
+ expect(isMentionAll(mention)).toBe(false);
38
+ });
39
+
40
+ it("should NOT detect mention.all when all is undefined", () => {
41
+ const mention: MentionPayload = { uids: ["user1"] };
42
+ expect(isMentionAll(mention)).toBe(false);
43
+ });
44
+
45
+ it("should NOT detect mention.all when mention is undefined", () => {
46
+ expect(isMentionAll(undefined)).toBe(false);
47
+ });
48
+
49
+ it("should NOT detect mention.all when all is a different number", () => {
50
+ const mention: MentionPayload = { all: 2 };
51
+ expect(isMentionAll(mention)).toBe(false);
52
+ });
53
+ });
package/src/inbound.ts CHANGED
@@ -44,11 +44,89 @@ export type DmworkStatusSink = (patch: {
44
44
  lastError?: string | null;
45
45
  }) => void;
46
46
 
47
- function resolveContent(payload: BotMessage["payload"]): string {
48
- if (!payload) return "";
49
- if (typeof payload.content === "string") return payload.content;
50
- if (typeof payload.url === "string") return payload.url;
51
- return "";
47
+ interface ResolvedContent {
48
+ text: string;
49
+ mediaUrl?: string;
50
+ mediaType?: string;
51
+ }
52
+
53
+ function resolveContent(payload: BotMessage["payload"]): ResolvedContent {
54
+ if (!payload) return { text: "" };
55
+ switch (payload.type) {
56
+ case MessageType.Text:
57
+ return { text: payload.content ?? "" };
58
+ case MessageType.Image:
59
+ return { text: "[图片]", mediaUrl: payload.url, mediaType: "image" };
60
+ case MessageType.GIF:
61
+ return { text: "[GIF]", mediaUrl: payload.url, mediaType: "image" };
62
+ case MessageType.Voice:
63
+ return { text: "[语音消息]" };
64
+ case MessageType.Video:
65
+ return { text: "[视频]", mediaUrl: payload.url, mediaType: "video" };
66
+ case MessageType.File:
67
+ return { text: `[文件: ${payload.name ?? "未知文件"}]`, mediaUrl: payload.url };
68
+ case MessageType.Location:
69
+ return { text: "[位置信息]" };
70
+ case MessageType.Card:
71
+ return { text: "[名片]" };
72
+ default:
73
+ return { text: payload.content ?? payload.url ?? "" };
74
+ }
75
+ }
76
+
77
+ /** Extract text-only content for history/quotes (no mediaUrl) */
78
+ function resolveContentText(payload: BotMessage["payload"]): string {
79
+ return resolveContent(payload).text;
80
+ }
81
+
82
+ /** Placeholder text for non-text API history messages */
83
+ function resolveApiMessagePlaceholder(type?: number, name?: string): string {
84
+ switch (type) {
85
+ case MessageType.Image: return "[图片]";
86
+ case MessageType.GIF: return "[GIF]";
87
+ case MessageType.Voice: return "[语音消息]";
88
+ case MessageType.Video: return "[视频]";
89
+ case MessageType.File: return `[文件: ${name ?? "未知文件"}]`;
90
+ case MessageType.Location: return "[位置信息]";
91
+ case MessageType.Card: return "[名片]";
92
+ default: return "[消息]";
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Strip emoji from string for fuzzy matching.
98
+ * Removes most emoji using Unicode ranges.
99
+ */
100
+ function stripEmoji(str: string): string {
101
+ return str
102
+ .replace(/[\u{1F300}-\u{1F9FF}]/gu, '') // Most emoji (faces, symbols, etc.)
103
+ .replace(/[\u{2600}-\u{26FF}]/gu, '') // Misc symbols
104
+ .replace(/[\u{2700}-\u{27BF}]/gu, '') // Dingbats
105
+ .replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation selectors
106
+ .replace(/[\u{1F000}-\u{1F02F}]/gu, '') // Mahjong, dominos
107
+ .replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') // Playing cards
108
+ .trim();
109
+ }
110
+
111
+ /**
112
+ * Find uid by displayName with emoji-tolerant matching.
113
+ * First tries exact match, then falls back to matching with emoji stripped.
114
+ */
115
+ function findUidByName(name: string, memberMap: Map<string, string>): string | undefined {
116
+ // First try exact match
117
+ const exact = memberMap.get(name);
118
+ if (exact) return exact;
119
+
120
+ // Then try matching by stripping emoji from both sides
121
+ const strippedName = stripEmoji(name);
122
+ if (!strippedName) return undefined;
123
+
124
+ for (const [displayName, uid] of memberMap.entries()) {
125
+ if (stripEmoji(displayName) === strippedName) {
126
+ return uid;
127
+ }
128
+ }
129
+ return undefined;
52
130
  }
53
131
 
54
132
  // Cache expiry time: 1 hour
@@ -78,7 +156,9 @@ export async function handleInboundMessage(params: {
78
156
  ? message.channel_id!
79
157
  : message.from_uid;
80
158
 
81
- const rawBody = resolveContent(message.payload);
159
+ const resolved = resolveContent(message.payload);
160
+ const rawBody = resolved.text;
161
+ const inboundMediaUrl = resolved.mediaUrl;
82
162
  if (!rawBody) {
83
163
  log?.info?.(
84
164
  `dmwork: inbound dropped session=${sessionId} reason=empty-content`,
@@ -91,7 +171,7 @@ export async function handleInboundMessage(params: {
91
171
  const replyData = message.payload?.reply;
92
172
  if (replyData) {
93
173
  const replyPayload = replyData.payload;
94
- const replyContent = replyPayload?.content ?? resolveContent(replyPayload);
174
+ const replyContent = replyPayload?.content ?? resolveContentText(replyPayload);
95
175
  const replyFrom = replyData.from_uid ?? replyData.from_name ?? "unknown";
96
176
  if (replyContent) {
97
177
  quotePrefix = `[Quoted message from ${replyFrom}]: ${replyContent}\n---\n`;
@@ -132,6 +212,14 @@ export async function handleInboundMessage(params: {
132
212
  if (m.name && m.uid) {
133
213
  memberMap.set(m.name, m.uid);
134
214
  uidToNameMap.set(m.uid, m.name);
215
+
216
+ // Also save name without leading emoji for faster lookup
217
+ // (complements findUidByName's emoji-tolerant matching)
218
+ const nameWithoutEmoji = stripEmoji(m.name);
219
+ if (nameWithoutEmoji && nameWithoutEmoji !== m.name && !memberMap.has(nameWithoutEmoji)) {
220
+ memberMap.set(nameWithoutEmoji, m.uid);
221
+ log?.debug?.(`dmwork: [CACHE] Added emoji alias: "${nameWithoutEmoji}" -> "${m.uid}"`);
222
+ }
135
223
  }
136
224
  }
137
225
  groupCacheTimestamps.set(sessionId, now);
@@ -188,7 +276,9 @@ export async function handleInboundMessage(params: {
188
276
 
189
277
  if (isGroup && requireMention) {
190
278
  const mentionUids: string[] = message.payload?.mention?.uids ?? [];
191
- const mentionAll: boolean = message.payload?.mention?.all === true;
279
+ // mention.all can be boolean `true` or numeric `1` depending on API version
280
+ const mentionAllRaw = message.payload?.mention?.all;
281
+ const mentionAll: boolean = mentionAllRaw === true || mentionAllRaw === 1;
192
282
  const isMentioned = mentionAll || mentionUids.includes(botUid);
193
283
 
194
284
  // Debug: log received mention info
@@ -241,11 +331,11 @@ export async function handleInboundMessage(params: {
241
331
  log,
242
332
  });
243
333
  entries = apiMessages
244
- .filter((m: any) => m.from_uid !== botUid && m.content && !m.content.includes(`@${botUid}`))
334
+ .filter((m: any) => m.from_uid !== botUid && (m.content || m.type !== 1))
245
335
  .slice(-historyLimit)
246
336
  .map((m: any) => ({
247
337
  sender: m.from_uid,
248
- body: m.content,
338
+ body: m.content || resolveApiMessagePlaceholder(m.type, m.name),
249
339
  timestamp: m.timestamp * 1000,
250
340
  }));
251
341
  log?.info?.(`dmwork: [MENTION] 从API获取到 ${entries.length} 条历史消息`);
@@ -330,6 +420,9 @@ export async function handleInboundMessage(params: {
330
420
  BodyForAgent: body, // ← 关键!AI 实际读取的是这个字段!
331
421
  RawBody: rawBody,
332
422
  CommandBody: rawBody,
423
+ MediaUrl: inboundMediaUrl,
424
+ MediaUrls: inboundMediaUrl ? [inboundMediaUrl] : undefined,
425
+ MediaTypes: resolved.mediaType ? [resolved.mediaType] : undefined,
333
426
  From: `dmwork:${message.from_uid}`,
334
427
  To: `dmwork:${sessionId}`,
335
428
  SessionKey: route.sessionKey,
@@ -408,7 +501,7 @@ export async function handleInboundMessage(params: {
408
501
  // Helper to resolve a single mention
409
502
  const resolveMention = (name: string): { uid: string | null; newContent: string } => {
410
503
  // First try memberMap (displayName -> uid)
411
- let uid = memberMap.get(name);
504
+ let uid = findUidByName(name, memberMap);
412
505
  let newContent = finalContent;
413
506
 
414
507
  if (uid) {
@@ -462,7 +555,7 @@ export async function handleInboundMessage(params: {
462
555
  if (refreshed) {
463
556
  // Retry unresolved names and insert at original positions
464
557
  for (const { name, index } of unresolvedNames) {
465
- const uid = memberMap.get(name);
558
+ const uid = findUidByName(name, memberMap);
466
559
  if (uid) {
467
560
  resolvedUids[index] = uid; // Insert at original position
468
561
  log?.debug?.(`dmwork: [REPLY] after refresh: resolved @${name}`);
@@ -476,10 +569,6 @@ export async function handleInboundMessage(params: {
476
569
  // Build final mention UIDs array preserving original order
477
570
  replyMentionUids = resolvedUids.filter((uid): uid is string => uid !== null);
478
571
 
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
572
 
484
573
  if (replyMentionUids.length > 0) {
485
574
  log?.debug?.(`dmwork: [REPLY] final mentionUids count: ${replyMentionUids.length}`);
package/src/types.ts CHANGED
@@ -52,13 +52,14 @@ export interface BotMessage {
52
52
 
53
53
  export interface MentionPayload {
54
54
  uids?: string[];
55
- all?: number; // 1 = @all
55
+ all?: boolean | number; // true or 1 = @all (API returns either depending on version)
56
56
  }
57
57
 
58
58
  export interface MessagePayload {
59
59
  type: MessageType;
60
60
  content?: string;
61
61
  url?: string;
62
+ name?: string;
62
63
  mention?: MentionPayload;
63
64
  [key: string]: unknown;
64
65
  }