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 +44 -0
- package/index.ts +27 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +59 -0
- package/src/accounts.ts +78 -0
- package/src/api-fetch.ts +106 -0
- package/src/api.ts +96 -0
- package/src/channel.ts +265 -0
- package/src/config-schema.ts +28 -0
- package/src/inbound.ts +223 -0
- package/src/runtime.ts +14 -0
- package/src/socket.ts +120 -0
- package/src/stream.ts +96 -0
- package/src/types.ts +110 -0
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;
|
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
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -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
|
+
}
|
package/src/api-fetch.ts
ADDED
|
@@ -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
|
+
|