@qihoo/tuitui-openclaw-channel 1.0.27 → 1.0.29

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/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  ## 安装与配置指南
29
29
 
30
- > 最小兼容版本为 `OpenClaw 2026.3.13`、最高测试版本 `OpenClaw 2026.4.9`。
30
+ > 最小兼容版本为 `OpenClaw 2026.3.13`。
31
31
 
32
32
  龙虾+推推插件配置指南
33
33
 
@@ -131,7 +131,7 @@
131
131
  "advanced": true
132
132
  },
133
133
  "monitorEnabled": {
134
- "help": "是否开启工作状态监控上报(默认 false)。开启后,所有 Agent 事件的完整原始数据将实时上报到监控接口。",
134
+ "help": "是否开启agent事件信息上报(默认 false)。修改后必须重启网关才能生效。",
135
135
  "order": 17,
136
136
  "advanced": true
137
137
  },
@@ -185,7 +185,7 @@
185
185
  "advanced": true
186
186
  },
187
187
  "accounts.*.monitorEnabled": {
188
- "help": "是否开启工作状态监控上报(默认 false)。",
188
+ "help": "是否开启agent事件信息上报(默认 false)。修改后必须重启网关才能生效。",
189
189
  "order": 311,
190
190
  "advanced": true
191
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
@@ -17,19 +17,19 @@ description: |
17
17
 
18
18
  ---
19
19
 
20
- ## 工具:tuitui_im_get_messages
20
+ ## 工具: tuitui_im_get_messages
21
21
 
22
22
  ### 参数说明
23
23
 
24
24
  | 参数 | 类型 | 必填 | 说明 |
25
25
  |------|------|------|------|
26
- | `chatId` | string | ✅ | 聊天 ID。单聊填对方的 tuitui account,群聊填群 ID |
27
- | `chatType` | string | ✅ | 聊天类型:单聊填 `direct`,群聊填 `group` |
28
- | `relativeTime` | string | ❌ | 相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 `startTime`/`endTime` 互斥 |
26
+ | `chatId` | string | ✅ | 聊天 ID。单聊填对方的 tuitui account,群聊填群ID,频道填频道ID |
27
+ | `chatType` | string | ✅ | 聊天类型:单聊填 `direct`,群聊填 `group`, 频道填 `channel` |
28
+ | `relativeTime` | string | ❌ | 相对时间范围:today / yesterday / day_before_yesterday / last_{N}_{unit}(unit: minutes/hours/days/months)。与 `startTime`/`endTime` 互斥 |
29
29
  | `startTime` | string | ❌ | 起始时间,ISO 8601 格式,如 `2026-02-27T00:00:00+08:00`。不填默认从最早开始。与 `relativeTime` 互斥 |
30
30
  | `endTime` | string | ❌ | 结束时间,ISO 8601 格式,如 `2026-02-27T23:59:59+08:00`。不填默认到当前时间。与 `relativeTime` 互斥 |
31
- | `limit` | number | ❌ | 每页条数,范围 1~100,默认 100 |
32
- | `cursor` | string | ❌ | 分页游标,首次调用填 `"0"`,翻页时传上次返回的 `cursor` |
31
+ | `limit` | number | ❌ | 每页条数,范围 1~100,单聊群聊默认 100,频道默认20 |
32
+ | `cursor` | string | ❌ | 分页游标,首次调用填 `"0"`,翻页时传上次返回的 `cursor`, 带有cursor时,必须要有relativeTime或者startTime参数 |
33
33
  | `orderAsc` | boolean | ❌ | 排序方向,`true` 正序(从旧到新),`false` 逆序(从新到旧,默认) |
34
34
 
35
35
  ### 返回结构
@@ -43,16 +43,7 @@ description: |
43
43
  "current_time": "2026-03-21 10:00:00",
44
44
  "msgs": [
45
45
  {
46
- "user_account": "zhangsan",
47
- "user_name": "张三",
48
- "msg_time": "2026-01-03 15:42:44",
49
- "msg_type": "text",
50
- "text": "消息内容",
51
- "images": [],
52
- "group_id": "group_abc",
53
- "group_name": "某群",
54
- "at_me": false,
55
- "ref": null
46
+ //...
56
47
  }
57
48
  ]
58
49
  }
@@ -65,15 +56,6 @@ description: |
65
56
  | `current_time` | 当前服务器时间,格式 `YYYY-MM-DD HH:MM:SS`,辅助理解消息的相对时间 |
66
57
  | `has_more` | `true` 时说明还有更多数据,可用返回的 `cursor` 继续翻页 |
67
58
  | `msgs` | 消息列表,空数组表示该时间段内没有消息 |
68
- | `msgs[].user_account` | 发送者账号 |
69
- | `msgs[].user_name` | 发送者显示名称 |
70
- | `msgs[].msg_time` | 消息时间,格式 `YYYY-MM-DD HH:MM:SS` |
71
- | `msgs[].msg_type` | 消息类型:`text` / `image` / `mixed` / `voice` / `file` |
72
- | `msgs[].text` | 文本内容(`msg_type` 为 `text` / `mixed` 时有值) |
73
- | `msgs[].images` | 图片 URL 列表(`msg_type` 为 `image` / `mixed` 时有值) |
74
- | `msgs[].file` | 文件信息 `{ name, url, file_id }`(`msg_type` 为 `file` 时有值) |
75
- | `msgs[].at_me` | 是否 @ 了机器人(群聊) |
76
- | `msgs[].ref` | 引用/回复的原消息,无引用时为 `null` |
77
59
 
78
60
  ---
79
61
 
@@ -81,7 +63,8 @@ description: |
81
63
 
82
64
  ### 1. 时间范围:确保消息覆盖完整
83
65
 
84
- - 用户说"今天"、"最近"等相对时间时,自主推算对应的 `startTime` / `endTime`(ISO 8601,+08:00 时区)
66
+ - 用户说"今天"、"最近一周"等相对时间时,应该优先使用 relativeTime 参数,例如 今天=today 过去一周=last_7_days 过去一个月=last_31_days
67
+ - 只有用户明确的说日期时,才计算并使用 `startTime` / `endTime` 字段(ISO 8601,+08:00 时区)
85
68
  - 不确定范围时适当放宽,宁可多拉再过滤
86
69
 
87
70
  ### 2. 分页:根据需要翻页获取更多结果
@@ -107,19 +90,17 @@ description: |
107
90
  }
108
91
  ```
109
92
 
110
- ### 场景 2:获取某时间段内的单聊消息
93
+ ### 场景 2:获取最近一周的某群聊消息
111
94
 
112
95
  ```json
113
96
  {
114
- "chatId": "zhangsan",
115
- "chatType": "direct",
116
- "startTime": "2026-03-01T00:00:00+08:00",
117
- "endTime": "2026-03-01T23:59:59+08:00",
118
- "orderAsc": true
97
+ "chatId": "4511334567",
98
+ "chatType": "group",
99
+ "relativeTime": "last_7_days"
119
100
  }
