@llblab/pi-kit 0.4.0 → 0.5.1

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  5. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  6. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  7. package/node_modules/@llblab/pi-telegram/docs/architecture.md +9 -5
  8. package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +39 -11
  9. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
  10. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  11. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -4
  12. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  13. package/node_modules/@llblab/pi-telegram/index.ts +12 -1
  14. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +12 -3
  15. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +5 -3
  16. package/node_modules/@llblab/pi-telegram/lib/locks.ts +99 -16
  17. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +72 -15
  18. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +81 -9
  19. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +2 -0
  20. package/node_modules/@llblab/pi-telegram/lib/polling.ts +142 -30
  21. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +8 -6
  22. package/node_modules/@llblab/pi-telegram/lib/queue.ts +4 -0
  23. package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
  24. package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -0
  25. package/node_modules/@llblab/pi-telegram/lib/updates.ts +6 -0
  26. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  27. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +4 -2
  28. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +4 -2
  29. package/node_modules/@llblab/pi-telegram/skills/generative-apps/SKILL.md +4 -3
  30. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +19 -8
  31. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +2 -0
  32. package/package.json +2 -2
@@ -871,6 +871,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
871
871
  options.runtimeGeneration ?? allocateTelegramLockRuntimeGeneration();
872
872
  let ownedLockKey: string | undefined;
873
873
  let ownedLock: TelegramLockEntry | undefined;
