@qihoo/tuitui-openclaw-channel 1.0.31 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
package/src/chat_base.ts CHANGED
@@ -33,18 +33,38 @@ export function teamsParseChatId(chatId: string): TuiTuiTeamsTarget {
33
33
  }
34
34
 
35
35
 
36
- export function parseChannelIdBySessionKey(str: string): string {
37
- // 检查是否包含必须的格式 "tuitui:channel:"
36
+ export interface TuiTuiSessionKeyInfo {
37
+ chat_type: ChatType,
38
+ channel_id: string;
39
+ thread_id: string;
40
+ }
41
+
42
+ // 暂时只支持频道(后续可以完善)
43
+ // 格式 agent:coder:tuitui:channel:123:thread:456
44
+ export function parseSessionKey(str: string): TuiTuiSessionKeyInfo {
45
+ const info: TuiTuiSessionKeyInfo = { chat_type: CHAT_TYPE_DIRECT, channel_id: '', thread_id: '' };
46
+
38
47
  if (!str.includes('tuitui:channel:')) {
39
- return "";
48
+ return info;
40
49
  }
41
-
50
+
51
+ info.chat_type = CHAT_TYPE_CHANNEL;
52
+
42
53
  const parts = str.split(':');
43
54
  const channelIndex = parts.findIndex(part => part === 'channel');
44
-
45
55
  if (channelIndex !== -1 && parts[channelIndex + 1]) {
46
- return parts[channelIndex + 1];
56
+ info.channel_id = parts[channelIndex + 1];
47
57
  }
48
-
49
- return "";
58
+
59
+ const threadIndex = parts.findIndex(part => part === 'thread');
60
+ if (threadIndex !== -1 && parts[threadIndex + 1]) {
61
+ info.thread_id = parts[threadIndex + 1];
62
+ }
63
+
64
+ return info;
65
+ }
66
+
67
+ export function parseChannelIdBySessionKey(str: string): string {
68
+ const info = parseSessionKey(str);
69
+ return info.channel_id;
50
70
  }
@@ -1,6 +1,8 @@
1
1
  import { CHANNEL_ID } from "./const";
2
2
  import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType, teamsParseChatId, guessChatType, teamsBuildChatId} from "./chat_base"
3
3
  import { tuituiRobotApi } from "./robot_api"
4
+ import { parseSessionKey } from "./chat_base"
5
+ import {getChannelInfoByChannelId} from "./robot_helper"
4
6
  import type { TuiTuiMessageData} from './types';
5
7
 
6
8
  export interface TuiTuiChatRecordMessage {
@@ -236,6 +238,7 @@ export async function getChannelInfoById(account: any, channel_id: string): Prom
236
238
  }
237
239
 
