@chbo297/infoflow 2026.5.8 → 2026.5.9-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ BAIDU_NPM_REGISTRY=http://registry.npm.baidu-int.com bash scripts/deploy.sh
46
46
  <!-- sync:infoflow-plugin-version -->
47
47
  ```bash
48
48
  # 正式版(latest)
49
- npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry https://registry.npmjs.org
49
+ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.9-beta.1 --registry https://registry.npmjs.org
50
50
  ```
51
51
  <!-- /sync:infoflow-plugin-version -->
52
52
 
@@ -60,7 +60,7 @@ npx -y @chbo297/infoflow-openclaw-tools@beta update --version 2026.5.8-beta.1 --
60
60
  <!-- sync:infoflow-plugin-version -->
61
61
  ```bash
62
62
  # 正式版
63
- openclaw plugins install @chbo297/infoflow@2026.5.8
63
+ openclaw plugins install @chbo297/infoflow@2026.5.9-beta.1
64
64
  ```
65
65
  <!-- /sync:infoflow-plugin-version -->
66
66
 
@@ -82,7 +82,7 @@ openclaw plugins inspect infoflow
82
82
 
83
83
  <!-- sync:infoflow-plugin-version -->
84
84
  ```bash
85
- npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry https://registry.npmjs.org
85
+ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.9-beta.1 --registry https://registry.npmjs.org
86
86
  ```
87
87
  <!-- /sync:infoflow-plugin-version -->
88
88
 
@@ -112,7 +112,7 @@ npx -y @chbo297/infoflow-openclaw-tools update --version 2026.5.8 --registry htt
112
112
  <!-- sync:infoflow-plugin-version -->
