@rlynicrisis/link 0.0.9 → 0.1.2

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
@@ -7,13 +7,25 @@
7
7
  - **协议支持**: 基于 TCP 的私有协议 (EMB Protocol V3),支持 Protobuf 消息序列化。
8
8
  - **消息类型**:
9
9
  - ✅ 文本消息 (Text)
10
- - ✅ 文件消息 (File) - *需配合文件上传服务使用*
11
- - **安全机制**:
12
- - 🔒 **仅限己方消息**: 插件默认仅处理当前登录用户发送给自己的消息(即“文件传输助手”模式),忽略群聊和其他用户的私聊,确保数据安全。
10
+ - **接入模式**:
11
+ - 📁 **FileHelper 模式(默认)**: 仅处理当前登录用户发送给自己的消息,忽略群聊和其他私聊,确保数据安全。
12
+ - 👥 **群组模式**: 配置 `groupId` 后监听指定群的消息,由群机器人 Webhook(HTTP POST)回复。
13
13
  - **连接管理**:
14
14
  - 💓 自动心跳保活
15
15
  - 🔄 断线自动重连
16
- - 🎫 **Token 自动刷新**: 支持配置 `refreshToken` 和 `ssoUrl`,在 Token 过期时自动通过 SSO 接口刷新并重连。
16
+ - 🎫 **Token 自动刷新**: 支持配置 `refreshToken`,在 Token 过期时自动通过 SSO 接口刷新并重连。
17
+
18
+ ## 默认值
19
+
20
+ 以下字段均有默认值,可以省略不填:
21
+
22
+ | 字段 | 默认值 |
23
+ |------|--------|
24
+ | `host` | `embtcp.bingolink.biz:20081` |
25
+ | `ssoUrl` | `https://www.bingolink.biz:443/sso` |
26
+ | `notificationApiUrl` | `https://notificationapi.bingolink.biz:443/notificationapi` |
27
+
28
+ **必填字段只有 `accessToken`**(群组模式还需额外填写 `groupId` 和 `botToken`)。
17
29
 
18
30
  ## 安装与配置
19
31
 
@@ -32,68 +44,65 @@ npm run build
32
44
 
33
45
  在您的 OpenClaw 主配置文件(通常是 `config.yaml` 或 `config.json`)中,添加以下内容:
34
46
 
35
- #### 频道配置 (Channel Config)
36
-
37
- 在 `channels` 部分添加 `link` 配置:
47
+ #### 最简配置(FileHelper 模式)
38
48
 
39
49
  ```yaml
40
50
  channels:
41
51
  link:
42
- enabled: true
43
- # Link 服务地址 (格式: host:port)
44
- host: "embtcpbeta.bingolink.biz:20081"
45
-
46
- # 鉴权信息 (必填)
52
+ # 必填:鉴权 Token
47
53
  accessToken: "your_access_token_here"
48
-
49
- # Token 自动刷新配置 (可选,推荐配置)
50
- refreshToken: "your_refresh_token_here"
51
- ssoUrl: "https://sso.example.com" # SSO 服务地址
52
-
53
- # 高级配置 (可选)
54
- # heartbeatIntervalMs: 30000 # 心跳间隔,默认 30秒
55
- # verifyInfo: # 如果需要覆盖默认生成的设备信息,可在此配置
56
- # deviceName: "CustomBotName"
54
+
55
+ # 以下均可省略,使用默认值:
56
+ # host: "embtcp.bingolink.biz:20081"
57
+ # ssoUrl: "https://www.bingolink.biz:443/sso"
58
+
59
+ # Token 自动刷新(可选,推荐配置)
60
+ # refreshToken: "your_refresh_token_here"
61
+ ```
62
+
63
+ #### 完整配置参考
64
+
65
+ ```yaml
66
+ channels:
67
+ link:
68
+ host: "embtcp.bingolink.biz:20081" # 消息服务地址,可省略
69
+ accessToken: "your_access_token_here" # 必填
70
+ refreshToken: "your_refresh_token_here" # 可选,用于 Token 自动刷新
71
+ ssoUrl: "https://www.bingolink.biz:443/sso" # 可省略,刷新 Token 时使用
72
+ heartbeatIntervalMs: 30000 # 心跳间隔,默认 30 秒
57
73
  ```
58
74
 
59
75
  > **注意**:
60
76
  > - `userId` 将自动从 `accessToken` (JWT) 中解析。
61
- > - `deviceUID` 将根据机器特征自动生成,确保同一设备上的稳定性。
62
- > - `host` 参数现已支持 `host:port` 格式,无需单独配置 `port`。
77
+ > - `deviceUID` 将根据机器特征自动生成。
78
+ > - `host` 支持 `host:port` 格式,默认端口 `20081`。
63
79
 
