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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "dmwork",
2
+ "id": "openclaw-channel-dmwork",
3
3
  "channels": [
4
4
  "dmwork"
5
5
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.2.21",
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 {} as T;
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
- return postJson(params.apiUrl, params.botToken, path, {}, params.signal);
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
- // Fallback: return empty if API not available
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: false,
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
- log?.error?.(`dmwork: heartbeat failed: ${String(err)}`);
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 non-text for now
224
- if (!msg.payload || msg.payload.type !== MessageType.Text) return;
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
- if (!hasRefreshedToken && !stopped &&
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
  },