113
113
  ```bash
114
114
  # 1) 修改版本号(会同步 package-lock.json)
115
- npm version 2026.5.8 --no-git-tag-version
115
+ npm version 2026.5.9-beta.1 --no-git-tag-version
116
116
 
117
117
  # 2) 同步 README 中标记块内的推荐安装命令与下文中的 tag / commit 示例版本号
118
118
  npm run sync-readme-install-version
@@ -124,12 +124,12 @@ npm run build
124
124
 
125
125
  # 4) 提交版本变更(含 README、CHANGELOG 等)
126
126
  git add package.json package-lock.json README.md CHANGELOG.md scripts src
127
- git commit -m "2026.5.8"
127
+ git commit -m "2026.5.9-beta.1"
128
128
 
129
129
  # 5) 打 tag 并推送代码与 tag
130
- git tag 2026.5.8
130
+ git tag 2026.5.9-beta.1
131
131
  git push origin main
132
- git push origin 2026.5.8
132
+ git push origin 2026.5.9-beta.1
133
133
 
134
134
  # 6) 发布 npm(可按需指定 registry)
135
135
  npm publish
@@ -6,6 +6,7 @@
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 { lookupInboundContext } from "./inbound-context.js";
9
10
  import { logVerbose } from "./logging.js";
10
11
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
11
12
  import { sendInfoflowMessage, recallInfoflowGroupMessage, recallInfoflowPrivateMessage, } from "./send.js";
@@ -15,12 +16,52 @@ import { normalizeInfoflowTarget } from "./targets.js";
15
16
  const RECALL_OK_HINT = "Recall succeeded. output only NO_REPLY with no other text.";
16
17
  const RECALL_FAIL_HINT = "Recall failed. Send a brief reply stating only the failure reason.";
17
18
  const RECALL_PARTIAL_HINT = "Some recalls failed. Send a brief reply stating only the failure reason(s).";
19
+ /**
20
+ * Resolve the inbound replyToMessageId from the action ctx + inbound-context map.
21
+ * Returns the bot-sent messageId the user is quote-replying to (if any), so we
22
+ * can recover when the LLM accidentally passes the inbound user-message id as
23
+ * the delete target.
24
+ */
25
+ function resolveInboundReplyToMessageId(params) {
26
+ const currentMessageId = params.currentMessageId != null ? String(params.currentMessageId) : undefined;
27
+ if (!currentMessageId)
28
+ return undefined;
29
+ const ctx = lookupInboundContext(currentMessageId);
30
+ if (!ctx)
31
+ return undefined;
32
+ // Scope match: same account + target (avoid using a stale context from another chat).
33
+ if (ctx.accountId !== params.accountId)
34
+ return undefined;
35
+ if (ctx.target !== params.target)
36
+ return undefined;
37
+ return ctx.replyToMessageId;
38
+ }
39
+ /** Format up to N recent sent messages for an error-path hint to the LLM. */
40
+ function formatRecentCandidatesForError(records, limit = 5) {
41
+ if (!records || !Array.isArray(records) || records.length === 0)
42
+ return "";
43
+ const lines = records.slice(0, limit).map((r) => {
44
+ const previewText = r.digest || "(no preview)";
45
+ return `messageId=${r.messageid} preview="${previewText}"`;
46
+ });
47
+ return lines.join("; ");
48
+ }
49
+ /** Safe candidate lookup that never throws (errors → empty string). */
50
+ function safeRecentCandidates(accountId, target) {
51
+ try {
52
+ const records = querySentMessages(accountId, { target, count: 5 });
53
+ return formatRecentCandidatesForError(records);
54
+ }
55
+ catch {
56
+ return "";
57
+ }
58
+ }
18
59
  export const infoflowMessageActions = {
19
60
  describeMessageTool: () => ({
20
61
  actions: ["send", "delete"],
21
62
  }),
22
63
  extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
23
- handleAction: async ({ action, params, cfg, accountId }) => {
64
+ handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
24
65
  // -----------------------------------------------------------------------
25
66
  // delete (群消息撤回) — Mode A: by messageId, Mode B: by count
26
67
  // -----------------------------------------------------------------------
@@ -46,25 +87,49 @@ export const infoflowMessageActions = {
46
87
  const groupId = Number(groupMatch[1]);
47
88
  // Mode A: single message recall by messageId
48
89
  if (messageId) {
90
+ // Resolve msgseqid (group recall requires it). If the LLM-passed messageId
91
+ // is unknown to the store, fall back to the inbound replyToMessageId — the
92
+ // common failure mode is the LLM passing the inbound user-message id as
93
+ // the delete target instead of the bot-message id it's quote-replying to.
94
+ let effectiveMessageId = messageId;
49
95
  let msgseqid = readStringParam(params, "msgseqid") ?? "";
50
- if (!msgseqid) {
51
- const stored = findSentMessage(account.accountId, messageId);
52
- if (stored?.msgseqid) {
53
- msgseqid = stored.msgseqid;
96
+ let stored = findSentMessage(account.accountId, effectiveMessageId);
97
+ if (!stored && !msgseqid) {
98
+ const fallbackId = resolveInboundReplyToMessageId({
99
+ accountId: account.accountId,
100
+ target: `group:${groupId}`,
101
+ currentMessageId: toolContext?.currentMessageId,
102
+ });
103
+ if (fallbackId && fallbackId !== effectiveMessageId) {
104
+ const fallbackStored = findSentMessage(account.accountId, fallbackId);
105
+ if (fallbackStored) {
106
+ logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
107
+ effectiveMessageId = fallbackId;
108
+ stored = fallbackStored;
109
+ }
54
110
  }
55
111
  }
112
+ if (!msgseqid && stored?.msgseqid) {
113
+ msgseqid = stored.msgseqid;
114
+ }
56
115
  if (!msgseqid) {
57
- throw new Error("delete requires msgseqid (not found in store; provide it explicitly or send messages first).");
116
+ const candidates = safeRecentCandidates(account.accountId, `group:${groupId}`);
117
+ logVerbose(`[infoflow:delete] unknown messageId=${effectiveMessageId}, no fallback available, returning candidates to LLM`);
118
+ throw new Error(`delete: messageId=${effectiveMessageId} is not a known bot-sent message in this chat (msgseqid not found in store). ` +
119
+ `It looks like you may have passed the inbound (user) message id instead of the bot's. ` +
120
+ (candidates
121
+ ? `Recent bot-sent messages here: ${candidates}. Pick the right messageId and retry.`
122
+ : `No recent bot-sent messages on file for this chat. Aborting to avoid wrong recall.`));
58
123
  }
