@llblab/pi-telegram 0.24.6 → 0.24.8
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 +5 -5
- package/BACKLOG.md +0 -12
- package/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/api/keyboard.ts +1 -0
- package/docs/architecture.md +1 -1
- package/docs/multi-instance-bus.md +7 -6
- package/docs/outbound.md +4 -2
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +4 -0
- package/index.ts +10 -0
- package/lib/bus-leader.ts +204 -59
- package/lib/bus.ts +6 -1
- package/lib/config.ts +1 -1
- package/lib/keyboard.ts +6 -0
- package/lib/menu-settings.ts +8 -12
- package/lib/outbound-buttons.ts +89 -9
- package/lib/outbound.ts +1 -0
- package/lib/prompts.ts +3 -2
- package/lib/routing.ts +25 -2
- package/lib/status.ts +11 -10
- package/lib/sync.ts +42 -20
- package/lib/thread-reconciler.ts +100 -2
- package/lib/threads.ts +95 -0
- package/package.json +3 -26
- package/scripts/audit-dependencies.ts +0 -78
- package/scripts/dependency-audit-policy.ts +0 -300
package/lib/outbound-buttons.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
TelegramInlineKeyboardButtonStyle,
|
|
11
|
+
TelegramInlineKeyboardMarkup,
|
|
12
|
+
} from "./keyboard.ts";
|
|
10
13
|
import {
|
|
11
14
|
parseTelegramCommentAttributes,
|
|
12
15
|
parseTopLevelTelegramComment,
|
|
@@ -24,6 +27,7 @@ const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
24
27
|
export interface TelegramOutboundButtonAction {
|
|
25
28
|
text: string;
|
|
26
29
|
prompt: string;
|
|
30
|
+
selectedStyle?: TelegramInlineKeyboardButtonStyle;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
|
|
@@ -51,6 +55,7 @@ export interface TelegramButtonCallbackQuery {
|
|
|
51
55
|
message_id?: number;
|
|
52
56
|
message_thread_id?: number;
|
|
53
57
|
chat?: { id?: number };
|
|
58
|
+
reply_markup?: TelegramOutboundButtonMarkup;
|
|
54
59
|
};
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -66,7 +71,12 @@ export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
|
|
|
66
71
|
query: TelegramButtonCallbackQuery,
|
|
67
72
|
action: TelegramOutboundButtonAction,
|
|
68
73
|
ctx: TContext,
|
|
69
|
-
) => void;
|
|
74
|
+
) => boolean | void;
|
|
75
|
+
editMessageReplyMarkup?: (
|
|
76
|
+
chatId: number,
|
|
77
|
+
messageId: number,
|
|
78
|
+
replyMarkup: TelegramOutboundButtonMarkup,
|
|
79
|
+
) => Promise<void>;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
function nowMs(): number {
|
|
@@ -80,11 +90,18 @@ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
|
|
|
80
90
|
function parseButtonsCommentAttributes(input: string): {
|
|
81
91
|
label?: string;
|
|
82
92
|
prompt?: string;
|
|
93
|
+
selectedStyle?: TelegramInlineKeyboardButtonStyle;
|
|
83
94
|
} {
|
|
84
95
|
const attributes = parseTelegramCommentAttributes(input);
|
|
96
|
+
const selectedStyle = attributes.selected_style;
|
|
85
97
|
return {
|
|
86
98
|
...(attributes.label ? { label: attributes.label } : {}),
|
|
87
99
|
...(attributes.prompt ? { prompt: attributes.prompt } : {}),
|
|
100
|
+
...(selectedStyle === "success" ||
|
|
101
|
+
selectedStyle === "danger" ||
|
|
102
|
+
selectedStyle === "primary"
|
|
103
|
+
? { selectedStyle }
|
|
104
|
+
: {}),
|
|
88
105
|
};
|
|
89
106
|
}
|
|
90
107
|
|
|
@@ -101,14 +118,34 @@ function parseButtonsCommentRows(
|
|
|
101
118
|
}
|
|
102
119
|
const attributes = parseButtonsCommentAttributes(head);
|
|
103
120
|
return attributes.label && attributes.prompt
|
|
104
|
-
? [
|
|
121
|
+
? [
|
|
122
|
+
[
|
|
123
|
+
{
|
|
124
|
+
text: attributes.label,
|
|
125
|
+
prompt: attributes.prompt,
|
|
126
|
+
...(attributes.selectedStyle
|
|
127
|
+
? { selectedStyle: attributes.selectedStyle }
|
|
128
|
+
: {}),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
]
|
|
105
132
|
: [];
|
|
106
133
|
}
|
|
107
134
|
|
|
108
|
-
const
|
|
135
|
+
const attributes = parseButtonsCommentAttributes(head);
|
|
109
136
|
const prompt = body.trim();
|
|
110
|
-
if (!label || !prompt) return [];
|
|
111
|
-
return [
|
|
137
|
+
if (!attributes.label || !prompt) return [];
|
|
138
|
+
return [
|
|
139
|
+
[
|
|
140
|
+
{
|
|
141
|
+
text: attributes.label,
|
|
142
|
+
prompt,
|
|
143
|
+
...(attributes.selectedStyle
|
|
144
|
+
? { selectedStyle: attributes.selectedStyle }
|
|
145
|
+
: {}),
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
];
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
export function createTelegramButtonActionStore(
|
|
@@ -138,11 +175,19 @@ export function createTelegramButtonActionStore(
|
|
|
138
175
|
const action = actions.get(callbackData);
|
|
139
176
|
if (!action) return undefined;
|
|
140
177
|
actions.delete(callbackData);
|
|
141
|
-
return {
|
|
178
|
+
return {
|
|
179
|
+
text: action.text,
|
|
180
|
+
prompt: action.prompt,
|
|
181
|
+
...(action.selectedStyle
|
|
182
|
+
? { selectedStyle: action.selectedStyle }
|
|
183
|
+
: {}),
|
|
184
|
+
};
|
|
142
185
|
},
|
|
143
186
|
};
|
|
144
187
|
}
|
|
145
188
|
|
|
189
|
+
const DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN = "Choose an option:";
|
|
190
|
+
|
|
146
191
|
export function planTelegramButtonReply(
|
|
147
192
|
markdown: string,
|
|
148
193
|
deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
|
|
@@ -162,8 +207,12 @@ export function planTelegramButtonReply(
|
|
|
162
207
|
}
|
|
163
208
|
return "";
|
|
164
209
|
});
|
|
210
|
+
const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
|
|
165
211
|
return {
|
|
166
|
-
markdown:
|
|
212
|
+
markdown:
|
|
213
|
+
keyboard.length > 0 && !visibleMarkdown
|
|
214
|
+
? DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN
|
|
215
|
+
: visibleMarkdown,
|
|
167
216
|
...(keyboard.length > 0
|
|
168
217
|
? { replyMarkup: { inline_keyboard: keyboard } }
|
|
169
218
|
: {}),
|
|
@@ -203,6 +252,22 @@ export function createTelegramButtonPromptTurn(options: {
|
|
|
203
252
|
};
|
|
204
253
|
}
|
|
205
254
|
|
|
255
|
+
export function markTelegramButtonSelected(
|
|
256
|
+
replyMarkup: TelegramOutboundButtonMarkup,
|
|
257
|
+
callbackData: string,
|
|
258
|
+
selectedStyle: TelegramInlineKeyboardButtonStyle = "primary",
|
|
259
|
+
): TelegramOutboundButtonMarkup | undefined {
|
|
260
|
+
let matched = false;
|
|
261
|
+
const inlineKeyboard = replyMarkup.inline_keyboard.map((row) =>
|
|
262
|
+
row.map((button) => {
|
|
263
|
+
if (button.callback_data !== callbackData) return { ...button };
|
|
264
|
+
matched = true;
|
|
265
|
+
return { ...button, style: selectedStyle };
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
return matched ? { inline_keyboard: inlineKeyboard } : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
206
271
|
export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
|
|
207
272
|
query: TelegramButtonCallbackQuery,
|
|
208
273
|
ctx: TContext,
|
|
@@ -225,7 +290,22 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
|
|
|
225
290
|
return true;
|
|
226
291
|
}
|
|
227
292
|
|
|
228
|
-
deps.enqueueButtonPrompt(query, action, ctx);
|
|
293
|
+
const enqueued = deps.enqueueButtonPrompt(query, action, ctx);
|
|
294
|
+
if (enqueued === false) {
|
|
295
|
+
await deps.answerCallbackQuery(query.id, "Already queued.");
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
const selectedMarkup =
|
|
299
|
+
query.data && query.message?.reply_markup
|
|
300
|
+
? markTelegramButtonSelected(
|
|
301
|
+
query.message.reply_markup,
|
|
302
|
+
query.data,
|
|
303
|
+
action.selectedStyle,
|
|
304
|
+
)
|
|
305
|
+
: undefined;
|
|
306
|
+
if (selectedMarkup && deps.editMessageReplyMarkup) {
|
|
307
|
+
await deps.editMessageReplyMarkup(chatId, messageId, selectedMarkup);
|
|
308
|
+
}
|
|
229
309
|
await deps.answerCallbackQuery(query.id, "Queued.");
|
|
230
310
|
return true;
|
|
231
311
|
}
|
package/lib/outbound.ts
CHANGED
|
@@ -817,6 +817,7 @@ export {
|
|
|
817
817
|
createTelegramButtonPromptTurn,
|
|
818
818
|
createTelegramButtonReplyPlanner,
|
|
819
819
|
handleTelegramButtonCallbackQuery,
|
|
820
|
+
markTelegramButtonSelected,
|
|
820
821
|
planTelegramButtonReply,
|
|
821
822
|
type TelegramButtonActionStore,
|
|
822
823
|
type TelegramButtonCallbackHandlerDeps,
|
package/lib/prompts.ts
CHANGED
|
@@ -143,8 +143,9 @@ Assistant-authored Telegram actions:
|
|
|
143
143
|
- Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
|
|
144
144
|
- Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
|
|
145
145
|
- Voice reply modes are compact: \`hidden\` emits no automatic context, \`mirror\` emits it for voice/audio input, and \`always\` emits it for every Telegram turn. Explicit \`telegram_voice\` remains available for an intentionally distinct spoken payload.
|
|
146
|
-
- Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
|
|
147
|
-
-
|
|
146
|
+
- Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks" selected_style="danger"\nList the main risks first.\n-->\`.
|
|
147
|
+
- Optional \`selected_style\` controls the button after queue admission: \`primary\` (default, blue), \`success\` (green), or \`danger\` (red). It never suppresses the prompt.
|
|
148
|
+
- If hidden button comments form the whole reply, the bridge supplies visible fallback text automatically.
|
|
148
149
|
|
|
149
150
|
Local/TUI direct delivery:
|
|
150
151
|
- Do not send Telegram actions from local/TUI prompts unless explicitly asked.
|
package/lib/routing.ts
CHANGED
|
@@ -608,6 +608,11 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
608
608
|
mode: "markdown" | "html" | "plain",
|
|
609
609
|
replyMarkup: Menu.TelegramReplyMarkup,
|
|
610
610
|
) => Promise<void>;
|
|
611
|
+
editMessageReplyMarkup?: (
|
|
612
|
+
chatId: number,
|
|
613
|
+
messageId: number,
|
|
614
|
+
replyMarkup: OutboundHandlers.TelegramOutboundButtonMarkup,
|
|
615
|
+
) => Promise<void>;
|
|
611
616
|
sendInteractiveMessage?: (
|
|
612
617
|
chatId: number,
|
|
613
618
|
text: string,
|
|
@@ -1448,11 +1453,28 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1448
1453
|
{
|
|
1449
1454
|
resolveAction: deps.buttonActionStore.resolve,
|
|
1450
1455
|
answerCallbackQuery: deps.answerCallbackQuery,
|
|
1456
|
+
editMessageReplyMarkup: deps.editMessageReplyMarkup
|
|
1457
|
+
? async (chatId, messageId, replyMarkup) => {
|
|
1458
|
+
try {
|
|
1459
|
+
await deps.editMessageReplyMarkup?.(
|
|
1460
|
+
chatId,
|
|
1461
|
+
messageId,
|
|
1462
|
+
replyMarkup,
|
|
1463
|
+
);
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
1466
|
+
phase: "button-selection-mark",
|
|
1467
|
+
chatId,
|
|
1468
|
+
messageId,
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
: undefined,
|
|
1451
1473
|
enqueueButtonPrompt: (buttonQuery, action, context) => {
|
|
1452
1474
|
const chatId = buttonQuery.message?.chat?.id;
|
|
1453
1475
|
const messageId = buttonQuery.message?.message_id;
|
|
1454
1476
|
if (typeof chatId !== "number" || typeof messageId !== "number")
|
|
1455
|
-
return;
|
|
1477
|
+
return false;
|
|
1456
1478
|
const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
|
|
1457
1479
|
const turn = OutboundHandlers.createTelegramButtonPromptTurn({
|
|
1458
1480
|
chatId,
|
|
@@ -1471,10 +1493,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1471
1493
|
deps.telegramQueueStore.getQueuedItems(),
|
|
1472
1494
|
turn,
|
|
1473
1495
|
);
|
|
1474
|
-
if (!result.appended) return;
|
|
1496
|
+
if (!result.appended) return false;
|
|
1475
1497
|
deps.telegramQueueStore.setQueuedItems(result.items);
|
|
1476
1498
|
deps.updateStatus(context);
|
|
1477
1499
|
requestDispatchNextQueuedTelegramTurn(context);
|
|
1500
|
+
return true;
|
|
1478
1501
|
},
|
|
1479
1502
|
},
|
|
1480
1503
|
);
|
package/lib/status.ts
CHANGED
|
@@ -1272,11 +1272,13 @@ function buildStatusSummary(ctx: TelegramStatusContext): string {
|
|
|
1272
1272
|
return "unknown";
|
|
1273
1273
|
}
|
|
1274
1274
|
|
|
1275
|
-
function
|
|
1275
|
+
function buildTelegramStatusThreadSummary(
|
|
1276
1276
|
state: TelegramBridgeStatusLineState | undefined,
|
|
1277
|
-
): string {
|
|
1278
|
-
if (state?.botThreadMode !== "enabled" || !state.busRole) return
|
|
1279
|
-
|
|
1277
|
+
): string | undefined {
|
|
1278
|
+
if (state?.botThreadMode !== "enabled" || !state.busRole) return undefined;
|
|
1279
|
+
const threadName = state.instanceThreadName?.trim();
|
|
1280
|
+
const identity = threadName || (state.instanceSlot ? `[${state.instanceSlot}]` : "");
|
|
1281
|
+
return identity ? `${identity} @${state.busRole}` : undefined;
|
|
1280
1282
|
}
|
|
1281
1283
|
|
|
1282
1284
|
export function buildStatusHtml(
|
|
@@ -1288,12 +1290,11 @@ export function buildStatusHtml(
|
|
|
1288
1290
|
const usesSubscription = activeModel
|
|
1289
1291
|
? ctx.modelRegistry.isUsingOAuth(activeModel)
|
|
1290
1292
|
: false;
|
|
1291
|
-
const lines: string[] = [
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
];
|
|
1293
|
+
const lines: string[] = [buildStatusRow("Status", buildStatusSummary(ctx))];
|
|
1294
|
+
const threadSummary = buildTelegramStatusThreadSummary(bridgeStatus);
|
|
1295
|
+
if (threadSummary) {
|
|
1296
|
+
lines.push(buildStatusRow("Thread", threadSummary));
|
|
1297
|
+
}
|
|
1297
1298
|
const usageSummary = buildUsageSummary(stats);
|
|
1298
1299
|
const costSummary = buildCostSummary(stats, usesSubscription);
|
|
1299
1300
|
if (usageSummary) {
|
package/lib/sync.ts
CHANGED
|
@@ -110,11 +110,19 @@ export interface TelegramLeaderHealthRuntime {
|
|
|
110
110
|
export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
111
111
|
instanceId: string;
|
|
112
112
|
getCurrentThreadRecord: () =>
|
|
113
|
-
| {
|
|
113
|
+
| {
|
|
114
|
+
target: TelegramTarget;
|
|
115
|
+
instanceId?: string;
|
|
116
|
+
profileKey?: string;
|
|
117
|
+
owner?: { kind?: string };
|
|
118
|
+
}
|
|
114
119
|
| undefined;
|
|
115
120
|
topicTargetStore: Pick<
|
|
116
121
|
TelegramTopicTargetStore,
|
|
117
|
-
|
|
122
|
+
| "markStaleByTarget"
|
|
123
|
+
| "persist"
|
|
124
|
+
| "upsertPendingCleanup"
|
|
125
|
+
| "removePendingCleanup"
|
|
118
126
|
>;
|
|
119
127
|
callApi: <TResponse>(
|
|
120
128
|
method: string,
|
|
@@ -175,22 +183,44 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
175
183
|
}
|
|
176
184
|
}
|
|
177
185
|
} else {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
186
|
+
const target = currentRecord.target as TelegramTarget & {
|
|
187
|
+
threadId: number;
|
|
188
|
+
};
|
|
189
|
+
const runtimeGeneration = currentRecord.instanceId ?? deps.instanceId;
|
|
190
|
+
const intent: ThreadReconciler.TelegramThreadCleanupIntent = {
|
|
191
|
+
id: `cleanup:${deps.instanceId}:${runtimeGeneration}:${target.chatId}:${target.threadId}`,
|
|
192
|
+
owner: isManualFollower ? "manual-follower" : "leader",
|
|
193
|
+
instanceId: deps.instanceId,
|
|
194
|
+
runtimeGeneration,
|
|
195
|
+
...(currentRecord.profileKey
|
|
196
|
+
? { profileKey: currentRecord.profileKey }
|
|
197
|
+
: {}),
|
|
198
|
+
target,
|
|
199
|
+
requestedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
200
|
+
};
|
|
201
|
+
deps.topicTargetStore.upsertPendingCleanup(intent);
|
|
202
|
+
await deps.topicTargetStore.persist();
|
|
182
203
|
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
183
|
-
ThreadReconciler.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
leaderEpoch,
|
|
204
|
+
ThreadReconciler.planThreadReconciliation({
|
|
205
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
206
|
+
currentLeaderEpoch: leaderEpoch,
|
|
207
|
+
records: [],
|
|
208
|
+
pendingCleanups: [intent],
|
|
189
209
|
}),
|
|
190
210
|
{
|
|
191
211
|
callApi(method, body) {
|
|
192
212
|
return deps.callApi(method, body);
|
|
193
213
|
},
|
|
214
|
+
markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
|
|
215
|
+
return deps.topicTargetStore.markStaleByTarget(
|
|
216
|
+
targetToMark,
|
|
217
|
+
syncStatus,
|
|
218
|
+
lastSyncError,
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
removeCleanupIntentById(id) {
|
|
222
|
+
return deps.topicTargetStore.removePendingCleanup(id);
|
|
223
|
+
},
|
|
194
224
|
persist() {
|
|
195
225
|
return deps.topicTargetStore.persist();
|
|
196
226
|
},
|
|
@@ -203,14 +233,6 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
203
233
|
"Telegram thread deletion was not confirmed; inspect /telegram-status --debug and retry /telegram-disconnect.",
|
|
204
234
|
);
|
|
205
235
|
}
|
|
206
|
-
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
|
|
207
|
-
const offlineChanged =
|
|
208
|
-
deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0;
|
|
209
|
-
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
|
|
210
|
-
if (offlineChanged) {
|
|
211
|
-
await deps.topicTargetStore.persist();
|
|
212
|
-
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
|
|
213
|
-
}
|
|
214
236
|
}
|
|
215
237
|
const leaderTarget = deps.getLeaderTarget();
|
|
216
238
|
if (
|
package/lib/thread-reconciler.ts
CHANGED
|
@@ -48,6 +48,16 @@ export interface TelegramThreadPendingProvision {
|
|
|
48
48
|
leaderEpoch?: number | string;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export interface TelegramThreadCleanupIntent {
|
|
52
|
+
id: string;
|
|
53
|
+
owner: "leader" | "manual-follower";
|
|
54
|
+
instanceId: string;
|
|
55
|
+
runtimeGeneration: string;
|
|
56
|
+
profileKey?: string;
|
|
57
|
+
target: ThreadTarget;
|
|
58
|
+
requestedAtMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export interface TelegramUnboundThreadMessageObservation {
|
|
52
62
|
target: TelegramTarget & { threadId: number };
|
|
53
63
|
observedAtMs: number;
|
|
@@ -130,6 +140,24 @@ export type ThreadReconciliationAction =
|
|
|
130
140
|
messageId?: number;
|
|
131
141
|
leaderEpoch?: number | string;
|
|
132
142
|
}
|
|
143
|
+
| {
|
|
144
|
+
kind: "close-delete-graceful-shutdown-topic";
|
|
145
|
+
target: TelegramTarget & { threadId: number };
|
|
146
|
+
reason: "graceful-shutdown";
|
|
147
|
+
cleanupIntentId: string;
|
|
148
|
+
instanceId: string;
|
|
149
|
+
runtimeGeneration: string;
|
|
150
|
+
leaderEpoch?: number | string;
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
kind: "cancel-superseded-graceful-shutdown-cleanup";
|
|
154
|
+
target: TelegramTarget & { threadId: number };
|
|
155
|
+
reason: "replacement-registration";
|
|
156
|
+
cleanupIntentId: string;
|
|
157
|
+
instanceId: string;
|
|
158
|
+
runtimeGeneration: string;
|
|
159
|
+
leaderEpoch?: number | string;
|
|
160
|
+
}
|
|
133
161
|
| {
|
|
134
162
|
kind: "close-delete-expired-pending-provision-topic";
|
|
135
163
|
target: TelegramTarget & { threadId: number };
|
|
@@ -186,6 +214,7 @@ export interface ThreadReconciliationApplyPorts {
|
|
|
186
214
|
) => boolean;
|
|
187
215
|
persist?: () => Promise<void>;
|
|
188
216
|
removePendingProvisionById?: (id: string) => boolean;
|
|
217
|
+
removeCleanupIntentById?: (id: string) => boolean;
|
|
189
218
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
190
219
|
recordRuntimeEvent?: (
|
|
191
220
|
category: string,
|
|
@@ -201,6 +230,7 @@ export interface ThreadReconciliationInput {
|
|
|
201
230
|
reservations?: readonly ThreadReconciliationReservation[];
|
|
202
231
|
observations?: readonly ThreadReconciliationObservation[];
|
|
203
232
|
pendingProvisions?: readonly TelegramThreadPendingProvision[];
|
|
233
|
+
pendingCleanups?: readonly TelegramThreadCleanupIntent[];
|
|
204
234
|
unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
|
|
205
235
|
reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
|
|
206
236
|
proactiveReservationCleanup?: boolean;
|
|
@@ -291,6 +321,8 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
|
|
|
291
321
|
action.kind === "close-delete-replaced-follower-topic" ||
|
|
292
322
|
action.kind === "close-delete-previous-leader-topic" ||
|
|
293
323
|
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
324
|
+
action.kind === "close-delete-graceful-shutdown-topic" ||
|
|
325
|
+
action.kind === "cancel-superseded-graceful-shutdown-cleanup" ||
|
|
294
326
|
action.kind === "close-delete-expired-pending-provision-topic"
|
|
295
327
|
);
|
|
296
328
|
}
|
|
@@ -505,6 +537,26 @@ export async function applyThreadReconciliationPlan(
|
|
|
505
537
|
shouldPersist;
|
|
506
538
|
continue;
|
|
507
539
|
}
|
|
540
|
+
if (action.kind === "cancel-superseded-graceful-shutdown-cleanup") {
|
|
541
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
542
|
+
incompleteActions.push(action);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const changed =
|
|
546
|
+
ports.removeCleanupIntentById?.(action.cleanupIntentId) ?? false;
|
|
547
|
+
shouldPersist = changed || shouldPersist;
|
|
548
|
+
ports.recordRuntimeEvent?.(
|
|
549
|
+
"telegram",
|
|
550
|
+
"Cancelled superseded Telegram topic cleanup",
|
|
551
|
+
{
|
|
552
|
+
phase: "thread-reconciler-cleanup-superseded",
|
|
553
|
+
instanceId: action.instanceId,
|
|
554
|
+
chatId: action.target.chatId,
|
|
555
|
+
threadId: action.target.threadId,
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
508
560
|
if (action.kind === "close-stale-replaced-topic") {
|
|
509
561
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
510
562
|
incompleteActions.push(action);
|
|
@@ -558,6 +610,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
558
610
|
action.kind === "close-delete-replaced-follower-topic" ||
|
|
559
611
|
action.kind === "close-delete-previous-leader-topic" ||
|
|
560
612
|
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
613
|
+
action.kind === "close-delete-graceful-shutdown-topic" ||
|
|
561
614
|
action.kind === "close-delete-expired-pending-provision-topic"
|
|
562
615
|
) {
|
|
563
616
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
|
|
@@ -647,7 +700,9 @@ export async function applyThreadReconciliationPlan(
|
|
|
647
700
|
? "Previous leader Telegram topic deleted"
|
|
648
701
|
: action.kind === "close-delete-disconnected-instance-topic"
|
|
649
702
|
? "Disconnected instance Telegram topic deleted"
|
|
650
|
-
:
|
|
703
|
+
: action.kind === "close-delete-graceful-shutdown-topic"
|
|
704
|
+
? "Graceful shutdown Telegram topic deleted"
|
|
705
|
+
: "Expired pending provision Telegram topic deleted",
|
|
651
706
|
{
|
|
652
707
|
phase:
|
|
653
708
|
action.kind === "close-delete-unbound-topic"
|
|
@@ -661,7 +716,10 @@ export async function applyThreadReconciliationPlan(
|
|
|
661
716
|
: action.kind ===
|
|
662
717
|
"close-delete-disconnected-instance-topic"
|
|
663
718
|
? "thread-reconciler-disconnected-instance-topic-delete"
|
|
664
|
-
:
|
|
719
|
+
: action.kind ===
|
|
720
|
+
"close-delete-graceful-shutdown-topic"
|
|
721
|
+
? "thread-reconciler-graceful-shutdown-topic-delete"
|
|
722
|
+
: "thread-reconciler-expired-pending-provision-topic-delete",
|
|
665
723
|
chatId: action.target.chatId,
|
|
666
724
|
threadId: action.target.threadId,
|
|
667
725
|
...("messageId" in action ? { messageId: action.messageId } : {}),
|
|
@@ -678,6 +736,16 @@ export async function applyThreadReconciliationPlan(
|
|
|
678
736
|
if (changed) persistFences.push(action);
|
|
679
737
|
shouldPersist = changed || shouldPersist;
|
|
680
738
|
}
|
|
739
|
+
if (
|
|
740
|
+
action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
741
|
+
deleteConfirmed
|
|
742
|
+
) {
|
|
743
|
+
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
|
|
744
|
+
const changed =
|
|
745
|
+
ports.removeCleanupIntentById?.(action.cleanupIntentId) ?? false;
|
|
746
|
+
if (changed) persistFences.push(action);
|
|
747
|
+
shouldPersist = changed || shouldPersist;
|
|
748
|
+
}
|
|
681
749
|
}
|
|
682
750
|
}
|
|
683
751
|
if (shouldPersist) {
|
|
@@ -736,6 +804,36 @@ export function planThreadReconciliation(
|
|
|
736
804
|
);
|
|
737
805
|
|
|
738
806
|
const actions: ThreadReconciliationAction[] = [];
|
|
807
|
+
for (const cleanup of input.pendingCleanups ?? []) {
|
|
808
|
+
const superseded = input.records.some(
|
|
809
|
+
(record) =>
|
|
810
|
+
isCurrentRecord(record) &&
|
|
811
|
+
targetKey(record.target) === targetKey(cleanup.target) &&
|
|
812
|
+
record.instanceId !== cleanup.instanceId,
|
|
813
|
+
);
|
|
814
|
+
const common = {
|
|
815
|
+
target: cleanup.target,
|
|
816
|
+
cleanupIntentId: cleanup.id,
|
|
817
|
+
instanceId: cleanup.instanceId,
|
|
818
|
+
runtimeGeneration: cleanup.runtimeGeneration,
|
|
819
|
+
...(input.currentLeaderEpoch !== undefined
|
|
820
|
+
? { leaderEpoch: input.currentLeaderEpoch }
|
|
821
|
+
: {}),
|
|
822
|
+
};
|
|
823
|
+
if (superseded) {
|
|
824
|
+
actions.push({
|
|
825
|
+
...common,
|
|
826
|
+
kind: "cancel-superseded-graceful-shutdown-cleanup",
|
|
827
|
+
reason: "replacement-registration",
|
|
828
|
+
});
|
|
829
|
+
} else {
|
|
830
|
+
actions.push({
|
|
831
|
+
...common,
|
|
832
|
+
kind: "close-delete-graceful-shutdown-topic",
|
|
833
|
+
reason: "graceful-shutdown",
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
739
837
|
for (const provision of input.pendingProvisions ?? []) {
|
|
740
838
|
if (!provision.target) continue;
|
|
741
839
|
if (!isPendingProvisionExpired(provision, input.nowMs)) continue;
|