@llblab/pi-telegram 0.43.1 → 0.44.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/AGENTS.md +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/multi-instance-bus.md +4 -0
- package/docs/outbound.md +27 -5
- package/docs/public-api.md +1 -0
- package/index.ts +24 -19
- package/lib/activity-verbosity.ts +43 -25
- package/lib/activity.ts +79 -6
- package/lib/bindings.ts +110 -91
- package/lib/config.ts +1 -1
- package/lib/delivery.ts +18 -18
- package/lib/lifecycle.ts +14 -3
- package/lib/menu-settings.ts +2 -2
- package/lib/outbound-attachments.ts +41 -37
- package/lib/outbound-voice.ts +39 -42
- package/lib/outbound.ts +28 -17
- package/lib/preview.ts +134 -73
- package/lib/queue.ts +113 -72
- package/lib/replies.ts +46 -38
- package/lib/routing.ts +192 -58
- package/lib/telegram-api.ts +36 -3
- package/lib/updates.ts +27 -35
- package/package.json +1 -1
package/lib/routing.ts
CHANGED
|
@@ -448,7 +448,19 @@ async function deleteReservedTelegramTopicThroughReconciler(
|
|
|
448
448
|
);
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
-
export
|
|
451
|
+
export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
452
|
+
|
|
453
|
+
export function isTelegramAllTabCommandExpired(
|
|
454
|
+
message: { date?: number; message_thread_id?: number },
|
|
455
|
+
nowMs = Date.now(),
|
|
456
|
+
): boolean {
|
|
457
|
+
return message.message_thread_id === undefined &&
|
|
458
|
+
typeof message.date === "number" && Number.isFinite(message.date) &&
|
|
459
|
+
message.date > 0 && Number.isFinite(nowMs) &&
|
|
460
|
+
nowMs - message.date * 1000 >= TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export type TelegramRoutedMessage = { date?: number } & Updates.TelegramUpdateMessage &
|
|
452
464
|
Media.TelegramMediaMessage &
|
|
453
465
|
Media.TelegramMediaGroupMessage &
|
|
454
466
|
Commands.TelegramCommandRuntimeMessage &
|
|
@@ -738,6 +750,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
738
750
|
messages: TMessage[];
|
|
739
751
|
createdAtMs: number;
|
|
740
752
|
dispatchKind: "prompt" | "command";
|
|
753
|
+
expiresAtMs?: number;
|
|
754
|
+
destinationSelected?: boolean;
|
|
755
|
+
selectionAttempted?: boolean;
|
|
756
|
+
dispatching?: boolean;
|
|
757
|
+
pauseExpiry?: () => void;
|
|
758
|
+
stopExpiry?: () => void;
|
|
741
759
|
cleanup?: PendingRerouteCleanup;
|
|
742
760
|
foreignRetry?: {
|
|
743
761
|
instanceId: string;
|
|
@@ -822,24 +840,71 @@ export function createTelegramInboundRouteRuntime<
|
|
|
822
840
|
): void => {
|
|
823
841
|
Updates.reportTelegramQueueAdmission(sources, receipts);
|
|
824
842
|
};
|
|
843
|
+
const removePendingReroute = (id: string): void => {
|
|
844
|
+
pendingUnboundReroutes.get(id)?.stopExpiry?.();
|
|
845
|
+
pendingUnboundReroutes.delete(id);
|
|
846
|
+
};
|
|
847
|
+
const expirePendingCommand = (id: string, pending: PendingUnboundReroute): boolean => {
|
|
848
|
+
if (pending.destinationSelected || pending.expiresAtMs === undefined ||
|
|
849
|
+
Date.now() < pending.expiresAtMs) return false;
|
|
850
|
+
if (pendingUnboundReroutes.get(id) !== pending) return true;
|
|
851
|
+
for (const message of pending.messages) Updates.reportTelegramUpdateCompleted(message);
|
|
852
|
+
removePendingReroute(id);
|
|
853
|
+
return true;
|
|
854
|
+
};
|
|
855
|
+
const armPendingCommandExpiry = (id: string, pending: PendingUnboundReroute): void => {
|
|
856
|
+
if (pending.dispatchKind !== "command" || pending.sourceTarget.threadId !== undefined) return;
|
|
857
|
+
pending.stopExpiry?.();
|
|
858
|
+
const execution = Updates.getTelegramUpdateExecutionFence(pending.messages[0]);
|
|
859
|
+
const onAbort = () => removePendingReroute(id);
|
|
860
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
861
|
+
let stopped = false;
|
|
862
|
+
pending.pauseExpiry = () => {
|
|
863
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
864
|
+
timer = undefined;
|
|
865
|
+
};
|
|
866
|
+
pending.stopExpiry = () => {
|
|
867
|
+
stopped = true;
|
|
868
|
+
pending.pauseExpiry?.();
|
|
869
|
+
execution?.signal.removeEventListener("abort", onAbort);
|
|
870
|
+
};
|
|
871
|
+
execution?.signal.addEventListener("abort", onAbort, { once: true });
|
|
872
|
+
if (execution?.signal.aborted) {
|
|
873
|
+
onAbort();
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (pending.expiresAtMs === undefined) {
|
|
877
|
+
const date = pending.messages[0]?.date;
|
|
878
|
+
if (typeof date !== "number" || !Number.isFinite(date) || date <= 0 || date * 1000 > Date.now()) return;
|
|
879
|
+
pending.expiresAtMs = date * 1000 + TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS;
|
|
880
|
+
}
|
|
881
|
+
const schedule = (): void => {
|
|
882
|
+
if (stopped || pending.destinationSelected || pendingUnboundReroutes.get(id) !== pending) return;
|
|
883
|
+
if (expirePendingCommand(id, pending)) return;
|
|
884
|
+
const delay = Math.min(TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS, pending.expiresAtMs! - Date.now());
|
|
885
|
+
timer = setTimeout(schedule, Math.max(1, delay));
|
|
886
|
+
timer.unref?.();
|
|
887
|
+
};
|
|
888
|
+
schedule();
|
|
889
|
+
};
|
|
825
890
|
const prunePendingUnboundReroutes = () => {
|
|
826
891
|
const nowMs = Date.now();
|
|
827
892
|
for (const [id, entry] of pendingUnboundReroutes) {
|
|
828
|
-
if (
|
|
829
|
-
|
|
893
|
+
if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
|
|
894
|
+
expirePendingCommand(id, entry);
|
|
895
|
+
} else if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
896
|
+
removePendingReroute(id);
|
|
830
897
|
}
|
|
831
898
|
}
|
|
832
|
-
while (pendingUnboundReroutes.size > 100) {
|
|
833
|
-
const oldest = pendingUnboundReroutes.keys().next().value;
|
|
834
|
-
if (!oldest) break;
|
|
835
|
-
pendingUnboundReroutes.delete(oldest);
|
|
836
|
-
}
|
|
837
899
|
};
|
|
838
900
|
const storePendingUnboundReroute = (
|
|
839
901
|
messages: TMessage[],
|
|
840
902
|
dispatchKind: "prompt" | "command" = "prompt",
|
|
841
903
|
): string => {
|
|
842
904
|
prunePendingUnboundReroutes();
|
|
905
|
+
if (pendingUnboundReroutes.size >= 100) {
|
|
906
|
+
throw new Error("Telegram route chooser capacity reached; source remains retryable.");
|
|
907
|
+
}
|
|
843
908
|
nextUnboundRerouteId += 1;
|
|
844
909
|
const id = nextUnboundRerouteId.toString(36);
|
|
845
910
|
pendingUnboundReroutes.set(id, {
|
|
@@ -853,11 +918,33 @@ export function createTelegramInboundRouteRuntime<
|
|
|
853
918
|
createdAtMs: Date.now(),
|
|
854
919
|
dispatchKind,
|
|
855
920
|
});
|
|
921
|
+
const pending = pendingUnboundReroutes.get(id)!;
|
|
922
|
+
armPendingCommandExpiry(id, pending);
|
|
856
923
|
return id;
|
|
857
924
|
};
|
|
858
925
|
const rememberRerouteChooser = (id: string, messageId: number | undefined): void => {
|
|
859
926
|
const pending = pendingUnboundReroutes.get(id);
|
|
860
|
-
if (pending)
|
|
927
|
+
if (!pending) return;
|
|
928
|
+
pending.chooserMessageId = messageId;
|
|
929
|
+
if (messageId === undefined || pending.dispatchKind !== "command" ||
|
|
930
|
+
pending.sourceTarget.threadId !== undefined || pending.selectionAttempted) return;
|
|
931
|
+
const source = pending.messages[0];
|
|
932
|
+
const text = source?.text?.trim();
|
|
933
|
+
const execution = Updates.getTelegramUpdateExecutionFence(source);
|
|
934
|
+
const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
|
|
935
|
+
if (!text || Commands.parseTelegramCommand(text)?.name !== "start" ||
|
|
936
|
+
source?.from?.id === undefined || !execution?.isCurrent() || sourceIds.length !== 1 ||
|
|
937
|
+
expirePendingCommand(id, pending)) return;
|
|
938
|
+
for (const [oldId, old] of pendingUnboundReroutes) {
|
|
939
|
+
if (oldId === id || old.dispatchKind !== "command" || old.selectionAttempted || old.dispatching ||
|
|
940
|
+
old.sourceTarget.threadId !== undefined || old.sourceTarget.chatId !== pending.sourceTarget.chatId) continue;
|
|
941
|
+
const oldSource = old.messages[0];
|
|
942
|
+
const oldIds = Updates.collectTelegramAdmissionSourceUpdateIds(old.messages);
|
|
943
|
+
if (oldSource?.from?.id !== source.from.id || oldSource?.text?.trim() !== text ||
|
|
944
|
+
oldIds.length !== 1 || oldIds[0]! >= sourceIds[0]! ||
|
|
945
|
+
Updates.getTelegramUpdateExecutionFence(oldSource)?.signal !== execution.signal) continue;
|
|
946
|
+
if (Updates.reportTelegramUpdateCompleted(oldSource)) removePendingReroute(oldId);
|
|
947
|
+
}
|
|
861
948
|
};
|
|
862
949
|
const matchesRerouteChooser = (
|
|
863
950
|
pending: PendingUnboundReroute,
|
|
@@ -1123,12 +1210,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1123
1210
|
| ((messages: TMessage[], ctx: TContext) => Promise<void>)
|
|
1124
1211
|
| undefined;
|
|
1125
1212
|
const dispatchPendingRerouteMessages = async (
|
|
1126
|
-
pending:
|
|
1213
|
+
pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget">,
|
|
1127
1214
|
messages: TMessage[],
|
|
1128
1215
|
ctx: TContext,
|
|
1129
1216
|
): Promise<void> => {
|
|
1130
1217
|
if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
|
|
1131
1218
|
await dispatchReroutedCommandMessages(messages, ctx);
|
|
1219
|
+
if (pending.sourceTarget.threadId === undefined) {
|
|
1220
|
+
for (const message of messages) Updates.reportTelegramUpdateCompleted(message);
|
|
1221
|
+
}
|
|
1132
1222
|
return;
|
|
1133
1223
|
}
|
|
1134
1224
|
await promptEnqueue(messages, ctx);
|
|
@@ -1147,7 +1237,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1147
1237
|
);
|
|
1148
1238
|
assertExecutionCurrent();
|
|
1149
1239
|
if (dismissed) {
|
|
1150
|
-
|
|
1240
|
+
removePendingReroute(rerouteId);
|
|
1151
1241
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
1152
1242
|
return;
|
|
1153
1243
|
}
|
|
@@ -1184,6 +1274,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1184
1274
|
outcome?.status === "fulfilled" &&
|
|
1185
1275
|
outcome.value.status === "accepted"
|
|
1186
1276
|
) {
|
|
1277
|
+
if (pending.dispatchKind === "command" && pending.sourceTarget.threadId === undefined) {
|
|
1278
|
+
Updates.reportTelegramUpdateCompleted(pending.messages[index]);
|
|
1279
|
+
}
|
|
1187
1280
|
return false;
|
|
1188
1281
|
}
|
|
1189
1282
|
if (outcome?.status === "rejected") {
|
|
@@ -1216,7 +1309,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1216
1309
|
typeof messageId !== "number" ||
|
|
1217
1310
|
!deps.threadStore ||
|
|
1218
1311
|
!pending ||
|
|
1219
|
-
!matchesRerouteChooser(pending, query)
|
|
1312
|
+
!matchesRerouteChooser(pending, query) ||
|
|
1313
|
+
expirePendingCommand(parsed.rerouteId, pending)
|
|
1220
1314
|
) {
|
|
1221
1315
|
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1222
1316
|
return true;
|
|
@@ -1258,7 +1352,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1258
1352
|
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
1259
1353
|
return true;
|
|
1260
1354
|
};
|
|
1261
|
-
const
|
|
1355
|
+
const executeUnboundRerouteCallback = async (
|
|
1262
1356
|
query: TCallbackQuery,
|
|
1263
1357
|
ctx: TContext,
|
|
1264
1358
|
): Promise<boolean> => {
|
|
@@ -1270,12 +1364,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1270
1364
|
const chatId = query.message?.chat?.id;
|
|
1271
1365
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1272
1366
|
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
1273
|
-
!matchesRerouteChooser(pending, query)) {
|
|
1367
|
+
!matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
|
|
1274
1368
|
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1275
1369
|
return true;
|
|
1276
1370
|
}
|
|
1277
1371
|
await deps.threadStore.load();
|
|
1278
1372
|
assertExecutionCurrent();
|
|
1373
|
+
if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
|
|
1374
|
+
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
1375
|
+
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1279
1378
|
if (pending.finalizeMessage) {
|
|
1280
1379
|
await finalizePendingReroute(
|
|
1281
1380
|
parsed.rerouteId,
|
|
@@ -1361,6 +1460,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1361
1460
|
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
1362
1461
|
return true;
|
|
1363
1462
|
}
|
|
1463
|
+
pending.destinationSelected = true;
|
|
1464
|
+
pending.selectionAttempted = true;
|
|
1465
|
+
pending.pauseExpiry?.();
|
|
1364
1466
|
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1365
1467
|
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
1366
1468
|
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
|
|
@@ -1657,6 +1759,33 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1657
1759
|
);
|
|
1658
1760
|
return true;
|
|
1659
1761
|
};
|
|
1762
|
+
const handleUnboundRerouteCallback = async (
|
|
1763
|
+
query: TCallbackQuery,
|
|
1764
|
+
ctx: TContext,
|
|
1765
|
+
): Promise<boolean> => {
|
|
1766
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
1767
|
+
const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1768
|
+
if (!parsed || !pending || pending.dispatchKind !== "command" ||
|
|
1769
|
+
pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
|
|
1770
|
+
return executeUnboundRerouteCallback(query, ctx);
|
|
1771
|
+
}
|
|
1772
|
+
if (pending.dispatching) {
|
|
1773
|
+
await deps.answerCallbackQuery(query.id, "Command routing is already in progress.");
|
|
1774
|
+
return true;
|
|
1775
|
+
}
|
|
1776
|
+
pending.dispatching = true;
|
|
1777
|
+
try {
|
|
1778
|
+
return await executeUnboundRerouteCallback(query, ctx);
|
|
1779
|
+
} finally {
|
|
1780
|
+
pending.dispatching = false;
|
|
1781
|
+
if (pendingUnboundReroutes.get(parsed.rerouteId) === pending &&
|
|
1782
|
+
pending.destinationSelected && pending.messages.length > 0 &&
|
|
1783
|
+
!pending.cleanup && !pending.finalizeMessage) {
|
|
1784
|
+
pending.destinationSelected = false;
|
|
1785
|
+
armPendingCommandExpiry(parsed.rerouteId, pending);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1660
1789
|
const callbackHandler = async (
|
|
1661
1790
|
query: TCallbackQuery,
|
|
1662
1791
|
ctx: TContext,
|
|
@@ -2122,11 +2251,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2122
2251
|
deps.getLiveThreadTargets?.(),
|
|
2123
2252
|
);
|
|
2124
2253
|
if (activeRecords.length === 0) return false;
|
|
2125
|
-
const commandMessage = {
|
|
2254
|
+
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
2126
2255
|
...message,
|
|
2127
2256
|
text: commandText,
|
|
2128
2257
|
caption: undefined,
|
|
2129
|
-
} as TMessage;
|
|
2258
|
+
} as TMessage);
|
|
2130
2259
|
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
2131
2260
|
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
2132
2261
|
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
@@ -2135,49 +2264,52 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2135
2264
|
activeRecords,
|
|
2136
2265
|
{ canRestore: typeof message.message_thread_id === "number" },
|
|
2137
2266
|
);
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
? {
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
}
|
|
2171
|
-
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2267
|
+
let chooserId: number | undefined;
|
|
2268
|
+
try {
|
|
2269
|
+
if (deps.sendInteractiveMessage) {
|
|
2270
|
+
chooserId = await deps.sendInteractiveMessage(
|
|
2271
|
+
message.chat.id,
|
|
2272
|
+
text,
|
|
2273
|
+
"html",
|
|
2274
|
+
replyMarkup,
|
|
2275
|
+
options.target || options.replyToSource
|
|
2276
|
+
? {
|
|
2277
|
+
...(options.target ? { target: options.target } : {}),
|
|
2278
|
+
...(options.replyToSource
|
|
2279
|
+
? { replyToMessageId: message.message_id }
|
|
2280
|
+
: {}),
|
|
2281
|
+
}
|
|
2282
|
+
: undefined,
|
|
2283
|
+
);
|
|
2284
|
+
} else if (deps.callApi) {
|
|
2285
|
+
const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
|
|
2286
|
+
chat_id: message.chat.id,
|
|
2287
|
+
text,
|
|
2288
|
+
parse_mode: "HTML",
|
|
2289
|
+
reply_markup: replyMarkup,
|
|
2290
|
+
...(typeof options.target?.threadId === "number"
|
|
2291
|
+
? { message_thread_id: options.target.threadId }
|
|
2292
|
+
: {}),
|
|
2293
|
+
...(options.replyToSource
|
|
2294
|
+
? {
|
|
2295
|
+
reply_parameters: {
|
|
2296
|
+
message_id: message.message_id,
|
|
2297
|
+
allow_sending_without_reply: true,
|
|
2298
|
+
},
|
|
2299
|
+
}
|
|
2300
|
+
: {}),
|
|
2301
|
+
});
|
|
2302
|
+
chooserId = chooser?.message_id;
|
|
2303
|
+
} else {
|
|
2304
|
+
chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2305
|
+
parseMode: "HTML",
|
|
2306
|
+
target: options.target,
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
removePendingReroute(rerouteId);
|
|
2311
|
+
throw error;
|
|
2176
2312
|
}
|
|
2177
|
-
const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2178
|
-
parseMode: "HTML",
|
|
2179
|
-
target: options.target,
|
|
2180
|
-
});
|
|
2181
2313
|
rememberRerouteChooser(rerouteId, chooserId);
|
|
2182
2314
|
return true;
|
|
2183
2315
|
};
|
|
@@ -2497,6 +2629,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2497
2629
|
deps.getLiveThreadTargets?.(),
|
|
2498
2630
|
);
|
|
2499
2631
|
const command = getKnownTelegramAllTabCommand(text);
|
|
2632
|
+
// Returning before deferral lets the admission worker terminally settle expired replay.
|
|
2633
|
+
if (command && command.name !== "thread" && isTelegramAllTabCommandExpired(message)) return;
|
|
2500
2634
|
if (bindings.length > 0 && command && command.name !== "thread") {
|
|
2501
2635
|
if (
|
|
2502
2636
|
await sendAllTabCommandChooser(command, text, message as TMessage, {
|
package/lib/telegram-api.ts
CHANGED
|
@@ -1783,11 +1783,44 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1783
1783
|
*/
|
|
1784
1784
|
export function createTelegramApiClient(
|
|
1785
1785
|
getBotToken: () => string | undefined,
|
|
1786
|
-
options: TelegramAnswerCallbackQueryOptions = {},
|
|
1786
|
+
options: TelegramAnswerCallbackQueryOptions & { now?: () => number } = {},
|
|
1787
1787
|
): TelegramApiClient {
|
|
1788
|
+
const now = options.now ?? Date.now;
|
|
1789
|
+
const draftRetryNotBeforeByTarget = new Map<string, number>();
|
|
1788
1790
|
return {
|
|
1789
|
-
call: async (
|
|
1790
|
-
|
|
1791
|
+
call: async <TResponse>(
|
|
1792
|
+
method: string,
|
|
1793
|
+
body: Record<string, unknown>,
|
|
1794
|
+
options?: TelegramApiCallOptions,
|
|
1795
|
+
): Promise<TResponse> => {
|
|
1796
|
+
const token = getBotToken();
|
|
1797
|
+
// Cooldown keys retain only the public bot-id prefix, not the credential.
|
|
1798
|
+
const botId = token?.match(/^(\d+):/)?.[1];
|
|
1799
|
+
const isDraft = method === "sendMessageDraft" || method === "sendRichMessageDraft";
|
|
1800
|
+
const draftKey = isDraft && botId
|
|
1801
|
+
? `${botId}:${String(body.chat_id)}:${String(body.message_thread_id ?? "all")}`
|
|
1802
|
+
: undefined;
|
|
1803
|
+
if (draftKey) {
|
|
1804
|
+
const nowMs = now();
|
|
1805
|
+
for (const [key, deadline] of draftRetryNotBeforeByTarget) {
|
|
1806
|
+
if (nowMs >= deadline) draftRetryNotBeforeByTarget.delete(key);
|
|
1807
|
+
}
|
|
1808
|
+
if (draftRetryNotBeforeByTarget.has(draftKey)) return false as TResponse;
|
|
1809
|
+
}
|
|
1810
|
+
try {
|
|
1811
|
+
// A draft is a replaceable snapshot, not a body to replay after backoff.
|
|
1812
|
+
return await callTelegram<TResponse>(
|
|
1813
|
+
token, method, body, isDraft ? { ...options, maxAttempts: 1 } : options,
|
|
1814
|
+
);
|
|
1815
|
+
} catch (error) {
|
|
1816
|
+
if (draftKey && isRetryableTelegramApiError(error)) {
|
|
1817
|
+
draftRetryNotBeforeByTarget.set(draftKey, Math.max(
|
|
1818
|
+
draftRetryNotBeforeByTarget.get(draftKey) ?? 0,
|
|
1819
|
+
now() + getTelegramRetryDelayMs(error, 0, options?.retryBaseDelayMs ?? 500),
|
|
1820
|
+
));
|
|
1821
|
+
}
|
|
1822
|
+
throw error;
|
|
1823
|
+
}
|
|
1791
1824
|
},
|
|
1792
1825
|
callMultipart: async (
|
|
1793
1826
|
method,
|
package/lib/updates.ts
CHANGED
|
@@ -481,12 +481,7 @@ const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
|
|
|
481
481
|
|
|
482
482
|
interface TelegramUpdateAdmissionBinding {
|
|
483
483
|
sourceUpdateId: number;
|
|
484
|
-
report: (
|
|
485
|
-
outcome: Extract<
|
|
486
|
-
TelegramUpdateAdmissionOutcome,
|
|
487
|
-
{ kind: "deferred" | "queued" }
|
|
488
|
-
>,
|
|
489
|
-
) => void;
|
|
484
|
+
report: (outcome: TelegramUpdateAdmissionOutcome) => void;
|
|
490
485
|
}
|
|
491
486
|
|
|
492
487
|
export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
|
|
@@ -589,6 +584,16 @@ export function collectTelegramAdmissionSourceUpdateIds(
|
|
|
589
584
|
return [...sourceUpdateIds].sort((left, right) => left - right);
|
|
590
585
|
}
|
|
591
586
|
|
|
587
|
+
/** Report source completion; true means reported, not a durable settlement acknowledgement. */
|
|
588
|
+
export function reportTelegramUpdateCompleted(value: unknown): boolean {
|
|
589
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
590
|
+
if (!binding) return false;
|
|
591
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
592
|
+
if (execution && !execution.isCurrent()) return false;
|
|
593
|
+
binding.report({ kind: "complete" });
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
592
597
|
export function reportTelegramUpdateDeferred(value: unknown): boolean {
|
|
593
598
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
594
599
|
if (!binding) return false;
|
|
@@ -1810,10 +1815,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
|
1810
1815
|
signal: () => void;
|
|
1811
1816
|
settleDeferred: (input: {
|
|
1812
1817
|
updateId: number;
|
|
1813
|
-
outcome:
|
|
1814
|
-
TelegramUpdateAdmissionOutcome,
|
|
1815
|
-
{ kind: "deferred" | "queued" }
|
|
1816
|
-
>;
|
|
1818
|
+
outcome: TelegramUpdateAdmissionOutcome;
|
|
1817
1819
|
signal: AbortSignal;
|
|
1818
1820
|
}) => void;
|
|
1819
1821
|
isQueueReceiptCommitted: (
|
|
@@ -3018,6 +3020,13 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3018
3020
|
return;
|
|
3019
3021
|
}
|
|
3020
3022
|
const claim = claims.get(input.updateId);
|
|
3023
|
+
if (input.outcome.kind === "complete") {
|
|
3024
|
+
// Expiry may retire only a still-deferred source, never accepted queue work.
|
|
3025
|
+
if (claim !== "deferred") return;
|
|
3026
|
+
const result = commitCompletedBatch(expectedOwner, [input.updateId]);
|
|
3027
|
+
if (!result) transition("idle", input.updateId);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3021
3030
|
if (input.outcome.kind === "deferred") {
|
|
3022
3031
|
if (claim === "queued") return;
|
|
3023
3032
|
if (claim !== "deferred") {
|
|
@@ -3453,10 +3462,7 @@ export interface TelegramUpdateAdmissionHandleDeps<
|
|
|
3453
3462
|
) => Promise<void>;
|
|
3454
3463
|
registry?: TelegramUpdateHandlerRegistry;
|
|
3455
3464
|
onLateOutcome?: (
|
|
3456
|
-
outcome:
|
|
3457
|
-
TelegramUpdateAdmissionOutcome,
|
|
3458
|
-
{ kind: "deferred" | "queued" }
|
|
3459
|
-
>,
|
|
3465
|
+
outcome: TelegramUpdateAdmissionOutcome,
|
|
3460
3466
|
details: {
|
|
3461
3467
|
updateId: number;
|
|
3462
3468
|
ctx: TContext;
|
|
@@ -3467,24 +3473,15 @@ export interface TelegramUpdateAdmissionHandleDeps<
|
|
|
3467
3473
|
}
|
|
3468
3474
|
|
|
3469
3475
|
function mergeTelegramReportedAdmissionOutcome(
|
|
3470
|
-
current:
|
|
3471
|
-
|
|
3472
|
-
TelegramUpdateAdmissionOutcome,
|
|
3473
|
-
{ kind: "deferred" | "queued" }
|
|
3474
|
-
>
|
|
3475
|
-
| undefined,
|
|
3476
|
-
next: Extract<
|
|
3477
|
-
TelegramUpdateAdmissionOutcome,
|
|
3478
|
-
{ kind: "deferred" | "queued" }
|
|
3479
|
-
>,
|
|
3476
|
+
current: TelegramUpdateAdmissionOutcome | undefined,
|
|
3477
|
+
next: TelegramUpdateAdmissionOutcome,
|
|
3480
3478
|
updateId: number,
|
|
3481
|
-
):
|
|
3482
|
-
TelegramUpdateAdmissionOutcome,
|
|
3483
|
-
{ kind: "deferred" | "queued" }
|
|
3484
|
-
> {
|
|
3479
|
+
): TelegramUpdateAdmissionOutcome {
|
|
3485
3480
|
if (!current || current.kind === "deferred") return next;
|
|
3486
3481
|
if (next.kind === "deferred") return current;
|
|
3487
|
-
if (
|
|
3482
|
+
if (current.kind === "complete" && next.kind === "complete") return current;
|
|
3483
|
+
if (current.kind === "queued" && next.kind === "queued" &&
|
|
3484
|
+
areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
|
|
3488
3485
|
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3489
3486
|
`Telegram update ${updateId} reported conflicting queue outcomes.`,
|
|
3490
3487
|
);
|
|
@@ -3529,12 +3526,7 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
3529
3526
|
execution.assertCurrent();
|
|
3530
3527
|
if (verdict === "consume") return { kind: "complete" };
|
|
3531
3528
|
let immediate = true;
|
|
3532
|
-
let outcome:
|
|
3533
|
-
| Extract<
|
|
3534
|
-
TelegramUpdateAdmissionOutcome,
|
|
3535
|
-
{ kind: "deferred" | "queued" }
|
|
3536
|
-
>
|
|
3537
|
-
| undefined;
|
|
3529
|
+
let outcome: TelegramUpdateAdmissionOutcome | undefined;
|
|
3538
3530
|
const boundUpdate = bindTelegramUpdateExecutionFence(
|
|
3539
3531
|
bindTelegramUpdateAdmissionSource(update, (next) => {
|
|
3540
3532
|
if (immediate) {
|