59
124
  const result = await recallInfoflowGroupMessage({
60
125
  account,
61
126
  groupId,
62
- messageid: messageId,
127
+ messageid: effectiveMessageId,
63
128
  msgseqid,
64
129
  });
65
130
  if (result.ok) {
66
131
  try {
67
- removeRecalledMessages(account.accountId, [messageId]);
132
+ removeRecalledMessages(account.accountId, [effectiveMessageId]);
68
133
  }
69
134
  catch {
70
135
  // ignore cleanup errors
@@ -74,6 +139,7 @@ export const infoflowMessageActions = {
74
139
  ok: result.ok,
75
140
  channel: "infoflow",
76
141
  to,
142
+ messageId: effectiveMessageId,
77
143
  ...(result.error ? { error: result.error } : {}),
78
144
  _hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
79
145
  });
@@ -157,14 +223,37 @@ export const infoflowMessageActions = {
157
223
  }
158
224
  // Mode A: single message recall by messageId (msgkey)
159
225
  if (messageId) {
226
+ // Attempt the inbound-context fallback when the LLM-passed messageId is
227
+ // unknown to the store. If we can swap it for a verified bot-message id
228
+ // from the inbound replyTo, do so. Otherwise PRESERVE the original
229
+ // permissive behavior (pass the LLM id straight to the API and let
230
+ // Infoflow's backend judge) — the store may legitimately not contain
231
+ // every recallable DM message (e.g., after the 7-day retention sweep
232
+ // or for messages sent before this plugin started recording).
233
+ let effectiveMessageId = messageId;
234
+ const stored = findSentMessage(account.accountId, effectiveMessageId);
235
+ if (!stored) {
236
+ const fallbackId = resolveInboundReplyToMessageId({
237
+ accountId: account.accountId,
238
+ target,
239
+ currentMessageId: toolContext?.currentMessageId,
240
+ });
241
+ if (fallbackId && fallbackId !== effectiveMessageId) {
242
+ const fallbackStored = findSentMessage(account.accountId, fallbackId);
243
+ if (fallbackStored) {
244
+ logVerbose(`[infoflow:delete] LLM passed unknown messageId=${effectiveMessageId}, falling back to replyToMessageId=${fallbackId}`);
245
+ effectiveMessageId = fallbackId;
246
+ }
247
+ }
248
+ }
160
249
  const result = await recallInfoflowPrivateMessage({
161
250
  account,
162
- msgkey: messageId,
251
+ msgkey: effectiveMessageId,
163
252
  appAgentId,
164
253
  });
165
254
  if (result.ok) {
166
255
  try {
167
- removeRecalledMessages(account.accountId, [messageId]);
256
+ removeRecalledMessages(account.accountId, [effectiveMessageId]);
168
257
  }
169
258
  catch {
170
259
  // ignore cleanup errors
@@ -174,6 +263,7 @@ export const infoflowMessageActions = {
174
263
  ok: result.ok,
175
264
  channel: "infoflow",
176
265
  to,
266
+ messageId: effectiveMessageId,
177
267
  ...(result.error ? { error: result.error } : {}),
178
268
  _hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
179
269
  });
@@ -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,11 @@
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";
6
7
  import { getInfoflowRuntime } from "./runtime.js";
7
- import { findSentMessage } from "./sent-message-store.js";
8
+ import { findSentMessage, querySentMessages } from "./sent-message-store.js";
8
9
  /**
9
10
  * Check if the bot was @mentioned in the message body.
10
11
  * Matches appAgentId, robotName, or robotId against AT items (same order as Baidu reference plugin).
@@ -183,14 +184,18 @@ function resolveFollowUpOtherMentioned(params) {
183
184
  logVerbose(`[infoflow:bot] skip dispatch: from=${fromuser}, group=${groupId}, reason=followUp-other-mentioned (record only, no LLM)`);
184
185
  return "record_only";
185
186
  }
186
- // ---------------------------------------------------------------------------
187
- // Reply-to-bot detection (引用回复机器人消息)
188
- // ---------------------------------------------------------------------------
189
187
  /**
190
- * Check if the message is a reply (引用回复) to one of the bot's own messages.
191
- * Looks up replyData body items' messageid against the sent-message-store.
188
+ * Resolve all replyData targets from inbound body items, including each target's
189
+ * messageid + body preview + isBotMessage (via sent-message-store lookup).
190
+ *
191
+ * This is the structured form behind `checkReplyToBot`: callers who only want a
192
+ * boolean can compute it as `targets.some((t) => t.isBotMessage)`. Returning the
193
+ * full list lets us surface the messageid to the LLM and resolve the right
194
+ * recall target — the previous bool-only API was the root cause of the LLM
195
+ * passing the inbound (user) messageId to action=delete.
192
196
  */
193
- function checkReplyToBot(bodyItems, accountId) {
197
+ function resolveReplyTargets(bodyItems, accountId) {
198
+ const out = [];
194
199
  for (const item of bodyItems) {
195
200
  if (item.type !== "replyData")
196
201
  continue;
@@ -200,16 +205,91 @@ function checkReplyToBot(bodyItems, accountId) {
200
205
  const msgIdStr = String(msgId);
201
206
  if (!msgIdStr)
202
207
  continue;
208
+ let isBotMessage = false;
203
209
  try {
204
- const found = findSentMessage(accountId, msgIdStr);
205
- if (found)
206
- return true;
210
+ isBotMessage = Boolean(findSentMessage(accountId, msgIdStr));
207
211
  }
208
212
  catch {
209
213
  // DB lookup failure should not block message processing
210
214
  }
215
+ out.push({
216
+ messageid: msgIdStr,
217
+ preview: (item.content ?? "").trim(),
218
+ isBotMessage,
219
+ });
211
220
  }
212
- return false;
221
+ return out;
222
+ }
223
+ // ---------------------------------------------------------------------------
224
+ // Sent-message context injection (push) — solves both the "AI uses inbound id
225
+ // as delete target" bug and the "DM-triggered cross-context send is invisible
226
+ // in the target group" bug. sent-messages.db tracks all bot-sent messages
227
+ // keyed by target, so a single push surfaces messages sent from any session.
228
+ // ---------------------------------------------------------------------------
229
+ /** Detect inbound text that semantically asks the bot to recall/delete messages. */
230
+ const RECALL_INTENT_REGEX = /(撤回|收回|删[掉了除]|取消|清除|recall|unsend|undo\s*send|delete\s+(?:that|those|the\s+(?:last|previous(?:\s+\d+)?)))/i;
231
+ function looksLikeRecallIntent(text) {
232
+ if (!text)
233
+ return false;
234
+ return RECALL_INTENT_REGEX.test(text);
235
+ }
236
+ const RECENT_BOT_AMBIENT_WINDOW_MS = 24 * 60 * 60 * 1000;
237
+ const RECENT_BOT_AMBIENT_COUNT = 5;
238
+ const RECENT_BOT_DETAIL_COUNT = 10;
239
+ /**
240
+ * Build a system-style section listing the bot's recent messages to this chat.
241
+ * Two modes — ambient (compact, awareness only) and detail (longer, with
242
+ * explicit instruction for recall). Returns undefined when there's nothing
243
+ * recent to report so unrelated chats stay token-cheap.
244
+ */
245
+ function buildBotRecentMessagesSection(params) {
246
+ const detail = params.inboundLooksLikeRecall || params.isReplyToBot;
247
+ const count = detail ? RECENT_BOT_DETAIL_COUNT : RECENT_BOT_AMBIENT_COUNT;
248
+ let records;
249
+ try {
250
+ records = querySentMessages(params.accountId, { target: params.target, count });
251
+ }
252
+ catch {
253
+ return undefined;
254
+ }
255
+ if (records.length === 0)
256
+ return undefined;
257
+ const cutoff = Date.now() - RECENT_BOT_AMBIENT_WINDOW_MS;
258
+ const recent = records.filter((r) => r.sentAt >= cutoff);
259
+ if (recent.length === 0)
260
+ return undefined;
261
+ const header = detail
262
+ ? "[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.]"
263
+ : "[System: Your recent messages to this chat (for awareness — these may have been sent from another session/context):]";
264
+ const lines = recent.map((r, i) => {
265
+ const ageMin = Math.max(0, Math.round((Date.now() - r.sentAt) / 60000));
266
+ const previewText = r.digest || "(no preview)";
267
+ return ` ${i + 1}. messageId=${r.messageid} sent=${ageMin}m ago preview="${previewText}"`;
268
+ });
269
+ return {
270
+ text: [header, ...lines].join("\n"),
271
+ mode: detail ? "detail" : "ambient",
272
+ count: recent.length,
273
+ };
274
+ }
275
+ /**
276
+ * Build a section describing each quoted-reply target on the inbound message,
277
+ * with messageid + isBotMessage. Only emitted when there's at least one target.
278
+ * This is the missing piece for the original bug — the LLM needs to know which
279
+ * id belongs to the quoted bot message, distinct from the inbound message's id.
280
+ */
281
+ function formatQuotedReplyTargetsSection(targets) {
282
+ if (!targets.length)
283
+ return undefined;
284
+ const lines = targets.map((t, i) => {
285
+ const previewText = t.preview ? t.preview.slice(0, 200) : "(no preview)";
286
+ return ` ${i + 1}. messageId=${t.messageid} sentByBot=${t.isBotMessage} preview="${previewText}"`;
287
+ });
288
+ return [
289
+ "[System: This message is a quoted reply to:",
290
+ ...lines,
291
+ "If the user is asking to act on (recall/edit/quote) a referenced message and sentByBot=true, use that messageId as the action target.]",
292
+ ].join("\n");
213
293
  }
214
294
  /** Shared judgment rules and reply format requirements for all conditional-reply prompts */
215
295
  function buildReplyJudgmentRules(options) {
@@ -565,8 +645,13 @@ export async function handleGroupChatMessage(params) {
565
645
  const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
566
646
  // Extract sender name from header or fallback to fromuser
567
647
  const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
568
- // Detect reply-to-bot: check if any replyData item quotes a bot-sent message
569
- const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
648
+ // Resolve all replyData targets (id + preview + isBotMessage). We need the
649
+ // structured form (not just a boolean) so we can surface the bot-message
650
+ // messageId to the LLM for correct recall.
651
+ const replyTargets = Array.isArray(bodyItems) && bodyItems.length > 0
652
+ ? resolveReplyTargets(bodyItems, accountId)
653
+ : [];
654
+ const isReplyToBot = replyTargets.some((t) => t.isBotMessage);
570
655
  // Delegate to the common message handler (group chat)
571
656
  await handleInfoflowMessage({
572
657
  cfg,
@@ -585,6 +670,7 @@ export async function handleGroupChatMessage(params) {
585
670
  mentionIds: mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
586
671
  replyContext,
587
672
  isReplyToBot: isReplyToBot || undefined,
673
+ replyTargets: replyTargets.length > 0 ? replyTargets : undefined,
588
674
  imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
589
675
  },
590
676
  accountId,
@@ -766,6 +852,54 @@ export async function handleInfoflowMessage(params) {
766
852
  ctxPayload.BodyForAgent = bodyForAgent;
767
853
  logVerbose(`[infoflow] group: BodyForAgent set for LLM (${bodyForAgent.length} chars, includes @/robotid)`);
768
854
  }
855
+ // ---------------------------------------------------------------------------
856
+ // Inject sent-message context into the LLM body. Two sections:
857
+ // 1. Quoted-reply targets (when this inbound is a 引用回复): exposes the
858
+ // bot-message messageId so the LLM doesn't mistake the inbound id for
859
+ // the recall target.
860
+ // 2. Recent bot-sent messages (always, when records exist within 24h):
861
+ // gives the LLM both ambient awareness (for cross-context-sent messages
862
+ // that aren't in this session's history) and a candidate list for
863
+ // semantic recall ("撤回刚才那条笑话").
864
+ // ---------------------------------------------------------------------------
865
+ const ctxTarget = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
866
+ {
867
+ const sections = [];
868
+ const quotedSection = formatQuotedReplyTargetsSection(event.replyTargets ?? []);
869
+ if (quotedSection)
870
+ sections.push(quotedSection);
871
+ const recallTextSource = `${bodyForAgent ?? ""}\n${event.replyContext?.join(" ") ?? ""}`.trim();
872
+ const recentSection = buildBotRecentMessagesSection({
873
+ accountId,
874
+ target: ctxTarget,
875
+ inboundLooksLikeRecall: looksLikeRecallIntent(recallTextSource),
876
+ isReplyToBot: event.isReplyToBot === true,
877
+ });
878
+ if (recentSection) {
879
+ sections.push(recentSection.text);
880
+ logVerbose(`[infoflow:ctx] injected recent-bot-messages section (mode=${recentSection.mode}, count=${recentSection.count}, target=${ctxTarget})`);
881
+ }
882
+ if (sections.length > 0) {
883
+ const existing = String(ctxPayload.Body ?? "");
884
+ ctxPayload.Body =
885
+ `${existing}\n\n${sections.join("\n\n")}`.trim();
886
+ }
887
+ }
888
+ // Register inbound context so the delete action handler can fall back to the
889
+ // bot-message id the inbound is quote-replying to (when present) — only used
890
+ // when the LLM otherwise passes an unknown id. We intentionally only pick a
891
+ // bot-sent reply target: falling back to a non-bot reply id would never help
892
+ // (it can't be in sent-messages.db) and only adds noise.
893
+ if (event.messageId) {
894
+ registerInboundContext({
895
+ accountId,
896
+ target: ctxTarget,
897
+ inboundMessageId: event.messageId,
898
+ replyToMessageId: event.replyTargets?.find((t) => t.isBotMessage)?.messageid,
899
+ replyTargets: event.replyTargets,
900
+ registeredAt: Date.now(),
901
+ });
902
+ }
769
903
  // Record session using recordInboundSession for proper session tracking
770
904
  await core.channel.session.recordInboundSession({
771
905
  storePath,
@@ -1019,7 +1153,17 @@ export const _checkWatchMentioned = checkWatchMentioned;
1019
1153
  export const _extractMentionIds = extractMentionIds;
1020
1154
  /** @internal — Check if message matches any watchRegex pattern (dotAll). Only exported for tests. */
1021
1155
  export const _checkWatchRegex = checkWatchRegex;
1022
- /** @internal — Check if message is a reply to one of the bot's own messages. Only exported for tests. */
1023
- export const _checkReplyToBot = checkReplyToBot;
1156
+ /** @internal — Resolve structured replyData targets (id + preview + isBotMessage). Only exported for tests. */
1157
+ export const _resolveReplyTargets = resolveReplyTargets;
1158
+ /** @internal — Back-compat boolean form used by older tests; prefer _resolveReplyTargets. */
1159
+ export function _checkReplyToBot(bodyItems, accountId) {
1160
+ return resolveReplyTargets(bodyItems, accountId).some((t) => t.isBotMessage);
1161
+ }
1024
1162
  /** @internal — Group output hygiene fragment appended to GroupSystemPrompt. Only exported for tests. */
1025
1163
  export const _buildGroupOutputHygienePrompt = buildGroupOutputHygienePrompt;
1164
+ /** @internal — Recall intent regex. Only exported for tests. */
1165
+ export const _looksLikeRecallIntent = looksLikeRecallIntent;
1166
+ /** @internal — Sent-messages section builder. Only exported for tests. */
1167
+ export const _buildBotRecentMessagesSection = buildBotRecentMessagesSection;
1168
+ /** @internal — Quoted-reply targets section builder. Only exported for tests. */
1169
+ export const _formatQuotedReplyTargetsSection = formatQuotedReplyTargetsSection;
@@ -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 supports message recall (撤回): use action="delete" to recall the most recent message, or specify messageId to recall a specific message. Works for both private and group messages.',
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chbo297/infoflow",
3
- "version": "2026.5.8",
3
+ "version": "2026.5.9-beta.1",
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
  },