120
101
  ```
121
102
 
122
- ### 场景 3:分页获取更多消息
103
+ ### 场景 3:获取最近一周的某群聊消息时出现分页后,继续分页获取更多消息
123
104
 
124
105
  第一次调用返回 `has_more: true` 和 `cursor: "xxx"`,继续获取:
125
106
 
@@ -127,10 +108,20 @@ description: |
127
108
  {
128
109
  "chatId": "4511334567",
129
110
  "chatType": "group",
111
+ "relativeTime": "last_7_days",
130
112
  "cursor": "xxx"
131
113
  }
132
114
  ```
133
115
 
116
+ ### 场景 4:获取某频道最新消息
117
+
118
+ ```json
119
+ {
120
+ "chatId": "45113345673344",
121
+ "chatType": "channel"
122
+ }
123
+ ```
124
+
134
125
  ---
135
126
 
136
127
  ## 常见错误与排查
@@ -140,4 +131,5 @@ description: |
140
131
  | 消息结果为空 | 时间范围不对或 chatId 有误 | 检查 `chatId`、`chatType`,适当放宽时间范围 |
141
132
  | 消息不完整 | 没有检查 `has_more` 并翻页 | `has_more=true` 时用返回的 `cursor` 继续翻页 |
142
133
  | 报错 invalid tuitui account | 账号未配置或未启用 | 确认 tuitui 账号已正确配置 |
143
- | 消息顺序不对 | `orderAsc` 方向有误 | 按需传 `orderAsc: true/false` |
134
+ | 报错 找不到 tuitui_im_get_messages tool | 龙虾选线未开启,需要去龙虾 Dashboard->代理->Tools->检查 tuitui_im_get_messages 是否开启
135
+
package/src/accounts.ts CHANGED
@@ -2,7 +2,13 @@ import { parseAllowFroms, isEnabled } from './utils';
2
2
  import { CHANNEL_ID } from './const';
3
3
  import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
4
4
 
5
+ export const enabledDefault = true;
5
6
  export const dmPolicyDefault = 'pairing';
7
+ export const groupPolicyDefault = 'allowlist';
8
+ export const requireMentionDefault = true;
9
+ export const emojiReactionDefault = true;
10
+ export const monitorEnabledDefault = false;
11
+
6
12
  const mergeArrs = (arr1: any, arr2: any) => {
7
13
  return [...new Set([...(arr1 || []), ...arr2 || []])];
8
14
  };
@@ -14,11 +20,11 @@ export const getAccountInfo = (acct?: any) => ({
14
20
  dmPolicy: acct?.dmPolicy || dmPolicyDefault,
15
21
  allowFrom: parseAllowFroms(acct?.allowFrom || []),
16
22
  // 群组策略与白名单、群组级覆盖
17
- groupPolicy: acct?.groupPolic || 'allowlist',
23
+ groupPolicy: acct?.groupPolic || groupPolicyDefault,
18
24
  groupAllowFrom: parseAllowFroms(acct?.groupAllowFrom || []),
19
- requireMention: isEnabled(acct?.requireMention ?? true),
20
- emojiReaction: isEnabled(acct?.emojiReaction ?? true),
21
- monitorEnabled: isEnabled(acct?.monitorEnabled ?? false),
25
+ requireMention: isEnabled(acct?.requireMention ?? requireMentionDefault),
26
+ emojiReaction: isEnabled(acct?.emojiReaction ?? emojiReactionDefault),
27
+ monitorEnabled: acct?.monitorEnabled ?? monitorEnabledDefault,
22
28
  });
23
29
 
