@qihoo/tuitui-openclaw-channel 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.17",
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
@@ -2,9 +2,7 @@
2
2
  * TuiTui Channel Plugin for OpenClaw.
3
3
  *
4
4
  * Implements the ChannelPlugin interface following the Synology Chat pattern.
5
- * Supports single chat (text, image, voice, file) and group chat with @mentions.
6
5
  */
7
- import WebSocket from 'ws';
8
6
  import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
9
7
  import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from 'openclaw/plugin-sdk/core';
10
8
  import { CHANNEL_ID, CHANNEL_NAME } from "./const";
@@ -15,16 +13,14 @@ import {
15
13
  sendMediaMsg,
16
14
  guessChatType,
17
15
  } from "./outbound";
18
- import { handleInboundMessage } from './inbound';
19
- import { capabilities, configSchema, baseFildsDefault } from './confs';
20
- import { resolveAccount } from "./accounts"
16
+ import createWebSocket from './websocket';
17
+ import { channelConfigs } from '../openclaw.plugin.json';
21
18
  import { isEnabled } from './utils';
19
+ import { dmPolicyDefault, resolveAccount, getDefaultAccountConfig } from "./accounts"
22
20
 
23
21
  const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
24
22
 
25
- const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
26
- let wsNumber = 0;
27
- const checkTuiTuiConfig = async (apiRuntime: any) => {
23
+ const checkAndSetDefaultTuiTuiConfig = async (apiRuntime: any) => {
28
24
  const cfg = await apiRuntime.config.loadConfig();
29
25
  const channels = cfg?.channels || {};
30
26
  const currChannel = channels?.[CHANNEL_ID] || {};
@@ -35,8 +31,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
35
31
  ...channels,
36
32
  [CHANNEL_ID]: {
37
33
  ...currChannel,
38
- ...baseFildsDefault,
39
- appSecret: undefined,
34
+ ...getDefaultAccountConfig(),
40
35
  }
41
36
  }
42
37
  });
@@ -44,7 +39,7 @@ const checkTuiTuiConfig = async (apiRuntime: any) => {
44
39
  };
45
40
 
