@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,295 @@
|
|
|
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 { coreEvents, type SentMessageEvent } from "../../events.js";
|
|
15
|
+
import { getInfoflowRuntime } from "../../runtime.js";
|
|
16
|
+
import type { InfoflowMessageContentItem } from "../../types.js";
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Constants
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const DB_FILENAME = "sent-messages.db";
|
|
25
|
+
const AUTO_CLEANUP_DAYS = 7;
|
|
26
|
+
const AUTO_CLEANUP_MS = AUTO_CLEANUP_DAYS * 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// DB singleton (lazy-init)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
let db: DatabaseSync | null = null;
|
|
33
|
+
|
|
34
|
+
function resolveDbPath(): string {
|
|
35
|
+
const env = process.env;
|
|
36
|
+
const stateDir = getInfoflowRuntime().state.resolveStateDir(env, os.homedir);
|
|
37
|
+
return path.join(stateDir, "infoflow", DB_FILENAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getDb(): DatabaseSync {
|
|
41
|
+
if (db) return db;
|
|
42
|
+
|
|
43
|
+
const dbPath = resolveDbPath();
|
|
44
|
+
const dir = path.dirname(dbPath);
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
|
|
47
|
+
const sqlite = require("node:sqlite") as typeof import("node:sqlite");
|
|
48
|
+
db = new sqlite.DatabaseSync(dbPath);
|
|
49
|
+
|
|
50
|
+
db.exec(`
|
|
51
|
+
CREATE TABLE IF NOT EXISTS sent_messages (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
account_id TEXT NOT NULL,
|
|
54
|
+
target TEXT NOT NULL,
|
|
55
|
+
from_id TEXT NOT NULL DEFAULT '',
|
|
56
|
+
messageid TEXT NOT NULL,
|
|
57
|
+
msgseqid TEXT NOT NULL DEFAULT '',
|
|
58
|
+
digest TEXT NOT NULL DEFAULT '',
|
|
59
|
+
sent_at INTEGER NOT NULL
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_target_sent
|
|
64
|
+
ON sent_messages(account_id, target, sent_at DESC);
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
// Migration: add from_id column to existing databases
|
|
68
|
+
try {
|
|
69
|
+
db.exec(`ALTER TABLE sent_messages ADD COLUMN from_id TEXT NOT NULL DEFAULT ''`);
|
|
70
|
+
} catch {
|
|
71
|
+
// Column already exists — ignore
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return db;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Public API
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export type SentMessageRecord = {
|
|
82
|
+
target: string;
|
|
83
|
+
from: string;
|
|
84
|
+
messageid: string;
|
|
85
|
+
msgseqid: string;
|
|
86
|
+
digest: string;
|
|
87
|
+
sentAt: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Records a sent message. Also runs auto-cleanup of old records.
|
|
92
|
+
* Synchronous (DatabaseSync); failures are swallowed so sending is never blocked.
|
|
93
|
+
*/
|
|
94
|
+
export function recordSentMessage(accountId: string, record: SentMessageRecord): void {
|
|
95
|
+
try {
|
|
96
|
+
const d = getDb();
|
|
97
|
+
d.prepare(
|
|
98
|
+
`INSERT INTO sent_messages (account_id, target, from_id, messageid, msgseqid, digest, sent_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
100
|
+
).run(
|
|
101
|
+
accountId,
|
|
102
|
+
record.target,
|
|
103
|
+
record.from,
|
|
104
|
+
record.messageid,
|
|
105
|
+
record.msgseqid,
|
|
106
|
+
record.digest,
|
|
107
|
+
record.sentAt,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Auto-cleanup: delete records older than 7 days
|
|
111
|
+
const cutoff = Date.now() - AUTO_CLEANUP_MS;
|
|
112
|
+
d.prepare(`DELETE FROM sent_messages WHERE sent_at < ? AND account_id = ?`).run(
|
|
113
|
+
cutoff,
|
|
114
|
+
accountId,
|
|
115
|
+
);
|
|
116
|
+
} catch {
|
|
117
|
+
// Silently ignore — do not block sending
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Queries the most recent N sent messages for a given target, ordered by sent_at DESC.
|
|
123
|
+
*/
|
|
124
|
+
export function querySentMessages(
|
|
125
|
+
accountId: string,
|
|
126
|
+
params: { target: string; count: number },
|
|
127
|
+
): SentMessageRecord[] {
|
|
128
|
+
const d = getDb();
|
|
129
|
+
const rows = d
|
|
130
|
+
.prepare(
|
|
131
|
+
`SELECT target, from_id, messageid, msgseqid, digest, sent_at
|
|
132
|
+
FROM sent_messages
|
|
133
|
+
WHERE account_id = ? AND target = ?
|
|
134
|
+
ORDER BY sent_at DESC
|
|
135
|
+
LIMIT ?`,
|
|
136
|
+
)
|
|
137
|
+
.all(accountId, params.target, params.count) as Array<{
|
|
138
|
+
target: string;
|
|
139
|
+
from_id: string;
|
|
140
|
+
messageid: string;
|
|
141
|
+
msgseqid: string;
|
|
142
|
+
digest: string;
|
|
143
|
+
sent_at: number;
|
|
144
|
+
}>;
|
|
145
|
+
|
|
146
|
+
return rows.map((r) => ({
|
|
147
|
+
target: r.target,
|
|
148
|
+
from: r.from_id,
|
|
149
|
+
messageid: r.messageid,
|
|
150
|
+
msgseqid: r.msgseqid,
|
|
151
|
+
digest: r.digest,
|
|
152
|
+
sentAt: r.sent_at,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Finds a single sent message by messageid.
|
|
158
|
+
*/
|
|
159
|
+
export function findSentMessage(
|
|
160
|
+
accountId: string,
|
|
161
|
+
messageid: string,
|
|
162
|
+
): SentMessageRecord | undefined {
|
|
163
|
+
const d = getDb();
|
|
164
|
+
const row = d
|
|
165
|
+
.prepare(
|
|
166
|
+
`SELECT target, from_id, messageid, msgseqid, digest, sent_at
|
|
167
|
+
FROM sent_messages
|
|
168
|
+
WHERE account_id = ? AND messageid = ?
|
|
169
|
+
LIMIT 1`,
|
|
170
|
+
)
|
|
171
|
+
.get(accountId, messageid) as
|
|
172
|
+
| {
|
|
173
|
+
target: string;
|
|
174
|
+
from_id: string;
|
|
175
|
+
messageid: string;
|
|
176
|
+
msgseqid: string;
|
|
177
|
+
digest: string;
|
|
178
|
+
sent_at: number;
|
|
179
|
+
}
|
|
180
|
+
| undefined;
|
|
181
|
+
|
|
182
|
+
if (!row) return undefined;
|
|
183
|
+
return {
|
|
184
|
+
target: row.target,
|
|
185
|
+
from: row.from_id,
|
|
186
|
+
messageid: row.messageid,
|
|
187
|
+
msgseqid: row.msgseqid,
|
|
188
|
+
digest: row.digest,
|
|
189
|
+
sentAt: row.sent_at,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Removes recalled messages from the store by their messageids.
|
|
195
|
+
*/
|
|
196
|
+
export function removeRecalledMessages(accountId: string, messageids: string[]): void {
|
|
197
|
+
if (messageids.length === 0) return;
|
|
198
|
+
const d = getDb();
|
|
199
|
+
const placeholders = messageids.map(() => "?").join(",");
|
|
200
|
+
d.prepare(
|
|
201
|
+
`DELETE FROM sent_messages WHERE account_id = ? AND messageid IN (${placeholders})`,
|
|
202
|
+
).run(accountId, ...messageids);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// From-ID builder
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/** Builds a `from` identifier for self-sent (agent) messages. */
|
|
210
|
+
export function buildAgentFrom(appAgentId: number | undefined): string {
|
|
211
|
+
return appAgentId != null ? `agent:${appAgentId}` : "agent:unknown";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Digest builder
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
const DIGEST_MAX_LEN = 100;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Builds a short digest string from message contents.
|
|
222
|
+
* - Text/markdown/link: first 100 chars, truncated with "…"
|
|
223
|
+
* - Image only: "image"
|
|
224
|
+
* - Empty: ""
|
|
225
|
+
*/
|
|
226
|
+
export function buildMessageDigest(contents: InfoflowMessageContentItem[]): string {
|
|
227
|
+
const textParts: string[] = [];
|
|
228
|
+
let hasImage = false;
|
|
229
|
+
|
|
230
|
+
for (const item of contents) {
|
|
231
|
+
const type = item.type.toLowerCase();
|
|
232
|
+
if (type === "text" || type === "md" || type === "markdown") {
|
|
233
|
+
textParts.push(item.content);
|
|
234
|
+
} else if (type === "link") {
|
|
235
|
+
textParts.push(item.content);
|
|
236
|
+
} else if (type === "image") {
|
|
237
|
+
hasImage = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const merged = textParts.join(" ").trim();
|
|
242
|
+
if (merged) {
|
|
243
|
+
return merged.length > DIGEST_MAX_LEN ? merged.slice(0, DIGEST_MAX_LEN) + "…" : merged;
|
|
244
|
+
}
|
|
245
|
+
if (hasImage) return "image";
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Test-only exports (@internal)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/** @internal — Close and reset the DB singleton. Only use in tests. */
|
|
254
|
+
export function _resetStore(): void {
|
|
255
|
+
if (db) {
|
|
256
|
+
try {
|
|
257
|
+
db.close();
|
|
258
|
+
} catch {
|
|
259
|
+
// ignore
|
|
260
|
+
}
|
|
261
|
+
db = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @internal — Override the DB instance for testing. */
|
|
266
|
+
export function _setDb(next: DatabaseSync): void {
|
|
267
|
+
db = next;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Event subscription — auto-record sent messages from core
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
function handleSentEvent(event: SentMessageEvent): void {
|
|
275
|
+
try {
|
|
276
|
+
recordSentMessage(event.accountId, {
|
|
277
|
+
target: event.target,
|
|
278
|
+
from: event.from,
|
|
279
|
+
messageid: event.messageid,
|
|
280
|
+
msgseqid: event.msgseqid,
|
|
281
|
+
digest: buildMessageDigest(event.contents),
|
|
282
|
+
sentAt: event.sentAt,
|
|
283
|
+
});
|
|
284
|
+
} catch {
|
|
285
|
+
// Do not propagate — store failures must never break core
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Register store listeners on the core event bus.
|
|
291
|
+
* Called once during plugin initialization.
|
|
292
|
+
*/
|
|
293
|
+
export function registerStoreListeners(): void {
|
|
294
|
+
coreEvents.on("message:sent", handleSentEvent);
|
|
295
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK Adapter Layer: wraps SDK TokenManager for token management.
|
|
3
|
+
* Each appKey gets its own adapter instance (multi-account isolation).
|
|
4
|
+
*
|
|
5
|
+
* Design decision: Only use SDK TokenManager, NOT HTTPClient, because:
|
|
6
|
+
* - SDK HTTPClient uses http:// but plugin requires https://
|
|
7
|
+
* - Plugin needs raw response text for large integer precision (>2^53)
|
|
8
|
+
* - Plugin needs manual JSON construction for group recall
|
|
9
|
+
*
|
|
10
|
+
* HTTP requests continue using fetch in send.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ConfigManager, TokenManager } from "@core-workspace/infoflow-sdk-nodejs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ensures apiHost uses HTTPS for security (secrets in transit).
|
|
17
|
+
* Allows HTTP only for localhost/127.0.0.1 (local development).
|
|
18
|
+
* (Inlined from send.ts to avoid circular dependency)
|
|
19
|
+
*/
|
|
20
|
+
function ensureHttps(apiHost: string): string {
|
|
21
|
+
if (apiHost.startsWith("http://")) {
|
|
22
|
+
const url = new URL(apiHost);
|
|
23
|
+
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
24
|
+
if (!isLocal) {
|
|
25
|
+
return apiHost.replace(/^http:/, "https:");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return apiHost;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Module-level instance cache (keyed by appKey for multi-account isolation)
|
|
32
|
+
const adapterCache = new Map<string, InfoflowSDKAdapter>();
|
|
33
|
+
|
|
34
|
+
export type SDKAdapterParams = {
|
|
35
|
+
apiHost: string;
|
|
36
|
+
appKey: string;
|
|
37
|
+
appSecret: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gets or creates an SDK adapter for the given appKey.
|
|
42
|
+
* Adapter instances are cached per appKey to reuse SDK TokenManager's
|
|
43
|
+
* internal token cache and Promise lock.
|
|
44
|
+
*/
|
|
45
|
+
export function getOrCreateAdapter(params: SDKAdapterParams): InfoflowSDKAdapter {
|
|
46
|
+
const existing = adapterCache.get(params.appKey);
|
|
47
|
+
if (existing) {
|
|
48
|
+
return existing;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const adapter = new InfoflowSDKAdapter(params);
|
|
52
|
+
adapterCache.set(params.appKey, adapter);
|
|
53
|
+
return adapter;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SDK adapter wrapping TokenManager for a single appKey.
|
|
58
|
+
*/
|
|
59
|
+
export class InfoflowSDKAdapter {
|
|
60
|
+
private tokenManager: TokenManager;
|
|
61
|
+
|
|
62
|
+
constructor(params: SDKAdapterParams) {
|
|
63
|
+
// SDK ConfigManager expects pure domain (e.g. "apiin.im.baidu.com"),
|
|
64
|
+
// but plugin's apiHost is a full URL (e.g. "https://apiin.im.baidu.com").
|
|
65
|
+
// Extract the host portion.
|
|
66
|
+
const apiUrl = ensureHttps(params.apiHost);
|
|
67
|
+
const domain = new URL(apiUrl).host;
|
|
68
|
+
|
|
69
|
+
const configManager = new ConfigManager({
|
|
70
|
+
appId: params.appKey,
|
|
71
|
+
appSecret: params.appSecret,
|
|
72
|
+
apiDomain: domain,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.tokenManager = new TokenManager(configManager);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Gets an access token via SDK TokenManager.
|
|
80
|
+
* Handles caching, MD5 signing, concurrency safety, and early refresh internally.
|
|
81
|
+
*/
|
|
82
|
+
async getToken(): Promise<string> {
|
|
83
|
+
return this.tokenManager.getAccessToken();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @internal — Resets all adapter instances. Only use in tests. */
|
|
88
|
+
export function _resetAdapters(): void {
|
|
89
|
+
adapterCache.clear();
|
|
90
|
+
}
|