@rlynicrisis/link 0.0.9 → 0.1.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 CHANGED
@@ -103,6 +103,91 @@ curl --location --request POST 'https://www.bingolink.biz/sso/oauth2/token' \
103
103
 
104
104
  > **提示**: 请将 `username` 和 `password` 替换为您的实际账号信息。响应结果将包含 `access_token` 和 `refresh_token`。
105
105
 
106
+ ## 多账号支持 (Multi-Account Support)
107
+
108
+ 插件支持在同一个 OpenClaw 实例中同时连接多个 Link 账号,每个账号独立维护连接和消息路由。
109
+
110
+ ### 配置格式
111
+
112
+ 在 `channels.link` 下添加 `accounts` 字段,以账号 ID 为键,每个账号独立配置:
113
+
114
+ ```yaml
115
+ channels:
116
+ link:
117
+ accounts:
118
+ default:
119
+ host: "embtcp.bingolink.biz:20081"
120
+ accessToken: "token_for_default_account"
121
+ refreshToken: "refresh_token_for_default"
122
+ ssoUrl: "https://www.bingolink.biz/sso"
123
+ work:
124
+ host: "embtcp.bingolink.biz:20081"
125
+ accessToken: "token_for_work_account"
126
+ refreshToken: "refresh_token_for_work"
127
+ ssoUrl: "https://www.bingolink.biz/sso"
128
+ ```
129
+
130
+ ### 绑定配置
131
+
132
+ 使用 `bindings` 将不同账号路由到不同的 Agent:
133
+
134
+ ```yaml
135
+ bindings:
136
+ - agentId: "main"
137
+ match:
138
+ channel: "link"
139
+ accountId: "default"
140
+ - agentId: "work-agent"
141
+ match:
142
+ channel: "link"
143
+ accountId: "work"
144
+ ```
145
+
146
+ ### JSON 格式示例
147
+
148
+ ```json
149
+ {
150
+ "channels": {
151
+ "link": {
152
+ "accounts": {
153
+ "default": {
154
+ "host": "embtcp.bingolink.biz:20081",
155
+ "accessToken": "xxx",
156
+ "refreshToken": "xxx",
157
+ "ssoUrl": "https://www.bingolink.biz/sso"
158
+ },
159
+ "work": {
160
+ "host": "embtcp.bingolink.biz:20081",
161
+ "accessToken": "yyy",
162
+ "refreshToken": "yyy",
163
+ "ssoUrl": "https://www.bingolink.biz/sso"
164
+ }
165
+ }
166
+ }
167
+ },
168
+ "bindings": [
169
+ { "agentId": "main", "match": { "channel": "link", "accountId": "default" } },
170
+ { "agentId": "work-agent", "match": { "channel": "link", "accountId": "work" } }
171
+ ]
172
+ }
173
+ ```
174
+
175
+ ### 向后兼容 (Backward Compatibility)
176
+
177
+ **无需修改现有配置**。如果没有 `accounts` 字段,插件会将根级别的 `host`、`accessToken` 等字段视为 `accountId = "default"` 的账号,行为与之前完全一致:
178
+
179
+ ```yaml
180
+ # 旧配置格式,继续有效
181
+ channels:
182
+ link:
183
+ host: "embtcp.bingolink.biz:20081"
184
+ accessToken: "your_token"
185
+ refreshToken: "your_refresh_token"
186
+ ssoUrl: "https://www.bingolink.biz/sso"
187
+ ```
188
+
189
+ > **注意**: `accounts` 字段与根级别的 `host`/`accessToken` 互斥。当 `accounts` 存在时,根级别字段会被忽略。
190
+
106
191
  ## 开发调试
107
192
 
108
193
  ### 单元测试
package/index.ts CHANGED
File without changes
@@ -8,11 +8,31 @@
8
8
  "type": "object",
9
9
  "additionalProperties": false,
