openclaw-channel-dmwork 0.2.21 → 0.2.23
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/openclaw.plugin.json +1 -1
- package/package.json +8 -5
- package/src/accounts.ts +4 -0
- package/src/api-fetch.test.ts +158 -0
- package/src/api-fetch.ts +129 -5
- package/src/channel.test.ts +76 -0
- package/src/channel.ts +131 -5
- package/src/config-schema.ts +38 -22
- package/src/inbound.test.ts +168 -0
- package/src/inbound.ts +361 -30
- package/src/mention-utils.test.ts +97 -0
- package/src/mention-utils.ts +44 -0
- package/src/socket.ts +86 -161
- package/src/types.ts +10 -13
- package/src/api.ts +0 -96
- package/src/stream.ts +0 -96
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-channel-dmwork",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
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,10 +26,11 @@
|
|
|
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
|
-
"id": "dmwork",
|
|
33
|
+
"id": "openclaw-channel-dmwork",
|
|
31
34
|
"type": "channel",
|
|
32
35
|
"extensions": [
|
|
33
36
|
"index.ts"
|
|
@@ -36,4 +39,4 @@
|
|
|
36
39
|
"bundledDependencies": [
|
|
37
40
|
"wukongimjssdk"
|
|
38
41
|
]
|
|
39
|
-
}
|
|
42
|
+
}
|
package/src/accounts.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type ResolvedDmworkAccount = {
|
|
|
18
18
|
pollIntervalMs: number;
|
|
19
19
|
heartbeatIntervalMs: number;
|
|
20
20
|
requireMention?: boolean;
|
|
21
|
+
historyLimit?: number; // 群聊历史消息条数限制
|
|
22
|
+
historyPromptTemplate?: string; // Template for group history context injection
|
|
21
23
|
};
|
|
22
24
|
};
|
|
23
25
|
|
|
@@ -73,6 +75,8 @@ export function resolveDmworkAccount(params: {
|
|
|
73
75
|
pollIntervalMs,
|
|
74
76
|
heartbeatIntervalMs,
|
|
75
77
|
requireMention: accountConfig.requireMention ?? channel.requireMention,
|
|
78
|
+
historyLimit: accountConfig.historyLimit ?? channel.historyLimit ?? 20,
|
|
79
|
+
historyPromptTemplate: accountConfig.historyPromptTemplate ?? channel.historyPromptTemplate,
|
|
76
80
|
},
|
|
77
81
|
};
|
|
78
82
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for api-fetch.ts functions.
|
|
5
|
+
*
|
|
6
|
+
* Verifies that async functions properly await their responses
|
|
7
|
+
* and return resolved data instead of Promises.
|
|
8
|
+
*/
|
|
9
|
+
describe("fetchBotGroups", () => {
|
|
10
|
+
const originalFetch = global.fetch;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Reset fetch mock before each test
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
// Restore original fetch
|
|
19
|
+
global.fetch = originalFetch;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should return an array, not a Promise", async () => {
|
|
23
|
+
// Mock fetch to return a successful response
|
|
24
|
+
const mockGroups = [
|
|
25
|
+
{ group_no: "group1", name: "Test Group 1" },
|
|
26
|
+
{ group_no: "group2", name: "Test Group 2" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
30
|
+
ok: true,
|
|
31
|
+
json: vi.fn().mockResolvedValue(mockGroups),
|
|
32
|
+
}) as unknown as typeof fetch;
|
|
33
|
+
|
|
34
|
+
// Import dynamically to use mocked fetch
|
|
35
|
+
const { fetchBotGroups } = await import("./api-fetch.js");
|
|
36
|
+
|
|
37
|
+
const result = await fetchBotGroups({
|
|
38
|
+
apiUrl: "http://localhost:8090",
|
|
39
|
+
botToken: "test-token",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Critical: result should be the actual array, not a Promise
|
|
43
|
+
expect(Array.isArray(result)).toBe(true);
|
|
44
|
+
expect(result).toHaveLength(2);
|
|
45
|
+
expect(result[0].group_no).toBe("group1");
|
|
46
|
+
expect(result[1].name).toBe("Test Group 2");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should return empty array on non-ok response", async () => {
|
|
50
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
51
|
+
ok: false,
|
|
52
|
+
status: 500,
|
|
53
|
+
}) as unknown as typeof fetch;
|
|
54
|
+
|
|
55
|
+
const { fetchBotGroups } = await import("./api-fetch.js");
|
|
56
|
+
|
|
57
|
+
const result = await fetchBotGroups({
|
|
58
|
+
apiUrl: "http://localhost:8090",
|
|
59
|
+
botToken: "test-token",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(Array.isArray(result)).toBe(true);
|
|
63
|
+
expect(result).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should properly await json() call", async () => {
|
|
67
|
+
// This test specifically verifies the fix for issue #29
|
|
68
|
+
// If await is missing, the result would be a Promise object
|
|
69
|
+
const mockGroups = [{ group_no: "g1", name: "Group" }];
|
|
70
|
+
const jsonMock = vi.fn().mockResolvedValue(mockGroups);
|
|
71
|
+
|
|
72
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
73
|
+
ok: true,
|
|
74
|
+
json: jsonMock,
|
|
75
|
+
}) as unknown as typeof fetch;
|
|
76
|
+
|
|
77
|
+
const { fetchBotGroups } = await import("./api-fetch.js");
|
|
78
|
+
|
|
79
|
+
const result = await fetchBotGroups({
|
|
80
|
+
apiUrl: "http://localhost:8090",
|
|
81
|
+
botToken: "test-token",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Verify json() was called
|
|
85
|
+
expect(jsonMock).toHaveBeenCalled();
|
|
86
|
+
|
|
87
|
+
// Verify result is resolved data, not a Promise
|
|
88
|
+
expect(result).not.toBeInstanceOf(Promise);
|
|
89
|
+
expect(result).toEqual(mockGroups);
|
|
90
|
+
|
|
91
|
+
// Additional check: calling array methods should work
|
|
92
|
+
expect(result.length).toBe(1);
|
|
93
|
+
expect(result.map((g) => g.name)).toEqual(["Group"]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("log parameter type compatibility", () => {
|
|
98
|
+
const originalFetch = global.fetch;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
vi.restoreAllMocks();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
global.fetch = originalFetch;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should accept ChannelLogSink-compatible log parameter", async () => {
|
|
109
|
+
// Simulates ChannelLogSink type from OpenClaw SDK:
|
|
110
|
+
// { info: (msg: string) => void; error: (msg: string) => void; ... }
|
|
111
|
+
const channelLogSink = {
|
|
112
|
+
info: (msg: string) => console.log(msg),
|
|
113
|
+
warn: (msg: string) => console.warn(msg),
|
|
114
|
+
error: (msg: string) => console.error(msg),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const mockGroups = [{ group_no: "g1", name: "Group" }];
|
|
118
|
+
|
|
119
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
120
|
+
ok: true,
|
|
121
|
+
json: vi.fn().mockResolvedValue(mockGroups),
|
|
122
|
+
}) as unknown as typeof fetch;
|
|
123
|
+
|
|
124
|
+
const { fetchBotGroups } = await import("./api-fetch.js");
|
|
125
|
+
|
|
126
|
+
// This should compile without TypeScript errors
|
|
127
|
+
const result = await fetchBotGroups({
|
|
128
|
+
apiUrl: "http://localhost:8090",
|
|
129
|
+
botToken: "test-token",
|
|
130
|
+
log: channelLogSink,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual(mockGroups);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should call log.error on non-ok response", async () => {
|
|
137
|
+
const errorSpy = vi.fn();
|
|
138
|
+
const log = {
|
|
139
|
+
info: vi.fn(),
|
|
140
|
+
error: errorSpy,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 401,
|
|
146
|
+
}) as unknown as typeof fetch;
|
|
147
|
+
|
|
148
|
+
const { fetchBotGroups } = await import("./api-fetch.js");
|
|
149
|
+
|
|
150
|
+
await fetchBotGroups({
|
|
151
|
+
apiUrl: "http://localhost:8090",
|
|
152
|
+
botToken: "test-token",
|
|
153
|
+
log,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("401"));
|
|
157
|
+
});
|
|
158
|
+
});
|
package/src/api-fetch.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { ChannelType, MessageType } from "./types.js";
|
|
7
7
|
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
8
10
|
const DEFAULT_HEADERS = {
|
|
9
11
|
"Content-Type": "application/json",
|
|
10
12
|
};
|
|
@@ -15,7 +17,7 @@ async function postJson<T>(
|
|
|
15
17
|
path: string,
|
|
16
18
|
payload: Record<string, unknown>,
|
|
17
19
|
signal?: AbortSignal,
|
|
18
|
-
): Promise<T> {
|
|
20
|
+
): Promise<T | undefined> {
|
|
19
21
|
const url = `${apiUrl.replace(/\/+$/, "")}${path}`;
|
|
20
22
|
const response = await fetch(url, {
|
|
21
23
|
method: "POST",
|
|
@@ -33,7 +35,7 @@ async function postJson<T>(
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const text = await response.text();
|
|
36
|
-
if (!text) return
|
|
38
|
+
if (!text) return undefined;
|
|
37
39
|
return JSON.parse(text) as T;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -125,13 +127,23 @@ export async function registerBot(params: {
|
|
|
125
127
|
const path = params.forceRefresh
|
|
126
128
|
? "/v1/bot/register?force_refresh=true"
|
|
127
129
|
: "/v1/bot/register";
|
|
128
|
-
|
|
130
|
+
const result = await postJson<{
|
|
131
|
+
robot_id: string;
|
|
132
|
+
im_token: string;
|
|
133
|
+
ws_url: string;
|
|
134
|
+
api_url: string;
|
|
135
|
+
owner_uid: string;
|
|
136
|
+
owner_channel_id: string;
|
|
137
|
+
}>(params.apiUrl, params.botToken, path, {}, params.signal);
|
|
138
|
+
if (!result) throw new Error("DMWork bot registration returned empty response");
|
|
139
|
+
return result;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
// Fetch the groups the bot belongs to
|
|
132
143
|
export async function fetchBotGroups(params: {
|
|
133
144
|
apiUrl: string;
|
|
134
145
|
botToken: string;
|
|
146
|
+
log?: { info?: (msg: string) => void; error?: (msg: string) => void };
|
|
135
147
|
}): Promise<Array<{ group_no: string; name: string }>> {
|
|
136
148
|
const url = `${params.apiUrl}/v1/bot/groups`;
|
|
137
149
|
const resp = await fetch(url, {
|
|
@@ -139,10 +151,122 @@ export async function fetchBotGroups(params: {
|
|
|
139
151
|
headers: {
|
|
140
152
|
"Authorization": `Bearer ${params.botToken}`,
|
|
141
153
|
},
|
|
154
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
142
155
|
});
|
|
143
156
|
if (!resp.ok) {
|
|
144
|
-
|
|
157
|
+
params.log?.error?.(`dmwork: fetchBotGroups failed: ${resp.status}`);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return await resp.json();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 获取群成员列表
|
|
165
|
+
*/
|
|
166
|
+
export interface GroupMember {
|
|
167
|
+
uid: string;
|
|
168
|
+
name: string;
|
|
169
|
+
role?: string; // admin/member
|
|
170
|
+
robot?: boolean; // 是否是机器人
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function getGroupMembers(params: {
|
|
174
|
+
apiUrl: string;
|
|
175
|
+
botToken: string;
|
|
176
|
+
groupNo: string; // 群 ID (channel_id)
|
|
177
|
+
log?: { info?: (msg: string) => void; error?: (msg: string) => void };
|
|
178
|
+
}): Promise<GroupMember[]> {
|
|
179
|
+
const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/groups/${params.groupNo}/members`;
|
|
180
|
+
try {
|
|
181
|
+
const resp = await fetch(url, {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers: {
|
|
184
|
+
"Authorization": `Bearer ${params.botToken}`,
|
|
185
|
+
},
|
|
186
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
187
|
+
});
|
|
188
|
+
if (!resp.ok) {
|
|
189
|
+
params.log?.error?.(`dmwork: getGroupMembers failed: ${resp.status}`);
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const data = await resp.json();
|
|
193
|
+
// Normalize to strict array to prevent silent failures
|
|
194
|
+
const members = Array.isArray(data?.members)
|
|
195
|
+
? data.members
|
|
196
|
+
: Array.isArray(data)
|
|
197
|
+
? data
|
|
198
|
+
: [];
|
|
199
|
+
return members as GroupMember[];
|
|
200
|
+
} catch (err) {
|
|
201
|
+
params.log?.error?.(`dmwork: getGroupMembers error: ${err}`);
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 获取频道历史消息(用于注入上下文)
|
|
208
|
+
* @param params.log - Optional logger for consistent logging with OpenClaw log system
|
|
209
|
+
*/
|
|
210
|
+
export async function getChannelMessages(params: {
|
|
211
|
+
apiUrl: string;
|
|
212
|
+
botToken: string;
|
|
213
|
+
channelId: string;
|
|
214
|
+
channelType: ChannelType;
|
|
215
|
+
limit?: number;
|
|
216
|
+
signal?: AbortSignal;
|
|
217
|
+
log?: { info?: (msg: string) => void; error?: (msg: string) => void };
|
|
218
|
+
}): Promise<Array<{ from_uid: string; content: string; timestamp: number; type?: number; url?: string; name?: string }>> {
|
|
219
|
+
try {
|
|
220
|
+
const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/messages/sync`;
|
|
221
|
+
const limit = params.limit ?? 20;
|
|
222
|
+
const response = await fetch(url, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
Authorization: `Bearer ${params.botToken}`,
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
channel_id: params.channelId,
|
|
230
|
+
channel_type: params.channelType,
|
|
231
|
+
limit,
|
|
232
|
+
start_message_seq: 0,
|
|
233
|
+
end_message_seq: 0,
|
|
234
|
+
pull_mode: 1, // 1 = pull up (newer messages)
|
|
235
|
+
}),
|
|
236
|
+
signal: params.signal,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
params.log?.info?.(`dmwork: getChannelMessages failed: ${response.status}`);
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = await response.json();
|
|
245
|
+
const messages = data.messages ?? [];
|
|
246
|
+
return messages.map((m: any) => {
|
|
247
|
+
// payload is base64-encoded JSON string
|
|
248
|
+
let payload: any = {};
|
|
249
|
+
if (m.payload) {
|
|
250
|
+
try {
|
|
251
|
+
const decoded = Buffer.from(m.payload, "base64").toString("utf-8");
|
|
252
|
+
payload = JSON.parse(decoded);
|
|
253
|
+
} catch {
|
|
254
|
+
// If decoding fails, try treating payload as already-parsed object
|
|
255
|
+
payload = typeof m.payload === "object" ? m.payload : {};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
from_uid: m.from_uid ?? "unknown",
|
|
260
|
+
type: payload.type ?? undefined,
|
|
261
|
+
url: payload.url ?? undefined,
|
|
262
|
+
name: payload.name ?? undefined,
|
|
263
|
+
content: payload.content ?? "",
|
|
264
|
+
// Convert seconds to milliseconds (API returns seconds, internal standard is ms)
|
|
265
|
+
timestamp: (m.timestamp ?? Math.floor(Date.now() / 1000)) * 1000,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
params.log?.error?.(`dmwork: getChannelMessages error: ${err}`);
|
|
145
270
|
return [];
|
|
146
271
|
}
|
|
147
|
-
return resp.json();
|
|
148
272
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for channel.ts singleton timer behavior.
|
|
5
|
+
* Verifies that cleanup timer doesn't accumulate during hot reloads.
|
|
6
|
+
*
|
|
7
|
+
* Fixes: https://github.com/dmwork-org/dmwork-adapters/issues/54
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
describe("ensureCleanupTimer singleton pattern", () => {
|
|
11
|
+
let originalSetInterval: typeof setInterval;
|
|
12
|
+
let setIntervalCalls: number;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
originalSetInterval = global.setInterval;
|
|
16
|
+
setIntervalCalls = 0;
|
|
17
|
+
|
|
18
|
+
// Track setInterval calls
|
|
19
|
+
global.setInterval = vi.fn(() => {
|
|
20
|
+
setIntervalCalls++;
|
|
21
|
+
// Return a mock timer object that won't actually run
|
|
22
|
+
const timerId = { unref: vi.fn() } as unknown as NodeJS.Timeout;
|
|
23
|
+
return timerId;
|
|
24
|
+
}) as unknown as typeof setInterval;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
global.setInterval = originalSetInterval;
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should only create one cleanup timer on first import", async () => {
|
|
33
|
+
// Fresh import - timer should be created lazily now (not at module load)
|
|
34
|
+
// Since we changed to lazy initialization, no timer at import time
|
|
35
|
+
vi.resetModules();
|
|
36
|
+
const { dmworkPlugin } = await import("./channel.js");
|
|
37
|
+
|
|
38
|
+
// At this point, no timer should have been created yet
|
|
39
|
+
// Timer is created when startAccount is called
|
|
40
|
+
expect(dmworkPlugin).toBeDefined();
|
|
41
|
+
expect(dmworkPlugin.id).toBe("dmwork");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should expose ensureCleanupTimer via gateway.startAccount pattern", async () => {
|
|
45
|
+
vi.resetModules();
|
|
46
|
+
const { dmworkPlugin } = await import("./channel.js");
|
|
47
|
+
|
|
48
|
+
// The gateway.startAccount method should exist and call ensureCleanupTimer
|
|
49
|
+
expect(dmworkPlugin.gateway?.startAccount).toBeDefined();
|
|
50
|
+
expect(typeof dmworkPlugin.gateway?.startAccount).toBe("function");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("dmworkPlugin structure", () => {
|
|
55
|
+
it("should have correct plugin id and meta", async () => {
|
|
56
|
+
const { dmworkPlugin } = await import("./channel.js");
|
|
57
|
+
|
|
58
|
+
expect(dmworkPlugin.id).toBe("dmwork");
|
|
59
|
+
expect(dmworkPlugin.meta.id).toBe("dmwork");
|
|
60
|
+
expect(dmworkPlugin.meta.label).toBe("DMWork");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should have gateway.startAccount defined", async () => {
|
|
64
|
+
const { dmworkPlugin } = await import("./channel.js");
|
|
65
|
+
|
|
66
|
+
expect(dmworkPlugin.gateway).toBeDefined();
|
|
67
|
+
expect(dmworkPlugin.gateway?.startAccount).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should support direct and group chat types", async () => {
|
|
71
|
+
const { dmworkPlugin } = await import("./channel.js");
|
|
72
|
+
|
|
73
|
+
expect(dmworkPlugin.capabilities?.chatTypes).toContain("direct");
|
|
74
|
+
expect(dmworkPlugin.capabilities?.chatTypes).toContain("group");
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { registerBot, sendMessage, sendHeartbeat } 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";
|
|
18
|
+
import { parseMentions } from "./mention-utils.js";
|
|
18
19
|
// HistoryEntry type - compatible with any version
|
|
19
20
|
type HistoryEntry = { sender: string; body: string; timestamp: number };
|
|
20
21
|
const DEFAULT_GROUP_HISTORY_LIMIT = 20;
|
|
@@ -30,6 +31,80 @@ function getOrCreateHistoryMap(accountId: string): Map<string, any[]> {
|
|
|
30
31
|
return m;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// Module-level member mapping: displayName -> uid
|
|
35
|
+
// Used to resolve @mentions in AI replies
|
|
36
|
+
const _memberMaps = new Map<string, Map<string, string>>();
|
|
37
|
+
function getOrCreateMemberMap(accountId: string): Map<string, string> {
|
|
38
|
+
let m = _memberMaps.get(accountId);
|
|
39
|
+
if (!m) {
|
|
40
|
+
m = new Map<string, string>();
|
|
41
|
+
_memberMaps.set(accountId, m);
|
|
42
|
+
}
|
|
43
|
+
return m;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Module-level reverse mapping: uid -> displayName
|
|
47
|
+
// Used to show display names instead of uids in replies
|
|
48
|
+
const _uidToNameMaps = new Map<string, Map<string, string>>();
|
|
49
|
+
function getOrCreateUidToNameMap(accountId: string): Map<string, string> {
|
|
50
|
+
let m = _uidToNameMaps.get(accountId);
|
|
51
|
+
if (!m) {
|
|
52
|
+
m = new Map<string, string>();
|
|
53
|
+
_uidToNameMaps.set(accountId, m);
|
|
54
|
+
}
|
|
55
|
+
return m;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Group member cache timestamps: groupId -> lastFetchedAt (ms)
|
|
59
|
+
const _groupCacheTimestamps = new Map<string, Map<string, number>>();
|
|
60
|
+
function getOrCreateGroupCacheTimestamps(accountId: string): Map<string, number> {
|
|
61
|
+
let m = _groupCacheTimestamps.get(accountId);
|
|
62
|
+
if (!m) {
|
|
63
|
+
m = new Map<string, number>();
|
|
64
|
+
_groupCacheTimestamps.set(accountId, m);
|
|
65
|
+
}
|
|
66
|
+
return m;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// --- Cache cleanup: evict groups inactive for >4 hours ---
|
|
71
|
+
const CACHE_MAX_AGE_MS = 4 * 60 * 60 * 1000;
|
|
72
|
+
const CACHE_CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
|
|
73
|
+
const _cacheActivity = new Map<string, Map<string, number>>();
|
|
74
|
+
|
|
75
|
+
function touchCache(accountId: string, groupId: string): void {
|
|
76
|
+
let m = _cacheActivity.get(accountId);
|
|
77
|
+
if (!m) { m = new Map(); _cacheActivity.set(accountId, m); }
|
|
78
|
+
m.set(groupId, Date.now());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cleanupStaleCaches(): void {
|
|
82
|
+
const cutoff = Date.now() - CACHE_MAX_AGE_MS;
|
|
83
|
+
for (const [accountId, activityMap] of _cacheActivity) {
|
|
84
|
+
for (const [groupId, lastAccess] of activityMap) {
|
|
85
|
+
if (lastAccess < cutoff) {
|
|
86
|
+
_historyMaps.get(accountId)?.delete(groupId);
|
|
87
|
+
_memberMaps.get(accountId)?.delete(groupId);
|
|
88
|
+
_uidToNameMaps.get(accountId)?.delete(groupId);
|
|
89
|
+
_groupCacheTimestamps.get(accountId)?.delete(groupId);
|
|
90
|
+
activityMap.delete(groupId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (activityMap.size === 0) _cacheActivity.delete(accountId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Singleton timer to prevent accumulation during hot reload (#54)
|
|
98
|
+
let _cleanupTimer: NodeJS.Timeout | null = null;
|
|
99
|
+
|
|
100
|
+
function ensureCleanupTimer(): void {
|
|
101
|
+
if (_cleanupTimer) return; // Already running
|
|
102
|
+
_cleanupTimer = setInterval(cleanupStaleCaches, CACHE_CLEANUP_INTERVAL_MS);
|
|
103
|
+
if (typeof _cleanupTimer === "object" && _cleanupTimer && "unref" in _cleanupTimer) {
|
|
104
|
+
_cleanupTimer.unref();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
33
108
|
const meta = {
|
|
34
109
|
id: "dmwork",
|
|
35
110
|
label: "DMWork",
|
|
@@ -45,7 +120,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
45
120
|
meta,
|
|
46
121
|
capabilities: {
|
|
47
122
|
chatTypes: ["direct", "group"],
|
|
48
|
-
media:
|
|
123
|
+
media: true,
|
|
49
124
|
reactions: false,
|
|
50
125
|
threads: false,
|
|
51
126
|
},
|
|
@@ -104,6 +179,19 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
104
179
|
channelId = groupPart;
|
|
105
180
|
}
|
|
106
181
|
channelType = ChannelType.Group;
|
|
182
|
+
|
|
183
|
+
// Parse @mentions from message content (e.g., "@chenpipi_bot", "@陈皮皮")
|
|
184
|
+
// Uses shared utility for consistent regex across inbound/outbound (fixes #31)
|
|
185
|
+
const contentMentionNames = parseMentions(content);
|
|
186
|
+
for (const name of contentMentionNames) {
|
|
187
|
+
if (name && !mentionUids.includes(name)) {
|
|
188
|
+
mentionUids.push(name);
|
|
189
|
+
console.log(`[dmwork] parsed @mention from content: ${name}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (mentionUids.length > 0) {
|
|
193
|
+
console.log(`[dmwork] sending message with mentionUids: ${mentionUids.join(", ")}`);
|
|
194
|
+
}
|
|
107
195
|
}
|
|
108
196
|
|
|
109
197
|
await sendMessage({
|
|
@@ -142,6 +230,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
142
230
|
},
|
|
143
231
|
gateway: {
|
|
144
232
|
startAccount: async (ctx) => {
|
|
233
|
+
// Ensure cleanup timer is running (singleton pattern for hot reload safety)
|
|
234
|
+
ensureCleanupTimer();
|
|
235
|
+
|
|
145
236
|
const account = ctx.account;
|
|
146
237
|
if (!account.configured || !account.config.botToken) {
|
|
147
238
|
throw new Error(
|
|
@@ -199,17 +290,40 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
199
290
|
sendHeartbeat({
|
|
200
291
|
apiUrl: account.config.apiUrl,
|
|
201
292
|
botToken: account.config.botToken!,
|
|
293
|
+
}).then(() => {
|
|
294
|
+
consecutiveHeartbeatFailures = 0; // Reset on success
|
|
202
295
|
}).catch((err) => {
|
|
203
|
-
|
|
296
|
+
consecutiveHeartbeatFailures++;
|
|
297
|
+
log?.error?.(`dmwork: heartbeat failed (${consecutiveHeartbeatFailures}/${MAX_HEARTBEAT_FAILURES}): ${String(err)}`);
|
|
298
|
+
if (consecutiveHeartbeatFailures >= MAX_HEARTBEAT_FAILURES && !stopped) {
|
|
299
|
+
log?.warn?.("dmwork: too many heartbeat failures, triggering reconnect...");
|
|
300
|
+
consecutiveHeartbeatFailures = 0;
|
|
301
|
+
socket.disconnect();
|
|
302
|
+
socket.connect();
|
|
303
|
+
}
|
|
204
304
|
});
|
|
205
305
|
}, account.config.heartbeatIntervalMs);
|
|
206
306
|
};
|
|
207
307
|
|
|
208
308
|
// 4. Group history map — persists across auto-restarts (module-level)
|
|
209
309
|
const groupHistories = getOrCreateHistoryMap(account.accountId);
|
|
310
|
+
|
|
311
|
+
// 4b. Member name->uid map — for resolving @mentions in replies
|
|
312
|
+
const memberMap = getOrCreateMemberMap(account.accountId);
|
|
313
|
+
|
|
314
|
+
// 4c. Reverse map uid->name — for showing display names in replies
|
|
315
|
+
const uidToNameMap = getOrCreateUidToNameMap(account.accountId);
|
|
316
|
+
|
|
317
|
+
// 4d. Group cache timestamps — track when each group's members were last fetched
|
|
318
|
+
const groupCacheTimestamps = getOrCreateGroupCacheTimestamps(account.accountId);
|
|
210
319
|
|
|
211
320
|
// 5. Token refresh state — detect stale cached token
|
|
212
321
|
let hasRefreshedToken = false;
|
|
322
|
+
let isRefreshingToken = false; // Guard against concurrent refreshes (#43)
|
|
323
|
+
|
|
324
|
+
// 5b. Heartbeat failure tracking — reconnect after consecutive failures (#42)
|
|
325
|
+
let consecutiveHeartbeatFailures = 0;
|
|
326
|
+
const MAX_HEARTBEAT_FAILURES = 3;
|
|
213
327
|
|
|
214
328
|
// 6. Connect WebSocket — pure real-time via WuKongIM SDK
|
|
215
329
|
const socket = new WKSocket({
|
|
@@ -220,18 +334,25 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
220
334
|
onMessage: (msg: BotMessage) => {
|
|
221
335
|
// Skip self messages
|
|
222
336
|
if (msg.from_uid === credentials.robot_id) return;
|
|
223
|
-
// Skip
|
|
224
|
-
|
|
337
|
+
// Skip unsupported message types (Location, Card)
|
|
338
|
+
const supportedTypes = [MessageType.Text, MessageType.Image, MessageType.GIF, MessageType.Voice, MessageType.Video, MessageType.File];
|
|
339
|
+
if (!msg.payload || !supportedTypes.includes(msg.payload.type)) return;
|
|
225
340
|
|
|
226
341
|
log?.info?.(
|
|
227
342
|
`dmwork: recv message from=${msg.from_uid} channel=${msg.channel_id ?? "DM"} type=${msg.channel_type ?? 1}`,
|
|
228
343
|
);
|
|
229
344
|
|
|
345
|
+
// Track cache activity for cleanup
|
|
346
|
+
if (msg.channel_id) touchCache(account.accountId, msg.channel_id);
|
|
347
|
+
|
|
230
348
|
handleInboundMessage({
|
|
231
349
|
account,
|
|
232
350
|
message: msg,
|
|
233
351
|
botUid: credentials.robot_id,
|
|
234
352
|
groupHistories,
|
|
353
|
+
memberMap,
|
|
354
|
+
uidToNameMap,
|
|
355
|
+
groupCacheTimestamps,
|
|
235
356
|
log,
|
|
236
357
|
statusSink,
|
|
237
358
|
}).catch((err) => {
|
|
@@ -256,8 +377,10 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
256
377
|
statusSink({ lastError: err.message });
|
|
257
378
|
|
|
258
379
|
// If kicked or connect failed, try refreshing the IM token once
|
|
259
|
-
|
|
380
|
+
// Use isRefreshingToken to prevent concurrent refresh attempts (#43)
|
|
381
|
+
if (!hasRefreshedToken && !isRefreshingToken && !stopped &&
|
|
260
382
|
(err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
|
|
383
|
+
isRefreshingToken = true;
|
|
261
384
|
hasRefreshedToken = true;
|
|
262
385
|
log?.warn?.("dmwork: connection rejected — refreshing IM token...");
|
|
263
386
|
try {
|
|
@@ -273,6 +396,9 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
|
|
|
273
396
|
socket.connect();
|
|
274
397
|
} catch (refreshErr) {
|
|
275
398
|
log?.error?.(`dmwork: token refresh failed: ${String(refreshErr)}`);
|
|
399
|
+
hasRefreshedToken = false; // Allow retry on next error (#43)
|
|
400
|
+
} finally {
|
|
401
|
+
isRefreshingToken = false;
|
|
276
402
|
}
|
|
277
403
|
}
|
|
278
404
|
},
|