@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 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
+ }