@chbo297/infoflow 2026.3.18 → 2026.5.4
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 +24 -528
- package/dist/index.js +21 -0
- package/dist/src/accounts.js +110 -0
- package/dist/src/actions.js +386 -0
- package/dist/src/bot.js +1010 -0
- package/dist/src/channel.js +385 -0
- package/dist/src/infoflow-req-parse.js +394 -0
- package/dist/src/logging.js +102 -0
- package/dist/src/markdown-local-images.js +65 -0
- package/dist/src/media.js +318 -0
- package/dist/src/monitor.js +145 -0
- package/dist/src/reply-dispatcher.js +301 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/send.js +820 -0
- package/dist/src/sent-message-store.js +190 -0
- package/dist/src/targets.js +90 -0
- package/dist/src/types.js +4 -0
- package/dist/src/ws-receiver.js +378 -0
- package/openclaw.plugin.json +194 -0
- package/package.json +18 -3
- package/scripts/deploy.sh +215 -0
- package/src/accounts.ts +25 -3
- package/src/actions.ts +9 -3
- package/src/bot.ts +63 -20
- package/src/channel.ts +64 -45
- package/src/infoflow-req-parse.ts +2 -2
- package/src/infoflow-sdk.d.ts +12 -0
- package/src/monitor.ts +21 -2
- package/src/reply-dispatcher.ts +2 -5
- package/src/types.ts +11 -0
- package/src/ws-receiver.ts +482 -0
- package/tsconfig.build.json +6 -0
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { getInfoflowRuntime } from "./runtime.js";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Constants
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const DB_FILENAME = "sent-messages.db";
|
|
18
|
+
const AUTO_CLEANUP_DAYS = 7;
|
|
19
|
+
const AUTO_CLEANUP_MS = AUTO_CLEANUP_DAYS * 24 * 60 * 60 * 1000;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// DB singleton (lazy-init)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
let db = null;
|
|
24
|
+
function resolveDbPath() {
|
|
25
|
+
const env = process.env;
|
|
26
|
+
const stateDir = getInfoflowRuntime().state.resolveStateDir(env, os.homedir);
|
|
27
|
+
return path.join(stateDir, "infoflow", DB_FILENAME);
|
|
28
|
+
}
|
|
29
|
+
function getDb() {
|
|
30
|
+
if (db)
|
|
31
|
+
return db;
|
|
32
|
+
const dbPath = resolveDbPath();
|
|
33
|
+
const dir = path.dirname(dbPath);
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
const sqlite = require("node:sqlite");
|
|
36
|
+
db = new sqlite.DatabaseSync(dbPath);
|
|
37
|
+
db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS sent_messages (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
account_id TEXT NOT NULL,
|
|
41
|
+
target TEXT NOT NULL,
|
|
42
|
+
from_id TEXT NOT NULL DEFAULT '',
|
|
43
|
+
messageid TEXT NOT NULL,
|
|
44
|
+
msgseqid TEXT NOT NULL DEFAULT '',
|
|
45
|
+
digest TEXT NOT NULL DEFAULT '',
|
|
46
|
+
sent_at INTEGER NOT NULL
|
|
47
|
+
);
|
|
48
|
+
`);
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_target_sent
|
|
51
|
+
ON sent_messages(account_id, target, sent_at DESC);
|
|
52
|
+
`);
|
|
53
|
+
// Migration: add from_id column to existing databases
|
|
54
|
+
try {
|
|
55
|
+
db.exec(`ALTER TABLE sent_messages ADD COLUMN from_id TEXT NOT NULL DEFAULT ''`);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Column already exists — ignore
|
|
59
|
+
}
|
|
60
|
+
return db;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Records a sent message. Also runs auto-cleanup of old records.
|
|
64
|
+
* Synchronous (DatabaseSync); failures are swallowed so sending is never blocked.
|
|
65
|
+
*/
|
|
66
|
+
export function recordSentMessage(accountId, record) {
|
|
67
|
+
try {
|
|
68
|
+
const d = getDb();
|
|
69
|
+
d.prepare(`INSERT INTO sent_messages (account_id, target, from_id, messageid, msgseqid, digest, sent_at)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(accountId, record.target, record.from, record.messageid, record.msgseqid, record.digest, record.sentAt);
|
|
71
|
+
// Auto-cleanup: delete records older than 7 days
|
|
72
|
+
const cutoff = Date.now() - AUTO_CLEANUP_MS;
|
|
73
|
+
d.prepare(`DELETE FROM sent_messages WHERE sent_at < ? AND account_id = ?`).run(cutoff, accountId);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Silently ignore — do not block sending
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Queries the most recent N sent messages for a given target, ordered by sent_at DESC.
|
|
81
|
+
*/
|
|
82
|
+
export function querySentMessages(accountId, params) {
|
|
83
|
+
const d = getDb();
|
|
84
|
+
const rows = d
|
|
85
|
+
.prepare(`SELECT target, from_id, messageid, msgseqid, digest, sent_at
|
|
86
|
+
FROM sent_messages
|
|
87
|
+
WHERE account_id = ? AND target = ?
|
|
88
|
+
ORDER BY sent_at DESC
|
|
89
|
+
LIMIT ?`)
|
|
90
|
+
.all(accountId, params.target, params.count);
|
|
91
|
+
return rows.map((r) => ({
|
|
92
|
+
target: r.target,
|
|
93
|
+
from: r.from_id,
|
|
94
|
+
messageid: r.messageid,
|
|
95
|
+
msgseqid: r.msgseqid,
|
|
96
|
+
digest: r.digest,
|
|
97
|
+
sentAt: r.sent_at,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Finds a single sent message by messageid.
|
|
102
|
+
*/
|
|
103
|
+
export function findSentMessage(accountId, messageid) {
|
|
104
|
+
const d = getDb();
|
|
105
|
+
const row = d
|
|
106
|
+
.prepare(`SELECT target, from_id, messageid, msgseqid, digest, sent_at
|
|
107
|
+
FROM sent_messages
|
|
108
|
+
WHERE account_id = ? AND messageid = ?
|
|
109
|
+
LIMIT 1`)
|
|
110
|
+
.get(accountId, messageid);
|
|
111
|
+
if (!row)
|
|
112
|
+
return undefined;
|
|
113
|
+
return {
|
|
114
|
+
target: row.target,
|
|
115
|
+
from: row.from_id,
|
|
116
|
+
messageid: row.messageid,
|
|
117
|
+
msgseqid: row.msgseqid,
|
|
118
|
+
digest: row.digest,
|
|
119
|
+
sentAt: row.sent_at,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Removes recalled messages from the store by their messageids.
|
|
124
|
+
*/
|
|
125
|
+
export function removeRecalledMessages(accountId, messageids) {
|
|
126
|
+
if (messageids.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
const d = getDb();
|
|
129
|
+
const placeholders = messageids.map(() => "?").join(",");
|
|
130
|
+
d.prepare(`DELETE FROM sent_messages WHERE account_id = ? AND messageid IN (${placeholders})`).run(accountId, ...messageids);
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// From-ID builder
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/** Builds a `from` identifier for self-sent (agent) messages. */
|
|
136
|
+
export function buildAgentFrom(appAgentId) {
|
|
137
|
+
return appAgentId != null ? `agent:${appAgentId}` : "agent:unknown";
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Digest builder
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
const DIGEST_MAX_LEN = 100;
|
|
143
|
+
/**
|
|
144
|
+
* Builds a short digest string from message contents.
|
|
145
|
+
* - Text/markdown/link: first 100 chars, truncated with "…"
|
|
146
|
+
* - Image only: "image"
|
|
147
|
+
* - Empty: ""
|
|
148
|
+
*/
|
|
149
|
+
export function buildMessageDigest(contents) {
|
|
150
|
+
const textParts = [];
|
|
151
|
+
let hasImage = false;
|
|
152
|
+
for (const item of contents) {
|
|
153
|
+
const type = item.type.toLowerCase();
|
|
154
|
+
if (type === "text" || type === "md" || type === "markdown") {
|
|
155
|
+
textParts.push(item.content);
|
|
156
|
+
}
|
|
157
|
+
else if (type === "link") {
|
|
158
|
+
textParts.push(item.content);
|
|
159
|
+
}
|
|
160
|
+
else if (type === "image") {
|
|
161
|
+
hasImage = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const merged = textParts.join(" ").trim();
|
|
165
|
+
if (merged) {
|
|
166
|
+
return merged.length > DIGEST_MAX_LEN ? merged.slice(0, DIGEST_MAX_LEN) + "…" : merged;
|
|
167
|
+
}
|
|
168
|
+
if (hasImage)
|
|
169
|
+
return "image";
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Test-only exports (@internal)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
/** @internal — Close and reset the DB singleton. Only use in tests. */
|
|
176
|
+
export function _resetStore() {
|
|
177
|
+
if (db) {
|
|
178
|
+
try {
|
|
179
|
+
db.close();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// ignore
|
|
183
|
+
}
|
|
184
|
+
db = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** @internal — Override the DB instance for testing. */
|
|
188
|
+
export function _setDb(next) {
|
|
189
|
+
db = next;
|
|
190
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infoflow target resolution utilities.
|
|
3
|
+
* Handles user and group ID formats for message targeting.
|
|
4
|
+
*/
|
|
5
|
+
import { logVerbose } from "./logging.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Target Format Constants
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Prefix for group targets: "group:123456" */
|
|
10
|
+
const GROUP_PREFIX = "group:";
|
|
11
|
+
/** Prefix for user targets (optional): "user:chengbo05" */
|
|
12
|
+
const USER_PREFIX = "user:";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Target Normalization
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes an Infoflow target string.
|
|
18
|
+
* Strips channel prefix and normalizes format.
|
|
19
|
+
*
|
|
20
|
+
* Examples:
|
|
21
|
+
* "infoflow:chengbo05" -> "chengbo05"
|
|
22
|
+
* "infoflow:group:123456" -> "group:123456"
|
|
23
|
+
* "user:chengbo05" -> "chengbo05"
|
|
24
|
+
* "group:123456" -> "group:123456"
|
|
25
|
+
* "chengbo05" -> "chengbo05"
|
|
26
|
+
* "123456" -> "group:123456" (pure digits treated as group)
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeInfoflowTarget(raw) {
|
|
29
|
+
logVerbose(`[infoflow:normalizeTarget] input: "${raw}"`);
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
logVerbose(`[infoflow:normalizeTarget] empty input, returning undefined`);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
// Strip infoflow: prefix
|
|
36
|
+
let target = trimmed.replace(/^infoflow:/i, "");
|
|
37
|
+
// Strip user: prefix (normalize to plain username)
|
|
38
|
+
if (target.toLowerCase().startsWith(USER_PREFIX)) {
|
|
39
|
+
target = target.slice(USER_PREFIX.length);
|
|
40
|
+
}
|
|
41
|
+
// Keep group: prefix as-is
|
|
42
|
+
if (target.toLowerCase().startsWith(GROUP_PREFIX)) {
|
|
43
|
+
logVerbose(`[infoflow:normalizeTarget] output: "${target}" (group)`);
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
// Pure digits -> treat as group ID
|
|
47
|
+
if (/^\d+$/.test(target)) {
|
|
48
|
+
const result = `${GROUP_PREFIX}${target}`;
|
|
49
|
+
logVerbose(`[infoflow:normalizeTarget] output: "${result}" (digits -> group)`);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
// Otherwise it's a username
|
|
53
|
+
logVerbose(`[infoflow:normalizeTarget] output: "${target}" (username)`);
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Target ID Detection
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* Checks if the input looks like a valid Infoflow target ID.
|
|
61
|
+
* Returns true if the system should use this value directly without directory lookup.
|
|
62
|
+
*
|
|
63
|
+
* Valid formats:
|
|
64
|
+
* - group:123456 (group ID with prefix)
|
|
65
|
+
* - user:chengbo05 (user ID with prefix)
|
|
66
|
+
* - 123456789 (pure digits = group ID)
|
|
67
|
+
* - chengbo05 (alphanumeric starting with letter = username/uuapName)
|
|
68
|
+
*/
|
|
69
|
+
export function looksLikeInfoflowId(raw) {
|
|
70
|
+
const trimmed = raw.trim();
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
// Strip infoflow: prefix for checking
|
|
75
|
+
const target = trimmed.replace(/^infoflow:/i, "");
|
|
76
|
+
// Explicit prefixes are always valid
|
|
77
|
+
if (/^(group|user):/i.test(target)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
// Pure digits (group ID)
|
|
81
|
+
if (/^\d+$/.test(target)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
// Alphanumeric starting with letter (username/uuapName)
|
|
85
|
+
// e.g., chengbo05, zhangsan, user123
|
|
86
|
+
if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(target)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket inbound receiver: wraps SDK WSClient for long-lived message delivery.
|
|
3
|
+
* Normalizes events into the same msgData shape as the webhook path, then calls
|
|
4
|
+
* handleGroupChatMessage / handlePrivateChatMessage.
|
|
5
|
+
*/
|
|
6
|
+
import { checkBotMentioned, handleGroupChatMessage, handlePrivateChatMessage, } from "./bot.js";
|
|
7
|
+
import { formatInfoflowError, getInfoflowWebhookLog, logVerbose } from "./logging.js";
|
|
8
|
+
import { isDuplicateMessage } from "./infoflow-req-parse.js";
|
|
9
|
+
import { extractIdFromRawJson } from "./send.js";
|
|
10
|
+
const GROUP_INBOUND_TTL_MS = 5 * 60 * 1000;
|
|
11
|
+
const GROUP_INBOUND_MAX_SIZE = 2048;
|
|
12
|
+
const groupInboundSeen = new Map();
|
|
13
|
+
function pruneGroupInboundSeen(now) {
|
|
14
|
+
for (const [key, value] of groupInboundSeen) {
|
|
15
|
+
if (now - value.seenAt > GROUP_INBOUND_TTL_MS) {
|
|
16
|
+
groupInboundSeen.delete(key);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (groupInboundSeen.size <= GROUP_INBOUND_MAX_SIZE)
|
|
20
|
+
return;
|
|
21
|
+
const overflow = groupInboundSeen.size - GROUP_INBOUND_MAX_SIZE;
|
|
22
|
+
const oldest = [...groupInboundSeen.entries()]
|
|
23
|
+
.sort((a, b) => a[1].seenAt - b[1].seenAt)
|
|
24
|
+
.slice(0, overflow);
|
|
25
|
+
for (const [key] of oldest) {
|
|
26
|
+
groupInboundSeen.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function shouldSkipDuplicateGroupEvent(dedupKey, incomingKind) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
pruneGroupInboundSeen(now);
|
|
32
|
+
const existing = groupInboundSeen.get(dedupKey);
|
|
33
|
+
if (!existing) {
|
|
34
|
+
groupInboundSeen.set(dedupKey, { kind: incomingKind, seenAt: now });
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (incomingKind === "forward") {
|
|
38
|
+
existing.seenAt = now;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (existing.kind === "mention") {
|
|
42
|
+
existing.seenAt = now;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
groupInboundSeen.set(dedupKey, { kind: "mention", seenAt: now });
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function botMentionIdentity(account) {
|
|
49
|
+
return {
|
|
50
|
+
robotName: account.config.robotName,
|
|
51
|
+
appAgentId: account.config.appAgentId,
|
|
52
|
+
robotId: account.config.robotId?.trim() || undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export class InfoflowWSReceiver {
|
|
56
|
+
wsClient = null;
|
|
57
|
+
options;
|
|
58
|
+
stopped = false;
|
|
59
|
+
handleGroupEventRef = null;
|
|
60
|
+
handlePrivateEventRef = null;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = options;
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
let WSClient;
|
|
66
|
+
try {
|
|
67
|
+
({ WSClient } = await import("@baidu/infoflow-sdk-nodejs"));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
throw new Error(`Infoflow WebSocket mode requires @baidu/infoflow-sdk-nodejs (install from the Baidu npm registry). ${formatInfoflowError(err)}`);
|
|
71
|
+
}
|
|
72
|
+
const { appKey, appSecret } = this.options.account.config;
|
|
73
|
+
const wsGateway = this.options.account.config.wsGateway;
|
|
74
|
+
const wsConnectDomain = this.options.account.config.wsConnectDomain;
|
|
75
|
+
this.wsClient = new WSClient({
|
|
76
|
+
appId: appKey,
|
|
77
|
+
appSecret: appSecret,
|
|
78
|
+
wsGateway,
|
|
79
|
+
...(wsConnectDomain ? { wsConnectDomain } : {}),
|
|
80
|
+
endpointTimeout: 15_000,
|
|
81
|
+
});
|
|
82
|
+
const client = this.wsClient;
|
|
83
|
+
const dispatcher = client.eventDispatcher;
|
|
84
|
+
if (dispatcher && typeof dispatcher.normalizePrivateMessage === "function") {
|
|
85
|
+
const original = dispatcher.normalizePrivateMessage.bind(dispatcher);
|
|
86
|
+
dispatcher.normalizePrivateMessage = (payload) => {
|
|
87
|
+
const normalized = original(payload);
|
|
88
|
+
normalized.originalMessage = payload;
|
|
89
|
+
return normalized;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const frameCodec = client.frameCodec;
|
|
93
|
+
if (frameCodec && typeof frameCodec.parsePayload === "function") {
|
|
94
|
+
const originalParse = frameCodec.parsePayload.bind(frameCodec);
|
|
95
|
+
frameCodec.parsePayload = (frame) => {
|
|
96
|
+
const f = frame;
|
|
97
|
+
const result = originalParse(frame);
|
|
98
|
+
if (result && typeof result === "object" && f.payload) {
|
|
99
|
+
try {
|
|
100
|
+
result._rawJson = f.payload.toString("utf-8");
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const method = f.method ?? "?";
|
|
107
|
+
getInfoflowWebhookLog().info(`[ws:frame] method=${method}, payloadLen=${f.payload?.length ?? 0}`);
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const rawHandleDisconnect = client.handleDisconnect;
|
|
112
|
+
if (typeof rawHandleDisconnect === "function") {
|
|
113
|
+
const originalHandleDisconnect = rawHandleDisconnect.bind(client);
|
|
114
|
+
client.handleDisconnect = (...args) => {
|
|
115
|
+
if (this.stopped) {
|
|
116
|
+
try {
|
|
117
|
+
client.stopHeartbeat?.();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* ignore */
|
|
121
|
+
}
|
|
122
|
+
client.state = "disconnected";
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
return originalHandleDisconnect(...args);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
getInfoflowWebhookLog().info(`[ws:init] WSClient created: appKey=${appKey.slice(0, 4)}***, gateway=${wsGateway}, endpointTimeout=15000ms`);
|
|
129
|
+
const handleGroupEvent = async (...args) => {
|
|
130
|
+
const event = args[0];
|
|
131
|
+
getInfoflowWebhookLog().info(`[ws:inbound] group event received, type=${event?.type ?? event?.data?.msgType ?? "?"}`);
|
|
132
|
+
if (this.stopped) {
|
|
133
|
+
getInfoflowWebhookLog().warn(`[ws:inbound] group event dropped (receiver stopped)`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const data = (event?.data ?? event);
|
|
138
|
+
await this.handleGroupEvent(data);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
getInfoflowWebhookLog().error(`[ws:inbound] group handler error: ${formatInfoflowError(err)}`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
this.wsClient.on("group.*", handleGroupEvent);
|
|
145
|
+
this.handleGroupEventRef = handleGroupEvent;
|
|
146
|
+
const handlePrivateEvent = async (...args) => {
|
|
147
|
+
const event = args[0];
|
|
148
|
+
getInfoflowWebhookLog().info(`[ws:inbound] private event received, type=${event?.type ?? event?.data?.msgType ?? "?"}`);
|
|
149
|
+
if (this.stopped) {
|
|
150
|
+
getInfoflowWebhookLog().warn(`[ws:inbound] private event dropped (receiver stopped)`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const data = (event?.data ?? event);
|
|
155
|
+
await this.handlePrivateEvent(data);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
getInfoflowWebhookLog().error(`[ws:inbound] private handler error: ${formatInfoflowError(err)}`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
this.wsClient.on("private.*", handlePrivateEvent);
|
|
162
|
+
this.handlePrivateEventRef = handlePrivateEvent;
|
|
163
|
+
this.wsClient.on("connected", (...args) => {
|
|
164
|
+
const event = args[0];
|
|
165
|
+
getInfoflowWebhookLog().info(`[ws:connect] websocket connected, connection_id=${event?.connectionId ?? "?"}`);
|
|
166
|
+
});
|
|
167
|
+
this.wsClient.on("disconnected", (...args) => {
|
|
168
|
+
const event = args[0];
|
|
169
|
+
getInfoflowWebhookLog().warn(`[ws:disconnect] websocket disconnected, connection_id=${event?.connectionId ?? "?"}`);
|
|
170
|
+
});
|
|
171
|
+
this.wsClient.on("error", (...args) => {
|
|
172
|
+
const event = args[0];
|
|
173
|
+
const msg = event?.error?.message ?? String(event?.error ?? event ?? "unknown");
|
|
174
|
+
getInfoflowWebhookLog().error(`[ws:error] websocket error: ${msg}`);
|
|
175
|
+
});
|
|
176
|
+
getInfoflowWebhookLog().info(`[ws:connect] connecting to ${wsGateway}`);
|
|
177
|
+
await this.wsClient.connect();
|
|
178
|
+
getInfoflowWebhookLog().info(`[ws:connect] initial connection established`);
|
|
179
|
+
this.options.abortSignal.addEventListener("abort", () => {
|
|
180
|
+
this.stop();
|
|
181
|
+
}, { once: true });
|
|
182
|
+
}
|
|
183
|
+
stop() {
|
|
184
|
+
if (this.stopped)
|
|
185
|
+
return;
|
|
186
|
+
this.stopped = true;
|
|
187
|
+
const client = this.wsClient;
|
|
188
|
+
if (this.handleGroupEventRef && client?.off) {
|
|
189
|
+
try {
|
|
190
|
+
client.off("group.*", this.handleGroupEventRef);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* ignore */
|
|
194
|
+
}
|
|
195
|
+
this.handleGroupEventRef = null;
|
|
196
|
+
}
|
|
197
|
+
if (this.handlePrivateEventRef && client?.off) {
|
|
198
|
+
try {
|
|
199
|
+
client.off("private.*", this.handlePrivateEventRef);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* ignore */
|
|
203
|
+
}
|
|
204
|
+
this.handlePrivateEventRef = null;
|
|
205
|
+
}
|
|
206
|
+
if (!client)
|
|
207
|
+
return;
|
|
208
|
+
getInfoflowWebhookLog().info(`[ws:disconnect] stopping ws receiver`);
|
|
209
|
+
try {
|
|
210
|
+
client.stopHeartbeat?.();
|
|
211
|
+
if (client.serverConfig && typeof client.serverConfig === "object") {
|
|
212
|
+
client.serverConfig = {
|
|
213
|
+
...client.serverConfig,
|
|
214
|
+
reconnect_count: 0,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
client.maxReconnectAttempts = 0;
|
|
218
|
+
client.reconnectAttempts = 0;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
client.disconnect();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* ignore */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async handleGroupEvent(data) {
|
|
231
|
+
if (!data)
|
|
232
|
+
return;
|
|
233
|
+
this.options.statusSink?.({ lastInboundAt: Date.now() });
|
|
234
|
+
const payload = (data.raw ?? data);
|
|
235
|
+
const originalMessage = (payload.originalMessage ?? payload);
|
|
236
|
+
const message = (payload.message ?? originalMessage.message ?? {});
|
|
237
|
+
const header = (message.header ?? originalMessage.header ?? {});
|
|
238
|
+
const rawJson = payload._rawJson;
|
|
239
|
+
const preciseMessageId = (rawJson && extractIdFromRawJson(rawJson, "messageid")) ??
|
|
240
|
+
(header.messageid != null ? String(header.messageid) : undefined);
|
|
241
|
+
const preciseClientMsgId = (rawJson && extractIdFromRawJson(rawJson, "clientmsgid")) ??
|
|
242
|
+
(header.clientmsgid != null ? String(header.clientmsgid) : undefined);
|
|
243
|
+
const preciseMsgId2 = (rawJson && extractIdFromRawJson(rawJson, "msgid2")) ??
|
|
244
|
+
(payload.msgid2 != null
|
|
245
|
+
? String(payload.msgid2)
|
|
246
|
+
: originalMessage.msgid2 != null
|
|
247
|
+
? String(originalMessage.msgid2)
|
|
248
|
+
: undefined);
|
|
249
|
+
const rawEventType = String(payload.eventtype ??
|
|
250
|
+
payload.eventType ??
|
|
251
|
+
originalMessage.eventtype ??
|
|
252
|
+
originalMessage.eventType ??
|
|
253
|
+
"MESSAGE_RECEIVE");
|
|
254
|
+
const bodyItems = (message.body ?? payload.body ?? data.body ?? []);
|
|
255
|
+
const isMentionEvent = rawEventType === "MESSAGE_RECEIVE"
|
|
256
|
+
? true
|
|
257
|
+
: checkBotMentioned(bodyItems, botMentionIdentity(this.options.account));
|
|
258
|
+
getInfoflowWebhookLog().info(`[ws:inbound] group eventtype=${rawEventType}, wasMentioned=${isMentionEvent}`);
|
|
259
|
+
const msgData = {
|
|
260
|
+
eventtype: rawEventType,
|
|
261
|
+
groupid: payload.groupid ?? payload.groupId ?? data.groupId,
|
|
262
|
+
fromid: payload.fromid,
|
|
263
|
+
msgid2: preciseMsgId2,
|
|
264
|
+
wasMentioned: isMentionEvent,
|
|
265
|
+
message: {
|
|
266
|
+
header: {
|
|
267
|
+
fromuserid: header.fromuserid ?? payload.fromUserId ?? data.fromUserId ?? "",
|
|
268
|
+
toid: payload.groupid ?? payload.groupId ?? data.groupId,
|
|
269
|
+
totype: "GROUP",
|
|
270
|
+
msgtype: header.msgtype ?? data.msgType ?? "text",
|
|
271
|
+
messageid: preciseMessageId,
|
|
272
|
+
clientmsgid: preciseClientMsgId,
|
|
273
|
+
servertime: header.servertime,
|
|
274
|
+
clienttime: header.clienttime,
|
|
275
|
+
at: header.at ?? { atrobotids: [] },
|
|
276
|
+
},
|
|
277
|
+
body: bodyItems.length > 0
|
|
278
|
+
? bodyItems
|
|
279
|
+
: data.content != null && String(data.content).length > 0
|
|
280
|
+
? [{ type: "TEXT", content: String(data.content) }]
|
|
281
|
+
: [],
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const dedupKey = preciseClientMsgId ?? preciseMessageId;
|
|
285
|
+
const dedupKind = isMentionEvent ? "mention" : "forward";
|
|
286
|
+
if (dedupKey && shouldSkipDuplicateGroupEvent(dedupKey, dedupKind)) {
|
|
287
|
+
logVerbose(`[infoflow:ws] duplicate group message skipped: key=${dedupKey}, kind=${dedupKind}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
logVerbose(`[infoflow:ws] group message: from=${header.fromuserid}, msgType=${header.msgtype}, groupId=${payload.groupid ?? payload.groupId}`);
|
|
291
|
+
await handleGroupChatMessage({
|
|
292
|
+
cfg: this.options.config,
|
|
293
|
+
msgData,
|
|
294
|
+
accountId: this.options.account.accountId,
|
|
295
|
+
statusSink: this.options.statusSink,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async handlePrivateEvent(data) {
|
|
299
|
+
if (!data)
|
|
300
|
+
return;
|
|
301
|
+
this.options.statusSink?.({ lastInboundAt: Date.now() });
|
|
302
|
+
const payload = (data.raw ?? data);
|
|
303
|
+
const originalMessage = (payload.originalMessage ?? payload);
|
|
304
|
+
const rawJson = (payload._rawJson ?? originalMessage._rawJson);
|
|
305
|
+
const preciseMsgId = (rawJson &&
|
|
306
|
+
(extractIdFromRawJson(rawJson, "MsgId") ?? extractIdFromRawJson(rawJson, "msgId"))) ??
|
|
307
|
+
(() => {
|
|
308
|
+
const raw = payload.MsgId ?? payload.msgId ?? data.msgId ?? originalMessage.MsgId;
|
|
309
|
+
return raw != null ? String(raw) : undefined;
|
|
310
|
+
})();
|
|
311
|
+
const preciseMsgId2 = (rawJson &&
|
|
312
|
+
(extractIdFromRawJson(rawJson, "MsgId2") ??
|
|
313
|
+
extractIdFromRawJson(rawJson, "msgid2") ??
|
|
314
|
+
extractIdFromRawJson(rawJson, "msgId2"))) ??
|
|
315
|
+
(() => {
|
|
316
|
+
const raw = payload.MsgId2 ??
|
|
317
|
+
payload.msgId2 ??
|
|
318
|
+
payload.msgid2 ??
|
|
319
|
+
data.MsgId2 ??
|
|
320
|
+
data.msgid2 ??
|
|
321
|
+
originalMessage.MsgId2;
|
|
322
|
+
return raw != null ? String(raw) : undefined;
|
|
323
|
+
})();
|
|
324
|
+
const msgData = {
|
|
325
|
+
FromUserId: payload.FromUserId ??
|
|
326
|
+
payload.fromUserId ??
|
|
327
|
+
data.fromUserId ??
|
|
328
|
+
originalMessage.FromUserId ??
|
|
329
|
+
"",
|
|
330
|
+
FromUserName: payload.FromUserName ??
|
|
331
|
+
payload.fromUserName ??
|
|
332
|
+
data.fromUserName ??
|
|
333
|
+
originalMessage.FromUserName,
|
|
334
|
+
Content: payload.Content ?? payload.content ?? data.content ?? originalMessage.Content ?? "",
|
|
335
|
+
MsgType: payload.MsgType ?? payload.msgType ?? data.msgType ?? originalMessage.MsgType ?? "text",
|
|
336
|
+
CreateTime: payload.CreateTime ??
|
|
337
|
+
payload.createTime ??
|
|
338
|
+
data.createTime ??
|
|
339
|
+
originalMessage.CreateTime ??
|
|
340
|
+
String(Date.now()),
|
|
341
|
+
PicUrl: payload.PicUrl ?? payload.picUrl ?? data.picUrl ?? originalMessage.PicUrl ?? "",
|
|
342
|
+
VoiceUrl: payload.VoiceUrl ?? payload.voiceUrl ?? data.voiceUrl ?? originalMessage.VoiceUrl ?? "",
|
|
343
|
+
FromPlatform: payload.FromPlatform ??
|
|
344
|
+
payload.fromPlatform ??
|
|
345
|
+
data.fromPlatform ??
|
|
346
|
+
originalMessage.FromPlatform ??
|
|
347
|
+
"",
|
|
348
|
+
agentId: payload.agentId ?? data.agentId ?? originalMessage.agentId ?? "",
|
|
349
|
+
OpenCode: payload.OpenCode ?? payload.openCode ?? data.openCode ?? originalMessage.OpenCode ?? "",
|
|
350
|
+
MsgId: preciseMsgId,
|
|
351
|
+
MsgId2: preciseMsgId2,
|
|
352
|
+
FromId: payload.FromId ?? payload.fromid ?? data.FromId ?? data.fromid ?? originalMessage.FromId,
|
|
353
|
+
Reply: payload.Reply ??
|
|
354
|
+
payload.reply ??
|
|
355
|
+
data.Reply ??
|
|
356
|
+
data.reply ??
|
|
357
|
+
originalMessage.Reply ??
|
|
358
|
+
undefined,
|
|
359
|
+
FileId: payload.FileId ?? payload.fileId ?? data.fileId ?? originalMessage.FileId,
|
|
360
|
+
Name: payload.Name ?? payload.name ?? data.name ?? originalMessage.Name,
|
|
361
|
+
FileSize: payload.FileSize ?? payload.fileSize ?? data.fileSize ?? originalMessage.FileSize,
|
|
362
|
+
FileType: payload.FileType ?? payload.fileType ?? data.fileType ?? originalMessage.FileType,
|
|
363
|
+
CardType: payload.CardType ?? payload.cardType ?? data.CardType ?? originalMessage.CardType,
|
|
364
|
+
Title: payload.Title ?? payload.title ?? data.Title ?? originalMessage.Title,
|
|
365
|
+
};
|
|
366
|
+
if (isDuplicateMessage(msgData)) {
|
|
367
|
+
logVerbose("[infoflow:ws] duplicate private message, skipping");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
logVerbose(`[infoflow:ws] private message: from=${msgData.FromUserId}, msgType=${msgData.MsgType}`);
|
|
371
|
+
await handlePrivateChatMessage({
|
|
372
|
+
cfg: this.options.config,
|
|
373
|
+
msgData,
|
|
374
|
+
accountId: this.options.account.accountId,
|
|
375
|
+
statusSink: this.options.statusSink,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|