@chbo297/infoflow 2026.3.4 → 2026.3.6

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/src/actions.ts CHANGED
@@ -8,16 +8,296 @@ import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "open
8
8
  import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
9
9
  import { resolveInfoflowAccount } from "./accounts.js";
10
10
  import { logVerbose } from "./logging.js";
11
- import { sendInfoflowMessage } from "./send.js";
11
+ import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
12
+ import {
13
+ sendInfoflowMessage,
14
+ recallInfoflowGroupMessage,
15
+ recallInfoflowPrivateMessage,
16
+ } from "./send.js";
17
+ import {
18
+ findSentMessage,
19
+ querySentMessages,
20
+ removeRecalledMessages,
21
+ } from "./sent-message-store.js";
12
22
  import { normalizeInfoflowTarget } from "./targets.js";
13
- import type { InfoflowMessageContentItem } from "./types.js";
23
+ import type { InfoflowMessageContentItem, InfoflowOutboundReply } from "./types.js";
14
24
 
15
25
  export const infoflowMessageActions: ChannelMessageActionAdapter = {
16
- listActions: (): ChannelMessageActionName[] => ["send"],
26
+ listActions: (): ChannelMessageActionName[] => ["send", "delete"],
17
27
 
18
28
  extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
19
29
 
20
30
  handleAction: async ({ action, params, cfg, accountId }) => {
31
+ // -----------------------------------------------------------------------
32
+ // delete (群消息撤回) — Mode A: by messageId, Mode B: by count
33
+ // -----------------------------------------------------------------------
34
+ if (action === "delete") {
35
+ const rawTo = readStringParam(params, "to", { required: true });
36
+ if (!rawTo) {
37
+ throw new Error("delete requires a target (to).");
38
+ }
39
+ const to = normalizeInfoflowTarget(rawTo) ?? rawTo;
40
+ const target = to.replace(/^infoflow:/i, "");
41
+
42
+ const account = resolveInfoflowAccount({ cfg, accountId: accountId ?? undefined });
43
+ if (!account.config.appKey || !account.config.appSecret) {
44
+ throw new Error("Infoflow appKey/appSecret not configured.");
45
+ }
46
+
47
+ const messageId = readStringParam(params, "messageId");
48
+ // Default to count=1 (recall latest message) when neither messageId nor count is provided
49
+ const countStr = readStringParam(params, "count") ?? (messageId ? undefined : "1");
50
+
51
+ const groupMatch = target.match(/^group:(\d+)/i);
52
+
53
+ if (groupMatch) {
54
+ // -----------------------------------------------------------------
55
+ // 群消息撤回
56
+ // -----------------------------------------------------------------
57
+ const groupId = Number(groupMatch[1]);
58
+
59
+ // Mode A: single message recall by messageId
60
+ if (messageId) {
61
+ let msgseqid = readStringParam(params, "msgseqid") ?? "";
62
+ if (!msgseqid) {
63
+ const stored = findSentMessage(account.accountId, messageId);
64
+ if (stored?.msgseqid) {
65
+ msgseqid = stored.msgseqid;
66
+ }
67
+ }
68
+ if (!msgseqid) {
69
+ throw new Error(
70
+ "delete requires msgseqid (not found in store; provide it explicitly or send messages first).",
71
+ );
72
+ }
73
+
74
+ const result = await recallInfoflowGroupMessage({
75
+ account,
76
+ groupId,
77
+ messageid: messageId,
78
+ msgseqid,
79
+ });
80
+
81
+ if (result.ok) {
82
+ try {
83
+ removeRecalledMessages(account.accountId, [messageId]);
84
+ } catch {
85
+ // ignore cleanup errors
86
+ }
87
+ }
88
+
89
+ return jsonResult({
90
+ ok: result.ok,
91
+ channel: "infoflow",
92
+ to,
93
+ ...(result.error ? { error: result.error } : {}),
94
+ _hint: result.ok
95
+ ? "Recall succeeded. Do NOT send any follow-up reply message to the user."
96
+ : "Recall failed. Send a brief reply stating only the failure reason.",
97
+ });
98
+ }
99
+
100
+ // Mode B: batch recall by count
101
+ if (countStr) {
102
+ const count = Number(countStr);
103
+ if (!Number.isFinite(count) || count < 1) {
104
+ throw new Error("count must be a positive integer.");
105
+ }
106
+
107
+ const records = querySentMessages(account.accountId, {
108
+ target: `group:${groupId}`,
109
+ count,
110
+ });
111
+ // Filter to records that have msgseqid (required for group recall)
112
+ const recallable = records.filter((r) => r.msgseqid);
113
+
114
+ if (recallable.length === 0) {
115
+ return jsonResult({
116
+ ok: true,
117
+ channel: "infoflow",
118
+ to,
119
+ recalled: 0,
120
+ message: "No recallable messages found in store.",
121
+ _hint: "No messages found to recall. Briefly inform the user.",
122
+ });
123
+ }
124
+
125
+ let succeeded = 0;
126
+ let failed = 0;
127
+ const recalledIds: string[] = [];
128
+ const details: Array<{
129
+ messageid: string;
130
+ digest: string;
131
+ ok: boolean;
132
+ error?: string;
133
+ }> = [];
134
+
135
+ for (const record of recallable) {
136
+ const result = await recallInfoflowGroupMessage({
137
+ account,
138
+ groupId,
139
+ messageid: record.messageid,
140
+ msgseqid: record.msgseqid,
141
+ });
142
+
143
+ if (result.ok) {
144
+ succeeded++;
145
+ recalledIds.push(record.messageid);
146
+ details.push({ messageid: record.messageid, digest: record.digest, ok: true });
147
+ } else {
148
+ failed++;
149
+ details.push({
150
+ messageid: record.messageid,
151
+ digest: record.digest,
152
+ ok: false,
153
+ error: result.error,
154
+ });
155
+ }
156
+ }
157
+
158
+ if (recalledIds.length > 0) {
159
+ try {
160
+ removeRecalledMessages(account.accountId, recalledIds);
161
+ } catch {
162
+ // ignore cleanup errors
163
+ }
164
+ }
165
+
166
+ return jsonResult({
167
+ ok: failed === 0,
168
+ channel: "infoflow",
169
+ to,
170
+ recalled: succeeded,
171
+ failed,
172
+ total: recallable.length,
173
+ details,
174
+ _hint:
175
+ failed === 0
176
+ ? "Recall succeeded. Do NOT send any follow-up reply message to the user."
177
+ : "Some recalls failed. Send a brief reply stating only the failure reason(s).",
178
+ });
179
+ }
180
+ } else {
181
+ // -----------------------------------------------------------------
182
+ // 私聊消息撤回
183
+ // -----------------------------------------------------------------
184
+ const appAgentId = account.config.appAgentId;
185
+ if (!appAgentId) {
186
+ throw new Error(
187
+ "Infoflow private message recall requires appAgentId configuration. " +
188
+ "Set channels.infoflow.appAgentId to your application ID (如流企业后台的应用ID).",
189
+ );
190
+ }
191
+
192
+ // Mode A: single message recall by messageId (msgkey)
193
+ if (messageId) {
194
+ const result = await recallInfoflowPrivateMessage({
195
+ account,
196
+ msgkey: messageId,
197
+ appAgentId,
198
+ });
199
+
200
+ if (result.ok) {
201
+ try {
202
+ removeRecalledMessages(account.accountId, [messageId]);
203
+ } catch {
204
+ // ignore cleanup errors
205
+ }
206
+ }
207
+
208
+ return jsonResult({
209
+ ok: result.ok,
210
+ channel: "infoflow",
211
+ to,
212
+ ...(result.error ? { error: result.error } : {}),
213
+ _hint: result.ok
214
+ ? "Recall succeeded. Do NOT send any follow-up reply message to the user."
215
+ : "Recall failed. Send a brief reply stating only the failure reason.",
216
+ });
217
+ }
218
+
219
+ // Mode B: batch recall by count
220
+ if (countStr) {
221
+ const count = Number(countStr);
222
+ if (!Number.isFinite(count) || count < 1) {
223
+ throw new Error("count must be a positive integer.");
224
+ }
225
+
226
+ const records = querySentMessages(account.accountId, { target, count });
227
+ // 私聊消息的 msgseqid 为空,只需要有 messageid (即 msgkey) 即可撤回
228
+ const recallable = records.filter((r) => r.messageid);
229
+
230
+ if (recallable.length === 0) {
231
+ return jsonResult({
232
+ ok: true,
233
+ channel: "infoflow",
234
+ to,
235
+ recalled: 0,
236
+ message: "No recallable messages found in store.",
237
+ _hint: "No messages found to recall. Briefly inform the user.",
238
+ });
239
+ }
240
+
241
+ let succeeded = 0;
242
+ let failed = 0;
243
+ const recalledIds: string[] = [];
244
+ const details: Array<{
245
+ messageid: string;
246
+ digest: string;
247
+ ok: boolean;
248
+ error?: string;
249
+ }> = [];
250
+
251
+ for (const record of recallable) {
252
+ const result = await recallInfoflowPrivateMessage({
253
+ account,
254
+ msgkey: record.messageid,
255
+ appAgentId,
256
+ });
257
+
258
+ if (result.ok) {
259
+ succeeded++;
260
+ recalledIds.push(record.messageid);
261
+ details.push({ messageid: record.messageid, digest: record.digest, ok: true });
262
+ } else {
263
+ failed++;
264
+ details.push({
265
+ messageid: record.messageid,
266
+ digest: record.digest,
267
+ ok: false,
268
+ error: result.error,
269
+ });
270
+ }
271
+ }
272
+
273
+ if (recalledIds.length > 0) {
274
+ try {
275
+ removeRecalledMessages(account.accountId, recalledIds);
276
+ } catch {
277
+ // ignore cleanup errors
278
+ }
279
+ }
280
+
281
+ return jsonResult({
282
+ ok: failed === 0,
283
+ channel: "infoflow",
284
+ to,
285
+ recalled: succeeded,
286
+ failed,
287
+ total: recallable.length,
288
+ details,
289
+ _hint:
290
+ failed === 0
291
+ ? "Recall succeeded. Do NOT send any follow-up reply message to the user."
292
+ : "Some recalls failed. Send a brief reply stating only the failure reason(s).",
293
+ });
294
+ }
295
+ }
296
+ }
297
+
298
+ // -----------------------------------------------------------------------
299
+ // send
300
+ // -----------------------------------------------------------------------
21
301
  if (action !== "send") {
22
302
  throw new Error(`Action "${action}" is not supported for Infoflow.`);
23
303
  }
@@ -42,6 +322,19 @@ export const infoflowMessageActions: ChannelMessageActionAdapter = {
42
322
  const isGroup = /^group:\d+$/i.test(to);
43
323
  const contents: InfoflowMessageContentItem[] = [];
44
324
 
325
+ // Infoflow reply-to params (group only)
326
+ const replyToMessageId = readStringParam(params, "replyToMessageId");
327
+ const replyToPreview = readStringParam(params, "replyToPreview");
328
+ const replyTypeRaw = readStringParam(params, "replyType");
329
+ const replyTo: InfoflowOutboundReply | undefined =
330
+ replyToMessageId && isGroup
331
+ ? {
332
+ messageid: replyToMessageId,
333
+ preview: replyToPreview ?? undefined,
334
+ replytype: replyTypeRaw === "2" ? "2" : "1",
335
+ }
336
+ : undefined;
337
+
45
338
  // Build AT content nodes (group messages only)
46
339
  if (isGroup) {
47
340
  if (atAll) {
@@ -79,7 +372,59 @@ export const infoflowMessageActions: ChannelMessageActionAdapter = {
79
372
  }
80
373
 
81
374
  if (mediaUrl) {
82
- contents.push({ type: "link", content: mediaUrl });
375
+ logVerbose(
376
+ `[infoflow:action:send] to=${to}, atAll=${atAll}, mentionUserIds=${mentionUserIdsRaw ?? "none"}`,
377
+ );
378
+
379
+ // Send text+mentions first (if any)
380
+ if (contents.length > 0) {
381
+ await sendInfoflowMessage({
382
+ cfg,
383
+ to,
384
+ contents,
385
+ accountId: accountId ?? undefined,
386
+ replyTo,
387
+ });
388
+ }
389
+
390
+ // Try native image send, fallback to link
391
+ try {
392
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl });
393
+ if (prepared.isImage) {
394
+ const imgResult = await sendInfoflowImageMessage({
395
+ cfg,
396
+ to,
397
+ base64Image: prepared.base64,
398
+ accountId: accountId ?? undefined,
399
+ replyTo: contents.length > 0 ? undefined : replyTo,
400
+ });
401
+ return jsonResult({
402
+ ok: imgResult.ok,
403
+ channel: "infoflow",
404
+ to,
405
+ messageId: imgResult.messageId ?? (imgResult.ok ? "sent" : "failed"),
406
+ ...(imgResult.error ? { error: imgResult.error } : {}),
407
+ });
408
+ }
409
+ } catch {
410
+ // fallback to link below
411
+ }
412
+
413
+ // Non-image or native send failed → send as link
414
+ const linkResult = await sendInfoflowMessage({
415
+ cfg,
416
+ to,
417
+ contents: [{ type: "link", content: mediaUrl }],
418
+ accountId: accountId ?? undefined,
419
+ replyTo: contents.length > 0 ? undefined : replyTo,
420
+ });
421
+ return jsonResult({
422
+ ok: linkResult.ok,
423
+ channel: "infoflow",
424
+ to,
425
+ messageId: linkResult.messageId ?? (linkResult.ok ? "sent" : "failed"),
426
+ ...(linkResult.error ? { error: linkResult.error } : {}),
427
+ });
83
428
  }
84
429
 
85
430
  if (contents.length === 0) {
@@ -95,6 +440,7 @@ export const infoflowMessageActions: ChannelMessageActionAdapter = {
95
440
  to,
96
441
  contents,
97
442
  accountId: accountId ?? undefined,
443
+ replyTo,
98
444
  });
99
445
 
100
446
  return jsonResult({