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 +58 -30
- package/package.json +9 -21
- package/src/api-fetch.ts +7 -1
- package/src/channel.ts +31 -15
- package/src/inbound.ts +3 -3
- package/src/socket.ts +6 -0
- package/src/types.ts +14 -5
package/README.md
CHANGED
|
@@ -1,44 +1,72 @@
|
|
|
1
1
|
# openclaw-channel-dmwork
|
|
2
2
|
|
|
3
|
-
DMWork channel plugin for
|
|
3
|
+
DMWork channel plugin for OpenClaw. Connects via WuKongIM WebSocket for real-time messaging.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Repository: https://github.com/yujiawei/dmwork-adapters
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
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
|
-
##
|
|
70
|
+
## Disconnect
|
|
43
71
|
|
|
44
|
-
|
|
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.
|
|
4
|
-
"description": "DMWork
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
71
|
+
historyMap: groupHistories,
|
|
72
|
+
historyKey: sessionId,
|
|
73
73
|
entry: {
|
|
74
74
|
sender: message.from_uid,
|
|
75
|
-
body:
|
|
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
|
|