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 +6 -3
- package/src/api-fetch.ts +4 -1
- package/src/channel.ts +4 -3
- package/src/inbound.test.ts +53 -0
- package/src/inbound.ts +105 -16
- package/src/types.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-channel-dmwork",
|
|
3
|
-
"version": "0.2.
|
|
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:
|
|
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
|
|
284
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
}
|