@llblab/pi-telegram 0.27.11 → 0.28.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/lifecycle.ts CHANGED
@@ -229,6 +229,7 @@ export interface TelegramBridgeSessionServiceRuntime {
229
229
  ctx: ExtensionContext,
230
230
  ): Promise<void>;
231
231
  };
232
+ inboundWorker: { onSessionShutdown(): Promise<void> };
232
233
  capabilityMonitor: { start(ctx: ExtensionContext): void; stop(): void };
233
234
  queueWatchdog: { start(ctx: ExtensionContext): void; stop(): void };
234
235
  }
@@ -283,6 +284,7 @@ export interface TelegramBridgeSessionLifecyclePorts<
283
284
  };
284
285
  delivery: TelegramBridgeSessionServiceRuntime["delivery"];
285
286
  polling: TelegramBridgeSessionServiceRuntime["polling"];
287
+ inboundWorker: TelegramBridgeSessionServiceRuntime["inboundWorker"];
286
288
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
287
289
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
288
290
  };
@@ -309,6 +311,7 @@ export function createTelegramBridgeSessionLifecycleDeps<
309
311
  }),
310
312
  delivery: ports.services.delivery,
311
313
  polling: ports.services.polling,
314
+ inboundWorker: ports.services.inboundWorker,
312
315
  capabilityMonitor: ports.services.capabilityMonitor,
313
316
  queueWatchdog: ports.services.queueWatchdog,
314
317
  },
@@ -349,6 +352,7 @@ export function createTelegramBridgeSessionLifecycleAssembly<
349
352
  await deps.services.delivery.onSessionShutdown();
350
353
  deps.services.queueWatchdog.stop();
351
354
  deps.services.capabilityMonitor.stop();
355
+ await deps.services.inboundWorker.onSessionShutdown();
352
356
  },
353
357
  },
354
358
  isSessionActive,
package/lib/locks.ts CHANGED
@@ -23,7 +23,8 @@ import { resolveTelegramOwnersPath } from "./paths.ts";
23
23
 
24
24
  export const TELEGRAM_LOCK_KEY = "default";
25
25
  export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
26
- const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
26
+ export const TELEGRAM_OWNERSHIP_CHECK_MS = 1_000;
27
+ export const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
27
28
  const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
28
29
  const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
29
30
  const TELEGRAM_LOCK_TRANSACTION_ATTEMPTS = 80;
@@ -276,20 +277,15 @@ function createLockTransactionContentionError(path: string): Error {
276
277
  );
277
278
  }
278
279
 
