@qihoo/tuitui-openclaw-channel 1.0.18 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
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 channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
8
- const targetId = accountId || DEFAULT_ACCOUNT_ID;
9
- const acct = targetId === DEFAULT_ACCOUNT_ID ? channelConfig : channelConfig.accounts?.[targetId];
10
- return {
11
- accountId: targetId,
12
- enabled: isEnabled(acct?.enabled),
13
- appId: acct?.appId || '',
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
@@ -3,7 +3,6 @@
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";
@@ -14,16 +13,14 @@ import {
14
13
  sendMediaMsg,
15
14
  guessChatType,
16
15
  } from "./outbound";
17
- import { handleInboundMessage } from './inbound';
18
- import { capabilities, configSchema, baseFildsDefault } from './confs';
19
- import { resolveAccount } from "./accounts"
16
+ import createWebSocket from './websocket';
17
+ import { channelConfigs } from '../openclaw.plugin.json';
20
18
  import { isEnabled } from './utils';
19
+ import { dmPolicyDefault, resolveAccount, getDefaultAccountConfig } from "./accounts"
21
20
 
22
21
  const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
23
22
 
24
- const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
25
- let wsNumber = 0;
26
- const checkTuiTuiConfig = async (apiRuntime: any) => {
23
+ const checkAndSetDefaultTuiTuiConfig = async (apiRuntime: any) => {
27
24
  const cfg = await apiRuntime.config.loadConfig();
28
25
  const channels = cfg?.channels || {};
29
26
  const currChannel = channels?.[CHANNEL_ID] || {};
@@ -34,8 +31,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
34
31
  ...channels,
35
32
  [CHANNEL_ID]: {
36
33
  ...currChannel,
37
- ...baseFildsDefault,
38
- appSecret: undefined,
34
+ ...getDefaultAccountConfig(),
39
35
  }
40
36
  }
41
37
  });
@@ -43,7 +39,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
43
39
  };
44
40
 
45
41
  export function createTuiTuiChannelPlugin(apiRuntime: any) {
46
- checkTuiTuiConfig(apiRuntime);
42
+ checkAndSetDefaultTuiTuiConfig(apiRuntime);
47
43
  return {
48
44
  id: CHANNEL_ID,
49
45
  meta: {
@@ -55,8 +51,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
55
51
  blurb: `Connect to ${CHANNEL_NAME} bot via WebSocket`,
56
52
  order: 100,
57
53
  },
58
- capabilities,
59
- configSchema,
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,
60
67
  reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
61
68
 
62
69
  config: {
@@ -105,8 +112,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
105
112
  ...cfg.channels,
106
113
  [CHANNEL_ID]: {
107
114
  ...(cfg.channels?.[CHANNEL_ID] ?? {}),
115
+ ...getDefaultAccountConfig(), // 重置基础配置字段,恢复默认值
108
116
  enabled: false, // 默认账户不删除,改为禁用,并重置配置字段
109
- ...baseFildsDefault, // 重置基础配置字段,恢复默认值
110
117
  },
111
118
  },
112
119
  } : deleteAccountFromConfigSection({ cfg, accountId, sectionKey: CHANNEL_ID }); // 多账户状态下的配置信息,按 accountId 删除指定账户; 除非子账户影响根账户字段信息,否则不应该使用 clearBaseFields
@@ -144,7 +151,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
144
151
  let allowFromPath =`channels.${CHANNEL_ID}.`;
145
152
  if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
146
153
 
147
- const policy = account.dmPolicy ?? baseFildsDefault.dmPolicy; // 默认使用 baseFildsDefault 中的 dmPolicy
154
+ const policy = account.dmPolicy ?? dmPolicyDefault;
148
155
  // dmPolicy semantics:
149
156
  // - open: always allow everyone (["*"]), ignore allowFrom values.
150
157
  // - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
@@ -240,97 +247,22 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
240
247
 
241
248
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
242
249
  _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
-
293
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}], Received event ${wsEvent}, body ${JSON.stringify(json?.body, null, 2)}`);
294
-
295
- if (!json?.header || !wsEvent || !json?.body?.data) {
296
- return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
297
- }
298
-
299
- if (!apiRuntime) {
300
- return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
301
- }
302
-
303
- await handleInboundMessage({ json, account, apiRuntime, log });
304
- });
305
-
306
- const onErrOrClose = () => {
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秒后尝试重启
314
- }
315
- };
316
- ws.on('close', () => {
317
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
318
- onErrOrClose();
319
- });
320
250
 
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();
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
+ });
328
260
 
329
- // Keep the account running until abortSignal is triggered
261
+ // 保持账户运行状态,直至触发“abortSignal”信号为止。
330
262
  await new Promise<void>((resolve) => {
331
263
  const _onAbort = () => {
332
264
  log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} stopping (abort signal)`);
333
- if (ws && ws.readyState !== WebSocket.CLOSED) ws.close();
265
+ _closeWs();
334
266
  resolve();
335
267
  };
