openclaw-channel-dmwork 0.1.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 ADDED
@@ -0,0 +1,44 @@
1
+ # openclaw-channel-dmwork
2
+
3
+ DMWork channel plugin for [OpenClaw](https://openclaw.ai) — connecting AI agents to DMWork (WuKongIM) instant messaging.
4
+
5
+ ## Features
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
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ openclaw plugins install openclaw-channel-dmwork
17
+ ```
18
+
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
+ }
34
+ ```
35
+
36
+ ## Requirements
37
+
38
+ - DMWork server with BotFather enabled
39
+ - Bot token from BotFather (`/newbot` command)
40
+ - OpenClaw >= 2026.2.0
41
+
42
+ ## License
43
+
44
+ MIT
package/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * openclaw-channel-dmwork
3
+ *
4
+ * OpenClaw channel plugin for DMWork messaging platform.
5
+ * Connects via WuKongIM WebSocket for real-time messaging.
6
+ */
7
+
8
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
+ import { dmworkPlugin } from "./src/channel.js";
10
+ import { setDmworkRuntime } from "./src/runtime.js";
11
+
12
+ const plugin: {
13
+ id: string;
14
+ name: string;
15
+ description: string;
16
+ register: (api: OpenClawPluginApi) => void;
17
+ } = {
18
+ id: "dmwork",
19
+ name: "DMWork",
20
+ description: "OpenClaw DMWork channel plugin via WuKongIM WebSocket",
21
+ register(api) {
22
+ setDmworkRuntime(api.runtime);
23
+ api.registerChannel({ plugin: dmworkPlugin });
24
+ },
25
+ };
26
+
27
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "dmwork",
3
+ "channels": ["dmwork"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "openclaw-channel-dmwork",
3
+ "version": "0.1.0",
4
+ "description": "DMWork (WuKongIM) channel plugin for OpenClaw — AI Agent 时代的 IM",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "files": [
8
+ "index.ts",
9
+ "src",
10
+ "openclaw.plugin.json",
11
+ "README.md"
12
+ ],
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
+ "scripts": {
29
+ "build": "tsc",
30
+ "type-check": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "axios": "^1.7.0",
34
+ "ws": "^8.16.0",
35
+ "wukongimjssdk": "^1.3.4",
36
+ "zod": "^4.0.0"
37
+ },
38
+ "peerDependencies": {
39
+ "openclaw": ">=2026.2.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/ws": "^8.5.10",
43
+ "openclaw": "2026.2.12",
44
+ "typescript": "^5.4.0"
45
+ },
46
+ "openclaw": {
47
+ "extensions": [
48
+ "./index.ts"
49
+ ],
50
+ "channel": {
51
+ "id": "dmwork",
52
+ "label": "DMWork",
53
+ "selectionLabel": "DMWork (WuKongIM)",
54
+ "docsLabel": "dmwork",
55
+ "blurb": "WuKongIM gateway for DMWork — AI Agent 时代的 IM",
56
+ "order": 90
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,78 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { DmworkConfig } from "./config-schema.js";
4
+
5
+ export type DmworkAccountConfig = DmworkConfig & {
6
+ accounts?: Record<string, DmworkConfig | undefined>;
7
+ };
8
+
9
+ export type ResolvedDmworkAccount = {
10
+ accountId: string;
11
+ name?: string;
12
+ enabled: boolean;
13
+ configured: boolean;
14
+ config: {
15
+ botToken?: string;
16
+ apiUrl: string;
17
+ wsUrl?: string;
18
+ pollIntervalMs: number;
19
+ heartbeatIntervalMs: number;
20
+ requireMention?: boolean;
21
+ };
22
+ };
23
+
24
+ const DEFAULT_API_URL = "http://localhost:8090";
25
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
26
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
27
+
28
+ export function listDmworkAccountIds(cfg: OpenClawConfig): string[] {
29
+ const channel = (cfg.channels?.dmwork ?? {}) as DmworkAccountConfig;
30
+ const accountIds = Object.keys(channel.accounts ?? {});
31
+ if (accountIds.length > 0) {
32
+ return accountIds;
33
+ }
34
+ return [DEFAULT_ACCOUNT_ID];
35
+ }
36
+
37
+ export function resolveDefaultDmworkAccountId(_cfg: OpenClawConfig): string {
38
+ return DEFAULT_ACCOUNT_ID;
39
+ }
40
+
41
+ export function resolveDmworkAccount(params: {
42
+ cfg: OpenClawConfig;
43
+ accountId?: string | null;
44
+ }): ResolvedDmworkAccount {
45
+ const accountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
46
+ const channel = (params.cfg.channels?.dmwork ?? {}) as DmworkAccountConfig;
47
+ const accountConfig = channel.accounts?.[accountId] ?? channel;
48
+
49
+ const botToken = accountConfig.botToken ?? channel.botToken;
50
+ const apiUrl = accountConfig.apiUrl ?? channel.apiUrl ?? DEFAULT_API_URL;
51
+ const wsUrl = accountConfig.wsUrl ?? channel.wsUrl;
52
+ const pollIntervalMs =
53
+ accountConfig.pollIntervalMs ??
54
+ channel.pollIntervalMs ??
55
+ DEFAULT_POLL_INTERVAL_MS;
56
+ const heartbeatIntervalMs =
57
+ accountConfig.heartbeatIntervalMs ??
58
+ channel.heartbeatIntervalMs ??
59
+ DEFAULT_HEARTBEAT_INTERVAL_MS;
60
+
61
+ const enabled = accountConfig.enabled ?? channel.enabled ?? true;
62
+ const configured = Boolean(botToken?.trim());
63
+
64
+ return {
65
+ accountId,
66
+ name: accountConfig.name ?? channel.name,
67
+ enabled,
68
+ configured,
69
+ config: {
70
+ botToken,
71
+ apiUrl,
72
+ wsUrl,
73
+ pollIntervalMs,
74
+ heartbeatIntervalMs,
75
+ requireMention: accountConfig.requireMention ?? channel.requireMention,
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Lightweight fetch-based API helpers for use inside OpenClaw plugin context.
3
+ * These are used by inbound/outbound where the full DMWorkAPI class is not available.
4
+ */
5
+
6
+ import { ChannelType, MessageType } from "./types.js";
7
+
8
+ const DEFAULT_HEADERS = {
9
+ "Content-Type": "application/json",
10
+ };
11
+
12
+ async function postJson<T>(
13
+ apiUrl: string,
14
+ botToken: string,
15
+ path: string,
16
+ payload: Record<string, unknown>,
17
+ signal?: AbortSignal,
18
+ ): Promise<T> {
19
+ const url = `${apiUrl.replace(/\/+$/, "")}${path}`;
20
+ const response = await fetch(url, {
21
+ method: "POST",
22
+ headers: {
23
+ ...DEFAULT_HEADERS,
24
+ Authorization: `Bearer ${botToken}`,
25
+ },
26
+ body: JSON.stringify(payload),
27
+ signal,
28
+ });
29
+
30
+ if (!response.ok) {
31
+ const text = await response.text().catch(() => "");
32
+ throw new Error(`DMWork API ${path} failed (${response.status}): ${text || response.statusText}`);
33
+ }
34
+
35
+ const text = await response.text();
36
+ if (!text) return {} as T;
37
+ return JSON.parse(text) as T;
38
+ }
39
+
40
+ export async function sendMessage(params: {
41
+ apiUrl: string;
42
+ botToken: string;
43
+ channelId: string;
44
+ channelType: ChannelType;
45
+ content: string;
46
+ streamNo?: string;
47
+ signal?: AbortSignal;
48
+ }): Promise<void> {
49
+ await postJson(params.apiUrl, params.botToken, "/v1/bot/sendMessage", {
50
+ channel_id: params.channelId,
51
+ channel_type: params.channelType,
52
+ ...(params.streamNo ? { stream_no: params.streamNo } : {}),
53
+ payload: { type: MessageType.Text, content: params.content },
54
+ }, params.signal);
55
+ }
56
+
57
+ export async function sendTyping(params: {
58
+ apiUrl: string;
59
+ botToken: string;
60
+ channelId: string;
61
+ channelType: ChannelType;
62
+ signal?: AbortSignal;
63
+ }): Promise<void> {
64
+ await postJson(params.apiUrl, params.botToken, "/v1/bot/typing", {
65
+ channel_id: params.channelId,
66
+ channel_type: params.channelType,
67
+ }, params.signal);
68
+ }
69
+
70
+ export async function sendReadReceipt(params: {
71
+ apiUrl: string;
72
+ botToken: string;
73
+ channelId: string;
74
+ channelType: ChannelType;
75
+ messageIds?: string[];
76
+ signal?: AbortSignal;
77
+ }): Promise<void> {
78
+ await postJson(params.apiUrl, params.botToken, "/v1/bot/readReceipt", {
79
+ channel_id: params.channelId,
80
+ channel_type: params.channelType,
81
+ ...(params.messageIds && params.messageIds.length > 0 ? { message_ids: params.messageIds } : {}),
82
+ }, params.signal);
83
+ }
84
+
85
+ export async function sendHeartbeat(params: {
86
+ apiUrl: string;
87
+ botToken: string;
88
+ signal?: AbortSignal;
89
+ }): Promise<void> {
90
+ await postJson(params.apiUrl, params.botToken, "/v1/bot/heartbeat", {}, params.signal);
91
+ }
92
+
93
+ export async function registerBot(params: {
94
+ apiUrl: string;
95
+ botToken: string;
96
+ signal?: AbortSignal;
97
+ }): Promise<{
98
+ robot_id: string;
99
+ im_token: string;
100
+ ws_url: string;
101
+ api_url: string;
102
+ owner_uid: string;
103
+ owner_channel_id: string;
104
+ }> {
105
+ return postJson(params.apiUrl, params.botToken, "/v1/bot/register", {}, params.signal);
106
+ }
package/src/api.ts ADDED
@@ -0,0 +1,96 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+ import type {
3
+ BotRegisterReq,
4
+ BotRegisterResp,
5
+ BotSendMessageReq,
6
+ BotTypingReq,
7
+ BotReadReceiptReq,
8
+ BotEventsReq,
9
+ BotEventsResp,
10
+ BotStreamStartReq,
11
+ BotStreamStartResp,
12
+ BotStreamEndReq,
13
+ SendMessageResult,
14
+ DMWorkConfig,
15
+ } from "./types.js";
16
+
17
+ /**
18
+ * DMWork Bot REST API client.
19
+ */
20
+ export class DMWorkAPI {
21
+ private client: AxiosInstance;
22
+
23
+ constructor(
24
+ private config: DMWorkConfig,
25
+ ) {
26
+ this.client = axios.create({
27
+ baseURL: config.apiUrl,
28
+ headers: {
29
+ Authorization: `Bearer ${config.botToken}`,
30
+ "Content-Type": "application/json",
31
+ },
32
+ timeout: 30_000,
33
+ });
34
+ }
35
+
36
+ /** Register bot and obtain credentials */
37
+ async register(req?: BotRegisterReq): Promise<BotRegisterResp> {
38
+ const { data } = await this.client.post<BotRegisterResp>(
39
+ "/v1/bot/register",
40
+ req ?? {},
41
+ );
42
+ return data;
43
+ }
44
+
45
+ /** Send a message */
46
+ async sendMessage(req: BotSendMessageReq): Promise<SendMessageResult> {
47
+ const { data } = await this.client.post<SendMessageResult>(
48
+ "/v1/bot/sendMessage",
49
+ req,
50
+ );
51
+ return data;
52
+ }
53
+
54
+ /** Send typing indicator */
55
+ async sendTyping(req: BotTypingReq): Promise<void> {
56
+ await this.client.post("/v1/bot/typing", req);
57
+ }
58
+
59
+ /** Send read receipt */
60
+ async sendReadReceipt(req: BotReadReceiptReq): Promise<void> {
61
+ await this.client.post("/v1/bot/readReceipt", req);
62
+ }
63
+
64
+ /** Poll for new events (REST mode) */
65
+ async getEvents(req: BotEventsReq): Promise<BotEventsResp> {
66
+ const { data } = await this.client.post<BotEventsResp>(
67
+ "/v1/bot/events",
68
+ req,
69
+ );
70
+ return data;
71
+ }
72
+
73
+ /** Acknowledge an event */
74
+ async ackEvent(eventId: number): Promise<void> {
75
+ await this.client.post(`/v1/bot/events/${eventId}/ack`);
76
+ }
77
+
78
+ /** Start a streaming message */
79
+ async streamStart(req: BotStreamStartReq): Promise<BotStreamStartResp> {
80
+ const { data } = await this.client.post<BotStreamStartResp>(
81
+ "/v1/bot/stream/start",
82
+ req,
83
+ );
84
+ return data;
85
+ }
86
+
87
+ /** End a streaming message */
88
+ async streamEnd(req: BotStreamEndReq): Promise<void> {
89
+ await this.client.post("/v1/bot/stream/end", req);
90
+ }
91
+
92
+ /** Send heartbeat (REST mode keep-alive) */
93
+ async heartbeat(): Promise<void> {
94
+ await this.client.post("/v1/bot/heartbeat");
95
+ }
96
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,265 @@
1
+ import {
2
+ buildChannelConfigSchema,
3
+ DEFAULT_ACCOUNT_ID,
4
+ type ChannelOutboundContext,
5
+ type ChannelPlugin,
6
+ } from "openclaw/plugin-sdk";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import { DmworkConfigSchema } from "./config-schema.js";
9
+ import {
10
+ listDmworkAccountIds,
11
+ resolveDefaultDmworkAccountId,
12
+ resolveDmworkAccount,
13
+ type ResolvedDmworkAccount,
14
+ } from "./accounts.js";
15
+ import { registerBot, sendMessage, sendHeartbeat } from "./api-fetch.js";
16
+ import { WKSocket } from "./socket.js";
17
+ import { handleInboundMessage, type DmworkStatusSink } from "./inbound.js";
18
+ import { ChannelType, MessageType, type BotMessage, type MessagePayload } from "./types.js";
19
+ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk";
20
+
21
+ const meta = {
22
+ id: "dmwork",
23
+ label: "DMWork",
24
+ selectionLabel: "DMWork (WuKongIM)",
25
+ docsPath: "/channels/dmwork",
26
+ docsLabel: "dmwork",
27
+ blurb: "WuKongIM gateway for DMWork",
28
+ order: 90,
29
+ };
30
+
31
+ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
32
+ id: "dmwork",
33
+ meta,
34
+ capabilities: {
35
+ chatTypes: ["direct", "group"],
36
+ media: false,
37
+ reactions: false,
38
+ threads: false,
39
+ },
40
+ reload: { configPrefixes: ["channels.dmwork"] },
41
+ configSchema: buildChannelConfigSchema(DmworkConfigSchema),
42
+ config: {
43
+ listAccountIds: (cfg) => listDmworkAccountIds(cfg),
44
+ resolveAccount: (cfg, accountId) => resolveDmworkAccount({ cfg, accountId }),
45
+ defaultAccountId: (cfg) => resolveDefaultDmworkAccountId(cfg),
46
+ isEnabled: (account) => account.enabled,
47
+ isConfigured: (account) => account.configured,
48
+ describeAccount: (account) => ({
49
+ accountId: account.accountId,
50
+ name: account.name,
51
+ enabled: account.enabled,
52
+ configured: account.configured,
53
+ apiUrl: account.config.apiUrl,
54
+ botToken: account.config.botToken ? "[set]" : "[missing]",
55
+ wsUrl: account.config.wsUrl ?? "[auto-detect]",
56
+ }),
57
+ },
58
+ messaging: {
59
+ normalizeTarget: (target) => target.trim(),
60
+ targetResolver: {
61
+ looksLikeId: (input) => Boolean(input.trim()),
62
+ hint: "<userId or channelId>",
63
+ },
64
+ },
65
+ outbound: {
66
+ deliveryMode: "direct",
67
+ sendText: async (ctx) => {
68
+ const account = resolveDmworkAccount({
69
+ cfg: ctx.cfg as OpenClawConfig,
70
+ accountId: ctx.accountId ?? DEFAULT_ACCOUNT_ID,
71
+ });
72
+ if (!account.config.botToken) {
73
+ throw new Error("DMWork botToken is not configured");
74
+ }
75
+ const content = ctx.text?.trim();
76
+ if (!content) {
77
+ return { channel: "dmwork", to: ctx.to, messageId: "" };
78
+ }
79
+
80
+ await sendMessage({
81
+ apiUrl: account.config.apiUrl,
82
+ botToken: account.config.botToken,
83
+ channelId: ctx.to,
84
+ channelType: ChannelType.DM,
85
+ content,
86
+ });
87
+
88
+ return { channel: "dmwork", to: ctx.to, messageId: "" };
89
+ },
90
+ },
91
+ status: {
92
+ defaultRuntime: {
93
+ accountId: DEFAULT_ACCOUNT_ID,
94
+ running: false,
95
+ lastStartAt: null,
96
+ lastStopAt: null,
97
+ lastError: null,
98
+ },
99
+ buildAccountSnapshot: ({ account, runtime }) => ({
100
+ accountId: account.accountId,
101
+ name: account.name,
102
+ enabled: account.enabled,
103
+ configured: account.configured,
104
+ apiUrl: account.config.apiUrl,
105
+ running: runtime?.running ?? false,
106
+ lastStartAt: runtime?.lastStartAt ?? null,
107
+ lastStopAt: runtime?.lastStopAt ?? null,
108
+ lastError: runtime?.lastError ?? null,
109
+ lastInboundAt: runtime?.lastInboundAt ?? null,
110
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
111
+ }),
112
+ },
113
+ gateway: {
114
+ startAccount: async (ctx) => {
115
+ const account = ctx.account;
116
+ if (!account.configured || !account.config.botToken) {
117
+ throw new Error(
118
+ `DMWork not configured for account "${account.accountId}" (missing botToken)`,
119
+ );
120
+ }
121
+
122
+ const log = ctx.log;
123
+ const statusSink: DmworkStatusSink = (patch) =>
124
+ ctx.setStatus({ accountId: account.accountId, ...patch });
125
+
126
+ log?.info?.(`[${account.accountId}] registering DMWork bot...`);
127
+
128
+ // 1. Register bot
129
+ let credentials: {
130
+ robot_id: string;
131
+ im_token: string;
132
+ ws_url: string;
133
+ owner_uid: string;
134
+ };
135
+ try {
136
+ credentials = await registerBot({
137
+ apiUrl: account.config.apiUrl,
138
+ botToken: account.config.botToken,
139
+ });
140
+ } catch (err) {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ log?.error?.(`dmwork: bot registration failed: ${message}`);
143
+ statusSink({ lastError: message });
144
+ throw err;
145
+ }
146
+
147
+ log?.info?.(
148
+ `[${account.accountId}] bot registered as ${credentials.robot_id}`,
149
+ );
150
+
151
+ ctx.setStatus({
152
+ accountId: account.accountId,
153
+ running: true,
154
+ lastStartAt: Date.now(),
155
+ lastError: null,
156
+ });
157
+
158
+ // 2. Resolve WebSocket URL
159
+ const wsUrl = account.config.wsUrl || credentials.ws_url;
160
+
161
+ // 3. Start heartbeat timer
162
+ let heartbeatTimer: NodeJS.Timeout | null = null;
163
+ let stopped = false;
164
+
165
+ const startHeartbeat = () => {
166
+ // Clear existing heartbeat to prevent duplicates on reconnect
167
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
168
+ heartbeatTimer = setInterval(() => {
169
+ if (stopped) return;
170
+ sendHeartbeat({
171
+ apiUrl: account.config.apiUrl,
172
+ botToken: account.config.botToken!,
173
+ }).catch((err) => {
174
+ log?.error?.(`dmwork: heartbeat failed: ${String(err)}`);
175
+ });
176
+ }, account.config.heartbeatIntervalMs);
177
+ };
178
+
179
+ // 4. Group history map for mention gating context
180
+ const groupHistories = new Map<string, HistoryEntry[]>();
181
+
182
+ // 5. Connect WebSocket
183
+ const socket = new WKSocket({
184
+ wsUrl,
185
+ uid: credentials.robot_id,
186
+ token: credentials.im_token,
187
+
188
+ onMessage: (msg: BotMessage) => {
189
+ // Skip self messages
190
+ if (msg.from_uid === credentials.robot_id) return;
191
+ // Skip non-text for now
192
+ if (!msg.payload || msg.payload.type !== MessageType.Text) return;
193
+
194
+ log?.info?.(
195
+ `dmwork: recv message from=${msg.from_uid} channel=${msg.channel_id ?? "DM"} type=${msg.channel_type ?? 1}`,
196
+ );
197
+
198
+ handleInboundMessage({
199
+ account,
200
+ message: msg,
201
+ botUid: credentials.robot_id,
202
+ groupHistories,
203
+ log,
204
+ statusSink,
205
+ }).catch((err) => {
206
+ log?.error?.(`dmwork: inbound handler failed: ${String(err)}`);
207
+ });
208
+ },
209
+
210
+ onConnected: () => {
211
+ log?.info?.(`dmwork: WebSocket connected to ${wsUrl}`);
212
+ statusSink({ lastError: null });
213
+ startHeartbeat();
214
+
215
+ // No greeting on connect — bot stays silent until user sends a message
216
+ },
217
+
218
+ onDisconnected: () => {
219
+ log?.warn?.("dmwork: WebSocket disconnected, will reconnect...");
220
+ statusSink({ lastError: "disconnected" });
221
+ },
222
+
223
+ onError: (err: Error) => {
224
+ log?.error?.(`dmwork: WebSocket error: ${err.message}`);
225
+ statusSink({ lastError: err.message });
226
+ },
227
+ });
228
+
229
+ socket.connect();
230
+
231
+ // Handle abort signal
232
+ const onAbort = () => {
233
+ stopped = true;
234
+ socket.disconnect();
235
+ if (heartbeatTimer) {
236
+ clearInterval(heartbeatTimer);
237
+ heartbeatTimer = null;
238
+ }
239
+ };
240
+
241
+ if (ctx.abortSignal.aborted) {
242
+ onAbort();
243
+ } else {
244
+ ctx.abortSignal.addEventListener("abort", onAbort, { once: true });
245
+ }
246
+
247
+ return {
248
+ stop: () => {
249
+ stopped = true;
250
+ socket.disconnect();
251
+ if (heartbeatTimer) {
252
+ clearInterval(heartbeatTimer);
253
+ heartbeatTimer = null;
254
+ }
255
+ ctx.abortSignal.removeEventListener("abort", onAbort);
256
+ ctx.setStatus({
257
+ accountId: account.accountId,
258
+ running: false,
259
+ lastStopAt: Date.now(),
260
+ });
261
+ },
262
+ };
263
+ },
264
+ },
265
+ };
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+
3
+ const DmworkAccountSchema = z.strictObject({
4
+ name: z.string().optional(),
5
+ enabled: z.boolean().optional(),
6
+ botToken: z.string().optional(),
7
+ apiUrl: z.string().optional(),
8
+ wsUrl: z.string().optional(),
9
+ pollIntervalMs: z.number().int().min(500).optional(),
10
+ heartbeatIntervalMs: z.number().int().min(5000).optional(),
11
+ requireMention: z.boolean().optional(),
12
+ botUid: z.string().optional(),
13
+ });
14
+
15
+ export const DmworkConfigSchema = z.strictObject({
16
+ name: z.string().optional(),
17
+ enabled: z.boolean().optional(),
18
+ botToken: z.string().optional(),
19
+ apiUrl: z.string().optional(),
20
+ wsUrl: z.string().optional(),
21
+ pollIntervalMs: z.number().int().min(500).optional(),
22
+ heartbeatIntervalMs: z.number().int().min(5000).optional(),
23
+ requireMention: z.boolean().optional(),
24
+ botUid: z.string().optional(),
25
+ accounts: z.record(z.string(), DmworkAccountSchema.optional()).optional(),
26
+ });
27
+
28
+ export type DmworkConfig = z.infer<typeof DmworkConfigSchema>;
package/src/inbound.ts ADDED
@@ -0,0 +1,223 @@
1
+ import type { ChannelLogSink, OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { sendMessage, sendReadReceipt, sendTyping } from "./api-fetch.js";
3
+ import type { ResolvedDmworkAccount } from "./accounts.js";
4
+ import type { BotMessage } from "./types.js";
5
+ import { ChannelType, MessageType } from "./types.js";
6
+ import { getDmworkRuntime } from "./runtime.js";
7
+ import {
8
+ recordPendingHistoryEntryIfEnabled,
9
+ buildPendingHistoryContextFromMap,
10
+ clearHistoryEntriesIfEnabled,
11
+ DEFAULT_GROUP_HISTORY_LIMIT,
12
+ type HistoryEntry,
13
+ } from "openclaw/plugin-sdk";
14
+
15
+ export type DmworkStatusSink = (patch: {
16
+ lastInboundAt?: number;
17
+ lastOutboundAt?: number;
18
+ lastError?: string | null;
19
+ }) => void;
20
+
21
+ function resolveContent(payload: BotMessage["payload"]): string {
22
+ if (!payload) return "";
23
+ if (typeof payload.content === "string") return payload.content;
24
+ if (typeof payload.url === "string") return payload.url;
25
+ return "";
26
+ }
27
+
28
+ export async function handleInboundMessage(params: {
29
+ account: ResolvedDmworkAccount;
30
+ message: BotMessage;
31
+ botUid: string;
32
+ groupHistories: Map<string, HistoryEntry[]>;
33
+ log?: ChannelLogSink;
34
+ statusSink?: DmworkStatusSink;
35
+ }) {
36
+ const { account, message, botUid, groupHistories, log, statusSink } = params;
37
+
38
+ const isGroup =
39
+ typeof message.channel_id === "string" &&
40
+ message.channel_id.length > 0 &&
41
+ message.channel_type === ChannelType.Group;
42
+
43
+ const sessionId = isGroup
44
+ ? message.channel_id!
45
+ : message.from_uid;
46
+
47
+ const rawBody = resolveContent(message.payload);
48
+ if (!rawBody) {
49
+ log?.info?.(
50
+ `dmwork: inbound dropped session=${sessionId} reason=empty-content`,
51
+ );
52
+ return;
53
+ }
54
+
55
+ // --- Mention gating for group messages ---
56
+ // In groups, only respond when the bot is explicitly @mentioned via
57
+ // payload.mention.uids (structured mention from WuKongIM).
58
+ // Unmentioned messages are recorded as history context for when the bot
59
+ // IS mentioned later.
60
+ const requireMention = account.config.requireMention !== false; // default true
61
+ let historyPrefix = "";
62
+
63
+ if (isGroup && requireMention) {
64
+ const mentionUids: string[] = message.payload?.mention?.uids ?? [];
65
+ const mentionAll: boolean = message.payload?.mention?.all === true;
66
+ const isMentioned = mentionAll || mentionUids.includes(botUid);
67
+
68
+ if (!isMentioned) {
69
+ // Record as pending history for future context
70
+ recordPendingHistoryEntryIfEnabled({
71
+ channelId: "dmwork",
72
+ groupId: sessionId,
73
+ entry: {
74
+ sender: message.from_uid,
75
+ body: historyPrefix + rawBody,
76
+ timestamp: message.timestamp ? message.timestamp * 1000 : Date.now(),
77
+ },
78
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
79
+ });
80
+ log?.info?.(
81
+ `dmwork: group message not mentioning bot, recorded as history context`,
82
+ );
83
+ return;
84
+ }
85
+
86
+ // Bot IS mentioned — prepend history context
87
+ const enrichedBody = buildPendingHistoryContextFromMap({
88
+ historyMap: groupHistories,
89
+ historyKey: sessionId,
90
+ currentMessage: rawBody,
91
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
92
+ });
93
+ if (enrichedBody !== rawBody) {
94
+ historyPrefix = enrichedBody.slice(0, enrichedBody.length - rawBody.length);
95
+ log?.info?.(`dmwork: prepending history context (${historyPrefix.length} chars)`);
96
+ }
97
+ // Clear history after consuming
98
+ clearHistoryEntriesIfEnabled({
99
+ historyMap: groupHistories,
100
+ historyKey: sessionId,
101
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
102
+ });
103
+ }
104
+
105
+ const core = getDmworkRuntime();
106
+ const config = core.config.loadConfig() as OpenClawConfig;
107
+
108
+ const route = core.channel.routing.resolveAgentRoute({
109
+ cfg: config,
110
+ channel: "dmwork",
111
+ accountId: account.accountId,
112
+ peer: {
113
+ kind: isGroup ? "group" : "direct",
114
+ id: sessionId,
115
+ },
116
+ });
117
+
118
+ const fromLabel = isGroup
119
+ ? `group:${message.channel_id}`
120
+ : `user:${message.from_uid}`;
121
+
122
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
123
+ agentId: route.agentId,
124
+ });
125
+
126
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
127
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
128
+ storePath,
129
+ sessionKey: route.sessionKey,
130
+ });
131
+
132
+ const body = core.channel.reply.formatAgentEnvelope({
133
+ channel: "DMWork",
134
+ from: fromLabel,
135
+ timestamp: message.timestamp ? message.timestamp * 1000 : undefined,
136
+ previousTimestamp,
137
+ envelope: envelopeOptions,
138
+ body: rawBody,
139
+ });
140
+
141
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
142
+ Body: body,
143
+ RawBody: rawBody,
144
+ CommandBody: rawBody,
145
+ From: `dmwork:${message.from_uid}`,
146
+ To: `dmwork:${sessionId}`,
147
+ SessionKey: route.sessionKey,
148
+ AccountId: route.accountId,
149
+ ChatType: isGroup ? "group" : "direct",
150
+ ConversationLabel: fromLabel,
151
+ SenderId: message.from_uid,
152
+ MessageSid: String(message.message_id),
153
+ Timestamp: message.timestamp ? message.timestamp * 1000 : undefined,
154
+ GroupSubject: isGroup ? message.channel_id : undefined,
155
+ Provider: "dmwork",
156
+ Surface: "dmwork",
157
+ OriginatingChannel: "dmwork",
158
+ OriginatingTo: `dmwork:${sessionId}`,
159
+ });
160
+
161
+ await core.channel.session.recordInboundSession({
162
+ storePath,
163
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
164
+ ctx: ctxPayload,
165
+ onRecordError: (err) => {
166
+ log?.error?.(`dmwork: failed updating session meta: ${String(err)}`);
167
+ },
168
+ });
169
+
170
+ statusSink?.({ lastInboundAt: Date.now(), lastError: null });
171
+
172
+ const replyChannelId = isGroup ? message.channel_id! : message.from_uid;
173
+ const replyChannelType = isGroup ? ChannelType.Group : ChannelType.DM;
174
+
175
+ // 已读回执 + 正在输入 — fire-and-forget,失败不影响主流程
176
+ log?.info?.(`dmwork: sending readReceipt+typing to channel=${replyChannelId} type=${replyChannelType} apiUrl=${account.config.apiUrl}`);
177
+ const messageIds = message.message_id ? [message.message_id] : [];
178
+ sendReadReceipt({ apiUrl: account.config.apiUrl, botToken: account.config.botToken ?? "", channelId: replyChannelId, channelType: replyChannelType, messageIds })
179
+ .then(() => log?.info?.("dmwork: readReceipt sent OK"))
180
+ .catch((err) => log?.error?.(`dmwork: readReceipt failed: ${String(err)}`));
181
+ sendTyping({ apiUrl: account.config.apiUrl, botToken: account.config.botToken ?? "", channelId: replyChannelId, channelType: replyChannelType })
182
+ .then(() => log?.info?.("dmwork: typing sent OK"))
183
+ .catch((err) => log?.error?.(`dmwork: typing failed: ${String(err)}`));
184
+
185
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
186
+ ctx: ctxPayload,
187
+ cfg: config,
188
+ dispatcherOptions: {
189
+ deliver: async (payload: {
190
+ text?: string;
191
+ mediaUrls?: string[];
192
+ mediaUrl?: string;
193
+ replyToId?: string | null;
194
+ }) => {
195
+ const contentParts: string[] = [];
196
+ if (payload.text) contentParts.push(payload.text);
197
+ const mediaUrls = [
198
+ ...(payload.mediaUrls ?? []),
199
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
200
+ ].filter(Boolean);
201
+ if (mediaUrls.length > 0) contentParts.push(...mediaUrls);
202
+ const content = contentParts.join("\n").trim();
203
+ if (!content) return;
204
+
205
+ const replyChannelId = isGroup ? message.channel_id! : message.from_uid;
206
+ const replyChannelType = isGroup ? ChannelType.Group : ChannelType.DM;
207
+
208
+ await sendMessage({
209
+ apiUrl: account.config.apiUrl,
210
+ botToken: account.config.botToken ?? "",
211
+ channelId: replyChannelId,
212
+ channelType: replyChannelType,
213
+ content,
214
+ });
215
+
216
+ statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
217
+ },
218
+ onError: (err, info) => {
219
+ log?.error?.(`dmwork ${info.kind} reply failed: ${String(err)}`);
220
+ },
221
+ },
222
+ });
223
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setDmworkRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getDmworkRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("DMWork runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
package/src/socket.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { EventEmitter } from "events";
2
+ import WKSDK, { ConnectStatus, type Message } from "wukongimjssdk";
3
+ import type { BotMessage, MessagePayload } from "./types.js";
4
+
5
+ interface WKSocketOptions {
6
+ wsUrl: string;
7
+ uid: string;
8
+ token: string;
9
+ onMessage: (msg: BotMessage) => void;
10
+ onConnected?: () => void;
11
+ onDisconnected?: () => void;
12
+ onError?: (err: Error) => void;
13
+ }
14
+
15
+ /**
16
+ * WuKongIM WebSocket client for bot connections.
17
+ * Thin wrapper around wukongimjssdk — the SDK handles binary encoding,
18
+ * DH key exchange, encryption, heartbeat, reconnect, and RECVACK.
19
+ *
20
+ * NOTE: WKSDK.shared() is a singleton. Each WKSocket must fully
21
+ * disconnect before connecting to avoid connection leaks and msgKey
22
+ * mismatch errors from concurrent WebSocket sessions.
23
+ */
24
+ export class WKSocket extends EventEmitter {
25
+ private statusListener: ((status: ConnectStatus, reasonCode?: number) => void) | null = null;
26
+ private messageListener: ((message: Message) => void) | null = null;
27
+ private connected = false;
28
+
29
+ constructor(private opts: WKSocketOptions) {
30
+ super();
31
+ }
32
+
33
+ /** Connect to WuKongIM WebSocket */
34
+ connect(): void {
35
+ const im = WKSDK.shared();
36
+
37
+ // Ensure clean state — disconnect any prior session first
38
+ try { im.disconnect(); } catch { /* ignore */ }
39
+
40
+ im.config.addr = this.opts.wsUrl;
41
+ im.config.uid = this.opts.uid;
42
+ im.config.token = this.opts.token;
43
+ im.config.deviceFlag = 0; // APP — matches bot registration device flag
44
+
45
+ // Remove any stale listeners before adding new ones
46
+ if (this.statusListener) {
47
+ im.connectManager.removeConnectStatusListener(this.statusListener);
48
+ }
49
+ if (this.messageListener) {
50
+ im.chatManager.removeMessageListener(this.messageListener);
51
+ }
52
+
53
+ // Listen for connection status changes
54
+ this.statusListener = (status: ConnectStatus, reasonCode?: number) => {
55
+ switch (status) {
56
+ case ConnectStatus.Connected:
57
+ this.connected = true;
58
+ this.opts.onConnected?.();
59
+ break;
60
+ case ConnectStatus.Disconnect:
61
+ if (this.connected) {
62
+ this.connected = false;
63
+ this.opts.onDisconnected?.();
64
+ }
65
+ break;
66
+ case ConnectStatus.ConnectFail:
67
+ this.connected = false;
68
+ this.opts.onError?.(
69
+ new Error(`Connect failed: reasonCode=${reasonCode ?? "unknown"}`),
70
+ );
71
+ break;
72
+ case ConnectStatus.ConnectKick:
73
+ this.connected = false;
74
+ this.opts.onError?.(new Error("Kicked by server"));
75
+ this.opts.onDisconnected?.();
76
+ break;
77
+ }
78
+ };
79
+ im.connectManager.addConnectStatusListener(this.statusListener);
80
+
81
+ // Listen for incoming messages — SDK auto-decrypts and sends RECVACK
82
+ this.messageListener = (message: Message) => {
83
+ const content = message.content;
84
+ const payload: MessagePayload = {
85
+ type: content?.contentType ?? 0,
86
+ content: content?.conversationDigest ?? content?.contentObj?.content,
87
+ ...content?.contentObj,
88
+ };
89
+
90
+ const msg: BotMessage = {
91
+ message_id: String(message.messageID),
92
+ message_seq: message.messageSeq,
93
+ from_uid: message.fromUID,
94
+ channel_id: message.channel?.channelID,
95
+ channel_type: message.channel?.channelType,
96
+ timestamp: message.timestamp,
97
+ payload,
98
+ };
99
+ this.opts.onMessage(msg);
100
+ };
101
+ im.chatManager.addMessageListener(this.messageListener);
102
+
103
+ im.connect();
104
+ }
105
+
106
+ /** Gracefully disconnect */
107
+ disconnect(): void {
108
+ const im = WKSDK.shared();
109
+ this.connected = false;
110
+ if (this.statusListener) {
111
+ im.connectManager.removeConnectStatusListener(this.statusListener);
112
+ this.statusListener = null;
113
+ }
114
+ if (this.messageListener) {
115
+ im.chatManager.removeMessageListener(this.messageListener);
116
+ this.messageListener = null;
117
+ }
118
+ try { im.disconnect(); } catch { /* ignore */ }
119
+ }
120
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,96 @@
1
+ import type { DMWorkAPI } from "./api.js";
2
+ import { ChannelType, MessageType } from "./types.js";
3
+
4
+ /**
5
+ * Manages streaming message output for AI agents.
6
+ * Handles stream/start → progressive sendMessage → stream/end lifecycle.
7
+ */
8
+ export class StreamManager {
9
+ private activeStreams = new Map<
10
+ string,
11
+ { channelId: string; channelType: ChannelType }
12
+ >();
13
+
14
+ constructor(private api: DMWorkAPI) {}
15
+
16
+ /**
17
+ * Start a new stream and return the stream_no.
18
+ */
19
+ async startStream(
20
+ channelId: string,
21
+ channelType: ChannelType,
22
+ initialContent = "",
23
+ ): Promise<string> {
24
+ const payload = Buffer.from(
25
+ JSON.stringify({
26
+ type: MessageType.Text,
27
+ content: initialContent,
28
+ }),
29
+ ).toString("base64");
30
+
31
+ const resp = await this.api.streamStart({
32
+ channel_id: channelId,
33
+ channel_type: channelType,
34
+ payload,
35
+ });
36
+
37
+ this.activeStreams.set(resp.stream_no, { channelId, channelType });
38
+ return resp.stream_no;
39
+ }
40
+
41
+ /**
42
+ * Send a chunk of streaming content.
43
+ */
44
+ async sendChunk(streamNo: string, content: string): Promise<void> {
45
+ const stream = this.activeStreams.get(streamNo);
46
+ if (!stream) throw new Error(`Unknown stream: ${streamNo}`);
47
+
48
+ await this.api.sendMessage({
49
+ channel_id: stream.channelId,
50
+ channel_type: stream.channelType,
51
+ stream_no: streamNo,
52
+ payload: {
53
+ type: MessageType.Text,
54
+ content,
55
+ },
56
+ });
57
+ }
58
+
59
+ /**
60
+ * End a stream.
61
+ */
62
+ async endStream(streamNo: string): Promise<void> {
63
+ const stream = this.activeStreams.get(streamNo);
64
+ if (!stream) return;
65
+
66
+ await this.api.streamEnd({
67
+ stream_no: streamNo,
68
+ channel_id: stream.channelId,
69
+ channel_type: stream.channelType,
70
+ });
71
+
72
+ this.activeStreams.delete(streamNo);
73
+ }
74
+
75
+ /**
76
+ * Convenience: stream a full text response with chunking.
77
+ * Splits by sentences/paragraphs and sends progressively.
78
+ */
79
+ async streamText(
80
+ channelId: string,
81
+ channelType: ChannelType,
82
+ textIterator: AsyncIterable<string>,
83
+ ): Promise<void> {
84
+ const streamNo = await this.startStream(channelId, channelType);
85
+
86
+ let accumulated = "";
87
+ try {
88
+ for await (const chunk of textIterator) {
89
+ accumulated += chunk;
90
+ await this.sendChunk(streamNo, accumulated);
91
+ }
92
+ } finally {
93
+ await this.endStream(streamNo);
94
+ }
95
+ }
96
+ }
package/src/types.ts ADDED
@@ -0,0 +1,110 @@
1
+ /** DMWork Bot API types */
2
+
3
+ export interface BotRegisterReq {
4
+ name?: string;
5
+ }
6
+
7
+ export interface BotRegisterResp {
8
+ robot_id: string;
9
+ im_token: string;
10
+ ws_url: string;
11
+ api_url: string;
12
+ owner_uid: string;
13
+ owner_channel_id: string;
14
+ }
15
+
16
+ export interface BotSendMessageReq {
17
+ channel_id: string;
18
+ channel_type: ChannelType;
19
+ stream_no?: string;
20
+ payload: MessagePayload;
21
+ }
22
+
23
+ export interface BotTypingReq {
24
+ channel_id: string;
25
+ channel_type: ChannelType;
26
+ }
27
+
28
+ export interface BotReadReceiptReq {
29
+ channel_id: string;
30
+ channel_type: ChannelType;
31
+ }
32
+
33
+ export interface BotEventsReq {
34
+ event_id: number;
35
+ limit?: number;
36
+ }
37
+
38
+ export interface BotEventsResp {
39
+ status: number;
40
+ results: BotEvent[];
41
+ }
42
+
43
+ export interface BotEvent {
44
+ event_id: number;
45
+ message?: BotMessage;
46
+ }
47
+
48
+ export interface BotMessage {
49
+ message_id: string;
50
+ message_seq: number;
51
+ from_uid: string;
52
+ channel_id?: string;
53
+ channel_type?: ChannelType;
54
+ timestamp: number;
55
+ payload: MessagePayload;
56
+ }
57
+
58
+ export interface MessagePayload {
59
+ type: MessageType;
60
+ content?: string;
61
+ url?: string;
62
+ [key: string]: unknown;
63
+ }
64
+
65
+ export interface BotStreamStartReq {
66
+ channel_id: string;
67
+ channel_type: ChannelType;
68
+ payload: string; // base64 encoded
69
+ }
70
+
71
+ export interface BotStreamStartResp {
72
+ stream_no: string;
73
+ }
74
+
75
+ export interface BotStreamEndReq {
76
+ stream_no: string;
77
+ channel_id: string;
78
+ channel_type: ChannelType;
79
+ }
80
+
81
+ export interface SendMessageResult {
82
+ message_id: number;
83
+ message_seq: number;
84
+ }
85
+
86
+ /** Channel types */
87
+ export enum ChannelType {
88
+ DM = 1,
89
+ Group = 2,
90
+ }
91
+
92
+ /** Message content types */
93
+ export enum MessageType {
94
+ Text = 1,
95
+ Image = 2,
96
+ GIF = 3,
97
+ Voice = 4,
98
+ Video = 5,
99
+ Location = 6,
100
+ Card = 7,
101
+ File = 8,
102
+ }
103
+
104
+ /** Plugin config */
105
+ export interface DMWorkConfig {
106
+ botToken: string;
107
+ apiUrl: string;
108
+ wsUrl?: string;
109
+ }
110
+