@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 +1 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +1 -1
- package/skills/tuitui-im-read/SKILL.md +26 -34
- package/src/accounts.ts +17 -10
- package/src/channel.ts +9 -9
- package/src/chat_base.ts +50 -0
- package/src/chat_record.ts +468 -0
- package/src/filespace.ts +2 -1
- package/src/inbound.ts +1 -3
- package/src/outbound.ts +2 -213
- package/src/robot_api.ts +0 -18
- package/src/tools.ts +17 -17
package/README.md
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"advanced": true
|
|
132
132
|
},
|
|
133
133
|
"monitorEnabled": {
|
|
134
|
-
"help": "
|
|
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": "
|
|
188
|
+
"help": "是否开启agent事件信息上报(默认 false)。修改后必须重启网关才能生效。",
|
|
189
189
|
"order": 311,
|
|
190
190
|
"advanced": true
|
|
191
191
|
}
|
package/package.json
CHANGED
|
@@ -17,19 +17,19 @@ description: |
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## 工具: tuitui_im_get_messages
|
|
21
21
|
|
|
22
22
|
### 参数说明
|
|
23
23
|
|
|
24
24
|
| 参数 | 类型 | 必填 | 说明 |
|
|
25
25
|
|------|------|------|------|
|
|
26
|
-
| `chatId` | string | ✅ | 聊天 ID。单聊填对方的 tuitui account,群聊填群
|
|
27
|
-
| `chatType` | string | ✅ | 聊天类型:单聊填 `direct`,群聊填 `group` |
|
|
28
|
-
| `relativeTime` | string | ❌ | 相对时间范围:today / yesterday / day_before_yesterday /
|
|
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
|
|
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
|
-
|
|
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
|
-
- 用户说"今天"、"
|
|
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": "
|
|
115
|
-
"chatType": "
|
|
116
|
-
"
|
|
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
|
-
|
|
|
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 ||
|
|
23
|
+
groupPolicy: acct?.groupPolic || groupPolicyDefault,
|
|
18
24
|
groupAllowFrom: parseAllowFroms(acct?.groupAllowFrom || []),
|
|
19
|
-
requireMention: isEnabled(acct?.requireMention ??
|
|
20
|
-
emojiReaction: isEnabled(acct?.emojiReaction ??
|
|
21
|
-
monitorEnabled:
|
|
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:
|
|
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:
|
|
43
|
+
dmPolicy: !dmPolicy || dmPolicy === dmPolicyDefault ? defAccount.dmPolicy : dmPolicy,
|
|
37
44
|
allowFrom: mergeArrs(subDefInfo.allowFrom, defAccount.allowFrom),
|
|
38
|
-
groupPolicy:
|
|
45
|
+
groupPolicy: !groupPolicy || groupPolicy === groupPolicyDefault ? defAccount.groupPolicy : groupPolicy,
|
|
39
46
|
groupAllowFrom: mergeArrs(subDefInfo.groupAllowFrom, defAccount.groupAllowFrom),
|
|
40
|
-
requireMention:
|
|
41
|
-
emojiReaction:
|
|
42
|
-
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 =
|
|
201
|
-
|
|
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
|
|
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 =
|
|
217
|
-
await sendPageMsg(account, chatId,
|
|
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 =
|
|
226
|
-
|
|
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
|
},
|
package/src/chat_base.ts
ADDED
|
@@ -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,
|
|
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,
|
|
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 {
|
|
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)
|
|
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 /
|
|
22
|
-
startTime: Type.Optional(Type.String({ description: `起始时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认指2000
|
|
23
|
-
endTime: Type.Optional(Type.String({ description: `结束时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00
|
|
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
|
-
|
|
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)
|
|
64
|
+
description: "推推(tuitui) 发送团队频道的帖子,帖子内容是markdown格式\n\n",
|
|
67
65
|
parameters: Type.Object({
|
|
68
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|