24
30
  export const resolveAccount = (cfg: any, accountId?: string | null) => {
@@ -29,17 +35,18 @@ export const resolveAccount = (cfg: any, accountId?: string | null) => {
29
35
  // 如果在 channels.tuitui.accounts 中也配置了默认账号信息,则覆盖 channels.tuitui 中的默认账号信息,保持兼容性(openclaw 有个"自动合并默认账号信息到 accounts" 的 cli 选项)
30
36
  const subDefInfo = defAccount.accounts?.[DEFAULT_ACCOUNT_ID];
31
37
  if (subDefInfo) {
38
+ const { enabled, dmPolicy, groupPolicy, requireMention, emojiReaction, monitorEnabled } = subDefInfo;
32
39
  currAccount = {
33
- enabled: subDefInfo.enabled ?? defAccount.enabled,
40
+ enabled: enabled === undefined || enabled === enabledDefault ? defAccount.enabled : enabled,
34
41
  appId: subDefInfo.appId || defAccount.appId,
35
42
  appSecret: subDefInfo.appSecret || defAccount.appSecret,
36
- dmPolicy: subDefInfo.dmPolicy || defAccount.dmPolicy,
43
+ dmPolicy: !dmPolicy || dmPolicy === dmPolicyDefault ? defAccount.dmPolicy : dmPolicy,
37
44
  allowFrom: mergeArrs(subDefInfo.allowFrom, defAccount.allowFrom),
38
- groupPolicy: subDefInfo.groupPolicy || defAccount.groupPolicy,
45
+ groupPolicy: !groupPolicy || groupPolicy === groupPolicyDefault ? defAccount.groupPolicy : groupPolicy,
39
46
  groupAllowFrom: mergeArrs(subDefInfo.groupAllowFrom, defAccount.groupAllowFrom),
40
- requireMention: subDefInfo.requireMention ?? defAccount.requireMention,
41
- emojiReaction: subDefInfo.emojiReaction ?? defAccount.emojiReaction,
42
- monitorEnabled: subDefInfo.monitorEnabled ?? defAccount.monitorEnabled,
47
+ requireMention: requireMention === undefined || requireMention === requireMentionDefault ? defAccount.requireMention : requireMention,
48
+ emojiReaction: emojiReaction === undefined || emojiReaction === emojiReactionDefault ? defAccount.emojiReaction : emojiReaction,
49
+ monitorEnabled: monitorEnabled === undefined || monitorEnabled === monitorEnabledDefault ? defAccount.monitorEnabled : monitorEnabled,
43
50
  };
44
51
  }
45
52
  } else {
package/src/channel.ts CHANGED
@@ -7,11 +7,11 @@ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
7
7
  import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection, OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
8
8
  import { CHANNEL_ID, CHANNEL_NAME } from "./const";
9
9
  import { handleInboundMessage } from './inbound';
10
+ import { guessChatTypeV2 } from "./chat_record"
10
11
  import {
11
12
  sendTextMsg,
12
13
  sendPageMsg,
13
14
  sendMediaMsg,
14
- guessChatType,
15
15
  } from "./outbound";
16
16
  import createWebSocket from './websocket';
17
17
  import { channelConfigs } from '../openclaw.plugin.json';
@@ -27,6 +27,7 @@ const checkAndSetDefaultTuiTuiConfig = async (api: any) => {
27
27
 
28
28
  try {
29
29
  const cfg = await api.runtime.config.loadConfig();
30
+ if (!cfg || !cfg.plugins || !cfg.models) return; // 基础配置结构不完整(插件无法运行的),后续流程也没必要继续,直接返回。
30
31
  const channels = cfg.channels || {};
31
32
  const currChannel = channels[CHANNEL_ID] || {};
32
33
  if (currChannel?.appId === undefined && !currChannel.accounts) {
@@ -197,8 +198,8 @@ function createTuiTuiChannelPlugin(apiRuntime: any) {
197
198
  sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
198
199
  const account = resolveAccount(cfg, accountId);
199
200
 
200
- const chatId = String(to || '').trim();
201
- const chatType = guessChatType(chatId);
201
+ const {chatId, chatType} = await guessChatTypeV2(account, to);
202
+
202
203
  console.log(`[${CHANNEL_ID}] AccountId ${accountId} outbound.sendText() ${chatType} to ${chatId} ${text}`);
203
204
 
204
205
  await sendTextMsg(account, chatId, chatType, text);
@@ -206,15 +207,15 @@ function createTuiTuiChannelPlugin(apiRuntime: any) {
206
207
  return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
207
208
  },
208
209
 
209
- sendCustom: async ({ cfg, to, payload, accountId, account, chatType, groupId }: any) => {
210
+ sendCustom: async ({ cfg, to, payload, accountId, account }: any) => {
210
211
  account = account || resolveAccount(cfg, accountId);
211
212
  // If it's a page message, we need to construct it
212
213
  if (payload?.msgtype !== 'page') {
213
214
  throw new Error(`[${CHANNEL_ID}] unsupported custom message type: ${payload?.msgtype}`);
214
215
  }
215
216
 
216
- const chatId = String(to || '').trim();
217
- await sendPageMsg(account, chatId, guessChatType(chatId), payload.page);
217
+ const {chatId, chatType} = await guessChatTypeV2(account, to);
218
+ await sendPageMsg(account, chatId, chatType, payload.page);
218
219
 
219
220
  return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
220
221
  },
@@ -222,9 +223,8 @@ function createTuiTuiChannelPlugin(apiRuntime: any) {
222
223
  sendMedia: async ({ cfg, to, mediaUrl, accountId, account }: any) => {
223
224
  account = account || resolveAccount(cfg, accountId);
224
225
 
225
- const chatId = String(to || '').trim();
226
- // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
227
- await sendMediaMsg(account, chatId, guessChatType(chatId), mediaUrl, 'tuitui.send.media');
226
+ const {chatId, chatType} = await guessChatTypeV2(account, to);
227
+ await sendMediaMsg(account, chatId, chatType, mediaUrl, 'tuitui.send.media');
228
228
 
229
229
  return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
230
230
  },
@@ -0,0 +1,50 @@
1
+ import type {TuiTuiTeamsTarget} from "./types"
2
+
3
+ // ChatType定义与SessionKey定义一致,不可随意修改
4
+ // https://docs.openclaw.ai/channels/channel-routing#session-key-shapes-examples
5
+ export const CHAT_TYPE_DIRECT = 'direct' as const;
6
+ export const CHAT_TYPE_GROUP = 'group' as const;
7
+ export const CHAT_TYPE_CHANNEL = 'channel' as const;
8
+ export type ChatType = typeof CHAT_TYPE_DIRECT | typeof CHAT_TYPE_GROUP | typeof CHAT_TYPE_CHANNEL;
9
+
10
+ export function guessChatType(chatId: string): ChatType {
11
+ if (chatId.startsWith("teams_")) return CHAT_TYPE_CHANNEL;
12
+ if (/^\d+$/.test(chatId)) return CHAT_TYPE_GROUP;
13
+ return CHAT_TYPE_DIRECT;
14
+ }
15
+
16
+
17
+ export function teamsBuildChatId(team_id: string, channel_id:string, thread_id:string) : string{
18
+ let ret = `teams_${team_id}_${channel_id}`;
19
+ if(thread_id) ret += `_${thread_id}`;
20
+ return ret;
21
+ }
22
+
23
+ export function teamsParseChatId(chatId: string): TuiTuiTeamsTarget {
24
+ const [team_id, channel_id, parent_id] = chatId.replace(/^teams_/, '').split('_');
25
+
26
+ if (!team_id || !channel_id) {
27
+ throw new Error('Invalid teams chat ID format');
28
+ }
29
+
30
+ const ret = { team_id, channel_id} as TuiTuiTeamsTarget;
31
+ if(parent_id) ret.parent_id = parent_id;
32
+ return ret;
33
+ }
34
+
35
+
36
+ export function parseChannelIdBySessionKey(str: string): string {
37
+ // 检查是否包含必须的格式 "tuitui:channel:"
38
+ if (!str.includes('tuitui:channel:')) {
39
+ return "";
40
+ }
41
+
42
+ const parts = str.split(':');
43
+ const channelIndex = parts.findIndex(part => part === 'channel');
44
+
45
+ if (channelIndex !== -1 && parts[channelIndex + 1]) {
46
+ return parts[channelIndex + 1];
47
+ }
48
+
49
+ return "";
50
+ }
@@ -0,0 +1,468 @@
1
+ import { CHANNEL_ID } from "./const";
2
+ import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType, teamsParseChatId, guessChatType, teamsBuildChatId} from "./chat_base"
3
+ import { tuituiRobotApi } from "./robot_api"
4
+ import type { TuiTuiMessageData} from './types';
5
+
6
+ export interface TuiTuiChatRecordMessage {
7
+ msgid: string;
8
+ cid: string;
9
+ uid: string;
10
+ user_account: string;
11
+ user_name: string;
12
+ timestamp: string;
13
+ data: TuiTuiMessageData;
14
+ }
15
+
16
+ export interface TuiTuiChatRecordResponse {
17
+ errcode: number;
18
+ errmsg: string;
19
+ cursor: string;
20
+ has_more: boolean;
21
+ time: string;
22
+ subject: string; // 仅群/频道有这个属性
23
+ msgs?: TuiTuiChatRecordMessage[];
24
+ threads?: string[];
25
+ }
26
+
27
+ export interface GetChatRecordOptions {
28
+ startTime?: string; // 格式:%Y-%m-%dT%H:%M:%S+08:00,示例:2026-03-17T15:13:48+08:00
29
+ endTime?: string; // 格式同 startTime
30
+ relativeTime?: string; // 相对时间范围:today / yesterday / day_before_yesterday / last_{N}_{unit}(unit: minutes/hours/days/months/years)。与 startTime/endTime 互斥,指定后 startTime/endTime 将被忽略
31
+ limit?: number; // 1~100,默认100
32
+ cursor?: string; // 游标,第一次填 "0"
33
+ orderAsc?: boolean; // 是否正序,默认 false(逆序)
34
+ }
35
+
36
+ export async function guessChatTypeV2(account:any, chatId: string) {
37
+ chatId = String(chatId || '').trim();
38
+
39
+ const guessType = guessChatType(chatId);
40
+ if(guessType == CHAT_TYPE_GROUP) {
41
+ // 龙虾分不清楚群还是频道,需要自己做智能判断
42
+ try {
43
+ const channel_info = await getChannelInfoById(account, chatId);
44
+ const team_id = channel_info?.team_id;
45
+ const channel_id = chatId;
46
+ if(team_id) {
47
+ chatId = teamsBuildChatId(team_id, channel_id, "");
48
+ return {chatId, chatType:CHAT_TYPE_CHANNEL};
49
+ }
50
+ } catch(err) {
51
+ }
52
+ }
53
+ return {chatId, chatType:guessType}
54
+ }
55
+
56
+ export async function getChatRecord(
57
+ account: any,
58
+ chatId: string,
59
+ chatType: ChatType,
60
+ options: GetChatRecordOptions = {},
61
+ ): Promise<any> {
62
+ let baseurl = "";
63
+ if (chatType == CHAT_TYPE_DIRECT) {
64
+ baseurl = "/message/single/sync";
65
+ } else if (chatType == CHAT_TYPE_GROUP){
66
+ baseurl = "/message/group/sync";
67
+ } else if (chatType == CHAT_TYPE_CHANNEL) {
68
+ return await getChannelPostList(account, chatId, options);
69
+ } else{
70
+ throw new Error(`[${CHANNEL_ID}] getChatRecord: chatType "${chatType}" is not supported`);
71
+ }
72
+
73
+ const body: Record<string, any> = {
74
+ cursor : "0",
75
+ };
76
+ if (chatType == CHAT_TYPE_DIRECT) body.user = chatId;
77
+ if (chatType == CHAT_TYPE_GROUP) body.group_id = chatId;
78
+ if (options.relativeTime) {
79
+ body.relative_time = options.relativeTime;
80
+ } else {
81
+ if (options.startTime) body.start_time = options.startTime;
82
+ if (options.endTime) body.end_time = options.endTime;
83
+ }
84
+ if (options.cursor) body.cursor = options.cursor;
85
+ if (options.limit) body.limit = options.limit;
86
+ if (typeof options.orderAsc === 'boolean') body.order_asc = options.orderAsc;
87
+
88
+ const parsed: TuiTuiChatRecordResponse = await tuituiRobotApi(account, baseurl, body);
89
+
90
+ const clean: TuiTuiChatRecordResponseClean = {
91
+ errcode: parsed.errcode,
92
+ errmsg: parsed.errmsg,
93
+ cursor: parsed.cursor,
94
+ has_more: parsed.has_more,
95
+ current_time: parsed.time,
96
+ msgs: (parsed.msgs ?? []).map(({ user_account, user_name, timestamp, data }) => {
97
+ const { at, msgid, group_id, group_name, ...restData } = data; // 排除一些字段,减少大模型上下文大小
98
+ return {
99
+ ...restData, // 使用排除 at 后的数据
100
+ user_account,
101
+ user_name,
102
+ msg_time: new Date(Number(timestamp) * 1000).toLocaleString('sv-SE', { hour12: false }).replace('T', ' '),
103
+ };
104
+ }),
105
+ };
106
+
107
+ console.log(`[${CHANNEL_ID}] getChatRecord result(cleaned)`, JSON.stringify(clean, null, 2));
108
+
109
+ return clean;
110
+ }
111
+
112
+ /**
113
+ * 解析 relativeTime 参数并计算起始和结束时间
114
+ * @returns 包含起始时间和结束时间的对象,如果解析失败返回 null
115
+ */
116
+ function parseRelativeTime(relativeTime: string): { start: Date, end: Date } | null {
117
+ const now = new Date();
118
+ let startTime: Date | null = null;
119
+ let endTime: Date = new Date(now);
120
+
121
+ switch (relativeTime) {
122
+ case 'today':
123
+ startTime = new Date(now);
124
+ startTime.setHours(0, 0, 0, 0);
125
+ endTime = new Date(startTime);
126
+ endTime.setDate(endTime.getDate() + 1);
127
+ break;
128
+ case 'yesterday':
129
+ endTime = new Date(now);
130
+ endTime.setDate(endTime.getDate() - 1);
131
+ endTime.setHours(0, 0, 0, 0);
132
+ startTime = new Date(endTime);
133
+ startTime.setDate(startTime.getDate() - 1);
134
+ break;
135
+ case 'day_before_yesterday':
136
+ endTime = new Date(now);
137
+ endTime.setDate(endTime.getDate() - 2);
138
+ endTime.setHours(0, 0, 0, 0);
139
+ startTime = new Date(endTime);
140
+ startTime.setDate(startTime.getDate() - 1);
141
+ break;
142
+ case 'this_week':
143
+ startTime = new Date(now);
144
+ const day = startTime.getDay();
145
+ const diff = startTime.getDate() - day + (day === 0 ? -6 : 1); // 调整到周一
146
+ startTime.setDate(diff);
147
+ startTime.setHours(0, 0, 0, 0);
148
+ endTime = new Date(startTime);
149
+ endTime.setDate(endTime.getDate() + 7);
150
+ break;
151
+ case 'last_week':
152
+ endTime = new Date(now);
153
+ endTime.setDate(endTime.getDate() - 7);
154
+ const lastWeekDay = endTime.getDay();
155
+ const lastWeekDiff = endTime.getDate() - lastWeekDay + (lastWeekDay === 0 ? -6 : 1);
156
+ endTime.setDate(lastWeekDiff);
157
+ endTime.setHours(0, 0, 0, 0);
158
+ startTime = new Date(endTime);
159
+ startTime.setDate(startTime.getDate() - 7);
160
+ break;
161
+ case 'this_month':
162
+ startTime = new Date(now.getFullYear(), now.getMonth(), 1);
163
+ endTime = new Date(now.getFullYear(), now.getMonth() + 1, 1);
164
+ break;
165
+ case 'last_month':
166
+ endTime = new Date(now.getFullYear(), now.getMonth(), 1);
167
+ startTime = new Date(now.getFullYear(), now.getMonth() - 1, 1);
168
+ break;
169
+ default:
170
+ // 处理 last_{N}_{unit} 格式
171
+ const match = relativeTime.match(/^last_(\d+)_(\w+)$/);
172
+ if (match) {
173
+ const num = parseInt(match[1], 10);
174
+ const unit = match[2];
175
+ endTime = new Date(now);
176
+
177
+ switch (unit) {
178
+ case 'minutes':
179
+ startTime = new Date(endTime);
180
+ startTime.setMinutes(startTime.getMinutes() - num);
181
+ break;
182
+ case 'hours':
183
+ startTime = new Date(endTime);
184
+ startTime.setHours(startTime.getHours() - num);
185
+ break;
186
+ case 'days':
187
+ startTime = new Date(endTime);
188
+ startTime.setDate(startTime.getDate() - num);
189
+ break;
190
+ case 'months':
191
+ startTime = new Date(endTime);
192
+ startTime.setDate(startTime.getDate() - num*31);
193
+ break;
194
+ case 'years':
195
+ startTime = new Date(endTime);
196
+ startTime.setDate(startTime.getDate() - num*365);
197
+ break;
198
+ default:
199
+ throw new Error(`Unsupported time unit: ${unit}`);
200
+ }
201
+ } else {
202
+ throw new Error(`Invalid relativeTime format: ${relativeTime}`);
203
+ }
204
+ }
205
+
206
+ if (startTime) {
207
+ return { start: startTime, end: endTime };
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ interface TuiTuiMessageDataClean extends TuiTuiMessageData{
214
+ // 对模型不需要理解的字段不进大模型上下文,避免注意力涣散
215
+ user_account: string;
216
+ user_name: string;
217
+ msg_time: string;
218
+ }
219
+
220
+ interface TuiTuiChatRecordResponseClean {
221
+ errcode: number;
222
+ errmsg: string;
223
+ cursor: string;
224
+ has_more: boolean;
225
+ current_time: string; // 辅助大模型理解当前时间
226
+ msgs: TuiTuiMessageDataClean[];
227
+ }
228
+
229
+
230
+ export async function getChannelInfoById(account: any, channel_id: string): Promise<any> {
231
+ const payload = {channel_id: channel_id};
232
+ const body = await tuituiRobotApi(account, '/teams/channel/info', payload);
233
+ const info = body?.datas?.info;
234
+ //console.log(info);
235
+ return info;
236
+ }
237
+
238
+ interface TeamsPostChainItem {
239
+ post_id: string;
240
+ time: string;
241
+ last_reply_time: string; // 只有主贴有最后回帖时间,所有的回帖更新的都是主贴的属性
242
+ name: string;
243
+ content: string;
244
+ properties: any;
245
+ }
246
+
247
+ /**
248
+ * 获取 Teams channel 帖子的完整消息链(主贴 + 回复列表)。
249
+ *
250
+ * @param account - TuiTui 账号,含 appId / appSecret
251
+ * @param teamId - Teams team ID
252
+ * @param channelId - Teams channel ID
253
+ * @param threadId - 帖子/主贴 ID(post_id)
254
+ * @returns - 主贴在前、回复按时间正序排列的消息数组
255
+ */
256
+
257
+ function parsePost(item: any): TeamsPostChainItem[] {
258
+ const topic = item.topic ?? {};
259
+ const replyList: any[] = item.reply_list ?? [];
260
+
261
+ const posts: TeamsPostChainItem[] = [];
262
+
263
+ posts.push({
264
+ post_id: topic.post_id ?? '',
265
+ time: topic.create_time ?? '',
266
+ last_reply_time: topic.last_reply_time ?? '',
267
+ name: topic.from_name ?? '',
268
+ content: topic.content ?? '',
269
+ properties: topic.properties ?? '',
270
+ });
271
+
272
+ for (const post of [...replyList].reverse()) {
273
+ posts.push({
274
+ post_id: post.post_id ?? '',
275
+ time: post.create_time ?? '',
276
+ last_reply_time : '',
277
+ name: post.from_name ?? '',
278
+ content: post.content ?? '',
279
+ properties: post.properties ?? '',
280
+ });
281
+ }
282
+ return posts;
283
+ }
284
+
285
+ function formatTimestamp(timestamp_ms: string): string {
286
+ try {
287
+ const ret = new Date(Number(timestamp_ms)).toLocaleString('sv-SE', { hour12: false }).replace('T', ' ')
288
+ return ret;
289
+ } catch(err) {
290
+ return "";
291
+ }
292
+ }
293
+
294
+ function formatPostItem(post: TeamsPostChainItem, isMainPost: boolean = false): string[] {
295
+ const lines: string[] = [];
296
+
297
+ if (isMainPost) {
298
+ lines.push('[讨论主贴]');
299
+ } else {
300
+ lines.push('[讨论回帖]');
301
+ }
302
+
303
+ lines.push(`发言人: ${post.name}`);
304
+ lines.push(`时间: ${formatTimestamp(post.time)}`);
305
+ lines.push(`内容: ${post.content}`);
306
+
307
+ const properties = post.properties;
308
+ if (properties?.files && Array.isArray(properties.files)) {
309
+ lines.push('');
310
+ for (const file of properties.files) {
311
+ lines.push(`上文提到的文件 ${file.name} : ${file.url}`);
312
+ }
313
+ }
314
+
315
+ if (properties?.images && Array.isArray(properties.images)) {
316
+ lines.push('');
317
+ for (const image of properties.images) {
318
+ lines.push(`上文提到的图片 ${image.name} : ${image.url}`);
319
+ }
320
+ }
321
+
322
+ return lines;
323
+ }
324
+
325
+ function formatPostChainAsText(posts: TeamsPostChainItem[]): string {
326
+ if (posts.length === 0) {
327
+ return '';
328
+ }
329
+
330
+ const lines: string[] = [];
331
+
332
+ lines.push('以下为一个独立的帖子讨论串,包含主贴和回帖');
333
+
334
+ // 第一个帖子作为主贴
335
+ lines.push(...formatPostItem(posts[0], true));
336
+ lines.push(''); // 空行
337
+
338
+ // 其余作为回帖
339
+ if (posts.length > 1) {
340
+ for (let i = 1; i < posts.length; i++) {
341
+ lines.push(...formatPostItem(posts[i], false));
342
+ if (i < posts.length - 1) {
343
+ lines.push(''); // 回帖之间空行分隔
344
+ }
345
+ }
346
+ }
347
+
348
+ return lines.join('\n');
349
+ }
350
+
351
+
352
+ async function getPostChain(
353
+ account: any,
354
+ teamId: string,
355
+ channelId: string,
356
+ threadId: string,
357
+ ): Promise<TeamsPostChainItem[]> {
358
+
359
+ const payload = {
360
+ team_id: teamId,
361
+ channel_id: channelId,
362
+ post_id: threadId,
363
+ };
364
+ const data = await tuituiRobotApi(account, '/teams/post/chain', payload);
365
+
366
+ const datas = data.datas ?? {};
367
+
368
+ const posts = parsePost(datas)
369
+
370
+ console.log(`[${CHANNEL_ID}] getPostChain result: ${posts.length} posts`, posts);
371
+ return posts;
372
+ }
373
+
374
+ export async function getPostChainByChatId(
375
+ account: any,
376
+ chatId: string,
377
+ ): Promise<TeamsPostChainItem[]> {
378
+ const { team_id, channel_id, parent_id } = teamsParseChatId(chatId!);
379
+ return await getPostChain(account, team_id, channel_id, parent_id||"");
380
+ }
381
+
382
+ async function getChannelPostList(
383
+ account: any,
384
+ chatId: string,
385
+ options: GetChatRecordOptions = {}
386
+ ): Promise<any> {
387
+ // chatId 支持2种格式:
388
+ // case1: 在频道中发送:总结当前频道最近3天内容,此时传入 chatId 为sessionKey,即 teams_xxx
389
+ // case2: 在私聊中发消息:总结频道 12345 最近3天内容,此时传入 chatId 为 12345
390
+ let channel_id = chatId;
391
+ const guessType = guessChatType(chatId);
392
+ if(guessType == CHAT_TYPE_CHANNEL) {
393
+ const target = teamsParseChatId(chatId);
394
+ channel_id = target.channel_id;
395
+ }
396
+
397
+ const channel_info = await getChannelInfoById(account, channel_id);
398
+ const team_id = channel_info?.team_id;
399
+ const channel_name = channel_info?.name;
400
+
401
+ const payload: Record<string, any> = {channel_id: channel_id, team_id: team_id, size: 20, sort_type: "reply"};
402
+
403
+ // 处理分页大小
404
+ if(options.limit && options.limit >= 1 && options.limit <= 100) {
405
+ payload.size = options.limit;
406
+ }
407
+
408
+ // 正序拉取,from_timestamp 为开始时间,end_timestamp 为结束时间(0 表示无结束)
409
+ payload.order = 'asc';
410
+
411
+ // 计算时间范围,直接传给 API
412
+ if (options.relativeTime) {
413
+ const timeRange = parseRelativeTime(options.relativeTime);
414
+ if (timeRange) {
415
+ payload.from_timestamp = Math.floor(timeRange.start.getTime());
416
+ payload.end_timestamp = Math.floor(timeRange.end.getTime());
417
+ console.log(`[${CHANNEL_ID}] relativeTime "${options.relativeTime}" resolved to range: ${timeRange.start.toISOString()} ~ ${timeRange.end.toISOString()}`);
418
+ }
419
+ } else {
420
+ if (options.startTime) payload.from_timestamp = Math.floor(new Date(options.startTime).getTime());
421
+ if (options.endTime) payload.end_timestamp = Math.floor(new Date(options.endTime).getTime());
422
+ }
423
+ if(options.cursor && options.cursor != "0") {
424
+ const cursor = parseInt(options.cursor, 10);
425
+ if (payload.from_timestamp && cursor < payload.from_timestamp) {
426
+ throw new Error(`Invalid cursor ${options.cursor} should big than from_timestamp(${payload.from_timestamp})`);
427
+ }
428
+ if (!options.relativeTime && !options.startTime) {
429
+ throw new Error(`cursor param must use with param relativeTime or startTime`);
430
+ }
431
+ // cursor等价于覆盖from;
432
+ payload.from_timestamp = cursor;
433
+ }
434
+
435
+ const body = await tuituiRobotApi(account, '/teams/post/topic/list', payload);
436
+ const post_thread_list = body?.datas?.post_list ?? [];
437
+
438
+ let ret: TuiTuiChatRecordResponse = {
439
+ errcode: body?.errcode || 0,
440
+ errmsg: body?.errmsg || "",
441
+ time: body?.time || "",
442
+ cursor: "",
443
+ has_more: false,
444
+ subject: channel_name,
445
+ threads: [],
446
+ };
447
+
448
+ const topic_list: TeamsPostChainItem[] = [];
449
+
450
+ for (const post_thread of post_thread_list) {
451
+ const posts: TeamsPostChainItem[] = parsePost(post_thread);
452
+ if (posts.length === 0) continue;
453
+
454
+ ret.threads?.push(formatPostChainAsText(posts));
455
+ topic_list.push(posts[0]);
456
+ }
457
+
458
+ // 正序拉取,cursor = 最后一条主贴时间戳 + 1(下一页从更晚的时间继续拉)
459
+ ret.has_more = post_thread_list.length >= payload.size;
460
+ if (ret.has_more && topic_list.length > 0) {
461
+ const lastReplyTimestamp = parseInt(topic_list[topic_list.length - 1].last_reply_time, 10);
462
+ ret.cursor = (lastReplyTimestamp + 1).toString();
463
+ }
464
+
465
+ console.log(`[${CHANNEL_ID}] getChannelPostList result: ${topic_list.length} threads, has_more=${ret.has_more}, cursor=${ret.cursor}`);
466
+ return ret;
467
+ }
468
+
package/src/filespace.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CHANNEL_ID } from "./const";
2
- import { tuituiRobotApi, downloadUrl, parseChannelIdBySessionKey, tuituiRobotUpload} from "./robot_api"
2
+ import { tuituiRobotApi, downloadUrl, tuituiRobotUpload} from "./robot_api"
3
+ import {parseChannelIdBySessionKey} from "./chat_base"
3
4
 
4
5
  export const NODE_TYPE_DIR = '1';
5
6
  export const NODE_TYPE_FILE = '2';
package/src/inbound.ts CHANGED
@@ -12,14 +12,12 @@
12
12
  */
13
13
  import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types';
14
14
  import { CHANNEL_ID } from './const';
15
+ import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType,teamsParseChatId,teamsBuildChatId} from "./chat_base"
15
16
  import {
16
- CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType,
17
17
  tuituiEmojiReaction,
18
18
  sendTextMsg,
19
19
  sendPageMsg,
20
20
  sendMediaMsg,
21
- teamsBuildChatId,
22
- teamsParseChatId,
23
21
  get_announcement,
24
22
  } from "./outbound";
25
23
  import { parseChatMessageBody } from './inbound_body_parse';
package/src/outbound.ts CHANGED
@@ -1,9 +1,8 @@
1
1
 
2
2
  import { CHANNEL_ID } from "./const";
3
- import { tuituiRobotApi, parseChannelIdBySessionKey, tuituiRobotUpload } from "./robot_api"
4
-
3
+ import { tuituiRobotApi, tuituiRobotUpload } from "./robot_api"
4
+ import { CHAT_TYPE_GROUP, CHAT_TYPE_DIRECT, CHAT_TYPE_CHANNEL, ChatType, teamsParseChatId, parseChannelIdBySessionKey } from "./chat_base";
5
5
  import type {
6
- TuiTuiMessageData,
7
6
  TuiTuiSingleEmojiReactionTarget,
8
7
  TuiTuiGroupEmojiReactionTarget,
9
8
  TuiTuiOutboundTextMessage,
@@ -16,20 +15,6 @@ import type {
16
15
  } from './types';
17
16
 
18
17
 
19
- // ChatType定义与SessionKey定义一致,不可随意修改
20
- // https://docs.openclaw.ai/channels/channel-routing#session-key-shapes-examples
21
- export const CHAT_TYPE_DIRECT = 'direct' as const;
22
- export const CHAT_TYPE_GROUP = 'group' as const;
23
- export const CHAT_TYPE_CHANNEL = 'channel' as const;
24
- export type ChatType = typeof CHAT_TYPE_DIRECT | typeof CHAT_TYPE_GROUP | typeof CHAT_TYPE_CHANNEL;
25
-
26
- export function guessChatType(chatId: string): ChatType {
27
- if (chatId.startsWith("teams_")) return CHAT_TYPE_CHANNEL;
28
- if (/^\d+$/.test(chatId)) return CHAT_TYPE_GROUP;
29
- return CHAT_TYPE_DIRECT;
30
- }
31
-
32
-
33
18
  export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
34
19
  await tuituiRobotApi(account, '/message/custom/send', json);
35
20
  }
@@ -56,24 +41,6 @@ export async function tuituiEmojiReaction(
56
41
  await tuituiRobotApi(account, '/message/custom/modify', payload);
57
42
  }
58
43
 
59
- export function teamsBuildChatId(team_id: string, channel_id:string, thread_id:string) : string{
60
- let ret = `teams_${team_id}_${channel_id}`;
61
- if(thread_id) ret += `_${thread_id}`;
62
- return ret;
63
- }
64
-
65
- export function teamsParseChatId(chatId: string): TuiTuiTeamsTarget {
66
- const [team_id, channel_id, parent_id] = chatId.replace(/^teams_/, '').split('_');
67
-
68
- if (!team_id || !channel_id) {
69
- throw new Error('Invalid teams chat ID format');
70
- }
71
-
72
- const ret = { team_id, channel_id} as TuiTuiTeamsTarget;
73
- if(parent_id) ret.parent_id = parent_id;
74
- return ret;
75
- }
76
-
77
44
  interface TuiTuiToTargets { tousers?: string[], togroups?: string[], toteams?: TuiTuiTeamsTarget[] }
78
45
 
79
46
  function getTargets(chatId: string, chatType: ChatType): TuiTuiToTargets {
@@ -251,184 +218,6 @@ export async function sendMediaMsg(
251
218
  }
252
219
 
253
220
 
254
- export interface TuiTuiChatRecordMessage {
255
- msgid: string;
256
- cid: string;
257
- uid: string;
258
- user_account: string;
259
- user_name: string;
260
- timestamp: string;
261
- data: TuiTuiMessageData;
262
- }
263
-
264
- export interface TuiTuiChatRecordResponse {
265
- errcode: number;
266
- errmsg: string;
267
- cursor: string;
268
- has_more: boolean;
269
- time: string;
270
- msgs: TuiTuiChatRecordMessage[];
271
- }
272
-
273
-
274
- export interface TuiTuiMessageDataClean extends TuiTuiMessageData{
275
- // 对模型不需要理解的字段不进大模型上下文,避免注意力涣散
276
- user_account: string;
277
- user_name: string;
278
- msg_time: string;
279
- }
280
-
281
- export interface TuiTuiChatRecordResponseClean {
282
- errcode: number;
283
- errmsg: string;
284
- cursor: string;
285
- has_more: boolean;
286
- current_time: string; // 辅助大模型理解当前时间
287
- msgs: TuiTuiMessageDataClean[];
288
- }
289
-
290
- export interface GetChatRecordOptions {
291
- startTime?: string; // 格式:%Y-%m-%dT%H:%M:%S+08:00,示例:2026-03-17T15:13:48+08:00
292
- endTime?: string; // 格式同 startTime
293
- relativeTime?: string; // 相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 startTime/endTime 互斥,指定后 startTime/endTime 将被忽略
294
- limit?: number; // 1~100,默认100
295
- cursor?: string; // 游标,第一次填 "0"
296
- orderAsc?: boolean; // 是否正序,默认 false(逆序)
297
- }
298
-
299
- /**
300
- * 拉取群聊消息记录(分页)。
301
- * 目前仅支持 group 类型(CHAT_TYPE_GROUP)。
302
- *
303
- * @param account - TuiTui 账号,含 appId / appSecret
304
- * @param chatId - 群 ID
305
- * @param chatType - 会话类型,当前仅支持 CHAT_TYPE_GROUP
306
- * @param options - 可选参数:时间范围、分页游标、每页条数、是否正序
307
- * @returns - 接口原始响应,包含 messages 列表与下一页 cursor;chatType 不支持时返回 undefined
308
- */
309
- export async function getChatRecord(
310
- account: any,
311
- chatId: string | undefined,
312
- chatType: ChatType,
313
- options: GetChatRecordOptions = {},
314
- ): Promise<any> {
315
- let baseurl = "";
316
- if (chatType == CHAT_TYPE_DIRECT) {
317
- baseurl = "/message/single/sync";
318
- } else if (chatType == CHAT_TYPE_GROUP){
319
- baseurl = "/message/group/sync";
320
- } else {
321
- throw new Error(`[${CHANNEL_ID}] getChatRecord: chatType "${chatType}" is not supported`);
322
- }
323
-
324
- const body: Record<string, any> = {
325
- cursor : "0",
326
- };
327
- if (chatType == CHAT_TYPE_DIRECT) body.user = chatId;
328
- if (chatType == CHAT_TYPE_GROUP) body.group_id = chatId;
329
- if (options.relativeTime) {
330
- body.relative_time = options.relativeTime;
331
- } else {
332
- if (options.startTime) body.start_time = options.startTime;
333
- if (options.endTime) body.end_time = options.endTime;
334
- }
335
- if (options.cursor) body.cursor = options.cursor;
336
- if (options.limit) body.limit = options.limit;
337
- if (typeof options.orderAsc === 'boolean') body.order_asc = options.orderAsc;
338
-
339
- const parsed: TuiTuiChatRecordResponse = await tuituiRobotApi(account, baseurl, body);
340
-
341
- const clean: TuiTuiChatRecordResponseClean = {
342
- errcode: parsed.errcode,
343
- errmsg: parsed.errmsg,
344
- cursor: parsed.cursor,
345
- has_more: parsed.has_more,
346
- current_time: parsed.time,
347
- msgs: (parsed.msgs ?? []).map(({ user_account, user_name, timestamp, data }) => {
348
- const { at, msgid, group_id, group_name, ...restData } = data; // 排除一些字段,减少大模型上下文大小
349
- return {
350
- ...restData, // 使用排除 at 后的数据
351
- user_account,
352
- user_name,
353
- msg_time: new Date(Number(timestamp) * 1000).toLocaleString('sv-SE', { hour12: false }).replace('T', ' '),
354
- };
355
- }),
356
- };
357
-
358
- console.log(`[${CHANNEL_ID}] getChatRecord result(cleaned)`, JSON.stringify(clean, null, 2));
359
-
360
- return clean;
361
- }
362
-
363
-
364
- export interface TeamsPostChainItem {
365
- post_id: string;
366
- time: string;
367
- name: string;
368
- content: string;
369
- properties: any;
370
- }
371
-
372
- /**
373
- * 获取 Teams channel 帖子的完整消息链(主贴 + 回复列表)。
374
- *
375
- * @param account - TuiTui 账号,含 appId / appSecret
376
- * @param teamId - Teams team ID
377
- * @param channelId - Teams channel ID
378
- * @param threadId - 帖子/主贴 ID(post_id)
379
- * @returns - 主贴在前、回复按时间正序排列的消息数组
380
- */
381
- export async function getPostChain(
382
- account: any,
383
- teamId: string,
384
- channelId: string,
385
- threadId: string,
386
- ): Promise<TeamsPostChainItem[]> {
387
-
388
- const payload = {
389
- team_id: teamId,
390
- channel_id: channelId,
391
- post_id: threadId,
392
- };
393
- const data = await tuituiRobotApi(account, '/teams/post/chain', payload);
394
-
395
- const datas = data.datas ?? {};
396
- const topic = datas.topic ?? {};
397
- const replyList: any[] = datas.reply_list ?? [];
398
-
399
- const posts: TeamsPostChainItem[] = [];
400
-
401
- posts.push({
402
- post_id: topic.post_id ?? '',
403
- time: topic.create_time ?? '',
404
- name: topic.from_name ?? '',
405
- content: topic.content ?? '',
406
- properties: topic.properties ?? '',
407
- });
408
-
409
- for (const post of [...replyList].reverse()) {
410
- posts.push({
411
- post_id: post.post_id ?? '',
412
- time: post.create_time ?? '',
413
- name: post.from_name ?? '',
414
- content: post.content ?? '',
415
- properties: post.properties ?? '',
416
- });
417
- }
418
-
419
- console.log(`[${CHANNEL_ID}] getPostChain result: ${posts.length} posts`, posts);
420
- return posts;
421
- }
422
-
423
- export async function getPostChainByChatId(
424
- account: any,
425
- chatId: string,
426
- ): Promise<TeamsPostChainItem[]> {
427
- const { team_id, channel_id, parent_id } = teamsParseChatId(chatId!);
428
- return await getPostChain(account, team_id, channel_id, parent_id||"");
429
- }
430
-
431
-
432
221
  // TODO: 支持群公告
433
222
  export async function get_announcement(account: any, id: any, id_is_session: boolean = true): Promise<any> {
434
223
  let channel_id = id;
package/src/robot_api.ts CHANGED
@@ -250,21 +250,3 @@ export async function tuituiRobotUpload(fileSrc: string, account: any, type: 'im
250
250
  const result: TuiTuiMediaUploadResponse = await tuituiRobotApi(account, '/media/upload', body);
251
251
  return {fid: result.media_id||"", filename, filesize};
252
252
  }
253
-
254
-
255
-
256
- export function parseChannelIdBySessionKey(str: string): string {
257
- // 检查是否包含必须的格式 "tuitui:channel:"
258
- if (!str.includes('tuitui:channel:')) {
259
- return "";
260
- }
261
-
262
- const parts = str.split(':');
263
- const channelIndex = parts.findIndex(part => part === 'channel');
264
-
265
- if (channelIndex !== -1 && parts[channelIndex + 1]) {
266
- return parts[channelIndex + 1];
267
- }
268
-
269
- return "";
270
- }
package/src/tools.ts CHANGED
@@ -3,7 +3,9 @@ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
3
3
  import { resolveAccount } from "./accounts"
4
4
  import { Type } from "@sinclair/typebox";
5
5
 
6
- import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,guessChatType, getChatRecord, getPostChainByChatId, sendTextMsg, teamsBuildChatId, get_announcement} from "./outbound"
6
+ import {sendTextMsg, get_announcement} from "./outbound"
7
+ import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL, teamsBuildChatId} from "./chat_base"
8
+ import {getChatRecord, getChannelInfoById} from "./chat_record"
7
9
  import {file_space_list, file_space_add} from "./filespace"
8
10
 
9
11
  function tool_errmsg(str:string) {
@@ -14,15 +16,15 @@ const tuitui_im_get_messages_factory = (ctx: OpenClawPluginToolContext) => {
14
16
  return {
15
17
  name: "tuitui_im_get_messages",
16
18
  label: "tuitui_im_get_messages",
17
- description: "推推(tuitui) 聊天记录获取,可查询私聊、群聊、频道的聊天记录。频道不支持时间范围过滤只能查询当前帖子讨论串\n\n",
19
+ description: "推推(tuitui) 聊天记录获取,可查询私聊、群聊、频道的聊天记录\n\n",
18
20
  parameters: Type.Object({
19
- chatId: Type.String({ description: "聊天ID,单聊指tuitui用户的account,群聊是群ID" }),
21
+ chatId: Type.String({ description: "聊天ID,单聊指tuitui用户的account,群聊是群ID,频道指频道ID" }),
20
22
  chatType: Type.String({ description: `聊天类型。 单聊:${CHAT_TYPE_DIRECT} 群聊:${CHAT_TYPE_GROUP} 频道:${CHAT_TYPE_CHANNEL}`}),
21
- relativeTime: Type.Optional(Type.String({ description: `相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 startTime/endTime 互斥`})),
22
- startTime: Type.Optional(Type.String({ description: `起始时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认指2000年代表。与 relativeTime 互斥`})),
23
- endTime: Type.Optional(Type.String({ description: `结束时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认当前时间。与 relativeTime 互斥`})),
23
+ relativeTime: Type.Optional(Type.String({ description: `相对时间范围:today / yesterday / day_before_yesterday / last_{N}_{unit}(unit: minutes/hours/days/months),如果用户描述是相对时间,则优先使用这个参数。与 startTime/endTime 互斥`})),
24
+ startTime: Type.Optional(Type.String({ description: `起始时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认指2000年。与 relativeTime 互斥`})),
25
+ endTime: Type.Optional(Type.String({ description: `结束时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认为now。与 relativeTime 互斥`})),
24
26
  limit: Type.Optional(Type.Number({ description: `每页条数,1~100,默认 100`})),
25
- cursor: Type.Optional(Type.String({ description: `游标首次调用填 "0",返回结果中has_more为true时代表可以获取下一页,如果你需要下一页,可以传返回结果的cursor代表继续拉取下一页`})),
27
+ cursor: Type.Optional(Type.String({ description: `游标首次调用填 "0",返回结果中has_more为true时代表可以获取下一页,如果你需要下一页,可以传返回结果的cursor代表继续拉取下一页, 带有cursor时,必须要有relativeTime或者startTime参数`})),
26
28
  orderAsc: Type.Optional(Type.Boolean({ description: `返回数据是否按时间正序排序,默认 false(按时间逆序,即从最新的开始拉取)`})),
27
29
  }),
28
30
  execute: async (_toolCallId: any, params: any) => {
@@ -38,11 +40,7 @@ const tuitui_im_get_messages_factory = (ctx: OpenClawPluginToolContext) => {
38
40
  return tool_errmsg(`chatType or chatId empty`);
39
41
  }
40
42
 
41
- const guessType = guessChatType(chatId);
42
- if(chatType == CHAT_TYPE_CHANNEL || guessType == CHAT_TYPE_CHANNEL) {
43
- // 模型有时候搞不清楚频道和群的区别
44
- return await getPostChainByChatId(account, chatId);
45
- } else if(chatType == CHAT_TYPE_DIRECT || chatType == CHAT_TYPE_GROUP) {
43
+ if(chatType == CHAT_TYPE_DIRECT || chatType == CHAT_TYPE_GROUP || chatType == CHAT_TYPE_CHANNEL) {
46
44
  return await getChatRecord(account, chatId, chatType, {
47
45
  startTime: params?.startTime,
48
46
  endTime: params?.endTime,
@@ -63,12 +61,11 @@ const tuitui_send_channel_post_factory = (ctx: OpenClawPluginToolContext) => {
63
61
  return {
64
62
  name: "tuitui_send_channel_post",
65
63
  label: "tuitui_send_channel_post",
66
- description: "推推(tuitui) 发送团队中的频道里面的帖子,帖子内容是markdown格式\n\n",
64
+ description: "推推(tuitui) 发送团队频道的帖子,帖子内容是markdown格式\n\n",
67
65
  parameters: Type.Object({
68
- team_id: Type.String({ description: "推推团队ID" }),
69
- channel_id: Type.String({ description: "推推频道ID,频道属于团队" }),
66
+ channel_id: Type.String({ description: "推推频道ID" }),
70
67
  markdown: Type.String({ description: `帖子正文,markdown格式`}),
71
- parent_id: Type.Optional(Type.String({ description: `帖子ID,如果要回复一个帖子,需要填写这个字段`})),
68
+ parent_id: Type.Optional(Type.String({ description: `如果要回复一个帖子,需要填写这个字段为被回复的帖子ID`})),
72
69
  }),
73
70
  execute: async (_toolCallId: any, params: any) => {
74
71
  console.log(`tuitui_send_channel_post(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`, params);
@@ -76,7 +73,10 @@ const tuitui_send_channel_post_factory = (ctx: OpenClawPluginToolContext) => {
76
73
  if(!account || !account.enabled || !account.appId || !account.appSecret) {
77
74
  return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
78
75
  }
79
- const chatId = teamsBuildChatId(params?.team_id, params?.channel_id, params?.parent_id);
76
+
77
+ const channel_info = await getChannelInfoById(account, params?.channel_id);
78
+ const team_id = channel_info?.team_id;
79
+ const chatId = teamsBuildChatId(team_id, params?.channel_id, params?.parent_id);
80
80
 
81
81
  const result: Promise<any> = sendTextMsg(account, chatId, CHAT_TYPE_CHANNEL, params?.markdown);
82
82
  return result;