46
41
  export function createTuiTuiChannelPlugin(apiRuntime: any) {
47
- checkTuiTuiConfig(apiRuntime);
42
+ checkAndSetDefaultTuiTuiConfig(apiRuntime);
48
43
  return {
49
44
  id: CHANNEL_ID,
50
45
  meta: {
@@ -56,8 +51,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
56
51
  blurb: `Connect to ${CHANNEL_NAME} bot via WebSocket`,
57
52
  order: 100,
58
53
  },
59
- capabilities,
60
- 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,
61
67
  reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
62
68
 
63
69
  config: {
@@ -106,8 +112,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
106
112
  ...cfg.channels,
107
113
  [CHANNEL_ID]: {
108
114
  ...(cfg.channels?.[CHANNEL_ID] ?? {}),
115
+ ...getDefaultAccountConfig(), // 重置基础配置字段,恢复默认值
109
116
  enabled: false, // 默认账户不删除,改为禁用,并重置配置字段
110
- ...baseFildsDefault, // 重置基础配置字段,恢复默认值
111
117
  },
112
118
  },
113
119
  } : deleteAccountFromConfigSection({ cfg, accountId, sectionKey: CHANNEL_ID }); // 多账户状态下的配置信息,按 accountId 删除指定账户; 除非子账户影响根账户字段信息,否则不应该使用 clearBaseFields
@@ -145,7 +151,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
145
151
  let allowFromPath =`channels.${CHANNEL_ID}.`;
146
152
  if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
147
153
 
148
- const policy = account.dmPolicy ?? baseFildsDefault.dmPolicy; // 默认使用 baseFildsDefault 中的 dmPolicy
154
+ const policy = account.dmPolicy ?? dmPolicyDefault;
149
155
  // dmPolicy semantics:
150
156
  // - open: always allow everyone (["*"]), ignore allowFrom values.
151
157
  // - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
@@ -241,97 +247,22 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
241
247
 
242
248
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
243
249
  _setStatus({ running: true });
244
- let ws: any = null;
245
- wsNumber++;
246
- const wsId = `${wsNumber}-${Date.now()}`;
247
- const wsEvtIds = new Set<string>();
248
- let wsRetryTimerId: any = 0;
249
-
250
- const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
251
- const defSendMsg = (msg: any) => {
252
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
253
- };
254
- let _sendMsg = defSendMsg;
255
- const startWebSocket = () => {
256
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
257
- ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
258
- ws.on('open', () => {
259
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
260
- _setStatus({ running: true, connected: true });
261
- _sendMsg = (msg) => ws.send(msg);
262
- });
263
-
264
- // on receiving messages from tuitui websocket server
265
- ws.on('message', async (wsData: string) => {
266
- let json: any = null;
267
- try {
268
- json = JSON.parse(wsData);
269
- } catch {
270
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
271
- return;
272
- }
273
- const ack = json?.event_id;
274
- if (!ack) {
275
- return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
276
- }
277
- if (wsEvtIds.has(ack)) {
278
- // 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
279
- return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
280
- }
281
- // 收到任意消息则回复一下,权当“收到”
282
- _sendMsg(JSON.stringify({ ack }));
283
- // 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
284
- wsEvtIds.add(ack);
285
- // 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
286
- if (wsEvtIds.size > 1e3) {
287
- const firsEvtId = wsEvtIds.values().next().value;
288
- if (firsEvtId) wsEvtIds.delete(firsEvtId);
289
- }
290
-
291
- const wsEvent = json?.body?.event;
292
- if (wsEvent === 'keepalive') return;
293
-
294
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}], Received event ${wsEvent}, body ${JSON.stringify(json?.body, null, 2)}`);
295
-
296
- if (!json?.header || !wsEvent || !json?.body?.data) {
297
- return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
298
- }
299
-
300
- if (!apiRuntime) {
301
- return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
302
- }
303
-
304
- await handleInboundMessage({ json, account, apiRuntime, log });
305
- });
306
-
307
- const onErrOrClose = () => {
308
- if (ws && ws.readyState !== WebSocket.CLOSED) ws.close(); // 关闭超时链接,防止产生多个
309
- _setStatus({ running: false, connected: false });
310
- _sendMsg = defSendMsg;
311
- if (!abortSignal?.aborted) {
312
- log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
313
- if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
314
- wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
315
- }
316
- };
317
- ws.on('close', () => {
318
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
319
- onErrOrClose();
320
- });
321
250
 
322
- // on socket errors
323
- ws.on('error', (err: any) => {
324
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] error: ${err}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
325
- onErrOrClose();
326
- });
327
- };
328
- 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
+ });
329
260
 
330
- // Keep the account running until abortSignal is triggered
261
+ // 保持账户运行状态,直至触发“abortSignal”信号为止。
331
262
  await new Promise<void>((resolve) => {
332
263
  const _onAbort = () => {
333
264
  log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} stopping (abort signal)`);
334
- if (ws && ws.readyState !== WebSocket.CLOSED) ws.close();
265
+ _closeWs();
335
266
  resolve();
336
267
  };
337
268
  if (abortSignal?.aborted) return _onAbort();
package/src/inbound.ts CHANGED
@@ -14,7 +14,6 @@ import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types
14
14
  import { CHANNEL_ID } from './const';