874
+ let deliveryRevoked = false;
874
875
  const stateOptions = () => ({
875
876
  nowMs: getNowMs(),
876
877
  staleHeartbeatMs: options.staleHeartbeatMs,
@@ -917,7 +918,8 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
917
918
  );
918
919
  if (
919
920
  state.kind === "active-here" &&
920
- hasSameLockOwner(current, expectedOwned)
921
+ hasSameLockOwner(current, expectedOwned) &&
922
+ !deliveryRevoked
921
923
  ) {
922
924
  return {
923
925
  result: {
@@ -958,6 +960,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
958
960
  if (
959
961
  !acquireOptions.election &&
960
962
  (state.kind === "active-here" || state.kind === "active-elsewhere") &&
963
+ !(deliveryRevoked && hasSameLockOwner(current, expectedOwned)) &&
961
964
  (!acquireOptions.force ||
962
965
  !expectedReplacementMatches ||
963
966
  !canReplaceCurrent)
@@ -978,6 +981,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
978
981
  locks[effectiveKey] = lock;
979
982
  ownedLockKey = effectiveKey;
980
983
  ownedLock = lock;
984
+ deliveryRevoked = false;
981
985
  return {
982
986
  result: {
983
987
  ok: true,
@@ -987,8 +991,10 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
987
991
  changed: true,
988
992
  };
989
993
  }),
990
- release: () =>
991
- withLockTransaction(locksPath, (locks) => {
994
+ release: () => {
995
+ // Withdraw local send authority even if the durable release fails.
996
+ deliveryRevoked = true;
997
+ return withLockTransaction(locksPath, (locks) => {
992
998
  const effectiveKey = resolveEffectiveKey();
993
999
  const state = getLockState(
994
1000
  parseTelegramLockEntry(locks[effectiveKey]),
@@ -1008,17 +1014,20 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1008
1014
  ownedLock = undefined;
1009
1015
  }
1010
1016
  return { result: state, changed };
1011
- }),
1017
+ });
1018
+ },
1012
1019
  getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
1013
1020
  getStatusLabel: () =>
1014
1021
  formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
1015
1022
  getOwnedLeaderEpoch: () => {
1023
+ if (deliveryRevoked) return undefined;
1016
1024
  const effectiveKey = resolveEffectiveKey();
1017
1025
  const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
1018
1026
  const exactOwner = adoptCompatibleOwnedLock(effectiveKey, lock);
1019
1027
  return hasSameLockOwner(lock, exactOwner) ? lock?.leaderEpoch : undefined;
1020
1028
  },
1021
1029
  owns: (ctx) => {
1030
+ if (deliveryRevoked) return false;
1022
1031
  const effectiveKey = resolveEffectiveKey();
1023
1032
  const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
1024
1033
  return hasSameLockOwner(
@@ -1027,7 +1036,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1027
1036
  );
1028
1037
  },
1029
1038
  commitIfOwned: (commit) =>
1030
- withLockTransaction(locksPath, (locks) => {
1039
+ !deliveryRevoked && withLockTransaction(locksPath, (locks) => {
1031
1040
  const effectiveKey = resolveEffectiveKey();
1032
1041
  const lock = parseTelegramLockEntry(locks[effectiveKey]);
1033
1042
  const exactOwner =
@@ -1043,7 +1052,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1043
1052
  return { result: true, changed: false };
1044
1053
  }),
1045
1054
  refresh: (ctx) =>
1046
- withLockTransaction(locksPath, (locks) => {
1055
+ !deliveryRevoked && withLockTransaction(locksPath, (locks) => {
1047
1056
  const effectiveKey = resolveEffectiveKey();
1048
1057
  const lock = parseTelegramLockEntry(locks[effectiveKey]);
1049
1058
  const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
@@ -1116,6 +1125,7 @@ export interface TelegramLockedPollingRuntime<
1116
1125
  ) => Promise<TelegramLockedPollingStartResult>;
1117
1126
  stop: () => Promise<string>;
1118
1127
  suspend: () => Promise<void>;
1128
+ onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
1119
1129
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
1120
1130
  registerFollowerWithOwner?: (
1121
1131
  ctx: TContext,
@@ -1130,6 +1140,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1130
1140
  lock: TelegramLockRuntime<TContext>;
1131
1141
  hasBotToken: () => boolean;
1132
1142
  canStartPolling?: (ctx: TContext) => boolean;
1143
+ isContextCurrent?: (ctx: TContext) => boolean;
1133
1144
  formatStartBlockedMessage?: (ctx: TContext) => string;
1134
1145
  startPolling: (
1135
1146
  ctx: TContext,
@@ -1142,6 +1153,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1142
1153
  ) => boolean | undefined | Promise<boolean | undefined>;
1143
1154
  stopFollowerRegistration?: () => void;
1144
1155
  onTransportAvailabilityChanged?: () => void;
1156
+ transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
1145
1157
  updateStatus: (ctx: TContext) => void;
1146
1158
  recordRuntimeEvent?: (
1147
1159
  category: string,
@@ -1164,9 +1176,10 @@ export function createTelegramLockedPollingRuntime<
1164
1176
  let ownershipCheckInterval: ReturnType<typeof setInterval> | undefined;
1165
1177
  let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
1166
1178
  let ownershipStop: Promise<void> | undefined;
1179
+ let activeContext: TContext | undefined;
1167
1180
  let takeoverCandidate: TelegramLockEntry | undefined;
1168
1181
  let sessionAutoStartRun: Promise<void> | undefined;
1169
- let sessionAutoStartGeneration = 0;
1182
+ let pollingGeneration = 0;
1170
1183
  const ownershipCheckMs =
1171
1184
  deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
1172
1185
  const ownershipRefreshMs =
@@ -1178,7 +1191,9 @@ export function createTelegramLockedPollingRuntime<
1178
1191
  ownershipRefreshInterval = undefined;
1179
1192
  };
1180
1193
  const suspendPolling = async () => {
1181
- sessionAutoStartGeneration += 1;
1194
+ pollingGeneration += 1;
1195
+ activeContext = undefined;
1196
+ deps.transportMonitor?.stop();
1182
1197
  deps.stopFollowerRegistration?.();
1183
1198
  stopOwnershipWatcher();
1184
1199
  if (sessionAutoStartRun) {
@@ -1192,6 +1207,8 @@ export function createTelegramLockedPollingRuntime<
1192
1207
  };
1193
1208
  const stopAfterOwnershipLoss = () => {
1194
1209
  if (ownershipStop) return;
1210
+ activeContext = undefined;
1211
+ deps.transportMonitor?.stop();
1195
1212
  stopOwnershipWatcher();
1196
1213
  deps.onTransportAvailabilityChanged?.();
1197
1214
  ownershipStop = deps
@@ -1228,7 +1245,10 @@ export function createTelegramLockedPollingRuntime<
1228
1245
  const runOwnedPollingStart = async (
1229
1246
  ctx: TContext,
1230
1247
  options: TelegramLockedPollingStartOptions,
1248
+ isCurrent: () => boolean,
1231
1249
  ): Promise<boolean> => {
1250
+ if (!isCurrent()) return false;
1251
+ activeContext = ctx;
1232
1252
  startOwnershipWatcher(ctx);
1233
1253
  try {
1234
1254
  if (!deps.lock.refresh(snapshotLockContext(ctx))) {
@@ -1236,8 +1256,10 @@ export function createTelegramLockedPollingRuntime<
1236
1256
  return false;
1237
1257
  }
1238
1258
  await options.onAcquired?.();
1259
+ if (!isCurrent()) return false;
1239
1260
  await deps.startPolling(ctx, options);
1240
1261
  } catch (error) {
1262
+ if (!isCurrent()) return false;
1241
1263
  stopOwnershipWatcher();
1242
1264
  try {
1243
1265
  await deps.stopPolling();
@@ -1246,14 +1268,22 @@ export function createTelegramLockedPollingRuntime<
1246
1268
  phase: "startup-rollback",
1247
1269
  });
1248
1270
  }
1271
+ if (!isCurrent()) return false;
1249
1272
  deps.lock.release();
1250
1273
  deps.onTransportAvailabilityChanged?.();
1251
1274
  throw error;
1252
1275
  }
1253
- if (deps.lock.owns(ctx)) return true;
1276
+ if (!isCurrent()) return false;
1277
+ if (deps.lock.owns(ctx)) {
1278
+ if (activeContext !== ctx) return false;
1279
+ deps.transportMonitor?.start(ctx);
1280
+ return true;
1281
+ }
1254
1282
  stopOwnershipWatcher();
1255
1283
  if (ownershipStop) await ownershipStop;
1284
+ if (!isCurrent()) return false;
1256
1285
  await deps.stopPolling();
1286
+ if (!isCurrent()) return false;
1257
1287
  deps.onTransportAvailabilityChanged?.();
1258
1288
  return false;
1259
1289
  };
@@ -1270,6 +1300,17 @@ export function createTelegramLockedPollingRuntime<
1270
1300
  if (!canStartPolling(ctx)) {
1271
1301
  return { ok: false, message: formatStartBlockedMessage(ctx) };
1272
1302
  }
1303
+ const cancelled = {
1304
+ ok: false as const,
1305
+ canTakeover: false as const,
1306
+ message: "Telegram polling startup was cancelled or superseded.",
1307
+ };
1308
+ if (deps.isContextCurrent?.(ctx) === false) return cancelled;
1309
+ const generation = ++pollingGeneration;
1310
+ const isCurrent = () => generation === pollingGeneration &&
1311
+ (deps.isContextCurrent?.(ctx) ?? true);
1312
+ if (ownershipStop) await ownershipStop;
1313
+ if (!isCurrent()) return cancelled;
1273
1314
  let acquired = deps.lock.acquire(ctx, {
1274
1315
  force: options.force,
1275
1316
  expectedOwner:
@@ -1306,6 +1347,7 @@ export function createTelegramLockedPollingRuntime<
1306
1347
  ctx,
1307
1348
  acquired.lock,
1308
1349
  );
1350
+ if (!isCurrent()) return cancelled;
1309
1351
  if (registered) {
1310
1352
  deps.updateStatus(ctx);
1311
1353
  return { ok: true, canTakeover: false };
@@ -1337,13 +1379,15 @@ export function createTelegramLockedPollingRuntime<
1337
1379
  };
1338
1380
  }
1339
1381
  takeoverCandidate = undefined;
1340
- if (!(await runOwnedPollingStart(ctx, options))) {
1382
+ if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
1383
+ if (!isCurrent()) return cancelled;
1341
1384
  return {
1342
1385
  ok: false,
1343
1386
  canTakeover: false,
1344
1387
  message: "Telegram leadership changed during polling startup.",
1345
1388
  };
1346
1389
  }
1390
+ if (!isCurrent()) return cancelled;
1347
1391
  deps.onTransportAvailabilityChanged?.();
1348
1392
  deps.updateStatus(ctx);
1349
1393
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
@@ -1362,6 +1406,42 @@ export function createTelegramLockedPollingRuntime<
1362
1406
  return "Telegram bridge disconnected.";
1363
1407
  },
1364
1408
  suspend: suspendPolling,
1409
+ onPersistentConflict: async (ctx, count) => {
1410
+ if (activeContext === undefined || ownershipStop) return;
1411
+ if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
1412
+ activeContext = undefined;
1413
+ pollingGeneration += 1;
1414
+ stopOwnershipWatcher();
1415
+ deps.transportMonitor?.stop();
1416
+ let ownership = "unverifiable";
1417
+ const cleanupErrors: string[] = [];
1418
+ try {
1419
+ ownership = deps.lock.owns(snapshotLockContext(ctx)) ? "owned" : "lost";
1420
+ } catch (error) {
1421
+ cleanupErrors.push(String(error));
1422
+ }
1423
+ try {
1424
+ deps.lock.release();
1425
+ } catch (error) {
1426
+ ownership = "unverifiable";
1427
+ cleanupErrors.push(String(error));
1428
+ }
1429
+ ownershipStop = Promise.resolve()
1430
+ .then(() => deps.stopPolling())
1431
+ .catch((error) => { cleanupErrors.push(String(error)); })
1432
+ .finally(() => {
1433
+ ownershipStop = undefined;
1434
+ deps.recordRuntimeEvent?.("polling", ownership === "lost"
1435
+ ? "Telegram transport stopped: local ownership lost; check for another Pi instance."
1436
+ : "Telegram transport stopped: competing getUpdates client or ownership mismatch.", {
1437
+ phase: "persistent-conflict", count, ownership,
1438
+ ...(cleanupErrors.length ? { cleanupErrors } : {}),
1439
+ });
1440
+ deps.updateStatus(ctx);
1441
+ });
1442
+ deps.onTransportAvailabilityChanged?.();
1443
+ await ownershipStop;
1444
+ },
1365
1445
  onSessionStart: async (_event, ctx) => {
1366
1446
  if (!deps.hasBotToken()) return;
1367
1447
  if (!canStartPolling(ctx)) return;
@@ -1379,15 +1459,18 @@ export function createTelegramLockedPollingRuntime<
1379
1459
  ) {
1380
1460
  return;
1381
1461
  }
1382
- sessionAutoStartGeneration += 1;
1383
- const generation = sessionAutoStartGeneration;
1462
+ if (deps.isContextCurrent?.(ctx) === false) return;
1463
+ const generation = ++pollingGeneration;
1464
+ const isCurrent = () => generation === pollingGeneration &&
1465
+ (deps.isContextCurrent?.(ctx) ?? true);
1384
1466
  const startedAtMs = Date.now();
1385
1467
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
1386
1468
  phase: "auto-start-scheduled",
1387
1469
  });
1388
1470
  const run = (async () => {
1389
1471
  await new Promise((resolve) => setTimeout(resolve, 0));
1390
- if (generation !== sessionAutoStartGeneration) return;
1472
+ if (ownershipStop) await ownershipStop;
1473
+ if (!isCurrent()) return;
1391
1474
  if (canResumeStaleSameCwd || canHandoffSameProcess) {
1392
1475
  const acquired = deps.lock.acquire(
1393
1476
  ctx,
@@ -1397,9 +1480,9 @@ export function createTelegramLockedPollingRuntime<
1397
1480
  );
1398
1481
  if (!acquired.ok) return;
1399
1482
  }
1400
- if (generation !== sessionAutoStartGeneration) return;
1401
- if (!(await runOwnedPollingStart(ctx, {}))) return;
1402
- if (generation !== sessionAutoStartGeneration) return;
1483
+ if (!isCurrent()) return;
1484
+ if (!(await runOwnedPollingStart(ctx, {}, isCurrent))) return;
1485
+ if (!isCurrent()) return;
1403
1486
  deps.onTransportAvailabilityChanged?.();
1404
1487
  deps.updateStatus(ctx);
1405
1488
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
@@ -12,6 +12,8 @@ import type {
12
12
  } from "./keyboard.ts";
13
13
  import {
14
14
  parseTelegramActionPayloadRows,
15
+ parseTelegramButtonPayloadRows,
16
+ replaceTelegramButtonFences,
15
17
  replaceTopLevelHtmlComments,
16
18
  } from "./outbound-markup.ts";
17
19
  import {
@@ -34,6 +36,7 @@ export interface TelegramOutboundButtonAction {
34
36
  prompt: string;
35
37
  binding?: TelegramOutboundButtonBinding;
36
38
  selectedStyle?: TelegramInlineKeyboardButtonStyle;
39
+ disabled?: true;
37
40
  }
38
41
 
39
42
  export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
@@ -115,6 +118,12 @@ function parseTelegramButtonAction(
115
118
  const explicitLabel = getTelegramButtonString(payload, "label");
116
119
  const explicitPrompt = getTelegramButtonString(payload, "prompt");
117
120
  const label = explicitLabel ?? value ?? explicitPrompt;
121
+ if (payload.disabled !== undefined && typeof payload.disabled !== "boolean") {
122
+ return undefined;
123
+ }
124
+ if (payload.disabled === true) {
125
+ return { text: label ?? "", prompt: "", disabled: true };
126
+ }
118
127
  const prompt = explicitPrompt ?? value ?? explicitLabel;
119
128
  if (!label || !prompt) return undefined;
120
129
  const selectedStyle = payload.selected_style;
@@ -159,6 +168,7 @@ export function createTelegramButtonActionStore(
159
168
  return {
160
169
  text: action.text,
161
170
  prompt: action.prompt,
171
+ ...(action.disabled ? { disabled: true as const } : {}),
162
172
  ...(action.binding ? { binding: action.binding } : {}),
163
173
  ...(action.selectedStyle
164
174
  ? { selectedStyle: action.selectedStyle }
@@ -171,33 +181,75 @@ export function createTelegramButtonActionStore(
171
181
  const DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN =
172
182
  "☑️ **Choose an option:**";
173
183
 
184
+ function escapeTelegramRichButtonText(text: string): string {
185
+ return text.replace(/[&<>"'`\\*_\[\]{}$~\r\n]/g, (character) =>
186
+ `&#${character.charCodeAt(0)};`,
187
+ );
188
+ }
189
+
190
+ function renderTelegramRichButtonRow(
191
+ row: TelegramOutboundButtonMarkup["inline_keyboard"][number],
192
+ ): string {
193
+ return `<tg-button-row>${row.map((button) => {
194
+ const attributes = button.disabled
195
+ ? 'type="disabled"'
196
+ : `type="callback_data" data="${escapeTelegramRichButtonText(button.callback_data)}"`;
197
+ return `<tg-button ${attributes}>${escapeTelegramRichButtonText(button.text)}</tg-button>`;
198
+ }).join("")}</tg-button-row>`;
199
+ }
200
+
174
201
  export function planTelegramButtonReply(
175
202
  markdown: string,
176
203
  deps: {
177
204
  registerAction: (action: TelegramOutboundButtonAction) => string;
178
205
  binding?: TelegramOutboundButtonBinding;
206
+ rendering?: "rich" | "html";
179
207
  },
180
208
  ): TelegramButtonReplyPlan {
181
209
  const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
182
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
210
+ const buildRows = (
211
+ payloadRows: Record<string, unknown>[][],
212
+ rich: boolean,
213
+ ): TelegramOutboundButtonMarkup["inline_keyboard"] | undefined => {
214
+ const actions = payloadRows.map((row) => row.map(parseTelegramButtonAction));
215
+ if (actions.some((row) => row.some((action) => !action))) return undefined;
216
+ if (rich && actions.some((row) => {
217
+ const projected = row.map((action) => action!.disabled
218
+ ? { text: action!.text || "\u00a0", disabled: {} }
219
+ : { text: action!.text, callback_data: "x".repeat(64) });
220
+ return row.length > 8 || renderTelegramRichButtonRow(projected).length > 32768;
221
+ })) return undefined;
222
+ return actions.map((row) => row.map((action) => action!.disabled
223
+ ? { text: action!.text || "\u00a0", disabled: {} }
224
+ : {
225
+ text: action!.text,
226
+ callback_data: deps.registerAction({
227
+ ...action!,
228
+ ...(deps.binding ? { binding: deps.binding } : {}),
229
+ }),
230
+ }));
231
+ };
232
+ const withRichButtons = replaceTelegramButtonFences(markdown, (payload, closed) => {
233
+ if (!closed) return "";
234
+ const payloadRows = parseTelegramButtonPayloadRows(payload);
235
+ if (!payloadRows) return "";
236
+ const rich = deps.rendering !== "html";
237
+ const rows = buildRows(payloadRows, rich);
238
+ if (!rows) return "";
239
+ if (!rich) {
240
+ keyboard.push(...rows);
241
+ return "";
242
+ }
243
+ return `\n${rows.map(renderTelegramRichButtonRow).join("\n\n")}\n`;
244
+ });
245
+ const stripped = replaceTopLevelHtmlComments(withRichButtons, (comment) => {
183
246
  const command = "telegram_button";
184
247
  const normalizedContent = comment.content.replace(/^\s+/, "").replace(/^!/, "");
185
248
  if (!normalizedContent.startsWith(command)) return comment.raw;
186
249
  const payloadRows = parseTelegramActionPayloadRows(comment, command);
187
250
  if (!payloadRows) return "";
188
- const actionRows = payloadRows.map((payloadRow) =>
189
- payloadRow.map(parseTelegramButtonAction),
190
- );
191
- if (actionRows.some((row) => row.some((action) => !action))) return "";
192
- for (const actionRow of actionRows) {
193
- keyboard.push(actionRow.map((action) => ({
194
- text: action!.text,
195
- callback_data: deps.registerAction({
196
- ...action!,
197
- ...(deps.binding ? { binding: deps.binding } : {}),
198
- }),
199
- })));
200
- }
251
+ const rows = buildRows(payloadRows, false);
252
+ if (rows) keyboard.push(...rows);
201
253
  return "";
202
254
  });
203
255
  const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
@@ -254,7 +306,7 @@ export function markTelegramButtonSelected(
254
306
  let matched = false;
255
307
  const inlineKeyboard = replyMarkup.inline_keyboard.map((row) =>
256
308
  row.map((button) => {
257
- if (button.callback_data !== callbackData) return { ...button };
309
+ if (button.disabled || button.callback_data !== callbackData) return { ...button };
258
310
  matched = true;
259
311
  return { ...button, style: selectedStyle };
260
312
  }),
@@ -277,6 +329,11 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
277
329
  return false;
278
330
  }
279
331
 
332
+ if (action.disabled) {
333
+ await deps.answerCallbackQuery(query.id, "Button action unavailable.");
334
+ return true;
335
+ }
336
+
280
337
  const chatId = query.message?.chat?.id;
281
338
  const messageId = query.message?.message_id;
282
339
  if (typeof chatId !== "number" || typeof messageId !== "number") {
@@ -90,6 +90,50 @@ export function collectTopLevelHtmlComments(markdown: string): {
90
90
  return { comments };
91
91
  }
92
92
 
93
+ export function replaceTelegramButtonFences(
94
+ markdown: string,
95
+ replace: (payload: string, closed: boolean) => string,
96
+ ): string {
97
+ let result = "";
98
+ let copied = 0;
99
+ let offset = 0;
100
+ let fence: TelegramTopLevelFenceState | undefined;
101
+ let actionStart: number | undefined;
102
+ let contentStart = 0;
103
+ while (offset < markdown.length) {
104
+ const end = getMarkdownLineEnd(markdown, offset);
105
+ const line = getMarkdownLineText(markdown, offset, end);
106
+ if (fence) {
107
+ if (isTopLevelClosingFence(line, fence)) {
108
+ if (actionStart !== undefined) {
109
+ result += markdown.slice(copied, actionStart);
110
+ result += replace(markdown.slice(contentStart, offset), true) + "\n";
111
+ copied = end;
112
+ actionStart = undefined;
113
+ }
114
+ fence = undefined;
115
+ }
116
+ } else if (line.includes("<!--")) {
117
+ const close = markdown.indexOf("-->", offset + line.indexOf("<!--") + 4);
118
+ if (close < 0) break;
119
+ offset = getMarkdownLineEnd(markdown, close + 3);
120
+ continue;
121
+ } else {
122
+ fence = getTopLevelOpeningFence(line);
123
+ if (fence && /^```telegram_button[ \t]*$/.test(line)) {
124
+ actionStart = offset;
125
+ contentStart = end;
126
+ }
127
+ }
128
+ offset = end;
129
+ }
130
+ if (actionStart !== undefined) {
131
+ result += markdown.slice(copied, actionStart);
132
+ return result + replace(markdown.slice(contentStart), false);
133
+ }
134
+ return result + markdown.slice(copied);
135
+ }
136
+
93
137
  export function replaceTopLevelHtmlComments(
94
138
  markdown: string,
95
139
  replacer: (comment: TelegramTopLevelHtmlComment) => string,
@@ -275,17 +319,26 @@ type TelegramCompactActionPayloadParser = (
275
319
  function parseTelegramButtonCompactActionPayload(
276
320
  atoms: readonly string[],
277
321
  ): Record<string, unknown> | undefined {
278
- const [label, prompt, selectedStyle] = atoms;
322
+ const [label, prompt, selectedStyle, disabled] = atoms;
279
323
  if (atoms.length === 1) return label ? { value: label } : undefined;
280
- if (!prompt) return undefined;
324
+ const isDisabled = disabled === "1" || disabled === "true";
325
+ if (!prompt && !(atoms.length === 4 && isDisabled)) return undefined;
281
326
  const action = label ? { label, prompt } : { prompt };
282
327
  if (atoms.length === 2) return action;
283
328
  if (
284
329
  selectedStyle !== "primary" &&
285
330
  selectedStyle !== "success" &&
286
- selectedStyle !== "danger"
331
+ selectedStyle !== "danger" &&
332
+ !(atoms.length === 4 && selectedStyle === "")
287
333
  ) return undefined;
288
- return { ...action, selected_style: selectedStyle };
334
+ if (atoms.length === 4 && !isDisabled && disabled !== "0" && disabled !== "false") {
335
+ return undefined;
336
+ }
337
+ return {
338
+ ...action,
339
+ ...(selectedStyle ? { selected_style: selectedStyle } : {}),
340
+ ...(atoms.length === 4 ? { disabled: isDisabled } : {}),
341
+ };
289
342
  }
290
343
 
291
344
  function parseTelegramVoiceCompactActionPayload(
@@ -303,7 +356,7 @@ function parseTelegramVoiceCompactActionPayload(
303
356
  function parseTelegramAdaptiveActionPayloadRows(
304
357
  source: string,
305
358
  parseCompactPayload: TelegramCompactActionPayloadParser,
306
- options: { allowTrailing?: boolean } = {},
359
+ options: { allowTrailing?: boolean; maxCompactAtoms?: number } = {},
307
360
  ): { rows: Record<string, unknown>[][]; end: number } | undefined {
308
361
  let offset = 0;
309
362
  const isStructuralWhitespace = (character: string | undefined): boolean =>
@@ -343,7 +396,7 @@ function parseTelegramAdaptiveActionPayloadRows(
343
396
  continue;
344
397
  }
345
398
  if (character === "|") {
346
- if (atomSources.length >= 3) return undefined;
399
+ if (atomSources.length >= (options.maxCompactAtoms ?? 3)) return undefined;
347
400
  atomSources.push([]);
348
401
  offset += 1;
349
402
  continue;
@@ -509,6 +562,16 @@ function isPlausibleTelegramMatrixStart(
509
562
  );
510
563
  }
511
564
 
565
+ export function parseTelegramButtonPayloadRows(
566
+ source: string,
567
+ ): Record<string, unknown>[][] | undefined {
568
+ return parseTelegramAdaptiveActionPayloadRows(
569
+ source.trim(),
570
+ parseTelegramButtonCompactActionPayload,
571
+ { maxCompactAtoms: 4 },
572
+ )?.rows;
573
+ }
574
+
512
575
  export function parseTelegramActionPayloadRows(
513
576
  comment: TelegramTopLevelHtmlComment,
514
577
  command: string,
@@ -533,7 +596,7 @@ export function parseTelegramActionPayloadRows(
533
596
  const parsed = parseTelegramAdaptiveActionPayloadRows(
534
597
  content.slice(offset),
535
598
  parseTelegramButtonCompactActionPayload,
536
- { allowTrailing: true },
599
+ { allowTrailing: true, maxCompactAtoms: 4 },
537
600
  );
538
601
  if (parsed) return parsed.rows;
539
602
  const end = findTelegramStructuredPayloadEnd(content, offset);
@@ -546,8 +609,15 @@ export function parseTelegramActionPayloadRows(
546
609
  "prompt",
547
610
  "value",
548
611
  "selected_style",
612
+ "disabled",
549
613
  ]);
550
- return attributes ? [[attributes]] : undefined;
614
+ if (!attributes) return undefined;
615
+ return [[{
616
+ ...attributes,
617
+ ...(attributes.disabled === "true" || attributes.disabled === "false"
618
+ ? { disabled: attributes.disabled === "true" }
619
+ : {}),
620
+ }]];
551
621
  }
552
622
 
553
623
  export function normalizeMarkdownAfterVoiceExtraction(
@@ -589,7 +659,9 @@ function stripTelegramHtmlCommentBlocks(markdown: string): string {
589
659
  }
590
660
 
591
661
  export function stripTelegramCommentMarkupForPreview(markdown: string): string {
592
- const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(markdown);
662
+ const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(
663
+ replaceTelegramButtonFences(markdown, () => ""),
664
+ );
593
665
  const openBlockIndex =
594
666
  findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
595
667
  const previewMarkdown =
@@ -835,6 +835,7 @@ export {
835
835
 
836
836
  export function createTelegramOutboundReplyPlanner(
837
837
  store: Pick<TelegramButtonActionStore, "register">,
838
+ getRenderingMode: () => "rich" | "html" = () => "rich",
838
839
  ): (
839
840
  markdown: string,
840
841
  options?: { binding?: TelegramOutboundButtonBinding },
@@ -842,6 +843,7 @@ export function createTelegramOutboundReplyPlanner(
842
843
  return (markdown, options) => {
843
844
  const buttonReply = planTelegramButtonReply(markdown, {
844
845
  registerAction: store.register,
846
+ rendering: getRenderingMode(),
845
847
  ...(options?.binding ? { binding: options.binding } : {}),
846
848
  });
847
849