cygnet 0.1.0 → 0.1.1
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/dist/index.js +1350 -0
- package/dist/index.js.map +22 -0
- package/package.json +6 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,1350 @@
|
|
|
1
|
+
// src/core/error.ts
|
|
2
|
+
class BotError extends Error {
|
|
3
|
+
error;
|
|
4
|
+
ctx;
|
|
5
|
+
constructor(error, ctx) {
|
|
6
|
+
super(`Bot error caused by update (${getUpdateType(ctx.update.envelope)}): ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? { cause: error } : undefined);
|
|
7
|
+
this.error = error;
|
|
8
|
+
this.ctx = ctx;
|
|
9
|
+
this.name = "BotError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class SignalError extends Error {
|
|
14
|
+
statusCode;
|
|
15
|
+
description;
|
|
16
|
+
constructor(statusCode, description) {
|
|
17
|
+
super(`Signal API error ${statusCode}: ${description}`);
|
|
18
|
+
this.statusCode = statusCode;
|
|
19
|
+
this.description = description;
|
|
20
|
+
this.name = "SignalError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function getUpdateType(envelope) {
|
|
24
|
+
if (envelope.dataMessage)
|
|
25
|
+
return "dataMessage";
|
|
26
|
+
if (envelope.syncMessage)
|
|
27
|
+
return "syncMessage";
|
|
28
|
+
if (envelope.editMessage)
|
|
29
|
+
return "editMessage";
|
|
30
|
+
if (envelope.deleteMessage)
|
|
31
|
+
return "deleteMessage";
|
|
32
|
+
if (envelope.receiptMessage)
|
|
33
|
+
return "receiptMessage";
|
|
34
|
+
if (envelope.typingMessage)
|
|
35
|
+
return "typingMessage";
|
|
36
|
+
if (envelope.callMessage)
|
|
37
|
+
return "callMessage";
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/filter.ts
|
|
42
|
+
function matchFilter(ctx, query) {
|
|
43
|
+
const env = ctx.update.envelope;
|
|
44
|
+
switch (query) {
|
|
45
|
+
case "message":
|
|
46
|
+
return env.dataMessage != null && !env.dataMessage.reaction && env.dataMessage.groupInfo?.type !== "UPDATE";
|
|
47
|
+
case "message:text":
|
|
48
|
+
return env.dataMessage != null && !env.dataMessage.reaction && typeof env.dataMessage.message === "string" && env.dataMessage.message.length > 0;
|
|
49
|
+
case "message:attachments":
|
|
50
|
+
return env.dataMessage != null && !env.dataMessage.reaction && (env.dataMessage.attachments?.length ?? 0) > 0;
|
|
51
|
+
case "message:quote":
|
|
52
|
+
return env.dataMessage != null && !env.dataMessage.reaction && env.dataMessage.quote != null;
|
|
53
|
+
case "message:reaction":
|
|
54
|
+
return env.dataMessage != null && env.dataMessage.reaction != null;
|
|
55
|
+
case "message:group":
|
|
56
|
+
return env.dataMessage != null && !env.dataMessage.reaction && env.dataMessage.groupInfo != null && env.dataMessage.groupInfo.type !== "UPDATE";
|
|
57
|
+
case "message:private":
|
|
58
|
+
return env.dataMessage != null && !env.dataMessage.reaction && env.dataMessage.groupInfo == null;
|
|
59
|
+
case "group_update":
|
|
60
|
+
return env.dataMessage != null && env.dataMessage.groupInfo?.type === "UPDATE";
|
|
61
|
+
case "message:sticker":
|
|
62
|
+
return env.dataMessage != null && !env.dataMessage.reaction && env.dataMessage.sticker != null;
|
|
63
|
+
case "edit_message":
|
|
64
|
+
return env.editMessage != null;
|
|
65
|
+
case "delete_message":
|
|
66
|
+
return env.deleteMessage != null;
|
|
67
|
+
case "receipt":
|
|
68
|
+
return env.receiptMessage != null;
|
|
69
|
+
case "typing":
|
|
70
|
+
return env.typingMessage != null;
|
|
71
|
+
case "call":
|
|
72
|
+
return env.callMessage != null;
|
|
73
|
+
case "sync_message":
|
|
74
|
+
return env.syncMessage != null;
|
|
75
|
+
default:
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/composer.ts
|
|
81
|
+
function flatten(mw) {
|
|
82
|
+
return typeof mw === "function" ? mw : (ctx, next) => mw.middleware()(ctx, next);
|
|
83
|
+
}
|
|
84
|
+
function concat(first, then) {
|
|
85
|
+
return async (ctx, next) => {
|
|
86
|
+
let called = false;
|
|
87
|
+
await first(ctx, async () => {
|
|
88
|
+
if (called)
|
|
89
|
+
throw new Error("next() called multiple times");
|
|
90
|
+
called = true;
|
|
91
|
+
await then(ctx, next);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function run(mw, ctx) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
Promise.resolve(mw(ctx, async () => {})).then(() => resolve(), reject);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function compose(middlewares) {
|
|
101
|
+
const fns = middlewares.map(flatten);
|
|
102
|
+
return fns.reduce(concat, (_ctx, next) => next());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class Composer {
|
|
106
|
+
#handler;
|
|
107
|
+
_onForkError = (err) => console.error("[cygnet] Error in forked middleware:", err);
|
|
108
|
+
constructor(...middleware) {
|
|
109
|
+
this.#handler = middleware.length > 0 ? compose(middleware) : (_ctx, next) => next();
|
|
110
|
+
}
|
|
111
|
+
middleware() {
|
|
112
|
+
return this.#handler;
|
|
113
|
+
}
|
|
114
|
+
use(...middleware) {
|
|
115
|
+
const mw = compose(middleware);
|
|
116
|
+
this.#handler = concat(this.#handler, mw);
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
on(filter, ...middleware) {
|
|
120
|
+
const filters = Array.isArray(filter) ? filter : [filter];
|
|
121
|
+
return this.filter((ctx) => filters.some((q) => matchFilter(ctx, q)), ...middleware);
|
|
122
|
+
}
|
|
123
|
+
chatType(type, ...middleware) {
|
|
124
|
+
return this.filter((ctx) => type === "group" ? ctx.isGroup : !ctx.isGroup, ...middleware);
|
|
125
|
+
}
|
|
126
|
+
hears(trigger, ...middleware) {
|
|
127
|
+
const triggers = Array.isArray(trigger) ? trigger : [trigger];
|
|
128
|
+
return this.filter((ctx) => {
|
|
129
|
+
const text = ctx.text;
|
|
130
|
+
if (!text)
|
|
131
|
+
return false;
|
|
132
|
+
for (const t of triggers) {
|
|
133
|
+
if (typeof t === "string") {
|
|
134
|
+
if (text.includes(t)) {
|
|
135
|
+
ctx.match = t;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const m = t.exec(text);
|
|
140
|
+
if (m) {
|
|
141
|
+
ctx.match = m;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}, ...middleware);
|
|
148
|
+
}
|
|
149
|
+
command(command, ...middleware) {
|
|
150
|
+
const commands = Array.isArray(command) ? command : [command];
|
|
151
|
+
const normalized = commands.map((c) => c.replace(/^\//, "").toLowerCase());
|
|
152
|
+
return this.filter((ctx) => {
|
|
153
|
+
const text = ctx.text;
|
|
154
|
+
if (!text?.startsWith("/"))
|
|
155
|
+
return false;
|
|
156
|
+
const match = /^\/([a-z0-9_]+)(@\S+)?(?:\s(.*))?$/i.exec(text);
|
|
157
|
+
if (!match)
|
|
158
|
+
return false;
|
|
159
|
+
const cmd = (match[1] ?? "").toLowerCase();
|
|
160
|
+
if (!normalized.includes(cmd))
|
|
161
|
+
return false;
|
|
162
|
+
ctx.match = (match[3] ?? "").trim();
|
|
163
|
+
return true;
|
|
164
|
+
}, ...middleware);
|
|
165
|
+
}
|
|
166
|
+
filter(predicate, ...middleware) {
|
|
167
|
+
const mw = compose(middleware);
|
|
168
|
+
return this.use(async (ctx, next) => {
|
|
169
|
+
if (await predicate(ctx)) {
|
|
170
|
+
await mw(ctx, next);
|
|
171
|
+
} else {
|
|
172
|
+
await next();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
drop(predicate) {
|
|
177
|
+
return this.use(async (ctx, next) => {
|
|
178
|
+
if (!await predicate(ctx))
|
|
179
|
+
await next();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
branch(predicate, trueMiddleware, falseMiddleware) {
|
|
183
|
+
return this.use(async (ctx, next) => {
|
|
184
|
+
if (await predicate(ctx)) {
|
|
185
|
+
await flatten(trueMiddleware)(ctx, next);
|
|
186
|
+
} else {
|
|
187
|
+
await flatten(falseMiddleware)(ctx, next);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
fork(...middleware) {
|
|
192
|
+
const mw = compose(middleware);
|
|
193
|
+
return this.use((ctx, next) => {
|
|
194
|
+
run(mw, ctx).catch((err) => this._onForkError(err, ctx));
|
|
195
|
+
return next();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
lazy(factory) {
|
|
199
|
+
return this.use(async (ctx, next) => {
|
|
200
|
+
const result = await factory(ctx);
|
|
201
|
+
const middlewares = Array.isArray(result) ? result : [result];
|
|
202
|
+
const mw = compose(middlewares);
|
|
203
|
+
await mw(ctx, next);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
errorBoundary(errorHandler, ...middleware) {
|
|
207
|
+
const mw = compose(middleware);
|
|
208
|
+
return this.use(async (ctx, next) => {
|
|
209
|
+
try {
|
|
210
|
+
await run(mw, ctx);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
await errorHandler(new BotError(err, ctx), next);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
await next();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/context.ts
|
|
221
|
+
var groupStateCaches = new Map;
|
|
222
|
+
function groupHasMember(group, memberId) {
|
|
223
|
+
if (group.isMember !== undefined)
|
|
224
|
+
return group.isMember;
|
|
225
|
+
const members = group.members;
|
|
226
|
+
if (!Array.isArray(members))
|
|
227
|
+
return false;
|
|
228
|
+
return members.some((member) => typeof member === "string" ? member === memberId : member.number === memberId || member.uuid === memberId);
|
|
229
|
+
}
|
|
230
|
+
function getGroupStateCache(botId) {
|
|
231
|
+
let cache = groupStateCaches.get(botId);
|
|
232
|
+
if (!cache) {
|
|
233
|
+
cache = new Map;
|
|
234
|
+
groupStateCaches.set(botId, cache);
|
|
235
|
+
}
|
|
236
|
+
return cache;
|
|
237
|
+
}
|
|
238
|
+
function primeGroupStateCache(botId, groups) {
|
|
239
|
+
const cache = getGroupStateCache(botId);
|
|
240
|
+
const seen = new Set;
|
|
241
|
+
for (const group of groups) {
|
|
242
|
+
seen.add(group.id);
|
|
243
|
+
const previous = cache.get(group.id);
|
|
244
|
+
cache.set(group.id, {
|
|
245
|
+
name: group.name,
|
|
246
|
+
isMember: groupHasMember(group, botId),
|
|
247
|
+
revision: group.revision ?? previous?.revision
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
for (const [groupId, previous] of cache) {
|
|
251
|
+
if (!seen.has(groupId) && previous.isMember) {
|
|
252
|
+
cache.set(groupId, {
|
|
253
|
+
...previous,
|
|
254
|
+
isMember: false
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function restoreGroupStateCache(botId, snapshot) {
|
|
260
|
+
const cache = getGroupStateCache(botId);
|
|
261
|
+
for (const [groupId, state] of Object.entries(snapshot)) {
|
|
262
|
+
cache.set(groupId, {
|
|
263
|
+
name: state.name,
|
|
264
|
+
isMember: state.isMember,
|
|
265
|
+
revision: state.revision
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function snapshotGroupStateCache(botId) {
|
|
270
|
+
const cache = getGroupStateCache(botId);
|
|
271
|
+
const snapshot = {};
|
|
272
|
+
for (const [groupId, state] of cache) {
|
|
273
|
+
snapshot[groupId] = {
|
|
274
|
+
name: state.name,
|
|
275
|
+
isMember: state.isMember,
|
|
276
|
+
revision: state.revision
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return snapshot;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
class Context {
|
|
283
|
+
update;
|
|
284
|
+
api;
|
|
285
|
+
me;
|
|
286
|
+
match;
|
|
287
|
+
constructor(update, api, me) {
|
|
288
|
+
this.update = update;
|
|
289
|
+
this.api = api;
|
|
290
|
+
this.me = me;
|
|
291
|
+
}
|
|
292
|
+
get dataMessage() {
|
|
293
|
+
return this.update.envelope.dataMessage;
|
|
294
|
+
}
|
|
295
|
+
get syncMessage() {
|
|
296
|
+
return this.update.envelope.syncMessage;
|
|
297
|
+
}
|
|
298
|
+
get editMessage() {
|
|
299
|
+
return this.update.envelope.editMessage;
|
|
300
|
+
}
|
|
301
|
+
get deleteMessage() {
|
|
302
|
+
return this.update.envelope.deleteMessage;
|
|
303
|
+
}
|
|
304
|
+
get receipt() {
|
|
305
|
+
return this.update.envelope.receiptMessage;
|
|
306
|
+
}
|
|
307
|
+
get typingMessage() {
|
|
308
|
+
return this.update.envelope.typingMessage;
|
|
309
|
+
}
|
|
310
|
+
get callMessage() {
|
|
311
|
+
return this.update.envelope.callMessage;
|
|
312
|
+
}
|
|
313
|
+
get message() {
|
|
314
|
+
const dm = this.update.envelope.dataMessage;
|
|
315
|
+
return dm && !dm.reaction && dm.groupInfo?.type !== "UPDATE" ? dm : undefined;
|
|
316
|
+
}
|
|
317
|
+
get reaction() {
|
|
318
|
+
const dm = this.update.envelope.dataMessage;
|
|
319
|
+
return dm?.reaction ?? undefined;
|
|
320
|
+
}
|
|
321
|
+
get groupUpdate() {
|
|
322
|
+
const dm = this.update.envelope.dataMessage;
|
|
323
|
+
return dm?.groupInfo?.type === "UPDATE" ? dm : undefined;
|
|
324
|
+
}
|
|
325
|
+
get from() {
|
|
326
|
+
return this.update.envelope.sourceNumber || undefined;
|
|
327
|
+
}
|
|
328
|
+
get fromUuid() {
|
|
329
|
+
return this.update.envelope.sourceUuid || this.update.envelope.source || undefined;
|
|
330
|
+
}
|
|
331
|
+
get fromName() {
|
|
332
|
+
return this.update.envelope.sourceName || undefined;
|
|
333
|
+
}
|
|
334
|
+
get sender() {
|
|
335
|
+
return this.update.envelope.sourceNumber || this.update.envelope.sourceUuid || this.update.envelope.source;
|
|
336
|
+
}
|
|
337
|
+
get chat() {
|
|
338
|
+
const dm = this.update.envelope.dataMessage;
|
|
339
|
+
if (dm?.groupInfo?.groupId)
|
|
340
|
+
return `group.${btoa(dm.groupInfo.groupId)}`;
|
|
341
|
+
const em = this.update.envelope.editMessage;
|
|
342
|
+
if (em?.message?.groupInfo?.groupId)
|
|
343
|
+
return `group.${btoa(em.message.groupInfo.groupId)}`;
|
|
344
|
+
const typing = this.update.envelope.typingMessage;
|
|
345
|
+
if (typing?.groupId)
|
|
346
|
+
return `group.${btoa(typing.groupId)}`;
|
|
347
|
+
return this.update.envelope.sourceNumber || this.update.envelope.source;
|
|
348
|
+
}
|
|
349
|
+
get isGroup() {
|
|
350
|
+
return this.update.envelope.dataMessage?.groupInfo != null || this.update.envelope.editMessage?.message?.groupInfo != null;
|
|
351
|
+
}
|
|
352
|
+
get text() {
|
|
353
|
+
const dm = this.update.envelope.dataMessage;
|
|
354
|
+
if (dm && !dm.reaction && typeof dm.message === "string") {
|
|
355
|
+
return dm.message;
|
|
356
|
+
}
|
|
357
|
+
const em = this.update.envelope.editMessage;
|
|
358
|
+
if (em && typeof em.message.message === "string") {
|
|
359
|
+
return em.message.message;
|
|
360
|
+
}
|
|
361
|
+
return "";
|
|
362
|
+
}
|
|
363
|
+
get msgTimestamp() {
|
|
364
|
+
return this.update.envelope.dataMessage?.timestamp ?? this.update.envelope.editMessage?.message?.timestamp ?? this.update.envelope.timestamp;
|
|
365
|
+
}
|
|
366
|
+
async reply(text, options = {}) {
|
|
367
|
+
await this.api.send(this.chat, text, options);
|
|
368
|
+
}
|
|
369
|
+
async quote(text, options = {}) {
|
|
370
|
+
const ts = this.msgTimestamp;
|
|
371
|
+
const author = this.sender;
|
|
372
|
+
if (!ts || !author) {
|
|
373
|
+
return this.reply(text, options);
|
|
374
|
+
}
|
|
375
|
+
await this.api.send(this.chat, text, {
|
|
376
|
+
...options,
|
|
377
|
+
quote: {
|
|
378
|
+
timestamp: ts,
|
|
379
|
+
author,
|
|
380
|
+
text: this.text || undefined
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async react(emoji) {
|
|
385
|
+
const ts = this.msgTimestamp;
|
|
386
|
+
const author = this.sender;
|
|
387
|
+
if (!ts || !author)
|
|
388
|
+
throw new Error("Cannot react: no message timestamp or author");
|
|
389
|
+
await this.api.react(this.chat, {
|
|
390
|
+
reaction: emoji,
|
|
391
|
+
targetAuthor: author,
|
|
392
|
+
targetTimestamp: ts
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
async unreact(emoji) {
|
|
396
|
+
const ts = this.msgTimestamp;
|
|
397
|
+
const author = this.sender;
|
|
398
|
+
if (!ts || !author)
|
|
399
|
+
throw new Error("Cannot unreact: no message timestamp or author");
|
|
400
|
+
await this.api.react(this.chat, {
|
|
401
|
+
reaction: emoji,
|
|
402
|
+
targetAuthor: author,
|
|
403
|
+
targetTimestamp: ts,
|
|
404
|
+
isRemove: true
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async typing(stop = false) {
|
|
408
|
+
await this.api.typing(this.chat, stop);
|
|
409
|
+
}
|
|
410
|
+
async deleteMsg(timestamp) {
|
|
411
|
+
const ts = timestamp ?? this.msgTimestamp;
|
|
412
|
+
if (!ts)
|
|
413
|
+
throw new Error("Cannot delete: no timestamp");
|
|
414
|
+
await this.api.deleteMessage(this.chat, ts);
|
|
415
|
+
}
|
|
416
|
+
has(query) {
|
|
417
|
+
return matchFilter(this, query);
|
|
418
|
+
}
|
|
419
|
+
hasText(trigger) {
|
|
420
|
+
const text = this.text;
|
|
421
|
+
if (!text)
|
|
422
|
+
return false;
|
|
423
|
+
if (trigger === undefined)
|
|
424
|
+
return true;
|
|
425
|
+
if (typeof trigger === "string") {
|
|
426
|
+
if (text.includes(trigger)) {
|
|
427
|
+
this.match = trigger;
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
const m = trigger.exec(text);
|
|
433
|
+
if (m) {
|
|
434
|
+
this.match = m;
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
hasChatType(type) {
|
|
440
|
+
return type === "group" ? this.isGroup : !this.isGroup;
|
|
441
|
+
}
|
|
442
|
+
async inspectGroupUpdate() {
|
|
443
|
+
const update = this.groupUpdate;
|
|
444
|
+
const info = update?.groupInfo;
|
|
445
|
+
if (!update || !info?.groupId)
|
|
446
|
+
return;
|
|
447
|
+
const groupId = `group.${btoa(info.groupId)}`;
|
|
448
|
+
const cache = getGroupStateCache(this.me);
|
|
449
|
+
const previous = cache.get(groupId);
|
|
450
|
+
const incomingRevision = info.revision;
|
|
451
|
+
const previousRevision = previous?.revision;
|
|
452
|
+
if (previousRevision !== undefined && incomingRevision !== undefined && incomingRevision <= previousRevision) {
|
|
453
|
+
return {
|
|
454
|
+
kind: "stale",
|
|
455
|
+
groupId,
|
|
456
|
+
groupName: info.groupName ?? previous?.name,
|
|
457
|
+
revision: incomingRevision,
|
|
458
|
+
previousName: previous?.name,
|
|
459
|
+
previousRevision
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const hasGap = previousRevision !== undefined && incomingRevision !== undefined && incomingRevision > previousRevision + 1;
|
|
463
|
+
if (hasGap) {
|
|
464
|
+
console.warn(`[cygnet] Group update gap for ${groupId}: expected revision ${previousRevision + 1}, got ${incomingRevision}`);
|
|
465
|
+
}
|
|
466
|
+
const nameChanged = previous?.name !== undefined && info.groupName !== undefined && previous.name !== info.groupName;
|
|
467
|
+
const needsReconcile = hasGap || !previous || !previous.isMember || !nameChanged;
|
|
468
|
+
let currentGroup;
|
|
469
|
+
if (needsReconcile) {
|
|
470
|
+
try {
|
|
471
|
+
const groups = await this.api.getGroups();
|
|
472
|
+
currentGroup = groups.find((group) => group.id === groupId);
|
|
473
|
+
} catch {}
|
|
474
|
+
}
|
|
475
|
+
let kind = "unknown";
|
|
476
|
+
let nextState;
|
|
477
|
+
if (!needsReconcile && previous) {
|
|
478
|
+
kind = "renamed";
|
|
479
|
+
nextState = {
|
|
480
|
+
name: info.groupName ?? previous.name,
|
|
481
|
+
isMember: true,
|
|
482
|
+
revision: incomingRevision ?? previousRevision
|
|
483
|
+
};
|
|
484
|
+
} else if (currentGroup) {
|
|
485
|
+
const isMember = groupHasMember(currentGroup, this.me);
|
|
486
|
+
if (!isMember) {
|
|
487
|
+
kind = previous?.isMember ? "left" : "unknown";
|
|
488
|
+
nextState = {
|
|
489
|
+
name: currentGroup.name || (info.groupName ?? previous?.name),
|
|
490
|
+
isMember: false,
|
|
491
|
+
revision: incomingRevision ?? currentGroup.revision ?? previousRevision
|
|
492
|
+
};
|
|
493
|
+
} else if (!previous || !previous.isMember) {
|
|
494
|
+
kind = "joined";
|
|
495
|
+
nextState = {
|
|
496
|
+
name: currentGroup.name || info.groupName,
|
|
497
|
+
isMember: true,
|
|
498
|
+
revision: incomingRevision ?? currentGroup.revision ?? previousRevision
|
|
499
|
+
};
|
|
500
|
+
} else if (nameChanged) {
|
|
501
|
+
kind = "renamed";
|
|
502
|
+
nextState = {
|
|
503
|
+
name: currentGroup.name || info.groupName,
|
|
504
|
+
isMember: true,
|
|
505
|
+
revision: incomingRevision ?? currentGroup.revision ?? previousRevision
|
|
506
|
+
};
|
|
507
|
+
} else {
|
|
508
|
+
kind = "updated";
|
|
509
|
+
nextState = {
|
|
510
|
+
name: currentGroup.name || (info.groupName ?? previous.name),
|
|
511
|
+
isMember: true,
|
|
512
|
+
revision: incomingRevision ?? currentGroup.revision ?? previousRevision
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
} else if (!previous) {
|
|
516
|
+
nextState = {
|
|
517
|
+
name: info.groupName,
|
|
518
|
+
isMember: false,
|
|
519
|
+
revision: incomingRevision
|
|
520
|
+
};
|
|
521
|
+
} else if (!previous.isMember) {
|
|
522
|
+
nextState = {
|
|
523
|
+
name: info.groupName ?? previous.name,
|
|
524
|
+
isMember: false,
|
|
525
|
+
revision: incomingRevision ?? previousRevision
|
|
526
|
+
};
|
|
527
|
+
} else if (nameChanged) {
|
|
528
|
+
kind = "renamed";
|
|
529
|
+
nextState = {
|
|
530
|
+
name: info.groupName ?? previous.name,
|
|
531
|
+
isMember: true,
|
|
532
|
+
revision: incomingRevision ?? previousRevision
|
|
533
|
+
};
|
|
534
|
+
} else {
|
|
535
|
+
kind = "updated";
|
|
536
|
+
nextState = {
|
|
537
|
+
name: info.groupName ?? previous.name,
|
|
538
|
+
isMember: true,
|
|
539
|
+
revision: incomingRevision ?? previousRevision
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
cache.set(groupId, nextState);
|
|
543
|
+
const missedRevisions = hasGap && previousRevision !== undefined && incomingRevision !== undefined ? incomingRevision - previousRevision - 1 : undefined;
|
|
544
|
+
return {
|
|
545
|
+
kind,
|
|
546
|
+
groupId,
|
|
547
|
+
groupName: currentGroup?.name || nextState.name,
|
|
548
|
+
revision: info.revision,
|
|
549
|
+
previousName: previous?.name,
|
|
550
|
+
previousRevision,
|
|
551
|
+
missedRevisions,
|
|
552
|
+
currentGroup
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/core/client.ts
|
|
558
|
+
class HttpClient {
|
|
559
|
+
baseUrl;
|
|
560
|
+
phoneNumber;
|
|
561
|
+
constructor(config) {
|
|
562
|
+
let url = config.baseUrl;
|
|
563
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
564
|
+
url = "http://" + url;
|
|
565
|
+
}
|
|
566
|
+
this.baseUrl = url.replace(/\/$/, "");
|
|
567
|
+
this.phoneNumber = config.phoneNumber;
|
|
568
|
+
}
|
|
569
|
+
async get(path) {
|
|
570
|
+
const res = await fetch(this.baseUrl + path, {
|
|
571
|
+
method: "GET",
|
|
572
|
+
headers: { "Content-Type": "application/json" }
|
|
573
|
+
});
|
|
574
|
+
return this.#handleResponse(res);
|
|
575
|
+
}
|
|
576
|
+
async post(path, body) {
|
|
577
|
+
const res = await fetch(this.baseUrl + path, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
headers: { "Content-Type": "application/json" },
|
|
580
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
581
|
+
});
|
|
582
|
+
return this.#handleResponse(res);
|
|
583
|
+
}
|
|
584
|
+
async#handleResponse(res) {
|
|
585
|
+
if (res.ok) {
|
|
586
|
+
const text = await res.text();
|
|
587
|
+
if (!text)
|
|
588
|
+
return;
|
|
589
|
+
try {
|
|
590
|
+
return JSON.parse(text);
|
|
591
|
+
} catch {
|
|
592
|
+
return text;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
let description;
|
|
596
|
+
try {
|
|
597
|
+
const body = await res.json();
|
|
598
|
+
description = body.error ?? body.message ?? res.statusText;
|
|
599
|
+
} catch {
|
|
600
|
+
description = res.statusText;
|
|
601
|
+
}
|
|
602
|
+
throw new SignalError(res.status, description);
|
|
603
|
+
}
|
|
604
|
+
wsReceiveUrl() {
|
|
605
|
+
const wsBase = this.baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
606
|
+
return `${wsBase}/v1/receive/${encodeURIComponent(this.phoneNumber)}`;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/core/api.ts
|
|
611
|
+
class SignalAPI {
|
|
612
|
+
#client;
|
|
613
|
+
constructor(config) {
|
|
614
|
+
this.#client = new HttpClient(config);
|
|
615
|
+
}
|
|
616
|
+
get phoneNumber() {
|
|
617
|
+
return this.#client.phoneNumber;
|
|
618
|
+
}
|
|
619
|
+
async checkHealth() {
|
|
620
|
+
try {
|
|
621
|
+
await this.#client.get("/v1/health");
|
|
622
|
+
return true;
|
|
623
|
+
} catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async send(recipient, text, options = {}) {
|
|
628
|
+
const payload = {
|
|
629
|
+
number: this.#client.phoneNumber,
|
|
630
|
+
message: text,
|
|
631
|
+
recipients: [recipient]
|
|
632
|
+
};
|
|
633
|
+
if (options.base64Attachments?.length) {
|
|
634
|
+
payload.base64_attachments = options.base64Attachments;
|
|
635
|
+
}
|
|
636
|
+
if (options.quote) {
|
|
637
|
+
payload.quote_timestamp = options.quote.timestamp;
|
|
638
|
+
payload.quote_author = options.quote.author;
|
|
639
|
+
if (options.quote.text !== undefined)
|
|
640
|
+
payload.quote_message = options.quote.text;
|
|
641
|
+
if (options.quote.mentions?.length)
|
|
642
|
+
payload.quote_mentions = options.quote.mentions;
|
|
643
|
+
}
|
|
644
|
+
if (options.mentions?.length) {
|
|
645
|
+
payload.mentions = options.mentions;
|
|
646
|
+
}
|
|
647
|
+
if (options.textMode) {
|
|
648
|
+
payload.text_mode = options.textMode;
|
|
649
|
+
}
|
|
650
|
+
if (options.viewOnce) {
|
|
651
|
+
payload.view_once = options.viewOnce;
|
|
652
|
+
}
|
|
653
|
+
if (options.editTimestamp !== undefined) {
|
|
654
|
+
payload.edit_timestamp = options.editTimestamp;
|
|
655
|
+
}
|
|
656
|
+
return this.#client.post("/v2/send", payload);
|
|
657
|
+
}
|
|
658
|
+
async react(recipient, reaction) {
|
|
659
|
+
const payload = {
|
|
660
|
+
recipient,
|
|
661
|
+
reaction: reaction.reaction,
|
|
662
|
+
target_author: reaction.targetAuthor,
|
|
663
|
+
timestamp: reaction.targetTimestamp,
|
|
664
|
+
remove: reaction.isRemove ?? false
|
|
665
|
+
};
|
|
666
|
+
await this.#client.post(`/v1/${encodeURIComponent(this.#client.phoneNumber)}/reaction`, payload);
|
|
667
|
+
}
|
|
668
|
+
async typing(recipient, stop = false) {
|
|
669
|
+
const payload = {
|
|
670
|
+
account: this.#client.phoneNumber,
|
|
671
|
+
recipient,
|
|
672
|
+
stop
|
|
673
|
+
};
|
|
674
|
+
await this.#client.post("/v1/typing", payload);
|
|
675
|
+
}
|
|
676
|
+
async deleteMessage(recipient, timestamp) {
|
|
677
|
+
const payload = {
|
|
678
|
+
recipient,
|
|
679
|
+
timestamp
|
|
680
|
+
};
|
|
681
|
+
await this.#client.post(`/v1/${encodeURIComponent(this.#client.phoneNumber)}/delete-message`, payload);
|
|
682
|
+
}
|
|
683
|
+
async editMessage(recipient, targetTimestamp, newText, options = {}) {
|
|
684
|
+
return this.send(recipient, newText, {
|
|
685
|
+
...options,
|
|
686
|
+
editTimestamp: targetTimestamp
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async getGroups() {
|
|
690
|
+
return this.#client.get(`/v1/groups/${encodeURIComponent(this.#client.phoneNumber)}`);
|
|
691
|
+
}
|
|
692
|
+
get httpClient() {
|
|
693
|
+
return this.#client;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/core/polling.ts
|
|
698
|
+
class PollingListener {
|
|
699
|
+
#client;
|
|
700
|
+
#receivePath;
|
|
701
|
+
#interval;
|
|
702
|
+
#stopped = false;
|
|
703
|
+
constructor(client, options = {}) {
|
|
704
|
+
this.#client = client;
|
|
705
|
+
this.#receivePath = `/v1/receive/${encodeURIComponent(client.phoneNumber)}`;
|
|
706
|
+
this.#interval = options.interval ?? 1000;
|
|
707
|
+
}
|
|
708
|
+
stop() {
|
|
709
|
+
this.#stopped = true;
|
|
710
|
+
}
|
|
711
|
+
async* [Symbol.asyncIterator]() {
|
|
712
|
+
while (!this.#stopped) {
|
|
713
|
+
try {
|
|
714
|
+
const updates = await this.#client.get(this.#receivePath);
|
|
715
|
+
if (Array.isArray(updates)) {
|
|
716
|
+
for (const update of updates) {
|
|
717
|
+
if (this.#stopped)
|
|
718
|
+
return;
|
|
719
|
+
yield update;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} catch (err) {
|
|
723
|
+
if (this.#stopped)
|
|
724
|
+
return;
|
|
725
|
+
console.error("[cygnet] Polling error:", err);
|
|
726
|
+
}
|
|
727
|
+
if (!this.#stopped) {
|
|
728
|
+
await sleep(this.#interval);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function sleep(ms) {
|
|
734
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/core/websocket.ts
|
|
738
|
+
class WebSocketListener {
|
|
739
|
+
#url;
|
|
740
|
+
#maxDelay;
|
|
741
|
+
#initialDelay;
|
|
742
|
+
#stopped = false;
|
|
743
|
+
#ws = null;
|
|
744
|
+
constructor(wsUrl, options = {}) {
|
|
745
|
+
this.#url = wsUrl;
|
|
746
|
+
this.#maxDelay = options.maxReconnectDelay ?? 30000;
|
|
747
|
+
this.#initialDelay = options.initialReconnectDelay ?? 1000;
|
|
748
|
+
}
|
|
749
|
+
stop() {
|
|
750
|
+
this.#stopped = true;
|
|
751
|
+
this.#ws?.close();
|
|
752
|
+
}
|
|
753
|
+
async* [Symbol.asyncIterator]() {
|
|
754
|
+
let delay = this.#initialDelay;
|
|
755
|
+
while (!this.#stopped) {
|
|
756
|
+
try {
|
|
757
|
+
yield* this.#connect();
|
|
758
|
+
delay = this.#initialDelay;
|
|
759
|
+
} catch (err) {
|
|
760
|
+
if (this.#stopped)
|
|
761
|
+
break;
|
|
762
|
+
console.error(`[cygnet] WebSocket error, reconnecting in ${delay}ms:`, err);
|
|
763
|
+
await sleep2(delay);
|
|
764
|
+
delay = Math.min(delay * 2, this.#maxDelay);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async* #connect() {
|
|
769
|
+
const ws = new WebSocket(this.#url);
|
|
770
|
+
this.#ws = ws;
|
|
771
|
+
const queue = [];
|
|
772
|
+
let resolve = null;
|
|
773
|
+
let closed = false;
|
|
774
|
+
let closeError = null;
|
|
775
|
+
ws.addEventListener("message", (event) => {
|
|
776
|
+
try {
|
|
777
|
+
const data = JSON.parse(event.data);
|
|
778
|
+
queue.push(data);
|
|
779
|
+
resolve?.();
|
|
780
|
+
resolve = null;
|
|
781
|
+
} catch (err) {
|
|
782
|
+
console.error("[cygnet] Failed to parse WebSocket message:", err);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
ws.addEventListener("close", (event) => {
|
|
786
|
+
closed = true;
|
|
787
|
+
if (!event.wasClean && event.code !== 1000) {
|
|
788
|
+
closeError = new Error(`WebSocket closed with code ${event.code}: ${event.reason}`);
|
|
789
|
+
}
|
|
790
|
+
resolve?.();
|
|
791
|
+
resolve = null;
|
|
792
|
+
});
|
|
793
|
+
ws.addEventListener("error", (event) => {
|
|
794
|
+
closeError = new Error(`WebSocket error: ${event.message ?? "unknown"}`);
|
|
795
|
+
resolve?.();
|
|
796
|
+
resolve = null;
|
|
797
|
+
});
|
|
798
|
+
await new Promise((res, rej) => {
|
|
799
|
+
ws.addEventListener("open", () => res());
|
|
800
|
+
ws.addEventListener("error", (e) => rej(new Error(`Failed to connect: ${e.message ?? "unknown"}`)));
|
|
801
|
+
});
|
|
802
|
+
try {
|
|
803
|
+
while (!this.#stopped) {
|
|
804
|
+
while (queue.length > 0) {
|
|
805
|
+
yield queue.shift();
|
|
806
|
+
}
|
|
807
|
+
if (closed)
|
|
808
|
+
break;
|
|
809
|
+
await new Promise((res) => {
|
|
810
|
+
resolve = res;
|
|
811
|
+
if (queue.length > 0 || closed) {
|
|
812
|
+
resolve = null;
|
|
813
|
+
res();
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
} finally {
|
|
818
|
+
ws.close();
|
|
819
|
+
this.#ws = null;
|
|
820
|
+
}
|
|
821
|
+
if (closeError)
|
|
822
|
+
throw closeError;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function sleep2(ms) {
|
|
826
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/bot.ts
|
|
830
|
+
class Bot extends Composer {
|
|
831
|
+
api;
|
|
832
|
+
config;
|
|
833
|
+
#me = "";
|
|
834
|
+
#stopped = false;
|
|
835
|
+
#listener = null;
|
|
836
|
+
#errorHandler = defaultErrorHandler;
|
|
837
|
+
constructor(config) {
|
|
838
|
+
super();
|
|
839
|
+
this.config = config;
|
|
840
|
+
this.api = new SignalAPI({
|
|
841
|
+
baseUrl: config.signalService,
|
|
842
|
+
phoneNumber: config.phoneNumber
|
|
843
|
+
});
|
|
844
|
+
this._onForkError = (err, ctx) => {
|
|
845
|
+
const botError = new BotError(err, ctx);
|
|
846
|
+
Promise.resolve(this.#errorHandler(botError)).catch((handlerErr) => {
|
|
847
|
+
console.error("[cygnet] Error in error handler:", handlerErr);
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
catch(handler) {
|
|
852
|
+
this.#errorHandler = handler;
|
|
853
|
+
return this;
|
|
854
|
+
}
|
|
855
|
+
async init() {
|
|
856
|
+
const healthy = await this.api.checkHealth();
|
|
857
|
+
if (!healthy) {
|
|
858
|
+
throw new Error(`[cygnet] Cannot reach signal-cli-rest-api at ${this.config.signalService}. Is it running?`);
|
|
859
|
+
}
|
|
860
|
+
this.#me = this.config.phoneNumber;
|
|
861
|
+
await this.#restoreGroupStateCache();
|
|
862
|
+
try {
|
|
863
|
+
const groups = await this.api.getGroups();
|
|
864
|
+
primeGroupStateCache(this.#me, groups);
|
|
865
|
+
await this.#persistGroupStateCache();
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.warn("[cygnet] Failed to prime group state cache:", err);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
async start() {
|
|
871
|
+
this.#stopped = false;
|
|
872
|
+
await this.init();
|
|
873
|
+
const transport = this.config.transport ?? "websocket";
|
|
874
|
+
if (transport === "polling") {
|
|
875
|
+
this.#listener = new PollingListener(this.api.httpClient, {
|
|
876
|
+
interval: this.config.pollingInterval
|
|
877
|
+
});
|
|
878
|
+
console.log(`[cygnet] Bot started as ${this.#me} (polling)`);
|
|
879
|
+
} else {
|
|
880
|
+
const wsUrl = this.api.httpClient.wsReceiveUrl();
|
|
881
|
+
this.#listener = new WebSocketListener(wsUrl);
|
|
882
|
+
console.log(`[cygnet] Bot started as ${this.#me} (websocket)`);
|
|
883
|
+
}
|
|
884
|
+
for await (const update of this.#listener) {
|
|
885
|
+
if (this.#stopped)
|
|
886
|
+
break;
|
|
887
|
+
await this.handleUpdate(update);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
stop() {
|
|
891
|
+
this.#stopped = true;
|
|
892
|
+
this.#listener?.stop();
|
|
893
|
+
console.log("[cygnet] Bot stopped.");
|
|
894
|
+
}
|
|
895
|
+
async handleUpdate(update) {
|
|
896
|
+
const ContextClass = this.config.ContextConstructor ?? Context;
|
|
897
|
+
const ctx = new ContextClass(update, this.api, this.#me);
|
|
898
|
+
try {
|
|
899
|
+
await run(this.middleware(), ctx);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
const botError = new BotError(err, ctx);
|
|
902
|
+
try {
|
|
903
|
+
await this.#errorHandler(botError);
|
|
904
|
+
} catch (handlerErr) {
|
|
905
|
+
console.error("[cygnet] Error in error handler:", handlerErr);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (update.envelope.dataMessage?.groupInfo?.type === "UPDATE") {
|
|
909
|
+
await this.#persistGroupStateCache();
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async#restoreGroupStateCache() {
|
|
913
|
+
const storage = this.config.groupStateStorage;
|
|
914
|
+
if (!storage)
|
|
915
|
+
return;
|
|
916
|
+
try {
|
|
917
|
+
const snapshot = await storage.read(this.#groupStateKey());
|
|
918
|
+
if (!snapshot)
|
|
919
|
+
return;
|
|
920
|
+
if (typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
921
|
+
console.warn("[cygnet] Ignoring invalid group state snapshot.");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
restoreGroupStateCache(this.#me, snapshot);
|
|
925
|
+
} catch (err) {
|
|
926
|
+
console.warn("[cygnet] Failed to restore group state cache:", err);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async#persistGroupStateCache() {
|
|
930
|
+
const storage = this.config.groupStateStorage;
|
|
931
|
+
if (!storage)
|
|
932
|
+
return;
|
|
933
|
+
try {
|
|
934
|
+
const snapshot = snapshotGroupStateCache(this.#me);
|
|
935
|
+
await storage.write(this.#groupStateKey(), snapshot);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
console.warn("[cygnet] Failed to persist group state cache:", err);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
#groupStateKey() {
|
|
941
|
+
return this.config.groupStateKey ?? this.#me;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
function defaultErrorHandler(err) {
|
|
945
|
+
console.error("[cygnet] Unhandled error:", err.error);
|
|
946
|
+
console.error("[cygnet] Set bot.catch() to handle errors");
|
|
947
|
+
}
|
|
948
|
+
// src/convenience/session.ts
|
|
949
|
+
import { readFile, writeFile } from "fs/promises";
|
|
950
|
+
var directStorageBrand = Symbol("cygnet.directStorage");
|
|
951
|
+
function session(options = {}) {
|
|
952
|
+
const storage = options.storage ?? new MemoryStorage;
|
|
953
|
+
const getKey = options.getSessionKey ?? ((ctx) => ctx.chat);
|
|
954
|
+
const initial = options.initial;
|
|
955
|
+
const prefix = options.keyPrefix ?? "";
|
|
956
|
+
return async (ctx, next) => {
|
|
957
|
+
const rawKey = await getKey(ctx);
|
|
958
|
+
const key = rawKey === undefined ? undefined : prefix + rawKey;
|
|
959
|
+
if (key === undefined) {
|
|
960
|
+
return next();
|
|
961
|
+
}
|
|
962
|
+
let data = await storage.read(key);
|
|
963
|
+
if (data === undefined && initial) {
|
|
964
|
+
data = initial();
|
|
965
|
+
}
|
|
966
|
+
ctx.session = data;
|
|
967
|
+
await next();
|
|
968
|
+
const updated = ctx.session;
|
|
969
|
+
if (updated === null || updated === undefined) {
|
|
970
|
+
await storage.delete(key);
|
|
971
|
+
} else {
|
|
972
|
+
await storage.write(key, updated);
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
class MemoryStorage {
|
|
978
|
+
[directStorageBrand] = true;
|
|
979
|
+
#store = new Map;
|
|
980
|
+
read(key) {
|
|
981
|
+
return this.#store.get(key);
|
|
982
|
+
}
|
|
983
|
+
write(key, value) {
|
|
984
|
+
this.#store.set(key, value);
|
|
985
|
+
}
|
|
986
|
+
delete(key) {
|
|
987
|
+
this.#store.delete(key);
|
|
988
|
+
}
|
|
989
|
+
keys() {
|
|
990
|
+
return this.#store.keys();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
class FileStorage {
|
|
995
|
+
[directStorageBrand] = true;
|
|
996
|
+
#path;
|
|
997
|
+
constructor(path) {
|
|
998
|
+
this.#path = path;
|
|
999
|
+
}
|
|
1000
|
+
async read(key) {
|
|
1001
|
+
const store = await this.#readStore();
|
|
1002
|
+
return store[key];
|
|
1003
|
+
}
|
|
1004
|
+
async write(key, value) {
|
|
1005
|
+
const store = await this.#readStore();
|
|
1006
|
+
store[key] = value;
|
|
1007
|
+
await this.#writeStore(store);
|
|
1008
|
+
}
|
|
1009
|
+
async delete(key) {
|
|
1010
|
+
const store = await this.#readStore();
|
|
1011
|
+
if (!(key in store))
|
|
1012
|
+
return;
|
|
1013
|
+
delete store[key];
|
|
1014
|
+
await this.#writeStore(store);
|
|
1015
|
+
}
|
|
1016
|
+
async#readStore() {
|
|
1017
|
+
try {
|
|
1018
|
+
const text = await readFile(this.#path, "utf8");
|
|
1019
|
+
if (!text)
|
|
1020
|
+
return {};
|
|
1021
|
+
const parsed = JSON.parse(text);
|
|
1022
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1023
|
+
return {};
|
|
1024
|
+
}
|
|
1025
|
+
return parsed;
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : "";
|
|
1028
|
+
if (code === "ENOENT")
|
|
1029
|
+
return {};
|
|
1030
|
+
throw err;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
async#writeStore(store) {
|
|
1034
|
+
await writeFile(this.#path, JSON.stringify(store), "utf8");
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
function enhanceStorage(options) {
|
|
1038
|
+
const { storage, ttl } = options;
|
|
1039
|
+
return {
|
|
1040
|
+
async read(key) {
|
|
1041
|
+
const entry = await storage.read(key);
|
|
1042
|
+
if (!entry)
|
|
1043
|
+
return;
|
|
1044
|
+
if (entry.expires !== undefined && Date.now() > entry.expires) {
|
|
1045
|
+
await storage.delete(key);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
return entry.value;
|
|
1049
|
+
},
|
|
1050
|
+
async write(key, value) {
|
|
1051
|
+
await storage.write(key, {
|
|
1052
|
+
value,
|
|
1053
|
+
expires: Date.now() + ttl
|
|
1054
|
+
});
|
|
1055
|
+
},
|
|
1056
|
+
async delete(key) {
|
|
1057
|
+
await storage.delete(key);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
// src/convenience/scenes/base.ts
|
|
1062
|
+
class BaseScene extends Composer {
|
|
1063
|
+
id;
|
|
1064
|
+
#ttl;
|
|
1065
|
+
#enterHandlers = [];
|
|
1066
|
+
#leaveHandlers = [];
|
|
1067
|
+
constructor(id, ttl) {
|
|
1068
|
+
super();
|
|
1069
|
+
this.id = id;
|
|
1070
|
+
this.#ttl = ttl;
|
|
1071
|
+
}
|
|
1072
|
+
get ttl() {
|
|
1073
|
+
return this.#ttl;
|
|
1074
|
+
}
|
|
1075
|
+
enter(...middleware) {
|
|
1076
|
+
this.#enterHandlers.push(...middleware);
|
|
1077
|
+
return this;
|
|
1078
|
+
}
|
|
1079
|
+
leave(...middleware) {
|
|
1080
|
+
this.#leaveHandlers.push(...middleware);
|
|
1081
|
+
return this;
|
|
1082
|
+
}
|
|
1083
|
+
enterMiddleware() {
|
|
1084
|
+
if (this.#enterHandlers.length === 0)
|
|
1085
|
+
return (_ctx, next) => next();
|
|
1086
|
+
const handlers = this.#enterHandlers;
|
|
1087
|
+
return (ctx, next) => {
|
|
1088
|
+
let i = 0;
|
|
1089
|
+
const dispatch = async () => {
|
|
1090
|
+
if (i >= handlers.length)
|
|
1091
|
+
return next();
|
|
1092
|
+
const mw = handlers[i++];
|
|
1093
|
+
if (!mw)
|
|
1094
|
+
return next();
|
|
1095
|
+
if (typeof mw === "function")
|
|
1096
|
+
await mw(ctx, dispatch);
|
|
1097
|
+
else
|
|
1098
|
+
await mw.middleware()(ctx, dispatch);
|
|
1099
|
+
};
|
|
1100
|
+
return dispatch();
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
leaveMiddleware() {
|
|
1104
|
+
if (this.#leaveHandlers.length === 0)
|
|
1105
|
+
return (_ctx, next) => next();
|
|
1106
|
+
const handlers = this.#leaveHandlers;
|
|
1107
|
+
return (ctx, next) => {
|
|
1108
|
+
let i = 0;
|
|
1109
|
+
const dispatch = async () => {
|
|
1110
|
+
if (i >= handlers.length)
|
|
1111
|
+
return next();
|
|
1112
|
+
const mw = handlers[i++];
|
|
1113
|
+
if (!mw)
|
|
1114
|
+
return next();
|
|
1115
|
+
if (typeof mw === "function")
|
|
1116
|
+
await mw(ctx, dispatch);
|
|
1117
|
+
else
|
|
1118
|
+
await mw.middleware()(ctx, dispatch);
|
|
1119
|
+
};
|
|
1120
|
+
return dispatch();
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
// src/convenience/scenes/stage.ts
|
|
1125
|
+
class Stage extends Composer {
|
|
1126
|
+
#scenes;
|
|
1127
|
+
constructor(scenes) {
|
|
1128
|
+
super();
|
|
1129
|
+
this.#scenes = new Map(scenes.map((s) => [s.id, s]));
|
|
1130
|
+
}
|
|
1131
|
+
middleware() {
|
|
1132
|
+
const scenes = this.#scenes;
|
|
1133
|
+
return async (ctx, next) => {
|
|
1134
|
+
const sceneCtrl = getOrCreateSceneController(ctx, scenes);
|
|
1135
|
+
const current = sceneCtrl.current;
|
|
1136
|
+
if (current) {
|
|
1137
|
+
const expires = ctx.session.__scenes?.expires;
|
|
1138
|
+
if (expires && Date.now() > expires) {
|
|
1139
|
+
await sceneCtrl.leave();
|
|
1140
|
+
return next();
|
|
1141
|
+
}
|
|
1142
|
+
await run(flatten(current), ctx);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
await next();
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
enter(sceneId) {
|
|
1149
|
+
return (ctx) => getOrCreateSceneController(ctx, this.#scenes).enter(sceneId);
|
|
1150
|
+
}
|
|
1151
|
+
leave() {
|
|
1152
|
+
return (ctx) => getOrCreateSceneController(ctx, this.#scenes).leave();
|
|
1153
|
+
}
|
|
1154
|
+
reenter() {
|
|
1155
|
+
return (ctx) => getOrCreateSceneController(ctx, this.#scenes).reenter();
|
|
1156
|
+
}
|
|
1157
|
+
static enter(sceneId) {
|
|
1158
|
+
return (ctx) => {
|
|
1159
|
+
const scene = ctx.scene;
|
|
1160
|
+
if (!scene) {
|
|
1161
|
+
throw new Error(`[cygnet] ctx.scene is not attached. Use a Stage instance helper (stage.enter("${sceneId}")) or register bot.use(stage) before this middleware.`);
|
|
1162
|
+
}
|
|
1163
|
+
return scene.enter(sceneId);
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
static leave() {
|
|
1167
|
+
return (ctx) => {
|
|
1168
|
+
const scene = ctx.scene;
|
|
1169
|
+
if (!scene) {
|
|
1170
|
+
throw new Error("[cygnet] ctx.scene is not attached. Use a Stage instance helper (stage.leave()) or register bot.use(stage) before this middleware.");
|
|
1171
|
+
}
|
|
1172
|
+
return scene.leave();
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
static reenter() {
|
|
1176
|
+
return (ctx) => {
|
|
1177
|
+
const scene = ctx.scene;
|
|
1178
|
+
if (!scene) {
|
|
1179
|
+
throw new Error("[cygnet] ctx.scene is not attached. Use a Stage instance helper (stage.reenter()) or register bot.use(stage) before this middleware.");
|
|
1180
|
+
}
|
|
1181
|
+
return scene.reenter();
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function getOrCreateSceneController(ctx, scenes) {
|
|
1186
|
+
const flavored = ctx;
|
|
1187
|
+
if (flavored.scene)
|
|
1188
|
+
return flavored.scene;
|
|
1189
|
+
const sceneCtrl = createSceneController(ctx, scenes);
|
|
1190
|
+
flavored.scene = sceneCtrl;
|
|
1191
|
+
return sceneCtrl;
|
|
1192
|
+
}
|
|
1193
|
+
function createSceneController(ctx, scenes) {
|
|
1194
|
+
const ctrl = {
|
|
1195
|
+
get id() {
|
|
1196
|
+
return ctx.session.__scenes?.current;
|
|
1197
|
+
},
|
|
1198
|
+
get current() {
|
|
1199
|
+
const id = ctx.session.__scenes?.current;
|
|
1200
|
+
return id ? scenes.get(id) : undefined;
|
|
1201
|
+
},
|
|
1202
|
+
get state() {
|
|
1203
|
+
return ctx.session.__scenes?.state ?? {};
|
|
1204
|
+
},
|
|
1205
|
+
set state(val) {
|
|
1206
|
+
if (!ctx.session.__scenes)
|
|
1207
|
+
ctx.session.__scenes = {};
|
|
1208
|
+
ctx.session.__scenes.state = val;
|
|
1209
|
+
},
|
|
1210
|
+
async enter(sceneId, initialState) {
|
|
1211
|
+
const current = scenes.get(ctx.session.__scenes?.current ?? "");
|
|
1212
|
+
if (current) {
|
|
1213
|
+
await run(flatten(current.leaveMiddleware()), ctx);
|
|
1214
|
+
}
|
|
1215
|
+
const next = scenes.get(sceneId);
|
|
1216
|
+
if (!next)
|
|
1217
|
+
throw new Error(`[cygnet] Scene "${sceneId}" not found`);
|
|
1218
|
+
const ttl = next.ttl;
|
|
1219
|
+
ctx.session.__scenes = {
|
|
1220
|
+
current: sceneId,
|
|
1221
|
+
state: initialState ?? {},
|
|
1222
|
+
cursor: 0,
|
|
1223
|
+
expires: ttl ? Date.now() + ttl : undefined
|
|
1224
|
+
};
|
|
1225
|
+
await run(flatten(next.enterMiddleware()), ctx);
|
|
1226
|
+
},
|
|
1227
|
+
async leave() {
|
|
1228
|
+
const id = ctx.session.__scenes?.current;
|
|
1229
|
+
if (!id)
|
|
1230
|
+
return;
|
|
1231
|
+
const scene = scenes.get(id);
|
|
1232
|
+
if (scene) {
|
|
1233
|
+
await run(flatten(scene.leaveMiddleware()), ctx);
|
|
1234
|
+
}
|
|
1235
|
+
ctx.session.__scenes = undefined;
|
|
1236
|
+
},
|
|
1237
|
+
async reenter() {
|
|
1238
|
+
const id = ctx.session.__scenes?.current;
|
|
1239
|
+
if (!id)
|
|
1240
|
+
return;
|
|
1241
|
+
await ctrl.enter(id, ctrl.state);
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
return ctrl;
|
|
1245
|
+
}
|
|
1246
|
+
// src/convenience/scenes/wizard.ts
|
|
1247
|
+
class WizardScene extends BaseScene {
|
|
1248
|
+
#steps;
|
|
1249
|
+
constructor(id, ...steps) {
|
|
1250
|
+
super(id);
|
|
1251
|
+
this.#steps = steps;
|
|
1252
|
+
this.use((ctx, next) => {
|
|
1253
|
+
const step = this.#currentStep(ctx);
|
|
1254
|
+
if (!step)
|
|
1255
|
+
return next();
|
|
1256
|
+
this.#attachWizard(ctx);
|
|
1257
|
+
if (typeof step === "function")
|
|
1258
|
+
return step(ctx, next);
|
|
1259
|
+
return step.middleware()(ctx, next);
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
enterMiddleware() {
|
|
1263
|
+
const enter = super.enterMiddleware();
|
|
1264
|
+
return async (ctx, next) => {
|
|
1265
|
+
const runFirstStep = async () => {
|
|
1266
|
+
const step = this.#steps[0];
|
|
1267
|
+
if (!step) {
|
|
1268
|
+
await next();
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
this.#attachWizard(ctx);
|
|
1272
|
+
if (typeof step === "function") {
|
|
1273
|
+
await step(ctx, next);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
await step.middleware()(ctx, next);
|
|
1277
|
+
};
|
|
1278
|
+
if (typeof enter === "function") {
|
|
1279
|
+
await enter(ctx, runFirstStep);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
await enter.middleware()(ctx, runFirstStep);
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
get stepCount() {
|
|
1286
|
+
return this.#steps.length;
|
|
1287
|
+
}
|
|
1288
|
+
#currentStep(ctx) {
|
|
1289
|
+
const cursor = ctx.scene.current?.id === this.id ? ctx.session.__scenes?.cursor ?? 0 : 0;
|
|
1290
|
+
return this.#steps[cursor];
|
|
1291
|
+
}
|
|
1292
|
+
#attachWizard(ctx) {
|
|
1293
|
+
const self = this;
|
|
1294
|
+
const wizard = {
|
|
1295
|
+
get cursor() {
|
|
1296
|
+
return ctx.session.__scenes?.cursor ?? 0;
|
|
1297
|
+
},
|
|
1298
|
+
selectStep(index) {
|
|
1299
|
+
if (!ctx.session.__scenes)
|
|
1300
|
+
ctx.session.__scenes = {};
|
|
1301
|
+
ctx.session.__scenes.cursor = Math.max(0, Math.min(index, self.#steps.length - 1));
|
|
1302
|
+
},
|
|
1303
|
+
async advance() {
|
|
1304
|
+
wizard.selectStep(wizard.cursor + 1);
|
|
1305
|
+
},
|
|
1306
|
+
async retreat() {
|
|
1307
|
+
wizard.selectStep(wizard.cursor - 1);
|
|
1308
|
+
},
|
|
1309
|
+
get state() {
|
|
1310
|
+
return ctx.session.__scenes?.state ?? {};
|
|
1311
|
+
},
|
|
1312
|
+
set state(val) {
|
|
1313
|
+
if (!ctx.session.__scenes)
|
|
1314
|
+
ctx.session.__scenes = {};
|
|
1315
|
+
ctx.session.__scenes.state = val;
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
ctx.wizard = wizard;
|
|
1319
|
+
return wizard;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
export {
|
|
1323
|
+
snapshotGroupStateCache,
|
|
1324
|
+
session,
|
|
1325
|
+
run,
|
|
1326
|
+
restoreGroupStateCache,
|
|
1327
|
+
primeGroupStateCache,
|
|
1328
|
+
matchFilter,
|
|
1329
|
+
flatten,
|
|
1330
|
+
enhanceStorage,
|
|
1331
|
+
directStorageBrand,
|
|
1332
|
+
concat,
|
|
1333
|
+
WizardScene,
|
|
1334
|
+
WebSocketListener,
|
|
1335
|
+
Stage,
|
|
1336
|
+
SignalError,
|
|
1337
|
+
SignalAPI,
|
|
1338
|
+
PollingListener,
|
|
1339
|
+
MemoryStorage,
|
|
1340
|
+
HttpClient,
|
|
1341
|
+
FileStorage,
|
|
1342
|
+
Context,
|
|
1343
|
+
Composer,
|
|
1344
|
+
BotError,
|
|
1345
|
+
Bot,
|
|
1346
|
+
BaseScene
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
//# debugId=5A36B56B282D103864756E2164756E21
|
|
1350
|
+
//# sourceMappingURL=index.js.map
|