@ssfxx44533/onebot-qq 0.1.2
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 +119 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +192 -0
- package/package.json +47 -0
- package/setup-entry.ts +3 -0
- package/src/channel.ts +549 -0
- package/src/config.js +380 -0
- package/src/inbound.ts +587 -0
- package/src/message.js +235 -0
- package/src/onebot-client.js +226 -0
- package/src/plugin.ts +16 -0
- package/src/runtime.js +85 -0
package/src/message.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export function makeEventKey(accountId, event) {
|
|
2
|
+
return [
|
|
3
|
+
String(accountId ?? ""),
|
|
4
|
+
String(event?.post_type ?? ""),
|
|
5
|
+
String(event?.message_type ?? ""),
|
|
6
|
+
String(event?.message_id ?? ""),
|
|
7
|
+
String(event?.group_id ?? ""),
|
|
8
|
+
String(event?.user_id ?? ""),
|
|
9
|
+
String(event?.time ?? ""),
|
|
10
|
+
].join(":");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function extractReplyToId(event) {
|
|
14
|
+
if (!Array.isArray(event?.message)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const replySegment = event.message.find((segment) => segment?.type === "reply");
|
|
18
|
+
const value = String(replySegment?.data?.id ?? "").trim();
|
|
19
|
+
return value || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isAtSelf(event, selfId) {
|
|
23
|
+
const normalizedSelfId = String(selfId ?? "").trim();
|
|
24
|
+
if (!normalizedSelfId) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(event?.message)) {
|
|
28
|
+
return event.message.some(
|
|
29
|
+
(segment) => segment?.type === "at" && String(segment?.data?.qq ?? "") === normalizedSelfId,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return String(event?.raw_message ?? "").includes(`[CQ:at,qq=${normalizedSelfId}]`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeSegment(segment, selfId) {
|
|
36
|
+
if (!segment || typeof segment !== "object") {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
const type = segment.type;
|
|
40
|
+
const data = segment.data ?? {};
|
|
41
|
+
|
|
42
|
+
if (type === "text") {
|
|
43
|
+
return String(data.text ?? "");
|
|
44
|
+
}
|
|
45
|
+
if (type === "at") {
|
|
46
|
+
const target = String(data.qq ?? "");
|
|
47
|
+
if (selfId && target === String(selfId)) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
return target ? `@${target}` : "@";
|
|
51
|
+
}
|
|
52
|
+
if (type === "reply" || type === "forward") {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
if (type === "image") {
|
|
56
|
+
return "[image]";
|
|
57
|
+
}
|
|
58
|
+
if (type === "video") {
|
|
59
|
+
return "[video]";
|
|
60
|
+
}
|
|
61
|
+
if (type === "record" || type === "audio") {
|
|
62
|
+
return "[voice]";
|
|
63
|
+
}
|
|
64
|
+
if (type === "file" || type === "onlinefile") {
|
|
65
|
+
const name = String(data.name ?? data.file ?? data.fileName ?? "").trim();
|
|
66
|
+
return name ? `[file: ${name}]` : "[file]";
|
|
67
|
+
}
|
|
68
|
+
if (type === "face") {
|
|
69
|
+
return "[sticker]";
|
|
70
|
+
}
|
|
71
|
+
if (type === "json" || type === "xml") {
|
|
72
|
+
return "[card]";
|
|
73
|
+
}
|
|
74
|
+
return `[${type}]`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeInboundText(text) {
|
|
78
|
+
return String(text ?? "").trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function extractMessageText(event, selfId) {
|
|
82
|
+
if (Array.isArray(event?.message)) {
|
|
83
|
+
return normalizeInboundText(
|
|
84
|
+
event.message
|
|
85
|
+
.map((segment) => normalizeSegment(segment, selfId))
|
|
86
|
+
.join("")
|
|
87
|
+
.replace(/\u00a0/g, " ")
|
|
88
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
89
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
90
|
+
.trim(),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return normalizeInboundText(
|
|
95
|
+
String(event?.raw_message ?? "")
|
|
96
|
+
.replace(/\[CQ:at,qq=\d+\]/g, "")
|
|
97
|
+
.replace(/\[CQ:reply,id=\d+\]/g, "")
|
|
98
|
+
.trim(),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stripAttachmentMarkerText(text) {
|
|
103
|
+
return String(text ?? "")
|
|
104
|
+
.replace(/\[(?:image|video|voice)\]/g, "")
|
|
105
|
+
.replace(/\[file(?:: [^\]]+)?\]/g, "")
|
|
106
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
107
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function extractAttachments(event) {
|
|
112
|
+
if (!Array.isArray(event?.message)) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const attachments = [];
|
|
117
|
+
for (const segment of event.message) {
|
|
118
|
+
if (!segment || typeof segment !== "object") {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const type = segment.type;
|
|
122
|
+
const data = segment.data ?? {};
|
|
123
|
+
|
|
124
|
+
if (type === "image") {
|
|
125
|
+
attachments.push({
|
|
126
|
+
kind: "image",
|
|
127
|
+
summary: typeof data.summary === "string" ? data.summary : "[image]",
|
|
128
|
+
file: typeof data.file === "string" ? data.file : "",
|
|
129
|
+
url: typeof data.url === "string" ? data.url : "",
|
|
130
|
+
fileSize: data.file_size ?? "",
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (type === "video") {
|
|
136
|
+
attachments.push({
|
|
137
|
+
kind: "video",
|
|
138
|
+
file: typeof data.file === "string" ? data.file : "",
|
|
139
|
+
url: typeof data.url === "string" ? data.url : "",
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (type === "record" || type === "audio") {
|
|
145
|
+
attachments.push({
|
|
146
|
+
kind: "audio",
|
|
147
|
+
file: typeof data.file === "string" ? data.file : "",
|
|
148
|
+
url: typeof data.url === "string" ? data.url : "",
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (type === "file" || type === "onlinefile") {
|
|
154
|
+
attachments.push({
|
|
155
|
+
kind: "file",
|
|
156
|
+
name:
|
|
157
|
+
typeof data.file === "string"
|
|
158
|
+
? data.file
|
|
159
|
+
: typeof data.fileName === "string"
|
|
160
|
+
? data.fileName
|
|
161
|
+
: "",
|
|
162
|
+
url: typeof data.url === "string" ? data.url : "",
|
|
163
|
+
fileId:
|
|
164
|
+
typeof data.file_id === "string"
|
|
165
|
+
? data.file_id
|
|
166
|
+
: typeof data.fileId === "string"
|
|
167
|
+
? data.fileId
|
|
168
|
+
: "",
|
|
169
|
+
fileSize: data.file_size ?? data.fileSize ?? "",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return attachments;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function buildAgentBody(text, attachments) {
|
|
178
|
+
const lines = [];
|
|
179
|
+
const trimmedText = stripAttachmentMarkerText(text);
|
|
180
|
+
if (trimmedText) {
|
|
181
|
+
lines.push(trimmedText);
|
|
182
|
+
} else if (attachments.length > 0) {
|
|
183
|
+
lines.push(
|
|
184
|
+
"[No text provided — attachment only. Focus on the attached content and respond accordingly; if it is an image, describe what is visually present.]",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (attachments.length > 0) {
|
|
189
|
+
if (lines.length > 0) {
|
|
190
|
+
lines.push("");
|
|
191
|
+
}
|
|
192
|
+
lines.push("Attachments:");
|
|
193
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
194
|
+
const details = [`${index + 1}. type=${attachment.kind}`];
|
|
195
|
+
if (attachment.summary) details.push(`summary=${attachment.summary}`);
|
|
196
|
+
if (attachment.name) details.push(`name=${attachment.name}`);
|
|
197
|
+
if (attachment.file) details.push(`file=${attachment.file}`);
|
|
198
|
+
if (attachment.fileId) details.push(`file_id=${attachment.fileId}`);
|
|
199
|
+
if (attachment.fileSize) details.push(`size=${attachment.fileSize}`);
|
|
200
|
+
if (attachment.url) details.push(`url=${attachment.url}`);
|
|
201
|
+
lines.push(`- ${details.join(", ")}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return lines.join("\n").trim();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function splitTextChunks(text, limit) {
|
|
209
|
+
const normalized = String(text ?? "").replace(/\r\n/g, "\n").trim();
|
|
210
|
+
if (!normalized) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const chunks = [];
|
|
215
|
+
let remaining = normalized;
|
|
216
|
+
while (remaining.length > limit) {
|
|
217
|
+
let splitAt = remaining.lastIndexOf("\n\n", limit);
|
|
218
|
+
if (splitAt < Math.floor(limit / 2)) {
|
|
219
|
+
splitAt = remaining.lastIndexOf("\n", limit);
|
|
220
|
+
}
|
|
221
|
+
if (splitAt < Math.floor(limit / 2)) {
|
|
222
|
+
splitAt = remaining.lastIndexOf(" ", limit);
|
|
223
|
+
}
|
|
224
|
+
if (splitAt < Math.floor(limit / 2)) {
|
|
225
|
+
splitAt = limit;
|
|
226
|
+
}
|
|
227
|
+
chunks.push(remaining.slice(0, splitAt).trim());
|
|
228
|
+
remaining = remaining.slice(splitAt).trim();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (remaining) {
|
|
232
|
+
chunks.push(remaining);
|
|
233
|
+
}
|
|
234
|
+
return chunks;
|
|
235
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
function parseWsData(data) {
|
|
5
|
+
if (typeof data === "string") {
|
|
6
|
+
return data;
|
|
7
|
+
}
|
|
8
|
+
if (Buffer.isBuffer(data)) {
|
|
9
|
+
return data.toString("utf8");
|
|
10
|
+
}
|
|
11
|
+
if (data instanceof ArrayBuffer) {
|
|
12
|
+
return Buffer.from(data).toString("utf8");
|
|
13
|
+
}
|
|
14
|
+
if (ArrayBuffer.isView(data)) {
|
|
15
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
|
16
|
+
}
|
|
17
|
+
return String(data);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class OneBotSocket {
|
|
21
|
+
constructor(ws, options) {
|
|
22
|
+
this.ws = ws;
|
|
23
|
+
this.callTimeoutMs = options.callTimeoutMs;
|
|
24
|
+
this.onEvent = options.onEvent;
|
|
25
|
+
this.pending = new Map();
|
|
26
|
+
this.closed = false;
|
|
27
|
+
this.closedPromise = new Promise((resolve) => {
|
|
28
|
+
this.#resolveClosed = resolve;
|
|
29
|
+
});
|
|
30
|
+
this.#attach();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#resolveClosed;
|
|
34
|
+
|
|
35
|
+
static async connect(options) {
|
|
36
|
+
const ws = new WebSocket(options.wsUrl);
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
ws.addEventListener("open", () => resolve(), { once: true });
|
|
39
|
+
ws.addEventListener(
|
|
40
|
+
"error",
|
|
41
|
+
(event) => reject(event?.error ?? new Error("OneBot WebSocket error")),
|
|
42
|
+
{ once: true },
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
return new OneBotSocket(ws, options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#attach() {
|
|
49
|
+
this.ws.addEventListener("message", (event) => {
|
|
50
|
+
void this.#onMessage(event.data);
|
|
51
|
+
});
|
|
52
|
+
this.ws.addEventListener("close", () => {
|
|
53
|
+
this.closed = true;
|
|
54
|
+
for (const [echo, pending] of this.pending.entries()) {
|
|
55
|
+
clearTimeout(pending.timeout);
|
|
56
|
+
pending.reject(new Error(`OneBot disconnected before response: ${echo}`));
|
|
57
|
+
}
|
|
58
|
+
this.pending.clear();
|
|
59
|
+
this.#resolveClosed();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async #onMessage(rawData) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(parseWsData(rawData));
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (parsed?.echo && this.pending.has(parsed.echo)) {
|
|
72
|
+
const pending = this.pending.get(parsed.echo);
|
|
73
|
+
this.pending.delete(parsed.echo);
|
|
74
|
+
clearTimeout(pending.timeout);
|
|
75
|
+
if (parsed.status && parsed.status !== "ok" && parsed.retcode !== 0) {
|
|
76
|
+
pending.reject(
|
|
77
|
+
new Error(
|
|
78
|
+
parsed.wording || parsed.message || parsed.msg || `retcode=${parsed.retcode}`,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
pending.resolve(parsed);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (parsed?.post_type === "message") {
|
|
88
|
+
await this.onEvent?.(parsed);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async call(action, params = {}) {
|
|
93
|
+
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
|
|
94
|
+
throw new Error("OneBot WebSocket is not connected");
|
|
95
|
+
}
|
|
96
|
+
const echo = crypto.randomUUID();
|
|
97
|
+
const payload = { action, params, echo };
|
|
98
|
+
|
|
99
|
+
return await new Promise((resolve, reject) => {
|
|
100
|
+
const timeout = setTimeout(() => {
|
|
101
|
+
this.pending.delete(echo);
|
|
102
|
+
reject(new Error(`OneBot action timed out: ${action}`));
|
|
103
|
+
}, this.callTimeoutMs);
|
|
104
|
+
|
|
105
|
+
this.pending.set(echo, { resolve, reject, timeout });
|
|
106
|
+
try {
|
|
107
|
+
this.ws.send(JSON.stringify(payload));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
this.pending.delete(echo);
|
|
111
|
+
reject(error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async getLoginInfo() {
|
|
117
|
+
const result = await this.call("get_login_info");
|
|
118
|
+
return result?.data ?? {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
close() {
|
|
122
|
+
if (this.closed) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
this.ws.close();
|
|
127
|
+
} catch {
|
|
128
|
+
this.closed = true;
|
|
129
|
+
this.#resolveClosed();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function withOneBotSocket(options, work) {
|
|
135
|
+
const socket = await OneBotSocket.connect(options);
|
|
136
|
+
try {
|
|
137
|
+
return await work(socket);
|
|
138
|
+
} finally {
|
|
139
|
+
socket.close();
|
|
140
|
+
await socket.closedPromise.catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function startOneBotMonitor(options) {
|
|
145
|
+
let stopped = false;
|
|
146
|
+
let activeSocket = null;
|
|
147
|
+
let loopPromise = null;
|
|
148
|
+
|
|
149
|
+
async function loop() {
|
|
150
|
+
let delayMs = Math.max(1, options.account.reconnectBaseMs);
|
|
151
|
+
while (!stopped) {
|
|
152
|
+
try {
|
|
153
|
+
options.onStatus?.({
|
|
154
|
+
running: true,
|
|
155
|
+
lastError: null,
|
|
156
|
+
lastStartAt: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
const socket = await OneBotSocket.connect({
|
|
159
|
+
wsUrl: options.account.wsUrl,
|
|
160
|
+
callTimeoutMs: options.account.callTimeoutMs,
|
|
161
|
+
onEvent: options.onEvent,
|
|
162
|
+
});
|
|
163
|
+
activeSocket = socket;
|
|
164
|
+
|
|
165
|
+
let selfId = options.account.selfId;
|
|
166
|
+
try {
|
|
167
|
+
const loginInfo = await socket.getLoginInfo();
|
|
168
|
+
if (loginInfo?.user_id) {
|
|
169
|
+
selfId = String(loginInfo.user_id);
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
options.log?.debug?.(`onebot-qq: get_login_info failed: ${String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
options.onConnected?.({
|
|
176
|
+
socket,
|
|
177
|
+
selfId: selfId || null,
|
|
178
|
+
});
|
|
179
|
+
delayMs = Math.max(1, options.account.reconnectBaseMs);
|
|
180
|
+
|
|
181
|
+
await socket.closedPromise;
|
|
182
|
+
if (stopped) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
options.onStatus?.({
|
|
186
|
+
running: false,
|
|
187
|
+
lastStopAt: Date.now(),
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
options.onStatus?.({
|
|
191
|
+
running: false,
|
|
192
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
193
|
+
lastStopAt: Date.now(),
|
|
194
|
+
});
|
|
195
|
+
options.log?.warn?.(`onebot-qq: connection failed: ${String(error)}`);
|
|
196
|
+
} finally {
|
|
197
|
+
if (activeSocket) {
|
|
198
|
+
options.onDisconnected?.(activeSocket);
|
|
199
|
+
}
|
|
200
|
+
activeSocket = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (stopped) {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
await sleep(delayMs);
|
|
207
|
+
delayMs = Math.min(
|
|
208
|
+
Math.max(1, delayMs * 2),
|
|
209
|
+
Math.max(options.account.reconnectBaseMs, options.account.reconnectMaxMs),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
loopPromise = loop();
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
waitUntilStopped() {
|
|
218
|
+
return loopPromise;
|
|
219
|
+
},
|
|
220
|
+
async stop() {
|
|
221
|
+
stopped = true;
|
|
222
|
+
activeSocket?.close();
|
|
223
|
+
await loopPromise;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
|
2
|
+
|
|
3
|
+
import { ONEBOT_QQ_CHANNEL_SCHEMA } from "./config.js";
|
|
4
|
+
import { onebotQqPlugin } from "./channel.ts";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "onebot-qq",
|
|
8
|
+
name: "OneBot QQ",
|
|
9
|
+
description: "NapCat / OneBot QQ channel plugin for OpenClaw",
|
|
10
|
+
configSchema: buildChannelConfigSchema(ONEBOT_QQ_CHANNEL_SCHEMA),
|
|
11
|
+
register(api) {
|
|
12
|
+
api.registerChannel({ plugin: onebotQqPlugin });
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default plugin;
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const MAX_RECENT_EVENT_IDS = 2000;
|
|
2
|
+
|
|
3
|
+
const state = {
|
|
4
|
+
liveAccounts: new Map(),
|
|
5
|
+
sessionQueues: new Map(),
|
|
6
|
+
recentEventIds: new Set(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Periodic cleanup: clear the entire dedup set every 10 minutes so it
|
|
10
|
+
// never accumulates indefinitely in low-traffic scenarios.
|
|
11
|
+
const _recentEventCleanupTimer = setInterval(() => {
|
|
12
|
+
state.recentEventIds.clear();
|
|
13
|
+
}, 10 * 60 * 1000);
|
|
14
|
+
if (_recentEventCleanupTimer.unref) {
|
|
15
|
+
_recentEventCleanupTimer.unref();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerLiveAccount(accountId, data) {
|
|
19
|
+
state.liveAccounts.set(String(accountId), {
|
|
20
|
+
socket: data.socket,
|
|
21
|
+
selfId: data.selfId ? String(data.selfId) : null,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function updateLiveAccountSelfId(accountId, selfId) {
|
|
26
|
+
const key = String(accountId);
|
|
27
|
+
const current = state.liveAccounts.get(key);
|
|
28
|
+
if (!current) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
state.liveAccounts.set(key, {
|
|
32
|
+
...current,
|
|
33
|
+
selfId: selfId ? String(selfId) : null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getLiveAccount(accountId) {
|
|
38
|
+
return state.liveAccounts.get(String(accountId)) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function unregisterLiveAccount(accountId, socket) {
|
|
42
|
+
const key = String(accountId);
|
|
43
|
+
const current = state.liveAccounts.get(key);
|
|
44
|
+
if (!current) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (socket && current.socket !== socket) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
state.liveAccounts.delete(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function enqueueSessionWork(sessionKey, work) {
|
|
54
|
+
const key = String(sessionKey);
|
|
55
|
+
const previous = state.sessionQueues.get(key) ?? Promise.resolve();
|
|
56
|
+
const next = previous.catch(() => undefined).then(work);
|
|
57
|
+
|
|
58
|
+
state.sessionQueues.set(
|
|
59
|
+
key,
|
|
60
|
+
next.finally(() => {
|
|
61
|
+
if (state.sessionQueues.get(key) === next) {
|
|
62
|
+
state.sessionQueues.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function hasSeenEvent(eventKey) {
|
|
71
|
+
return state.recentEventIds.has(String(eventKey));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function rememberEvent(eventKey) {
|
|
75
|
+
const key = String(eventKey);
|
|
76
|
+
if (state.recentEventIds.has(key)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Cap the set size: evict the oldest entry (insertion order) when full.
|
|
80
|
+
if (state.recentEventIds.size >= MAX_RECENT_EVENT_IDS) {
|
|
81
|
+
const oldest = state.recentEventIds.values().next().value;
|
|
82
|
+
state.recentEventIds.delete(oldest);
|
|
83
|
+
}
|
|
84
|
+
state.recentEventIds.add(key);
|
|
85
|
+
}
|