@llblab/pi-telegram 0.22.0 → 0.23.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/AGENTS.md +12 -7
- package/BACKLOG.md +1 -23
- package/CHANGELOG.md +53 -1
- package/README.md +3 -1
- package/docs/README.md +1 -1
- package/docs/activity.md +8 -0
- package/docs/architecture.md +17 -15
- package/docs/locks.md +22 -12
- package/docs/multi-instance-bus.md +17 -16
- package/docs/outbound.md +16 -0
- package/docs/public-api.md +9 -4
- package/index.ts +74 -59
- package/lib/activity.ts +100 -3
- package/lib/bindings.ts +81 -6
- package/lib/bus-follower.ts +205 -17
- package/lib/bus-leader.ts +339 -133
- package/lib/bus.ts +82 -19
- package/lib/commands.ts +28 -4
- package/lib/config.ts +40 -28
- package/lib/locks.ts +318 -34
- package/lib/logs.ts +7 -3
- package/lib/media.ts +102 -1
- package/lib/menu-settings.ts +4 -1
- package/lib/outbound-attachments.ts +150 -1
- package/lib/outbound.ts +106 -1
- package/lib/polling.ts +5 -0
- package/lib/queue.ts +41 -48
- package/lib/routing.ts +88 -1
- package/lib/sync.ts +40 -3
- package/lib/telegram-api.ts +77 -13
- package/lib/text-groups.ts +183 -16
- package/lib/thread-reconciler.ts +66 -22
- package/lib/threads.ts +131 -23
- package/lib/turns.ts +102 -13
- package/lib/updates.ts +2 -0
- package/package.json +1 -1
package/lib/bus.ts
CHANGED
|
@@ -158,28 +158,22 @@ export function getTelegramFollowerTargetOwnership(input: {
|
|
|
158
158
|
target: TelegramTarget;
|
|
159
159
|
}[];
|
|
160
160
|
currentInstanceId?: string;
|
|
161
|
-
}): { instanceId: string } | undefined {
|
|
161
|
+
}): { instanceId: string; ownerGeneration?: string } | undefined {
|
|
162
162
|
const liveFollower = input.followers.find((follower) => {
|
|
163
163
|
return (
|
|
164
164
|
follower.target?.chatId === input.target.chatId &&
|
|
165
165
|
follower.target.threadId === input.target.threadId
|
|
166
166
|
);
|
|
167
167
|
});
|
|
168
|
-
if (liveFollower)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
candidate.instanceId !== input.currentInstanceId &&
|
|
178
|
-
candidate.target.chatId === input.target.chatId &&
|
|
179
|
-
candidate.target.threadId === input.target.threadId
|
|
180
|
-
);
|
|
181
|
-
});
|
|
182
|
-
return record?.instanceId ? { instanceId: record.instanceId } : undefined;
|
|
168
|
+
if (liveFollower) {
|
|
169
|
+
return {
|
|
170
|
+
instanceId: liveFollower.instanceId,
|
|
171
|
+
ownerGeneration: liveFollower.registrationGeneration,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// Persisted records are restart hints, not live routing authority. Only an
|
|
175
|
+
// authenticated current follower registration may receive forwarded work.
|
|
176
|
+
return undefined;
|
|
183
177
|
}
|
|
184
178
|
|
|
185
179
|
const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD = "__piTelegramAggregateDelivery";
|
|
@@ -237,6 +231,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
237
231
|
"sendDocument",
|
|
238
232
|
"sendMediaGroup",
|
|
239
233
|
"sendPhoto",
|
|
234
|
+
"sendRichMessage",
|
|
240
235
|
"sendVoice",
|
|
241
236
|
]);
|
|
242
237
|
const target = input.follower.target;
|
|
@@ -363,6 +358,13 @@ export type TelegramBusEnvelope = (
|
|
|
363
358
|
registrationGeneration?: string;
|
|
364
359
|
sentAtMs: number;
|
|
365
360
|
}
|
|
361
|
+
| {
|
|
362
|
+
kind: "follower.disconnect";
|
|
363
|
+
requestId: string;
|
|
364
|
+
instanceId: string;
|
|
365
|
+
registrationGeneration?: string;
|
|
366
|
+
sentAtMs: number;
|
|
367
|
+
}
|
|
366
368
|
| {
|
|
367
369
|
kind: "leader.forwardCallback";
|
|
368
370
|
requestId: string;
|
|
@@ -385,6 +387,7 @@ export type TelegramBusEnvelope = (
|
|
|
385
387
|
recipientInstanceId: string;
|
|
386
388
|
recipientRegistrationGeneration?: string;
|
|
387
389
|
message: unknown;
|
|
390
|
+
forwardCommentBatchPosition?: "comment" | "forward";
|
|
388
391
|
sentAtMs: number;
|
|
389
392
|
}
|
|
390
393
|
| {
|
|
@@ -473,6 +476,9 @@ export function parseTelegramBusEnvelope(
|
|
|
473
476
|
case "follower.heartbeat":
|
|
474
477
|
envelope = parseHeartbeatEnvelope(value, requestId);
|
|
475
478
|
break;
|
|
479
|
+
case "follower.disconnect":
|
|
480
|
+
envelope = parseDisconnectEnvelope(value, requestId);
|
|
481
|
+
break;
|
|
476
482
|
case "leader.forwardCallback":
|
|
477
483
|
envelope = parseForwardCallbackEnvelope(value, requestId);
|
|
478
484
|
break;
|
|
@@ -548,12 +554,20 @@ export interface TelegramBusLocalClientOptions {
|
|
|
548
554
|
recordTransportEvent?: TelegramBusTransportEventRecorder;
|
|
549
555
|
}
|
|
550
556
|
|
|
551
|
-
export interface TelegramBusForeignOwnedForwarderDeps {
|
|
557
|
+
export interface TelegramBusForeignOwnedForwarderDeps<TMessage = unknown> {
|
|
552
558
|
socketPath: TelegramBusSocketPathSource;
|
|
553
559
|
createRequestId: () => string;
|
|
554
560
|
getNowMs?: () => number;
|
|
555
561
|
timeoutMs?: number;
|
|
556
562
|
getAuthSecret?: () => string | undefined;
|
|
563
|
+
getForwardCommentBatchPosition?: (
|
|
564
|
+
message: TMessage,
|
|
565
|
+
) => "comment" | "forward" | undefined;
|
|
566
|
+
recordRuntimeEvent?: (
|
|
567
|
+
category: string,
|
|
568
|
+
error: unknown,
|
|
569
|
+
details?: Record<string, unknown>,
|
|
570
|
+
) => void;
|
|
557
571
|
}
|
|
558
572
|
|
|
559
573
|
export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
@@ -562,7 +576,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
562
576
|
TCallbackQuery,
|
|
563
577
|
TMessage = unknown,
|
|
564
578
|
>(
|
|
565
|
-
deps: TelegramBusForeignOwnedForwarderDeps
|
|
579
|
+
deps: TelegramBusForeignOwnedForwarderDeps<TMessage>,
|
|
566
580
|
): {
|
|
567
581
|
forwardCallback: (input: {
|
|
568
582
|
query: TCallbackQuery;
|
|
@@ -598,7 +612,24 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
598
612
|
operation: "operation",
|
|
599
613
|
}),
|
|
600
614
|
});
|
|
601
|
-
|
|
615
|
+
const accepted = response?.kind === "bus.ack" && response.ok;
|
|
616
|
+
if (!accepted) {
|
|
617
|
+
deps.recordRuntimeEvent?.(
|
|
618
|
+
"bus",
|
|
619
|
+
response?.kind === "bus.ack"
|
|
620
|
+
? response.message ?? "Follower rejected forwarded Telegram update."
|
|
621
|
+
: "Follower returned no forwarding acknowledgement.",
|
|
622
|
+
{
|
|
623
|
+
phase: "foreign-update-forward-rejected",
|
|
624
|
+
envelopeKind: envelope.kind,
|
|
625
|
+
recipientInstanceId:
|
|
626
|
+
"recipientInstanceId" in envelope
|
|
627
|
+
? envelope.recipientInstanceId
|
|
628
|
+
: undefined,
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
return accepted;
|
|
602
633
|
};
|
|
603
634
|
return {
|
|
604
635
|
forwardCallback: ({ query, ownership }) =>
|
|
@@ -632,6 +663,12 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
632
663
|
? { recipientRegistrationGeneration: ownership.ownerGeneration }
|
|
633
664
|
: {}),
|
|
634
665
|
message,
|
|
666
|
+
...(deps.getForwardCommentBatchPosition?.(message) !== undefined
|
|
667
|
+
? {
|
|
668
|
+
forwardCommentBatchPosition:
|
|
669
|
+
deps.getForwardCommentBatchPosition(message),
|
|
670
|
+
}
|
|
671
|
+
: {}),
|
|
635
672
|
sentAtMs: getNowMs(),
|
|
636
673
|
}),
|
|
637
674
|
forwardEditedMessage: ({ message, ownership }) =>
|
|
@@ -1287,6 +1324,24 @@ function parseHeartbeatEnvelope(
|
|
|
1287
1324
|
: undefined;
|
|
1288
1325
|
}
|
|
1289
1326
|
|
|
1327
|
+
function parseDisconnectEnvelope(
|
|
1328
|
+
value: Record<string, unknown>,
|
|
1329
|
+
requestId: string,
|
|
1330
|
+
): TelegramBusEnvelope | undefined {
|
|
1331
|
+
return typeof value.instanceId === "string" &&
|
|
1332
|
+
typeof value.sentAtMs === "number"
|
|
1333
|
+
? {
|
|
1334
|
+
kind: "follower.disconnect",
|
|
1335
|
+
requestId,
|
|
1336
|
+
instanceId: value.instanceId,
|
|
1337
|
+
...(typeof value.registrationGeneration === "string"
|
|
1338
|
+
? { registrationGeneration: value.registrationGeneration }
|
|
1339
|
+
: {}),
|
|
1340
|
+
sentAtMs: value.sentAtMs,
|
|
1341
|
+
}
|
|
1342
|
+
: undefined;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1290
1345
|
function parseForwardCallbackEnvelope(
|
|
1291
1346
|
value: Record<string, unknown>,
|
|
1292
1347
|
requestId: string,
|
|
@@ -1349,6 +1404,14 @@ function parseForwardMessageEnvelope(
|
|
|
1349
1404
|
}
|
|
1350
1405
|
: {}),
|
|
1351
1406
|
message: value.message,
|
|
1407
|
+
...(kind === "leader.forwardMessage" &&
|
|
1408
|
+
(value.forwardCommentBatchPosition === "comment" ||
|
|
1409
|
+
value.forwardCommentBatchPosition === "forward")
|
|
1410
|
+
? {
|
|
1411
|
+
forwardCommentBatchPosition:
|
|
1412
|
+
value.forwardCommentBatchPosition,
|
|
1413
|
+
}
|
|
1414
|
+
: {}),
|
|
1352
1415
|
sentAtMs: value.sentAtMs,
|
|
1353
1416
|
}
|
|
1354
1417
|
: undefined;
|
package/lib/commands.ts
CHANGED
|
@@ -307,6 +307,7 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
307
307
|
| Promise<void | TelegramBridgeCommandStartPollingResult>
|
|
308
308
|
| TelegramBridgeCommandStartPollingResult;
|
|
309
309
|
stopPolling: () => Promise<void | string>;
|
|
310
|
+
getDisconnectThreadName?: () => string | undefined;
|
|
310
311
|
updateStatus: (ctx: ExtensionCommandContext) => void;
|
|
311
312
|
getProfileNames?: () => string[];
|
|
312
313
|
activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
@@ -411,11 +412,34 @@ export function registerTelegramBridgeCommands(
|
|
|
411
412
|
},
|
|
412
413
|
});
|
|
413
414
|
pi.registerCommand("telegram-disconnect", {
|
|
414
|
-
description:
|
|
415
|
+
description:
|
|
416
|
+
"Stop Telegram; in Threaded Mode, delete this instance's current thread",
|
|
415
417
|
handler: async (_args, ctx) => {
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
418
|
+
const threadName = deps.getDisconnectThreadName?.();
|
|
419
|
+
if (threadName) {
|
|
420
|
+
const confirmed = await ctx.ui.confirm(
|
|
421
|
+
ctx.ui.theme.fg("accent", "pi-telegram"),
|
|
422
|
+
`Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`,
|
|
423
|
+
);
|
|
424
|
+
if (!confirmed) {
|
|
425
|
+
ctx.ui.notify("Telegram disconnect cancelled.", "info");
|
|
426
|
+
deps.updateStatus(ctx);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const message = await deps.stopPolling();
|
|
432
|
+
if (message) ctx.ui.notify(message, "info");
|
|
433
|
+
} catch (error) {
|
|
434
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
435
|
+
ctx.ui.notify(
|
|
436
|
+
`Telegram disconnect did not complete: ${detail} Keep this Pi session open, restore leader connectivity, inspect /telegram-status --debug, and retry /telegram-disconnect.`,
|
|
437
|
+
"warning",
|
|
438
|
+
);
|
|
439
|
+
throw error;
|
|
440
|
+
} finally {
|
|
441
|
+
deps.updateStatus(ctx);
|
|
442
|
+
}
|
|
419
443
|
},
|
|
420
444
|
});
|
|
421
445
|
}
|
package/lib/config.ts
CHANGED
|
@@ -60,10 +60,10 @@ export interface TelegramConfig {
|
|
|
60
60
|
inboundHandlers?: TelegramInboundHandlerConfig[];
|
|
61
61
|
attachmentHandlers?: TelegramInboundHandlerConfig[];
|
|
62
62
|
outboundHandlers?: TelegramOutboundHandlerConfig[];
|
|
63
|
-
proactivePush?: boolean;
|
|
64
63
|
assistant?: {
|
|
65
64
|
draftPreviews?: boolean;
|
|
66
65
|
rendering?: TelegramAssistantRenderingMode;
|
|
66
|
+
proactivePush?: boolean;
|
|
67
67
|
};
|
|
68
68
|
/** @deprecated use assistant.draftPreviews */
|
|
69
69
|
draftPreviews?: boolean;
|
|
@@ -85,7 +85,7 @@ export interface TelegramConfig {
|
|
|
85
85
|
* Per-profile bot/session identity fields.
|
|
86
86
|
* Stored under `profiles.<name>` in telegram.json.
|
|
87
87
|
* Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
|
|
88
|
-
* assistant
|
|
88
|
+
* assistant) stay at the top level.
|
|
89
89
|
*/
|
|
90
90
|
export interface TelegramBotProfile {
|
|
91
91
|
botToken: string;
|
|
@@ -259,31 +259,39 @@ export async function readTelegramConfig(
|
|
|
259
259
|
onInvalidConfig?: (recovery: TelegramInvalidConfigRecovery) => void;
|
|
260
260
|
} = {},
|
|
261
261
|
): Promise<TelegramConfig> {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
262
|
+
if (!existsSync(configPath)) return {};
|
|
263
|
+
const content = readFileSync(configPath, "utf8");
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(content) as TelegramConfig;
|
|
266
|
+
} catch {
|
|
267
|
+
// Atomic config publication makes ordinary reads safe without serialization.
|
|
268
|
+
// Acquire the transaction only before destructive invalid-file recovery.
|
|
269
|
+
return withTelegramFileTransaction(`${configPath}.transaction`, () => {
|
|
270
|
+
if (!existsSync(configPath)) return {};
|
|
271
|
+
const identity = statSync(configPath);
|
|
272
|
+
const currentContent = readFileSync(configPath, "utf8");
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(currentContent) as TelegramConfig;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const currentIdentity = statSync(configPath);
|
|
277
|
+
if (
|
|
278
|
+
currentIdentity.dev !== identity.dev ||
|
|
279
|
+
currentIdentity.ino !== identity.ino ||
|
|
280
|
+
currentIdentity.size !== identity.size ||
|
|
281
|
+
currentIdentity.mtimeMs !== identity.mtimeMs
|
|
282
|
+
) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Telegram config changed while validating invalid content: ${configPath}`,
|
|
285
|
+
{ cause: error },
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const recoveryPath = getInvalidTelegramConfigRecoveryPath(configPath);
|
|
289
|
+
renameSync(configPath, recoveryPath);
|
|
290
|
+
options.onInvalidConfig?.({ configPath, recoveryPath, error });
|
|
291
|
+
return {};
|
|
280
292
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
options.onInvalidConfig?.({ configPath, recoveryPath, error });
|
|
284
|
-
return {};
|
|
285
|
-
}
|
|
286
|
-
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
287
295
|
}
|
|
288
296
|
|
|
289
297
|
export async function writeTelegramConfig(
|
|
@@ -538,7 +546,7 @@ export function createTelegramConfigStore(
|
|
|
538
546
|
export function createTelegramProactivePushChecker(
|
|
539
547
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
540
548
|
): () => boolean {
|
|
541
|
-
return () => configStore.get().proactivePush ??
|
|
549
|
+
return () => configStore.get().assistant?.proactivePush ?? true;
|
|
542
550
|
}
|
|
543
551
|
|
|
544
552
|
export function createTelegramProactivePushSetter(
|
|
@@ -546,7 +554,11 @@ export function createTelegramProactivePushSetter(
|
|
|
546
554
|
): (enabled: boolean) => Promise<void> {
|
|
547
555
|
return async (enabled) => {
|
|
548
556
|
await loadLatestTelegramConfig(configStore);
|
|
549
|
-
const
|
|
557
|
+
const current = configStore.get();
|
|
558
|
+
const config = {
|
|
559
|
+
...current,
|
|
560
|
+
assistant: { ...current.assistant, proactivePush: enabled },
|
|
561
|
+
};
|
|
550
562
|
configStore.set(config);
|
|
551
563
|
await configStore.persist(config);
|
|
552
564
|
};
|