15
15
  import {
16
16
  CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType,
17
- buildMessageBody,
18
17
  tuituiEmojiReaction,
19
18
  sendTextMsg,
20
19
  sendPageMsg,
@@ -22,6 +21,7 @@ import {
22
21
  teamsBuildChatId,
23
22
  teamsParseChatId,
24
23
  } from "./outbound";
24
+ import { parseChatMessageBody } from './inbound_body_parse';
25
25
  import { parseAllowFroms } from './utils';
26
26
  import {
27
27
  addUnmentionedHistory,
@@ -87,11 +87,13 @@ function getSessionKey(cfg: any, payload: ChatPayload, account: InboundAccount,
87
87
  return String(sessionKey).replace(/\//g, '_');
88
88
  }
89
89
 
90
- function getMediaUrls({ msg_type, images, voice, file }: any): string[] | undefined {
90
+ function getMediaUrls({ msg_type, images, voice, video, file }: any): string[] | undefined {
91
91
  if (msg_type === 'image' || msg_type === 'mixed') {
92
92
  if (images?.length) return images;
93
93
  } else if (msg_type === 'voice') {
94
94
  if (voice) return [voice];
95
+ } else if (msg_type === 'video') {
96
+ if (video) return [video];
95
97
  } else if (msg_type === 'file') {
96
98
  if (file?.url) return [file.url];
97
99
  }
@@ -174,6 +176,20 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
174
176
  }
175
177
  }
176
178
 
179
+ //目标存在于私聊白名单或配对存储中
180
+ async function isAllowFrom(chatId: any, apiRuntime: any, account: InboundAccount) {
181
+ if(!chatId) return false;
182
+ const { accountId, allowFrom } = account;
183
+ let storeAllowFrom: string[] = [];
184
+ try {
185
+ storeAllowFrom = parseAllowFroms(
186
+ await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
187
+ );
188
+ } catch {}
189
+ return [...allowFrom, ...storeAllowFrom].includes(chatId);
190
+ }
191
+
192
+
177
193
  /**
178
194
  * 处理单聊(single_chat)、群聊、团队帖子分支,直接修改并校验 payload。
179
195
  * 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
@@ -181,7 +197,7 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
181
197
  const parseAndVerifyPayload: Record<string, Function> = {
182
198
  single_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
183
199
  const { accountId, dmPolicy, allowFrom } = account;
184
- const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
200
+ const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
185
201
  if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
186
202
  log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
187
203
  return false;
@@ -189,7 +205,7 @@ const parseAndVerifyPayload: Record<string, Function> = {
189
205
 
190
206
  payload.chatType = CHAT_TYPE_DIRECT;
191
207
  payload.chatId = chatId;
192
- payload.text = buildMessageBody(msgData);
208
+ payload.text = parseChatMessageBody(msgData);
193
209
  payload.msgId = msgData.msgid;
194
210
  log?.debug?.(
195
211
  `[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat:
@@ -206,15 +222,8 @@ const parseAndVerifyPayload: Record<string, Function> = {
206
222
 
207
223
  if (dmPolicy === 'open') return true;
208
224
 
209
- let storeAllowFrom: string[] = [];
210
- try {
211
- storeAllowFrom = parseAllowFroms(
212
- await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
213
- );
214
- } catch {}
215
-
216
- if(chatId && [...allowFrom, ...storeAllowFrom].includes(chatId)) {
217
- return true; // isAllowed:单聊目标存在于白名单或配对存储中
225
+ if(await isAllowFrom(chatId, apiRuntime, account)){
226
+ return true;
218
227
  }
219
228
 
220
229
  if (dmPolicy === 'pairing') {
@@ -233,10 +242,11 @@ const parseAndVerifyPayload: Record<string, Function> = {
233
242
  payload.chatId = chatId;
234
243
  payload.groupName = msgData.group_name;
235
244
  payload.msgId = msgData.msgid;
236
- payload.text = buildMessageBody(msgData);
245
+ payload.text = parseChatMessageBody(msgData);
246
+ const { tuituiAccount } = payload;
237
247
  log?.debug?.(
238
248
  `[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat:
239
- tuituiAccount=${payload.tuituiAccount},
249
+ tuituiAccount=${tuituiAccount},
240
250
  tuituiUid=${payload.tuituiUid},
241
251
  tuituiUserName=${payload.tuituiUserName},
242
252
  groupId=${chatId},
@@ -250,34 +260,39 @@ const parseAndVerifyPayload: Record<string, Function> = {
250
260
  return false;
251
261
  }
252
262
 
253
- if (!payload.tuituiAccount || !chatId) {
263
+ if (!tuituiAccount || !chatId) {
254
264
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
255
265
  return false;
256
266
  }
257
267
 
258
268
  if (!msgData.at_me && account.requireMention) {
259
269
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore group message (not mentioned), add to history key: ${chatId}`);
260
- await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, payload.tuituiAccount, payload.text, payload.timestamp);
270
+ await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
261
271
  return false;
262
272
  }
263
273
 
274
+ // 私聊白名单对群聊仍然生效,当群里只想特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
275
+ if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
276
+ return true;
277
+ }
278
+
264
279
  if (!groupAllowFrom.includes(chatId)) {
265
280
  if (needPairingThrottle(accountId, chatId)) {
266
281
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
267
282
  return false;
268
283
  }
269
- await sendTextMsg(
270
- account,
271
- chatId,
272
- payload.chatType,
273
- `当前openclaw(AccountId: ${accountId})群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${chatId}`,
274
- 'tuitui.groupPolicy.reply',
275
- );
284
+
285
+ const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊策略为白名单模式。
286
+ 需由龙虾主人在配置中添加白名单(两种方式二选一即可):
287
+
288
+ - 允许群中所有人用,在群白名单(Group Allow From)添加当前群ID${chatId}
289
+
290
+ - 不想群所有人用,只想特定人用,在私聊白名单(Allow From)添加当前用户 ${payload.tuituiAccount} ,私聊白名单对群仍然有效`
291
+
292
+ await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
276
293
  return false;
277
294
  }
278
295
 
279
-
280
-
281
296
  return true;
282
297
  },
283
298
  teams_post_create: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
@@ -291,9 +306,10 @@ const parseAndVerifyPayload: Record<string, Function> = {
291
306
  payload.text = content;
292
307
  payload.channelName = channel_name;
293
308
  payload.replyToId = "";
309
+ const { tuituiAccount } = payload;
294
310
  log?.debug?.(
295
311
  `[${CHANNEL_ID}] AccountId: ${accountId}, inbound teams:
296
- tuituiAccount=${payload.tuituiAccount},
312
+ tuituiAccount=${tuituiAccount},
297
313
  tuituiUid=${payload.tuituiUid},
298
314
  tuituiUserName=${payload.tuituiUserName},
299
315
  chatId=${chatId},
@@ -308,17 +324,22 @@ const parseAndVerifyPayload: Record<string, Function> = {
308
324
  return false;
309
325
  }
310
326
 
311
- if (!payload.tuituiAccount) {
327
+ if (!tuituiAccount) {
312
328
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
313
329
  return false;
314
330
  }
315
331
 
316
332
  if (!msgData.at_me && account.requireMention) {
317
333
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned), add to history key: ${chatId}`);
318
- await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, payload.tuituiAccount, payload.text, payload.timestamp);
334
+ await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
319
335
  return false;
320
336
  }
321
337
 
338
+ // 私聊白名单对团队仍然生效,限制特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
339
+ if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
340
+ return true;
341
+ }
342
+
322
343
  if (!groupAllowFrom.includes(String(team_id)) && !groupAllowFrom.includes(String(channel_id)) ) {
323
344
  if (!msgData.at_me) {
324
345
  // 解决这个case:requireMention=false时,团队里发任意帖子都会回复加白,搞得没法用
@@ -330,13 +351,15 @@ const parseAndVerifyPayload: Record<string, Function> = {
330
351
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
331
352
  return false;
332
353
  }
333
- await sendTextMsg(
334
- account,
335
- chatId,
336
- payload.chatType,
337
- `当前openclaw(AccountId: ${accountId})群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID: ${team_id} 或者频道ID: ${channel_id} 如果配置团队ID指当前团队下所有频道都可以使用,如果配置频道ID则仅此频道可使用`,
338
- 'tuitui.groupPolicy.reply',
339
- );
354
+
355
+ const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊/团队策略为白名单模式。
356
+ 需由龙虾主人在配置中添加白名单(3种方式选一种即可):
357
+
358
+ - 允许团队中所有人用,在群白名单(Group Allow From)添加当前团队ID: ${team_id}
359
+ - 仅允许当前频道使用,在群白名单(Group Allow From)添加当前频道ID: ${channel_id}
360
+ - 仅允许特定人使用,在私聊白名单(Allow From)添加当前用户: ${payload.tuituiAccount} ,私聊白名单对团队仍然有效`
361
+
362
+ await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
340
363
  return false;
341
364
  }
342
365