279
- function isLockTransactionContentionError(
280
- error: unknown,
281
- path: string,
282
- ): boolean {
280
+ function isLockTransactionContentionError(error: unknown): boolean {
283
281
  const code = (error as { code?: unknown })?.code;
284
- if (
282
+ return (
285
283
  code === "EEXIST" ||
286
284
  code === "ENOTEMPTY" ||
287
285
  code === "ENOTDIR" ||
288
- code === "EISDIR"
289
- ) {
290
- return true;
291
- }
292
- return existsSync(path) && (code === "EPERM" || code === "EACCES");
286
+ code === "EISDIR" ||
287
+ isRetryableLockWriteError(error)
288
+ );
293
289
  }
294
290
 
295
291
  function removeLockTransactionGuard(path: string): void {
@@ -298,6 +294,7 @@ function removeLockTransactionGuard(path: string): void {
298
294
 
299
295
  function createLockTransactionGuard(
300
296
  path: string,
297
+ options: TelegramFileTransactionOptions = {},
301
298
  ): TelegramLockTransactionOwner {
302
299
  const owner: TelegramLockTransactionOwner = {
303
300
  pid: process.pid,
@@ -313,7 +310,7 @@ function createLockTransactionGuard(
313
310
  { encoding: "utf8", flag: "wx", mode: 0o600 },
314
311
  );
315
312
  if (existsSync(path)) throw createLockTransactionContentionError(path);
316
- renameSync(stagedPath, path);
313
+ (options.publishRename ?? renameSync)(stagedPath, path);
317
314
  return owner;
318
315
  } finally {
319
316
  try {
@@ -368,6 +365,7 @@ type TelegramTransactionGlobal = typeof globalThis & {
368
365
 
369
366
  export interface TelegramFileTransactionOptions {
370
367
  recoveryRename?: typeof renameSync;
368
+ publishRename?: typeof renameSync;
371
369
  attempts?: number;
372
370
  retryDelayMs?: number;
373
371
  }
@@ -465,9 +463,9 @@ function acquireRecoverableDirectoryGuard(
465
463
  ): TelegramLockTransactionOwner | undefined {
466
464
  for (let attempt = 0; attempt < 2; attempt += 1) {
467
465
  try {
468
- return createLockTransactionGuard(path);
466
+ return createLockTransactionGuard(path, options);
469
467
  } catch (error) {
470
- if (!isLockTransactionContentionError(error, path)) throw error;
468
+ if (!isLockTransactionContentionError(error)) throw error;
471
469
  if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
472
470
  }
473
471
  }
@@ -528,11 +526,12 @@ function acquireLegacyRecoveryGuard(
528
526
 
529
527
  function createRecoveredLockTransactionGuard(
530
528
  path: string,
529
+ options: TelegramFileTransactionOptions = {},
531
530
  ): TelegramLockTransactionOwner | undefined {
532
531
  try {
533
- return createLockTransactionGuard(path);
532
+ return createLockTransactionGuard(path, options);
534
533
  } catch (error) {
535
- if (isLockTransactionContentionError(error, path)) return undefined;
534
+ if (isLockTransactionContentionError(error)) return undefined;
536
535
  throw error;
537
536
  }
538
537
  }
@@ -550,7 +549,7 @@ function recoverAbandonedLockTransaction(
550
549
  }
551
550
  if (isDirectory) {
552
551
  if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
553
- const recoveredOwner = createRecoveredLockTransactionGuard(path);
552
+ const recoveredOwner = createRecoveredLockTransactionGuard(path, options);
554
553
  try {
555
554
  reclaimAbandonedDirectoryGuard(`${path}.recovery`, options);
556
555
  return recoveredOwner;
@@ -584,7 +583,7 @@ function recoverAbandonedLockTransaction(
584
583
  } catch {
585
584
  /* stale debris cannot retain transaction authority */
586
585
  }
587
- recoveredOwner = createRecoveredLockTransactionGuard(path);
586
+ recoveredOwner = createRecoveredLockTransactionGuard(path, options);
588
587
  return recoveredOwner;
589
588
  } finally {
590
589
  try {
@@ -617,9 +616,9 @@ function acquireLockTransaction(
617
616
  mkdirSync(dirname(path), { recursive: true });
618
617
  for (let attempt = 0; attempt < attempts; attempt += 1) {
619
618
  try {
620
- return createLockTransactionGuard(path);
619
+ return createLockTransactionGuard(path, options);
621
620
  } catch (error) {
622
- if (!isLockTransactionContentionError(error, path)) throw error;
621
+ if (!isLockTransactionContentionError(error)) throw error;
623
622
  const recoveredOwner = recoverAbandonedLockTransaction(path, options);
624
623
  if (recoveredOwner !== undefined) return recoveredOwner;
625
624
  if (attempt === attempts - 1) {
@@ -1163,7 +1162,8 @@ export function createTelegramLockedPollingRuntime<
1163
1162
  let takeoverCandidate: TelegramLockEntry | undefined;
1164
1163
  let sessionAutoStartRun: Promise<void> | undefined;
1165
1164
  let sessionAutoStartGeneration = 0;
1166
- const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
1165
+ const ownershipCheckMs =
1166
+ deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
1167
1167
  const ownershipRefreshMs =
1168
1168
  deps.ownershipRefreshMs ?? TELEGRAM_OWNERSHIP_REFRESH_MS;
1169
1169
  const stopOwnershipWatcher = () => {
package/lib/media.ts CHANGED
@@ -101,6 +101,8 @@ export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
101
101
  context?: TContext;
102
102
  flushTimer?: ReturnType<typeof setTimeout>;
103
103
  dispatching?: boolean;
104
+ dispatchPromise?: Promise<void>;
105
+ dispatchNow?: () => Promise<void>;
104
106
  suspended?: boolean;
105
107
  reschedule?: () => void;
106
108
  }
@@ -118,6 +120,7 @@ export interface TelegramMediaGroupController<
118
120
  ) => unknown | Promise<unknown>;
119
121
  }) => boolean;
120
122
  removeMessages: (messageIds: number[]) => number;
123
+ flushMessage: (messageId: number) => Promise<boolean>;
121
124
  suspend: () => void;
122
125
  resume: (context: TContext) => void;
123
126
  clear: () => void;
@@ -129,6 +132,7 @@ export interface TelegramMediaGroupDispatchRuntimeDeps<
129
132
  > {
130
133
  mediaGroups: TelegramMediaGroupController<TMessage, TContext>;
131
134
  dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
135
+ onDeferredMessage?: (message: TMessage) => void;
132
136
  }
133
137
 
134
138
  export interface TelegramMediaGroupDispatchRuntime<
@@ -504,45 +508,59 @@ export function queueTelegramMediaGroupMessage<
504
508
  const key = getTelegramMediaGroupKey(options.message);
505
509
  if (!key) return false;
506
510
  const existing = options.groups.get(key) ?? { messages: [] };
507
- existing.messages.push(options.message);
511
+ const duplicateIndex = existing.messages.findIndex(
512
+ (message) => message.message_id === options.message.message_id,
513
+ );
514
+ if (duplicateIndex >= 0) {
515
+ existing.messages[duplicateIndex] = options.message;
516
+ } else {
517
+ existing.messages.push(options.message);
518
+ }
508
519
  existing.context = options.context;
520
+ const dispatchQueued = (): Promise<void> => {
521
+ existing.flushTimer = undefined;
522
+ const state = options.groups.get(key);
523
+ if (!state) return Promise.resolve();
524
+ if (state.dispatching) return state.dispatchPromise ?? Promise.resolve();
525
+ const dispatchedMessages = [...state.messages];
526
+ const dispatchedIds = new Set(
527
+ dispatchedMessages.map((message) => message.message_id),
528
+ );
529
+ state.dispatching = true;
530
+ const operation = Promise.resolve(
531
+ options.dispatchMessages(dispatchedMessages, state.context),
532
+ ).then(
533
+ () => {
534
+ if (options.groups.get(key) !== state) return;
535
+ state.messages = state.messages.filter(
536
+ (message) => !dispatchedIds.has(message.message_id),
537
+ );
538
+ state.dispatching = false;
539
+ state.dispatchPromise = undefined;
540
+ if (state.messages.length === 0) options.groups.delete(key);
541
+ else if (!state.flushTimer) scheduleDispatch();
542
+ },
543
+ (error) => {
544
+ if (options.groups.get(key) === state) {
545
+ state.dispatching = false;
546
+ state.dispatchPromise = undefined;
547
+ if (!state.flushTimer) scheduleDispatch();
548
+ }
549
+ throw error;
550
+ },
551
+ );
552
+ state.dispatchPromise = operation;
553
+ return operation;
554
+ };
509
555
  const scheduleDispatch = (): void => {
510
556
  if (existing.suspended) return;
511
557
  existing.flushTimer = options.setTimer(() => {
512
- existing.flushTimer = undefined;
513
- const state = options.groups.get(key);
514
- if (!state) return;
515
- if (state.dispatching) {
516
- scheduleDispatch();
517
- return;
518
- }
519
- const dispatchedMessages = [...state.messages];
520
- const dispatchedIds = new Set(
521
- dispatchedMessages.map((message) => message.message_id),
522
- );
523
- state.dispatching = true;
524
- void Promise.resolve(
525
- options.dispatchMessages(dispatchedMessages, state.context),
526
- ).then(
527
- () => {
528
- if (options.groups.get(key) !== state) return;
529
- state.messages = state.messages.filter(
530
- (message) => !dispatchedIds.has(message.message_id),
531
- );
532
- state.dispatching = false;
533
- if (state.messages.length === 0) options.groups.delete(key);
534
- else if (!state.flushTimer) scheduleDispatch();
535
- },
536
- () => {
537
- if (options.groups.get(key) !== state) return;
538
- state.dispatching = false;
539
- if (!state.flushTimer) scheduleDispatch();
540
- },
541
- );
558
+ void dispatchQueued().catch(() => undefined);
542
559
  }, options.debounceMs);
543
560
  existing.flushTimer.unref?.();
544
561
  };
545
562
  existing.reschedule = scheduleDispatch;
563
+ existing.dispatchNow = dispatchQueued;
546
564
  if (existing.flushTimer) options.clearTimer(existing.flushTimer);
547
565
  scheduleDispatch();
548
566
  options.groups.set(key, existing);
@@ -575,6 +593,24 @@ export function createTelegramMediaGroupController<
575
593
  }),
576
594
  removeMessages: (messageIds) =>
577
595
  removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
596
+ async flushMessage(messageId) {
597
+ for (const state of groups.values()) {
598
+ if (!state.messages.some((message) => message.message_id === messageId)) {
599
+ continue;
600
+ }
601
+ if (state.flushTimer) clearTimer(state.flushTimer);
602
+ state.flushTimer = undefined;
603
+ await state.dispatchNow?.();
604
+ if (
605
+ state.messages.some((message) => message.message_id === messageId) &&
606
+ !state.dispatching
607
+ ) {
608
+ await state.dispatchNow?.();
609
+ }
610
+ return true;
611
+ }
612
+ return false;
613
+ },
578
614
  suspend: () => {
579
615
  for (const state of groups.values()) {
580
616
  state.suspended = true;
@@ -614,7 +650,10 @@ export function createTelegramMediaGroupDispatchRuntime<
614
650
  ? Promise.resolve()
615
651
  : deps.dispatchMessages(messages, queuedCtx),
616
652
  });
617
- if (queuedMediaGroup) return;
653
+ if (queuedMediaGroup) {
654
+ deps.onDeferredMessage?.(message);
655
+ return;
656
+ }
618
657
  await deps.dispatchMessages([message], ctx);
619
658
  },
620
659
  };
package/lib/menu-queue.ts CHANGED
@@ -34,6 +34,7 @@ interface TelegramQueueMenuItem {
34
34
  queuePosition: number;
35
35
  isPriority: boolean;
36
36
  priorityEmoji?: string;
37
+ reactionSuppressionEmoji?: string;
37
38
  hasAttachments: boolean;
38
39
  statusSummary: string;
39
40
  promptText: string;
@@ -62,6 +63,8 @@ function toTelegramQueueMenuItems<Context>(
62
63
  queuePosition: index + 1,
63
64
  isPriority: item.queueLane === "priority",
64
65
  priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
66
+ reactionSuppressionEmoji:
67
+ item.kind === "prompt" ? item.reactionSuppressionEmoji : undefined,
65
68
  hasAttachments:
66
69
  item.kind === "prompt" && item.queuedAttachments.length > 0,
67
70
  statusSummary: item.statusSummary,
@@ -84,11 +87,13 @@ function buildTelegramQueueMenuReplyMarkup(
84
87
  const refreshRow = [{ text: "🌀 Refresh", callback_data: refreshData }];
85
88
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
86
89
  const rows = items.map((item, index) => {
87
- const prefix = item.isPriority
88
- ? `${item.priorityEmoji ?? "⚡"} `
89
- : item.hasAttachments
90
- ? "📎 "
91
- : "";
90
+ const prefix = item.reactionSuppressionEmoji
91
+ ? `${item.reactionSuppressionEmoji} suppressed · `
92
+ : item.isPriority
93
+ ? `${item.priorityEmoji ?? "⚡"} `
94
+ : item.hasAttachments
95
+ ? "📎 "
96
+ : "";
92
97
  const label = `${index + 1}. ${prefix}${item.statusSummary}`;
93
98
  return [
94
99
  {
@@ -152,10 +157,17 @@ function escapeTelegramQueueMenuHtmlPreview(text: string): string {
152
157
  }
153
158
 
154
159
  function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
155
- const badge = item.isPriority ? ` ${item.priorityEmoji ?? "⚡"}` : "";
160
+ const badge = item.reactionSuppressionEmoji
161
+ ? ` ${item.reactionSuppressionEmoji}`
162
+ : item.isPriority
163
+ ? ` ${item.priorityEmoji ?? "⚡"}`
164
+ : "";
156
165
  const heading = `<b>${item.queuePosition}.</b>${badge}`;
166
+ const suppression = item.reactionSuppressionEmoji
167
+ ? "\n<i>Suppressed by reaction. Remove it to restore this turn.</i>"
168
+ : "";
157
169
  const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
158
- return `${heading}\n${preview}`;
170
+ return `${heading}${suppression}\n${preview}`;
159
171
  }
160
172
 
161
173
  function buildTelegramQueueItemSubmenuReplyMarkup(
@@ -820,11 +832,13 @@ function toggleQueuedTelegramPromptPriority<Context>(
820
832
  replyToMessageId,
821
833
  );
822
834
  if (!item) return false;
823
- if (item.queueLane === "priority") {
824
- deps.queueMutationRuntime.clearPriorityByMessageId(replyToMessageId, ctx);
825
- } else {
826
- deps.queueMutationRuntime.prioritizeByMessageId(replyToMessageId, ctx);
827
- }
835
+ deps.queueMutationRuntime.applyReactionByMessageId(
836
+ replyToMessageId,
837
+ item.queueLane === "priority"
838
+ ? { kind: "default" }
839
+ : { kind: "priority", emoji: "⚡" },
840
+ ctx,
841
+ );
828
842
  return true;
829
843
  }
830
844
 
@@ -844,11 +858,11 @@ function setQueuedTelegramPromptPriority<Context>(
844
858
  replyToMessageId,
845
859
  );
846
860
  if (!item) return false;
847
- if (enabled) {
848
- deps.queueMutationRuntime.prioritizeByMessageId(replyToMessageId, ctx);
849
- } else {
850
- deps.queueMutationRuntime.clearPriorityByMessageId(replyToMessageId, ctx);
851
- }
861
+ deps.queueMutationRuntime.applyReactionByMessageId(
862
+ replyToMessageId,
863
+ enabled ? { kind: "priority", emoji: "⚡" } : { kind: "default" },
864
+ ctx,
865
+ );
852
866
  return true;
853
867
  }
854
868
 
package/lib/menu.ts CHANGED
@@ -229,6 +229,7 @@ export interface TelegramMenuCallbackRuntimeDeps<
229
229
  prompt: string,
230
230
  ctx: TContext,
231
231
  target?: { chatId: number; threadId?: number },
232
+ source?: unknown,
232
233
  ) => Promise<void>;
233
234
  deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
234
235
  isVoiceReplyActive?: () => boolean;
@@ -478,6 +479,7 @@ export interface TelegramMenuCallbackRuntimeAdapterDeps<
478
479
  prompt: string,
479
480
  ctx: TContext,
480
481
  target?: { chatId: number; threadId?: number },
482
+ source?: unknown,
481
483
  ) => Promise<void>;
482
484
  deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
483
485
  }
@@ -588,7 +590,7 @@ export async function handleTelegramMenuCallbackRuntime<
588
590
  deps.sendInteractiveMessage ?? (async () => undefined),
589
591
  enqueuePrompt: deps.enqueueSectionPrompt
590
592
  ? (prompt: string) =>
591
- deps.enqueueSectionPrompt!(prompt, ctx, target)
593
+ deps.enqueueSectionPrompt!(prompt, ctx, target, query)
592
594
  : async () => {},
593
595
  deleteMessage: deps.deleteMessage ?? (async () => {}),
594
596
  },
@@ -611,7 +613,7 @@ export async function handleTelegramMenuCallbackRuntime<
611
613
  deps.sendInteractiveMessage ?? (async () => undefined),
612
614
  enqueuePrompt: deps.enqueueSectionPrompt
613
615
  ? (prompt: string) =>
614
- deps.enqueueSectionPrompt!(prompt, ctx, target)
616
+ deps.enqueueSectionPrompt!(prompt, ctx, target, query)
615
617
  : async () => {},
616
618
  deleteMessage: deps.deleteMessage ?? (async () => {}),
617
619
  },
@@ -636,7 +638,7 @@ export async function handleTelegramMenuCallbackRuntime<
636
638
  deps.sendInteractiveMessage ?? (async () => undefined),
637
639
  enqueuePrompt: deps.enqueueSectionPrompt
638
640
  ? (prompt: string) =>
639
- deps.enqueueSectionPrompt!(prompt, ctx, target)
641
+ deps.enqueueSectionPrompt!(prompt, ctx, target, query)
640
642
  : async () => {},
641
643
  deleteMessage: deps.deleteMessage ?? (async () => {}),
642
644
  },
package/lib/model.ts CHANGED
@@ -188,6 +188,19 @@ function isAliasModelId(id: string): boolean {
188
188
  return !/-\d{8}$/.test(id);
189
189
  }
190
190
 
191
+ function findUniqueModelMatch<TModel extends MenuModel>(
192
+ availableModels: TModel[],
193
+ matches: (model: TModel) => boolean,
194
+ ): { model?: TModel; ambiguous: boolean } {
195
+ let model: TModel | undefined;
196
+ for (const candidate of availableModels) {
197
+ if (!matches(candidate)) continue;
198
+ if (model) return { ambiguous: true };
199
+ model = candidate;
200
+ }
201
+ return { model, ambiguous: false };
202
+ }
203
+
191
204
  function findExactModelReferenceMatch<TModel extends MenuModel = MenuModel>(
192
205
  modelReference: string,
193
206
  availableModels: TModel[],
@@ -195,29 +208,35 @@ function findExactModelReferenceMatch<TModel extends MenuModel = MenuModel>(
195
208
  const trimmedReference = modelReference.trim();
196
209
  if (!trimmedReference) return undefined;
197
210
  const normalizedReference = trimmedReference.toLowerCase();
198
- const canonicalMatches = availableModels.filter(
211
+ const canonicalMatch = findUniqueModelMatch(
212
+ availableModels,
199
213
  (model) => getCanonicalModelId(model).toLowerCase() === normalizedReference,
200
214
  );
201
- if (canonicalMatches.length === 1) return canonicalMatches[0];
202
- if (canonicalMatches.length > 1) return undefined;
215
+ if (canonicalMatch.model || canonicalMatch.ambiguous) {
216
+ return canonicalMatch.model;
217
+ }
203
218
  const slashIndex = trimmedReference.indexOf("/");
204
219
  if (slashIndex !== -1) {
205
220
  const provider = trimmedReference.substring(0, slashIndex).trim();
206
221
  const modelId = trimmedReference.substring(slashIndex + 1).trim();
207
222
  if (provider && modelId) {
208
- const providerMatches = availableModels.filter(
223
+ const normalizedProvider = provider.toLowerCase();
224
+ const normalizedModelId = modelId.toLowerCase();
225
+ const providerMatch = findUniqueModelMatch(
226
+ availableModels,
209
227
  (model) =>
210
- model.provider.toLowerCase() === provider.toLowerCase() &&
211
- model.id.toLowerCase() === modelId.toLowerCase(),
228
+ model.provider.toLowerCase() === normalizedProvider &&
229
+ model.id.toLowerCase() === normalizedModelId,
212
230
  );
213
- if (providerMatches.length === 1) return providerMatches[0];
214
- if (providerMatches.length > 1) return undefined;
231
+ if (providerMatch.model || providerMatch.ambiguous) {
232
+ return providerMatch.model;
233
+ }
215
234
  }
216
235
  }
217
- const idMatches = availableModels.filter(
236
+ return findUniqueModelMatch(
237
+ availableModels,
218
238
  (model) => model.id.toLowerCase() === normalizedReference,
219
- );
220
- return idMatches.length === 1 ? idMatches[0] : undefined;
239
+ ).model;
221
240
  }
222
241
 
223
242
  function tryMatchScopedModel<TModel extends MenuModel = MenuModel>(
@@ -229,20 +248,28 @@ function tryMatchScopedModel<TModel extends MenuModel = MenuModel>(
229
248
  availableModels,
230
249
  );
231
250
  if (exactMatch) return exactMatch;
232
- const matches = availableModels.filter(
233
- (model) =>
234
- model.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
235
- model.name?.toLowerCase().includes(modelPattern.toLowerCase()),
236
- );
237
- if (matches.length === 0) return undefined;
238
- const aliases = matches.filter((model) => isAliasModelId(model.id));
239
- const datedVersions = matches.filter((model) => !isAliasModelId(model.id));
240
- if (aliases.length > 0) {
241
- aliases.sort((a, b) => b.id.localeCompare(a.id));
242
- return aliases[0];
251
+ const normalizedPattern = modelPattern.toLowerCase();
252
+ let bestAlias: TModel | undefined;
253
+ let bestDatedVersion: TModel | undefined;
254
+ for (const model of availableModels) {
255
+ if (
256
+ !model.id.toLowerCase().includes(normalizedPattern) &&
257
+ !model.name?.toLowerCase().includes(normalizedPattern)
258
+ ) {
259
+ continue;
260
+ }
261
+ if (isAliasModelId(model.id)) {
262
+ if (!bestAlias || model.id.localeCompare(bestAlias.id) > 0) {
263
+ bestAlias = model;
264
+ }
265
+ } else if (
266
+ !bestDatedVersion ||
267
+ model.id.localeCompare(bestDatedVersion.id) > 0
268
+ ) {
269
+ bestDatedVersion = model;
270
+ }
243
271
  }
244
- datedVersions.sort((a, b) => b.id.localeCompare(a.id));
245
- return datedVersions[0];
272
+ return bestAlias ?? bestDatedVersion;
246
273
  }
247
274
 
248
275
  function parseScopedModelPattern<TModel extends MenuModel = MenuModel>(
package/lib/ownership.ts CHANGED
@@ -13,6 +13,7 @@ export interface TelegramMessageOwnershipRecord {
13
13
  instanceId: string;
14
14
  profileKey?: string;
15
15
  ownerGeneration?: string;
16
+ recipientBindingKey?: string;
16
17
  createdAt: number;
17
18
  updatedAt: number;
18
19
  }
@@ -25,6 +26,7 @@ export interface TelegramMessageOwnershipStore {
25
26
  instanceId: string;
26
27
  profileKey?: string;
27
28
  ownerGeneration?: string;
29
+ recipientBindingKey?: string;
28
30
  now?: number;
29
31
  }) => TelegramMessageOwnershipRecord;
30
32
  get: (
@@ -45,6 +47,7 @@ export interface TelegramMessageOwnershipStore {
45
47
  export interface TelegramFollowerOwnershipView {
46
48
  instanceId: string;
47
49
  connectedAtMs: number;
50
+ profileKey?: string;
48
51
  registrationGeneration?: string;
49
52
  }
50
53
 
@@ -100,6 +103,22 @@ export function createTelegramBusMessageOwnershipRuntime(deps: {
100
103
  );
101
104
  });
102
105
  },
106
+ resolveOwnerReplacement(record) {
107
+ if (!record.recipientBindingKey) return undefined;
108
+ const follower = deps
109
+ .listFollowers()
110
+ .find(
111
+ (candidate) =>
112
+ candidate.profileKey === record.recipientBindingKey,
113
+ );
114
+ return follower
115
+ ? {
116
+ instanceId: follower.instanceId,
117
+ ownerGeneration: getTelegramFollowerOwnershipGeneration(follower),
118
+ recipientBindingKey: record.recipientBindingKey,
119
+ }
120
+ : undefined;
121
+ },
103
122
  });
104
123
  const recordFollower = function (input: {
105
124
  chatId: number;
@@ -113,6 +132,7 @@ export function createTelegramBusMessageOwnershipRuntime(deps: {
113
132
  target: input.target,
114
133
  instanceId: input.follower.instanceId,
115
134
  ownerGeneration: getTelegramFollowerOwnershipGeneration(input.follower),
135
+ recipientBindingKey: input.follower.profileKey,
116
136
  });
117
137
  };
118
138
  return {
@@ -155,6 +175,7 @@ function createTelegramMessageOwnershipRecord(input: {
155
175
  instanceId: string;
156
176
  profileKey?: string;
157
177
  ownerGeneration?: string;
178
+ recipientBindingKey?: string;
158
179
  now: number;
159
180
  previous?: TelegramMessageOwnershipRecord;
160
181
  }): TelegramMessageOwnershipRecord {
@@ -167,6 +188,9 @@ function createTelegramMessageOwnershipRecord(input: {
167
188
  ...(input.ownerGeneration
168
189
  ? { ownerGeneration: input.ownerGeneration }
169
190
  : {}),
191
+ ...(input.recipientBindingKey
192
+ ? { recipientBindingKey: input.recipientBindingKey }
193
+ : {}),
170
194
  createdAt: input.previous?.createdAt ?? input.now,
171
195
  updatedAt: input.now,
172
196
  };
@@ -176,6 +200,14 @@ export function createTelegramMessageOwnershipStore(
176
200
  options: {
177
201
  getProfileKey?: () => string | undefined;
178
202
  isOwnerGenerationLive?: (record: TelegramMessageOwnershipRecord) => boolean;
203
+ resolveOwnerReplacement?: (
204
+ record: TelegramMessageOwnershipRecord,
205
+ ) =>
206
+ | Pick<
207
+ TelegramMessageOwnershipRecord,
208
+ "instanceId" | "ownerGeneration" | "recipientBindingKey"
209
+ >
210
+ | undefined;
179
211
  } = {},
180
212
  ): TelegramMessageOwnershipStore {
181
213
  const records = new Map<string, TelegramMessageOwnershipRecord>();
@@ -198,20 +230,23 @@ export function createTelegramMessageOwnershipStore(
198
230
  return record;
199
231
  },
200
232
  get: (chatId, messageId) => {
201
- const record = records.get(
202
- getTelegramMessageOwnershipKey(
203
- chatId,
204
- messageId,
205
- options.getProfileKey?.(),
206
- ),
233
+ const key = getTelegramMessageOwnershipKey(
234
+ chatId,
235
+ messageId,
236
+ options.getProfileKey?.(),
207
237
  );
238
+ const record = records.get(key);
208
239
  if (!record) return undefined;
209
240
  if (
210
241
  record.ownerGeneration &&
211
242
  options.isOwnerGenerationLive &&
212
243
  !options.isOwnerGenerationLive(record)
213
244
  ) {
214
- return undefined;
245
+ const replacement = options.resolveOwnerReplacement?.(record);
246
+ if (!replacement) return undefined;
247
+ const rebound = { ...record, ...replacement };
248
+ records.set(key, rebound);
249
+ return rebound;
215
250
  }
216
251
  return record;
217
252
  },