@llblab/pi-kit 0.5.1 → 0.6.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/CHANGELOG.md +8 -0
- package/README.md +3 -3
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-grow-loop/README.md +6 -6
- package/node_modules/@llblab/pi-grow-loop/index.ts +6 -3
- package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +20 -0
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +4 -0
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +27 -5
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -0
- package/node_modules/@llblab/pi-telegram/index.ts +24 -19
- package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +43 -25
- package/node_modules/@llblab/pi-telegram/lib/activity.ts +79 -6
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +110 -91
- package/node_modules/@llblab/pi-telegram/lib/config.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +18 -18
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +14 -3
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +41 -37
- package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +39 -42
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +28 -17
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +134 -73
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +113 -72
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +46 -38
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +192 -58
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +36 -3
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +27 -35
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/skills/abcd-context/AGENTS.md +1 -0
- package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +6 -2
- package/node_modules/@llblab/skills/abcd-context/SKILL.md +1 -1
- package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +11 -5
- package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +61 -0
- package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +67 -0
- package/node_modules/@llblab/skills/package.json +1 -1
- package/node_modules/@llblab/skills/release-flow/SKILL.md +2 -4
- package/package.json +4 -4
|
@@ -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, {
|
|
@@ -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,
|
|
@@ -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) {
|
|
@@ -36,5 +36,6 @@
|
|
|
36
36
|
- `Path Portability`: Resolve paths through Node rather than platform-specific shell utilities.
|
|
37
37
|
- `Human Output`: Emit concise readable logs and a summary; `NO_COLOR=1` suppresses color.
|
|
38
38
|
- `Bounded Scanning`: Skip link scanning above the configured byte threshold while validating the surrounding graph.
|
|
39
|
+
- `Contiguous Lists`: Fail blank lines inside one Markdown list; list items are contiguous structural peers, not paragraphs. Ignore fenced examples and bounded-scan exclusions.
|
|
39
40
|
- `Compact Tables`: Fail non-compact delimiter cells; require exactly three hyphens with one space inside each pipe, while table rows over 120 characters only warn.
|
|
40
41
|
- `Core Shape Flexibility`: Accept both numbered mature-project sections and compact skill-style durable sections.
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
3
|
+
## 1.14.1
|
|
4
4
|
|
|
5
|
-
- `Markdown
|
|
5
|
+
- `Contiguous Markdown Lists`: The validator now rejects blank lines inside continuous lists, including multiline and nested item boundaries, while ignoring fenced examples and bounded large-reference scans. Impact: list items remain structural peers instead of being formatted as separate paragraphs.
|
|
6
|
+
|
|
7
|
+
## 1.13.2
|
|
8
|
+
|
|
9
|
+
- `Markdown Freedom`: LaTeX syntax is now allowed, while table checks always enforce compact three-hyphen delimiter cells and warn on rows over 120 characters without imposing a maximum. Indented backtick and tilde code fences remain excluded from table validation. Impact: context authors can use mathematical notation and fenced examples while receiving default, noise-focused table formatting guidance
|
|
6
10
|
|
|
7
11
|
## 1.5.2
|
|
8
12
|
|
|
@@ -102,7 +102,7 @@ Run the Node validator from the project root or pass an explicit root:
|
|
|
102
102
|
node "${SKILL_DIR}/scripts/validate-context.mjs" /path/to/project
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
Output is human-readable. Markdown table checks always require compact delimiter cells such as `| --- | ---: | :--- |`; non-compact delimiters fail validation, while rows longer than 120 characters only warn without imposing a maximum length.
|
|
105
|
+
Output is human-readable. Markdown list items must remain contiguous: blank lines inside one list fail validation, including between multiline or nested items, while fenced examples are ignored. Markdown table checks always require compact delimiter cells such as `| --- | ---: | :--- |`; non-compact delimiters fail validation, while rows longer than 120 characters only warn without imposing a maximum length.
|
|
106
106
|
|
|
107
107
|
Useful environment controls:
|
|
108
108
|
|
|
@@ -28,10 +28,11 @@ A missing or non-directory root fails before validation.
|
|
|
28
28
|
6. `README reachability — Warning`: Finds subtree README files with no inbound Markdown link.
|
|
29
29
|
7. `Meta-protocol presence — Warning`: Checks the durable file for `Meta-Protocol Principles`.
|
|
30
30
|
8. `Bloat signals — Warning`: Reports low information density or sparse structure in the durable file.
|
|
31
|
-
9. `Markdown
|
|
32
|
-
10. `
|
|
33
|
-
11. `
|
|
34
|
-
12. `Docs
|
|
31
|
+
9. `Markdown lists — Error`: Fails blank lines within one continuous list, including multiline and nested items, while ignoring fenced examples and files above the bounded Markdown scan limit.
|
|
32
|
+
10. `Markdown tables — Error/Warning`: Always fails non-compact delimiter rows and warns about table rows longer than 120 characters.
|
|
33
|
+
11. `Freshness — Warning`: Reports durable files older than 30 days.
|
|
34
|
+
12. `Docs directory — Warning`: Checks for `/docs`.
|
|
35
|
+
13. `Docs index coverage — Warning`: Detects docs missing from `docs/README.md` and indexed files that do not exist.
|
|
35
36
|
|
|
36
37
|
## Severity Contract
|
|
37
38
|
|
|
@@ -70,6 +71,10 @@ The validator avoids a hard file-length limit. It checks independent signals:
|
|
|
70
71
|
|
|
71
72
|
Signals suggest consolidation; they do not replace judgment.
|
|
72
73
|
|
|
74
|
+
## Markdown Lists
|
|
75
|
+
|
|
76
|
+
List checks apply to Markdown within the bounded scan size and outside fenced code blocks. A blank line fails when it separates sibling list items, a list item from its nested list, nested sibling items, or a multiline item's continuation from its next item. Paragraphs and independent blocks remain separated by blank lines normally.
|
|
77
|
+
|
|
73
78
|
## Markdown Tables
|
|
74
79
|
|
|
75
80
|
Table checks always run and have no enabling option.
|
|
@@ -93,7 +98,8 @@ Validation prints each check and a summary by default. `NO_COLOR=1` disables ANS
|
|
|
93
98
|
3. A missing path, which must fail clearly.
|
|
94
99
|
4. The removed `--json` option, which must fail clearly.
|
|
95
100
|
5. A temporary fixture with an out-of-range line reference, which must fail clearly.
|
|
96
|
-
6. Temporary fixtures proving that
|
|
101
|
+
6. Temporary fixtures proving that contiguous multiline/nested lists and fenced examples pass while blank separators inside lists fail.
|
|
102
|
+
7. Temporary fixtures proving that LaTeX and compact table delimiters pass, non-compact delimiters fail, and rows over 120 characters warn.
|
|
97
103
|
|
|
98
104
|
The fixture remains linked from [its README](../fixtures/abcd-project/README.md).
|
|
99
105
|
|