@llblab/pi-telegram 0.42.3 → 0.43.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/BACKLOG.md +3 -0
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/docs/README.md +1 -1
- package/docs/architecture.md +5 -3
- package/docs/compact-matrix-literal.md +39 -11
- package/docs/generative-apps.md +2 -2
- package/docs/multi-instance-bus.md +17 -5
- package/docs/outbound.md +6 -4
- package/docs/public-api.md +1 -1
- package/index.ts +13 -1
- package/lib/bindings.ts +12 -3
- package/lib/bus-follower.ts +29 -18
- package/lib/bus-leader.ts +15 -6
- package/lib/bus.ts +11 -4
- package/lib/keyboard.ts +5 -3
- package/lib/outbound-buttons.ts +72 -15
- package/lib/outbound-markup.ts +81 -9
- package/lib/outbound.ts +2 -0
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +89 -32
- package/lib/sync.ts +74 -15
- package/lib/telegram-api.ts +32 -1
- package/lib/thread-reconciler.ts +17 -0
- package/lib/threads.ts +123 -15
- package/package.json +1 -1
- package/skills/generated-control-surface/SKILL.md +4 -2
- package/skills/generated-control-surface/references/layout-and-state.md +4 -2
- package/skills/generative-apps/SKILL.md +4 -3
- package/skills/telegram-bridge/SKILL.md +19 -8
package/lib/sync.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { getTelegramApiErrorRequestTarget } from "./telegram-api.ts";
|
|
7
|
+
import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
|
|
8
8
|
import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
|
|
9
9
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
10
10
|
import {
|
|
11
|
+
createTelegramCleanupTargetProtection,
|
|
11
12
|
getTelegramTargetFromApiBody,
|
|
12
13
|
isTelegramTopicTargetStaleError,
|
|
13
14
|
provisionOwnBusTopic,
|
|
@@ -121,6 +122,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
|
121
122
|
| undefined;
|
|
122
123
|
topicTargetStore: Pick<
|
|
123
124
|
TelegramTopicTargetStore,
|
|
125
|
+
| "list"
|
|
124
126
|
| "markStaleByTarget"
|
|
125
127
|
| "persist"
|
|
126
128
|
| "upsertPendingCleanup"
|
|
@@ -247,16 +249,21 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
247
249
|
target,
|
|
248
250
|
requestedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
249
251
|
};
|
|
252
|
+
const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === currentRecord.instanceId &&
|
|
253
|
+
record.target.chatId === target.chatId && record.target.threadId === target.threadId);
|
|
254
|
+
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
|
|
250
255
|
deps.topicTargetStore.upsertPendingCleanup(intent);
|
|
251
256
|
await deps.topicTargetStore.persist();
|
|
257
|
+
const cleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
258
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
259
|
+
currentLeaderEpoch: leaderEpoch,
|
|
260
|
+
records: [],
|
|
261
|
+
pendingCleanups: [intent],
|
|
262
|
+
});
|
|
252
263
|
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
253
|
-
|
|
254
|
-
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
255
|
-
currentLeaderEpoch: leaderEpoch,
|
|
256
|
-
records: [],
|
|
257
|
-
pendingCleanups: [intent],
|
|
258
|
-
}),
|
|
264
|
+
cleanupPlan,
|
|
259
265
|
{
|
|
266
|
+
isCleanupTargetProtected,
|
|
260
267
|
callApi(method, body) {
|
|
261
268
|
return deps.callApi(method, body);
|
|
262
269
|
},
|
|
@@ -277,6 +284,9 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
277
284
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
278
285
|
},
|
|
279
286
|
);
|
|
287
|
+
if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
|
|
288
|
+
return "Thread disconnect superseded by a new binding.";
|
|
289
|
+
}
|
|
280
290
|
cleanupPending = Boolean(cleanup.incompleteActions?.length);
|
|
281
291
|
}
|
|
282
292
|
const leaderTarget = deps.getLeaderTarget();
|
|
@@ -404,7 +414,7 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
|
|
|
404
414
|
topicTargetStore: Pick<
|
|
405
415
|
TelegramTopicTargetStore,
|
|
406
416
|
"load" | "markStaleByTarget" | "persist"
|
|
407
|
-
|
|
417
|
+
> & Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
|
|
408
418
|
getSyncState: () => TSyncState;
|
|
409
419
|
setSyncState: (state: TSyncState) => void;
|
|
410
420
|
recordEvent: (
|
|
@@ -413,6 +423,46 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
|
|
|
413
423
|
details?: Record<string, unknown>,
|
|
414
424
|
) => void;
|
|
415
425
|
getNowMs?: () => number;
|
|
426
|
+
isCurrent?: () => boolean;
|
|
427
|
+
isAuthorityCurrent?: () => boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function captureTelegramStaleTargetRequestRecovery<TSyncState extends TelegramSyncState>(
|
|
431
|
+
body: Record<string, unknown>,
|
|
432
|
+
deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> & {
|
|
433
|
+
topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget">;
|
|
434
|
+
getCurrentLeaderEpoch: () => number | string | undefined;
|
|
435
|
+
getSessionGeneration: () => number;
|
|
436
|
+
getProfileName: () => string | undefined;
|
|
437
|
+
onRecovered: () => void;
|
|
438
|
+
},
|
|
439
|
+
): ((error: unknown) => Promise<void>) | undefined {
|
|
440
|
+
const target = getTelegramTargetFromApiBody(body);
|
|
441
|
+
const epoch = deps.getCurrentLeaderEpoch();
|
|
442
|
+
if (!target || epoch === undefined) return undefined;
|
|
443
|
+
const key = getTelegramTargetKey(target);
|
|
444
|
+
const record = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
|
|
445
|
+
if (!record) return undefined;
|
|
446
|
+
const generation = deps.getSessionGeneration();
|
|
447
|
+
const profile = deps.getProfileName();
|
|
448
|
+
const isAuthorityCurrent = (): boolean => deps.getCurrentLeaderEpoch() === epoch &&
|
|
449
|
+
deps.getSessionGeneration() === generation && deps.getProfileName() === profile;
|
|
450
|
+
const isCurrent = (): boolean => {
|
|
451
|
+
const current = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
|
|
452
|
+
return isAuthorityCurrent() &&
|
|
453
|
+
current?.instanceId === record.instanceId && current?.profileKey === record.profileKey &&
|
|
454
|
+
current?.updatedAtMs === record.updatedAtMs && current?.createdAtMs === record.createdAtMs;
|
|
455
|
+
};
|
|
456
|
+
return async (error) => {
|
|
457
|
+
const requestTarget = getTelegramApiErrorRequestTarget(error);
|
|
458
|
+
if (!isTelegramStaleTargetHttpError(error) || !requestTarget ||
|
|
459
|
+
getTelegramTargetKey(requestTarget) !== key || !isCurrent()) return;
|
|
460
|
+
if (await recoverStaleTelegramTopicApiError(
|
|
461
|
+
{ chat_id: target.chatId, message_thread_id: target.threadId }, error, { ...deps, isCurrent, isAuthorityCurrent },
|
|
462
|
+
)) {
|
|
463
|
+
deps.onRecovered();
|
|
464
|
+
}
|
|
465
|
+
};
|
|
416
466
|
}
|
|
417
467
|
|
|
418
468
|
export function createTelegramStaleTopicApiErrorRecoveryRuntime<
|
|
@@ -448,12 +498,16 @@ export async function recoverStaleTelegramTopicApiError<
|
|
|
448
498
|
deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
|
|
449
499
|
): Promise<boolean> {
|
|
450
500
|
const target = getTelegramTargetFromApiBody(apiBody);
|
|
451
|
-
if (!target || !isTelegramTopicTargetStaleError(error)) return false;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return false;
|
|
501
|
+
if (!target || !isTelegramTopicTargetStaleError(error) || deps.isCurrent?.() === false) return false;
|
|
502
|
+
if (deps.isCurrent) {
|
|
503
|
+
if (!deps.topicTargetStore.invalidateTarget || !await deps.topicTargetStore.invalidateTarget(
|
|
504
|
+
target, deps.isCurrent, String(error),
|
|
505
|
+
)) return false;
|
|
506
|
+
if (deps.isAuthorityCurrent?.() === false) return false;
|
|
507
|
+
} else {
|
|
508
|
+
await deps.topicTargetStore.load();
|
|
509
|
+
if (!deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error))) return false;
|
|
510
|
+
await deps.topicTargetStore.persist();
|
|
457
511
|
}
|
|
458
512
|
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
459
513
|
let state = markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
|
|
@@ -466,8 +520,12 @@ export async function recoverStaleTelegramTopicApiError<
|
|
|
466
520
|
reason: "stale-api-error",
|
|
467
521
|
action: "topic-target-stale",
|
|
468
522
|
}) as TSyncState;
|
|
523
|
+
state = markTelegramSyncSliceSuspect(state, "target-bindings", {
|
|
524
|
+
nowMs,
|
|
525
|
+
reason: "stale-api-error",
|
|
526
|
+
action: "topic-target-stale",
|
|
527
|
+
}) as TSyncState;
|
|
469
528
|
deps.setSyncState(state);
|
|
470
|
-
await deps.topicTargetStore.persist();
|
|
471
529
|
deps.recordEvent("bus", error, {
|
|
472
530
|
phase: "topic-target-stale",
|
|
473
531
|
chatId: target.chatId,
|
|
@@ -606,6 +664,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
606
664
|
});
|
|
607
665
|
deps.recordThreadReconciliationPlan?.(replacementPlan);
|
|
608
666
|
await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
|
|
667
|
+
isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.topicTargetStore),
|
|
609
668
|
callApi: deps.callApi,
|
|
610
669
|
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
611
670
|
deps.topicTargetStore.markStaleByTarget(
|
package/lib/telegram-api.ts
CHANGED
|
@@ -466,6 +466,8 @@ export interface TelegramApiClient {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
export interface TelegramBridgeApiRuntimeDeps {
|
|
469
|
+
captureRequestErrorHandler?: (body: Record<string, unknown>) =>
|
|
470
|
+
((error: unknown) => Promise<void>) | undefined;
|
|
469
471
|
client: TelegramApiClient;
|
|
470
472
|
tempDir: string;
|
|
471
473
|
maxFileSizeBytes: number;
|
|
@@ -634,6 +636,11 @@ export function getTelegramApiErrorRequestTarget(
|
|
|
634
636
|
return target ? { ...target } : undefined;
|
|
635
637
|
}
|
|
636
638
|
|
|
639
|
+
export function isTelegramStaleTargetHttpError(error: unknown): boolean {
|
|
640
|
+
if (!(error instanceof TelegramApiHttpError) || error.status !== 400) return false;
|
|
641
|
+
return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
|
|
642
|
+
}
|
|
643
|
+
|
|
637
644
|
export function isTelegramMessageNotModifiedError(error: unknown): boolean {
|
|
638
645
|
return (
|
|
639
646
|
error instanceof Error && error.message.includes("message is not modified")
|
|
@@ -1438,6 +1445,7 @@ export function createTelegramAssistantDraftSender(deps: {
|
|
|
1438
1445
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
1439
1446
|
getBotToken: () => string | undefined;
|
|
1440
1447
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
|
1448
|
+
captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
|
|
1441
1449
|
}): TelegramBridgeApiRuntime {
|
|
1442
1450
|
return createTelegramBridgeApiRuntime({
|
|
1443
1451
|
client: createTelegramApiClient(deps.getBotToken, {
|
|
@@ -1447,12 +1455,23 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1447
1455
|
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
|
1448
1456
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
|
1449
1457
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1458
|
+
captureRequestErrorHandler: deps.captureRequestErrorHandler,
|
|
1450
1459
|
});
|
|
1451
1460
|
}
|
|
1452
1461
|
|
|
1453
1462
|
export function createTelegramBridgeApiRuntime(
|
|
1454
1463
|
deps: TelegramBridgeApiRuntimeDeps,
|
|
1455
1464
|
): TelegramBridgeApiRuntime {
|
|
1465
|
+
const recoverRequestError = async (
|
|
1466
|
+
handler: ((error: unknown) => Promise<void>) | undefined,
|
|
1467
|
+
error: unknown,
|
|
1468
|
+
): Promise<void> => {
|
|
1469
|
+
try {
|
|
1470
|
+
await handler?.(error);
|
|
1471
|
+
} catch (recoveryError) {
|
|
1472
|
+
deps.recordRuntimeEvent("api", recoveryError, { phase: "stale-target-recovery" });
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1456
1475
|
const now = deps.now ?? Date.now;
|
|
1457
1476
|
const chatActionMinIntervalMs = Math.max(
|
|
1458
1477
|
0,
|
|
@@ -1488,6 +1507,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1488
1507
|
body: Record<string, unknown>,
|
|
1489
1508
|
options?: TelegramApiCallOptions,
|
|
1490
1509
|
): Promise<TResponse> => {
|
|
1510
|
+
const recoverError = deps.captureRequestErrorHandler?.(body);
|
|
1491
1511
|
const chatActionKey = getChatActionKey(method, body);
|
|
1492
1512
|
if (chatActionKey) {
|
|
1493
1513
|
const nowMs = now();
|
|
@@ -1516,7 +1536,8 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1516
1536
|
gate.notBeforeMs = now() + chatActionMinIntervalMs;
|
|
1517
1537
|
return result;
|
|
1518
1538
|
})
|
|
1519
|
-
.catch((error: unknown) => {
|
|
1539
|
+
.catch(async (error: unknown) => {
|
|
1540
|
+
await recoverRequestError(recoverError, error);
|
|
1520
1541
|
if (error instanceof TelegramApiHttpError && error.status === 429) {
|
|
1521
1542
|
const retryAfterMs = Math.max(
|
|
1522
1543
|
chatActionMinIntervalMs,
|
|
@@ -1550,6 +1571,12 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1550
1571
|
try {
|
|
1551
1572
|
return await deps.client.call<TResponse>(method, body, options);
|
|
1552
1573
|
} catch (error) {
|
|
1574
|
+
await recoverRequestError(recoverError, error);
|
|
1575
|
+
if (method === "deleteMessage" && error instanceof TelegramApiHttpError &&
|
|
1576
|
+
error.status === 400 && error.message ===
|
|
1577
|
+
"Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
|
|
1578
|
+
return true as TResponse;
|
|
1579
|
+
}
|
|
1553
1580
|
deps.recordRuntimeEvent(
|
|
1554
1581
|
"api",
|
|
1555
1582
|
error,
|
|
@@ -1574,6 +1601,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1574
1601
|
fileName,
|
|
1575
1602
|
options,
|
|
1576
1603
|
) => {
|
|
1604
|
+
const recoverError = deps.captureRequestErrorHandler?.(fields);
|
|
1577
1605
|
try {
|
|
1578
1606
|
return await deps.client.callMultipart(
|
|
1579
1607
|
method,
|
|
@@ -1584,6 +1612,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1584
1612
|
options,
|
|
1585
1613
|
);
|
|
1586
1614
|
} catch (error) {
|
|
1615
|
+
await recoverRequestError(recoverError, error);
|
|
1587
1616
|
deps.recordRuntimeEvent(
|
|
1588
1617
|
"multipart",
|
|
1589
1618
|
error,
|
|
@@ -1676,11 +1705,13 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1676
1705
|
sendRichMessageDraft: (body) =>
|
|
1677
1706
|
callRecorded<boolean>("sendRichMessageDraft", body),
|
|
1678
1707
|
editMessageText: async (body) => {
|
|
1708
|
+
const recoverError = deps.captureRequestErrorHandler?.(body);
|
|
1679
1709
|
try {
|
|
1680
1710
|
await deps.client.call("editMessageText", body);
|
|
1681
1711
|
return "edited";
|
|
1682
1712
|
} catch (error) {
|
|
1683
1713
|
if (isTelegramMessageNotModifiedError(error)) return "unchanged";
|
|
1714
|
+
await recoverRequestError(recoverError, error);
|
|
1684
1715
|
deps.recordRuntimeEvent(
|
|
1685
1716
|
"api",
|
|
1686
1717
|
error,
|
package/lib/thread-reconciler.ts
CHANGED
|
@@ -202,6 +202,7 @@ export interface ThreadReconciliationApplyResult {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export interface ThreadReconciliationApplyPorts {
|
|
205
|
+
isCleanupTargetProtected?: (target: ThreadTarget, action: ThreadReconciliationAction) => boolean;
|
|
205
206
|
callApi?: <TResponse>(
|
|
206
207
|
method: string,
|
|
207
208
|
body: Record<string, unknown>,
|
|
@@ -576,6 +577,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
576
577
|
incompleteActions.push(action);
|
|
577
578
|
continue;
|
|
578
579
|
}
|
|
580
|
+
if (ports.isCleanupTargetProtected?.(action.target, action)) continue;
|
|
579
581
|
let closeConfirmed = false;
|
|
580
582
|
try {
|
|
581
583
|
await ports.callApi("closeForumTopic", {
|
|
@@ -598,6 +600,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
598
600
|
incompleteActions.push(action);
|
|
599
601
|
continue;
|
|
600
602
|
}
|
|
603
|
+
if (ports.isCleanupTargetProtected?.(action.target, action)) continue;
|
|
601
604
|
const changed =
|
|
602
605
|
ports.markStaleByTarget?.(action.target, "closed") ?? false;
|
|
603
606
|
if (changed) persistFences.push(action);
|
|
@@ -632,7 +635,12 @@ export async function applyThreadReconciliationPlan(
|
|
|
632
635
|
continue;
|
|
633
636
|
}
|
|
634
637
|
let deleteConfirmed = false;
|
|
638
|
+
let superseded = false;
|
|
635
639
|
for (const method of ["closeForumTopic", "deleteForumTopic"]) {
|
|
640
|
+
if (ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
641
|
+
superseded = true;
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
636
644
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) break;
|
|
637
645
|
try {
|
|
638
646
|
await ports.callApi(method, {
|
|
@@ -660,6 +668,15 @@ export async function applyThreadReconciliationPlan(
|
|
|
660
668
|
incompleteActions.push(action);
|
|
661
669
|
continue;
|
|
662
670
|
}
|
|
671
|
+
if (superseded || ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
672
|
+
ports.recordRuntimeEvent?.("telegram", "Cancelled cleanup of a protected Telegram target", {
|
|
673
|
+
phase: "thread-reconciler-cleanup-target-reused",
|
|
674
|
+
action: action.kind,
|
|
675
|
+
chatId: action.target.chatId,
|
|
676
|
+
threadId: action.target.threadId,
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
663
680
|
if (!deleteConfirmed) {
|
|
664
681
|
ports.recordRuntimeEvent?.(
|
|
665
682
|
"telegram",
|
package/lib/threads.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
writeFile,
|
|
22
22
|
} from "node:fs/promises";
|
|
23
23
|
import { dirname } from "node:path";
|
|
24
|
+
import { isDeepStrictEqual } from "node:util";
|
|
24
25
|
|
|
25
26
|
import {
|
|
26
27
|
isTelegramApiCommitUnknownError,
|
|
@@ -229,6 +230,11 @@ export interface TelegramTopicTargetStore {
|
|
|
229
230
|
/** Discard process-local projections and reload owner-published state. */
|
|
230
231
|
refresh?: () => Promise<void>;
|
|
231
232
|
persist: () => Promise<void>;
|
|
233
|
+
invalidateTarget: (
|
|
234
|
+
target: TelegramTarget,
|
|
235
|
+
isCurrent: () => boolean,
|
|
236
|
+
lastSyncError: string,
|
|
237
|
+
) => Promise<boolean>;
|
|
232
238
|
list: () => TelegramTopicTargetRecord[];
|
|
233
239
|
getFollowerRecoveryHintByTarget?: (
|
|
234
240
|
target: TelegramTarget,
|
|
@@ -316,6 +322,53 @@ export function reconcileTelegramFreshAllocationCursor(
|
|
|
316
322
|
return true;
|
|
317
323
|
}
|
|
318
324
|
|
|
325
|
+
export function createTelegramCleanupTargetProtection(
|
|
326
|
+
store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups">>,
|
|
327
|
+
departingRecord?: TelegramTopicTargetRecord,
|
|
328
|
+
): NonNullable<ThreadReconciler.ThreadReconciliationApplyPorts["isCleanupTargetProtected"]> {
|
|
329
|
+
const records = store.list();
|
|
330
|
+
const reservations = store.listReservations?.() ?? [];
|
|
331
|
+
const provisions = store.listPendingProvisions?.() ?? [];
|
|
332
|
+
const intents = store.listPendingCleanups?.() ?? [];
|
|
333
|
+
// Persistence may reconstruct keys in another order and omit undefined
|
|
334
|
+
// optional fields; neither changes the authority represented by a snapshot.
|
|
335
|
+
const sameSnapshot = (left: unknown, right: unknown): boolean =>
|
|
336
|
+
isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
337
|
+
return (target, action) => {
|
|
338
|
+
for (const record of store.list()) {
|
|
339
|
+
if (!targetMatches(record.target, target)) continue;
|
|
340
|
+
// A persisted shutdown intent may retire only its original pre-intent
|
|
341
|
+
// binding. Registration/rebinding after that intent supersedes it.
|
|
342
|
+
const intent = action.kind === "close-delete-graceful-shutdown-topic"
|
|
343
|
+
? intents.find((candidate) => candidate.id === action.cleanupIntentId && candidate.runtimeGeneration === action.runtimeGeneration)
|
|
344
|
+
: undefined;
|
|
345
|
+
const expectedDeparting = departingRecord ?? (intent && records.find((candidate) =>
|
|
346
|
+
candidate.instanceId === intent.instanceId && targetMatches(candidate.target, intent.target) &&
|
|
347
|
+
candidate.updatedAtMs <= intent.requestedAtMs));
|
|
348
|
+
if (expectedDeparting && "instanceId" in action &&
|
|
349
|
+
(action.kind === "close-delete-previous-leader-topic" || action.instanceId === expectedDeparting.instanceId) &&
|
|
350
|
+
sameSnapshot(record, expectedDeparting)) {
|
|
351
|
+
if (action.kind === "close-delete-previous-leader-topic" || action.kind === "close-stale-replaced-topic") continue;
|
|
352
|
+
if (action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
353
|
+
store.listPendingCleanups?.().some((intent) => intent.id === action.cleanupIntentId &&
|
|
354
|
+
intent.instanceId === action.instanceId && intent.runtimeGeneration === action.runtimeGeneration &&
|
|
355
|
+
targetMatches(intent.target, target))) continue;
|
|
356
|
+
}
|
|
357
|
+
if (record.status === "active" || record.status === "starting" || record.status === "pending" || record.status === "probe-required") return true;
|
|
358
|
+
}
|
|
359
|
+
for (const reservation of store.listReservations?.() ?? []) {
|
|
360
|
+
if (!targetMatches(reservation.target, target)) continue;
|
|
361
|
+
if (action.kind !== "close-delete-reserved-topic" || !reservations.some((initial) => sameSnapshot(initial, reservation))) return true;
|
|
362
|
+
}
|
|
363
|
+
for (const provision of store.listPendingProvisions?.() ?? []) {
|
|
364
|
+
if (!provision.target || !targetMatches(provision.target, target)) continue;
|
|
365
|
+
if (action.kind !== "close-delete-expired-pending-provision-topic" || provision.id !== action.pendingProvisionId ||
|
|
366
|
+
!provisions.some((initial) => sameSnapshot(initial, provision))) return true;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
319
372
|
export interface TelegramTopicTargetStoreOptions {
|
|
320
373
|
path: string | (() => string);
|
|
321
374
|
getNowMs?: () => number;
|
|
@@ -1230,7 +1283,11 @@ export function createTelegramTopicTargetStore(
|
|
|
1230
1283
|
loaded = true;
|
|
1231
1284
|
return;
|
|
1232
1285
|
}
|
|
1286
|
+
const revision = mutationRevision;
|
|
1233
1287
|
const content = await readFile(path, "utf8");
|
|
1288
|
+
// A read begun before a local mutation must not replace the newly admitted
|
|
1289
|
+
// binding/cleanup state with its older disk snapshot.
|
|
1290
|
+
if (mutationRevision !== revision || getPath() !== path) return;
|
|
1234
1291
|
const rawFile: unknown = JSON.parse(content);
|
|
1235
1292
|
const file = parseTopicTargetFile(rawFile);
|
|
1236
1293
|
followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
|
|
@@ -1293,25 +1350,20 @@ export function createTelegramTopicTargetStore(
|
|
|
1293
1350
|
mutationRevision += 1;
|
|
1294
1351
|
};
|
|
1295
1352
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
refresh() {
|
|
1302
|
-
const refresh = persistQueue.then(loadFromDisk);
|
|
1303
|
-
persistQueue = refresh.catch(() => undefined);
|
|
1304
|
-
return refresh;
|
|
1305
|
-
},
|
|
1306
|
-
persist() {
|
|
1353
|
+
const persistSnapshot = (invalidation?: {
|
|
1354
|
+
target: TelegramTarget;
|
|
1355
|
+
isCurrent: () => boolean;
|
|
1356
|
+
lastSyncError: string;
|
|
1357
|
+
}): Promise<boolean> => {
|
|
1307
1358
|
const persist = persistQueue.then(async () => {
|
|
1308
1359
|
const path = getPath();
|
|
1309
1360
|
if (loadedPath !== path && !dirty) resetForPath(path);
|
|
1310
1361
|
if (options.canPersist && !options.canPersist()) {
|
|
1311
|
-
await loadFromDisk();
|
|
1312
|
-
return;
|
|
1362
|
+
if (!invalidation) await loadFromDisk();
|
|
1363
|
+
return false;
|
|
1313
1364
|
}
|
|
1314
1365
|
if (!dirty || !loaded) await loadFromDisk();
|
|
1366
|
+
if (invalidation && !invalidation.isCurrent()) return false;
|
|
1315
1367
|
await mkdir(dirname(path), { recursive: true });
|
|
1316
1368
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1317
1369
|
const nowMs = getNowMs();
|
|
@@ -1359,6 +1411,21 @@ export function createTelegramTopicTargetStore(
|
|
|
1359
1411
|
return serialized;
|
|
1360
1412
|
}),
|
|
1361
1413
|
};
|
|
1414
|
+
if (invalidation) {
|
|
1415
|
+
const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
|
|
1416
|
+
if (!record) return false;
|
|
1417
|
+
file.threads = file.threads.filter((candidate) => candidate !== record);
|
|
1418
|
+
file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
|
|
1419
|
+
file.syncObservations.push({
|
|
1420
|
+
target: { ...record.target },
|
|
1421
|
+
syncStatus: "deleted",
|
|
1422
|
+
observedAtMs: nowMs,
|
|
1423
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
1424
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1425
|
+
lastSyncError: invalidation.lastSyncError,
|
|
1426
|
+
lastReconcileAction: "mark-stale",
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1362
1429
|
let persistedSemanticSnapshot: string | undefined;
|
|
1363
1430
|
try {
|
|
1364
1431
|
persistedSemanticSnapshot = serializeTelegramStateSemanticSnapshot(
|
|
@@ -1375,7 +1442,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1375
1442
|
) {
|
|
1376
1443
|
loaded = true;
|
|
1377
1444
|
dirty = false;
|
|
1378
|
-
return;
|
|
1445
|
+
return true;
|
|
1379
1446
|
}
|
|
1380
1447
|
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1381
1448
|
encoding: "utf8",
|
|
@@ -1383,6 +1450,25 @@ export function createTelegramTopicTargetStore(
|
|
|
1383
1450
|
});
|
|
1384
1451
|
await chmod(tempPath, 0o600);
|
|
1385
1452
|
try {
|
|
1453
|
+
if (invalidation) {
|
|
1454
|
+
let applied = false;
|
|
1455
|
+
const commit = () => {
|
|
1456
|
+
if (getPath() !== path || mutationRevision !== persistedRevision ||
|
|
1457
|
+
statusRevision !== persistedStatusRevision || !invalidation.isCurrent()) return;
|
|
1458
|
+
// Fence and rename share one synchronous commit boundary. No stale
|
|
1459
|
+
// invalidation enters the live projection before durable commit.
|
|
1460
|
+
renameSync(tempPath, path);
|
|
1461
|
+
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
|
|
1462
|
+
syncObservations = file.syncObservations;
|
|
1463
|
+
mutationRevision += 1;
|
|
1464
|
+
dirty = false;
|
|
1465
|
+
applied = true;
|
|
1466
|
+
};
|
|
1467
|
+
if (options.commitPersist) options.commitPersist(commit);
|
|
1468
|
+
else if (!options.canPersist || options.canPersist()) commit();
|
|
1469
|
+
if (!applied) await unlink(tempPath).catch(() => undefined);
|
|
1470
|
+
return applied;
|
|
1471
|
+
}
|
|
1386
1472
|
if (options.commitPersist) {
|
|
1387
1473
|
const committed = options.commitPersist(() => {
|
|
1388
1474
|
renameSync(tempPath, path);
|
|
@@ -1404,9 +1490,27 @@ export function createTelegramTopicTargetStore(
|
|
|
1404
1490
|
}
|
|
1405
1491
|
loaded = true;
|
|
1406
1492
|
if (mutationRevision === persistedRevision) dirty = false;
|
|
1493
|
+
return true;
|
|
1407
1494
|
});
|
|
1408
|
-
persistQueue = persist.
|
|
1495
|
+
persistQueue = persist.then(() => undefined, () => undefined);
|
|
1409
1496
|
return persist;
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
return {
|
|
1500
|
+
async load() {
|
|
1501
|
+
if (dirty) return;
|
|
1502
|
+
await loadFromDisk();
|
|
1503
|
+
},
|
|
1504
|
+
refresh() {
|
|
1505
|
+
const refresh = persistQueue.then(loadFromDisk);
|
|
1506
|
+
persistQueue = refresh.catch(() => undefined);
|
|
1507
|
+
return refresh;
|
|
1508
|
+
},
|
|
1509
|
+
async persist() {
|
|
1510
|
+
await persistSnapshot();
|
|
1511
|
+
},
|
|
1512
|
+
invalidateTarget(target, isCurrent, lastSyncError) {
|
|
1513
|
+
return persistSnapshot({ target, isCurrent, lastSyncError });
|
|
1410
1514
|
},
|
|
1411
1515
|
list() {
|
|
1412
1516
|
return Array.from(records.values()).map(cloneRecord);
|
|
@@ -2100,6 +2204,7 @@ export async function provisionOwnBusTopic(
|
|
|
2100
2204
|
if (typeof chatId !== "number") return undefined;
|
|
2101
2205
|
await deps.store.load();
|
|
2102
2206
|
const reservationCleanupPorts = {
|
|
2207
|
+
isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.store),
|
|
2103
2208
|
callApi: deps.callApi,
|
|
2104
2209
|
markStaleByTarget: (
|
|
2105
2210
|
target: TelegramTarget & { threadId: number },
|
|
@@ -2253,9 +2358,11 @@ export async function provisionOwnBusTopic(
|
|
|
2253
2358
|
continue;
|
|
2254
2359
|
}
|
|
2255
2360
|
const previousLeaderCleanupStartedAtMs = Date.now();
|
|
2361
|
+
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.store, record);
|
|
2256
2362
|
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
2257
2363
|
{ actions: [action] },
|
|
2258
2364
|
{
|
|
2365
|
+
isCleanupTargetProtected,
|
|
2259
2366
|
callApi: deps.callApi,
|
|
2260
2367
|
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
2261
2368
|
deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
@@ -2304,6 +2411,7 @@ export async function provisionOwnBusTopic(
|
|
|
2304
2411
|
"Previous Telegram leader topic deletion was not confirmed.",
|
|
2305
2412
|
);
|
|
2306
2413
|
}
|
|
2414
|
+
if (isCleanupTargetProtected(action.target, action)) continue;
|
|
2307
2415
|
deps.store.markStaleByTarget(record.target);
|
|
2308
2416
|
deps.store.reserveThread({
|
|
2309
2417
|
target: record.target,
|
package/package.json
CHANGED
|
@@ -40,7 +40,9 @@ A surface is an ordered ragged sequence of rows. Each button carries:
|
|
|
40
40
|
|
|
41
41
|
- A short, distinct label.
|
|
42
42
|
- The smallest self-contained next-request prompt.
|
|
43
|
-
- Optional presentation state supported by the transport.
|
|
43
|
+
- Optional presentation state supported by the transport, including disabled controls when their visible unavailability helps explain current state.
|
|
44
|
+
|
|
45
|
+
A disabled control is not an action: it needs no prompt or selected style and must not enqueue a prompt or invoke a bound method. Prefer a meaningful label; omit it only for an intentional blank cell in a spatial layout, never as decorative padding. Preserve its label and position when that makes a changing surface easier to understand; otherwise omit irrelevant controls. Explain non-obvious unavailability without relying on color alone. Retain at least one useful enabled action, such as refresh or navigation. Derive disabled state from the same evidence as the view; an old enabled control still requires current domain validation. Use the transport owner's disabled encoding rather than a dummy prompt or no-op callback.
|
|
44
46
|
|
|
45
47
|
Prompts must name any target, operation, constraint, or freshness identity whose omission could change the action. Reuse visible context only when it remains unambiguous under delayed or reordered clicks. Never encode volatile output that should be freshly inspected.
|
|
46
48
|
|
|
@@ -57,7 +59,7 @@ Every generated human-readable action label must use `emoji + space + text`; emo
|
|
|
57
59
|
|
|
58
60
|
For complex grids, navigation collections, or stateful repeated clicks, read [`references/layout-and-state.md`](./references/layout-and-state.md).
|
|
59
61
|
|
|
60
|
-
Serialize the resulting rows with the active transport contract. This Skill owns admission and composition, not transport syntax.
|
|
62
|
+
Place a control group beside the section it governs when the transport supports in-body blocks; use a footer for whole-answer actions. Placement must not change the matrix or action semantics. Serialize the resulting rows with the active transport contract. This Skill owns admission and composition, not transport syntax.
|
|
61
63
|
|
|
62
64
|
## Safety
|
|
63
65
|
|
|
@@ -10,7 +10,7 @@ Model the surface as ordered ragged rows, not a rectangle to fill. Infer indepen
|
|
|
10
10
|
- A horizontal pair is earned only by genuine peers with unmistakably compact labels and no plausible wrapping or truncation.
|
|
11
11
|
- Three through five columns are for short symbols, coordinates, glyphs, or codes whose position carries meaning.
|
|
12
12
|
- Six through eight columns require single-glyph or similarly minimal position-bearing labels. Never exceed eight columns on a phone surface.
|
|
13
|
-
- Vary row width intentionally; never pad with empty, duplicate, or no-op controls.
|
|
13
|
+
- Vary row width intentionally; never pad for symmetry with empty, duplicate, or no-op controls. A blank disabled cell is appropriate only when it represents a real unavailable position in a spatial grid.
|
|
14
14
|
- Preserve reading order: orientation/navigation, primary content or choices, secondary controls, then separated destructive actions.
|
|
15
15
|
- Rectangular grids require genuine spatial or coordinate correspondence. Vertical continuity may justify many rows; non-spatial button walls should paginate or group.
|
|
16
16
|
|
|
@@ -32,4 +32,6 @@ Keep trivial state in conversation. Persist a small human-auditable artifact whe
|
|
|
32
32
|
|
|
33
33
|
Evaluate repeated clicks against current state, not stale button appearance. Preserve tap-ahead when the transport queues each click independently. In source-then-destination interaction, retain the source selection without duplicating the whole surface; regenerate after a completed transition, invalid input, or evidence that the transport cannot preserve the intermediate view.
|
|
34
34
|
|
|
35
|
-
Omit unavailable controls when layout does not matter. Preserve occupied or selected cells when spatial topology depends on stable coordinates.
|
|
35
|
+
Omit unavailable controls when layout does not matter. Preserve occupied or selected cells when spatial topology depends on stable coordinates, using the transport's disabled state without a fabricated prompt. Keep a useful enabled navigation or inspection action.
|
|
36
|
+
|
|
37
|
+
Place each control group beside the content it governs when in-body blocks are supported; keep global navigation and whole-view actions in the footer. Do not duplicate one action in both positions merely for visibility. Row topology and current-state validation stay the same across compact and named representations and across placements; renderer limits and selection feedback belong to the transport.
|
|
@@ -29,9 +29,9 @@ Generated Control Surface → current context → model → one ephemeral surfac
|
|
|
29
29
|
Generative App → model → reusable program → many evolving surfaces
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Both Skills use the same logical button matrix and `label + prompt` interaction model. The Telegram runtime owns its full JSON/CML notation and callback routing; this Skill owns reusable program judgment, while `generated-control-surface` owns ephemeral agent-authored composition. Shared rendering needs no third button Skill and does not collapse those responsibilities.
|
|
32
|
+
Both Skills use the same logical button matrix and `label + prompt` interaction model. An app may place groups beside their related content using the transport's in-body button blocks or keep whole-view controls in the footer; placement does not change binding, disabled state, or method authority. The Telegram runtime owns its full JSON/CML notation and callback routing; this Skill owns reusable program judgment, while `generated-control-surface` owns ephemeral agent-authored composition. Shared rendering needs no third button Skill and does not collapse those responsibilities.
|
|
33
33
|
|
|
34
|
-
An app may mix deterministic `app::method` controls and ordinary prompts in one view. Compile only the stable transitions that benefit from inference bypass; keep explanation, interpretation, teaching, and adaptation on the model-mediated plane. When no reusable state or deterministic loop earns a script, load and use `generated-control-surface` instead.
|
|
34
|
+
An app may mix deterministic `app::method` controls and ordinary prompts in one view, including one control group. Compact and named cells may coexist under the shared transport grammar; neither representation nor placement creates another action plane. Compile only the stable transitions that benefit from inference bypass; keep explanation, interpretation, teaching, and adaptation on the model-mediated plane. When no reusable state or deterministic loop earns a script, load and use `generated-control-surface` instead.
|
|
35
35
|
|
|
36
36
|
The `generated` / `generative` distinction is intentional. Do not rename `generated-control-surface` to a competing generative term.
|
|
37
37
|
|
|
@@ -76,7 +76,7 @@ Another capability remains the authoritative real owner. The app stores validate
|
|
|
76
76
|
1. Identify the repeated feedback loop, real state owner, and actions that are truly deterministic.
|
|
77
77
|
2. Choose one stable lowercase app and one self-contained `<app>.mjs` source outside the managed installation directory.
|
|
78
78
|
3. Keep `init` and every exported method small, named, bounded, shell-free, and capability-specific.
|
|
79
|
-
4. Render one complete next view after each action.
|
|
79
|
+
4. Render one complete next view after each action. Derive disabled controls from current state using the shared transport contract; visible disabled controls neither invoke methods nor enqueue prompts. Revalidate domain preconditions when an enabled control is invoked, because previously rendered views may be stale.
|
|
80
80
|
5. Mix action planes intentionally:
|
|
81
81
|
|
|
82
82
|
```text
|
|
@@ -107,6 +107,7 @@ Before presenting an app as working:
|
|
|
107
107
|
- Inspect the installed initial view and persisted bounded state.
|
|
108
108
|
- Exercise at least one real bound action and prove it bypasses Pi queue/model admission.
|
|
109
109
|
- Exercise at least one ordinary prompt when the app intentionally uses the model plane.
|
|
110
|
+
- Verify the placements actually used by the app and prove disabled cells invoke nothing. A successful ordinary prompt-button smoke is not evidence of app-method dispatch or stale-revision rejection.
|
|
110
111
|
- Verify replacement rejects stale buttons and failed initialization preserves the prior app.
|
|
111
112
|
- For adapters, prove fresh external status and terminal mutation evidence.
|
|
112
113
|
- Confirm failures are bounded, redacted, and do not silently render success.
|