@llblab/pi-telegram 0.21.0 → 0.22.0

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/lib/logs.ts CHANGED
@@ -10,13 +10,15 @@ import {
10
10
  mkdirSync,
11
11
  statSync,
12
12
  writeFileSync,
13
- appendFile,
13
+ appendFileSync,
14
14
  } from "node:fs";
15
15
  import { dirname } from "node:path";
16
16
  import {
17
17
  resolveAgentDir,
18
18
  resolveTelegramProfileTempFilePath,
19
19
  } from "./paths.ts";
20
+ import { withTelegramFileTransaction } from "./locks.ts";
21
+ import * as Status from "./status.ts";
20
22
 
21
23
  export type TelegramLogPathInput = string | (() => string);
22
24
 
@@ -32,6 +34,8 @@ export interface TelegramRuntimeJsonlLogOptions {
32
34
  previousPath?: TelegramLogPathInput;
33
35
  maxBytes?: number;
34
36
  getNowMs?: () => number;
37
+ canReset?: () => boolean;
38
+ commitReset?: (commit: () => void) => boolean;
35
39
  }
36
40
 
37
41
  export interface TelegramRuntimeJsonlLog {
@@ -89,7 +93,8 @@ export function createTelegramRuntimeJsonlLog(
89
93
  ? options.path()
90
94
  : (options.path ?? getTelegramRuntimeLogPath());
91
95
  const resolvePreviousPath = () => {
92
- if (typeof options.previousPath === "function") return options.previousPath();
96
+ if (typeof options.previousPath === "function")
97
+ return options.previousPath();
93
98
  if (options.previousPath) return options.previousPath;
94
99
  return resolvePath().replace(/\.jsonl$/u, "._prev.jsonl");
95
100
  };
@@ -108,9 +113,12 @@ export function createTelegramRuntimeJsonlLog(
108
113
  copyFileSync(path, previousPath);
109
114
  };
110
115
 
111
- const writeReset = (reason: string, scope?: Record<string, unknown>) => {
112
- const path = resolvePath();
113
- const previousPath = resolvePreviousPath();
116
+ const writeResetLocked = (
117
+ path: string,
118
+ previousPath: string,
119
+ reason: string,
120
+ scope?: Record<string, unknown>,
121
+ ) => {
114
122
  ensureParent(path);
115
123
  preserveCurrentLog(path, previousPath);
116
124
  writeFileSync(
@@ -126,20 +134,49 @@ export function createTelegramRuntimeJsonlLog(
126
134
  );
127
135
  };
128
136
 
137
+ const writeReset = (
138
+ reason: string,
139
+ scope?: Record<string, unknown>,
140
+ ): boolean => {
141
+ if (options.canReset && !options.canReset()) return false;
142
+ const path = resolvePath();
143
+ let didReset = false;
144
+ withTelegramFileTransaction(`${path}.transaction`, () => {
145
+ const commit = () => {
146
+ writeResetLocked(path, resolvePreviousPath(), reason, scope);
147
+ didReset = true;
148
+ };
149
+ if (options.commitReset) {
150
+ options.commitReset(commit);
151
+ } else {
152
+ commit();
153
+ }
154
+ });
155
+ return didReset;
156
+ };
157
+
129
158
  const appendLine = (line: string) => {
159
+ const path = resolvePath();
160
+ const previousPath = resolvePreviousPath();
130
161
  pending = pending
131
162
  .catch(() => undefined)
132
- .then(async () => {
133
- const path = resolvePath();
163
+ .then(() => {
134
164
  ensureParent(path);
135
- if (existsSync(path) && statSync(path).size > maxBytes) {
136
- writeReset("max-bytes", { maxBytes });
137
- }
138
- await new Promise<void>((resolve, reject) => {
139
- appendFile(path, line, { mode: 0o600 }, (error) => {
140
- if (error) reject(error);
141
- else resolve();
142
- });
165
+ withTelegramFileTransaction(`${path}.transaction`, () => {
166
+ if (
167
+ existsSync(path) &&
168
+ statSync(path).size > maxBytes &&
169
+ (!options.canReset || options.canReset())
170
+ ) {
171
+ const rotate = () =>
172
+ writeResetLocked(path, previousPath, "max-bytes", { maxBytes });
173
+ if (options.commitReset) {
174
+ options.commitReset(rotate);
175
+ } else {
176
+ rotate();
177
+ }
178
+ }
179
+ appendFileSync(path, line, { mode: 0o600 });
143
180
  });
144
181
  });
145
182
  };
@@ -148,9 +185,10 @@ export function createTelegramRuntimeJsonlLog(
148
185
  getPath: resolvePath,
149
186
  reset(reason, scope) {
150
187
  const path = resolvePath();
151
- scopeKeys.set(path, scope ? safeJsonLine(scope) : undefined);
152
188
  try {
153
- writeReset(reason, scope);
189
+ if (writeReset(reason, scope)) {
190
+ scopeKeys.set(path, scope ? safeJsonLine(scope) : undefined);
191
+ }
154
192
  } catch {
155
193
  // Diagnostics must never break Telegram runtime behavior.
156
194
  }
@@ -158,9 +196,8 @@ export function createTelegramRuntimeJsonlLog(
158
196
  resetIfScopeChanged(nextScopeKey, reason, scope) {
159
197
  const path = resolvePath();
160
198
  if (scopeKeys.get(path) === nextScopeKey) return;
161
- scopeKeys.set(path, nextScopeKey);
162
199
  try {
163
- writeReset(reason, scope);
200
+ if (writeReset(reason, scope)) scopeKeys.set(path, nextScopeKey);
164
201
  } catch {
165
202
  // Diagnostics must never break Telegram runtime behavior.
166
203
  }
@@ -170,3 +207,125 @@ export function createTelegramRuntimeJsonlLog(
170
207
  },
171
208
  };
172
209
  }
210
+
211
+ export interface TelegramRuntimeDiagnosticsRuntime<TContext> {
212
+ events: Status.TelegramRuntimeEventRecorder;
213
+ recordRuntimeEvent(
214
+ category: string,
215
+ error: unknown,
216
+ details?: Record<string, unknown>,
217
+ ): void;
218
+ bindStorage(ports: {
219
+ getBotToken(): string | undefined;
220
+ getProfileName(): string | undefined;
221
+ canReset(): boolean;
222
+ commitReset(commit: () => void): boolean;
223
+ }): void;
224
+ bindStatus(ports: {
225
+ instanceId: string;
226
+ updateStatus(ctx: TContext, error?: string): void;
227
+ getStatusState(): Status.TelegramBridgeStatusLineState;
228
+ persistSnapshot(
229
+ snapshot: ReturnType<typeof Status.createTelegramStatusSnapshot>,
230
+ ): Promise<void>;
231
+ }): void;
232
+ updateStatus(ctx: TContext, error?: string): void;
233
+ getStatusLines(options?: Status.TelegramBridgeStatusLineOptions): string[];
234
+ scheduleSnapshotPersist(): void;
235
+ }
236
+
237
+ export function createTelegramRuntimeDiagnosticsRuntime<
238
+ TContext,
239
+ >(): TelegramRuntimeDiagnosticsRuntime<TContext> {
240
+ let getBotToken = (): string | undefined => undefined;
241
+ let getProfileName = (): string | undefined => undefined;
242
+ let canReset = (): boolean => false;
243
+ let commitReset = (_commit: () => void): boolean => false;
244
+ let statusPorts:
245
+ | {
246
+ instanceId: string;
247
+ updateStatus(ctx: TContext, error?: string): void;
248
+ getStatusState(): Status.TelegramBridgeStatusLineState;
249
+ persistSnapshot(
250
+ snapshot: ReturnType<typeof Status.createTelegramStatusSnapshot>,
251
+ ): Promise<void>;
252
+ }
253
+ | undefined;
254
+ let requestSnapshotPersist = (): void => {};
255
+ const events = Status.createTelegramRuntimeEventRecorder({
256
+ getBotToken: () => getBotToken(),
257
+ });
258
+ const jsonl = createTelegramRuntimeJsonlLog({
259
+ path: () => getTelegramRuntimeLogPath(undefined, getProfileName()),
260
+ previousPath: () =>
261
+ getTelegramPreviousRuntimeLogPath(undefined, getProfileName()),
262
+ canReset: () => canReset(),
263
+ commitReset: (commit) => commitReset(commit),
264
+ });
265
+ const recordRuntimeEvent = function (
266
+ category: string,
267
+ error: unknown,
268
+ details?: Record<string, unknown>,
269
+ ): void {
270
+ events.record(category, error, details);
271
+ const latestEvent = events.getEvents().at(-1);
272
+ if (latestEvent) jsonl.record(latestEvent);
273
+ requestSnapshotPersist();
274
+ };
275
+ const persistCurrentSnapshot = async (): Promise<void> => {
276
+ if (!statusPorts) return;
277
+ await statusPorts.persistSnapshot(
278
+ Status.createTelegramStatusSnapshot(statusPorts.getStatusState()),
279
+ );
280
+ };
281
+ const updateRuntimeLogScope = function (reason: string): void {
282
+ if (!statusPorts) return;
283
+ const scope = Status.createTelegramRuntimeLogScope({
284
+ state: statusPorts.getStatusState(),
285
+ instanceId: statusPorts.instanceId,
286
+ });
287
+ jsonl.resetIfScopeChanged(JSON.stringify(scope), reason, scope);
288
+ };
289
+ return {
290
+ events,
291
+ recordRuntimeEvent,
292
+ bindStorage(ports) {
293
+ getBotToken = ports.getBotToken;
294
+ getProfileName = ports.getProfileName;
295
+ canReset = ports.canReset;
296
+ commitReset = ports.commitReset;
297
+ },
298
+ bindStatus(ports) {
299
+ statusPorts = ports;
300
+ requestSnapshotPersist =
301
+ Status.createTelegramRuntimeDiagnosticsSnapshotScheduler({
302
+ persistSnapshot: persistCurrentSnapshot,
303
+ recordError(error) {
304
+ events.record("telegram", error, {
305
+ phase: "runtime-diagnostics-snapshot-persist",
306
+ });
307
+ },
308
+ });
309
+ },
310
+ updateStatus(ctx, error) {
311
+ if (!statusPorts) return;
312
+ statusPorts.updateStatus(ctx, error);
313
+ updateRuntimeLogScope("status-scope-change");
314
+ },
315
+ getStatusLines(options) {
316
+ if (!statusPorts) return [];
317
+ void persistCurrentSnapshot().catch((error) => {
318
+ recordRuntimeEvent("telegram", error, {
319
+ phase: "status-snapshot-persist",
320
+ });
321
+ });
322
+ return Status.buildTelegramBridgeStatusLines(
323
+ statusPorts.getStatusState(),
324
+ options,
325
+ );
326
+ },
327
+ scheduleSnapshotPersist() {
328
+ requestSnapshotPersist();
329
+ },
330
+ };
331
+ }
package/lib/media.ts CHANGED
@@ -100,6 +100,9 @@ export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
100
100
  messages: TMessage[];
101
101
  context?: TContext;
102
102
  flushTimer?: ReturnType<typeof setTimeout>;
103
+ dispatching?: boolean;
104
+ suspended?: boolean;
105
+ reschedule?: () => void;
103
106
  }
104
107
 
105
108
  export interface TelegramMediaGroupController<
@@ -109,9 +112,14 @@ export interface TelegramMediaGroupController<
109
112
  queueMessage: (options: {
110
113
  message: TMessage;
111
114
  context?: TContext;
112
- dispatchMessages: (messages: TMessage[], ctx?: TContext) => void;
115
+ dispatchMessages: (
116
+ messages: TMessage[],
117
+ ctx?: TContext,
118
+ ) => unknown | Promise<unknown>;
113
119
  }) => boolean;
114
120
  removeMessages: (messageIds: number[]) => number;
121
+ suspend: () => void;
122
+ resume: (context: TContext) => void;
115
123
  clear: () => void;
116
124
  }
117
125
 
@@ -140,13 +148,7 @@ export interface TelegramMediaGroupControllerOptions {
140
148
  }
141
149
 
142
150
  export type TelegramAttachmentKind =
143
- | "photo"
144
- | "document"
145
- | "video"
146
- | "audio"
147
- | "voice"
148
- | "animation"
149
- | "sticker";
151
+ "photo" | "document" | "video" | "audio" | "voice" | "animation" | "sticker";
150
152
 
151
153
  export interface TelegramFileInfo {
152
154
  file_id: string;
@@ -306,11 +308,16 @@ function truncateTelegramReplyContextText(text: string): string {
306
308
  return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
307
309
  }
308
310
 
309
- function formatTelegramUser(user: TelegramMessageUser | undefined): string | undefined {
311
+ function formatTelegramUser(
312
+ user: TelegramMessageUser | undefined,
313
+ ): string | undefined {
310
314
  if (!user) return undefined;
311
315
  if (user.username) return user.username;
312
316
  if (typeof user.id === "number") return String(user.id);
313
- const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
317
+ const name = [user.first_name, user.last_name]
318
+ .filter(Boolean)
319
+ .join(" ")
320
+ .trim();
314
321
  return name || undefined;
315
322
  }
316
323
 
@@ -331,7 +338,8 @@ export function extractTelegramForwardContextText(
331
338
  message: TelegramMediaMessage,
332
339
  allowedUserId?: number,
333
340
  ): string {
334
- const originUser = message.forward_origin?.sender_user ?? message.forward_from;
341
+ const originUser =
342
+ message.forward_origin?.sender_user ?? message.forward_from;
335
343
  const isOwnerOrigin =
336
344
  typeof allowedUserId === "number" && originUser?.id === allowedUserId;
337
345
  const origin = formatTelegramForwardOriginIdentifier(message);
@@ -497,21 +505,55 @@ export function queueTelegramMediaGroupMessage<
497
505
  debounceMs: number;
498
506
  setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
499
507
  clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
500
- dispatchMessages: (messages: TMessage[], ctx?: TContext) => void;
508
+ dispatchMessages: (
509
+ messages: TMessage[],
510
+ ctx?: TContext,
511
+ ) => unknown | Promise<unknown>;
501
512
  }): boolean {
502
513
  const key = getTelegramMediaGroupKey(options.message);
503
514
  if (!key) return false;
504
515
  const existing = options.groups.get(key) ?? { messages: [] };
505
516
  existing.messages.push(options.message);
506
517
  existing.context = options.context;
518
+ const scheduleDispatch = (): void => {
519
+ if (existing.suspended) return;
520
+ existing.flushTimer = options.setTimer(() => {
521
+ existing.flushTimer = undefined;
522
+ const state = options.groups.get(key);
523
+ if (!state) return;
524
+ if (state.dispatching) {
525
+ scheduleDispatch();
526
+ return;
527
+ }
528
+ const dispatchedMessages = [...state.messages];
529
+ const dispatchedIds = new Set(
530
+ dispatchedMessages.map((message) => message.message_id),
531
+ );
532
+ state.dispatching = true;
533
+ void Promise.resolve(
534
+ options.dispatchMessages(dispatchedMessages, state.context),
535
+ ).then(
536
+ () => {
537
+ if (options.groups.get(key) !== state) return;
538
+ state.messages = state.messages.filter(
539
+ (message) => !dispatchedIds.has(message.message_id),
540
+ );
541
+ state.dispatching = false;
542
+ if (state.messages.length === 0) options.groups.delete(key);
543
+ else if (!state.flushTimer) scheduleDispatch();
544
+ },
545
+ () => {
546
+ if (options.groups.get(key) !== state) return;
547
+ state.dispatching = false;
548
+ if (!state.flushTimer) scheduleDispatch();
549
+ },
550
+ );
551
+ }, options.debounceMs);
552
+ existing.flushTimer.unref?.();
553
+ };
554
+ existing.reschedule = scheduleDispatch;
507
555
  if (existing.flushTimer) options.clearTimer(existing.flushTimer);
508
- existing.flushTimer = options.setTimer(() => {
509
- const state = options.groups.get(key);
510
- options.groups.delete(key);
511
- if (!state) return;
512
- options.dispatchMessages(state.messages, state.context);
513
- }, options.debounceMs);
514
- existing.flushTimer.unref?.();
556
+ scheduleDispatch();
515
557
  options.groups.set(key, existing);
516
558
  return true;
517
559
  }
@@ -542,6 +584,20 @@ export function createTelegramMediaGroupController<
542
584
  }),
543
585
  removeMessages: (messageIds) =>
544
586
  removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
587
+ suspend: () => {
588
+ for (const state of groups.values()) {
589
+ state.suspended = true;
590
+ if (state.flushTimer) clearTimer(state.flushTimer);
591
+ state.flushTimer = undefined;
592
+ }
593
+ },
594
+ resume: (context) => {
595
+ for (const state of groups.values()) {
596
+ state.context = context;
597
+ state.suspended = false;
598
+ if (!state.dispatching && !state.flushTimer) state.reschedule?.();
599
+ }
600
+ },
545
601
  clear: () => {
546
602
  for (const state of groups.values()) {
547
603
  if (state.flushTimer) clearTimer(state.flushTimer);
@@ -562,11 +618,10 @@ export function createTelegramMediaGroupDispatchRuntime<
562
618
  const queuedMediaGroup = deps.mediaGroups.queueMessage({
563
619
  message,
564
620
  context: ctx,
565
- dispatchMessages: (messages, queuedCtx) => {
566
- if (queuedCtx !== undefined) {
567
- void deps.dispatchMessages(messages, queuedCtx);
568
- }
569
- },
621
+ dispatchMessages: (messages, queuedCtx) =>
622
+ queuedCtx === undefined
623
+ ? Promise.resolve()
624
+ : deps.dispatchMessages(messages, queuedCtx),
570
625
  });
571
626
  if (queuedMediaGroup) return;
572
627
  await deps.dispatchMessages([message], ctx);
package/lib/model.ts CHANGED
@@ -15,13 +15,7 @@ export interface MenuModel {
15
15
  }
16
16
 
17
17
  export type ThinkingLevel =
18
- | "off"
19
- | "minimal"
20
- | "low"
21
- | "medium"
22
- | "high"
23
- | "xhigh"
24
- | "max";
18
+ "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
25
19
 
26
20
  export interface ScopedTelegramModel<TModel extends MenuModel = MenuModel> {
27
21
  model: TModel;
@@ -443,7 +437,7 @@ export function buildTelegramModelSwitchContinuationText<
443
437
  export function buildTelegramModelSwitchContinuationTurn<
444
438
  TModel extends MenuModel,
445
439
  >(options: {
446
- turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId">;
440
+ turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
447
441
  selection: ScopedTelegramModel<TModel>;
448
442
  telegramPrefix?: string;
449
443
  queueOrder: number;
@@ -456,6 +450,7 @@ export function buildTelegramModelSwitchContinuationTurn<
456
450
  return {
457
451
  kind: "prompt",
458
452
  chatId: options.turn.chatId,
453
+ ...(options.turn.target ? { target: { ...options.turn.target } } : {}),
459
454
  replyToMessageId: options.turn.replyToMessageId,
460
455
  sourceMessageIds: [],
461
456
  queueOrder: options.queueOrder,
@@ -484,7 +479,7 @@ export function createTelegramModelSwitchContinuationTurnBuilder<
484
479
  allocateItemOrder: () => number;
485
480
  allocateControlOrder: () => number;
486
481
  }): (options: {
487
- turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId">;
482
+ turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
488
483
  selection: ScopedTelegramModel<TModel>;
489
484
  }) => PendingTelegramTurn {
490
485
  return (options) =>
@@ -501,7 +496,7 @@ export function createTelegramModelSwitchContinuationQueue<
501
496
  TSelection extends ScopedTelegramModel,
502
497
  >(deps: {
503
498
  createContinuationTurn: (options: {
504
- turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId">;
499
+ turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
505
500
  selection: TSelection;
506
501
  }) => PendingTelegramTurn;
507
502
  appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
package/lib/ownership.ts CHANGED
@@ -11,6 +11,8 @@ export interface TelegramMessageOwnershipRecord {
11
11
  messageId: number;
12
12
  target: TelegramTarget;
13
13
  instanceId: string;
14
+ profileKey?: string;
15
+ ownerGeneration?: string;
14
16
  createdAt: number;
15
17
  updatedAt: number;
16
18
  }
@@ -21,6 +23,8 @@ export interface TelegramMessageOwnershipStore {
21
23
  messageId: number;
22
24
  target?: TelegramTarget;
23
25
  instanceId: string;
26
+ profileKey?: string;
27
+ ownerGeneration?: string;
24
28
  now?: number;
25
29
  }) => TelegramMessageOwnershipRecord;
26
30
  get: (
@@ -38,11 +42,110 @@ export interface TelegramMessageOwnershipStore {
38
42
  clear: () => void;
39
43
  }
40
44
 
45
+ export interface TelegramFollowerOwnershipView {
46
+ instanceId: string;
47
+ connectedAtMs: number;
48
+ registrationGeneration?: string;
49
+ }
50
+
51
+ export interface TelegramBusMessageOwnershipRuntime {
52
+ store: TelegramMessageOwnershipStore;
53
+ recordLocal(input: {
54
+ chatId: number;
55
+ messageId: number;
56
+ target?: TelegramTarget;
57
+ }): TelegramMessageOwnershipRecord;
58
+ recordRouted(input: {
59
+ chatId: number;
60
+ messageId: number;
61
+ target?: TelegramTarget;
62
+ instanceId: string;
63
+ }): TelegramMessageOwnershipRecord;
64
+ recordFollower(input: {
65
+ chatId: number;
66
+ messageId: number;
67
+ target?: TelegramTarget;
68
+ follower: TelegramFollowerOwnershipView;
69
+ }): TelegramMessageOwnershipRecord;
70
+ isOwnedByFollower(input: {
71
+ chatId: number;
72
+ messageId: number;
73
+ follower: TelegramFollowerOwnershipView;
74
+ }): boolean;
75
+ }
76
+
77
+ function getTelegramFollowerOwnershipGeneration(
78
+ follower: TelegramFollowerOwnershipView,
79
+ ): string {
80
+ return (
81
+ follower.registrationGeneration ??
82
+ `${follower.instanceId}:${follower.connectedAtMs}`
83
+ );
84
+ }
85
+
86
+ export function createTelegramBusMessageOwnershipRuntime(deps: {
87
+ instanceId: string;
88
+ getProfileKey(): string;
89
+ listFollowers(): readonly TelegramFollowerOwnershipView[];
90
+ }): TelegramBusMessageOwnershipRuntime {
91
+ const store = createTelegramMessageOwnershipStore({
92
+ getProfileKey: deps.getProfileKey,
93
+ isOwnerGenerationLive(record) {
94
+ if (!record.ownerGeneration) return true;
95
+ return deps.listFollowers().some((follower) => {
96
+ return (
97
+ follower.instanceId === record.instanceId &&
98
+ getTelegramFollowerOwnershipGeneration(follower) ===
99
+ record.ownerGeneration
100
+ );
101
+ });
102
+ },
103
+ });
104
+ const recordFollower = function (input: {
105
+ chatId: number;
106
+ messageId: number;
107
+ target?: TelegramTarget;
108
+ follower: TelegramFollowerOwnershipView;
109
+ }): TelegramMessageOwnershipRecord {
110
+ return store.record({
111
+ chatId: input.chatId,
112
+ messageId: input.messageId,
113
+ target: input.target,
114
+ instanceId: input.follower.instanceId,
115
+ ownerGeneration: getTelegramFollowerOwnershipGeneration(input.follower),
116
+ });
117
+ };
118
+ return {
119
+ store,
120
+ recordLocal(input) {
121
+ return store.record({ ...input, instanceId: deps.instanceId });
122
+ },
123
+ recordRouted(input) {
124
+ const follower = deps
125
+ .listFollowers()
126
+ .find((candidate) => candidate.instanceId === input.instanceId);
127
+ return follower
128
+ ? recordFollower({ ...input, follower })
129
+ : store.record(input);
130
+ },
131
+ recordFollower,
132
+ isOwnedByFollower({ chatId, messageId, follower }) {
133
+ const ownership = store.get(chatId, messageId);
134
+ return (
135
+ ownership?.instanceId === follower.instanceId &&
136
+ ownership.ownerGeneration ===
137
+ getTelegramFollowerOwnershipGeneration(follower)
138
+ );
139
+ },
140
+ };
141
+ }
142
+
41
143
  function getTelegramMessageOwnershipKey(
42
144
  chatId: number,
43
145
  messageId: number,
146
+ profileKey?: string,
44
147
  ): string {
45
- return `${chatId}:${messageId}`;
148
+ return `${profileKey ?? ""}:${chatId}:${messageId}`;
46
149
  }
47
150
 
48
151
  function createTelegramMessageOwnershipRecord(input: {
@@ -50,6 +153,8 @@ function createTelegramMessageOwnershipRecord(input: {
50
153
  messageId: number;
51
154
  target?: TelegramTarget;
52
155
  instanceId: string;
156
+ profileKey?: string;
157
+ ownerGeneration?: string;
53
158
  now: number;
54
159
  previous?: TelegramMessageOwnershipRecord;
55
160
  }): TelegramMessageOwnershipRecord {
@@ -58,33 +163,72 @@ function createTelegramMessageOwnershipRecord(input: {
58
163
  messageId: input.messageId,
59
164
  target: input.target ?? { chatId: input.chatId },
60
165
  instanceId: input.instanceId,
166
+ ...(input.profileKey ? { profileKey: input.profileKey } : {}),
167
+ ...(input.ownerGeneration
168
+ ? { ownerGeneration: input.ownerGeneration }
169
+ : {}),
61
170
  createdAt: input.previous?.createdAt ?? input.now,
62
171
  updatedAt: input.now,
63
172
  };
64
173
  }
65
174
 
66
- export function createTelegramMessageOwnershipStore(): TelegramMessageOwnershipStore {
175
+ export function createTelegramMessageOwnershipStore(
176
+ options: {
177
+ getProfileKey?: () => string | undefined;
178
+ isOwnerGenerationLive?: (record: TelegramMessageOwnershipRecord) => boolean;
179
+ } = {},
180
+ ): TelegramMessageOwnershipStore {
67
181
  const records = new Map<string, TelegramMessageOwnershipRecord>();
68
182
  return {
69
183
  record: (input) => {
70
- const key = getTelegramMessageOwnershipKey(input.chatId, input.messageId);
184
+ const profileKey = input.profileKey ?? options.getProfileKey?.();
185
+ const key = getTelegramMessageOwnershipKey(
186
+ input.chatId,
187
+ input.messageId,
188
+ profileKey,
189
+ );
71
190
  const now = input.now ?? Date.now();
72
191
  const record = createTelegramMessageOwnershipRecord({
73
192
  ...input,
193
+ profileKey,
74
194
  now,
75
195
  previous: records.get(key),
76
196
  });
77
197
  records.set(key, record);
78
198
  return record;
79
199
  },
80
- get: (chatId, messageId) =>
81
- records.get(getTelegramMessageOwnershipKey(chatId, messageId)),
200
+ get: (chatId, messageId) => {
201
+ const record = records.get(
202
+ getTelegramMessageOwnershipKey(
203
+ chatId,
204
+ messageId,
205
+ options.getProfileKey?.(),
206
+ ),
207
+ );
208
+ if (!record) return undefined;
209
+ if (
210
+ record.ownerGeneration &&
211
+ options.isOwnerGenerationLive &&
212
+ !options.isOwnerGenerationLive(record)
213
+ ) {
214
+ return undefined;
215
+ }
216
+ return record;
217
+ },
82
218
  forget: (chatId, messageId) =>
83
- records.delete(getTelegramMessageOwnershipKey(chatId, messageId)),
219
+ records.delete(
220
+ getTelegramMessageOwnershipKey(
221
+ chatId,
222
+ messageId,
223
+ options.getProfileKey?.(),
224
+ ),
225
+ ),
84
226
  forgetTarget: (target) => {
85
227
  const targetKey = getTelegramTargetKey(target);
228
+ const profileKey = options.getProfileKey?.();
86
229
  let removed = 0;
87
230
  for (const [key, record] of records) {
231
+ if ((record.profileKey ?? undefined) !== profileKey) continue;
88
232
  if (getTelegramTargetKey(record.target) !== targetKey) continue;
89
233
  records.delete(key);
90
234
  removed += 1;