@rlynicrisis/link 0.0.1
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 +64 -0
- package/index.ts +22 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +49 -0
- package/src/bot.ts +138 -0
- package/src/channel.ts +90 -0
- package/src/client-manager.ts +27 -0
- package/src/link/client.ts +281 -0
- package/src/link/constants.ts +42 -0
- package/src/link/protocol.ts +295 -0
- package/src/link/types.ts +36 -0
- package/src/onboarding.ts +99 -0
- package/src/reply-dispatcher.ts +60 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +43 -0
- package/src/types.ts +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# OpenClaw Link Channel Plugin
|
|
2
|
+
|
|
3
|
+
这是一个 OpenClaw 的 Link 消息服务接入插件。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
- 接入 Link 消息服务 (TCP 协议)
|
|
7
|
+
- 支持接收文本消息
|
|
8
|
+
- 支持回复文本消息
|
|
9
|
+
- **安全限制**: 仅允许接收和回复自己账号的消息(Bot 自言自语模式)
|
|
10
|
+
|
|
11
|
+
## 安装与配置
|
|
12
|
+
|
|
13
|
+
### 1. 构建插件
|
|
14
|
+
|
|
15
|
+
在插件根目录下运行:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
npm run build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
这将生成 `dist` 目录。
|
|
23
|
+
|
|
24
|
+
### 2. 配置 OpenClaw
|
|
25
|
+
|
|
26
|
+
在您的 OpenClaw 主配置文件(通常是 `config.yaml` 或 `config.json`)中,添加以下内容:
|
|
27
|
+
|
|
28
|
+
#### 注册插件
|
|
29
|
+
如果 OpenClaw 支持本地插件加载,请确保插件路径被包含在加载列表中,或者将本插件发布/链接到 `node_modules`。
|
|
30
|
+
|
|
31
|
+
#### 频道配置
|
|
32
|
+
在 `channels` 部分添加 `link` 配置:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
channels:
|
|
36
|
+
link:
|
|
37
|
+
enabled: true
|
|
38
|
+
# Link 服务地址
|
|
39
|
+
host: "embtcpbeta.bingolink.biz"
|
|
40
|
+
port: 20081
|
|
41
|
+
# 鉴权信息 (必填)
|
|
42
|
+
accessToken: "your_access_token"
|
|
43
|
+
# 可选配置
|
|
44
|
+
# heartbeatIntervalMs: 30000
|
|
45
|
+
# verifyInfo: # 如果需要覆盖默认生成的设备信息,可在此配置
|
|
46
|
+
# deviceName: "CustomBotName"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> **注意**:
|
|
50
|
+
> - `userId` 将自动从 `accessToken` (JWT) 中解析。
|
|
51
|
+
> - `deviceUID` 将根据机器特征自动生成,确保同一设备上的稳定性。
|
|
52
|
+
> - 其他参数如 `version`, `deviceToken` 等已内置默认值。
|
|
53
|
+
|
|
54
|
+
### 3. 启动 OpenClaw
|
|
55
|
+
|
|
56
|
+
启动 OpenClaw 主程序,插件将自动连接 Link 服务。
|
|
57
|
+
|
|
58
|
+
## 开发调试
|
|
59
|
+
|
|
60
|
+
可以使用 `test/manual-connect.ts` 脚本单独测试连接:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx tsx test/manual-connect.ts
|
|
64
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { setLinkRuntime } from "./src/runtime.js";
|
|
3
|
+
import { linkPlugin } from "./src/channel.js";
|
|
4
|
+
|
|
5
|
+
const plugin = {
|
|
6
|
+
id: "link",
|
|
7
|
+
name: "Link",
|
|
8
|
+
description: "Link channel plugin",
|
|
9
|
+
configSchema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
host: { type: "string" },
|
|
13
|
+
port: { type: "number" }
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
register(api: OpenClawPluginApi) {
|
|
17
|
+
setLinkRuntime(api.runtime);
|
|
18
|
+
api.registerChannel(linkPlugin);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default plugin;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "link",
|
|
3
|
+
"channels": ["link"],
|
|
4
|
+
"configSchema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"host": { "type": "string", "description": "Link Server Host (e.g. embtcpbeta.bingolink.biz)" },
|
|
9
|
+
"port": { "type": "number", "description": "Link Server Port (e.g. 20081)" },
|
|
10
|
+
"accessToken": { "type": "string", "description": "User Access Token" },
|
|
11
|
+
"heartbeatIntervalMs": { "type": "number", "default": 30000 }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rlynicrisis/link",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw Link channel plugin",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.ts",
|
|
8
|
+
"src/**/*.ts",
|
|
9
|
+
"!src/**/__tests__/**",
|
|
10
|
+
"!src/**/*.test.ts",
|
|
11
|
+
"openclaw.plugin.json"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"registry": "https://registry.npmjs.org/"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.18.0",
|
|
23
|
+
"zod": "^3.23.8"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.5.4",
|
|
27
|
+
"@types/ws": "^8.5.12",
|
|
28
|
+
"openclaw": "latest",
|
|
29
|
+
"typescript": "^5.5.4",
|
|
30
|
+
"vitest": "^2.0.5"
|
|
31
|
+
},
|
|
32
|
+
"openclaw": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./index.ts"
|
|
35
|
+
],
|
|
36
|
+
"channel": {
|
|
37
|
+
"id": "link",
|
|
38
|
+
"label": "Link",
|
|
39
|
+
"selectionLabel": "Link Messaging Service",
|
|
40
|
+
"blurb": "Integration with Link messaging service.",
|
|
41
|
+
"order": 100
|
|
42
|
+
},
|
|
43
|
+
"install": {
|
|
44
|
+
"npmSpec": "@rlynicrisis/link",
|
|
45
|
+
"localPath": ".",
|
|
46
|
+
"defaultChoice": "npm"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClawdbotConfig,
|
|
3
|
+
type RuntimeEnv,
|
|
4
|
+
} from "openclaw/plugin-sdk";
|
|
5
|
+
import { LinkConfig } from "./types.js";
|
|
6
|
+
import { createLinkClient, getLinkClient } from "./client-manager.js";
|
|
7
|
+
import { getLinkRuntime } from "./runtime.js";
|
|
8
|
+
import { createLinkReplyDispatcher } from "./reply-dispatcher.js";
|
|
9
|
+
import { EmbMessage } from "./link/types.js";
|
|
10
|
+
import { MsgType, ParticipantType } from "./link/constants.js";
|
|
11
|
+
|
|
12
|
+
export async function startLinkBot(cfg: ClawdbotConfig, runtime: RuntimeEnv) {
|
|
13
|
+
const linkCfg = cfg.channels?.link as LinkConfig | undefined;
|
|
14
|
+
|
|
15
|
+
// Basic validation
|
|
16
|
+
if (!linkCfg) {
|
|
17
|
+
// Channel not enabled or configured
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!linkCfg.accessToken) {
|
|
22
|
+
runtime.log?.("Link channel configured but missing accessToken");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const accountId = "default"; // For now single account support
|
|
27
|
+
|
|
28
|
+
let client = getLinkClient(accountId);
|
|
29
|
+
if (!client) {
|
|
30
|
+
client = createLinkClient(accountId, linkCfg);
|
|
31
|
+
|
|
32
|
+
client.on('connected', () => {
|
|
33
|
+
runtime.log?.("Link client connected");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
client.on('disconnected', () => {
|
|
37
|
+
runtime.log?.("Link client disconnected");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
client.on('error', (err) => {
|
|
41
|
+
runtime.error?.(`Link client error: ${err}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
client.on('message', (msg: EmbMessage) => {
|
|
45
|
+
handleLinkMessage({ cfg, msg, runtime, accountId, linkCfg }).catch(err => {
|
|
46
|
+
runtime.error?.(`Error handling Link message: ${err}`);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
client.connect();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function stopLinkBot() {
|
|
55
|
+
// Implement cleanup if needed
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleLinkMessage(params: {
|
|
59
|
+
cfg: ClawdbotConfig;
|
|
60
|
+
msg: EmbMessage;
|
|
61
|
+
runtime: RuntimeEnv;
|
|
62
|
+
accountId: string;
|
|
63
|
+
linkCfg: LinkConfig;
|
|
64
|
+
}) {
|
|
65
|
+
const { cfg, msg, runtime, accountId, linkCfg } = params;
|
|
66
|
+
const core = getLinkRuntime();
|
|
67
|
+
|
|
68
|
+
if (msg.type !== MsgType.TEXT) {
|
|
69
|
+
// Only support text for now
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const senderId = msg.fromId || "unknown";
|
|
74
|
+
const toId = msg.toId || "unknown";
|
|
75
|
+
|
|
76
|
+
// SECURITY: Only process messages sent by the bot user itself
|
|
77
|
+
// Note: userId is populated in client.ts into verifyInfo during connection
|
|
78
|
+
const allowedUserId = linkCfg.verifyInfo?.userId;
|
|
79
|
+
if (!allowedUserId || senderId !== allowedUserId || toId !== allowedUserId) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Determine chat ID (for reply)
|
|
84
|
+
// If group message, reply to group (toId). If DM, reply to sender (fromId).
|
|
85
|
+
// Assuming if toType is GROUP, then it's a group chat.
|
|
86
|
+
const isGroup = msg.toType === ParticipantType.GROUP;
|
|
87
|
+
const chatId = isGroup ? msg.toId : senderId;
|
|
88
|
+
|
|
89
|
+
// Map Link message to OpenClaw InboundContext
|
|
90
|
+
const sessionKey = `link:${chatId}`;
|
|
91
|
+
|
|
92
|
+
const ctx = core.channel.reply.finalizeInboundContext({
|
|
93
|
+
Body: msg.content,
|
|
94
|
+
RawBody: msg.content,
|
|
95
|
+
CommandBody: msg.content,
|
|
96
|
+
From: senderId,
|
|
97
|
+
To: "bot",
|
|
98
|
+
SessionKey: sessionKey,
|
|
99
|
+
AccountId: accountId,
|
|
100
|
+
ChatType: isGroup ? "group" : "direct",
|
|
101
|
+
GroupSubject: isGroup ? chatId : undefined,
|
|
102
|
+
SenderName: msg.fromName || senderId,
|
|
103
|
+
SenderId: senderId,
|
|
104
|
+
Provider: "link" as any,
|
|
105
|
+
Surface: "link" as any,
|
|
106
|
+
MessageSid: msg.id || `temp-${Date.now()}`,
|
|
107
|
+
Timestamp: msg.sendTime || Date.now(),
|
|
108
|
+
WasMentioned: !isGroup, // Assume DM is always mentioned. For group, need mention logic.
|
|
109
|
+
OriginatingChannel: "link" as any,
|
|
110
|
+
OriginatingTo: "bot",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Resolve route (Agent)
|
|
114
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
115
|
+
cfg,
|
|
116
|
+
channel: "link",
|
|
117
|
+
accountId,
|
|
118
|
+
peer: { kind: isGroup ? "group" : "direct", id: chatId }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createLinkReplyDispatcher({
|
|
122
|
+
cfg,
|
|
123
|
+
agentId: route.agentId,
|
|
124
|
+
runtime,
|
|
125
|
+
chatId: chatId,
|
|
126
|
+
accountId,
|
|
127
|
+
linkConfig: linkCfg
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await core.channel.reply.dispatchReplyFromConfig({
|
|
131
|
+
ctx,
|
|
132
|
+
cfg,
|
|
133
|
+
dispatcher,
|
|
134
|
+
replyOptions
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
markDispatchIdle();
|
|
138
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
2
|
+
import { startLinkBot, stopLinkBot } from "./bot.js";
|
|
3
|
+
import { linkOnboardingAdapter } from "./onboarding.js";
|
|
4
|
+
|
|
5
|
+
export const linkPlugin: ChannelPlugin<any> = {
|
|
6
|
+
id: "link",
|
|
7
|
+
onboarding: linkOnboardingAdapter,
|
|
8
|
+
meta: {
|
|
9
|
+
id: "link",
|
|
10
|
+
label: "Link",
|
|
11
|
+
selectionLabel: "Link Messaging",
|
|
12
|
+
docsPath: "",
|
|
13
|
+
docsLabel: "link",
|
|
14
|
+
blurb: "Integration with Link messaging service",
|
|
15
|
+
order: 100
|
|
16
|
+
},
|
|
17
|
+
capabilities: {
|
|
18
|
+
chatTypes: ["direct", "channel"],
|
|
19
|
+
polls: false,
|
|
20
|
+
threads: false,
|
|
21
|
+
media: false,
|
|
22
|
+
reactions: false,
|
|
23
|
+
edit: false,
|
|
24
|
+
reply: true
|
|
25
|
+
},
|
|
26
|
+
configSchema: {
|
|
27
|
+
schema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
properties: {
|
|
31
|
+
host: { type: "string" },
|
|
32
|
+
port: { type: "number" },
|
|
33
|
+
verifyInfo: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
userId: { type: "string" },
|
|
37
|
+
deviceUID: { type: "string" },
|
|
38
|
+
deviceName: { type: "string" },
|
|
39
|
+
deviceToken: { type: "string" },
|
|
40
|
+
accessToken: { type: "string" },
|
|
41
|
+
version: { type: "string" },
|
|
42
|
+
force: { type: "boolean" },
|
|
43
|
+
protocolVersion: { type: "number" },
|
|
44
|
+
isFirstLogin: { type: "boolean" },
|
|
45
|
+
secret: { type: "string" }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
heartbeatIntervalMs: { type: "number" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
config: {
|
|
53
|
+
listAccountIds: (cfg) => {
|
|
54
|
+
// Return default account if channel is configured
|
|
55
|
+
if (cfg?.channels?.link?.accessToken) { return ["default"]; }
|
|
56
|
+
return [];
|
|
57
|
+
},
|
|
58
|
+
resolveAccount: (cfg, accountId) => {
|
|
59
|
+
// Return dummy account for now
|
|
60
|
+
return {
|
|
61
|
+
accountId,
|
|
62
|
+
enabled: true,
|
|
63
|
+
configured: true,
|
|
64
|
+
name: "Link Bot",
|
|
65
|
+
} as any;
|
|
66
|
+
},
|
|
67
|
+
defaultAccountId: (cfg) => "default",
|
|
68
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => cfg,
|
|
69
|
+
deleteAccount: ({ cfg, accountId }) => cfg,
|
|
70
|
+
isConfigured: (account) => true,
|
|
71
|
+
describeAccount: (account) => ({
|
|
72
|
+
accountId: account.accountId,
|
|
73
|
+
enabled: true,
|
|
74
|
+
configured: true,
|
|
75
|
+
name: "Link Bot",
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
gateway: {
|
|
79
|
+
startAccount: async (ctx) => {
|
|
80
|
+
await startLinkBot(ctx.cfg, ctx.runtime);
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
84
|
+
stopLinkBot();
|
|
85
|
+
resolve(undefined);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LinkClient } from "./link/client.js";
|
|
2
|
+
import { LinkConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const clients = new Map<string, LinkClient>();
|
|
5
|
+
|
|
6
|
+
export function getLinkClient(accountId: string = "default"): LinkClient | undefined {
|
|
7
|
+
return clients.get(accountId);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createLinkClient(accountId: string, config: LinkConfig): LinkClient {
|
|
11
|
+
const existing = clients.get(accountId);
|
|
12
|
+
if (existing) {
|
|
13
|
+
existing.disconnect();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const client = new LinkClient(config);
|
|
17
|
+
clients.set(accountId, client);
|
|
18
|
+
return client;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function removeLinkClient(accountId: string) {
|
|
22
|
+
const client = clients.get(accountId);
|
|
23
|
+
if (client) {
|
|
24
|
+
client.disconnect();
|
|
25
|
+
clients.delete(accountId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { Buffer } from 'buffer';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
import { CmdCodes } from './constants.js';
|
|
7
|
+
import { decodePacket, encodePacket, encodeMessage, decodeMessage, ProtocolError } from './protocol.js';
|
|
8
|
+
import { EmbMessage, ClientVerifyInfo } from './types.js';
|
|
9
|
+
|
|
10
|
+
export class LinkClient extends EventEmitter {
|
|
11
|
+
private socket: net.Socket | null = null;
|
|
12
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
13
|
+
private heartbeatInterval: NodeJS.Timeout | null = null;
|
|
14
|
+
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
15
|
+
private isConnected = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
public readonly config: {
|
|
19
|
+
host: string;
|
|
20
|
+
port: number;
|
|
21
|
+
accessToken: string;
|
|
22
|
+
verifyInfo?: Partial<ClientVerifyInfo>;
|
|
23
|
+
heartbeatIntervalMs?: number;
|
|
24
|
+
}
|
|
25
|
+
) {
|
|
26
|
+
super();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public connect() {
|
|
30
|
+
if (this.socket) {
|
|
31
|
+
this.socket.destroy();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.socket = new net.Socket();
|
|
35
|
+
|
|
36
|
+
this.socket.on('connect', () => {
|
|
37
|
+
console.log('LinkClient connected');
|
|
38
|
+
this.isConnected = true;
|
|
39
|
+
this.sendVerify();
|
|
40
|
+
this.startHeartbeat();
|
|
41
|
+
this.emit('connected');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.socket.on('data', (data) => {
|
|
45
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
46
|
+
this.processBuffer();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.socket.on('close', () => {
|
|
50
|
+
console.log('LinkClient disconnected');
|
|
51
|
+
this.isConnected = false;
|
|
52
|
+
this.stopHeartbeat();
|
|
53
|
+
this.emit('disconnected');
|
|
54
|
+
this.scheduleReconnect();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.socket.on('error', (err) => {
|
|
58
|
+
console.error('LinkClient error:', err);
|
|
59
|
+
this.emit('error', err);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.socket.connect(this.config.port, this.config.host);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public disconnect() {
|
|
66
|
+
this.stopHeartbeat();
|
|
67
|
+
if (this.reconnectTimeout) {
|
|
68
|
+
clearTimeout(this.reconnectTimeout);
|
|
69
|
+
}
|
|
70
|
+
if (this.socket) {
|
|
71
|
+
this.socket.destroy();
|
|
72
|
+
this.socket = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public sendMessage(msg: EmbMessage) {
|
|
77
|
+
if (!this.isConnected || !this.socket) {
|
|
78
|
+
throw new Error('Client not connected');
|
|
79
|
+
}
|
|
80
|
+
// Encode message body
|
|
81
|
+
const body = encodeMessage(msg);
|
|
82
|
+
// Encode packet
|
|
83
|
+
const packet = encodePacket(CmdCodes.SEND_MSG, body);
|
|
84
|
+
this.socket.write(packet);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private sendVerify() {
|
|
88
|
+
// Derive userId from accessToken (JWT sub claim)
|
|
89
|
+
const tokenParts = this.config.accessToken.split('.');
|
|
90
|
+
let userId = 'unknown';
|
|
91
|
+
if (tokenParts.length === 3) {
|
|
92
|
+
try {
|
|
93
|
+
const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString());
|
|
94
|
+
userId = payload.sub || payload.user_id || 'unknown';
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error('Failed to parse accessToken for userId', e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Generate stable deviceUID based on machine info if not provided
|
|
101
|
+
// Use a hash of hostname + user info + mac address (if available) to ensure stability on the same machine
|
|
102
|
+
let deviceUID = this.config.verifyInfo?.deviceUID;
|
|
103
|
+
if (!deviceUID) {
|
|
104
|
+
try {
|
|
105
|
+
const interfaces = os.networkInterfaces();
|
|
106
|
+
let mac = '';
|
|
107
|
+
for (const key in interfaces) {
|
|
108
|
+
const iface = interfaces[key];
|
|
109
|
+
if (iface) {
|
|
110
|
+
const macEntry = iface.find(i => i.mac && i.mac !== '00:00:00:00:00:00' && !i.internal);
|
|
111
|
+
if (macEntry) {
|
|
112
|
+
mac = macEntry.mac;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const machineId = `${os.hostname()}-${os.platform()}-${os.arch()}-${mac || 'nomac'}`;
|
|
118
|
+
// Use MD5 or SHA256 to keep it short and clean
|
|
119
|
+
deviceUID = crypto.createHash('md5').update(machineId).digest('hex');
|
|
120
|
+
} catch (e) {
|
|
121
|
+
// Fallback
|
|
122
|
+
deviceUID = `openclaw-link-${userId}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const info = {
|
|
127
|
+
userId: userId,
|
|
128
|
+
deviceUID: deviceUID!, // deviceUID is definitely assigned
|
|
129
|
+
deviceName: 'OpenClawBot',
|
|
130
|
+
deviceToken: 'openclaw-device-token',
|
|
131
|
+
accessToken: this.config.accessToken,
|
|
132
|
+
version: '1.0.0',
|
|
133
|
+
force: true,
|
|
134
|
+
// protocolVersion: 3.0,
|
|
135
|
+
isFirstLogin: false,
|
|
136
|
+
secret: '',
|
|
137
|
+
...this.config.verifyInfo // Allow override
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Update config with derived values for reference (e.g. sender checks)
|
|
141
|
+
// We need to cast or update the type definition if we want to store it back cleanly,
|
|
142
|
+
// but for now let's just use local `info` object for the verify packet.
|
|
143
|
+
// Also need to make sure `this.config.verifyInfo` is updated so `bot.ts` can use it for `userId` check.
|
|
144
|
+
if (!this.config.verifyInfo) {
|
|
145
|
+
this.config.verifyInfo = {};
|
|
146
|
+
}
|
|
147
|
+
this.config.verifyInfo.userId = info.userId;
|
|
148
|
+
this.config.verifyInfo.deviceUID = info.deviceUID;
|
|
149
|
+
|
|
150
|
+
// Construct verify body JSON
|
|
151
|
+
const body = JSON.stringify({
|
|
152
|
+
UserId: info.userId,
|
|
153
|
+
DeviceUID: info.deviceUID,
|
|
154
|
+
DeviceName: info.deviceName,
|
|
155
|
+
DeviceToken: info.deviceToken,
|
|
156
|
+
AccessToken: info.accessToken,
|
|
157
|
+
Version: info.version,
|
|
158
|
+
Force: info.force,
|
|
159
|
+
protocolVersion: info.protocolVersion,
|
|
160
|
+
isFirstLogin: info.isFirstLogin,
|
|
161
|
+
secret: info.secret
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const packet = encodePacket(CmdCodes.CLIENT_VERIFY_UP, body);
|
|
165
|
+
if (this.socket) this.socket.write(packet);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private startHeartbeat() {
|
|
169
|
+
this.stopHeartbeat();
|
|
170
|
+
this.heartbeatInterval = setInterval(() => {
|
|
171
|
+
if (this.socket && this.isConnected) {
|
|
172
|
+
const packet = encodePacket(CmdCodes.HEART_BEAT_UP);
|
|
173
|
+
this.socket.write(packet);
|
|
174
|
+
}
|
|
175
|
+
}, this.config.heartbeatIntervalMs || 30000);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private stopHeartbeat() {
|
|
179
|
+
if (this.heartbeatInterval) {
|
|
180
|
+
clearInterval(this.heartbeatInterval);
|
|
181
|
+
this.heartbeatInterval = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private scheduleReconnect() {
|
|
186
|
+
if (this.reconnectTimeout) return;
|
|
187
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
188
|
+
this.reconnectTimeout = null;
|
|
189
|
+
console.log('Reconnecting...');
|
|
190
|
+
this.connect();
|
|
191
|
+
}, 5000);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private processBuffer() {
|
|
195
|
+
while (this.buffer.length > 0) {
|
|
196
|
+
try {
|
|
197
|
+
// Try to decode one packet
|
|
198
|
+
// We need to peek header first
|
|
199
|
+
if (this.buffer.length < 4) return; // Wait for more data
|
|
200
|
+
|
|
201
|
+
// decodePacket throws if incomplete body, but we need to handle that gracefully
|
|
202
|
+
// The decodePacket implementation I wrote throws "Incomplete body" which is not ideal for stream processing
|
|
203
|
+
// Let's modify decodePacket or handle it here.
|
|
204
|
+
// Better: implement a peek function or modify decodePacket to return null if incomplete.
|
|
205
|
+
// For now, I'll rely on the length check I added in decodePacket:
|
|
206
|
+
// if (buffer.length < 8 + bodyLen) throw ...
|
|
207
|
+
|
|
208
|
+
// Wait, if it throws "Incomplete body", I should catch it and return.
|
|
209
|
+
// But "ProtocolError" might mean malformed too.
|
|
210
|
+
// I should probably improve decodePacket to differentiate "Need more data" vs "Invalid data".
|
|
211
|
+
|
|
212
|
+
// Let's do a manual check here to be safe and efficient
|
|
213
|
+
const bodyCount = this.buffer[3];
|
|
214
|
+
let requiredLen = 4;
|
|
215
|
+
if (bodyCount === 1) {
|
|
216
|
+
if (this.buffer.length < 8) return; // Need length bytes
|
|
217
|
+
const bodyLen = this.buffer.readUInt32BE(4);
|
|
218
|
+
requiredLen = 8 + bodyLen;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (this.buffer.length < requiredLen) return; // Wait for more data
|
|
222
|
+
|
|
223
|
+
// Now we have a full packet
|
|
224
|
+
const packetData = this.buffer.subarray(0, requiredLen);
|
|
225
|
+
const { cmdCode, body } = decodePacket(packetData); // This shouldn't throw "Incomplete" now
|
|
226
|
+
|
|
227
|
+
// Advance buffer
|
|
228
|
+
this.buffer = this.buffer.subarray(requiredLen);
|
|
229
|
+
|
|
230
|
+
this.handleCommand(cmdCode, body);
|
|
231
|
+
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err instanceof ProtocolError) {
|
|
234
|
+
// If it's truly a protocol error (e.g. invalid header), we might need to close connection or skip byte?
|
|
235
|
+
// For simplicity, let's log and maybe close.
|
|
236
|
+
console.error('Protocol error:', err);
|
|
237
|
+
this.socket?.destroy();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private handleCommand(cmdCode: number, body?: string) {
|
|
246
|
+
switch (cmdCode) {
|
|
247
|
+
case CmdCodes.HEART_BEAT_DOWN:
|
|
248
|
+
// Heartbeat ack, ignore or reset timer
|
|
249
|
+
break;
|
|
250
|
+
case CmdCodes.SEND_MSG:
|
|
251
|
+
if (body) {
|
|
252
|
+
try {
|
|
253
|
+
const msg = decodeMessage(body);
|
|
254
|
+
this.emit('message', msg);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.error('Failed to decode message:', e);
|
|
257
|
+
console.error('Raw body:', body);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
case CmdCodes.CLIENT_VERIFY_DOWN:
|
|
262
|
+
console.log('Client verify success');
|
|
263
|
+
break;
|
|
264
|
+
case CmdCodes.ERROR_UP:
|
|
265
|
+
console.error('Link server returned error:', body);
|
|
266
|
+
break;
|
|
267
|
+
case CmdCodes.OFFLINE_UNEND_DOWN:
|
|
268
|
+
case CmdCodes.OFFLINE_END_DOWN:
|
|
269
|
+
case CmdCodes.SINGLE_DEVICE_DOWN:
|
|
270
|
+
case CmdCodes.REFRESH_TOKEN_DOWN:
|
|
271
|
+
case CmdCodes.SLEEP_DOWN:
|
|
272
|
+
case CmdCodes.SEND_MSG_RECEIPT:
|
|
273
|
+
case CmdCodes.REC_READ_DOWN:
|
|
274
|
+
// Ignore for now or log
|
|
275
|
+
// console.log(`Received cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
|
|
276
|
+
break;
|
|
277
|
+
default:
|
|
278
|
+
console.log(`Received unknown cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const CmdCodes = {
|
|
2
|
+
HEART_BEAT_UP: 0x6,
|
|
3
|
+
HEART_BEAT_DOWN: 0x10,
|
|
4
|
+
CLIENT_VERIFY_UP: 0xB,
|
|
5
|
+
CLIENT_VERIFY_DOWN: 0xC8,
|
|
6
|
+
SINGLE_DEVICE_DOWN: 0xC9,
|
|
7
|
+
REFRESH_TOKEN_DOWN: 0x63,
|
|
8
|
+
SLEEP_DOWN: 0x64,
|
|
9
|
+
SEND_MSG: 0x3,
|
|
10
|
+
SEND_MSG_RECEIPT: 0xD,
|
|
11
|
+
ERROR_UP: 0x62,
|
|
12
|
+
|
|
13
|
+
REC_READ_UP: 0xE,
|
|
14
|
+
REC_READ_DOWN: 0x13,
|
|
15
|
+
OFFLINE_UNEND_DOWN: 0x65,
|
|
16
|
+
OFFLINE_GET_UP: 0x66,
|
|
17
|
+
OFFLINE_END_DOWN: 0x61,
|
|
18
|
+
SERVICE_UP: 0xF,
|
|
19
|
+
SERVICE_TOKEN_UP: 0x10,
|
|
20
|
+
SERVICE_TOKEN_DOWN: 0x11,
|
|
21
|
+
CLEAR_DEVICE_OFFMSG: 0x14,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export enum MsgType {
|
|
25
|
+
TEXT = 1,
|
|
26
|
+
IMAGE = 2,
|
|
27
|
+
AUDIO = 3,
|
|
28
|
+
VIDEO = 4,
|
|
29
|
+
FILE = 5,
|
|
30
|
+
LOCATION = 6,
|
|
31
|
+
CMD = 7,
|
|
32
|
+
LINK = 8,
|
|
33
|
+
CONTACT = 9,
|
|
34
|
+
// ...
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export enum ParticipantType {
|
|
38
|
+
USER = 1,
|
|
39
|
+
GROUP = 2,
|
|
40
|
+
SYSTEM = 3,
|
|
41
|
+
// ...
|
|
42
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { CmdCodes } from './constants.js';
|
|
3
|
+
import { EmbMessage, EmbCommand } from './types.js';
|
|
4
|
+
|
|
5
|
+
const HEAD = 0x5;
|
|
6
|
+
const OPT = 0x0;
|
|
7
|
+
|
|
8
|
+
export class ProtocolError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ProtocolError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Simple encryption from Java client: ~byte then Base64
|
|
16
|
+
export function encryptContent(raw: string): string {
|
|
17
|
+
if (!raw) return raw;
|
|
18
|
+
const buffer = Buffer.from(raw, 'utf-8');
|
|
19
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
20
|
+
buffer[i] = ~buffer[i];
|
|
21
|
+
}
|
|
22
|
+
return buffer.toString('base64');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decryptContent(encoded: string): string {
|
|
26
|
+
if (!encoded) return encoded;
|
|
27
|
+
const buffer = Buffer.from(encoded, 'base64');
|
|
28
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
29
|
+
buffer[i] = ~buffer[i];
|
|
30
|
+
}
|
|
31
|
+
return buffer.toString('utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function encodePacket(cmdCode: number, body?: string): Buffer {
|
|
35
|
+
const header = Buffer.alloc(4);
|
|
36
|
+
header[0] = HEAD;
|
|
37
|
+
header[1] = cmdCode;
|
|
38
|
+
header[2] = OPT;
|
|
39
|
+
|
|
40
|
+
if (!body) {
|
|
41
|
+
header[3] = 0;
|
|
42
|
+
return header;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
header[3] = 1; // Body count = 1
|
|
46
|
+
const bodyBuffer = Buffer.from(body, 'utf-8');
|
|
47
|
+
const lenBuffer = Buffer.alloc(4);
|
|
48
|
+
lenBuffer.writeUInt32BE(bodyBuffer.length, 0);
|
|
49
|
+
|
|
50
|
+
return Buffer.concat([header, lenBuffer, bodyBuffer]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decodePacket(buffer: Buffer): { cmdCode: number; body?: string; consumed: number } {
|
|
54
|
+
if (buffer.length < 4) {
|
|
55
|
+
throw new ProtocolError('Incomplete header');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (buffer[0] !== HEAD) {
|
|
59
|
+
throw new ProtocolError(`Invalid header: ${buffer[0]}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cmdCode = buffer[1];
|
|
63
|
+
// const opt = buffer[2];
|
|
64
|
+
const bodyCount = buffer[3];
|
|
65
|
+
|
|
66
|
+
if (bodyCount === 0) {
|
|
67
|
+
return { cmdCode, consumed: 4 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (bodyCount === 1) {
|
|
71
|
+
if (buffer.length < 8) { // 4 header + 4 length
|
|
72
|
+
throw new ProtocolError('Incomplete body length');
|
|
73
|
+
}
|
|
74
|
+
const bodyLen = buffer.readUInt32BE(4);
|
|
75
|
+
if (buffer.length < 8 + bodyLen) {
|
|
76
|
+
throw new ProtocolError('Incomplete body');
|
|
77
|
+
}
|
|
78
|
+
const body = buffer.toString('utf-8', 8, 8 + bodyLen);
|
|
79
|
+
return { cmdCode, body, consumed: 8 + bodyLen };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new ProtocolError(`Unsupported body count: ${bodyCount}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function encodeMessage(msg: EmbMessage): string {
|
|
86
|
+
// Map fields to JSON keys as per Java implementation
|
|
87
|
+
const json = {
|
|
88
|
+
msg_id: msg.id,
|
|
89
|
+
msg_type: msg.type,
|
|
90
|
+
content: msg.sign ? msg.content : encryptContent(msg.content),
|
|
91
|
+
from_type: msg.fromType,
|
|
92
|
+
from_id: msg.fromId,
|
|
93
|
+
from_name: msg.fromName,
|
|
94
|
+
from_company: msg.fromCompany,
|
|
95
|
+
to_type: msg.toType,
|
|
96
|
+
to_id: msg.toId,
|
|
97
|
+
to_name: msg.toName,
|
|
98
|
+
to_company: msg.toCompany,
|
|
99
|
+
send_time: msg.sendTime,
|
|
100
|
+
is_read: msg.read ? 1 : 0,
|
|
101
|
+
sign: msg.sign
|
|
102
|
+
};
|
|
103
|
+
return JSON.stringify(json);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function decodeMessage(jsonStr: string): EmbMessage {
|
|
107
|
+
// Try to fix malformed JSON if it has leading garbage
|
|
108
|
+
let cleanJsonStr = jsonStr;
|
|
109
|
+
|
|
110
|
+
// Find first '{'
|
|
111
|
+
const braceIndex = jsonStr.indexOf('{');
|
|
112
|
+
if (braceIndex >= 0) {
|
|
113
|
+
cleanJsonStr = jsonStr.substring(braceIndex);
|
|
114
|
+
} else {
|
|
115
|
+
// No JSON object start found?
|
|
116
|
+
throw new Error("No JSON object found in message body");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Find last '}' to handle trailing garbage if any
|
|
120
|
+
const lastBraceIndex = cleanJsonStr.lastIndexOf('}');
|
|
121
|
+
if (lastBraceIndex >= 0 && lastBraceIndex < cleanJsonStr.length - 1) {
|
|
122
|
+
cleanJsonStr = cleanJsonStr.substring(0, lastBraceIndex + 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Attempt to remove non-JSON prefix if '{' check wasn't enough (e.g. if body starts with garbage but contains {)
|
|
126
|
+
// The logs show bodies starting with $UUID... then JSON content?
|
|
127
|
+
// Actually the logs show: $UUID... and then some binary/garbage? Or maybe the body IS just that string?
|
|
128
|
+
// Wait, the logs show: "$c8b8dca6-..." ... is not valid JSON
|
|
129
|
+
// And Raw Body: $c8b8dca6-... followed by some binary chars.
|
|
130
|
+
|
|
131
|
+
// It seems the body received is NOT JSON.
|
|
132
|
+
// The Java code suggests:
|
|
133
|
+
// protected EmbMessage decode(JsonObject json) { ... }
|
|
134
|
+
// And TcpProtocolV2.java:
|
|
135
|
+
// public EmbCommand decodeCommandWithBody(byte[] body) {
|
|
136
|
+
// if(bodyCount == 1) {
|
|
137
|
+
// EmbCommandWithBody cmd = newInstance();
|
|
138
|
+
// cmd.setCmdBody(Strings.newStringUtf8(body));
|
|
139
|
+
// return cmd;
|
|
140
|
+
// }
|
|
141
|
+
// }
|
|
142
|
+
|
|
143
|
+
// However, `SendMessage` (Cmd 0x3) is `EmbCommandWithMsgBase`.
|
|
144
|
+
// Wait, `SendMessage` extends `EmbCommandWithMsgBase`.
|
|
145
|
+
// `EmbCommandWithMsgBase` implements `EmbCommandWithMsg`.
|
|
146
|
+
// `EmbCommandWithMsg` extends `EmbCommandWithBody`.
|
|
147
|
+
|
|
148
|
+
// The `TcpProtocolV2` decodes the body as a UTF-8 string.
|
|
149
|
+
// Then `MessageProtocol` decodes that string into an `EmbMessage`.
|
|
150
|
+
// `MessageProtocolV3.decode(JsonObject json)` expects a JSON object.
|
|
151
|
+
|
|
152
|
+
// BUT the logs show the body content starting with a UUID (maybe msgId?) and NOT a JSON object.
|
|
153
|
+
// Example: $c8b8dca6-a423-48e1-8c15-911eebc316b0c...
|
|
154
|
+
|
|
155
|
+
// Is it possible the server is sending V2 protocol or a different format?
|
|
156
|
+
// Or maybe the body is encrypted?
|
|
157
|
+
// `MessageProtocolV2` has `encrypt`/`decrypt`.
|
|
158
|
+
// But `decode` calls `JsonObject.parse(body)`.
|
|
159
|
+
|
|
160
|
+
// Let's look closely at the Java code again.
|
|
161
|
+
// TcpProtocolV2:
|
|
162
|
+
// byte[] body = ...
|
|
163
|
+
// cmd.setCmdBody(Strings.newStringUtf8(body));
|
|
164
|
+
|
|
165
|
+
// If the server sends `SEND_MSG` (0x3), the client receives it.
|
|
166
|
+
// The body SHOULD be a JSON string representing the message.
|
|
167
|
+
|
|
168
|
+
// Why does the log show `$UUID...`?
|
|
169
|
+
// Maybe the `TcpProtocolV2` logic for parsing the packet is slightly off?
|
|
170
|
+
// Packet structure:
|
|
171
|
+
// HEAD(1) + CMD(1) + OPT(1) + BODY_COUNT(1)
|
|
172
|
+
// If BODY_COUNT=1: + LENGTH(4) + BODY(LENGTH)
|
|
173
|
+
|
|
174
|
+
// My implementation:
|
|
175
|
+
// header[0] = HEAD;
|
|
176
|
+
// header[1] = cmdCode;
|
|
177
|
+
// header[3] = bodyCount;
|
|
178
|
+
// ...
|
|
179
|
+
// const bodyLen = buffer.readUInt32BE(4);
|
|
180
|
+
|
|
181
|
+
// Let's verify if I am reading the length correctly.
|
|
182
|
+
// If I read length wrong, I might be reading into the next packet or reading garbage.
|
|
183
|
+
|
|
184
|
+
// In the logs:
|
|
185
|
+
// Raw body: $c8b8dca6-a423-48e1-8c15-911eebc316b0c...
|
|
186
|
+
// This looks like a UUID.
|
|
187
|
+
// Maybe the body IS just a UUID? But SEND_MSG expects a full message.
|
|
188
|
+
|
|
189
|
+
// Is it possible that `0x3` (SEND_MSG) from server to client has a different format?
|
|
190
|
+
// `SendMessage` class:
|
|
191
|
+
// public byte getCmdCode() { return CmdCodes.SEND_MSG; }
|
|
192
|
+
|
|
193
|
+
// Wait, `SendMessage` is typically Client -> Server.
|
|
194
|
+
// Server -> Client message push is also `SEND_MSG`?
|
|
195
|
+
// Java client `TcpClient` handles received messages.
|
|
196
|
+
// It decodes the command.
|
|
197
|
+
|
|
198
|
+
// If the server is sending `SEND_MSG`, it should contain the message.
|
|
199
|
+
// The logs show what looks like a message ID (UUID).
|
|
200
|
+
|
|
201
|
+
// Maybe the packet has multiple bodies?
|
|
202
|
+
// My code throws "Unsupported body count" if != 0 or 1.
|
|
203
|
+
// It didn't throw that. So bodyCount is 1.
|
|
204
|
+
|
|
205
|
+
// Maybe the length includes something else?
|
|
206
|
+
// `TcpProtocolV2`: `Bytes.bytes4ToInt(bytesLen)` (Big Endian usually).
|
|
207
|
+
|
|
208
|
+
// Let's try to parse the body as if it might be encrypted or formatted differently?
|
|
209
|
+
// Or maybe I should log the hex of the body to see what's going on.
|
|
210
|
+
|
|
211
|
+
// If I look at the "Raw body" output again:
|
|
212
|
+
// $c8b8dca6-a423-48e1-8c15-911eebc316b0c...
|
|
213
|
+
// It starts with `$`.
|
|
214
|
+
// A UUID is 36 chars.
|
|
215
|
+
// The log shows more chars after it.
|
|
216
|
+
// It looks like `UUID` + `Content`?
|
|
217
|
+
|
|
218
|
+
// Wait! In Java `MessageProtocolV2`:
|
|
219
|
+
// encode: writes JSON.
|
|
220
|
+
// decode: parses JSON.
|
|
221
|
+
|
|
222
|
+
// Is it possible the server is using a custom serialization that is NOT JSON?
|
|
223
|
+
// Or maybe I am connecting to a server version that uses a different protocol?
|
|
224
|
+
|
|
225
|
+
// Let's relax the JSON requirement and return a raw message if JSON fails, just to see what happens.
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const json = JSON.parse(cleanJsonStr);
|
|
229
|
+
const sign = json.sign;
|
|
230
|
+
const content = sign ? json.content : decryptContent(json.content);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: json.msg_id,
|
|
234
|
+
type: json.msg_type,
|
|
235
|
+
content: content,
|
|
236
|
+
fromType: json.from_type,
|
|
237
|
+
fromId: json.from_id,
|
|
238
|
+
fromName: json.from_name,
|
|
239
|
+
fromCompany: json.from_company,
|
|
240
|
+
toType: json.to_type,
|
|
241
|
+
toId: json.to_id,
|
|
242
|
+
toName: json.to_name,
|
|
243
|
+
toCompany: json.to_company,
|
|
244
|
+
sendTime: json.send_time,
|
|
245
|
+
read: json.is_read === 1,
|
|
246
|
+
sign: sign
|
|
247
|
+
};
|
|
248
|
+
} catch (e) {
|
|
249
|
+
// If parsing fails, use regex to extract key fields as a fallback
|
|
250
|
+
// The raw body seems to have format: $UUID...{JSON content}...binary?
|
|
251
|
+
// Or maybe: $UUID...JSON...
|
|
252
|
+
|
|
253
|
+
// Let's try to extract JSON-like structure using regex if standard parsing fails
|
|
254
|
+
// This is risky but might work for the mixed content we are seeing
|
|
255
|
+
const jsonMatch = jsonStr.match(/(\{.*\})/s);
|
|
256
|
+
if (jsonMatch) {
|
|
257
|
+
try {
|
|
258
|
+
const json = JSON.parse(jsonMatch[1]);
|
|
259
|
+
const sign = json.sign;
|
|
260
|
+
const content = sign ? json.content : decryptContent(json.content);
|
|
261
|
+
return {
|
|
262
|
+
id: json.msg_id,
|
|
263
|
+
type: json.msg_type,
|
|
264
|
+
content: content,
|
|
265
|
+
fromType: json.from_type,
|
|
266
|
+
fromId: json.from_id,
|
|
267
|
+
fromName: json.from_name,
|
|
268
|
+
fromCompany: json.from_company,
|
|
269
|
+
toType: json.to_type,
|
|
270
|
+
toId: json.to_id,
|
|
271
|
+
toName: json.to_name,
|
|
272
|
+
toCompany: json.to_company,
|
|
273
|
+
sendTime: json.send_time,
|
|
274
|
+
read: json.is_read === 1,
|
|
275
|
+
sign: sign
|
|
276
|
+
};
|
|
277
|
+
} catch (innerE) {
|
|
278
|
+
// ignore
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fallback: return raw content for debugging
|
|
283
|
+
return {
|
|
284
|
+
id: 'unknown',
|
|
285
|
+
type: 1, // TEXT
|
|
286
|
+
content: jsonStr, // Return original string
|
|
287
|
+
fromType: 1,
|
|
288
|
+
fromId: 'unknown',
|
|
289
|
+
toType: 1,
|
|
290
|
+
toId: 'unknown',
|
|
291
|
+
sendTime: Date.now(),
|
|
292
|
+
read: false
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MsgType, ParticipantType } from './constants.js';
|
|
2
|
+
|
|
3
|
+
export interface EmbMessage {
|
|
4
|
+
id: string;
|
|
5
|
+
type: MsgType;
|
|
6
|
+
content: string; // Encrypted or plain? Java code suggests encryption.
|
|
7
|
+
fromType: ParticipantType;
|
|
8
|
+
fromId: string;
|
|
9
|
+
fromName?: string;
|
|
10
|
+
fromCompany?: string;
|
|
11
|
+
toType: ParticipantType;
|
|
12
|
+
toId: string;
|
|
13
|
+
toName?: string;
|
|
14
|
+
toCompany?: string;
|
|
15
|
+
sendTime: number;
|
|
16
|
+
read: boolean;
|
|
17
|
+
sign?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClientVerifyInfo {
|
|
21
|
+
userId: string;
|
|
22
|
+
deviceUID: string;
|
|
23
|
+
deviceName: string;
|
|
24
|
+
deviceToken: string;
|
|
25
|
+
accessToken: string;
|
|
26
|
+
version: string;
|
|
27
|
+
force: boolean;
|
|
28
|
+
protocolVersion: number;
|
|
29
|
+
isFirstLogin: boolean;
|
|
30
|
+
secret?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EmbCommand {
|
|
34
|
+
cmdCode: number;
|
|
35
|
+
body?: string; // JSON string
|
|
36
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelOnboardingAdapter,
|
|
3
|
+
ClawdbotConfig,
|
|
4
|
+
WizardPrompter,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import type { LinkConfig } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const channel = "link" as const;
|
|
9
|
+
|
|
10
|
+
function getLinkConfig(cfg: ClawdbotConfig): LinkConfig | undefined {
|
|
11
|
+
return cfg.channels?.link as LinkConfig | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const linkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
15
|
+
channel,
|
|
16
|
+
getStatus: async ({ cfg }) => {
|
|
17
|
+
const linkCfg = getLinkConfig(cfg);
|
|
18
|
+
const configured = Boolean(
|
|
19
|
+
linkCfg?.host && linkCfg?.port && linkCfg?.accessToken
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const statusLines: string[] = [];
|
|
23
|
+
if (!configured) {
|
|
24
|
+
statusLines.push("Link: needs host, port, and accessToken");
|
|
25
|
+
} else {
|
|
26
|
+
statusLines.push(`Link: configured (host: ${linkCfg?.host})`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
channel,
|
|
31
|
+
configured,
|
|
32
|
+
statusLines,
|
|
33
|
+
selectionHint: configured ? "configured" : "needs config",
|
|
34
|
+
quickstartScore: configured ? 2 : 0,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
configure: async ({ cfg, prompter }) => {
|
|
39
|
+
let next = cfg;
|
|
40
|
+
const linkCfg = getLinkConfig(next) || {} as LinkConfig;
|
|
41
|
+
|
|
42
|
+
const host = await prompter.text({
|
|
43
|
+
message: "Link Server Host",
|
|
44
|
+
initialValue: linkCfg.host,
|
|
45
|
+
placeholder: "e.g. embtcpbeta.bingolink.biz",
|
|
46
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const portStr = await prompter.text({
|
|
50
|
+
message: "Link Server Port",
|
|
51
|
+
initialValue: linkCfg.port ? String(linkCfg.port) : undefined,
|
|
52
|
+
placeholder: "e.g. 20081",
|
|
53
|
+
validate: (value) => {
|
|
54
|
+
if (!value?.trim()) return "Required";
|
|
55
|
+
const n = Number(value);
|
|
56
|
+
if (isNaN(n) || n <= 0) return "Must be a positive number";
|
|
57
|
+
return undefined;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const port = Number(portStr);
|
|
61
|
+
|
|
62
|
+
const accessToken = await prompter.text({
|
|
63
|
+
message: "User Access Token",
|
|
64
|
+
initialValue: linkCfg.accessToken,
|
|
65
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const newLinkConfig: LinkConfig = {
|
|
69
|
+
...linkCfg,
|
|
70
|
+
host: String(host).trim(),
|
|
71
|
+
port,
|
|
72
|
+
accessToken: String(accessToken).trim(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
next = {
|
|
76
|
+
...next,
|
|
77
|
+
channels: {
|
|
78
|
+
...next.channels,
|
|
79
|
+
link: newLinkConfig,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return { cfg: next };
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
disable: (cfg) => {
|
|
87
|
+
// To disable, we might just remove the config or set a disabled flag if supported.
|
|
88
|
+
// Since LinkConfig doesn't have an 'enabled' flag in the type definition yet,
|
|
89
|
+
// we can just return the config as is or remove the link section.
|
|
90
|
+
// However, usually 'enabled' is checked at the framework level or added to the config.
|
|
91
|
+
// For now, let's just return the config.
|
|
92
|
+
// Better yet, let's remove the link config to effectively disable it since we don't have an enabled flag.
|
|
93
|
+
const { link, ...otherChannels } = cfg.channels || {};
|
|
94
|
+
return {
|
|
95
|
+
...cfg,
|
|
96
|
+
channels: otherChannels,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClawdbotConfig,
|
|
3
|
+
type ReplyPayload,
|
|
4
|
+
type RuntimeEnv,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import { sendMessageLink } from "./send.js";
|
|
7
|
+
import { getLinkRuntime } from "./runtime.js";
|
|
8
|
+
import { LinkConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type CreateLinkReplyDispatcherParams = {
|
|
11
|
+
cfg: ClawdbotConfig;
|
|
12
|
+
agentId: string;
|
|
13
|
+
runtime: RuntimeEnv;
|
|
14
|
+
chatId: string;
|
|
15
|
+
replyToMessageId?: string;
|
|
16
|
+
accountId?: string;
|
|
17
|
+
linkConfig: LinkConfig;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function createLinkReplyDispatcher(params: CreateLinkReplyDispatcherParams) {
|
|
21
|
+
const core = getLinkRuntime();
|
|
22
|
+
const { cfg, agentId, chatId, accountId, linkConfig } = params;
|
|
23
|
+
|
|
24
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
25
|
+
(core.channel.reply as any).createReplyDispatcherWithTyping({
|
|
26
|
+
onReplyStart: () => {},
|
|
27
|
+
deliver: async (payload: ReplyPayload, info: any) => {
|
|
28
|
+
const text = payload.text ?? "";
|
|
29
|
+
if (!text.trim()) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await sendMessageLink({
|
|
35
|
+
to: chatId,
|
|
36
|
+
text,
|
|
37
|
+
accountId,
|
|
38
|
+
cfg: linkConfig
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
params.runtime.error?.(
|
|
42
|
+
`link[${accountId}] ${info?.kind} reply failed: ${String(error)}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
onError: async (error: any, info: any) => {
|
|
47
|
+
params.runtime.error?.(
|
|
48
|
+
`link[${accountId}] ${info.kind} reply failed: ${String(error)}`,
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
onIdle: async () => {},
|
|
52
|
+
onCleanup: () => {},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
dispatcher,
|
|
57
|
+
replyOptions,
|
|
58
|
+
markDispatchIdle,
|
|
59
|
+
};
|
|
60
|
+
}
|
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 setLinkRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getLinkRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("Link runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getLinkClient } from "./client-manager.js";
|
|
3
|
+
import { CmdCodes, MsgType, ParticipantType } from "./link/constants.js";
|
|
4
|
+
import { EmbMessage } from "./link/types.js";
|
|
5
|
+
import { LinkConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export async function sendMessageLink(params: {
|
|
8
|
+
to: string;
|
|
9
|
+
text: string;
|
|
10
|
+
accountId?: string;
|
|
11
|
+
cfg: LinkConfig; // Might need config for some context
|
|
12
|
+
}): Promise<void> {
|
|
13
|
+
const accountId = params.accountId || "default";
|
|
14
|
+
const client = getLinkClient(accountId);
|
|
15
|
+
|
|
16
|
+
if (!client) {
|
|
17
|
+
throw new Error(`Link client not found for account ${accountId}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// SECURITY: Only allow sending messages to self
|
|
21
|
+
// Note: verifyInfo.userId is populated in client.ts
|
|
22
|
+
const allowedUserId = client.config.verifyInfo?.userId;
|
|
23
|
+
if (!allowedUserId || params.to !== allowedUserId) {
|
|
24
|
+
// console.warn(`Link: Blocked outgoing message to ${params.to}. Only self-messages are allowed.`);
|
|
25
|
+
// Silently fail or throw? Throwing might cause retries or errors in logs.
|
|
26
|
+
// Let's throw for now to make it explicit.
|
|
27
|
+
throw new Error(`Link: Sending messages to others (${params.to}) is restricted. Only self-messages allowed.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const msg: EmbMessage = {
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
type: MsgType.TEXT,
|
|
33
|
+
content: params.text,
|
|
34
|
+
fromType: ParticipantType.USER, // Or SYSTEM? Using USER for bot acting as user
|
|
35
|
+
fromId: allowedUserId,
|
|
36
|
+
toType: ParticipantType.USER,
|
|
37
|
+
toId: params.to,
|
|
38
|
+
sendTime: Date.now(),
|
|
39
|
+
read: false
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
client.sendMessage(msg);
|
|
43
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { ClientVerifyInfo } from "./link/types.js";
|
|
3
|
+
|
|
4
|
+
export interface LinkConfig {
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
accessToken: string;
|
|
8
|
+
verifyInfo?: Partial<ClientVerifyInfo>;
|
|
9
|
+
heartbeatIntervalMs?: number;
|
|
10
|
+
protocol?: "tcp" | "ws"; // Default tcp
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LinkAccountConfig {
|
|
14
|
+
accountId?: string;
|
|
15
|
+
config: LinkConfig;
|
|
16
|
+
}
|