@chbo297/infoflow 2026.5.8 → 2026.5.9-beta.2
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 +131 -34
- package/dist/src/actions.js +159 -12
- package/dist/src/agent-tools.js +102 -0
- package/dist/src/bot.js +158 -15
- package/dist/src/channel.js +13 -1
- package/dist/src/inbound-context.js +61 -0
- package/dist/src/recall-intent.js +26 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -39,35 +39,49 @@ BAIDU_NPM_REGISTRY=http://registry.npm.baidu-int.com bash scripts/deploy.sh
|
|
|
39
39
|
|
|
40
40
|
### 首次安装(推荐命令)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
下面的安装命令块由 `npm run sync-readme-install-version` 自动维护,版本号始终与 npm 上当前的 `latest` / `beta` dist-tag 保持一致,请直接复制使用。
|
|
43
43
|
|
|
44
|
-
方式 A:通过独立 tools 包安装并部署(推荐,支持 `update` 子命令)
|
|
44
|
+
#### 方式 A:通过独立 tools 包安装并部署(推荐,支持 `update` 子命令)
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
正式版(`latest` dist-tag):
|
|
47
|
+
|
|
48
|
+
<!-- sync:infoflow-plugin-version:latest -->
|
|
47
49
|
```bash
|
|
48
|
-
|
|
49
|
-
npx -y @chbo297/infoflow-openclaw-tools update
|
|
50
|
+
npm cache clean --force
|
|
51
|
+
npx -y --prefer-online @chbo297/infoflow-openclaw-tools update \
|
|
52
|
+
--version 2026.5.8 --registry https://registry.npmjs.org
|
|
50
53
|
```
|
|
51
|
-
<!-- /sync:infoflow-plugin-version -->
|
|
54
|
+
<!-- /sync:infoflow-plugin-version:latest -->
|
|
55
|
+
|
|
56
|
+
Beta 版(`beta` dist-tag,按需):
|
|
52
57
|
|
|
58
|
+
<!-- sync:infoflow-plugin-version:beta -->
|
|
53
59
|
```bash
|
|
54
|
-
|
|
55
|
-
npx -y @chbo297/infoflow-openclaw-tools@beta update
|
|
60
|
+
npm cache clean --force
|
|
61
|
+
npx -y --prefer-online @chbo297/infoflow-openclaw-tools@beta update \
|
|
62
|
+
--version 2026.5.9-beta.1 --registry https://registry.npmjs.org
|
|
56
63
|
```
|
|
64
|
+
<!-- /sync:infoflow-plugin-version:beta -->
|
|
57
65
|
|
|
58
|
-
|
|
66
|
+
> 加上 `npm cache clean --force` 和 `--prefer-online`,可避免本机 npm metadata 缓存尚未刷新而看不到刚发布版本(典型表现为 `ETARGET: No matching version found`)。
|
|
59
67
|
|
|
60
|
-
|
|
68
|
+
#### 方式 B:通过 OpenClaw 插件命令安装
|
|
69
|
+
|
|
70
|
+
正式版:
|
|
71
|
+
|
|
72
|
+
<!-- sync:infoflow-plugin-version:latest -->
|
|
61
73
|
```bash
|
|
62
|
-
# 正式版
|
|
63
74
|
openclaw plugins install @chbo297/infoflow@2026.5.8
|
|
64
75
|
```
|
|
65
|
-
<!-- /sync:infoflow-plugin-version -->
|
|
76
|
+
<!-- /sync:infoflow-plugin-version:latest -->
|
|
77
|
+
|
|
78
|
+
Beta 版:
|
|
66
79
|
|
|
80
|
+
<!-- sync:infoflow-plugin-version:beta -->
|
|
67
81
|
```bash
|
|
68
|
-
|
|
69
|
-
openclaw plugins install @chbo297/infoflow@2026.5.8-beta.1
|
|
82
|
+
openclaw plugins install @chbo297/infoflow@2026.5.9-beta.1
|
|
70
83
|
```
|
|
84
|
+
<!-- /sync:infoflow-plugin-version:beta -->
|
|
71
85
|
|
|
72
86
|
安装后建议检查插件状态:
|
|
73
87
|
|
|
@@ -76,17 +90,32 @@ openclaw plugins list
|
|
|
76
90
|
openclaw plugins inspect infoflow
|
|
77
91
|
```
|
|
78
92
|
|
|
79
|
-
|
|
93
|
+
#### 如遇 `ETARGET: No matching version found`
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
刚发布的版本可能在本机 npm metadata 缓存里看不到,按下面顺序排查:
|
|
82
96
|
|
|
83
|
-
<!-- sync:infoflow-plugin-version -->
|
|
84
97
|
```bash
|
|
85
|
-
|
|
98
|
+
# 1) 强制清缓存 + 在线拉取最新元数据
|
|
99
|
+
npm cache clean --force
|
|
100
|
+
npx -y --prefer-online @chbo297/infoflow-openclaw-tools@beta update \
|
|
101
|
+
--version <要装的版本> --registry https://registry.npmjs.org
|
|
102
|
+
|
|
103
|
+
# 2) 直接查 registry,确认版本确实可见
|
|
104
|
+
npm view @chbo297/infoflow versions --registry https://registry.npmjs.org
|
|
105
|
+
|
|
106
|
+
# 3) 确认默认 registry 未被改到镜像源(有些内网会重写到 cnpm/baidu 镜像,那里同步可能滞后)
|
|
107
|
+
npm config get registry # 期望: https://registry.npmjs.org/
|
|
108
|
+
# 临时强制覆盖(不改全局配置):
|
|
109
|
+
npm_config_registry=https://registry.npmjs.org \
|
|
110
|
+
npx -y --prefer-online @chbo297/infoflow-openclaw-tools@beta update \
|
|
111
|
+
--version <要装的版本> --registry https://registry.npmjs.org
|
|
112
|
+
|
|
113
|
+
# 4) 直接 curl 验证那台机器能否拿到 manifest
|
|
114
|
+
curl -sI https://registry.npmjs.org/@chbo297/infoflow | head -5
|
|
115
|
+
curl -s https://registry.npmjs.org/@chbo297/infoflow/<要装的版本> | head -50
|
|
86
116
|
```
|
|
87
|
-
<!-- /sync:infoflow-plugin-version -->
|
|
88
117
|
|
|
89
|
-
|
|
118
|
+
### tools 包的常用参数
|
|
90
119
|
|
|
91
120
|
- `--version <version>`: 指定安装版本(默认 `latest`)
|
|
92
121
|
- `--registry <url>`: 插件包下载源(默认 `https://registry.npmjs.org`)
|
|
@@ -107,34 +136,102 @@ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry htt
|
|
|
107
136
|
|
|
108
137
|
### 版本升级、打 tag、推送与 npm 发布流程
|
|
109
138
|
|
|
110
|
-
|
|
139
|
+
正式版与 Beta 预发各自有完整流程,区别只在 **`npm version` 用的版本号** 和 **`npm publish` 是否带 `--tag beta`** 两处。请按需选择对应小节,从头到尾执行。
|
|
140
|
+
|
|
141
|
+
执行 `npm run sync-readme-install-version` 时脚本会:
|
|
142
|
+
|
|
143
|
+
- 把"发版流程"代码块内 `--version` / `npm version` / `git tag` / `git commit -m "<version>"` 等示例同步到当前 `package.json.version`("current" stream);
|
|
144
|
+
- 同时把上文"首次安装"段落里 `:latest` / `:beta` 两个 marker 区按 npm 上的 dist-tag 刷新——发 stable 时 `:latest` 区会写成新版本号;发 prerelease 时 `:beta` 区会写成新版本号;另一条 stream 通过 npm registry 接口拉取真实 dist-tag。
|
|
145
|
+
|
|
146
|
+
#### A. 正式版(stable)发布流程
|
|
147
|
+
|
|
148
|
+
发布一个不带预发后缀的版本(例如 `2026.5.10`),会同时占用 npm 的 `latest` dist-tag,是 `npx` 默认拉取的版本。
|
|
149
|
+
|
|
150
|
+
将下方所有 `<X.Y.Z>` 替换为目标正式版本号(不带 `-beta.N`):
|
|
111
151
|
|
|
112
|
-
<!-- sync:infoflow-plugin-version -->
|
|
113
152
|
```bash
|
|
114
153
|
# 1) 修改版本号(会同步 package-lock.json)
|
|
115
|
-
npm version
|
|
154
|
+
npm version <X.Y.Z> --no-git-tag-version
|
|
116
155
|
|
|
117
|
-
# 2) 同步 README
|
|
156
|
+
# 2) 同步 README:current 区写入 <X.Y.Z>;:latest 区也写入 <X.Y.Z>(因为本次发的就是新 latest);
|
|
157
|
+
# :beta 区从 npm 拉取当前 beta dist-tag 保持不变
|
|
118
158
|
npm run sync-readme-install-version
|
|
119
159
|
|
|
120
|
-
# 3)
|
|
160
|
+
# 3) 编辑 CHANGELOG.md 顶部,添加本版本章节
|
|
161
|
+
|
|
162
|
+
# 4) 发布前校验
|
|
121
163
|
npm run typecheck
|
|
122
164
|
npm run test
|
|
123
165
|
npm run build
|
|
124
166
|
|
|
125
|
-
#
|
|
167
|
+
# 5) 提交版本变更
|
|
126
168
|
git add package.json package-lock.json README.md CHANGELOG.md scripts src
|
|
127
|
-
git commit -m "
|
|
169
|
+
git commit -m "<X.Y.Z>"
|
|
170
|
+
|
|
171
|
+
# 6) 打 tag 并推送代码与 tag
|
|
172
|
+
git tag <X.Y.Z>
|
|
173
|
+
git push origin main
|
|
174
|
+
git push origin <X.Y.Z>
|
|
175
|
+
|
|
176
|
+
# 7) 发布到 npm(占用 latest dist-tag)
|
|
177
|
+
npm publish --registry https://registry.npmjs.org
|
|
128
178
|
|
|
129
|
-
#
|
|
130
|
-
|
|
179
|
+
# 8) 发布成功后再跑一次 sync,把 :latest 区刷新成 npm registry 真实状态(通常已经一致,
|
|
180
|
+
# 但若另一条 stream 在期间也有新发布,这一步会顺带更新),并补一个 docs 提交
|
|
181
|
+
npm run sync-readme-install-version
|
|
182
|
+
git add README.md && git diff --cached --quiet || git commit -m "docs: refresh README install commands"
|
|
131
183
|
git push origin main
|
|
132
|
-
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### B. Beta 预发布流程
|
|
187
|
+
|
|
188
|
+
发布一个带预发后缀的版本(例如 `2026.5.10-beta.1`),通过 `--tag beta` 占用 `beta` dist-tag,**不会**改写 `latest`,默认 `npx` 装到的仍是正式版。
|
|
133
189
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
#
|
|
190
|
+
将下方所有 `<X.Y.Z-beta.N>` 替换为目标预发版本号:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# 1) 修改版本号(会同步 package-lock.json)
|
|
194
|
+
npm version <X.Y.Z-beta.N> --no-git-tag-version
|
|
195
|
+
|
|
196
|
+
# 2) 同步 README:current 区写入 <X.Y.Z-beta.N>;:beta 区也写入 <X.Y.Z-beta.N>(因为本次发的就是新 beta);
|
|
197
|
+
# :latest 区从 npm 拉取当前 latest dist-tag 保持不变
|
|
198
|
+
npm run sync-readme-install-version
|
|
199
|
+
|
|
200
|
+
# 3) 编辑 CHANGELOG.md 顶部,添加本版本章节
|
|
201
|
+
|
|
202
|
+
# 4) 发布前校验
|
|
203
|
+
npm run typecheck
|
|
204
|
+
npm run test
|
|
205
|
+
npm run build
|
|
206
|
+
|
|
207
|
+
# 5) 提交版本变更
|
|
208
|
+
git add package.json package-lock.json README.md CHANGELOG.md scripts src
|
|
209
|
+
git commit -m "<X.Y.Z-beta.N>"
|
|
210
|
+
|
|
211
|
+
# 6) 打 tag 并推送代码与 tag
|
|
212
|
+
git tag <X.Y.Z-beta.N>
|
|
213
|
+
git push origin main
|
|
214
|
+
git push origin <X.Y.Z-beta.N>
|
|
215
|
+
|
|
216
|
+
# 7) 发布到 npm(占用 beta dist-tag;不影响 latest)
|
|
217
|
+
npm publish --tag beta --registry https://registry.npmjs.org
|
|
218
|
+
|
|
219
|
+
# 8) 发布成功后再跑一次 sync 并补一个 docs 提交(同 A.8)
|
|
220
|
+
npm run sync-readme-install-version
|
|
221
|
+
git add README.md && git diff --cached --quiet || git commit -m "docs: refresh README install commands"
|
|
222
|
+
git push origin main
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### 当前 `package.json` 的版本号示例
|
|
226
|
+
|
|
227
|
+
以下示例由 `sync` 脚本自动维护,反映**仓库当前 `package.json.version`**(可作为复制 A / B 流程时的版本号参考):
|
|
228
|
+
|
|
229
|
+
<!-- sync:infoflow-plugin-version -->
|
|
230
|
+
```bash
|
|
231
|
+
npm version 2026.5.9-beta.1 --no-git-tag-version
|
|
232
|
+
git commit -m "2026.5.9-beta.1"
|
|
233
|
+
git tag 2026.5.9-beta.1
|
|
234
|
+
git push origin 2026.5.9-beta.1
|
|
138
235
|
```
|
|
139
236
|
<!-- /sync:infoflow-plugin-version -->
|
|
140
237
|
|
package/dist/src/actions.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/core";
|
|
7
7
|
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
|
8
8
|
import { resolveInfoflowAccount } from "./accounts.js";
|
|
9
|
+
import { looksLikeRecallLatest } from "./recall-intent.js";
|
|
10
|
+
import { lookupInboundContext } from "./inbound-context.js";
|
|
9
11
|
import { logVerbose } from "./logging.js";
|
|
10
12
|
import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
|
|
11
13
|
import { sendInfoflowMessage, recallInfoflowGroupMessage, recallInfoflowPrivateMessage, } from "./send.js";
|
|
@@ -15,12 +17,89 @@ import { normalizeInfoflowTarget } from "./targets.js";
|
|
|
15
17
|
const RECALL_OK_HINT = "Recall succeeded. output only NO_REPLY with no other text.";
|
|
16
18
|
const RECALL_FAIL_HINT = "Recall failed. Send a brief reply stating only the failure reason.";
|
|
17
19
|
const RECALL_PARTIAL_HINT = "Some recalls failed. Send a brief reply stating only the failure reason(s).";
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the inbound replyToMessageId from the action ctx + inbound-context map.
|
|
22
|
+
* Returns the bot-sent messageId the user is quote-replying to (if any), so we
|
|
23
|
+
* can recover when the LLM accidentally passes the inbound user-message id as
|
|
24
|
+
* the delete target.
|
|
25
|
+
*/
|
|
26
|
+
function resolveInboundReplyToMessageId(params) {
|
|
27
|
+
const currentMessageId = params.currentMessageId != null ? String(params.currentMessageId) : undefined;
|
|
28
|
+
if (!currentMessageId)
|
|
29
|
+
return undefined;
|
|
30
|
+
const ctx = lookupInboundContext(currentMessageId);
|
|
31
|
+
if (!ctx)
|
|
32
|
+
return undefined;
|
|
33
|
+
// Scope match: same account + target (avoid using a stale context from another chat).
|
|
34
|
+
if (ctx.accountId !== params.accountId)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (ctx.target !== params.target)
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Aggressive guard: when LLM passes messageId === inbound currentMessageId (a known
|
|
41
|
+
* confusion pattern), auto-correct based on context instead of failing.
|
|
42
|
+
*
|
|
43
|
+
* Priorities:
|
|
44
|
+
* 1) Use replyToMessageId — user quote-replied to a bot message (highest confidence).
|
|
45
|
+
* 2) Drop to count=1 mode — when no replyTo AND text indicates "recall latest one".
|
|
46
|
+
* 3) Defer to existing fallback chain — ambiguous intent (e.g., "recall the one about X").
|
|
47
|
+
*
|
|
48
|
+
* Returns the corrected messageId (or undefined to signal count=1 mode).
|
|
49
|
+
*/
|
|
50
|
+
function applyAggressiveGuardForInboundMessageId(params) {
|
|
51
|
+
const inboundMsgId = params.currentMessageId != null ? String(params.currentMessageId) : undefined;
|
|
52
|
+
// Guard only triggers when LLM passes the inbound message id as delete target
|
|
53
|
+
if (!inboundMsgId || params.messageId !== inboundMsgId) {
|
|
54
|
+
return params.messageId;
|
|
55
|
+
}
|
|
56
|
+
const ctxRec = lookupInboundContext(inboundMsgId);
|
|
57
|
+
const scopeOk = ctxRec && ctxRec.accountId === params.accountId && ctxRec.target === params.target;
|
|
58
|
+
if (!scopeOk) {
|
|
59
|
+
// No inbound context to guide correction — defer to existing fallback chain
|
|
60
|
+
return params.messageId;
|
|
61
|
+
}
|
|
62
|
+
// Priority 1: replyToMessageId (user quote-replied to a bot message)
|
|
63
|
+
const replyToId = ctxRec.replyToMessageId;
|
|
64
|
+
if (replyToId && findSentMessage(params.accountId, replyToId)) {
|
|
65
|
+
logVerbose(`[infoflow:delete] aggressive: messageId==inboundMsgId(${params.messageId}); using replyTo=${replyToId}`);
|
|
66
|
+
return replyToId;
|
|
67
|
+
}
|
|
68
|
+
// Priority 2: text indicates "recall latest one" — safe to auto-correct to count=1
|
|
69
|
+
if (looksLikeRecallLatest(ctxRec.inboundBody ?? "")) {
|
|
70
|
+
logVerbose(`[infoflow:delete] aggressive: messageId==inboundMsgId(${params.messageId}); recall-latest intent → drop to count=1`);
|
|
71
|
+
return undefined; // undefined → count=1 mode
|
|
72
|
+
}
|
|
73
|
+
// Priority 3: ambiguous intent — defer to existing fallback chain
|
|
74
|
+
logVerbose(`[infoflow:delete] aggressive: messageId==inboundMsgId(${params.messageId}); ambiguous intent → defer to candidate-error path`);
|
|
75
|
+
return params.messageId;
|
|
76
|
+
}
|
|
77
|
+
/** Format up to N recent sent messages for an error-path hint to the LLM. */
|
|
78
|
+
function formatRecentCandidatesForError(records, limit = 5) {
|
|
79
|
+
if (!records || !Array.isArray(records) || records.length === 0)
|
|
80
|
+
return "";
|
|
81
|
+
const lines = records.slice(0, limit).map((r) => {
|
|
82
|
+
const previewText = r.digest || "(no preview)";
|
|
83
|
+
return `messageId=${r.messageid} preview="${previewText}"`;
|
|
84
|
+
});
|
|
85
|
+
return lines.join("; ");
|
|
86
|
+
}
|
|
87
|
+
/** Safe candidate lookup that never throws (errors → empty string). */
|
|
88
|
+
function safeRecentCandidates(accountId, target) {
|
|
89
|
+
try {
|
|
90
|
+
const records = querySentMessages(accountId, { target, count: 5 });
|
|
91
|
+
return formatRecentCandidatesForError(records);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
18
97
|
export const infoflowMessageActions = {
|
|
19
98
|
describeMessageTool: () => ({
|
|
20
99
|
actions: ["send", "delete"],
|
|
21
100
|
}),
|
|
22
101
|
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
|
23
|
-
handleAction: async ({ action, params, cfg, accountId }) => {
|
|
102
|
+
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
|
24
103
|
// -----------------------------------------------------------------------
|
|
25
104
|
// delete (群消息撤回) — Mode A: by messageId, Mode B: by count
|
|
26
105
|
// -----------------------------------------------------------------------
|
|
@@ -35,7 +114,7 @@ export const infoflowMessageActions = {
|
|
|
35
114
|
if (!account.config.appKey || !account.config.appSecret) {
|
|
36
115
|
throw new Error("Infoflow appKey/appSecret not configured.");
|
|
37
116
|
}
|
|
38
|
-
|
|
117
|
+
let messageId = readStringParam(params, "messageId");
|
|
39
118
|
// Default to count=1 (recall latest message) when neither messageId nor count is provided
|
|
40
119
|
const countStr = readStringParam(params, "count") ?? (messageId ? undefined : "1");
|
|
41
120
|
const groupMatch = target.match(/^group:(\d+)/i);
|
|
@@ -44,27 +123,61 @@ export const infoflowMessageActions = {
|
|
|
44
123
|
// 群消息撤回
|
|
45
124
|
// -----------------------------------------------------------------
|
|
46
125
|
const groupId = Number(groupMatch[1]);
|
|
126
|
+
const targetForStore = `group:${groupId}`;
|
|
127
|
+
// Apply aggressive guard when messageId equals inbound currentMessageId (LLM confusion pattern)
|
|
128
|
+
if (messageId) {
|
|
129
|
+
messageId = applyAggressiveGuardForInboundMessageId({
|
|
130
|
+
messageId,
|
|
131
|
+
currentMessageId: toolContext?.currentMessageId,
|
|
132
|
+
accountId: account.accountId,
|
|
133
|
+
target: targetForStore,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
47
136
|
// Mode A: single message recall by messageId
|
|
48
137
|
if (messageId) {
|
|
138
|
+
// Resolve msgseqid (group recall requires it). If the LLM-passed messageId
|
|
139
|
+
// is unknown to the store, fall back to the inbound replyToMessageId — the
|
|
140
|
+
// common failure mode is the LLM passing the inbound user-message id as
|
|
141
|
+
// the delete target instead of the bot-message id it's quote-replying to.
|
|
142
|
+
let effectiveMessageId = messageId;
|
|
49
143
|
let msgseqid = readStringParam(params, "msgseqid") ?? "";
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
144
|
+
let stored = findSentMessage(account.accountId, effectiveMessageId);
|
|
145
|
+
if (!stored && !msgseqid) {
|
|
146
|
+
const fallbackId = resolveInboundReplyToMessageId({
|
|
147
|
+
accountId: account.accountId,
|
|
148
|
+
target: targetForStore,
|
|
149
|
+
currentMessageId: toolContext?.currentMessageId,
|
|
150
|
+
});
|
|
151
|
+
if (fallbackId && fallbackId !== effectiveMessageId) {
|
|
152
|
+
const fallbackStored = findSentMessage(account.accountId, fallbackId);
|
|
153
|
+
if (fallbackStored) {
|
|
154
|
+
logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
|
|
155
|
+
effectiveMessageId = fallbackId;
|
|
156
|
+
stored = fallbackStored;
|
|
157
|
+
}
|
|
54
158
|
}
|
|
55
159
|
}
|
|
160
|
+
if (!msgseqid && stored?.msgseqid) {
|
|
161
|
+
msgseqid = stored.msgseqid;
|
|
162
|
+
}
|
|
56
163
|
if (!msgseqid) {
|
|
57
|
-
|
|
164
|
+
const candidates = safeRecentCandidates(account.accountId, `group:${groupId}`);
|
|
165
|
+
logVerbose(`[infoflow:delete] unknown messageId=${effectiveMessageId}, no fallback available, returning candidates to LLM`);
|
|
166
|
+
throw new Error(`delete: messageId=${effectiveMessageId} is not a known bot-sent message in this chat (msgseqid not found in store). ` +
|
|
167
|
+
`It looks like you may have passed the inbound (user) message id instead of the bot's. ` +
|
|
168
|
+
(candidates
|
|
169
|
+
? `Recent bot-sent messages here: ${candidates}. Pick the right messageId and retry.`
|
|
170
|
+
: `No recent bot-sent messages on file for this chat. Aborting to avoid wrong recall.`));
|
|
58
171
|
}
|
|
59
172
|
const result = await recallInfoflowGroupMessage({
|
|
60
173
|
account,
|
|
61
174
|
groupId,
|
|
62
|
-
messageid:
|
|
175
|
+
messageid: effectiveMessageId,
|
|
63
176
|
msgseqid,
|
|
64
177
|
});
|
|
65
178
|
if (result.ok) {
|
|
66
179
|
try {
|
|
67
|
-
removeRecalledMessages(account.accountId, [
|
|
180
|
+
removeRecalledMessages(account.accountId, [effectiveMessageId]);
|
|
68
181
|
}
|
|
69
182
|
catch {
|
|
70
183
|
// ignore cleanup errors
|
|
@@ -74,6 +187,7 @@ export const infoflowMessageActions = {
|
|
|
74
187
|
ok: result.ok,
|
|
75
188
|
channel: "infoflow",
|
|
76
189
|
to,
|
|
190
|
+
messageId: effectiveMessageId,
|
|
77
191
|
...(result.error ? { error: result.error } : {}),
|
|
78
192
|
_hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
|
|
79
193
|
});
|
|
@@ -85,7 +199,7 @@ export const infoflowMessageActions = {
|
|
|
85
199
|
throw new Error("count must be a positive integer.");
|
|
86
200
|
}
|
|
87
201
|
const records = querySentMessages(account.accountId, {
|
|
88
|
-
target:
|
|
202
|
+
target: targetForStore,
|
|
89
203
|
count,
|
|
90
204
|
});
|
|
91
205
|
// Filter to records that have msgseqid (required for group recall)
|
|
@@ -155,16 +269,48 @@ export const infoflowMessageActions = {
|
|
|
155
269
|
throw new Error("Infoflow private message recall requires appAgentId configuration. " +
|
|
156
270
|
"Set channels.infoflow.appAgentId to your application ID (如流企业后台的应用ID).");
|
|
157
271
|
}
|
|
272
|
+
// Apply aggressive guard when messageId equals inbound currentMessageId (LLM confusion pattern)
|
|
273
|
+
if (messageId) {
|
|
274
|
+
messageId = applyAggressiveGuardForInboundMessageId({
|
|
275
|
+
messageId,
|
|
276
|
+
currentMessageId: toolContext?.currentMessageId,
|
|
277
|
+
accountId: account.accountId,
|
|
278
|
+
target,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
158
281
|
// Mode A: single message recall by messageId (msgkey)
|
|
159
282
|
if (messageId) {
|
|
283
|
+
// Attempt the inbound-context fallback when the LLM-passed messageId is
|
|
284
|
+
// unknown to the store. If we can swap it for a verified bot-message id
|
|
285
|
+
// from the inbound replyTo, do so. Otherwise PRESERVE the original
|
|
286
|
+
// permissive behavior (pass the LLM id straight to the API and let
|
|
287
|
+
// Infoflow's backend judge) — the store may legitimately not contain
|
|
288
|
+
// every recallable DM message (e.g., after the 7-day retention sweep
|
|
289
|
+
// or for messages sent before this plugin started recording).
|
|
290
|
+
let effectiveMessageId = messageId;
|
|
291
|
+
const stored = findSentMessage(account.accountId, effectiveMessageId);
|
|
292
|
+
if (!stored) {
|
|
293
|
+
const fallbackId = resolveInboundReplyToMessageId({
|
|
294
|
+
accountId: account.accountId,
|
|
295
|
+
target,
|
|
296
|
+
currentMessageId: toolContext?.currentMessageId,
|
|
297
|
+
});
|
|
298
|
+
if (fallbackId && fallbackId !== effectiveMessageId) {
|
|
299
|
+
const fallbackStored = findSentMessage(account.accountId, fallbackId);
|
|
300
|
+
if (fallbackStored) {
|
|
301
|
+
logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
|
|
302
|
+
effectiveMessageId = fallbackId;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
160
306
|
const result = await recallInfoflowPrivateMessage({
|
|
161
307
|
account,
|
|
162
|
-
msgkey:
|
|
308
|
+
msgkey: effectiveMessageId,
|
|
163
309
|
appAgentId,
|
|
164
310
|
});
|
|
165
311
|
if (result.ok) {
|
|
166
312
|
try {
|
|
167
|
-
removeRecalledMessages(account.accountId, [
|
|
313
|
+
removeRecalledMessages(account.accountId, [effectiveMessageId]);
|
|
168
314
|
}
|
|
169
315
|
catch {
|
|
170
316
|
// ignore cleanup errors
|
|
@@ -174,6 +320,7 @@ export const infoflowMessageActions = {
|
|
|
174
320
|
ok: result.ok,
|
|
175
321
|
channel: "infoflow",
|
|
176
322
|
to,
|
|
323
|
+
messageId: effectiveMessageId,
|
|
177
324
|
...(result.error ? { error: result.error } : {}),
|
|
178
325
|
_hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
|
|
179
326
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-callable tools exposed by the Infoflow channel plugin.
|
|
3
|
+
*
|
|
4
|
+
* Currently exposes: `infoflow_list_sent_messages` — lets the LLM look up
|
|
5
|
+
* bot-sent messages by target / time window / content substring when the
|
|
6
|
+
* push-injected recent list (in bot.ts) isn't enough (e.g., older messages,
|
|
7
|
+
* search-by-content).
|
|
8
|
+
*/
|
|
9
|
+
import { jsonResult } from "openclaw/plugin-sdk/core";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { resolveDefaultInfoflowAccountId } from "./accounts.js";
|
|
12
|
+
import { logVerbose } from "./logging.js";
|
|
13
|
+
import { querySentMessages } from "./sent-message-store.js";
|
|
14
|
+
const listSentMessagesSchema = Type.Object({
|
|
15
|
+
target: Type.String({
|
|
16
|
+
description: "Chat target to query. Format: 'group:<groupId>' for groups or '<username>' for private chats. " +
|
|
17
|
+
"MUST be the current chat target — do not query other chats.",
|
|
18
|
+
}),
|
|
19
|
+
count: Type.Optional(Type.Integer({
|
|
20
|
+
minimum: 1,
|
|
21
|
+
maximum: 50,
|
|
22
|
+
default: 20,
|
|
23
|
+
description: "Maximum number of messages to return, newest first.",
|
|
24
|
+
})),
|
|
25
|
+
withinHours: Type.Optional(Type.Integer({
|
|
26
|
+
minimum: 1,
|
|
27
|
+
maximum: 168,
|
|
28
|
+
description: "Only include messages sent within the last N hours (1-168, i.e. up to 7 days, which matches the store's retention). Omit for no time filter.",
|
|
29
|
+
})),
|
|
30
|
+
containsText: Type.Optional(Type.String({
|
|
31
|
+
description: "Case-insensitive substring filter against the message digest (first ~100 chars of body). Use this to find a message by content, e.g. containsText='会议改到3点'.",
|
|
32
|
+
})),
|
|
33
|
+
accountId: Type.Optional(Type.String({
|
|
34
|
+
description: "Account id to query against (only needed when multiple Infoflow accounts are configured). Defaults to the configured default account.",
|
|
35
|
+
})),
|
|
36
|
+
});
|
|
37
|
+
const TOOL_DESCRIPTION = [
|
|
38
|
+
"List messages the bot previously sent to a given Infoflow chat, with optional time-window and content-substring filters.",
|
|
39
|
+
"Use this BEFORE action='delete' when:",
|
|
40
|
+
" (a) the message you need to recall is older than the recent list already injected into the message body, or",
|
|
41
|
+
" (b) you need to find a bot-sent message by its content (e.g. 'the joke about programmers', '会议通知').",
|
|
42
|
+
"Returns: target, count, and an array of { messageId, sentAt, ageMinutes, preview }.",
|
|
43
|
+
"Feed the chosen messageId back into action='delete' to recall it.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
export function createListSentMessagesTool(deps) {
|
|
46
|
+
return {
|
|
47
|
+
name: "infoflow_list_sent_messages",
|
|
48
|
+
label: "infoflow_list_sent_messages",
|
|
49
|
+
description: TOOL_DESCRIPTION,
|
|
50
|
+
parameters: listSentMessagesSchema,
|
|
51
|
+
execute: async (_toolCallId, rawParams) => {
|
|
52
|
+
const p = (rawParams ?? {});
|
|
53
|
+
if (typeof p.target !== "string" || !p.target.trim()) {
|
|
54
|
+
throw new Error("infoflow_list_sent_messages: 'target' is required.");
|
|
55
|
+
}
|
|
56
|
+
const target = p.target.trim();
|
|
57
|
+
const limit = Math.min(Math.max(p.count ?? 20, 1), 50);
|
|
58
|
+
const hasContains = typeof p.containsText === "string" && p.containsText.trim().length > 0;
|
|
59
|
+
const needle = hasContains ? p.containsText.trim().toLowerCase() : undefined;
|
|
60
|
+
let accountId = p.accountId?.trim();
|
|
61
|
+
if (!accountId) {
|
|
62
|
+
try {
|
|
63
|
+
accountId = resolveDefaultInfoflowAccountId(deps.getConfig());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
accountId = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!accountId) {
|
|
70
|
+
throw new Error("infoflow_list_sent_messages: cannot resolve account id. Pass accountId explicitly or configure a default account.");
|
|
71
|
+
}
|
|
72
|
+
// Over-fetch when filtering by content so the post-filter slice still has up to `limit` rows.
|
|
73
|
+
const fetchCount = needle ? Math.max(limit * 4, 50) : limit;
|
|
74
|
+
let records;
|
|
75
|
+
try {
|
|
76
|
+
records = querySentMessages(accountId, { target, count: fetchCount });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new Error(`infoflow_list_sent_messages: store query failed: ${err?.message ?? String(err)}`);
|
|
80
|
+
}
|
|
81
|
+
if (p.withinHours) {
|
|
82
|
+
const cutoff = Date.now() - p.withinHours * 60 * 60 * 1000;
|
|
83
|
+
records = records.filter((r) => r.sentAt >= cutoff);
|
|
84
|
+
}
|
|
85
|
+
if (needle) {
|
|
86
|
+
records = records.filter((r) => (r.digest ?? "").toLowerCase().includes(needle));
|
|
87
|
+
}
|
|
88
|
+
const sliced = records.slice(0, limit);
|
|
89
|
+
logVerbose(`[infoflow:tool:list_sent_messages] target=${target} accountId=${accountId} count=${sliced.length} (limit=${limit}, withinHours=${p.withinHours ?? "none"}, containsText=${hasContains ? "yes" : "no"})`);
|
|
90
|
+
return jsonResult({
|
|
91
|
+
target,
|
|
92
|
+
count: sliced.length,
|
|
93
|
+
messages: sliced.map((r) => ({
|
|
94
|
+
messageId: r.messageid,
|
|
95
|
+
sentAt: new Date(r.sentAt).toISOString(),
|
|
96
|
+
ageMinutes: Math.max(0, Math.round((Date.now() - r.sentAt) / 60000)),
|
|
97
|
+
preview: r.digest || "",
|
|
98
|
+
})),
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/src/bot.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { buildAgentMediaPayload, getAgentScopedMediaLocalRoots, } from "openclaw/plugin-sdk/agent-media-payload";
|
|
2
2
|
import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, } from "openclaw/plugin-sdk/reply-history";
|
|
3
3
|
import { resolveInfoflowAccount } from "./accounts.js";
|
|
4
|
+
import { registerInboundContext } from "./inbound-context.js";
|
|
4
5
|
import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
|
|
5
6
|
import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
|
|
7
|
+
import { looksLikeRecallIntent, looksLikeRecallLatest } from "./recall-intent.js";
|
|
6
8
|
import { getInfoflowRuntime } from "./runtime.js";
|
|
7
|
-
import { findSentMessage } from "./sent-message-store.js";
|
|
9
|
+
import { findSentMessage, querySentMessages } from "./sent-message-store.js";
|
|
8
10
|
/**
|
|
9
11
|
* Check if the bot was @mentioned in the message body.
|
|
10
12
|
* Matches appAgentId, robotName, or robotId against AT items (same order as Baidu reference plugin).
|
|
@@ -183,14 +185,18 @@ function resolveFollowUpOtherMentioned(params) {
|
|
|
183
185
|
logVerbose(`[infoflow:bot] skip dispatch: from=${fromuser}, group=${groupId}, reason=followUp-other-mentioned (record only, no LLM)`);
|
|
184
186
|
return "record_only";
|
|
185
187
|
}
|
|
186
|
-
// ---------------------------------------------------------------------------
|
|
187
|
-
// Reply-to-bot detection (引用回复机器人消息)
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
188
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
189
|
+
* Resolve all replyData targets from inbound body items, including each target's
|
|
190
|
+
* messageid + body preview + isBotMessage (via sent-message-store lookup).
|
|
191
|
+
*
|
|
192
|
+
* This is the structured form behind `checkReplyToBot`: callers who only want a
|
|
193
|
+
* boolean can compute it as `targets.some((t) => t.isBotMessage)`. Returning the
|
|
194
|
+
* full list lets us surface the messageid to the LLM and resolve the right
|
|
195
|
+
* recall target — the previous bool-only API was the root cause of the LLM
|
|
196
|
+
* passing the inbound (user) messageId to action=delete.
|
|
192
197
|
*/
|
|
193
|
-
function
|
|
198
|
+
function resolveReplyTargets(bodyItems, accountId) {
|
|
199
|
+
const out = [];
|
|
194
200
|
for (const item of bodyItems) {
|
|
195
201
|
if (item.type !== "replyData")
|
|
196
202
|
continue;
|
|
@@ -200,16 +206,85 @@ function checkReplyToBot(bodyItems, accountId) {
|
|
|
200
206
|
const msgIdStr = String(msgId);
|
|
201
207
|
if (!msgIdStr)
|
|
202
208
|
continue;
|
|
209
|
+
let isBotMessage = false;
|
|
203
210
|
try {
|
|
204
|
-
|
|
205
|
-
if (found)
|
|
206
|
-
return true;
|
|
211
|
+
isBotMessage = Boolean(findSentMessage(accountId, msgIdStr));
|
|
207
212
|
}
|
|
208
213
|
catch {
|
|
209
214
|
// DB lookup failure should not block message processing
|
|
210
215
|
}
|
|
216
|
+
out.push({
|
|
217
|
+
messageid: msgIdStr,
|
|
218
|
+
preview: (item.content ?? "").trim(),
|
|
219
|
+
isBotMessage,
|
|
220
|
+
});
|
|
211
221
|
}
|
|
212
|
-
return
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Sent-message context injection (push) — solves both the "AI uses inbound id
|
|
226
|
+
// as delete target" bug and the "DM-triggered cross-context send is invisible
|
|
227
|
+
// in the target group" bug. sent-messages.db tracks all bot-sent messages
|
|
228
|
+
// keyed by target, so a single push surfaces messages sent from any session.
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Recall-intent detection lives in ./recall-intent.js — shared with actions.ts.
|
|
231
|
+
const RECENT_BOT_AMBIENT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
232
|
+
const RECENT_BOT_AMBIENT_COUNT = 5;
|
|
233
|
+
const RECENT_BOT_DETAIL_COUNT = 10;
|
|
234
|
+
/**
|
|
235
|
+
* Build a system-style section listing the bot's recent messages to this chat.
|
|
236
|
+
* Two modes — ambient (compact, awareness only) and detail (longer, with
|
|
237
|
+
* explicit instruction for recall). Returns undefined when there's nothing
|
|
238
|
+
* recent to report so unrelated chats stay token-cheap.
|
|
239
|
+
*/
|
|
240
|
+
function buildBotRecentMessagesSection(params) {
|
|
241
|
+
const detail = params.inboundLooksLikeRecall || params.isReplyToBot;
|
|
242
|
+
const count = detail ? RECENT_BOT_DETAIL_COUNT : RECENT_BOT_AMBIENT_COUNT;
|
|
243
|
+
let records;
|
|
244
|
+
try {
|
|
245
|
+
records = querySentMessages(params.accountId, { target: params.target, count });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (records.length === 0)
|
|
251
|
+
return undefined;
|
|
252
|
+
const cutoff = Date.now() - RECENT_BOT_AMBIENT_WINDOW_MS;
|
|
253
|
+
const recent = records.filter((r) => r.sentAt >= cutoff);
|
|
254
|
+
if (recent.length === 0)
|
|
255
|
+
return undefined;
|
|
256
|
+
const header = detail
|
|
257
|
+
? "[System: Recent messages you (the bot) sent to this chat (newest first). Use these messageIds when recalling/deleting your own messages. NEVER pass the current inbound message_id as the delete target — that is the USER's message, not a bot message.]"
|
|
258
|
+
: "[System: Your recent messages to this chat (for awareness — these may have been sent from another session/context):]";
|
|
259
|
+
const lines = recent.map((r, i) => {
|
|
260
|
+
const ageMin = Math.max(0, Math.round((Date.now() - r.sentAt) / 60000));
|
|
261
|
+
const previewText = r.digest || "(no preview)";
|
|
262
|
+
return ` ${i + 1}. messageId=${r.messageid} sent=${ageMin}m ago preview="${previewText}"`;
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
text: [header, ...lines].join("\n"),
|
|
266
|
+
mode: detail ? "detail" : "ambient",
|
|
267
|
+
count: recent.length,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Build a section describing each quoted-reply target on the inbound message,
|
|
272
|
+
* with messageid + isBotMessage. Only emitted when there's at least one target.
|
|
273
|
+
* This is the missing piece for the original bug — the LLM needs to know which
|
|
274
|
+
* id belongs to the quoted bot message, distinct from the inbound message's id.
|
|
275
|
+
*/
|
|
276
|
+
function formatQuotedReplyTargetsSection(targets) {
|
|
277
|
+
if (!targets.length)
|
|
278
|
+
return undefined;
|
|
279
|
+
const lines = targets.map((t, i) => {
|
|
280
|
+
const previewText = t.preview ? t.preview.slice(0, 200) : "(no preview)";
|
|
281
|
+
return ` ${i + 1}. messageId=${t.messageid} sentByBot=${t.isBotMessage} preview="${previewText}"`;
|
|
282
|
+
});
|
|
283
|
+
return [
|
|
284
|
+
"[System: This message is a quoted reply to:",
|
|
285
|
+
...lines,
|
|
286
|
+
"If the user is asking to act on (recall/edit/quote) a referenced message and sentByBot=true, use that messageId as the action target.]",
|
|
287
|
+
].join("\n");
|
|
213
288
|
}
|
|
214
289
|
/** Shared judgment rules and reply format requirements for all conditional-reply prompts */
|
|
215
290
|
function buildReplyJudgmentRules(options) {
|
|
@@ -565,8 +640,13 @@ export async function handleGroupChatMessage(params) {
|
|
|
565
640
|
const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
|
|
566
641
|
// Extract sender name from header or fallback to fromuser
|
|
567
642
|
const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
|
|
568
|
-
//
|
|
569
|
-
|
|
643
|
+
// Resolve all replyData targets (id + preview + isBotMessage). We need the
|
|
644
|
+
// structured form (not just a boolean) so we can surface the bot-message
|
|
645
|
+
// messageId to the LLM for correct recall.
|
|
646
|
+
const replyTargets = Array.isArray(bodyItems) && bodyItems.length > 0
|
|
647
|
+
? resolveReplyTargets(bodyItems, accountId)
|
|
648
|
+
: [];
|
|
649
|
+
const isReplyToBot = replyTargets.some((t) => t.isBotMessage);
|
|
570
650
|
// Delegate to the common message handler (group chat)
|
|
571
651
|
await handleInfoflowMessage({
|
|
572
652
|
cfg,
|
|
@@ -585,6 +665,7 @@ export async function handleGroupChatMessage(params) {
|
|
|
585
665
|
mentionIds: mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
|
|
586
666
|
replyContext,
|
|
587
667
|
isReplyToBot: isReplyToBot || undefined,
|
|
668
|
+
replyTargets: replyTargets.length > 0 ? replyTargets : undefined,
|
|
588
669
|
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
589
670
|
},
|
|
590
671
|
accountId,
|
|
@@ -766,6 +847,56 @@ export async function handleInfoflowMessage(params) {
|
|
|
766
847
|
ctxPayload.BodyForAgent = bodyForAgent;
|
|
767
848
|
logVerbose(`[infoflow] group: BodyForAgent set for LLM (${bodyForAgent.length} chars, includes @/robotid)`);
|
|
768
849
|
}
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// Inject sent-message context into the LLM body. Two sections:
|
|
852
|
+
// 1. Quoted-reply targets (when this inbound is a 引用回复): exposes the
|
|
853
|
+
// bot-message messageId so the LLM doesn't mistake the inbound id for
|
|
854
|
+
// the recall target.
|
|
855
|
+
// 2. Recent bot-sent messages (always, when records exist within 24h):
|
|
856
|
+
// gives the LLM both ambient awareness (for cross-context-sent messages
|
|
857
|
+
// that aren't in this session's history) and a candidate list for
|
|
858
|
+
// semantic recall ("撤回刚才那条笑话").
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
const ctxTarget = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
|
|
861
|
+
{
|
|
862
|
+
const sections = [];
|
|
863
|
+
const quotedSection = formatQuotedReplyTargetsSection(event.replyTargets ?? []);
|
|
864
|
+
if (quotedSection)
|
|
865
|
+
sections.push(quotedSection);
|
|
866
|
+
const recallTextSource = `${bodyForAgent ?? ""}\n${event.replyContext?.join(" ") ?? ""}`.trim();
|
|
867
|
+
const recentSection = buildBotRecentMessagesSection({
|
|
868
|
+
accountId,
|
|
869
|
+
target: ctxTarget,
|
|
870
|
+
inboundLooksLikeRecall: looksLikeRecallIntent(recallTextSource),
|
|
871
|
+
isReplyToBot: event.isReplyToBot === true,
|
|
872
|
+
});
|
|
873
|
+
if (recentSection) {
|
|
874
|
+
sections.push(recentSection.text);
|
|
875
|
+
logVerbose(`[infoflow:ctx] injected recent-bot-messages section (mode=${recentSection.mode}, count=${recentSection.count}, target=${ctxTarget})`);
|
|
876
|
+
}
|
|
877
|
+
if (sections.length > 0) {
|
|
878
|
+
const existing = String(ctxPayload.Body ?? "");
|
|
879
|
+
ctxPayload.Body =
|
|
880
|
+
`${existing}\n\n${sections.join("\n\n")}`.trim();
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// Register inbound context so the delete action handler can fall back to the
|
|
884
|
+
// bot-message id the inbound is quote-replying to (when present), or detect
|
|
885
|
+
// the "messageId===inboundMessageId" LLM confusion pattern with the body
|
|
886
|
+
// text to decide whether it's safe to auto-correct to count=1. We only pick
|
|
887
|
+
// a bot-sent reply target: falling back to a non-bot reply id would never
|
|
888
|
+
// help (it can't be in sent-messages.db) and only adds noise.
|
|
889
|
+
if (event.messageId) {
|
|
890
|
+
registerInboundContext({
|
|
891
|
+
accountId,
|
|
892
|
+
target: ctxTarget,
|
|
893
|
+
inboundMessageId: event.messageId,
|
|
894
|
+
replyToMessageId: event.replyTargets?.find((t) => t.isBotMessage)?.messageid,
|
|
895
|
+
replyTargets: event.replyTargets,
|
|
896
|
+
inboundBody: bodyForAgent || mes || event.replyContext?.join(" "),
|
|
897
|
+
registeredAt: Date.now(),
|
|
898
|
+
});
|
|
899
|
+
}
|
|
769
900
|
// Record session using recordInboundSession for proper session tracking
|
|
770
901
|
await core.channel.session.recordInboundSession({
|
|
771
902
|
storePath,
|
|
@@ -1019,7 +1150,19 @@ export const _checkWatchMentioned = checkWatchMentioned;
|
|
|
1019
1150
|
export const _extractMentionIds = extractMentionIds;
|
|
1020
1151
|
/** @internal — Check if message matches any watchRegex pattern (dotAll). Only exported for tests. */
|
|
1021
1152
|
export const _checkWatchRegex = checkWatchRegex;
|
|
1022
|
-
/** @internal —
|
|
1023
|
-
export const
|
|
1153
|
+
/** @internal — Resolve structured replyData targets (id + preview + isBotMessage). Only exported for tests. */
|
|
1154
|
+
export const _resolveReplyTargets = resolveReplyTargets;
|
|
1155
|
+
/** @internal — Back-compat boolean form used by older tests; prefer _resolveReplyTargets. */
|
|
1156
|
+
export function _checkReplyToBot(bodyItems, accountId) {
|
|
1157
|
+
return resolveReplyTargets(bodyItems, accountId).some((t) => t.isBotMessage);
|
|
1158
|
+
}
|
|
1024
1159
|
/** @internal — Group output hygiene fragment appended to GroupSystemPrompt. Only exported for tests. */
|
|
1025
1160
|
export const _buildGroupOutputHygienePrompt = buildGroupOutputHygienePrompt;
|
|
1161
|
+
/** @internal — Recall intent regex. Only exported for tests. */
|
|
1162
|
+
export const _looksLikeRecallIntent = looksLikeRecallIntent;
|
|
1163
|
+
/** @internal — Stricter recall-latest detector. Only exported for tests. */
|
|
1164
|
+
export const _looksLikeRecallLatest = looksLikeRecallLatest;
|
|
1165
|
+
/** @internal — Sent-messages section builder. Only exported for tests. */
|
|
1166
|
+
export const _buildBotRecentMessagesSection = buildBotRecentMessagesSection;
|
|
1167
|
+
/** @internal — Quoted-reply targets section builder. Only exported for tests. */
|
|
1168
|
+
export const _formatQuotedReplyTargetsSection = formatQuotedReplyTargetsSection;
|
package/dist/src/channel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import { getChannelSection, listInfoflowAccountIds, resolveDefaultInfoflowAccountId, resolveInfoflowAccount, } from "./accounts.js";
|
|
3
3
|
import { infoflowMessageActions } from "./actions.js";
|
|
4
|
+
import { createListSentMessagesTool } from "./agent-tools.js";
|
|
4
5
|
import { logVerbose } from "./logging.js";
|
|
5
6
|
import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
|
|
6
7
|
import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
|
|
@@ -66,10 +67,21 @@ export const infoflowPlugin = {
|
|
|
66
67
|
},
|
|
67
68
|
reload: { configPrefixes: ["channels.infoflow"] },
|
|
68
69
|
actions: infoflowMessageActions,
|
|
70
|
+
agentTools: (params) => [
|
|
71
|
+
createListSentMessagesTool({
|
|
72
|
+
getConfig: () => params.cfg ?? {},
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
69
75
|
agentPrompt: {
|
|
70
76
|
messageToolHints: () => [
|
|
71
77
|
'Infoflow group @mentions: set atAll=true to @all members, or mentionUserIds="user1,user2" (comma-separated uuapName) to @mention specific users. Only effective for group targets (group:<id>).',
|
|
72
|
-
'Infoflow
|
|
78
|
+
'Infoflow message recall (撤回): use action="delete" to recall a bot-sent message.',
|
|
79
|
+
' - To recall a specific message, pass messageId=<the bot message id>. Get the id from (a) the "Recent messages you (the bot) sent" section that may be injected into the body, (b) the "quoted reply target" block when sentByBot=true, or (c) the infoflow_list_sent_messages tool.',
|
|
80
|
+
' - To recall the most recent message without specifying id, omit messageId (defaults to count=1).',
|
|
81
|
+
' - For batch recall use count=<N>.',
|
|
82
|
+
' - NEVER pass the current inbound message_id (the user-sent message you are replying to) as the delete target — that is the USER\'s message, not a bot message; the call will fail.',
|
|
83
|
+
' - When a quoted reply target is present with sentByBot=true, that messageId is the most likely recall target.',
|
|
84
|
+
' - For messages older than the injected recent window, or hard-to-identify ones, call infoflow_list_sent_messages first (use containsText / withinHours filters) and then pass the chosen messageId to action="delete".',
|
|
73
85
|
],
|
|
74
86
|
},
|
|
75
87
|
config: {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory registry of inbound message context, used by the delete action handler
|
|
3
|
+
* to recover when the LLM passes a wrong messageId.
|
|
4
|
+
*
|
|
5
|
+
* Why: openclaw's ChannelMessageActionContext exposes the inbound trigger's
|
|
6
|
+
* currentMessageId (via ctx.toolContext) but does NOT carry the inbound's
|
|
7
|
+
* replyToMessageId / resolved reply targets. We register that context here on
|
|
8
|
+
* inbound, keyed by the inbound messageId, and consume it from the action
|
|
9
|
+
* handler.
|
|
10
|
+
*
|
|
11
|
+
* Entries auto-expire after RETENTION_MS to keep the map bounded.
|
|
12
|
+
*/
|
|
13
|
+
import { logVerbose } from "./logging.js";
|
|
14
|
+
const RETENTION_MS = 10 * 60 * 1000; // 10 minutes — same order of magnitude as followUp window
|
|
15
|
+
const MAX_ENTRIES = 500;
|
|
16
|
+
const store = new Map();
|
|
17
|
+
function evictExpired() {
|
|
18
|
+
if (store.size === 0)
|
|
19
|
+
return;
|
|
20
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
21
|
+
let count = 0;
|
|
22
|
+
for (const [key, entry] of store) {
|
|
23
|
+
if (entry.registeredAt < cutoff) {
|
|
24
|
+
store.delete(key);
|
|
25
|
+
count++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (count > 0) {
|
|
29
|
+
logVerbose(`[infoflow:inbound-ctx] evicted ${count} expired entries`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function registerInboundContext(record) {
|
|
33
|
+
evictExpired();
|
|
34
|
+
// Cap the map size; if we're over, drop the oldest entries.
|
|
35
|
+
if (store.size >= MAX_ENTRIES) {
|
|
36
|
+
const sorted = Array.from(store.entries()).sort((a, b) => a[1].registeredAt - b[1].registeredAt);
|
|
37
|
+
const dropCount = store.size - MAX_ENTRIES + 1;
|
|
38
|
+
for (let i = 0; i < dropCount; i++) {
|
|
39
|
+
store.delete(sorted[i][0]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
store.set(record.inboundMessageId, record);
|
|
43
|
+
}
|
|
44
|
+
export function lookupInboundContext(inboundMessageId) {
|
|
45
|
+
const entry = store.get(inboundMessageId);
|
|
46
|
+
if (!entry)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (Date.now() - entry.registeredAt > RETENTION_MS) {
|
|
49
|
+
store.delete(inboundMessageId);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return entry;
|
|
53
|
+
}
|
|
54
|
+
/** @internal — for tests */
|
|
55
|
+
export function _resetInboundContext() {
|
|
56
|
+
store.clear();
|
|
57
|
+
}
|
|
58
|
+
/** @internal — for tests */
|
|
59
|
+
export function _inboundContextSize() {
|
|
60
|
+
return store.size;
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight recall-intent detection used by both the prompt-injection path
|
|
3
|
+
* (bot.ts) and the delete action's aggressive guard (actions.ts).
|
|
4
|
+
*
|
|
5
|
+
* Two helpers:
|
|
6
|
+
* - looksLikeRecallIntent: matches any "撤回/删除/recall/unsend/..." verb.
|
|
7
|
+
* - looksLikeRecallLatest: requires both a recall verb AND an explicit
|
|
8
|
+
* "the latest one" qualifier (上一条 / 最后一条 / 刚才那条 / 最近一条 /
|
|
9
|
+
* last / previous / most recent / ...). Used to decide whether it's safe
|
|
10
|
+
* to auto-correct `messageId=inbound_user_msg_id` to count=1 (recall most
|
|
11
|
+
* recent). Standalone "撤回那条" without a temporal qualifier is rejected:
|
|
12
|
+
* it could refer to a specific quoted message and we'd rather surface
|
|
13
|
+
* candidates to the LLM than risk recalling the wrong one.
|
|
14
|
+
*/
|
|
15
|
+
const RECALL_INTENT_REGEX = /(撤回|收回|删[掉了除]|取消|清除|recall|unsend|undo\s*send|delete\s+(?:that|those|the\s+(?:last|previous(?:\s+\d+)?)))/i;
|
|
16
|
+
const RECALL_LATEST_HINT_REGEX = /(上一?条|最后一?条|刚才那?条|最近一?条|last(?:\s+(?:one|message|two|few|reply))?|previous|most\s*recent)/iu;
|
|
17
|
+
export function looksLikeRecallIntent(text) {
|
|
18
|
+
if (!text)
|
|
19
|
+
return false;
|
|
20
|
+
return RECALL_INTENT_REGEX.test(text);
|
|
21
|
+
}
|
|
22
|
+
export function looksLikeRecallLatest(text) {
|
|
23
|
+
if (!text)
|
|
24
|
+
return false;
|
|
25
|
+
return RECALL_INTENT_REGEX.test(text) && RECALL_LATEST_HINT_REGEX.test(text);
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chbo297/infoflow",
|
|
3
|
-
"version": "2026.5.
|
|
3
|
+
"version": "2026.5.9-beta.2",
|
|
4
4
|
"description": "OpenClaw Infoflow (如流) channel plugin for Baidu enterprise messaging",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"@baidu/infoflow-sdk-nodejs": ">=0.1.0",
|
|
23
23
|
"openclaw": ">=2026.5.4"
|
|
24
24
|
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"typebox": "^1.1.0"
|
|
27
|
+
},
|
|
25
28
|
"peerDependenciesMeta": {
|
|
26
29
|
"@baidu/infoflow-sdk-nodejs": {
|
|
27
30
|
"optional": true
|
|
@@ -51,6 +54,7 @@
|
|
|
51
54
|
"sync-readme-install-version": "node scripts/sync-readme-install-version.mjs"
|
|
52
55
|
},
|
|
53
56
|
"devDependencies": {
|
|
57
|
+
"typebox": "^1.1.38",
|
|
54
58
|
"typescript": "^6.0.3",
|
|
55
59
|
"vitest": "^4.1.5"
|
|
56
60
|
},
|