336
268
  if (abortSignal?.aborted) return _onAbort();
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 = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
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
- let storeAllowFrom: string[] = [];
212
- try {
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=${payload.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 (!payload.tuituiAccount || !chatId) {
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, payload.tuituiAccount, payload.text, payload.timestamp);
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
- await sendTextMsg(
272
- account,
273
- chatId,
274
- payload.chatType,
275
- `当前openclaw(AccountId: ${accountId})群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${chatId}`,
276
- 'tuitui.groupPolicy.reply',
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=${payload.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 (!payload.tuituiAccount) {
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, payload.tuituiAccount, payload.text, payload.timestamp);
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
- await sendTextMsg(
336
- account,
337
- chatId,
338
- payload.chatType,
339
- `当前openclaw(AccountId: ${accountId})群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID: ${team_id} 或者频道ID: ${channel_id} 如果配置团队ID指当前团队下所有频道都可以使用,如果配置频道ID则仅此频道可使用`,
340
- 'tuitui.groupPolicy.reply',
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
@@ -250,43 +250,37 @@ export async function tuituiEmojiReaction(
250
250
  ): Promise<void> {
251
251
  const payload = {
252
252
  msgtype: 'emoji_reaction',
253
- tousers: [] as TuiTuiSingleEmojiReactionTarget[],
254
- togroups: [] as TuiTuiGroupEmojiReactionTarget[],
255
- toteams: [] as TuiTuiTeamsTarget[],
256
253
  emoji_reaction: { emoji, cancel: false},
257
- };
254
+ } as any;
258
255
  if(chatType == CHAT_TYPE_GROUP) {
259
- payload.togroups.push({ group: target, msgid });
256
+ payload.togroups = [{ group: target, msgid }] as TuiTuiGroupEmojiReactionTarget[];
260
257
  } else if(chatType == CHAT_TYPE_DIRECT) {
261
- payload.tousers.push({ user: target, msgid });
258
+ payload.tousers = [{ user: target, msgid }] as TuiTuiSingleEmojiReactionTarget[];
262
259
  } else {
263
- var toteam = teamsParseChatId(target);
264
- toteam.parent_id = "";
265
- toteam.post_id = msgid;
266
- payload.toteams.push(toteam);
260
+ payload.toteams = [{ ...teamsParseChatId(target), parent_id: '', post_id: msgid }] as TuiTuiTeamsTarget[];
267
261
  }
268
262
 
269
263
  const { appId: appid, appSecret: secret } = account;
264
+ const toTarget = (payload.togroups || payload.tousers || payload.toteams)[0];
265
+ const _logTxt =`[${CHANNEL_ID}] emoji_reaction "${emoji}"`;
266
+ console.log(`${_logTxt} request`, toTarget);
270
267
  const sendUrl = addParams2Url('https://im.live.360.cn:8282/robot/message/custom/modify', { appid, secret });
271
- console.log('tuitui emoji_reaction request', payload);
272
268
  const { response, release } = await _fetchJson(sendUrl, payload, 'tuitui.emoji_reaction');
273
269
 
274
270
  try {
275
- const body = await response.text().catch(() => "");
276
- console.log('tuitui emoji_reaction response', body);
271
+ const body = JSON.parse(await response.text().catch(() => "{}"));
272
+ console.log(`${_logTxt} response errcode=${body.errcode} errmsg=${body.errmsg}`, toTarget);
277
273
  } catch (err) {
278
- console.error(`[${CHANNEL_ID}] tuituiEmojiReaction Caught exception:`, err)
274
+ console.error(`${_logTxt} Caught exception:`, err)
279
275
  } finally {
280
276
  await release();
281
277
  }
282
278
  }
283
279
 
284
280
  export function teamsBuildChatId(team_id: string, channel_id:string, thread_id:string) : string{
285
- if(thread_id) {
286
- return `teams_${team_id}_${channel_id}_${thread_id}`;
287
- } else {
288
- return `teams_${team_id}_${channel_id}`;
289
- }
281
+ let ret = `teams_${team_id}_${channel_id}`;
282
+ if(thread_id) ret += `_${thread_id}`;
283
+ return ret;
290
284
  }
291
285
 
292
286
  export function teamsParseChatId(chatId: string): TuiTuiTeamsTarget {
@@ -443,11 +437,11 @@ export async function sendMediaMsg(
443
437
 
444
438
  const targets = getTargets(chatId, chatType);
445
439
  if (chatType == CHAT_TYPE_CHANNEL) {
446
- var content = "";
447
- if (isImage){
440
+ let content = '';
441
+ if (isImage) {
448
442
  content = `![]({{tuitui_image "${fid}"}})`;
449
443
  } else {
450
- content = `[${filename}]({{tuitui_file "${fid}"}})`;
444
+ content = `[${filename}]({{tuitui_file "${fid}"}})`
451
445
  }
452
446
  const msg: TuiTuiOutboundTeamsMarkdownMessage = {
453
447
  ...targets,
@@ -0,0 +1,110 @@
1
+ import WebSocket from 'ws';
2
+ import { CHANNEL_ID } from "./const";
3
+ import { handleInboundMessage } from './inbound';
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, apiRuntime }: 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 = `wss://im.live.360.cn:8282/robot/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
+ if (!apiRuntime) {
85
+ return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
86
+ }
87
+
88
+ handleInboundMessage({ json, account, apiRuntime, log });
89
+ });
90
+
91
+ const onErrOrClose = (errStr: string) => {
92
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] ${errStr}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
93
+
94
+ _closeWS(); // 关闭已异常或超时的连接,防止产生多个(超时并不会主动关闭或断开)
95
+ _clearTimeoutTimer(); // 清除连接超时计时器,防止误触发
96
+ _restartWS(1e4); // 不是主动终止的,则10秒后尝试重启
97
+
98
+ onDisconnected();
99
+ };
100
+ ws.on('close', () => onErrOrClose('closed'));
101
+ ws.on('error', (err: any) => onErrOrClose(`error: ${err}`));
102
+ };
103
+ _startWS();
104
+
105
+ // 返回一个函数,调用它可以关闭 WebSocket 连接并清除重试计时器
106
+ return () => {
107
+ _clearRetryTimer();
108
+ _closeWS();
109
+ };
110
+ }
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
- };