@core-workspace/infoflow-openclaw-plugin 2026.3.8
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 +989 -0
- package/docs/architecture-data-flow.md +429 -0
- package/docs/architecture.md +423 -0
- package/docs/dev-guide.md +611 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +138 -0
- package/package.json +40 -0
- package/scripts/deploy.sh +34 -0
- package/skills/infoflow-dev/SKILL.md +88 -0
- package/skills/infoflow-dev/references/api.md +413 -0
- package/src/adapter/inbound/webhook-parser.ts +433 -0
- package/src/adapter/inbound/ws-receiver.ts +226 -0
- package/src/adapter/outbound/reply-dispatcher.ts +281 -0
- package/src/adapter/outbound/target-resolver.ts +109 -0
- package/src/channel/accounts.ts +164 -0
- package/src/channel/channel.ts +364 -0
- package/src/channel/media.ts +365 -0
- package/src/channel/monitor.ts +184 -0
- package/src/channel/outbound.ts +934 -0
- package/src/events.ts +62 -0
- package/src/handler/message-handler.ts +801 -0
- package/src/logging.ts +123 -0
- package/src/runtime.ts +14 -0
- package/src/security/dm-policy.ts +80 -0
- package/src/security/group-policy.ts +271 -0
- package/src/tools/actions/index.ts +456 -0
- package/src/tools/hooks/index.ts +82 -0
- package/src/tools/index.ts +277 -0
- package/src/types.ts +277 -0
- package/src/utils/store/message-store.ts +295 -0
- package/src/utils/token-adapter.ts +90 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infoflow channel message actions adapter.
|
|
3
|
+
* Intercepts the "send" action from the message tool to support
|
|
4
|
+
* @all and @user mentions in group messages.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "openclaw/plugin-sdk";
|
|
8
|
+
import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
|
|
9
|
+
import { resolveInfoflowAccount } from "../../channel/accounts.js";
|
|
10
|
+
import { logVerbose } from "../../logging.js";
|
|
11
|
+
import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "../../channel/media.js";
|
|
12
|
+
import {
|
|
13
|
+
sendInfoflowMessage,
|
|
14
|
+
recallInfoflowGroupMessage,
|
|
15
|
+
recallInfoflowPrivateMessage,
|
|
16
|
+
isLikelyLocalPath,
|
|
17
|
+
} from "../../channel/outbound.js";
|
|
18
|
+
import {
|
|
19
|
+
findSentMessage,
|
|
20
|
+
querySentMessages,
|
|
21
|
+
removeRecalledMessages,
|
|
22
|
+
} from "../../utils/store/message-store.js";
|
|
23
|
+
import { normalizeInfoflowTarget } from "../../adapter/outbound/target-resolver.js";
|
|
24
|
+
import type { InfoflowMessageContentItem, InfoflowOutboundReply } from "../../types.js";
|
|
25
|
+
|
|
26
|
+
// Recall result hint constants — reused across single/batch, group/private recall paths
|
|
27
|
+
const RECALL_OK_HINT = "Recall succeeded. output only NO_REPLY with no other text.";
|
|
28
|
+
const RECALL_FAIL_HINT = "Recall failed. Send a brief reply stating only the failure reason.";
|
|
29
|
+
const RECALL_PARTIAL_HINT =
|
|
30
|
+
"Some recalls failed. Send a brief reply stating only the failure reason(s).";
|
|
31
|
+
|
|
32
|
+
export const infoflowMessageActions: ChannelMessageActionAdapter = {
|
|
33
|
+
listActions: (): ChannelMessageActionName[] => ["send", "delete"],
|
|
34
|
+
|
|
35
|
+
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
|
36
|
+
|
|
37
|
+
handleAction: async ({ action, params, cfg, accountId }) => {
|
|
38
|
+
// -----------------------------------------------------------------------
|
|
39
|
+
// delete (群消息撤回) — Mode A: by messageId, Mode B: by count
|
|
40
|
+
// -----------------------------------------------------------------------
|
|
41
|
+
if (action === "delete") {
|
|
42
|
+
const rawTo = readStringParam(params, "to", { required: true });
|
|
43
|
+
if (!rawTo) {
|
|
44
|
+
throw new Error("delete requires a target (to).");
|
|
45
|
+
}
|
|
46
|
+
const to = normalizeInfoflowTarget(rawTo) ?? rawTo;
|
|
47
|
+
const target = to.replace(/^infoflow:/i, "");
|
|
48
|
+
|
|
49
|
+
const account = resolveInfoflowAccount({ cfg, accountId: accountId ?? undefined });
|
|
50
|
+
if (!account.config.appKey || !account.config.appSecret) {
|
|
51
|
+
throw new Error("Infoflow appKey/appSecret not configured.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const messageId = readStringParam(params, "messageId");
|
|
55
|
+
// Default to count=1 (recall latest message) when neither messageId nor count is provided
|
|
56
|
+
const countStr = readStringParam(params, "count") ?? (messageId ? undefined : "1");
|
|
57
|
+
|
|
58
|
+
const groupMatch = target.match(/^group:(\d+)/i);
|
|
59
|
+
|
|
60
|
+
if (groupMatch) {
|
|
61
|
+
// -----------------------------------------------------------------
|
|
62
|
+
// 群消息撤回
|
|
63
|
+
// -----------------------------------------------------------------
|
|
64
|
+
const groupId = Number(groupMatch[1]);
|
|
65
|
+
|
|
66
|
+
// Mode A: single message recall by messageId
|
|
67
|
+
if (messageId) {
|
|
68
|
+
let msgseqid = readStringParam(params, "msgseqid") ?? "";
|
|
69
|
+
if (!msgseqid) {
|
|
70
|
+
const stored = findSentMessage(account.accountId, messageId);
|
|
71
|
+
if (stored?.msgseqid) {
|
|
72
|
+
msgseqid = stored.msgseqid;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!msgseqid) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"delete requires msgseqid (not found in store; provide it explicitly or send messages first).",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await recallInfoflowGroupMessage({
|
|
82
|
+
account,
|
|
83
|
+
groupId,
|
|
84
|
+
messageid: messageId,
|
|
85
|
+
msgseqid,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (result.ok) {
|
|
89
|
+
try {
|
|
90
|
+
removeRecalledMessages(account.accountId, [messageId]);
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore cleanup errors
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return jsonResult({
|
|
97
|
+
ok: result.ok,
|
|
98
|
+
channel: "infoflow",
|
|
99
|
+
to,
|
|
100
|
+
...(result.error ? { error: result.error } : {}),
|
|
101
|
+
_hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Mode B: batch recall by count
|
|
106
|
+
if (countStr) {
|
|
107
|
+
const count = Number(countStr);
|
|
108
|
+
if (!Number.isFinite(count) || count < 1) {
|
|
109
|
+
throw new Error("count must be a positive integer.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const records = querySentMessages(account.accountId, {
|
|
113
|
+
target: `group:${groupId}`,
|
|
114
|
+
count,
|
|
115
|
+
});
|
|
116
|
+
// Filter to records that have msgseqid (required for group recall)
|
|
117
|
+
const recallable = records.filter((r) => r.msgseqid);
|
|
118
|
+
|
|
119
|
+
if (recallable.length === 0) {
|
|
120
|
+
return jsonResult({
|
|
121
|
+
ok: true,
|
|
122
|
+
channel: "infoflow",
|
|
123
|
+
to,
|
|
124
|
+
recalled: 0,
|
|
125
|
+
message: "No recallable messages found in store.",
|
|
126
|
+
_hint: "No messages found to recall. Briefly inform the user.",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let succeeded = 0;
|
|
131
|
+
let failed = 0;
|
|
132
|
+
const recalledIds: string[] = [];
|
|
133
|
+
const details: Array<{
|
|
134
|
+
messageid: string;
|
|
135
|
+
digest: string;
|
|
136
|
+
ok: boolean;
|
|
137
|
+
error?: string;
|
|
138
|
+
}> = [];
|
|
139
|
+
|
|
140
|
+
for (const record of recallable) {
|
|
141
|
+
const result = await recallInfoflowGroupMessage({
|
|
142
|
+
account,
|
|
143
|
+
groupId,
|
|
144
|
+
messageid: record.messageid,
|
|
145
|
+
msgseqid: record.msgseqid,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (result.ok) {
|
|
149
|
+
succeeded++;
|
|
150
|
+
recalledIds.push(record.messageid);
|
|
151
|
+
details.push({ messageid: record.messageid, digest: record.digest, ok: true });
|
|
152
|
+
} else {
|
|
153
|
+
failed++;
|
|
154
|
+
details.push({
|
|
155
|
+
messageid: record.messageid,
|
|
156
|
+
digest: record.digest,
|
|
157
|
+
ok: false,
|
|
158
|
+
error: result.error,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (recalledIds.length > 0) {
|
|
164
|
+
try {
|
|
165
|
+
removeRecalledMessages(account.accountId, recalledIds);
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore cleanup errors
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return jsonResult({
|
|
172
|
+
ok: failed === 0,
|
|
173
|
+
channel: "infoflow",
|
|
174
|
+
to,
|
|
175
|
+
recalled: succeeded,
|
|
176
|
+
failed,
|
|
177
|
+
total: recallable.length,
|
|
178
|
+
details,
|
|
179
|
+
_hint: failed === 0 ? RECALL_OK_HINT : RECALL_PARTIAL_HINT,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// -----------------------------------------------------------------
|
|
184
|
+
// 私聊消息撤回
|
|
185
|
+
// -----------------------------------------------------------------
|
|
186
|
+
const appAgentId = account.config.appAgentId;
|
|
187
|
+
if (!appAgentId) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"Infoflow private message recall requires appAgentId configuration. " +
|
|
190
|
+
"Set channels.infoflow.appAgentId to your application ID (如流企业后台的应用ID).",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Mode A: single message recall by messageId (msgkey)
|
|
195
|
+
if (messageId) {
|
|
196
|
+
const result = await recallInfoflowPrivateMessage({
|
|
197
|
+
account,
|
|
198
|
+
msgkey: messageId,
|
|
199
|
+
appAgentId,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (result.ok) {
|
|
203
|
+
try {
|
|
204
|
+
removeRecalledMessages(account.accountId, [messageId]);
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore cleanup errors
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return jsonResult({
|
|
211
|
+
ok: result.ok,
|
|
212
|
+
channel: "infoflow",
|
|
213
|
+
to,
|
|
214
|
+
...(result.error ? { error: result.error } : {}),
|
|
215
|
+
_hint: result.ok ? RECALL_OK_HINT : RECALL_FAIL_HINT,
|
|
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: failed === 0 ? RECALL_OK_HINT : RECALL_PARTIAL_HINT,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// -----------------------------------------------------------------------
|
|
296
|
+
// send
|
|
297
|
+
// -----------------------------------------------------------------------
|
|
298
|
+
if (action !== "send") {
|
|
299
|
+
throw new Error(`Action "${action}" is not supported for Infoflow.`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const account = resolveInfoflowAccount({ cfg, accountId: accountId ?? undefined });
|
|
303
|
+
if (!account.config.appKey || !account.config.appSecret) {
|
|
304
|
+
throw new Error("Infoflow appKey/appSecret not configured.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const rawTo = readStringParam(params, "to", { required: true });
|
|
308
|
+
if (!rawTo) {
|
|
309
|
+
throw new Error("send requires a target (to).");
|
|
310
|
+
}
|
|
311
|
+
const to = normalizeInfoflowTarget(rawTo) ?? rawTo;
|
|
312
|
+
const message = readStringParam(params, "message", { required: false, allowEmpty: true }) ?? "";
|
|
313
|
+
const mediaUrl = readStringParam(params, "media", { trim: false });
|
|
314
|
+
|
|
315
|
+
// Log sendMessage action call
|
|
316
|
+
logVerbose(`[DEBUG actions:send] action=send, to=${to}, message="${message}", mediaUrl="${mediaUrl}"`);
|
|
317
|
+
|
|
318
|
+
// Infoflow-specific mention params
|
|
319
|
+
const atAll = params.atAll === true || params.atAll === "true";
|
|
320
|
+
const mentionUserIdsRaw = readStringParam(params, "mentionUserIds");
|
|
321
|
+
|
|
322
|
+
const isGroup = /^group:\d+$/i.test(to);
|
|
323
|
+
const contents: InfoflowMessageContentItem[] = [];
|
|
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
|
+
|
|
338
|
+
// Build AT content nodes (group messages only)
|
|
339
|
+
if (isGroup) {
|
|
340
|
+
if (atAll) {
|
|
341
|
+
contents.push({ type: "at", content: "all" });
|
|
342
|
+
} else if (mentionUserIdsRaw) {
|
|
343
|
+
const userIds = mentionUserIdsRaw
|
|
344
|
+
.split(",")
|
|
345
|
+
.map((s) => s.trim())
|
|
346
|
+
.filter(Boolean);
|
|
347
|
+
if (userIds.length > 0) {
|
|
348
|
+
contents.push({ type: "at", content: userIds.join(",") });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Prepend @all/@user prefix to display text (same pattern as reply-dispatcher.ts)
|
|
354
|
+
let messageText = message;
|
|
355
|
+
if (isGroup) {
|
|
356
|
+
if (atAll) {
|
|
357
|
+
messageText = `@all ${message}`;
|
|
358
|
+
} else if (mentionUserIdsRaw) {
|
|
359
|
+
const userIds = mentionUserIdsRaw
|
|
360
|
+
.split(",")
|
|
361
|
+
.map((s) => s.trim())
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
if (userIds.length > 0) {
|
|
364
|
+
const prefix = userIds.map((id) => `@${id}`).join(" ");
|
|
365
|
+
messageText = `${prefix} ${message}`;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (messageText.trim()) {
|
|
371
|
+
contents.push({ type: "markdown", content: messageText });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (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 paths = isLikelyLocalPath(mediaUrl)?[mediaUrl]:undefined;
|
|
393
|
+
const prepared = await prepareInfoflowImageBase64({ mediaUrl, mediaLocalRoots:paths });
|
|
394
|
+
logVerbose(`prepareInfoflowImageBase64 result: isImage=${prepared.isImage}`);
|
|
395
|
+
if (prepared.isImage) {
|
|
396
|
+
const imgResult = await sendInfoflowImageMessage({
|
|
397
|
+
cfg,
|
|
398
|
+
to,
|
|
399
|
+
base64Image: prepared.base64,
|
|
400
|
+
accountId: accountId ?? undefined,
|
|
401
|
+
replyTo: contents.length > 0 ? undefined : replyTo,
|
|
402
|
+
});
|
|
403
|
+
return jsonResult({
|
|
404
|
+
ok: imgResult.ok,
|
|
405
|
+
channel: "infoflow",
|
|
406
|
+
to,
|
|
407
|
+
messageId: imgResult.messageId ?? (imgResult.ok ? "sent" : "failed"),
|
|
408
|
+
...(imgResult.error ? { error: imgResult.error } : {}),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
} catch (e) {
|
|
412
|
+
logVerbose(`[DEBUG actions:send] prepareInfoflowImageBase64 error: ${e}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Non-image or native send failed → send as link
|
|
416
|
+
const linkResult = await sendInfoflowMessage({
|
|
417
|
+
cfg,
|
|
418
|
+
to,
|
|
419
|
+
contents: [{ type: "link", content: mediaUrl }],
|
|
420
|
+
accountId: accountId ?? undefined,
|
|
421
|
+
replyTo: contents.length > 0 ? undefined : replyTo,
|
|
422
|
+
});
|
|
423
|
+
return jsonResult({
|
|
424
|
+
ok: linkResult.ok,
|
|
425
|
+
channel: "infoflow",
|
|
426
|
+
to,
|
|
427
|
+
messageId: linkResult.messageId ?? (linkResult.ok ? "sent" : "failed"),
|
|
428
|
+
...(linkResult.error ? { error: linkResult.error } : {}),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (contents.length === 0) {
|
|
433
|
+
throw new Error("send requires text or media");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
logVerbose(
|
|
437
|
+
`[infoflow:action:send] to=${to}, atAll=${atAll}, mentionUserIds=${mentionUserIdsRaw ?? "none"}`,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const result = await sendInfoflowMessage({
|
|
441
|
+
cfg,
|
|
442
|
+
to,
|
|
443
|
+
contents,
|
|
444
|
+
accountId: accountId ?? undefined,
|
|
445
|
+
replyTo,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return jsonResult({
|
|
449
|
+
ok: result.ok,
|
|
450
|
+
channel: "infoflow",
|
|
451
|
+
to,
|
|
452
|
+
messageId: result.messageId ?? (result.ok ? "sent" : "failed"),
|
|
453
|
+
...(result.error ? { error: result.error } : {}),
|
|
454
|
+
});
|
|
455
|
+
},
|
|
456
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infoflow Agent Hooks(生命周期钩子)
|
|
3
|
+
*
|
|
4
|
+
* 通过 api.on() 注册 OpenClaw Agent 生命周期钩子,
|
|
5
|
+
* 在 Agent 启动前向 system prompt 注入如流平台背景知识,
|
|
6
|
+
* 让 LLM 无需用户解释就能理解如流的功能和配置。
|
|
7
|
+
*
|
|
8
|
+
* 目前注册的 hook:
|
|
9
|
+
* - before_agent_start:每次 Agent 运行时触发,追加如流平台介绍到 system prompt。
|
|
10
|
+
* 注入内容会被 server 端缓存(prompt caching),首次之后不产生额外 token 消耗。
|
|
11
|
+
*
|
|
12
|
+
* 新增 hook 的方法:
|
|
13
|
+
* 1. 编写一个返回字符串的函数作为注入内容。
|
|
14
|
+
* 2. 在 registerInfoflowHooks 中调用 api.on("before_agent_start", ...) 注册。
|
|
15
|
+
* 3. 如果是可选功能,可以加配置开关。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Hook: infoflow-intro(注入如流平台背景知识)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 构建注入 system prompt 的如流平台介绍文本。
|
|
26
|
+
* 抽为独立函数便于单独测试。
|
|
27
|
+
*/
|
|
28
|
+
export function buildInfoflowIntroContext(): string {
|
|
29
|
+
return `
|
|
30
|
+
## 如流 (Infoflow) 平台 — 背景知识
|
|
31
|
+
|
|
32
|
+
如流是百度自研的企业即时通讯平台,运行于百度内网,类似飞书/钉钉。
|
|
33
|
+
当前 OpenClaw Agent 通过如流 Bot(机器人)与用户通信,具备以下能力:
|
|
34
|
+
|
|
35
|
+
### 会话类型
|
|
36
|
+
- **私聊 (DM)**:用户直接与机器人一对一对话。
|
|
37
|
+
- **群聊 (Group)**:机器人加入多人群,通过 @mention 触发回复,也支持主动发送通知。
|
|
38
|
+
|
|
39
|
+
### 消息格式
|
|
40
|
+
- **text**:纯文本,所有终端兼容。
|
|
41
|
+
- **markdown (MD)**:支持标题、加粗、代码块、列表(仅群聊支持引用回复时请用 text 格式)。
|
|
42
|
+
|
|
43
|
+
### Bot 能力
|
|
44
|
+
- **infoflow_send 工具**:主动向用户或群发送消息,支持 Markdown 和 @mention。
|
|
45
|
+
- **infoflow_recall 工具**:撤回近期发出的消息(群消息需 msgseqid,私聊需 appAgentId)。
|
|
46
|
+
- **@mention**:群消息中可 @全体成员 或 @指定成员(uuapName/邮箱前缀)。
|
|
47
|
+
- **消息撤回**:群消息 2 分钟内可撤回;私聊消息撤回需配置 appAgentId。
|
|
48
|
+
|
|
49
|
+
### 典型使用场景
|
|
50
|
+
- 收到用户提问后,通过私聊回复详细答案。
|
|
51
|
+
- 在指定群中发送项目进度通知,并 @相关人员。
|
|
52
|
+
- 定时播报天气、日程、监控告警等信息。
|
|
53
|
+
- 撤回误发的消息。
|
|
54
|
+
|
|
55
|
+
### 消息访问控制
|
|
56
|
+
- 私聊:通过 dmPolicy 控制(open / pairing / allowlist)。
|
|
57
|
+
- 群聊:通过 groupPolicy 控制(open / allowlist / disabled)。
|
|
58
|
+
- 白名单通过 allowFrom / groupAllowFrom 配置。
|
|
59
|
+
|
|
60
|
+
> 注意:如流消息 API 使用非标准 Bearer 格式(Bearer-<token>,含连字符),
|
|
61
|
+
> 且大整数 messageid / msgseqid 需保留原始字符串精度。
|
|
62
|
+
`.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// 注册函数
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 向 OpenClaw 插件 API 注册如流生命周期钩子。
|
|
71
|
+
* 在插件的 register() 函数中调用。
|
|
72
|
+
*/
|
|
73
|
+
export function registerInfoflowHooks(api: OpenClawPluginApi): void {
|
|
74
|
+
// 每次 Agent 启动前,将如流平台介绍追加到 system prompt
|
|
75
|
+
api.on("before_agent_start", (_event, _ctx) => {
|
|
76
|
+
return {
|
|
77
|
+
appendSystemContext: buildInfoflowIntroContext(),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
api.logger.info?.("infoflow_hooks: Registered before_agent_start hook (infoflow-intro)");
|
|
82
|
+
}
|