@qihoo/tuitui-openclaw-channel 1.0.18 → 1.0.20
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 +40 -99
- package/src/env.ts +15 -0
- package/src/inbound.ts +53 -32
- package/src/outbound.ts +29 -41
- package/src/websocket.ts +106 -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
|
-
const
|
|
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
|
+
const accId = accountId || DEFAULT_ACCOUNT_ID;
|
|
23
|
+
let currAccount = cfg?.channels?.[CHANNEL_ID] || {};
|
|
24
|
+
if (accId !== DEFAULT_ACCOUNT_ID) {
|
|
25
|
+
currAccount = currAccount.accounts?.[accId];
|
|
26
|
+
}
|
|
27
|
+
return { accountId: accId, ...getDefaultAccountConfig(currAccount) };
|
|
28
|
+
};
|
package/src/channel.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Implements the ChannelPlugin interface following the Synology Chat pattern.
|
|
5
5
|
*/
|
|
6
|
-
import WebSocket from 'ws';
|
|
7
6
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
|
|
8
7
|
import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from 'openclaw/plugin-sdk/core';
|
|
9
8
|
import { CHANNEL_ID, CHANNEL_NAME } from "./const";
|
|
9
|
+
import { handleInboundMessage } from './inbound';
|
|
10
10
|
import {
|
|
11
11
|
checkAccount,
|
|
12
12
|
sendTextMsg,
|
|
@@ -14,16 +14,14 @@ import {
|
|
|
14
14
|
sendMediaMsg,
|
|
15
15
|
guessChatType,
|
|
16
16
|
} from "./outbound";
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import { resolveAccount } from "./accounts"
|
|
17
|
+
import createWebSocket from './websocket';
|
|
18
|
+
import { channelConfigs } from '../openclaw.plugin.json';
|
|
20
19
|
import { isEnabled } from './utils';
|
|
20
|
+
import { dmPolicyDefault, resolveAccount, getDefaultAccountConfig } from "./accounts"
|
|
21
21
|
|
|
22
22
|
const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
let wsNumber = 0;
|
|
26
|
-
const checkTuiTuiConfig = async (apiRuntime: any) => {
|
|
24
|
+
const checkAndSetDefaultTuiTuiConfig = async (apiRuntime: any) => {
|
|
27
25
|
const cfg = await apiRuntime.config.loadConfig();
|
|
28
26
|
const channels = cfg?.channels || {};
|
|
29
27
|
const currChannel = channels?.[CHANNEL_ID] || {};
|
|
@@ -34,8 +32,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
|
|
|
34
32
|
...channels,
|
|
35
33
|
[CHANNEL_ID]: {
|
|
36
34
|
...currChannel,
|
|
37
|
-
...
|
|
38
|
-
appSecret: undefined,
|
|
35
|
+
...getDefaultAccountConfig(),
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
});
|
|
@@ -43,7 +40,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
|
|
|
43
40
|
};
|
|
44
41
|
|
|
45
42
|
export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
46
|
-
|
|
43
|
+
checkAndSetDefaultTuiTuiConfig(apiRuntime);
|
|
47
44
|
return {
|
|
48
45
|
id: CHANNEL_ID,
|
|
49
46
|
meta: {
|
|
@@ -55,8 +52,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
55
52
|
blurb: `Connect to ${CHANNEL_NAME} bot via WebSocket`,
|
|
56
53
|
order: 100,
|
|
57
54
|
},
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
/*** 能力清单配置 ***/
|
|
56
|
+
capabilities: {
|
|
57
|
+
chatTypes: ['direct' as const, 'group' as const],
|
|
58
|
+
media: true,
|
|
59
|
+
threads: false,
|
|
60
|
+
reactions: false,
|
|
61
|
+
edit: false,
|
|
62
|
+
unsend: false,
|
|
63
|
+
reply: true,
|
|
64
|
+
effects: false,
|
|
65
|
+
blockStreaming: false,
|
|
66
|
+
},
|
|
67
|
+
configSchema: channelConfigs.tuitui,
|
|
60
68
|
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
61
69
|
|
|
62
70
|
config: {
|
|
@@ -105,8 +113,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
105
113
|
...cfg.channels,
|
|
106
114
|
[CHANNEL_ID]: {
|
|
107
115
|
...(cfg.channels?.[CHANNEL_ID] ?? {}),
|
|
116
|
+
...getDefaultAccountConfig(), // 重置基础配置字段,恢复默认值
|
|
108
117
|
enabled: false, // 默认账户不删除,改为禁用,并重置配置字段
|
|
109
|
-
...baseFildsDefault, // 重置基础配置字段,恢复默认值
|
|
110
118
|
},
|
|
111
119
|
},
|
|
112
120
|
} : deleteAccountFromConfigSection({ cfg, accountId, sectionKey: CHANNEL_ID }); // 多账户状态下的配置信息,按 accountId 删除指定账户; 除非子账户影响根账户字段信息,否则不应该使用 clearBaseFields
|
|
@@ -144,7 +152,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
144
152
|
let allowFromPath =`channels.${CHANNEL_ID}.`;
|
|
145
153
|
if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
|
|
146
154
|
|
|
147
|
-
const policy = account.dmPolicy ??
|
|
155
|
+
const policy = account.dmPolicy ?? dmPolicyDefault;
|
|
148
156
|
// dmPolicy semantics:
|
|
149
157
|
// - open: always allow everyone (["*"]), ignore allowFrom values.
|
|
150
158
|
// - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
|
|
@@ -240,97 +248,30 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
240
248
|
|
|
241
249
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
|
|
242
250
|
_setStatus({ running: true });
|
|
243
|
-
let ws: any = null;
|
|
244
|
-
wsNumber++;
|
|
245
|
-
const wsId = `${wsNumber}-${Date.now()}`;
|
|
246
|
-
const wsEvtIds = new Set<string>();
|
|
247
|
-
let wsRetryTimerId: any = 0;
|
|
248
|
-
|
|
249
|
-
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
250
|
-
const defSendMsg = (msg: any) => {
|
|
251
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
|
|
252
|
-
};
|
|
253
|
-
let _sendMsg = defSendMsg;
|
|
254
|
-
const startWebSocket = () => {
|
|
255
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
|
|
256
|
-
ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
|
|
257
|
-
ws.on('open', () => {
|
|
258
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
|
|
259
|
-
_setStatus({ running: true, connected: true });
|
|
260
|
-
_sendMsg = (msg) => ws.send(msg);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// on receiving messages from tuitui websocket server
|
|
264
|
-
ws.on('message', async (wsData: string) => {
|
|
265
|
-
let json: any = null;
|
|
266
|
-
try {
|
|
267
|
-
json = JSON.parse(wsData);
|
|
268
|
-
} catch {
|
|
269
|
-
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
const ack = json?.event_id;
|
|
273
|
-
if (!ack) {
|
|
274
|
-
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
|
|
275
|
-
}
|
|
276
|
-
if (wsEvtIds.has(ack)) {
|
|
277
|
-
// 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
|
|
278
|
-
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
|
|
279
|
-
}
|
|
280
|
-
// 收到任意消息则回复一下,权当“收到”
|
|
281
|
-
_sendMsg(JSON.stringify({ ack }));
|
|
282
|
-
// 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
|
|
283
|
-
wsEvtIds.add(ack);
|
|
284
|
-
// 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
|
|
285
|
-
if (wsEvtIds.size > 1e3) {
|
|
286
|
-
const firsEvtId = wsEvtIds.values().next().value;
|
|
287
|
-
if (firsEvtId) wsEvtIds.delete(firsEvtId);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const wsEvent = json?.body?.event;
|
|
291
|
-
if (wsEvent === 'keepalive') return;
|
|
292
251
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (ws && ws.readyState !== WebSocket.CLOSED) ws.close(); // 关闭超时链接,防止产生多个
|
|
308
|
-
_setStatus({ running: false, connected: false });
|
|
309
|
-
_sendMsg = defSendMsg;
|
|
310
|
-
if (!abortSignal?.aborted) {
|
|
311
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
312
|
-
if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
|
|
313
|
-
wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
|
|
252
|
+
// 创建 WebSocket 连接,并得到在需要时关闭该连接的方法。
|
|
253
|
+
const _closeWs = createWebSocket({
|
|
254
|
+
account,
|
|
255
|
+
log,
|
|
256
|
+
abortSignal,
|
|
257
|
+
apiRuntime,
|
|
258
|
+
onConnected: () => _setStatus({ running: true, connected: true }),
|
|
259
|
+
onDisconnected: () => _setStatus({ running: false, connected: false }),
|
|
260
|
+
onInbounMessage: (json: any) => {
|
|
261
|
+
_setStatus({ running: true, connected: true, lastInboundAt: Date.now() });
|
|
262
|
+
if (apiRuntime) {
|
|
263
|
+
handleInboundMessage({ json, account, apiRuntime, log });
|
|
264
|
+
} else {
|
|
265
|
+
log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket.onInbounMessage TuiTuiRuntime error`);
|
|
314
266
|
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
318
|
-
onErrOrClose();
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// on socket errors
|
|
322
|
-
ws.on('error', (err: any) => {
|
|
323
|
-
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] error: ${err}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
324
|
-
onErrOrClose();
|
|
325
|
-
});
|
|
326
|
-
};
|
|
327
|
-
startWebSocket();
|
|
267
|
+
},
|
|
268
|
+
});
|
|
328
269
|
|
|
329
|
-
//
|
|
270
|
+
// 保持账户运行状态,直至触发“abortSignal”信号为止。
|
|
330
271
|
await new Promise<void>((resolve) => {
|
|
331
272
|
const _onAbort = () => {
|
|
332
273
|
log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} stopping (abort signal)`);
|
|
333
|
-
|
|
274
|
+
_closeWs();
|
|
334
275
|
resolve();
|
|
335
276
|
};
|
|
336
277
|
if (abortSignal?.aborted) return _onAbort();
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const DEFAULT_ONLINE = true;
|
|
2
|
+
|
|
3
|
+
export const getTuituiHost = (online: boolean = DEFAULT_ONLINE): string => {
|
|
4
|
+
return online ? "im.live.360.cn" : "im.qihoo.net";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const getTuituiWebsocketHost = (online: boolean = DEFAULT_ONLINE): string => {
|
|
8
|
+
return `wss://${getTuituiHost(online)}:8282/robot`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getTuituiApiHost = (online: boolean = DEFAULT_ONLINE): string => {
|
|
12
|
+
return `https://${getTuituiHost(online)}:8282/robot`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const TUITUI_SSRF_POLICY = { allowedHostnames: ['im.live.360.cn', 'im.qihoo.net'] } as const;
|
package/src/inbound.ts
CHANGED
|
@@ -176,6 +176,20 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
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
|
+
|
|
179
193
|
/**
|
|
180
194
|
* 处理单聊(single_chat)、群聊、团队帖子分支,直接修改并校验 payload。
|
|
181
195
|
* 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
|
|
@@ -183,7 +197,7 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
|
|
|
183
197
|
const parseAndVerifyPayload: Record<string, Function> = {
|
|
184
198
|
single_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
185
199
|
const { accountId, dmPolicy, allowFrom } = account;
|
|
186
|
-
const chatId
|
|
200
|
+
const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
|
|
187
201
|
if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
|
|
188
202
|
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
|
|
189
203
|
return false;
|
|
@@ -208,15 +222,8 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
208
222
|
|
|
209
223
|
if (dmPolicy === 'open') return true;
|
|
210
224
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
storeAllowFrom = parseAllowFroms(
|
|
214
|
-
await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
|
|
215
|
-
);
|
|
216
|
-
} catch {}
|
|
217
|
-
|
|
218
|
-
if(chatId && [...allowFrom, ...storeAllowFrom].includes(chatId)) {
|
|
219
|
-
return true; // isAllowed:单聊目标存在于白名单或配对存储中
|
|
225
|
+
if(await isAllowFrom(chatId, apiRuntime, account)){
|
|
226
|
+
return true;
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
if (dmPolicy === 'pairing') {
|
|
@@ -236,9 +243,10 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
236
243
|
payload.groupName = msgData.group_name;
|
|
237
244
|
payload.msgId = msgData.msgid;
|
|
238
245
|
payload.text = parseChatMessageBody(msgData);
|
|
246
|
+
const { tuituiAccount } = payload;
|
|
239
247
|
log?.debug?.(
|
|
240
248
|
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat:
|
|
241
|
-
tuituiAccount=${
|
|
249
|
+
tuituiAccount=${tuituiAccount},
|
|
242
250
|
tuituiUid=${payload.tuituiUid},
|
|
243
251
|
tuituiUserName=${payload.tuituiUserName},
|
|
244
252
|
groupId=${chatId},
|
|
@@ -252,34 +260,39 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
252
260
|
return false;
|
|
253
261
|
}
|
|
254
262
|
|
|
255
|
-
if (!
|
|
263
|
+
if (!tuituiAccount || !chatId) {
|
|
256
264
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
|
|
257
265
|
return false;
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
if (!msgData.at_me && account.requireMention) {
|
|
261
269
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore group message (not mentioned), add to history key: ${chatId}`);
|
|
262
|
-
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName,
|
|
270
|
+
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
|
|
263
271
|
return false;
|
|
264
272
|
}
|
|
265
273
|
|
|
274
|
+
// 私聊白名单对群聊仍然生效,当群里只想特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
|
|
275
|
+
if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
266
279
|
if (!groupAllowFrom.includes(chatId)) {
|
|
267
280
|
if (needPairingThrottle(accountId, chatId)) {
|
|
268
281
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
|
|
269
282
|
return false;
|
|
270
283
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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');
|
|
278
293
|
return false;
|
|
279
294
|
}
|
|
280
295
|
|
|
281
|
-
|
|
282
|
-
|
|
283
296
|
return true;
|
|
284
297
|
},
|
|
285
298
|
teams_post_create: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
@@ -293,9 +306,10 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
293
306
|
payload.text = content;
|
|
294
307
|
payload.channelName = channel_name;
|
|
295
308
|
payload.replyToId = "";
|
|
309
|
+
const { tuituiAccount } = payload;
|
|
296
310
|
log?.debug?.(
|
|
297
311
|
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound teams:
|
|
298
|
-
tuituiAccount=${
|
|
312
|
+
tuituiAccount=${tuituiAccount},
|
|
299
313
|
tuituiUid=${payload.tuituiUid},
|
|
300
314
|
tuituiUserName=${payload.tuituiUserName},
|
|
301
315
|
chatId=${chatId},
|
|
@@ -310,17 +324,22 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
310
324
|
return false;
|
|
311
325
|
}
|
|
312
326
|
|
|
313
|
-
if (!
|
|
327
|
+
if (!tuituiAccount) {
|
|
314
328
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
|
|
315
329
|
return false;
|
|
316
330
|
}
|
|
317
331
|
|
|
318
332
|
if (!msgData.at_me && account.requireMention) {
|
|
319
333
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned), add to history key: ${chatId}`);
|
|
320
|
-
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName,
|
|
334
|
+
await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
|
|
321
335
|
return false;
|
|
322
336
|
}
|
|
323
337
|
|
|
338
|
+
// 私聊白名单对团队仍然生效,限制特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
|
|
339
|
+
if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
324
343
|
if (!groupAllowFrom.includes(String(team_id)) && !groupAllowFrom.includes(String(channel_id)) ) {
|
|
325
344
|
if (!msgData.at_me) {
|
|
326
345
|
// 解决这个case:requireMention=false时,团队里发任意帖子都会回复加白,搞得没法用
|
|
@@ -332,13 +351,15 @@ const parseAndVerifyPayload: Record<string, Function> = {
|
|
|
332
351
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
|
|
333
352
|
return false;
|
|
334
353
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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');
|
|
342
363
|
return false;
|
|
343
364
|
}
|
|
344
365
|
|
package/src/outbound.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
|
|
4
4
|
import { CHANNEL_ID } from "./const";
|
|
5
|
+
import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
|
|
5
6
|
|
|
6
7
|
import type {
|
|
7
8
|
TuiTuiMessageData,
|
|
@@ -17,8 +18,6 @@ import type {
|
|
|
17
18
|
TuiTuiTeamsTarget
|
|
18
19
|
} from './types';
|
|
19
20
|
|
|
20
|
-
/* 一些常量配置 */
|
|
21
|
-
export const TUITUI_SSRF_POLICY = { allowedHostnames: ['im.live.360.cn'] } as const;
|
|
22
21
|
|
|
23
22
|
// ChatType定义与SessionKey定义一致,不可随意修改
|
|
24
23
|
// https://docs.openclaw.ai/channels/channel-routing#session-key-shapes-examples
|
|
@@ -33,8 +32,8 @@ export function guessChatType(chatId: string): ChatType {
|
|
|
33
32
|
return CHAT_TYPE_DIRECT;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
export function
|
|
37
|
-
const url = new URL(urlStr);
|
|
35
|
+
export function addTuituiParams2Url(urlStr: string, params: any) {
|
|
36
|
+
const url = new URL(getTuituiApiHost() + urlStr);
|
|
38
37
|
for (let k in params) url.searchParams.set(k, params[k]);
|
|
39
38
|
return url.toString();
|
|
40
39
|
}
|
|
@@ -82,7 +81,7 @@ function _fetchJson(url: string, json: any, auditCtx: string): Promise<any> {
|
|
|
82
81
|
export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
|
|
83
82
|
const { appId: appid, appSecret: secret } = account;
|
|
84
83
|
const { response, release } = await _fetchJson(
|
|
85
|
-
|
|
84
|
+
addTuituiParams2Url('/message/custom/send', { appid, secret }),
|
|
86
85
|
json,
|
|
87
86
|
auditCtx,
|
|
88
87
|
);
|
|
@@ -132,7 +131,7 @@ interface tuituiUploadResult {
|
|
|
132
131
|
* @param type - Media type: 'image' or "file" (auto-detected if not specified)
|
|
133
132
|
* @returns The media_id from TuiTui
|
|
134
133
|
*/
|
|
135
|
-
export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult
|
|
134
|
+
export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult> {
|
|
136
135
|
checkAccount(account, 'uploadFileToTuiTui');
|
|
137
136
|
|
|
138
137
|
const { appId: appid, appSecret: secret } = account;
|
|
@@ -219,23 +218,23 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
|
|
|
219
218
|
body.append('media', new Blob([fileBuffer], { type: contentType }), filename);
|
|
220
219
|
|
|
221
220
|
const { response, release } = await _fetch({
|
|
222
|
-
url:
|
|
221
|
+
url: addTuituiParams2Url('/media/upload', { appid, secret, type }),
|
|
223
222
|
init: { method: "POST", body },
|
|
224
223
|
auditCtx: "tuitui.media.upload",
|
|
225
224
|
});
|
|
226
225
|
try {
|
|
227
226
|
if (!response.ok) {
|
|
228
|
-
throw new Error(`[${CHANNEL_ID}] Failed to upload
|
|
227
|
+
throw new Error(`[${CHANNEL_ID}] Failed to upload file to TuiTui: HTTP ${response.status}`);
|
|
229
228
|
}
|
|
230
229
|
|
|
231
230
|
const result: TuiTuiMediaUploadResponse = await response.json();
|
|
232
231
|
if (result.errcode !== 0 || !result.media_id) {
|
|
233
|
-
throw new Error(`[${CHANNEL_ID}]
|
|
232
|
+
throw new Error(`[${CHANNEL_ID}] file upload failed: ${result.errmsg || "Unknown error"}`);
|
|
234
233
|
}
|
|
235
234
|
|
|
236
235
|
return {fid: result.media_id, filename};
|
|
237
236
|
} catch(err) {
|
|
238
|
-
|
|
237
|
+
throw err;
|
|
239
238
|
} finally {
|
|
240
239
|
await release();
|
|
241
240
|
}
|
|
@@ -250,43 +249,37 @@ export async function tuituiEmojiReaction(
|
|
|
250
249
|
): Promise<void> {
|
|
251
250
|
const payload = {
|
|
252
251
|
msgtype: 'emoji_reaction',
|
|
253
|
-
tousers: [] as TuiTuiSingleEmojiReactionTarget[],
|
|
254
|
-
togroups: [] as TuiTuiGroupEmojiReactionTarget[],
|
|
255
|
-
toteams: [] as TuiTuiTeamsTarget[],
|
|
256
252
|
emoji_reaction: { emoji, cancel: false},
|
|
257
|
-
};
|
|
253
|
+
} as any;
|
|
258
254
|
if(chatType == CHAT_TYPE_GROUP) {
|
|
259
|
-
payload.togroups
|
|
255
|
+
payload.togroups = [{ group: target, msgid }] as TuiTuiGroupEmojiReactionTarget[];
|
|
260
256
|
} else if(chatType == CHAT_TYPE_DIRECT) {
|
|
261
|
-
payload.tousers
|
|
257
|
+
payload.tousers = [{ user: target, msgid }] as TuiTuiSingleEmojiReactionTarget[];
|
|
262
258
|
} else {
|
|
263
|
-
|
|
264
|
-
toteam.parent_id = "";
|
|
265
|
-
toteam.post_id = msgid;
|
|
266
|
-
payload.toteams.push(toteam);
|
|
259
|
+
payload.toteams = [{ ...teamsParseChatId(target), parent_id: '', post_id: msgid }] as TuiTuiTeamsTarget[];
|
|
267
260
|
}
|
|
268
261
|
|
|
269
262
|
const { appId: appid, appSecret: secret } = account;
|
|
270
|
-
const
|
|
271
|
-
|
|
263
|
+
const toTarget = (payload.togroups || payload.tousers || payload.toteams)[0];
|
|
264
|
+
const _logTxt =`[${CHANNEL_ID}] emoji_reaction "${emoji}"`;
|
|
265
|
+
console.log(`${_logTxt} request`, toTarget);
|
|
266
|
+
const sendUrl = addTuituiParams2Url('/message/custom/modify', { appid, secret });
|
|
272
267
|
const { response, release } = await _fetchJson(sendUrl, payload, 'tuitui.emoji_reaction');
|
|
273
268
|
|
|
274
269
|
try {
|
|
275
|
-
const body = await response.text().catch(() => "");
|
|
276
|
-
console.log(
|
|
270
|
+
const body = JSON.parse(await response.text().catch(() => "{}"));
|
|
271
|
+
console.log(`${_logTxt} response errcode=${body.errcode} errmsg=${body.errmsg}`, toTarget);
|
|
277
272
|
} catch (err) {
|
|
278
|
-
console.error(
|
|
273
|
+
console.error(`${_logTxt} Caught exception:`, err)
|
|
279
274
|
} finally {
|
|
280
275
|
await release();
|
|
281
276
|
}
|
|
282
277
|
}
|
|
283
278
|
|
|
284
279
|
export function teamsBuildChatId(team_id: string, channel_id:string, thread_id:string) : string{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return `teams_${team_id}_${channel_id}`;
|
|
289
|
-
}
|
|
280
|
+
let ret = `teams_${team_id}_${channel_id}`;
|
|
281
|
+
if(thread_id) ret += `_${thread_id}`;
|
|
282
|
+
return ret;
|
|
290
283
|
}
|
|
291
284
|
|
|
292
285
|
export function teamsParseChatId(chatId: string): TuiTuiTeamsTarget {
|
|
@@ -434,20 +427,15 @@ export async function sendMediaMsg(
|
|
|
434
427
|
const mediaType = isImage ? 'image' : 'file';
|
|
435
428
|
|
|
436
429
|
const uploadResult = await uploadFileToTuiTui(mediaUrl, account, mediaType);
|
|
437
|
-
if (!uploadResult || !uploadResult?.fid) {
|
|
438
|
-
console.error(`[${CHANNEL_ID}] uploadFileToTuiTui failed ${auditCtx}, {mediaUrl: ${mediaUrl}, mediaType: ${mediaType}}`);
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
430
|
const {fid, filename} = uploadResult;
|
|
443
431
|
|
|
444
432
|
const targets = getTargets(chatId, chatType);
|
|
445
433
|
if (chatType == CHAT_TYPE_CHANNEL) {
|
|
446
|
-
|
|
447
|
-
if (isImage){
|
|
434
|
+
let content = '';
|
|
435
|
+
if (isImage) {
|
|
448
436
|
content = ``;
|
|
449
437
|
} else {
|
|
450
|
-
content = `[${filename}]({{tuitui_file "${fid}"}})
|
|
438
|
+
content = `[${filename}]({{tuitui_file "${fid}"}})`
|
|
451
439
|
}
|
|
452
440
|
const msg: TuiTuiOutboundTeamsMarkdownMessage = {
|
|
453
441
|
...targets,
|
|
@@ -542,9 +530,9 @@ export async function getChatRecord(
|
|
|
542
530
|
|
|
543
531
|
let baseurl = "";
|
|
544
532
|
if (chatType == CHAT_TYPE_DIRECT) {
|
|
545
|
-
baseurl = "
|
|
533
|
+
baseurl = "/message/single/sync";
|
|
546
534
|
} else if (chatType == CHAT_TYPE_GROUP){
|
|
547
|
-
baseurl = "
|
|
535
|
+
baseurl = "/message/group/sync";
|
|
548
536
|
} else {
|
|
549
537
|
console.log(`[${CHANNEL_ID}] getChatRecord: chatType "${chatType}" is not supported`);
|
|
550
538
|
return undefined;
|
|
@@ -567,7 +555,7 @@ export async function getChatRecord(
|
|
|
567
555
|
|
|
568
556
|
const { appId: appid, appSecret: secret } = account;
|
|
569
557
|
|
|
570
|
-
const url =
|
|
558
|
+
const url = addTuituiParams2Url(baseurl, { appid, secret });
|
|
571
559
|
|
|
572
560
|
console.log(`[${CHANNEL_ID}] getChatRecord request `, body);
|
|
573
561
|
|
package/src/websocket.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { CHANNEL_ID } from "./const";
|
|
3
|
+
import { getTuituiWebsocketHost } from "./env"
|
|
4
|
+
|
|
5
|
+
const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
|
|
6
|
+
let wsNum = 0;
|
|
7
|
+
|
|
8
|
+
export default function createWebSocket({ account, log, abortSignal, onConnected, onDisconnected, onInbounMessage }: any): () => void {
|
|
9
|
+
const { accountId, appId, appSecret } = account;
|
|
10
|
+
let ws: any = null;
|
|
11
|
+
const _closeWS = () => ws && ws.readyState !== WebSocket.CLOSED && ws.close();
|
|
12
|
+
|
|
13
|
+
wsNum++;
|
|
14
|
+
const wsId = `${wsNum}-${Date.now()}`;
|
|
15
|
+
|
|
16
|
+
let _retryTimerId: any = 0;
|
|
17
|
+
const _clearRetryTimer = () => _retryTimerId && clearTimeout(_retryTimerId);
|
|
18
|
+
|
|
19
|
+
let _timeoutId: any = 0;
|
|
20
|
+
const _clearTimeoutTimer = () => _timeoutId && clearTimeout(_timeoutId);
|
|
21
|
+
|
|
22
|
+
const wsEvtIds = new Set<string>();
|
|
23
|
+
const wsUrl = getTuituiWebsocketHost() + `/callback/ws?auth=${appId}.${appSecret}`;
|
|
24
|
+
const _startWS = () => {
|
|
25
|
+
_closeWS(); // 启动新链接前先关闭旧链接,防止产生空指针链接
|
|
26
|
+
|
|
27
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
|
|
28
|
+
|
|
29
|
+
const _restartWS = (ts: number = 1e4) => {
|
|
30
|
+
if (abortSignal?.aborted) return;
|
|
31
|
+
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
32
|
+
_clearRetryTimer();
|
|
33
|
+
_retryTimerId = setTimeout(_startWS, ts);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
_clearTimeoutTimer();
|
|
37
|
+
_timeoutId = setTimeout(() => {
|
|
38
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Connection Timeout`);
|
|
39
|
+
_closeWS();
|
|
40
|
+
_restartWS(1e3);
|
|
41
|
+
}, 3e4); // 30秒连接超时
|
|
42
|
+
|
|
43
|
+
ws = new WebSocket(wsUrl);
|
|
44
|
+
ws.on('open', () => {
|
|
45
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
|
|
46
|
+
_clearTimeoutTimer();
|
|
47
|
+
onConnected();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// on receiving messages from tuitui websocket server
|
|
51
|
+
ws.on('message', (wsData: string) => {
|
|
52
|
+
let json: any = null;
|
|
53
|
+
try {
|
|
54
|
+
json = JSON.parse(wsData);
|
|
55
|
+
} catch {
|
|
56
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const ack = json?.event_id;
|
|
60
|
+
if (!ack) return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
|
|
61
|
+
|
|
62
|
+
// 收到任意消息则回复一下,权当“收到”
|
|
63
|
+
ws.send(JSON.stringify({ ack }));
|
|
64
|
+
if (wsEvtIds.has(ack)) {
|
|
65
|
+
// 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
|
|
66
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
|
|
67
|
+
}
|
|
68
|
+
// 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
|
|
69
|
+
wsEvtIds.add(ack);
|
|
70
|
+
// 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
|
|
71
|
+
if (wsEvtIds.size > 1e3) {
|
|
72
|
+
const firsEvtId = wsEvtIds.values().next().value;
|
|
73
|
+
if (firsEvtId) wsEvtIds.delete(firsEvtId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const wsEvent = json?.body?.event;
|
|
77
|
+
if (wsEvent === 'keepalive') return;
|
|
78
|
+
|
|
79
|
+
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}], Received event ${wsEvent}, json.body:`, json?.body);
|
|
80
|
+
|
|
81
|
+
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
82
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
|
83
|
+
}
|
|
84
|
+
onInbounMessage(json);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const onErrOrClose = (errStr: string) => {
|
|
88
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] ${errStr}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
89
|
+
|
|
90
|
+
_closeWS(); // 关闭已异常或超时的连接,防止产生多个(超时并不会主动关闭或断开)
|
|
91
|
+
_clearTimeoutTimer(); // 清除连接超时计时器,防止误触发
|
|
92
|
+
_restartWS(1e4); // 不是主动终止的,则10秒后尝试重启
|
|
93
|
+
|
|
94
|
+
onDisconnected();
|
|
95
|
+
};
|
|
96
|
+
ws.on('close', () => onErrOrClose('closed'));
|
|
97
|
+
ws.on('error', (err: any) => onErrOrClose(`error: ${err}`));
|
|
98
|
+
};
|
|
99
|
+
_startWS();
|
|
100
|
+
|
|
101
|
+
// 返回一个函数,调用它可以关闭 WebSocket 连接并清除重试计时器
|
|
102
|
+
return () => {
|
|
103
|
+
_clearRetryTimer();
|
|
104
|
+
_closeWS();
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/confs.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/*** 能力清单配置 ***/
|
|
2
|
-
export const capabilities = {
|
|
3
|
-
chatTypes: ['direct' as const, 'group' as const],
|
|
4
|
-
media: true,
|
|
5
|
-
threads: false,
|
|
6
|
-
reactions: false,
|
|
7
|
-
edit: false,
|
|
8
|
-
unsend: false,
|
|
9
|
-
reply: true,
|
|
10
|
-
effects: false,
|
|
11
|
-
blockStreaming: false,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/*** 一些配置字段设定信息 ***/
|
|
15
|
-
const baseFields = {
|
|
16
|
-
appId: { type: 'string', default: '' } ,
|
|
17
|
-
appSecret: { type: 'string', default: '' },
|
|
18
|
-
dmPolicy: {
|
|
19
|
-
type: 'string',
|
|
20
|
-
default: 'pairing',
|
|
21
|
-
enum: ['pairing', 'allowlist', 'open', 'disabled'],
|
|
22
|
-
},
|
|
23
|
-
allowFrom: {
|
|
24
|
-
type: 'array',
|
|
25
|
-
default: [],
|
|
26
|
-
items: { type: 'string' }
|
|
27
|
-
},
|
|
28
|
-
groupPolicy: {
|
|
29
|
-
type: 'string',
|
|
30
|
-
default: 'allowlist',
|
|
31
|
-
enum: ['allowlist', 'disabled']
|
|
32
|
-
},
|
|
33
|
-
requireMention: {
|
|
34
|
-
type: 'boolean',
|
|
35
|
-
default: true,
|
|
36
|
-
},
|
|
37
|
-
groupAllowFrom: {
|
|
38
|
-
type: 'array',
|
|
39
|
-
default: [],
|
|
40
|
-
items: { type: 'string' }
|
|
41
|
-
},
|
|
42
|
-
channelContext: {
|
|
43
|
-
type: 'string',
|
|
44
|
-
default: 'thread',
|
|
45
|
-
enum: ['channel', 'thread']
|
|
46
|
-
},
|
|
47
|
-
emojiReaction: {
|
|
48
|
-
type: 'boolean',
|
|
49
|
-
default: true,
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
const baseFieldsKeys = Object.keys(baseFields);
|
|
53
|
-
export const baseFildsDefault = {} as Record<string, any>;
|
|
54
|
-
baseFieldsKeys.forEach(k => baseFildsDefault[k] = (baseFields as any)[k].default);
|
|
55
|
-
|
|
56
|
-
const fields = {
|
|
57
|
-
enabled: { type: 'boolean', default: true },
|
|
58
|
-
...baseFields,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const fieldsUiHints = {
|
|
62
|
-
// 基础配置(优先显示)
|
|
63
|
-
enabled: { order: 1, help: '开启或关闭' },
|
|
64
|
-
appId: {
|
|
65
|
-
help: '推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)',
|
|
66
|
-
order: 2,
|
|
67
|
-
},
|
|
68
|
-
appSecret: {
|
|
69
|
-
help: '推推机器人密钥 Secret(推推机器人的 App Secret)',
|
|
70
|
-
order: 3,
|
|
71
|
-
},
|
|
72
|
-
// 高级配置
|
|
73
|
-
dmPolicy: {
|
|
74
|
-
help: '私聊策略(pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊)',
|
|
75
|
-
order: 10,
|
|
76
|
-
advanced: true,
|
|
77
|
-
},
|
|
78
|
-
allowFrom: {
|
|
79
|
-
help: '私聊白名单-域账号(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)',
|
|
80
|
-
order: 11,
|
|
81
|
-
advanced: true,
|
|
82
|
-
},
|
|
83
|
-
groupPolicy: {
|
|
84
|
-
help: '群聊/团队策略(allowlist=白名单;disabled=禁用)',
|
|
85
|
-
order: 12,
|
|
86
|
-
advanced: true,
|
|
87
|
-
},
|
|
88
|
-
requireMention: {
|
|
89
|
-
help: '群组/团队是否需要 @ 机器人才触发 Agent(默认 true)。注意:如果你关闭了这个开关,且有多个龙虾机器人在同一个群里,他们会聊的停不下来',
|
|
90
|
-
order: 13,
|
|
91
|
-
advanced: true,
|
|
92
|
-
},
|
|
93
|
-
groupAllowFrom: {
|
|
94
|
-
help: '群组/团队白名单-包含群ID、团队ID或频道ID(仅在 groupPolicy=allowlist 生效)',
|
|
95
|
-
order: 14,
|
|
96
|
-
advanced: true,
|
|
97
|
-
},
|
|
98
|
-
channelContext: {
|
|
99
|
-
help: '团队-频道上下文(channel=每个频道共享上下文; thread=每个帖子使用独立上下文)。修改后对后续对话生效,对历史数据不生效。',
|
|
100
|
-
order: 15,
|
|
101
|
-
advanced: true,
|
|
102
|
-
},
|
|
103
|
-
emojiReaction: {
|
|
104
|
-
help: '在收到消息后,大模型给出反应结果前,先对原消息发送一个”收到“的表情回复。',
|
|
105
|
-
order: 16,
|
|
106
|
-
advanced: true,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/* 多账户管理 */
|
|
111
|
-
const accountsFieldsUiHints = {} as Record<string, any>;
|
|
112
|
-
function isValidObjKey(key: string | number | symbol , object: object): key is keyof typeof object {
|
|
113
|
-
return key in object;
|
|
114
|
-
}
|
|
115
|
-
for (const k in fieldsUiHints) {
|
|
116
|
-
if (isValidObjKey(k, fieldsUiHints)) accountsFieldsUiHints[`accounts.*.${k}` as string] = fieldsUiHints[k];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export const configSchema = {
|
|
120
|
-
schema: {
|
|
121
|
-
type: 'object',
|
|
122
|
-
additionalProperties: false,
|
|
123
|
-
properties: {
|
|
124
|
-
...fields,
|
|
125
|
-
/* 基础配置信息中,准备 accounts 配置支持多账户管理 */
|
|
126
|
-
accounts: {
|
|
127
|
-
type: 'object',
|
|
128
|
-
additionalProperties: {
|
|
129
|
-
type: 'object',
|
|
130
|
-
additionalProperties: false,
|
|
131
|
-
properties: { ...fields },
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
uiHints: {
|
|
137
|
-
...fieldsUiHints,
|
|
138
|
-
/* 基础配置信息中,准备 accounts 配置支持多账户管理 */
|
|
139
|
-
accounts: {
|
|
140
|
-
help: 'Accounts(多账户配置)',
|
|
141
|
-
order: 30,
|
|
142
|
-
advanced: true
|
|
143
|
-
},
|
|
144
|
-
...accountsFieldsUiHints,
|
|
145
|
-
},
|
|
146
|
-
};
|