64
80
  ## 接入 OpenClaw
65
81
 
66
82
  ### 1. 安装插件
67
83
 
68
- 通过 OpenClaw CLI 安装本插件:
69
-
70
84
  ```bash
71
85
  openclaw plugins install @rlynicrisis/link
72
86
  ```
73
87
 
74
88
  ### 2. 配置频道
75
89
 
76
- 使用 OpenClaw CLI 的交互式引导添加频道配置:
77
-
78
90
  ```bash
79
91
  openclaw channels add
80
92
  ```
81
93
 
82
- 在引导过程中选择 **Link**,并按照提示输入以下信息:
94
+ 在引导过程中选择 **Link**,按提示输入:
83
95
 
84
- 1. **Link Server Host**: 输入消息服务地址(例如 `embtcpbeta.bingolink.biz:20081`)。
85
- 2. **User Access Token**: 输入初始的 Access Token。
86
- 3. **Refresh Token** (可选): 输入 Refresh Token,用于 Token 自动刷新。
87
- 4. **SSO URL** (可选): 输入 SSO 认证服务地址(例如 `https://sso.example.com`),用于 Token 刷新请求。
88
-
89
- 配置完成后,OpenClaw 将自动连接并开始监听消息。
96
+ 1. **User Access Token**(必填)
97
+ 2. **Refresh Token**(可选,推荐填写,用于 Token 自动刷新)
98
+ 3. 其余字段均有默认值,直接回车跳过即可
90
99
 
91
100
  ### 3. 获取 Token 示例
92
101
 
93
- 您可以使用以下命令通过账号密码获取初始的 Access Token 和 Refresh Token:
102
+ 通过账号密码获取 Access Token 和 Refresh Token:
94
103
 
95
104
  ```bash
96
- curl --location --request POST 'https://www.bingolink.biz/sso/oauth2/token' \
105
+ curl --location --request POST 'https://www.bingolink.biz:443/sso/oauth2/token' \
97
106
  --header 'Authorization: Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0' \
98
107
  --header 'Content-Type: application/x-www-form-urlencoded' \
99
108
  --data-urlencode 'grant_type=password' \
@@ -101,36 +110,114 @@ curl --location --request POST 'https://www.bingolink.biz/sso/oauth2/token' \
101
110
  --data-urlencode 'password=个人密码'
102
111
  ```
103
112
 
104
- > **提示**: 请将 `username` 和 `password` 替换为您的实际账号信息。响应结果将包含 `access_token` 和 `refresh_token`。
113
+ 响应结果中的 `access_token` 和 `refresh_token` 即为所需的 Token。
105
114
 
106
- ## 开发调试
115
+ ## 群组接入模式
107
116
 
108
- ### 单元测试
117
+ FileHelper 模式之外,插件还支持监听指定群的消息,并通过**群机器人 Webhook** 回复。
109
118
 
110
- 运行所有单元测试:
119
+ ### 工作原理
111
120
 
112
- ```bash
113
- npm test
121
+ - **接收**:建立 TCP 长连接,过滤出发往 `groupId` 的群消息,触发 OpenClaw 的 Agent 处理流程。
122
+ - **发送**:Agent 回复内容通过 HTTP POST 发送到群机器人 Webhook 地址,而非 TCP 自发自收。
123
+
124
+ ### 配置示例
125
+
126
+ ```yaml
127
+ channels:
128
+ link:
129
+ accounts:
130
+ mygroup:
131
+ accessToken: "your_access_token_here"
132
+ refreshToken: "your_refresh_token_here" # 可选
133
+
134
+ # 群组接入配置
135
+ groupId: "3ab33646-3a55-46fd-97ba-868d4bf29915" # 要监听的群 ID
136
+ botToken: "xxxxxxxxxxxxxxxx" # 群机器人 Webhook Token
137
+
138
+ # notificationApiUrl 可省略,默认:
139
+ # https://notificationapi.bingolink.biz:443/notificationapi
140
+
141
+ bindings:
142
+ - agentId: "group-agent"
143
+ match:
144
+ channel: "link"
145
+ accountId: "mygroup"
114
146
  ```
115
147
 
116
- ### 手动连接测试
148
+ ### 配置字段说明
117
149
 
118
- 可以使用 `test/manual-connect.ts` 脚本单独测试连接和基本消息收发:
150
+ | 字段 | 必填 | 说明 |
151
+ |------|------|------|
152
+ | `groupId` | 群组模式必填 | 要监听的群 ID |
153
+ | `botToken` | 群组模式必填 | 群机器人 Webhook Token |
154
+ | `notificationApiUrl` | 可省略 | Webhook 服务基础地址,默认 `https://notificationapi.bingolink.biz:443/notificationapi` |
119
155
 