10
10
  "properties": {
11
- "host": { "type": "string", "description": "Link Server Host (e.g. embtcpbeta.bingolink.biz:20081)" },
12
- "ssoUrl": { "type": "string", "description": "SSO URL for refreshing token (e.g. https://sso.example.com)" },
11
+ "host": { "type": "string", "description": "Link Server Host", "default": "embtcp.bingolink.biz:20081" },
12
+ "ssoUrl": { "type": "string", "description": "SSO URL for refreshing token", "default": "https://www.bingolink.biz:443/sso" },
13
13
  "accessToken": { "type": "string", "description": "User Access Token" },
14
14
  "refreshToken": { "type": "string", "description": "User Refresh Token (Optional)" },
15
- "heartbeatIntervalMs": { "type": "number", "default": 30000 }
15
+ "groupId": { "type": "string", "description": "Group ID to monitor (enables group bot mode)" },
16
+ "botToken": { "type": "string", "description": "Group bot webhook token" },
17
+ "notificationApiUrl": { "type": "string", "description": "Webhook base URL", "default": "https://notificationapi.bingolink.biz:443/notificationapi" },
18
+ "accounts": {
19
+ "type": "object",
20
+ "description": "Multi-account configuration. Keys are accountIds.",
21
+ "additionalProperties": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "required": ["accessToken"],
25
+ "properties": {
26
+ "host": { "type": "string", "description": "Link Server Host", "default": "embtcp.bingolink.biz:20081" },
27
+ "accessToken": { "type": "string", "description": "User Access Token" },
28
+ "refreshToken": { "type": "string", "description": "User Refresh Token (Optional)" },
29
+ "ssoUrl": { "type": "string", "description": "SSO URL for refreshing token", "default": "https://www.bingolink.biz:443/sso" },
30
+ "groupId": { "type": "string", "description": "Group ID to monitor (enables group bot mode)" },
31
+ "botToken": { "type": "string", "description": "Group bot webhook token" },
32
+ "notificationApiUrl": { "type": "string", "description": "Webhook base URL", "default": "https://notificationapi.bingolink.biz:443/notificationapi" }
33
+ }
34
+ }
35
+ }
16
36
  }
17
37
  }
