@chbo297/infoflow 2026.3.2 → 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/README.md +426 -15
- package/index.ts +6 -1
- package/openclaw.plugin.json +14 -2
- package/package.json +2 -2
- package/src/accounts.ts +3 -0
- package/src/actions.ts +350 -4
- package/src/bot.ts +357 -14
- package/src/channel.ts +64 -23
- package/src/infoflow-req-parse.ts +0 -1
- package/src/media.ts +367 -0
- package/src/monitor.ts +1 -1
- package/src/reply-dispatcher.ts +157 -67
- package/src/send.ts +497 -57
- package/src/sent-message-store.ts +238 -0
- package/src/types.ts +27 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed persistent store for sent message IDs.
|
|
3
|
+
* Records messageid + msgseqid for every outbound message so that
|
|
4
|
+
* recall (撤回) can look up any sub-message, including those from split sends.
|
|
5
|
+
*
|
|
6
|
+
* Uses Node 22+ built-in `node:sqlite` (DatabaseSync, synchronous API).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { getInfoflowRuntime } from "./runtime.js";
|
|
15
|
+
import type { InfoflowMessageContentItem } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Constants
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const DB_FILENAME = "sent-messages.db";
|
|
24
|
+
const AUTO_CLEANUP_DAYS = 7;
|
|
25
|
+
const AUTO_CLEANUP_MS = AUTO_CLEANUP_DAYS * 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// DB singleton (lazy-init)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
let db: DatabaseSync | null = null;
|
|
32
|
+
|
|
33
|
+
function resolveDbPath(): string {
|
|
34
|
+
const env = process.env;
|
|
35
|
+
const stateDir = getInfoflowRuntime().state.resolveStateDir(env, os.homedir);
|
|
36
|
+
return path.join(stateDir, "infoflow", DB_FILENAME);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDb(): DatabaseSync {
|
|
40
|
+
if (db) return db;
|
|
41
|
+
|
|
42
|
+
const dbPath = resolveDbPath();
|
|
43
|
+
const dir = path.dirname(dbPath);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
|
|
46
|
+
const sqlite = require("node:sqlite") as typeof import("node:sqlite");
|
|
47
|
+
db = new sqlite.DatabaseSync(dbPath);
|
|
48
|
+
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS sent_messages (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
account_id TEXT NOT NULL,
|
|
53
|
+
target TEXT NOT NULL,
|
|
54
|
+
messageid TEXT NOT NULL,
|
|
55
|
+
msgseqid TEXT NOT NULL DEFAULT '',
|
|
56
|
+
digest TEXT NOT NULL DEFAULT '',
|
|
57
|
+
sent_at INTEGER NOT NULL
|
|
58
|
+
);
|
|
59
|
+
`);
|
|
60
|
+
db.exec(`
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_target_sent
|
|
62
|
+
ON sent_messages(account_id, target, sent_at DESC);
|
|
63
|
+
`);
|
|
64
|
+
|
|
65
|
+
return db;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Public API
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export type SentMessageRecord = {
|
|
73
|
+
target: string;
|
|
74
|
+
messageid: string;
|
|
75
|
+
msgseqid: string;
|
|
76
|
+
digest: string;
|
|
77
|
+
sentAt: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Records a sent message. Also runs auto-cleanup of old records.
|
|
82
|
+
* Synchronous (DatabaseSync); failures are swallowed so sending is never blocked.
|
|
83
|
+
*/
|
|
84
|
+
export function recordSentMessage(accountId: string, record: SentMessageRecord): void {
|
|
85
|
+
try {
|
|
86
|
+
const d = getDb();
|
|
87
|
+
d.prepare(
|
|
88
|
+
`INSERT INTO sent_messages (account_id, target, messageid, msgseqid, digest, sent_at)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
90
|
+
).run(
|
|
91
|
+
accountId,
|
|
92
|
+
record.target,
|
|
93
|
+
record.messageid,
|
|
94
|
+
record.msgseqid,
|
|
95
|
+
record.digest,
|
|
96
|
+
record.sentAt,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Auto-cleanup: delete records older than 7 days
|
|
100
|
+
const cutoff = Date.now() - AUTO_CLEANUP_MS;
|
|
101
|
+
d.prepare(`DELETE FROM sent_messages WHERE sent_at < ? AND account_id = ?`).run(
|
|
102
|
+
cutoff,
|
|
103
|
+
accountId,
|
|
104
|
+
);
|
|
105
|
+
} catch {
|
|
106
|
+
// Silently ignore — do not block sending
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Queries the most recent N sent messages for a given target, ordered by sent_at DESC.
|
|
112
|
+
*/
|
|
113
|
+
export function querySentMessages(
|
|
114
|
+
accountId: string,
|
|
115
|
+
params: { target: string; count: number },
|
|
116
|
+
): SentMessageRecord[] {
|
|
117
|
+
const d = getDb();
|
|
118
|
+
const rows = d
|
|
119
|
+
.prepare(
|
|
120
|
+
`SELECT target, messageid, msgseqid, digest, sent_at
|
|
121
|
+
FROM sent_messages
|
|
122
|
+
WHERE account_id = ? AND target = ?
|
|
123
|
+
ORDER BY sent_at DESC
|
|
124
|
+
LIMIT ?`,
|
|
125
|
+
)
|
|
126
|
+
.all(accountId, params.target, params.count) as Array<{
|
|
127
|
+
target: string;
|
|
128
|
+
messageid: string;
|
|
129
|
+
msgseqid: string;
|
|
130
|
+
digest: string;
|
|
131
|
+
sent_at: number;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
134
|
+
return rows.map((r) => ({
|
|
135
|
+
target: r.target,
|
|
136
|
+
messageid: r.messageid,
|
|
137
|
+
msgseqid: r.msgseqid,
|
|
138
|
+
digest: r.digest,
|
|
139
|
+
sentAt: r.sent_at,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Finds a single sent message by messageid.
|
|
145
|
+
*/
|
|
146
|
+
export function findSentMessage(
|
|
147
|
+
accountId: string,
|
|
148
|
+
messageid: string,
|
|
149
|
+
): SentMessageRecord | undefined {
|
|
150
|
+
const d = getDb();
|
|
151
|
+
const row = d
|
|
152
|
+
.prepare(
|
|
153
|
+
`SELECT target, messageid, msgseqid, digest, sent_at
|
|
154
|
+
FROM sent_messages
|
|
155
|
+
WHERE account_id = ? AND messageid = ?
|
|
156
|
+
LIMIT 1`,
|
|
157
|
+
)
|
|
158
|
+
.get(accountId, messageid) as
|
|
159
|
+
| { target: string; messageid: string; msgseqid: string; digest: string; sent_at: number }
|
|
160
|
+
| undefined;
|
|
161
|
+
|
|
162
|
+
if (!row) return undefined;
|
|
163
|
+
return {
|
|
164
|
+
target: row.target,
|
|
165
|
+
messageid: row.messageid,
|
|
166
|
+
msgseqid: row.msgseqid,
|
|
167
|
+
digest: row.digest,
|
|
168
|
+
sentAt: row.sent_at,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Removes recalled messages from the store by their messageids.
|
|
174
|
+
*/
|
|
175
|
+
export function removeRecalledMessages(accountId: string, messageids: string[]): void {
|
|
176
|
+
if (messageids.length === 0) return;
|
|
177
|
+
const d = getDb();
|
|
178
|
+
const placeholders = messageids.map(() => "?").join(",");
|
|
179
|
+
d.prepare(
|
|
180
|
+
`DELETE FROM sent_messages WHERE account_id = ? AND messageid IN (${placeholders})`,
|
|
181
|
+
).run(accountId, ...messageids);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Digest builder
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
const DIGEST_MAX_LEN = 100;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Builds a short digest string from message contents.
|
|
192
|
+
* - Text/markdown/link: first 100 chars, truncated with "…"
|
|
193
|
+
* - Image only: "image"
|
|
194
|
+
* - Empty: ""
|
|
195
|
+
*/
|
|
196
|
+
export function buildMessageDigest(contents: InfoflowMessageContentItem[]): string {
|
|
197
|
+
const textParts: string[] = [];
|
|
198
|
+
let hasImage = false;
|
|
199
|
+
|
|
200
|
+
for (const item of contents) {
|
|
201
|
+
const type = item.type.toLowerCase();
|
|
202
|
+
if (type === "text" || type === "md" || type === "markdown") {
|
|
203
|
+
textParts.push(item.content);
|
|
204
|
+
} else if (type === "link") {
|
|
205
|
+
textParts.push(item.content);
|
|
206
|
+
} else if (type === "image") {
|
|
207
|
+
hasImage = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const merged = textParts.join(" ").trim();
|
|
212
|
+
if (merged) {
|
|
213
|
+
return merged.length > DIGEST_MAX_LEN ? merged.slice(0, DIGEST_MAX_LEN) + "…" : merged;
|
|
214
|
+
}
|
|
215
|
+
if (hasImage) return "image";
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Test-only exports (@internal)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** @internal — Close and reset the DB singleton. Only use in tests. */
|
|
224
|
+
export function _resetStore(): void {
|
|
225
|
+
if (db) {
|
|
226
|
+
try {
|
|
227
|
+
db.close();
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore
|
|
230
|
+
}
|
|
231
|
+
db = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @internal — Override the DB instance for testing. */
|
|
236
|
+
export function _setDb(next: DatabaseSync): void {
|
|
237
|
+
db = next;
|
|
238
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type InfoflowGroupConfig = {
|
|
|
25
25
|
followUp?: boolean;
|
|
26
26
|
followUpWindow?: number;
|
|
27
27
|
systemPrompt?: string;
|
|
28
|
+
/** Enable thinking indicator ("收到🤔...") before processing (default: true) */
|
|
29
|
+
thinkingIndicator?: boolean;
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
@@ -42,6 +44,8 @@ export type InfoflowInboundBodyItem = {
|
|
|
42
44
|
name?: string;
|
|
43
45
|
/** 人类用户 AT 时有此字段(uuap name),与 robotid 互斥 */
|
|
44
46
|
userid?: string;
|
|
47
|
+
/** IMAGE 类型 body item 的图片下载地址 */
|
|
48
|
+
downloadurl?: string;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
/** Mention IDs extracted from inbound group AT items (excluding the bot itself) */
|
|
@@ -69,14 +73,25 @@ export type InfoflowGroupMessageBodyItem =
|
|
|
69
73
|
| { type: "TEXT"; content: string }
|
|
70
74
|
| { type: "MD"; content: string }
|
|
71
75
|
| { type: "AT"; atall?: boolean; atuserids: string[]; atagentids?: number[] }
|
|
72
|
-
| { type: "LINK"; href: string }
|
|
76
|
+
| { type: "LINK"; href: string }
|
|
77
|
+
| { type: "IMAGE"; content: string };
|
|
73
78
|
|
|
74
79
|
/** Content item for sendInfoflowMessage */
|
|
75
80
|
export type InfoflowMessageContentItem = {
|
|
76
|
-
type: "text" | "markdown" | "at" | "at-agent" | "link";
|
|
81
|
+
type: "text" | "markdown" | "at" | "at-agent" | "link" | "image";
|
|
77
82
|
content: string;
|
|
78
83
|
};
|
|
79
84
|
|
|
85
|
+
/** Outbound reply/quote context for group messages */
|
|
86
|
+
export type InfoflowOutboundReply = {
|
|
87
|
+
/** Message ID of the message being replied to (string to preserve large integer precision) */
|
|
88
|
+
messageid: string;
|
|
89
|
+
/** Preview text of the quoted message */
|
|
90
|
+
preview?: string;
|
|
91
|
+
/** "1" = reply (default), "2" = quote */
|
|
92
|
+
replytype?: "1" | "2";
|
|
93
|
+
};
|
|
94
|
+
|
|
80
95
|
// ---------------------------------------------------------------------------
|
|
81
96
|
// Account configuration
|
|
82
97
|
// ---------------------------------------------------------------------------
|
|
@@ -105,6 +120,10 @@ export type InfoflowAccountConfig = {
|
|
|
105
120
|
followUp?: boolean;
|
|
106
121
|
/** Follow-up window in seconds after last bot reply (default: 300) */
|
|
107
122
|
followUpWindow?: number;
|
|
123
|
+
/** Enable thinking indicator ("收到🤔...") before processing (default: true) */
|
|
124
|
+
thinkingIndicator?: boolean;
|
|
125
|
+
/** 如流企业后台的应用ID(私聊消息撤回依赖此字段) */
|
|
126
|
+
appAgentId?: number;
|
|
108
127
|
/** Per-group configuration overrides, keyed by group ID */
|
|
109
128
|
groups?: Record<string, InfoflowGroupConfig>;
|
|
110
129
|
accounts?: Record<string, InfoflowAccountConfig>;
|
|
@@ -140,6 +159,10 @@ export type ResolvedInfoflowAccount = {
|
|
|
140
159
|
followUp?: boolean;
|
|
141
160
|
/** Follow-up window in seconds after last bot reply (default: 300) */
|
|
142
161
|
followUpWindow?: number;
|
|
162
|
+
/** Enable thinking indicator ("收到🤔...") before processing (default: true) */
|
|
163
|
+
thinkingIndicator?: boolean;
|
|
164
|
+
/** 如流企业后台的应用ID(私聊消息撤回依赖此字段) */
|
|
165
|
+
appAgentId?: number;
|
|
143
166
|
/** Per-group configuration overrides, keyed by group ID */
|
|
144
167
|
groups?: Record<string, InfoflowGroupConfig>;
|
|
145
168
|
};
|
|
@@ -169,6 +192,8 @@ export type InfoflowMessageEvent = {
|
|
|
169
192
|
mentionIds?: InfoflowMentionIds;
|
|
170
193
|
/** Reply/quote context extracted from replyData body items (supports multiple quotes) */
|
|
171
194
|
replyContext?: string[];
|
|
195
|
+
/** Image download URLs extracted from IMAGE body items (group) or PicUrl (private) */
|
|
196
|
+
imageUrls?: string[];
|
|
172
197
|
};
|
|
173
198
|
|
|
174
199
|
// ---------------------------------------------------------------------------
|