@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.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-telegram/README.md +4 -2
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +9 -5
- package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +39 -11
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -4
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
- package/node_modules/@llblab/pi-telegram/index.ts +12 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +12 -3
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +5 -3
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +99 -16
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +72 -15
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +81 -9
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +142 -30
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +8 -6
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +4 -0
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -0
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +6 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +4 -2
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +4 -2
- package/node_modules/@llblab/pi-telegram/skills/generative-apps/SKILL.md +4 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +19 -8
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +2 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1383
|
-
const generation =
|
|
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 (
|
|
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 (
|
|
1401
|
-
if (!(await runOwnedPollingStart(ctx, {}))) return;
|
|
1402
|
-
if (
|
|
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
|
|
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
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|