120
- ```bash
121
- npx tsx test/manual-connect.ts
156
+ > **说明**:`groupId` + `botToken` 同时存在时自动启用群组模式,否则退回到 FileHelper 模式。
157
+
158
+ ## 多账号支持
159
+
160
+ 插件支持在同一个 OpenClaw 实例中同时连接多个 Link 账号,每个账号独立维护连接和消息路由。
161
+
162
+ ### 配置格式
163
+
164
+ ```yaml
165
+ channels:
166
+ link:
167
+ accounts:
168
+ # FileHelper 账号(最简配置)
169
+ default:
170
+ accessToken: "token_for_default_account"
171
+ refreshToken: "refresh_token_for_default"
172
+
173
+ # 群组账号
174
+ mygroup:
175
+ accessToken: "token_for_group_account"
176
+ groupId: "3ab33646-3a55-46fd-97ba-868d4bf29915"
177
+ botToken: "xxxxxxxxxxxxxxxx"
178
+
179
+ bindings:
180
+ - agentId: "main"
181
+ match:
182
+ channel: "link"
183
+ accountId: "default"
184
+ - agentId: "group-agent"
185
+ match:
186
+ channel: "link"
187
+ accountId: "mygroup"
188
+ ```
189
+
190
+ ### 向后兼容
191
+
192
+ **无需修改现有配置**。如果没有 `accounts` 字段,插件会将根级别的字段视为 `accountId = "default"` 的账号:
193
+
194
+ ```yaml
195
+ # 旧配置格式,继续有效
196
+ channels:
197
+ link:
198
+ accessToken: "your_token"
199
+ refreshToken: "your_refresh_token"
122
200
  ```
123
201
 
124
- ### 发送文件消息测试
202
+ > `accounts` 字段与根级别的 `accessToken` 互斥。当 `accounts` 存在时,根级别字段会被忽略。
125
203
 
126
- 测试发送文件类型消息(构造虚拟文件信息):
204
+ ## 开发调试
205
+
206
+ ### 单元测试
127
207
 
128
208
  ```bash
129
- npx tsx test/send-file.ts
209
+ npm test
210
+ ```
211
+
212
+ ### 手动连接测试
213
+
214
+ ```bash
215
+ npx tsx test/manual-connect.ts
130
216
  ```
131
217
 
132
218
  ## 常见问题
133
219
 
134
- - **消息发送失败 (Protobuf Error)**: 确保服务端支持 V3 协议,且 `MsgType` 枚举值与服务端定义一致(如 File=3)。
135
- - **Token 过期**: 如果配置了 `refreshToken` 和 `ssoUrl`,插件会自动尝试刷新。否则需手动更新配置文件中的 `accessToken`。
136
- - **无法收到消息**: 检查 `bot.ts` 中的安全过滤逻辑,确保发送者和接收者均为当前登录用户。
220
+ - **Token 过期**: 配置了 `refreshToken` 后,插件会在收到服务端刷新信号时自动续期。否则需手动更新 `accessToken`。
221
+ - **收不到群消息**: 确认 `groupId` 和 `botToken` 均已配置,且 `groupId` 与实际群 ID 一致。
222
+ - **Webhook 调用失败**: 检查 `notificationApiUrl` 是否可访问,以及 `botToken` 是否有效。
223
+ - **FileHelper 模式收不到消息**: 发送者和接收者必须均为当前登录用户自身(自发自收)。
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)" },
13
11
  "accessToken": { "type": "string", "description": "User Access Token" },
14
12
  "refreshToken": { "type": "string", "description": "User Refresh Token (Optional)" },
15
- "heartbeatIntervalMs": { "type": "number", "default": 30000 }
13
+ "groupId": { "type": "string", "description": "Group ID to monitor (enables group bot mode)" },
14
+ "botToken": { "type": "string", "description": "Group bot webhook token" },
15
+ "host": { "type": "string", "description": "Link Server Host", "default": "embtcp.bingolink.biz:20081" },
16
+ "ssoUrl": { "type": "string", "description": "SSO URL for refreshing token", "default": "https://www.bingolink.biz:443/sso" },
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
+ "accessToken": { "type": "string", "description": "User Access Token" },
27
+ "refreshToken": { "type": "string", "description": "User Refresh Token (Optional)" },
28
+ "groupId": { "type": "string", "description": "Group ID to monitor (enables group bot mode)" },
29
+ "botToken": { "type": "string", "description": "Group bot webhook token" },
30
+ "host": { "type": "string", "description": "Link Server Host", "default": "embtcp.bingolink.biz:20081" },
31
+ "ssoUrl": { "type": "string", "description": "SSO URL for refreshing token", "default": "https://www.bingolink.biz:443/sso" },
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.2",
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
+ }