18
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlynicrisis/link",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Link channel plugin",
6
6
  "files": [
package/src/bot.ts CHANGED
@@ -2,29 +2,34 @@ import {
2
2
  type ClawdbotConfig,
3
3
  type RuntimeEnv,
4
4
  } from "openclaw/plugin-sdk";
5
- import { LinkConfig } from "./types.js";
6
- import { createLinkClient, getLinkClient } from "./client-manager.js";
5
+ import { LinkConfig, LinkChannelConfig, resolveAccountConfig, applyLinkDefaults } from "./types.js";
6
+ import { createLinkClient, getLinkClient, removeLinkClient } from "./client-manager.js";
7
7
  import { getLinkRuntime } from "./runtime.js";
8
8
  import { createLinkReplyDispatcher } from "./reply-dispatcher.js";
9
9
  import { EmbMessage } from "./link/types.js";
10
10
  import { MsgType, ParticipantType } from "./link/constants.js";
11
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
12
+ export async function startLinkBot(cfg: ClawdbotConfig, runtime: RuntimeEnv, accountId: string = "default") {
13
+ const channelCfg = cfg.channels?.link as LinkChannelConfig | undefined;
14
+
15
+ if (!channelCfg) {
18
16
  return;
19
17
  }
20
-
18
+
19
+ const rawCfg = resolveAccountConfig(channelCfg, accountId);
20
+
21
+ if (!rawCfg) {
22
+ runtime.log?.(`Link: no config found for accountId=${accountId}`);
23
+ return;
24
+ }
25
+
26
+ const linkCfg = applyLinkDefaults(rawCfg);
27
+
21
28
  if (!linkCfg.accessToken) {
22
- runtime.log?.("Link channel configured but missing accessToken");
29
+ runtime.log?.(`Link channel configured but missing accessToken for accountId=${accountId}`);
23
30
  return;
24
31
  }
25
-
26
- const accountId = "default"; // For now single account support
27
-
32
+
28
33
  let client = getLinkClient(accountId);
29
34
  if (!client) {
30
35
  client = createLinkClient(accountId, linkCfg);
@@ -51,8 +56,8 @@ export async function startLinkBot(cfg: ClawdbotConfig, runtime: RuntimeEnv) {
51
56
  }
52
57
  }
53
58
 
54
- export async function stopLinkBot() {
55
- // Implement cleanup if needed
59
+ export async function stopLinkBot(accountId: string = "default") {
60
+ removeLinkClient(accountId);
56
61
  }
57
62
 
58
63
  async function handleLinkMessage(params: {
@@ -75,15 +80,17 @@ async function handleLinkMessage(params: {
75
80
 
76
81
  const senderId = msg.fromId || "unknown";
77
82
 
78
- // SECURITY: Only process messages sent by the bot user itself AND sent to itself (FileHelper/Self)
79
- // Note: userId is populated in client.ts into verifyInfo during connection
80
83
  const allowedUserId = linkCfg.verifyInfo?.userId;
81
84
  const receiverId = msg.toId || "unknown";
82
85
 
83
- console.log(`[LinkBot] allowedUserId=${allowedUserId}, senderId=${senderId}, receiverId=${receiverId}`);
86
+ console.log(`[LinkBot] allowedUserId=${allowedUserId}, senderId=${senderId}, receiverId=${receiverId}, toType=${msg.toType}`);
84
87
 
85
- if (!allowedUserId || senderId !== allowedUserId || receiverId !== allowedUserId) {
86
- return;
88
+ if (linkCfg.groupId) {
89
+ // Group mode: only accept messages sent to the configured group
90
+ if (msg.toId !== linkCfg.groupId || msg.toType !== ParticipantType.GROUP) return;
91
+ } else {
92
+ // FileHelper mode: only accept self-messages (sender == receiver == self)
93
+ if (!allowedUserId || senderId !== allowedUserId || receiverId !== allowedUserId) return;
87
94
  }
88
95
 
89
96
  // Convert content to string if it's a buffer
package/src/channel.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ChannelPlugin } from "openclaw/plugin-sdk";
2
2
  import { startLinkBot, stopLinkBot } from "./bot.js";
3
3
  import { linkOnboardingAdapter } from "./onboarding.js";
4
+ import { LinkChannelConfig } from "./types.js";
4
5
 
5
6
  export const linkPlugin: ChannelPlugin<any> = {
6
7
  id: "link",
@@ -51,17 +52,21 @@ export const linkPlugin: ChannelPlugin<any> = {
51
52
  },
52
53
  config: {
53
54
  listAccountIds: (cfg) => {
54
- // Return default account if channel is configured
55
- if (cfg?.channels?.link?.accessToken) { return ["default"]; }
56
- return [];
55
+ const linkCfg = cfg?.channels?.link as LinkChannelConfig | undefined;
56
+ if (!linkCfg) return [];
57
+ if (linkCfg.accounts) {
58
+ return Object.keys(linkCfg.accounts);
59
+ }
60
+ // Backward compat: single-account root config
61
+ if (linkCfg.accessToken) return ["default"];
62
+ return [];
57
63
  },
58
64
  resolveAccount: (cfg, accountId) => {
59
- // Return dummy account for now
60
65
  return {
61
66
  accountId,
62
67
  enabled: true,
63
68
  configured: true,
64
- name: "Link Bot",
69
+ name: `Link Bot (${accountId})`,
65
70
  } as any;
66
71
  },
67
72
  defaultAccountId: (cfg) => "default",
@@ -77,11 +82,11 @@ export const linkPlugin: ChannelPlugin<any> = {
77
82
  },
78
83
  gateway: {
79
84
  startAccount: async (ctx) => {
80
- await startLinkBot(ctx.cfg, ctx.runtime);
81
-
85
+ await startLinkBot(ctx.cfg, ctx.runtime, ctx.accountId);
86
+
82
87
  return new Promise((resolve) => {
83
88
  ctx.abortSignal.addEventListener("abort", () => {
84
- stopLinkBot();
89
+ stopLinkBot(ctx.accountId);
85
90
  resolve(undefined);
86
91
  });
87
92
  });
@@ -1,5 +1,5 @@
1
1
  import { LinkClient } from "./link/client.js";
2
- import { LinkConfig } from "./types.js";
2
+ import { LinkConfig, LINK_DEFAULTS } from "./types.js";
3
3
 
4
4
  const clients = new Map<string, LinkClient>();
5
5
 
@@ -14,7 +14,7 @@ export function createLinkClient(accountId: string, config: LinkConfig): LinkCli
14
14
  }
15
15
 
16
16
  // Parse host:port
17
- const parts = config.host.split(':');
17
+ const parts = (config.host || LINK_DEFAULTS.host).split(':');
18
18
  let host = parts[0];
19
19
  let port = 20081; // Default port
20
20
 
@@ -13,6 +13,7 @@ export class LinkClient extends EventEmitter {
13
13
  private heartbeatInterval: NodeJS.Timeout | null = null;
14
14
  private reconnectTimeout: NodeJS.Timeout | null = null;
15
15
  private isConnected = false;
16
+ private immediateReconnect = false;
16
17
 
17
18
  constructor(
18
19
  public readonly config: {
@@ -181,11 +182,13 @@ export class LinkClient extends EventEmitter {
181
182
 
182
183
  private scheduleReconnect() {
183
184
  if (this.reconnectTimeout) return;
185
+ const delay = this.immediateReconnect ? 0 : 5000;
186
+ this.immediateReconnect = false;
184
187
  this.reconnectTimeout = setTimeout(() => {
185
188
  this.reconnectTimeout = null;
186
189
  console.log('Reconnecting...');
187
190
  this.connect();
188
- }, 5000);
191
+ }, delay);
189
192
  }
190
193
 
191
194
  private processBuffer() {
@@ -250,6 +253,7 @@ export class LinkClient extends EventEmitter {
250
253
  if (body) {
251
254
  try {
252
255
  const msg = decodeMessage(body);
256
+ this.sendReceipt(msg.id);
253
257
  this.emit('message', msg);
254
258
  } catch (e) {
255
259
  console.error('Failed to decode message:', e);
@@ -278,11 +282,22 @@ export class LinkClient extends EventEmitter {
278
282
  // Ignore for now or log
279
283
  // console.log(`Received cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
280
284
  break;
285
+ case CmdCodes.FORCE_RECONNECT_DOWN:
286
+ console.log('Server signaled session reset (0x6b), will reconnect immediately on disconnect');
287
+ this.immediateReconnect = true;
288
+ break;
281
289
  default:
282
290
  console.log(`Received unknown cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
283
291
  }
284
292
  }
285
293
 
294
+ private sendReceipt(msgId: unknown) {
295
+ if (!this.socket || !this.isConnected) return;
296
+ console.log('Sending receipt for message:', msgId);
297
+ const packet = encodePacket(CmdCodes.SEND_MSG_RECEIPT, String(msgId));
298
+ this.socket.write(packet);
299
+ }
300
+
286
301
  private async refreshAccessToken() {
287
302
  if (!this.config.refreshToken || !this.config.ssoUrl) {
288
303
  console.error('Cannot refresh token: Missing refreshToken or ssoUrl in config');
@@ -19,6 +19,7 @@ export const CmdCodes = {
19
19
  SERVICE_TOKEN_UP: 0x10,
20
20
  SERVICE_TOKEN_DOWN: 0x11,
21
21
  CLEAR_DEVICE_OFFMSG: 0x14,
22
+ FORCE_RECONNECT_DOWN: 0x6b, // Server signals session reset / connection cycling
22
23
  } as const;
23
24
 
24
25
  export enum MsgType {
File without changes
@@ -1,5 +1,6 @@
1
1
  /*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars*/
2
- import * as $protobuf from "protobufjs/minimal.js";
2
+ import * as $protobufNs from "protobufjs/minimal.js";
3
+ const $protobuf = $protobufNs.default || $protobufNs;
3
4
 
4
5
  // Common aliases
5
6
  const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;
File without changes
package/src/link/types.ts CHANGED
File without changes
package/src/onboarding.ts CHANGED
File without changes
File without changes
package/src/runtime.ts CHANGED
File without changes
package/src/send.ts CHANGED
@@ -8,32 +8,48 @@ export async function sendMessageLink(params: {
8
8
  to: string;
9
9
  text: string;
10
10
  accountId?: string;
11
- cfg: LinkConfig; // Might need config for some context
11
+ cfg: LinkConfig;
12
12
  }): Promise<void> {
13
+ const { cfg } = params;
14
+
15
+ // Group bot mode: send via HTTP webhook
16
+ if (cfg.groupId && cfg.botToken && cfg.notificationApiUrl) {
17
+ const url = `${cfg.notificationApiUrl}/hook/send?token=${cfg.botToken}`;
18
+ const res = await fetch(url, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({
22
+ msgType: 'text',
23
+ content: { text: params.text }
24
+ })
25
+ });
26
+ if (!res.ok) {
27
+ throw new Error(`Group bot webhook failed: ${res.status} ${res.statusText}`);
28
+ }
29
+ return;
30
+ }
31
+
32
+ // FileHelper mode: send via TCP self-message
13
33
  const accountId = params.accountId || "default";
14
34
  const client = getLinkClient(accountId);
15
-
35
+
16
36
  if (!client) {
17
37
  throw new Error(`Link client not found for account ${accountId}`);
18
38
  }
19
39
 
20
- // SECURITY: Only allow sending messages to self
21
- // Note: verifyInfo.userId is populated in client.ts
40
+ // SECURITY: Only allow sending messages to self in non-group mode
22
41
  const allowedUserId = client.config.verifyInfo?.userId;
23
42
  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.`);
43
+ throw new Error(`Link: Sending messages to others (${params.to}) is restricted. Only self-messages allowed in non-group mode.`);
28
44
  }
29
45
 
30
46
  const msg: EmbMessage = {
31
47
  id: randomUUID(),
32
48
  type: MsgType.TEXT,
33
49
  content: params.text,
34
- fromType: ParticipantType.USER, // Or SYSTEM? Using USER for bot acting as user
50
+ fromType: ParticipantType.USER,
35
51
  fromId: allowedUserId,
36
- toType: ParticipantType.USER,
52
+ toType: ParticipantType.USER,
37
53
  toId: params.to,
38
54
  sendTime: Date.now(),
39
55
  read: false
package/src/types.ts CHANGED
@@ -1,18 +1,63 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { ClientVerifyInfo } from "./link/types.js";
3
3
 
4
+ export const LINK_DEFAULTS = {
5
+ host: 'embtcp.bingolink.biz:20081',
6
+ ssoUrl: 'https://www.bingolink.biz:443/sso',
7
+ notificationApiUrl: 'https://notificationapi.bingolink.biz:443/notificationapi',
8
+ } as const;
9
+
4
10
  export interface LinkConfig {
5
- host: string;
11
+ host?: string; // Default: embtcp.bingolink.biz:20081
6
12
  // port: number; // merged into host
7
13
  accessToken: string;
8
14
  refreshToken?: string;
9
- ssoUrl?: string;
15
+ ssoUrl?: string; // Default: https://www.bingolink.biz:443/sso
10
16
  verifyInfo?: Partial<ClientVerifyInfo>;
11
17
  heartbeatIntervalMs?: number;
12
18
  protocol?: "tcp" | "ws"; // Default tcp
19
+ // Group bot config
20
+ groupId?: string; // Group ID to monitor
21
+ botToken?: string; // Group bot webhook token
22
+ notificationApiUrl?: string; // Default: https://notificationapi.bingolink.biz:443/notificationapi
23
+ }
24
+
25
+ /** Fill in default values for optional fields that have known defaults. */
26
+ export function applyLinkDefaults(cfg: LinkConfig): LinkConfig {
27
+ return {
28
+ ...cfg,
29
+ host: cfg.host || LINK_DEFAULTS.host,
30
+ ssoUrl: cfg.ssoUrl || LINK_DEFAULTS.ssoUrl,
31
+ notificationApiUrl: cfg.notificationApiUrl || LINK_DEFAULTS.notificationApiUrl,
32
+ };
13
33
  }
14
34
 
15
35
  export interface LinkAccountConfig {
16
36
  accountId?: string;
17
37
  config: LinkConfig;
18
38
  }
39
+
40
+ /** Top-level channels.link config — supports both single-account and multi-account */
41
+ export interface LinkChannelConfig extends Partial<LinkConfig> {
42
+ accounts?: Record<string, LinkConfig>;
43
+ }
44
+
45
+ /**
46
+ * Resolve the LinkConfig for a given accountId.
47
+ * When `accounts` is present, looks up the named entry.
48
+ * Otherwise falls back to the root config (backward-compat single-account "default").
49
+ */
50
+ export function resolveAccountConfig(
51
+ channelCfg: LinkChannelConfig,
52
+ accountId: string
53
+ ): LinkConfig | undefined {
54
+ if (channelCfg.accounts) {
55
+ return channelCfg.accounts[accountId];
56
+ }
57
+ // Backward compat: treat root-level fields as the "default" account
58
+ if (accountId === "default" && channelCfg.host && channelCfg.accessToken) {
59
+ const { accounts, ...rest } = channelCfg as any;
60
+ return rest as LinkConfig;
61
+ }
62
+ return undefined;
63
+ }