@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 +139 -52
- package/index.ts +0 -0
- package/openclaw.plugin.json +23 -3
- package/package.json +1 -1
- package/src/bot.ts +27 -20
- package/src/channel.ts +13 -8
- package/src/client-manager.ts +2 -2
- package/src/link/client.ts +16 -1
- package/src/link/constants.ts +1 -0
- package/src/link/message.d.ts +0 -0
- package/src/link/message.js +2 -1
- package/src/link/protocol.ts +0 -0
- package/src/link/types.ts +0 -0
- package/src/onboarding.ts +0 -0
- package/src/reply-dispatcher.ts +0 -0
- package/src/runtime.ts +0 -0
- package/src/send.ts +26 -10
- package/src/types.ts +47 -2
package/README.md
CHANGED
|
@@ -7,13 +7,25 @@
|
|
|
7
7
|
- **协议支持**: 基于 TCP 的私有协议 (EMB Protocol V3),支持 Protobuf 消息序列化。
|
|
8
8
|
- **消息类型**:
|
|
9
9
|
- ✅ 文本消息 (Text)
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
10
|
+
- **接入模式**:
|
|
11
|
+
- 📁 **FileHelper 模式(默认)**: 仅处理当前登录用户发送给自己的消息,忽略群聊和其他私聊,确保数据安全。
|
|
12
|
+
- 👥 **群组模式**: 配置 `groupId` 后监听指定群的消息,由群机器人 Webhook(HTTP POST)回复。
|
|
13
13
|
- **连接管理**:
|
|
14
14
|
- 💓 自动心跳保活
|
|
15
15
|
- 🔄 断线自动重连
|
|
16
|
-
- 🎫 **Token 自动刷新**: 支持配置 `refreshToken
|
|
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
|
-
####
|
|
36
|
-
|
|
37
|
-
在 `channels` 部分添加 `link` 配置:
|
|
47
|
+
#### 最简配置(FileHelper 模式)
|
|
38
48
|
|
|
39
49
|
```yaml
|
|
40
50
|
channels:
|
|
41
51
|
link:
|
|
42
|
-
|
|
43
|
-
# Link 服务地址 (格式: host:port)
|
|
44
|
-
host: "embtcpbeta.bingolink.biz:20081"
|
|
45
|
-
|
|
46
|
-
# 鉴权信息 (必填)
|
|
52
|
+
# 必填:鉴权 Token
|
|
47
53
|
accessToken: "your_access_token_here"
|
|
48
|
-
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
ssoUrl: "https://
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
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`
|
|
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.
|
|
85
|
-
2.
|
|
86
|
-
3.
|
|
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
|
-
|
|
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
|
-
|
|
113
|
+
响应结果中的 `access_token` 和 `refresh_token` 即为所需的 Token。
|
|
105
114
|
|
|
106
|
-
##
|
|
115
|
+
## 群组接入模式
|
|
107
116
|
|
|
108
|
-
|
|
117
|
+
在 FileHelper 模式之外,插件还支持监听指定群的消息,并通过**群机器人 Webhook** 回复。
|
|
109
118
|
|
|
110
|
-
|
|
119
|
+
### 工作原理
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
150
|
+
| 字段 | 必填 | 说明 |
|
|
151
|
+
|------|------|------|
|
|
152
|
+
| `groupId` | 群组模式必填 | 要监听的群 ID |
|
|
153
|
+
| `botToken` | 群组模式必填 | 群机器人 Webhook Token |
|
|
154
|
+
| `notificationApiUrl` | 可省略 | Webhook 服务基础地址,默认 `https://notificationapi.bingolink.biz:443/notificationapi` |
|
|
119
155
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
135
|
-
-
|
|
136
|
-
-
|
|
220
|
+
- **Token 过期**: 配置了 `refreshToken` 后,插件会在收到服务端刷新信号时自动续期。否则需手动更新 `accessToken`。
|
|
221
|
+
- **收不到群消息**: 确认 `groupId` 和 `botToken` 均已配置,且 `groupId` 与实际群 ID 一致。
|
|
222
|
+
- **Webhook 调用失败**: 检查 `notificationApiUrl` 是否可访问,以及 `botToken` 是否有效。
|
|
223
|
+
- **FileHelper 模式收不到消息**: 发送者和接收者必须均为当前登录用户自身(自发自收)。
|
package/index.ts
CHANGED
|
File without changes
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
-
"
|
|
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
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
|
|
14
|
-
|
|
15
|
-
|
|
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?.(
|
|
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
|
-
|
|
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 (
|
|
86
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
});
|
package/src/client-manager.ts
CHANGED
|
@@ -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
|
|
package/src/link/client.ts
CHANGED
|
@@ -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
|
-
},
|
|
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');
|
package/src/link/constants.ts
CHANGED
package/src/link/message.d.ts
CHANGED
|
File without changes
|
package/src/link/message.js
CHANGED
|
@@ -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 $
|
|
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;
|
package/src/link/protocol.ts
CHANGED
|
File without changes
|
package/src/link/types.ts
CHANGED
|
File without changes
|
package/src/onboarding.ts
CHANGED
|
File without changes
|
package/src/reply-dispatcher.ts
CHANGED
|
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;
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
+
}
|