238
240
  interface TeamsPostChainItem {
241
+ from_uid: string;
239
242
  post_id: string;
240
243
  time: string;
241
244
  last_reply_time: string; // 只有主贴有最后回帖时间,所有的回帖更新的都是主贴的属性
@@ -261,6 +264,7 @@ function parsePost(item: any): TeamsPostChainItem[] {
261
264
  const posts: TeamsPostChainItem[] = [];
262
265
 
263
266
  posts.push({
267
+ from_uid: topic.from_uid ?? '',
264
268
  post_id: topic.post_id ?? '',
265
269
  time: topic.create_time ?? '',
266
270
  last_reply_time: topic.last_reply_time ?? '',
@@ -271,6 +275,7 @@ function parsePost(item: any): TeamsPostChainItem[] {
271
275
 
272
276
  for (const post of [...replyList].reverse()) {
273
277
  posts.push({
278
+ from_uid: post.from_uid ?? '',
274
279
  post_id: post.post_id ?? '',
275
280
  time: post.create_time ?? '',
276
281
  last_reply_time : '',
@@ -365,12 +370,26 @@ async function getPostChain(
365
370
 
366
371
  const datas = data.datas ?? {};
367
372
 
368
- const posts = parsePost(datas)
373
+ const posts = parsePost(datas)
369
374
 
370
375
  console.log(`[${CHANNEL_ID}] getPostChain result: ${posts.length} posts`, posts);
371
376
  return posts;
372
377
  }
373
378
 
379
+ export async function getPostChainBySessionKey(
380
+ account: any,
381
+ sessionKey: string,
382
+ ): Promise<TeamsPostChainItem[]> {
383
+ const session_info = parseSessionKey(sessionKey);
384
+
385
+ const channelInfo = await getChannelInfoByChannelId(account, session_info.channel_id);
386
+ if (!channelInfo) {
387
+ throw new Error(`无法获取频道信息`);
388
+ }
389
+
390
+ return await getPostChain(account, channelInfo.team_id, session_info.channel_id, session_info.thread_id);
391
+ }
392
+
374
393
  export async function getPostChainByChatId(
375
394
  account: any,
376
395
  chatId: string,
package/src/filespace.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CHANNEL_ID } from "./const";
2
- import { tuituiRobotApi, downloadUrl, tuituiRobotUpload} from "./robot_api"
3
- import {parseChannelIdBySessionKey} from "./chat_base"
2
+ import { tuituiRobotApi, tuituiRobotUpload} from "./robot_api"
3
+ import {CHAT_TYPE_CHANNEL, parseSessionKey} from "./chat_base"
4
+ import {getChannelInfoByChannelId} from "./robot_helper"
4
5
 
5
6
  export const NODE_TYPE_DIR = '1';
6
7
  export const NODE_TYPE_FILE = '2';
@@ -51,29 +52,21 @@ export function flattenFileSpaceList(list: any[]): FileSpaceItem[] {
51
52
  }));
52
53
  }
53
54
 
54
-
55
-
56
- async function getChannelInfoBySessionKey(account: any, sessionKey: string) {
57
- const channel_id = parseChannelIdBySessionKey(sessionKey);
58
- if(!channel_id) {
59
- return null;
60
- }
61
-
62
- const payload = {channel_id: channel_id};
63
- const body = await tuituiRobotApi(account, '/teams/channel/info', payload);
64
- return body?.datas?.info;
65
- }
66
-
67
55
  export async function file_space_list(
68
56
  account: any,
69
57
  sessionKey: string
70
58
  ): Promise<any> {
71
59
 
72
- const channelInfo = await getChannelInfoBySessionKey(account, sessionKey);
73
- if (!channelInfo) {
60
+ const session_info = parseSessionKey(sessionKey);
61
+ if(session_info.chat_type != CHAT_TYPE_CHANNEL) {
74
62
  throw new Error(`私聊、群聊会话不支持共享空间。仅团队频道支持。`);
75
63
  }
76
64
 
65
+ const channelInfo = await getChannelInfoByChannelId(account, session_info.channel_id);
66
+ if (!channelInfo) {
67
+ throw new Error(`无法获取频道信息`);
68
+ }
69
+
77
70
  const payload = {
78
71
  space_id: channelInfo.team_id,
79
72
  space_type: SPACE_TYPE_TEAM,
@@ -81,6 +74,8 @@ export async function file_space_list(
81
74
 
82
75
  const data = await tuituiRobotApi(account, '/file_space/node/list', payload);
83
76
 
77
+ //console.log(JSON.stringify(data));
78
+
84
79
  const list = data?.datas?.list;
85
80
  const flat_list = flattenFileSpaceList(list);
86
81
 
@@ -98,7 +93,8 @@ export async function file_space_list(
98
93
  async function ensureFolderPath(
99
94
  account: any,
100
95
  spaceId: string,
101
- folderPath: string
96
+ folderPath: string,
97
+ source: string
102
98
  ): Promise<string> {
103
99
  // 标准化路径
104
100
  const normalizedPath = folderPath.startsWith('/') ? folderPath : '/' + folderPath;
@@ -151,6 +147,7 @@ async function ensureFolderPath(
151
147
  node_type: NODE_TYPE_DIR,
152
148
  name: folderName,
153
149
  parent_id: currentParentId,
150
+ source: source,
154
151
  };
155
152
 
156
153
  const createResult = await tuituiRobotApi(account, '/file_space/node/add', createFolderPayload);
@@ -184,11 +181,18 @@ export async function file_space_add(
184
181
  cloud_filepath: string,
185
182
  url_or_localpath: string,
186
183
  ): Promise<any> {
187
- const channelInfo = await getChannelInfoBySessionKey(account, sessionKey);
188
- if (!channelInfo) {
184
+ const session_info = parseSessionKey(sessionKey);
185
+ if(session_info.chat_type != CHAT_TYPE_CHANNEL) {
189
186
  throw new Error(`私聊、群聊会话不支持共享空间。仅团队频道支持。`);
190
187
  }
191
188
 
189
+ const channelInfo = await getChannelInfoByChannelId(account, session_info.channel_id);
190
+ if (!channelInfo) {
191
+ throw new Error(`无法获取频道信息`);
192
+ }
193
+
194
+ const source = session_info.thread_id;
195
+
192
196
  // 1. 解析云路径,分离目录和文件名
193
197
  const normalizedPath = cloud_filepath.startsWith('/')
194
198
  ? cloud_filepath
@@ -201,7 +205,7 @@ export async function file_space_add(
201
205
  : '';
202
206
 
203
207
  // 2. 确保目标目录存在,获取 parent_id
204
- const parentId = await ensureFolderPath(account, channelInfo.team_id, folderPath);
208
+ const parentId = await ensureFolderPath(account, channelInfo.team_id, folderPath, source);
205
209
 
206
210
  // 3. 上传文件获取 fid
207
211
  const isImage = /^data:image\//i.test(url_or_localpath) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(url_or_localpath);
@@ -219,6 +223,7 @@ export async function file_space_add(
219
223
  name: filename,
220
224
  fid: fid,
221
225
  parent_id: parentId,
226
+ source: source,
222
227
  };
223
228
 
224
229
  const body = await tuituiRobotApi(account, '/file_space/node/add', filePayload);
package/src/inbound.ts CHANGED
@@ -19,11 +19,13 @@ import {
19
19
  sendPageMsg,
20
20
  sendMediaMsg,
21
21
  get_announcement,
22
+ get_team_members,
22
23
  } from "./outbound";
23
24
  import { parseChatMessageBody } from './inbound_body_parse';
24
25
  import { parseAllowFroms } from './utils';
25
26
  import { addUnmentionedHistory, popUnmentionedHistories } from "./histories";
26
27
  import { StringDeduplicator } from "./deduplicator"
28
+ import { getPostChainByChatId } from "./chat_record"
27
29
 
28
30
  // 会话上下文注入排重
29
31
  let _session_ctx_injected = new StringDeduplicator(1000, 3600);
@@ -177,19 +179,52 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
177
179
  }
178
180
  }
179
181
 
180
- //目标账号是否允许私聊(私聊白名单、配对、或者open)
181
- async function isAllowAccount(tuituiAccount: any, apiRuntime: any, account: InboundAccount) {
182
- if(!tuituiAccount) return false;
182
+ //tuituiAccounts列表中是否存在至少1个允许私聊的用户(私聊白名单、配对、或者open)
183
+ async function isAllowOneOfAccount(tuituiAccounts: string[], apiRuntime: any, account: InboundAccount) {
184
+ if(!tuituiAccounts) return false;
183
185
  const { accountId, allowFrom, dmPolicy } = account;
184
186
  if (dmPolicy === 'open') return true;
185
187
 
186
- let storeAllowFrom: string[] = [];
188
+ // 是否包含任意一个元素
189
+ if (tuituiAccounts.some(item => allowFrom.includes(item))) return true;
190
+
187
191
  try {
188
- storeAllowFrom = parseAllowFroms(
192
+ let storeAllowFrom = parseAllowFroms(
189
193
  await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
190
194
  );
195
+ if (tuituiAccounts.some(item => storeAllowFrom.includes(item))) return true;
191
196
  } catch {}
192
- return [...allowFrom, ...storeAllowFrom].includes(tuituiAccount);
197
+ return false;
198
+ }
199
+
200
+ async function isAllowAccount(tuituiAccount: string, apiRuntime: any, account: InboundAccount) {
201
+ return await isAllowOneOfAccount([tuituiAccount], apiRuntime, account);
202
+ }
203
+
204
+ async function isChannelCoordAuthorized(tuituiAccount: string, apiRuntime: any, account: InboundAccount, team_id:string, chat_id:string) {
205
+ // 协调员身份校验
206
+ if (tuituiAccount != "bot-FiwwCeDw" && tuituiAccount != "bot-sUwUeknd") return false;
207
+
208
+ // 当前 thread 中有白名单用户发帖,则协调员继承该权限
209
+ const [members, posts] = await Promise.all([
210
+ get_team_members(account, team_id),
211
+ getPostChainByChatId(account, chat_id),
212
+ ]);
213
+ if (!members?.length || !posts?.length) return false;
214
+
215
+ const from_uids = posts.map((p: any) => p.from_uid).filter(Boolean);
216
+ if (!from_uids.length) return false;
217
+
218
+ //console.log("from_uids", from_uids);
219
+
220
+ // 将 uid 映射到 user_account
221
+ const from_accounts: string[] = members
222
+ .filter((m: any) => from_uids.includes(m.uid) && m.account)
223
+ .map((m: any) => String(m.account));
224
+ if (!from_accounts.length) return false;
225
+
226
+ //console.log("from_accounts", from_accounts);
227
+ return await isAllowOneOfAccount(from_accounts, apiRuntime, account);
193
228
  }
194
229
 
195
230
 
@@ -386,6 +421,12 @@ async function parse_teams_post(payload: ChatPayload, msgData: any, account: Inb
386
421
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned)`);
387
422
  return false;
388
423
  }
424
+
425
+ // 频道中允许协调员@自己(且帖子里有白名单用户发帖)
426
+ if (await isChannelCoordAuthorized(tuituiAccount, apiRuntime, account, team_id, chatId)) {
427
+ return true;
428
+ }
429
+
389
430
  if (needPairingThrottle(accountId, chatId)) {
390
431
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
391
432
  return false;
package/src/outbound.ts CHANGED
@@ -234,3 +234,12 @@ export async function get_announcement(account: any, id: any, id_is_session: boo
234
234
  const announcement = body?.datas?.info?.announcement;
235
235
  return announcement;
236
236
  }
237
+
238
+
239
+ export async function get_team_members(account: any, team_id: string): Promise<any> {
240
+ const payload = {team_id: team_id};
241
+ const body = await tuituiRobotApi(account, '/teams/member/list', payload);
242
+ const members = body?.datas?.members;
243
+ //console.log("info", members);
244
+ return members;
245
+ }
package/src/robot_api.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
- import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
4
3
  import { CHANNEL_ID } from "./const";
5
4
  import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
6
5
 
@@ -19,6 +18,8 @@ export function checkAccount(account: any, ctxTips: string = 'send text') {
19
18
  export async function tuituiRobotApi(account: any, api: string, payload: any, log: boolean = true) {
20
19
  checkAccount(account);
21
20
 
21
+ const startTime = Date.now(); // 记录开始时间
22
+
22
23
  if (log) {
23
24
  console.log(`[${CHANNEL_ID}] ${api} request`, payload);
24
25
  }
@@ -32,8 +33,8 @@ export async function tuituiRobotApi(account: any, api: string, payload: any, lo
32
33
  fetch_fun = _fetchForm;
33
34
  }
34
35
 
35
- const { response, release } = await fetch_fun(url, payload);
36
36
  try {
37
+ const response = await fetch_fun(url, payload);
37
38
  const bodyText = await response.text();
38
39
 
39
40
  if (!response.ok) {
@@ -50,36 +51,26 @@ export async function tuituiRobotApi(account: any, api: string, payload: any, lo
50
51
  console.error(`[${CHANNEL_ID}] ${api} error:`, err);
51
52
  throw err;
52
53
  } finally {
53
- await release();
54
+ if (log) {
55
+ const endTime = Date.now();
56
+ const duration = endTime - startTime;
57
+ console.log(`[${CHANNEL_ID}] ${api} response took ${duration}ms`);
58
+ }
54
59
  }
55
60
  }
56
61
 
57
- function _fetch(opts: any): Promise<any> {
58
- return fetchWithSsrFGuard({
59
- policy: TUITUI_SSRF_POLICY,
60
- ...opts,
61
- })
62
- }
63
- function _fetchJson(url: string, json: any, auditCtx: string = "tuitui.api.call"): Promise<any> {
64
- return _fetch({
65
- url,
66
- init: {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify(json),
70
- },
71
- auditCtx
62
+ function _fetchJson(url: string, json: any): Promise<Response> {
63
+ return fetch(url, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify(json),
72
67
  });
73
68
  }
74
69
 
75
- function _fetchForm(url: string, form: any, auditCtx: string = "tuitui.api.call"): Promise<any> {
76
- return _fetch({
77
- url,
78
- init: {
79
- method: 'POST',
80
- body: form,
81
- },
82
- auditCtx
70
+ function _fetchForm(url: string, form: any): Promise<Response> {
71
+ return fetch(url, {
72
+ method: 'POST',
73
+ body: form,
83
74
  });
84
75
  }
85
76
 
@@ -89,10 +80,26 @@ function _fetchForm(url: string, form: any, auditCtx: string = "tuitui.api.call"
89
80
  * @returns Object with buffer, filename and content type
90
81
  */
91
82
  export async function downloadUrl(url: string): Promise<{ buffer: ArrayBuffer; filename: string; contentType: string }> {
92
- const { response, release } = await _fetch({
83
+ console.log(`[${CHANNEL_ID}] downloadUrl prepair`);
84
+
85
+ let fetchWithSsrFGuard: any;
86
+ try {
87
+ fetchWithSsrFGuard = (await import('openclaw/plugin-sdk/ssrf-runtime')).fetchWithSsrFGuard;
88
+ } catch {
89
+ try {
90
+ fetchWithSsrFGuard = (await import('openclaw/plugin-sdk/tlon')).fetchWithSsrFGuard;
91
+ } catch {
92
+ throw new Error(`[${CHANNEL_ID}] fetchWithSsrFGuard() API import failed`);
93
+ }
94
+ }
95
+
96
+ console.log(`[${CHANNEL_ID}] downloadUrl start ${url}`);
97
+
98
+ const { response, release } = await fetchWithSsrFGuard({
99
+ policy: TUITUI_SSRF_POLICY,
93
100
  url,
94
101
  init: { method: 'GET' },
95
- auditCtx: "tuitui.download"
102
+ auditContext: "tuitui.download",
96
103
  });
97
104
 
98
105
  try {
@@ -120,6 +127,8 @@ export async function downloadUrl(url: string): Promise<{ buffer: ArrayBuffer; f
120
127
  if (match) filename = decodeURIComponent(match[1]);
121
128
  }
122
129
 
130
+ console.log(`[${CHANNEL_ID}] downloadUrl ok`);
131
+
123
132
  return { buffer, filename, contentType };
124
133
  } finally {
125
134
  await release();
@@ -0,0 +1,7 @@
1
+ import { tuituiRobotApi} from "./robot_api"
2
+
3
+ export async function getChannelInfoByChannelId(account: any, channel_id: string) {
4
+ const payload = {channel_id: channel_id};
5
+ const body = await tuituiRobotApi(account, '/teams/channel/info', payload);
6
+ return body?.datas?.info;
7
+ }
package/src/tools.ts CHANGED
@@ -2,7 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
2
  import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
3
3
  import { resolveAccount } from "./accounts"
4
4
  import { Type } from "@sinclair/typebox";
5
-
5
+ import { CHANNEL_ID } from './const';
6
6
  import {sendTextMsg, get_announcement} from "./outbound"
7
7
  import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,guessChatType, teamsBuildChatId} from "./chat_base"
8
8
  import {getChatRecord, getChannelInfoById} from "./chat_record"
@@ -162,6 +162,14 @@ export function registerTuituiTools(api: OpenClawPluginApi) {
162
162
  api.logger.debug?.("tuitui: Registered tool: No config available");
163
163
  return;
164
164
  }
165
+
166
+ const channels = api.config.channels || {};
167
+ const currChannel = channels[CHANNEL_ID] || {};
168
+ if (!currChannel?.appId && !currChannel?.accounts) {
169
+ // 无推推配置时,不需要注册工具防止污染上下文
170
+ // api.logger.info?.(`tuitui: ignore Registered tools`);
171
+ return
172
+ }
165
173
 
166
174
  api.registerTool(tuitui_im_get_messages_factory);
167
175
  api.registerTool(tuitui_send_channel_post_factory);