@qihoo/tuitui-openclaw-channel 1.0.17 → 1.0.19
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/openclaw.plugin.json +174 -1
- package/package.json +1 -1
- package/src/accounts.ts +23 -19
- package/src/channel.ts +32 -101
- package/src/inbound.ts +59 -36
- package/src/inbound_body_parse.ts +100 -0
- package/src/outbound.ts +23 -92
- package/src/tools.ts +63 -58
- package/src/types.ts +47 -27
- package/src/websocket.ts +110 -0
- package/src/confs.ts +0 -146
package/openclaw.plugin.json
CHANGED
|
@@ -6,5 +6,178 @@
|
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {}
|
|
9
|
+
},
|
|
10
|
+
"channelConfigs": {
|
|
11
|
+
"tuitui": {
|
|
12
|
+
"schema": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"enabled": { "type": "boolean", "default": true },
|
|
17
|
+
"appId": { "type": "string", "default": "" },
|
|
18
|
+
"appSecret": { "type": "string" },
|
|
19
|
+
"dmPolicy": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"default": "pairing",
|
|
22
|
+
"enum": ["pairing", "allowlist", "open", "disabled"]
|
|
23
|
+
},
|
|
24
|
+
"allowFrom": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"default": [],
|
|
27
|
+
"items": { "type": "string" }
|
|
28
|
+
},
|
|
29
|
+
"groupPolicy": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"default": "allowlist",
|
|
32
|
+
"enum": ["allowlist", "disabled"]
|
|
33
|
+
},
|
|
34
|
+
"requireMention": { "type": "boolean", "default": true },
|
|
35
|
+
"groupAllowFrom": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"default": [],
|
|
38
|
+
"items": { "type": "string" }
|
|
39
|
+
},
|
|
40
|
+
"channelContext": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"default": "thread",
|
|
43
|
+
"enum": ["channel", "thread"]
|
|
44
|
+
},
|
|
45
|
+
"emojiReaction": { "type": "boolean", "default": true },
|
|
46
|
+
"accounts": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"properties": {
|
|
52
|
+
"enabled": { "type": "boolean", "default": true },
|
|
53
|
+
"appId": { "type": "string", "default": "" },
|
|
54
|
+
"appSecret": { "type": "string" },
|
|
55
|
+
"dmPolicy": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"default": "pairing",
|
|
58
|
+
"enum": ["pairing", "allowlist", "open", "disabled"]
|
|
59
|
+
},
|
|
60
|
+
"allowFrom": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"default": [],
|
|
63
|
+
"items": { "type": "string" }
|
|
64
|
+
},
|
|
65
|
+
"groupPolicy": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"default": "allowlist",
|
|
68
|
+
"enum": ["allowlist", "disabled"]
|
|
69
|
+
},
|
|
70
|
+
"requireMention": { "type": "boolean", "default": true },
|
|
71
|
+
"groupAllowFrom": {
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": { "type": "string" }
|
|
74
|
+
},
|
|
75
|
+
"channelContext": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"default": "thread",
|
|
78
|
+
"enum": ["channel", "thread"]
|
|
79
|
+
},
|
|
80
|
+
"emojiReaction": { "type": "boolean", "default": true }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"uiHints": {
|
|
87
|
+
"enabled": { "order": 1, "help": "开启或关闭" },
|
|
88
|
+
"appId": {
|
|
89
|
+
"help": "推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)",
|
|
90
|
+
"order": 2
|
|
91
|
+
},
|
|
92
|
+
"appSecret": {
|
|
93
|
+
"help": "推推机器人密钥 Secret(推推机器人的 App Secret)",
|
|
94
|
+
"order": 3
|
|
95
|
+
},
|
|
96
|
+
"dmPolicy": {
|
|
97
|
+
"help": "私聊策略(pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊)",
|
|
98
|
+
"order": 10,
|
|
99
|
+
"advanced": true
|
|
100
|
+
},
|
|
101
|
+
"allowFrom": {
|
|
102
|
+
"help": "私聊白名单-域账号(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)。对群聊/团队也能生效",
|
|
103
|
+
"order": 11,
|
|
104
|
+
"advanced": true
|
|
105
|
+
},
|
|
106
|
+
"groupPolicy": {
|
|
107
|
+
"help": "群聊/团队策略(allowlist=白名单;disabled=禁用)",
|
|
108
|
+
"order": 12,
|
|
109
|
+
"advanced": true
|
|
110
|
+
},
|
|
111
|
+
"requireMention": {
|
|
112
|
+
"help": "群组/团队是否需要 @ 机器人才触发 Agent(默认 true)。注意:如果你关闭了这个开关,且有多个龙虾机器人在同一个群里,他们会聊的停不下来",
|
|
113
|
+
"order": 13,
|
|
114
|
+
"advanced": true
|
|
115
|
+
},
|
|
116
|
+
"groupAllowFrom": {
|
|
117
|
+
"help": "群组/团队白名单-包含群ID、团队ID或频道ID(仅在 groupPolicy=allowlist 生效)",
|
|
118
|
+
"order": 14,
|
|
119
|
+
"advanced": true
|
|
120
|
+
},
|
|
121
|
+
"channelContext": {
|
|
122
|
+
"help": "团队-频道上下文(channel=每个频道共享上下文; thread=每个帖子使用独立上下文)。修改后对后续对话生效,对历史数据不生效。",
|
|
123
|
+
"order": 15,
|
|
124
|
+
"advanced": true
|
|
125
|
+
},
|
|
126
|
+
"emojiReaction": {
|
|
127
|
+
"help": "在收到消息后,大模型给出反应结果前,先对原消息发送一个\"收到\"的表情回复。",
|
|
128
|
+
"order": 16,
|
|
129
|
+
"advanced": true
|
|
130
|
+
},
|
|
131
|
+
"accounts": {
|
|
132
|
+
"help": "Accounts(多账户配置)",
|
|
133
|
+
"order": 30,
|
|
134
|
+
"advanced": true
|
|
135
|
+
},
|
|
136
|
+
"accounts.*.enabled": { "order": 301, "help": "开启或关闭" },
|
|
137
|
+
"accounts.*.appId": {
|
|
138
|
+
"help": "推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)",
|
|
139
|
+
"order": 302
|
|
140
|
+
},
|
|
141
|
+
"accounts.*.appSecret": {
|
|
142
|
+
"help": "推推机器人密钥 Secret(推推机器人的 App Secret)",
|
|
143
|
+
"order": 303
|
|
144
|
+
},
|
|
145
|
+
"accounts.*.dmPolicy": {
|
|
146
|
+
"help": "私聊策略(pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊)",
|
|
147
|
+
"order": 304,
|
|
148
|
+
"advanced": true
|
|
149
|
+
},
|
|
150
|
+
"accounts.*.allowFrom": {
|
|
151
|
+
"help": "私聊白名单-推推域账号(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户);对群、团队也生效",
|
|
152
|
+
"order": 305,
|
|
153
|
+
"advanced": true
|
|
154
|
+
},
|
|
155
|
+
"accounts.*.groupPolicy": {
|
|
156
|
+
"help": "群聊/团队策略(allowlist=白名单;disabled=禁用)",
|
|
157
|
+
"order": 306,
|
|
158
|
+
"advanced": true
|
|
159
|
+
},
|
|
160
|
+
"accounts.*.requireMention": {
|
|
161
|
+
"help": "群组/团队是否需要 @ 机器人才触发 Agent(默认 true)",
|
|
162
|
+
"order": 307,
|
|
163
|
+
"advanced": true
|
|
164
|
+
},
|
|
165
|
+
"accounts.*.groupAllowFrom": {
|
|
166
|
+
"help": "群组/团队白名单-包含群ID、团队ID或频道ID(仅在 groupPolicy=allowlist 生效)。如果不想所有人使用,可以配置私聊白名单,私聊白名单对群/团队仍然有效",
|
|
167
|
+
"order": 308,
|
|
168
|
+
"advanced": true
|
|
169
|
+
},
|
|
170
|
+
"accounts.*.channelContext": {
|
|
171
|
+
"help": "团队-频道上下文(channel=每个频道共享上下文; thread=每个帖子使用独立上下文)",
|
|
172
|
+
"order": 309,
|
|
173
|
+
"advanced": true
|
|
174
|
+
},
|
|
175
|
+
"accounts.*.emojiReaction": {
|
|
176
|
+
"help": "在收到消息后,大模型给出反应结果前,先对原消息发送一个\"收到\"的表情回复。",
|
|
177
|
+
"order": 310,
|
|
178
|
+
"advanced": true
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
9
182
|
}
|
|
10
|
-
}
|
|
183
|
+
}
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
import { parseAllowFroms, isEnabled } from './utils';
|
|
2
|
-
import { baseFildsDefault } from './confs';
|
|
3
2
|
import { CHANNEL_ID } from './const';
|
|
4
3
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
|
|
5
4
|
|
|
5
|
+
export const dmPolicyDefault = 'pairing';
|
|
6
|
+
|
|
7
|
+
export const getDefaultAccountConfig = (acct?: any) => ({
|
|
8
|
+
enabled: isEnabled(acct?.enabled),
|
|
9
|
+
appId: acct?.appId || '',
|
|
10
|
+
appSecret: acct?.appSecret || undefined, // 避免默认值覆盖用户输入的空字符串 Secret 空字串系统也会认为是已填写
|
|
11
|
+
dmPolicy: acct?.dmPolicy || dmPolicyDefault,
|
|
12
|
+
allowFrom: parseAllowFroms(acct?.allowFrom || []),
|
|
13
|
+
// 群组策略与白名单、群组级覆盖
|
|
14
|
+
groupPolicy: acct?.groupPolic || 'allowlist',
|
|
15
|
+
groupAllowFrom: parseAllowFroms(acct?.groupAllowFrom || []),
|
|
16
|
+
requireMention: isEnabled(acct?.requireMention ?? true),
|
|
17
|
+
channelContext: acct?.channelContext || 'thread',
|
|
18
|
+
emojiReaction: isEnabled(acct?.emojiReaction ?? true),
|
|
19
|
+
});
|
|
20
|
+
|
|
6
21
|
export const resolveAccount = (cfg: any, accountId?: string | null) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
appSecret: acct?.appSecret || '',
|
|
15
|
-
dmPolicy: acct?.dmPolicy || baseFildsDefault.dmPolicy,
|
|
16
|
-
allowFrom: parseAllowFroms(acct?.allowFrom || baseFildsDefault.allowFrom),
|
|
17
|
-
// 群组策略与白名单、群组级覆盖
|
|
18
|
-
groupPolicy: acct?.groupPolic || baseFildsDefault.groupPolicy,
|
|
19
|
-
groupAllowFrom: parseAllowFroms(acct?.groupAllowFrom || baseFildsDefault.groupAllowFrom),
|
|
20
|
-
requireMention: isEnabled(acct?.requireMention ?? baseFildsDefault.requireMention),
|
|
21
|
-
channelContext: acct?.channelContext || baseFildsDefault.channelContext,
|
|
22
|
-
emojiReaction: isEnabled(acct?.emojiReaction ?? baseFildsDefault.emojiReaction),
|
|
23
|
-
};
|
|
24
|
-
};
|
|
22
|
+
accountId = accountId || DEFAULT_ACCOUNT_ID;
|
|
23
|
+
let currAccount = cfg?.channels?.[CHANNEL_ID] || {};
|
|
24
|
+
if (accountId && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
25
|
+
currAccount = currAccount.accounts?.[accountId];
|
|
26
|
+
}
|
|
27
|
+
return { accountId, ...getDefaultAccountConfig(currAccount) };
|
|
28
|
+
};
|
package/src/channel.ts
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
* TuiTui Channel Plugin for OpenClaw.
|
|
3
3
|
*
|
|
4
4
|
* Implements the ChannelPlugin interface following the Synology Chat pattern.
|
|
5
|
-
* Supports single chat (text, image, voice, file) and group chat with @mentions.
|
|
6
5
|
*/
|
|
7
|
-
import WebSocket from 'ws';
|
|
8
6
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
|
|
9
7
|
import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from 'openclaw/plugin-sdk/core';
|
|
10
8
|
import { CHANNEL_ID, CHANNEL_NAME } from "./const";
|
|
@@ -15,16 +13,14 @@ import {
|
|
|
15
13
|
sendMediaMsg,
|
|
16
14
|
guessChatType,
|
|
17
15
|
} from "./outbound";
|
|
18
|
-
import
|
|
19
|
-
import {
|
|
20
|
-
import { resolveAccount } from "./accounts"
|
|
16
|
+
import createWebSocket from './websocket';
|
|
17
|
+
import { channelConfigs } from '../openclaw.plugin.json';
|
|
21
18
|
import { isEnabled } from './utils';
|
|
19
|
+
import { dmPolicyDefault, resolveAccount, getDefaultAccountConfig } from "./accounts"
|
|
22
20
|
|
|
23
21
|
const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
|
|
24
22
|
|
|
25
|
-
const
|
|
26
|
-
let wsNumber = 0;
|
|
27
|
-
const checkTuiTuiConfig = async (apiRuntime: any) => {
|
|
23
|
+
const checkAndSetDefaultTuiTuiConfig = async (apiRuntime: any) => {
|
|
28
24
|
const cfg = await apiRuntime.config.loadConfig();
|
|
29
25
|
const channels = cfg?.channels || {};
|
|
30
26
|
const currChannel = channels?.[CHANNEL_ID] || {};
|
|
@@ -35,8 +31,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
|
|
|
35
31
|
...channels,
|
|
36
32
|
[CHANNEL_ID]: {
|
|
37
33
|
...currChannel,
|
|
38
|
-
...
|
|
39
|
-
appSecret: undefined,
|
|
34
|
+
...getDefaultAccountConfig(),
|
|
40
35
|
}
|
|
41
36
|
}
|
|
42
37
|
});
|
|
@@ -44,7 +39,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
|
|
|
44
39
|
};
|
|
45
40
|
|
|
46
41
|
export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
47
|
-
|
|
42
|
+
checkAndSetDefaultTuiTuiConfig(apiRuntime);
|
|
48
43
|
return {
|
|
49
44
|
id: CHANNEL_ID,
|
|
50
45
|
meta: {
|
|
@@ -56,8 +51,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
56
51
|
blurb: `Connect to ${CHANNEL_NAME} bot via WebSocket`,
|
|
57
52
|
order: 100,
|
|
58
53
|
},
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
/*** 能力清单配置 ***/
|
|
55
|
+
capabilities: {
|
|
56
|
+
chatTypes: ['direct' as const, 'group' as const],
|
|
57
|
+
media: true,
|
|
58
|
+
threads: false,
|
|
59
|
+
reactions: false,
|
|
60
|
+
edit: false,
|
|
61
|
+
unsend: false,
|
|
62
|
+
reply: true,
|
|
63
|
+
effects: false,
|
|
64
|
+
blockStreaming: false,
|
|
65
|
+
},
|
|
66
|
+
configSchema: channelConfigs.tuitui,
|
|
61
67
|
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
62
68
|
|
|
63
69
|
config: {
|
|
@@ -106,8 +112,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
106
112
|
...cfg.channels,
|
|
107
113
|
[CHANNEL_ID]: {
|
|
108
114
|
...(cfg.channels?.[CHANNEL_ID] ?? {}),
|
|
115
|
+
...getDefaultAccountConfig(), // 重置基础配置字段,恢复默认值
|
|
109
116
|
enabled: false, // 默认账户不删除,改为禁用,并重置配置字段
|
|
110
|
-
...baseFildsDefault, // 重置基础配置字段,恢复默认值
|
|
111
117
|
},
|
|
112
118
|
},
|
|
113
119
|
} : deleteAccountFromConfigSection({ cfg, accountId, sectionKey: CHANNEL_ID }); // 多账户状态下的配置信息,按 accountId 删除指定账户; 除非子账户影响根账户字段信息,否则不应该使用 clearBaseFields
|
|
@@ -145,7 +151,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
145
151
|
let allowFromPath =`channels.${CHANNEL_ID}.`;
|
|
146
152
|
if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
|
|
147
153
|
|
|
148
|
-
const policy = account.dmPolicy ??
|
|
154
|
+
const policy = account.dmPolicy ?? dmPolicyDefault;
|
|
149
155
|
// dmPolicy semantics:
|
|
150
156
|
// - open: always allow everyone (["*"]), ignore allowFrom values.
|
|
151
157
|
// - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
|
|
@@ -241,97 +247,22 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
241
247
|
|
|
242
248
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
|
|
243
249
|
_setStatus({ running: true });
|
|
244
|
-
let ws: any = null;
|
|
245
|
-
wsNumber++;
|
|
246
|
-
const wsId = `${wsNumber}-${Date.now()}`;
|
|
247
|
-
const wsEvtIds = new Set<string>();
|
|
248
|
-
let wsRetryTimerId: any = 0;
|
|
249
|
-
|
|
250
|
-
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
251
|
-
const defSendMsg = (msg: any) => {
|
|
252
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
|
|
253
|
-
};
|
|
254
|
-
let _sendMsg = defSendMsg;
|
|
255
|
-
const startWebSocket = () => {
|
|
256
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
|
|
257
|
-
ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
|
|
258
|
-
ws.on('open', () => {
|
|
259
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
|
|
260
|
-
_setStatus({ running: true, connected: true });
|
|
261
|
-
_sendMsg = (msg) => ws.send(msg);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
// on receiving messages from tuitui websocket server
|
|
265
|
-
ws.on('message', async (wsData: string) => {
|
|
266
|
-
let json: any = null;
|
|
267
|
-
try {
|
|
268
|
-
json = JSON.parse(wsData);
|
|
269
|
-
} catch {
|
|
270
|
-
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
const ack = json?.event_id;
|
|
274
|
-
if (!ack) {
|
|
275
|
-
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
|
|
276
|
-
}
|
|
277
|
-
if (wsEvtIds.has(ack)) {
|
|
278
|
-
// 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
|
|
279
|
-
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
|
|
280
|
-
}
|
|
281
|
-
// 收到任意消息则回复一下,权当“收到”
|
|
282
|
-
_sendMsg(JSON.stringify({ ack }));
|
|
283
|
-
// 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
|
|
284
|
-
wsEvtIds.add(ack);
|
|
285
|
-
// 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
|
|
286
|
-
if (wsEvtIds.size > 1e3) {
|
|
287
|
-
const firsEvtId = wsEvtIds.values().next().value;
|
|
288
|
-
if (firsEvtId) wsEvtIds.delete(firsEvtId);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const wsEvent = json?.body?.event;
|
|
292
|
-
if (wsEvent === 'keepalive') return;
|
|
293
|
-
|
|
294
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}], Received event ${wsEvent}, body ${JSON.stringify(json?.body, null, 2)}`);
|
|
295
|
-
|
|
296
|
-
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
297
|
-
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (!apiRuntime) {
|
|
301
|
-
return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
await handleInboundMessage({ json, account, apiRuntime, log });
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
const onErrOrClose = () => {
|
|
308
|
-
if (ws && ws.readyState !== WebSocket.CLOSED) ws.close(); // 关闭超时链接,防止产生多个
|
|
309
|
-
_setStatus({ running: false, connected: false });
|
|
310
|
-
_sendMsg = defSendMsg;
|
|
311
|
-
if (!abortSignal?.aborted) {
|
|
312
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
313
|
-
if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
|
|
314
|
-
wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
ws.on('close', () => {
|
|
318
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
319
|
-
onErrOrClose();
|
|
320
|
-
});
|
|
321
250
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
251
|
+
// 创建 WebSocket 连接,并得到在需要时关闭该连接的方法。
|
|
252
|
+
const _closeWs = createWebSocket({
|
|
253
|
+
account,
|
|
254
|
+
log,
|
|
255
|
+
abortSignal,
|
|
256
|
+
apiRuntime,
|
|
257
|
+
onConnected: () => _setStatus({ running: true, connected: true }),
|
|
258
|
+
onDisconnected: () => _setStatus({ running: false, connected: false }),
|
|
259
|
+
});
|
|
329
260
|
|
|
330
|
-
//
|
|
261
|
+
// 保持账户运行状态,直至触发“abortSignal”信号为止。
|
|
331
262
|
await new Promise<void>((resolve) => {
|
|
332
263
|
const _onAbort = () => {
|
|
333
264
|
log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} stopping (abort signal)`);
|
|
334
|
-
|
|
265
|
+
_closeWs();
|
|
335
266
|
resolve();
|
|
336
267
|
};
|
|
337
268
|
if (abortSignal?.aborted) return _onAbort();
|
package/src/inbound.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types
|
|
|
14
14
|
import { CHANNEL_ID } from './const';
|
|
15
15
|
import {
|
|
16
16
|
CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType,
|
|
17
|
-
buildMessageBody,
|
|
18
17
|
tuituiEmojiReaction,
|
|
19
18
|
sendTextMsg,
|
|
20
19
|
sendPageMsg,
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
teamsBuildChatId,
|
|
23
22
|
teamsParseChatId,
|
|
24
23
|
} from "./outbound";
|
|
24
|
+
import { parseChatMessageBody } from './inbound_body_parse';
|
|
25
25
|
import { parseAllowFroms } from './utils';
|
|
26
26
|
import {
|
|
27
27
|
addUnmentionedHistory,
|
|
@@ -87,11 +87,13 @@ function getSessionKey(cfg: any, payload: ChatPayload, account: InboundAccount,
|
|
|
87
87
|
return String(sessionKey).replace(/\//g, '_');
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function getMediaUrls({ msg_type, images, voice, file }: any): string[] | undefined {
|
|
90
|
+
function getMediaUrls({ msg_type, images, voice, video, file }: any): string[] | undefined {
|
|
91
91
|
if (msg_type === 'image' || msg_type === 'mixed') {
|
|
92
92
|
if (images?.length) return images;
|
|
93
93
|
} else if (msg_type === 'voice') {
|
|
94
94
|
if (voice) return [voice];
|
|
95
|
+
} else if (msg_type === 'video') {
|
|
96
|
+
if (video) return [video];
|
|
95
97
|
} else if (msg_type === 'file') {
|
|
96
98
|
if (file?.url) return [file.url];
|
|
97
99
|
}
|
|
@@ -174,6 +176,20 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
|
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
//目标存在于私聊白名单或配对存储中
|
|
180
|
+
async function isAllowFrom(chatId: any, apiRuntime: any, account: InboundAccount) {
|
|
181
|
+
if(!chatId) return false;
|
|
182
|
+
const { accountId, allowFrom } = account;
|
|
183
|
+
let storeAllowFrom: string[] = [];
|
|
184
|
+
try {
|
|
185
|
+
storeAllowFrom = parseAllowFroms(
|
|
186
|
+
await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
|
|
187
|
+
);
|
|
188
|
+
} catch {}
|
|
189
|
+
return [...allowFrom, ...storeAllowFrom].includes(chatId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
177
193
|
/**
|
|
178
194
|
* 处理单聊(single_chat)、群聊、团队帖子分支,直接修改并校验 payload。
|
|
179
195
|
* 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
|
|
@@ -181,7 +197,7 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
|
|
|
181
197
|
const parseAndVerifyPayload: Record<string, Function> = {
|
|
182
198
|
single_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
183
199
|
const { accountId, dmPolicy, allowFrom } = account;
|
|
184
|
-
const chatId
|
|
200
|
+
const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
|
|
185
201
|
if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
|
|
186
202
|
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
|
|
187
203
|
return false;
|
|
@@ -189,7 +205,7 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
189
205
|
|
|
190
206
|
payload.chatType = CHAT_TYPE_DIRECT;
|
|
191
207
|
payload.chatId = chatId;
|
|
192
|
-
payload.text =
|
|
208
|
+
payload.text = parseChatMessageBody(msgData);
|
|
193
209
|
payload.msgId = msgData.msgid;
|
|
194
210
|
log?.debug?.(
|
|
195
211
|
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat:
|
|
@@ -206,15 +222,8 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
206
222
|
|
|
207
223
|
if (dmPolicy === 'open') return true;
|
|
208
224
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
storeAllowFrom = parseAllowFroms(
|
|
212
|
-
await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
|
|
213
|
-
);
|
|
214
|
-
} catch {}
|
|
215
|
-
|
|
216
|
-
if(chatId && [...allowFrom, ...storeAllowFrom].includes(chatId)) {
|
|
217
|
-
return true; // isAllowed:单聊目标存在于白名单或配对存储中
|
|
225
|
+
if(await isAllowFrom(chatId, apiRuntime, account)){
|
|
226
|
+
return true;
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
if (dmPolicy === 'pairing') {
|
|
@@ -233,10 +242,11 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
233
242
|
payload.chatId = chatId;
|
|
234
243
|
payload.groupName = msgData.group_name;
|
|
235
244
|
payload.msgId = msgData.msgid;
|
|
236
|
-
payload.text =
|
|
245
|
+
payload.text = parseChatMessageBody(msgData);
|
|
246
|
+
const { tuituiAccount } = payload;
|
|
237
247
|
log?.debug?.(
|
|
238
248
|
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat:
|
|
239
|
-
tuituiAccount=${
|
|
249
|
+
tuituiAccount=${tuituiAccount},
|
|
240
250
|
tuituiUid=${payload.tuituiUid},
|
|
241
251
|
tuituiUserName=${payload.tuituiUserName},
|
|
242
252
|
groupId=${chatId},
|
|
@@ -250,34 +260,39 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
250
260
|
return false;
|
|
251
261
|
}
|
|
252
262
|
|
|
253
|
-
if (!
|
|
263
|
+
if (!tuituiAccount || !chatId) {
|
|
254
264
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
|
|
255
265
|
return false;
|
|
256
266
|
}
|
|
257
267
|
|
|
258
268
|
if (!msgData.at_me && account.requireMention) {
|
|
259
269
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore group message (not mentioned), add to history key: ${chatId}`);
|
|
260
|
-
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName,
|
|
270
|
+
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
|
|
261
271
|
return false;
|
|
262
272
|
}
|
|
263
273
|
|
|
274
|
+
// 私聊白名单对群聊仍然生效,当群里只想特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
|
|
275
|
+
if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
264
279
|
if (!groupAllowFrom.includes(chatId)) {
|
|
265
280
|
if (needPairingThrottle(accountId, chatId)) {
|
|
266
281
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
|
|
267
282
|
return false;
|
|
268
283
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
284
|
+
|
|
285
|
+
const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊策略为白名单模式。
|
|
286
|
+
需由龙虾主人在配置中添加白名单(两种方式二选一即可):
|
|
287
|
+
|
|
288
|
+
- 允许群中所有人用,在群白名单(Group Allow From)添加当前群ID: ${chatId}
|
|
289
|
+
|
|
290
|
+
- 不想群所有人用,只想特定人用,在私聊白名单(Allow From)添加当前用户 ${payload.tuituiAccount} ,私聊白名单对群仍然有效`
|
|
291
|
+
|
|
292
|
+
await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
|
|
276
293
|
return false;
|
|
277
294
|
}
|
|
278
295
|
|
|
279
|
-
|
|
280
|
-
|
|
281
296
|
return true;
|
|
282
297
|
},
|
|
283
298
|
teams_post_create: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
@@ -291,9 +306,10 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
291
306
|
payload.text = content;
|
|
292
307
|
payload.channelName = channel_name;
|
|
293
308
|
payload.replyToId = "";
|
|
309
|
+
const { tuituiAccount } = payload;
|
|
294
310
|
log?.debug?.(
|
|
295
311
|
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound teams:
|
|
296
|
-
tuituiAccount=${
|
|
312
|
+
tuituiAccount=${tuituiAccount},
|
|
297
313
|
tuituiUid=${payload.tuituiUid},
|
|
298
314
|
tuituiUserName=${payload.tuituiUserName},
|
|
299
315
|
chatId=${chatId},
|
|
@@ -308,17 +324,22 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
308
324
|
return false;
|
|
309
325
|
}
|
|
310
326
|
|
|
311
|
-
if (!
|
|
327
|
+
if (!tuituiAccount) {
|
|
312
328
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
|
|
313
329
|
return false;
|
|
314
330
|
}
|
|
315
331
|
|
|
316
332
|
if (!msgData.at_me && account.requireMention) {
|
|
317
333
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned), add to history key: ${chatId}`);
|
|
318
|
-
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName,
|
|
334
|
+
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
|
|
319
335
|
return false;
|
|
320
336
|
}
|
|
321
337
|
|
|
338
|
+
// 私聊白名单对团队仍然生效,限制特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
|
|
339
|
+
if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
322
343
|
if (!groupAllowFrom.includes(String(team_id)) && !groupAllowFrom.includes(String(channel_id)) ) {
|
|
323
344
|
if (!msgData.at_me) {
|
|
324
345
|
// 解决这个case:requireMention=false时,团队里发任意帖子都会回复加白,搞得没法用
|
|
@@ -330,13 +351,15 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
330
351
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
|
|
331
352
|
return false;
|
|
332
353
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
354
|
+
|
|
355
|
+
const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊/团队策略为白名单模式。
|
|
356
|
+
需由龙虾主人在配置中添加白名单(3种方式选一种即可):
|
|
357
|
+
|
|
358
|
+
- 允许团队中所有人用,在群白名单(Group Allow From)添加当前团队ID: ${team_id}
|
|
359
|
+
- 仅允许当前频道使用,在群白名单(Group Allow From)添加当前频道ID: ${channel_id}
|
|
360
|
+
- 仅允许特定人使用,在私聊白名单(Allow From)添加当前用户: ${payload.tuituiAccount} ,私聊白名单对团队仍然有效`
|
|
361
|
+
|
|
362
|
+
await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
|
|
340
363
|
return false;
|
|
341
364
|
}
|
|
342
365
|
|