openclaw-channel-dmwork 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -1,44 +1,72 @@
1
1
  # openclaw-channel-dmwork
2
2
 
3
- DMWork channel plugin for [OpenClaw](https://openclaw.ai) connecting AI agents to DMWork (WuKongIM) instant messaging.
3
+ DMWork channel plugin for OpenClaw. Connects via WuKongIM WebSocket for real-time messaging.
4
4
 
5
- ## Features
5
+ Repository: https://github.com/yujiawei/dmwork-adapters
6
6
 
7
- - WebSocket connection via WuKongIM protocol
8
- - Private chat: AI responds to all messages
9
- - Group chat: mention gating (`@bot` required, configurable)
10
- - History context: unmentioned messages are recorded and prepended when bot is mentioned
11
- - Multi-bot support: each bot token = one OpenClaw instance
7
+ ## Prerequisites
12
8
 
13
- ## Install
9
+ - Node.js >= 18
10
+ - OpenClaw installed and configured
11
+ - A bot created via BotFather in DMWork (send `/newbot` to BotFather)
12
+
13
+ ## Install as OpenClaw Extension
14
14
 
15
15
  ```bash
16
- openclaw plugins install openclaw-channel-dmwork
16
+ git clone https://github.com/yujiawei/dmwork-adapters.git
17
+ cp -r dmwork-adapters/openclaw-channel-dmwork ~/.openclaw/extensions/dmwork
18
+ cd ~/.openclaw/extensions/dmwork && npm install
17
19
  ```
18
20
 
19
- ## Configuration
20
-
21
- In your `openclaw.json`:
22
-
23
- ```json
24
- {
25
- "dmwork": {
26
- "accounts": [{
27
- "apiUrl": "http://localhost:8090",
28
- "wsUrl": "ws://localhost:5200",
29
- "botToken": "bf_your_bot_token_here",
30
- "requireMention": true
31
- }]
32
- }
33
- }
21
+ ## Configure
22
+
23
+ Add to your `~/.openclaw/config.yaml`:
24
+
25
+ ```yaml
26
+ channels:
27
+ dmwork:
28
+ botToken: "bf_your_token_here" # Bot token from BotFather
29
+ apiUrl: "http://your-server:8090" # DMWork server API URL
30
+ # wsUrl: "ws://your-server:5200" # Optional — auto-detected from register if omitted
31
+ ```
32
+
33
+ Configuration fields:
34
+
35
+ - `botToken` (required): Bot token from BotFather (`bf_` prefix)
36
+ - `apiUrl` (required): DMWork server API URL, e.g. `http://192.168.1.100:8090`
37
+ - `wsUrl` (optional): WuKongIM WebSocket URL. Auto-detected from register if omitted.
38
+
39
+ ## Run
40
+
41
+ ```bash
42
+ openclaw gateway restart
34
43
  ```
35
44
 
36
- ## Requirements
45
+ The plugin is loaded automatically by OpenClaw when the gateway starts.
46
+
47
+ ## What it does
48
+
49
+ 1. Registers the bot with the DMWork server via REST API
50
+ 2. Connects to WuKongIM WebSocket for real-time message receiving
51
+ 3. Auto-reconnects on disconnection
52
+ 4. Sends a greeting to the bot owner on connect
53
+ 5. Dispatches incoming messages to OpenClaw's message handler
54
+ 6. Supports streaming responses (start/send/end), typing indicators, and read receipts
55
+
56
+ ## As an OpenClaw Plugin
57
+
58
+ The `index.ts` exports a standard OpenClaw plugin object. When loaded by OpenClaw:
59
+
60
+ - `register(api)` is called automatically
61
+ - `api.runtime` is injected for logging and lifecycle management
62
+ - `api.registerChannel()` registers the DMWork channel plugin
63
+ - Configuration is read from `channels.dmwork` in OpenClaw's config
37
64
 
38
- - DMWork server with BotFather enabled
39
- - Bot token from BotFather (`/newbot` command)
40
- - OpenClaw >= 2026.2.0
65
+ The plugin uses the `ChannelPlugin` SDK interface with support for:
66
+ - Direct messages and group chats
67
+ - Multi-account configuration via `channels.dmwork.accounts`
68
+ - Config hot-reload on `channels.dmwork` prefix changes
41
69
 
42
- ## License
70
+ ## Disconnect
43
71
 
44
- MIT
72
+ To disconnect the bot, send `/disconnect` to BotFather in DMWork. This invalidates the current IM token and kicks the WebSocket connection.
package/package.json CHANGED
@@ -1,30 +1,14 @@
1
1
  {
2
2
  "name": "openclaw-channel-dmwork",
3
- "version": "0.1.0",
4
- "description": "DMWork (WuKongIM) channel plugin for OpenClaw AI Agent 时代的 IM",
3
+ "version": "0.2.0",
4
+ "description": "DMWork channel plugin for OpenClaw via WuKongIM WebSocket",
5
5
  "main": "index.ts",
6
6
  "type": "module",
7
7
  "files": [
8
8
  "index.ts",
9
9
  "src",
10
- "openclaw.plugin.json",
11
- "README.md"
10
+ "openclaw.plugin.json"
12
11
  ],
13
- "keywords": [
14
- "openclaw",
15
- "openclaw-plugin",
16
- "openclaw-channel",
17
- "dmwork",
18
- "wukongim",
19
- "im",
20
- "bot",
21
- "ai-agent"
22
- ],
23
- "repository": {
24
- "type": "git",
25
- "url": "https://github.com/yujiawei/dmwork-adapters.git"
26
- },
27
- "license": "MIT",
28
12
  "scripts": {
29
13
  "build": "tsc",
30
14
  "type-check": "tsc --noEmit"
@@ -41,7 +25,7 @@
41
25
  "devDependencies": {
42
26
  "@types/ws": "^8.5.10",
43
27
  "openclaw": "2026.2.12",
44
- "typescript": "^5.4.0"
28
+ "typescript": "^5.9.3"
45
29
  },
46
30
  "openclaw": {
47
31
  "extensions": [
@@ -52,8 +36,12 @@
52
36
  "label": "DMWork",
53
37
  "selectionLabel": "DMWork (WuKongIM)",
54
38
  "docsLabel": "dmwork",
55
- "blurb": "WuKongIM gateway for DMWork — AI Agent 时代的 IM",
39
+ "blurb": "WuKongIM gateway for DMWork",
56
40
  "order": 90
41
+ },
42
+ "install": {
43
+ "localPath": "extensions/dmwork",
44
+ "defaultChoice": "local"
57
45
  }
58
46
  }
59
47
  }
package/src/api-fetch.ts CHANGED
@@ -90,9 +90,12 @@ export async function sendHeartbeat(params: {
90
90
  await postJson(params.apiUrl, params.botToken, "/v1/bot/heartbeat", {}, params.signal);
91
91
  }
92
92
 
93
+
94
+
93
95
  export async function registerBot(params: {
94
96
  apiUrl: string;
95
97
  botToken: string;
98
+ forceRefresh?: boolean;
96
99
  signal?: AbortSignal;
97
100
  }): Promise<{
98
101
  robot_id: string;
@@ -102,5 +105,8 @@ export async function registerBot(params: {
102
105
  owner_uid: string;
103
106
  owner_channel_id: string;
104
107
  }> {
105
- return postJson(params.apiUrl, params.botToken, "/v1/bot/register", {}, params.signal);
108
+ const path = params.forceRefresh
109
+ ? "/v1/bot/register?force_refresh=true"
110
+ : "/v1/bot/register";
111
+ return postJson(params.apiUrl, params.botToken, path, {}, params.signal);
106
112
  }
package/src/channel.ts CHANGED
@@ -125,7 +125,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
125
125
 
126
126
  log?.info?.(`[${account.accountId}] registering DMWork bot...`);
127
127
 
128
- // 1. Register bot
128
+ // 1. Register bot (first attempt uses cached token)
129
129
  let credentials: {
130
130
  robot_id: string;
131
131
  im_token: string;
@@ -163,7 +163,6 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
163
163
  let stopped = false;
164
164
 
165
165
  const startHeartbeat = () => {
166
- // Clear existing heartbeat to prevent duplicates on reconnect
167
166
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
168
167
  heartbeatTimer = setInterval(() => {
169
168
  if (stopped) return;
@@ -179,7 +178,10 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
179
178
  // 4. Group history map for mention gating context
180
179
  const groupHistories = new Map<string, HistoryEntry[]>();
181
180
 
182
- // 5. Connect WebSocket
181
+ // 5. Token refresh state — detect stale cached token
182
+ let hasRefreshedToken = false;
183
+
184
+ // 6. Connect WebSocket — pure real-time via WuKongIM SDK
183
185
  const socket = new WKSocket({
184
186
  wsUrl,
185
187
  uid: credentials.robot_id,
@@ -211,8 +213,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
211
213
  log?.info?.(`dmwork: WebSocket connected to ${wsUrl}`);
212
214
  statusSink({ lastError: null });
213
215
  startHeartbeat();
214
-
215
- // No greeting on connect — bot stays silent until user sends a message
216
+ // WS connected successfully = WuKongIM accepted the token
216
217
  },
217
218
 
218
219
  onDisconnected: () => {
@@ -220,9 +221,30 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
220
221
  statusSink({ lastError: "disconnected" });
221
222
  },
222
223
 
223
- onError: (err: Error) => {
224
+ onError: async (err: Error) => {
224
225
  log?.error?.(`dmwork: WebSocket error: ${err.message}`);
225
226
  statusSink({ lastError: err.message });
227
+
228
+ // If kicked or connect failed, try refreshing the IM token once
229
+ if (!hasRefreshedToken && !stopped &&
230
+ (err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
231
+ hasRefreshedToken = true;
232
+ log?.warn?.("dmwork: connection rejected — refreshing IM token...");
233
+ try {
234
+ const fresh = await registerBot({
235
+ apiUrl: account.config.apiUrl,
236
+ botToken: account.config.botToken!,
237
+ forceRefresh: true,
238
+ });
239
+ credentials = fresh;
240
+ log?.info?.("dmwork: got fresh IM token, reconnecting WS...");
241
+ socket.disconnect();
242
+ socket.updateCredentials(fresh.robot_id, fresh.im_token);
243
+ socket.connect();
244
+ } catch (refreshErr) {
245
+ log?.error?.(`dmwork: token refresh failed: ${String(refreshErr)}`);
246
+ }
247
+ }
226
248
  },
227
249
  });
228
250
 
@@ -232,10 +254,7 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
232
254
  const onAbort = () => {
233
255
  stopped = true;
234
256
  socket.disconnect();
235
- if (heartbeatTimer) {
236
- clearInterval(heartbeatTimer);
237
- heartbeatTimer = null;
238
- }
257
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
239
258
  };
240
259
 
241
260
  if (ctx.abortSignal.aborted) {
@@ -248,11 +267,8 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
248
267
  stop: () => {
249
268
  stopped = true;
250
269
  socket.disconnect();
251
- if (heartbeatTimer) {
252
- clearInterval(heartbeatTimer);
253
- heartbeatTimer = null;
254
- }
255
- ctx.abortSignal.removeEventListener("abort", onAbort);
270
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
271
+ ctx.abortSignal.removeEventListener("abort", onAbort);
256
272
  ctx.setStatus({
257
273
  accountId: account.accountId,
258
274
  running: false,
package/src/inbound.ts CHANGED
@@ -68,11 +68,11 @@ export async function handleInboundMessage(params: {
68
68
  if (!isMentioned) {
69
69
  // Record as pending history for future context
70
70
  recordPendingHistoryEntryIfEnabled({
71
- channelId: "dmwork",
72
- groupId: sessionId,
71
+ historyMap: groupHistories,
72
+ historyKey: sessionId,
73
73
  entry: {
74
74
  sender: message.from_uid,
75
- body: historyPrefix + rawBody,
75
+ body: rawBody,
76
76
  timestamp: message.timestamp ? message.timestamp * 1000 : Date.now(),
77
77
  },
78
78
  limit: DEFAULT_GROUP_HISTORY_LIMIT,
package/src/socket.ts CHANGED
@@ -103,6 +103,12 @@ export class WKSocket extends EventEmitter {
103
103
  im.connect();
104
104
  }
105
105
 
106
+ /** Update credentials for reconnection (e.g. after token refresh) */
107
+ updateCredentials(uid: string, token: string): void {
108
+ this.opts.uid = uid;
109
+ this.opts.token = token;
110
+ }
111
+
106
112
  /** Gracefully disconnect */
107
113
  disconnect(): void {
108
114
  const im = WKSDK.shared();
package/src/types.ts CHANGED
@@ -35,11 +35,6 @@ export interface BotEventsReq {
35
35
  limit?: number;
36
36
  }
37
37
 
38
- export interface BotEventsResp {
39
- status: number;
40
- results: BotEvent[];
41
- }
42
-
43
38
  export interface BotEvent {
44
39
  event_id: number;
45
40
  message?: BotMessage;
@@ -55,10 +50,16 @@ export interface BotMessage {
55
50
  payload: MessagePayload;
56
51
  }
57
52
 
53
+ export interface MentionPayload {
54
+ uids?: string[];
55
+ all?: number; // 1 = @all
56
+ }
57
+
58
58
  export interface MessagePayload {
59
59
  type: MessageType;
60
60
  content?: string;
61
61
  url?: string;
62
+ mention?: MentionPayload;
62
63
  [key: string]: unknown;
63
64
  }
64
65
 
@@ -102,9 +103,17 @@ export enum MessageType {
102
103
  }
103
104
 
104
105
  /** Plugin config */
106
+ export interface DMWorkGroupConfig {
107
+ requireMention?: boolean;
108
+ enabled?: boolean;
109
+ }
110
+
105
111
  export interface DMWorkConfig {
106
112
  botToken: string;
107
113
  apiUrl: string;
108
114
  wsUrl?: string;
115
+ groupPolicy?: "open" | "allowlist" | "disabled";
116
+ requireMention?: boolean;
117
+ groups?: Record<string, DMWorkGroupConfig>;
109
118
  }
110
119