@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.
@@ -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.20",
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
+ 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 { handleInboundMessage } from './inbound';
18
- import { capabilities, configSchema, baseFildsDefault } from './confs';
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 wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as 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
- ...baseFildsDefault,
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
- checkTuiTuiConfig(apiRuntime);
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
- capabilities,
59
- configSchema,
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 ?? baseFildsDefault.dmPolicy; // 默认使用 baseFildsDefault 中的 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
- 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秒后尝试重启
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
- ws.on('close', () => {
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
- // Keep the account running until abortSignal is triggered
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
- if (ws && ws.readyState !== WebSocket.CLOSED) ws.close();
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 = 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
@@ -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 addParams2Url(urlStr: string, params: any) {
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
- addParams2Url('https://im.live.360.cn:8282/robot/message/custom/send', { appid, secret }),
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 | undefined> {
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: addParams2Url('https://im.live.360.cn:8282/robot/media/upload', { appid, secret, type }),
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 media to TuiTui: ${response.status}`);
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}] media upload failed: ${result.errmsg || "Unknown error"}`);
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
- console.error(`[${CHANNEL_ID}] uploadFileToTuiTui error:`, err, `filename: ${filename}, fileSrc: ${fileSrc}`);
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.push({ group: target, msgid });
255
+ payload.togroups = [{ group: target, msgid }] as TuiTuiGroupEmojiReactionTarget[];
260
256
  } else if(chatType == CHAT_TYPE_DIRECT) {
261
- payload.tousers.push({ user: target, msgid });
257
+ payload.tousers = [{ user: target, msgid }] as TuiTuiSingleEmojiReactionTarget[];
262
258
  } else {
263
- var toteam = teamsParseChatId(target);
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 sendUrl = addParams2Url('https://im.live.360.cn:8282/robot/message/custom/modify', { appid, secret });
271
- console.log('tuitui emoji_reaction request', payload);
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('tuitui emoji_reaction response', body);
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(`[${CHANNEL_ID}] tuituiEmojiReaction Caught exception:`, err)
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
- if(thread_id) {
286
- return `teams_${team_id}_${channel_id}_${thread_id}`;
287
- } else {
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
- var content = "";
447
- if (isImage){
434
+ let content = '';
435
+ if (isImage) {
448
436
  content = `![]({{tuitui_image "${fid}"}})`;
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 = "https://im.live.360.cn:8282/robot/message/single/sync";
533
+ baseurl = "/message/single/sync";
546
534
  } else if (chatType == CHAT_TYPE_GROUP){
547
- baseurl = "https://im.live.360.cn:8282/robot/message/group/sync";
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 = addParams2Url(baseurl, { appid, secret });
558
+ const url = addTuituiParams2Url(baseurl, { appid, secret });
571
559
 
572
560
  console.log(`[${CHANNEL_ID}] getChatRecord request `, body);
573
561
 
@@ -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
- };