@llblab/pi-telegram 0.22.1 → 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 +11 -7
- package/BACKLOG.md +0 -50
- package/CHANGELOG.md +49 -2
- package/README.md +3 -1
- package/docs/README.md +1 -1
- package/docs/activity.md +8 -0
- package/docs/architecture.md +16 -14
- package/docs/locks.md +21 -15
- 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/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
|
};
|
package/lib/media.ts
CHANGED
|
@@ -682,11 +682,107 @@ export async function downloadTelegramMessageFiles(
|
|
|
682
682
|
return downloaded;
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
+
function collectTelegramRichBlockFileInfos(
|
|
686
|
+
blocks: unknown,
|
|
687
|
+
messageId: number,
|
|
688
|
+
): TelegramFileInfo[] {
|
|
689
|
+
if (!Array.isArray(blocks)) return [];
|
|
690
|
+
const files: TelegramFileInfo[] = [];
|
|
691
|
+
let mediaIndex = 0;
|
|
692
|
+
const visit = (entries: unknown): void => {
|
|
693
|
+
if (!Array.isArray(entries)) return;
|
|
694
|
+
for (const entry of entries) {
|
|
695
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
696
|
+
const type = getObjectField(entry, "type");
|
|
697
|
+
if (
|
|
698
|
+
type === "photo" ||
|
|
699
|
+
type === "animation" ||
|
|
700
|
+
type === "audio" ||
|
|
701
|
+
type === "video" ||
|
|
702
|
+
type === "voice_note"
|
|
703
|
+
) {
|
|
704
|
+
mediaIndex += 1;
|
|
705
|
+
}
|
|
706
|
+
if (type === "photo") {
|
|
707
|
+
const photos = getObjectField(entry, "photo");
|
|
708
|
+
if (Array.isArray(photos)) {
|
|
709
|
+
const photo = photos
|
|
710
|
+
.filter(
|
|
711
|
+
(value): value is TelegramPhotoSize =>
|
|
712
|
+
typeof value === "object" &&
|
|
713
|
+
value !== null &&
|
|
714
|
+
typeof getObjectField(value, "file_id") === "string",
|
|
715
|
+
)
|
|
716
|
+
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
|
717
|
+
.at(-1);
|
|
718
|
+
if (photo) {
|
|
719
|
+
files.push({
|
|
720
|
+
file_id: photo.file_id,
|
|
721
|
+
fileName: `photo-${messageId}-${mediaIndex}.jpg`,
|
|
722
|
+
mimeType: "image/jpeg",
|
|
723
|
+
kind: "photo",
|
|
724
|
+
isImage: true,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const fileField =
|
|
730
|
+
type === "animation"
|
|
731
|
+
? "animation"
|
|
732
|
+
: type === "audio"
|
|
733
|
+
? "audio"
|
|
734
|
+
: type === "video"
|
|
735
|
+
? "video"
|
|
736
|
+
: type === "voice_note"
|
|
737
|
+
? "voice_note"
|
|
738
|
+
: undefined;
|
|
739
|
+
if (fileField) {
|
|
740
|
+
const media = getObjectField(entry, fileField);
|
|
741
|
+
const fileId = getObjectField(media, "file_id");
|
|
742
|
+
const mimeType = getObjectField(media, "mime_type");
|
|
743
|
+
const fileName = getObjectField(media, "file_name");
|
|
744
|
+
if (typeof fileId === "string") {
|
|
745
|
+
const kind =
|
|
746
|
+
type === "voice_note" ? "voice" : (type as TelegramAttachmentKind);
|
|
747
|
+
const fallbackExtension =
|
|
748
|
+
kind === "voice" ? ".ogg" : kind === "audio" ? ".mp3" : ".mp4";
|
|
749
|
+
files.push({
|
|
750
|
+
file_id: fileId,
|
|
751
|
+
fileName:
|
|
752
|
+
typeof fileName === "string"
|
|
753
|
+
? fileName
|
|
754
|
+
: `${kind}-${messageId}-${mediaIndex}${guessExtensionFromMime(
|
|
755
|
+
typeof mimeType === "string" ? mimeType : undefined,
|
|
756
|
+
fallbackExtension,
|
|
757
|
+
)}`,
|
|
758
|
+
mimeType: typeof mimeType === "string" ? mimeType : undefined,
|
|
759
|
+
kind,
|
|
760
|
+
isImage: false,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
visit(getObjectField(entry, "blocks"));
|
|
765
|
+
const items = getObjectField(entry, "items");
|
|
766
|
+
if (Array.isArray(items)) {
|
|
767
|
+
for (const item of items) visit(getObjectField(item, "blocks"));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
visit(blocks);
|
|
772
|
+
return files;
|
|
773
|
+
}
|
|
774
|
+
|
|
685
775
|
export function collectTelegramFileInfos(
|
|
686
776
|
messages: TelegramMediaMessage[],
|
|
687
777
|
): TelegramFileInfo[] {
|
|
688
778
|
const files: TelegramFileInfo[] = [];
|
|
689
779
|
for (const message of messages) {
|
|
780
|
+
files.push(
|
|
781
|
+
...collectTelegramRichBlockFileInfos(
|
|
782
|
+
message.rich_message?.blocks,
|
|
783
|
+
message.message_id,
|
|
784
|
+
),
|
|
785
|
+
);
|
|
690
786
|
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
691
787
|
const photo = [...message.photo]
|
|
692
788
|
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
|
@@ -786,5 +882,10 @@ export function collectTelegramFileInfos(
|
|
|
786
882
|
});
|
|
787
883
|
}
|
|
788
884
|
}
|
|
789
|
-
|
|
885
|
+
const seenFileIds = new Set<string>();
|
|
886
|
+
return files.filter((file) => {
|
|
887
|
+
if (seenFileIds.has(file.file_id)) return false;
|
|
888
|
+
seenFileIds.add(file.file_id);
|
|
889
|
+
return true;
|
|
890
|
+
});
|
|
790
891
|
}
|
package/lib/menu-settings.ts
CHANGED
|
@@ -159,7 +159,10 @@ export function buildProactivePushSettingsText(
|
|
|
159
159
|
return [
|
|
160
160
|
`${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
|
|
161
161
|
"",
|
|
162
|
-
"
|
|
162
|
+
"Control whether public assistant output from local/autonomous work is projected to Telegram.",
|
|
163
|
+
"",
|
|
164
|
+
"<code>-</code> <code>on</code> (default): send each completed public block, including visible checkpoints and the final answer, while connected.",
|
|
165
|
+
"<code>-</code> <code>off</code>: keep local/autonomous assistant blocks in Pi; Telegram-originated replies still use their normal delivery path.",
|
|
163
166
|
].join("\n");
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -10,7 +10,10 @@ import { basename } from "node:path";
|
|
|
10
10
|
import { Type } from "@sinclair/typebox";
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "./pi.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
buildTelegramMultipartReplyParameters,
|
|
15
|
+
normalizeTelegramNativeMarkdown,
|
|
16
|
+
} from "./replies.ts";
|
|
14
17
|
import {
|
|
15
18
|
getTelegramTargetThreadParams,
|
|
16
19
|
type TelegramTarget,
|
|
@@ -97,6 +100,152 @@ export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutbou
|
|
|
97
100
|
target?: TelegramTarget;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
class TelegramRichAttachmentCommitUnknownError extends Error {
|
|
104
|
+
readonly kind = "commit-unknown" as const;
|
|
105
|
+
|
|
106
|
+
constructor(cause: unknown) {
|
|
107
|
+
super("Telegram Rich media upload may have committed without a message id.");
|
|
108
|
+
this.name = "TelegramRichAttachmentCommitUnknownError";
|
|
109
|
+
this.cause = cause;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isTelegramRichAttachmentCommitUnknownError(error: unknown): boolean {
|
|
114
|
+
return (
|
|
115
|
+
error instanceof TelegramRichAttachmentCommitUnknownError ||
|
|
116
|
+
(typeof error === "object" &&
|
|
117
|
+
error !== null &&
|
|
118
|
+
(error as { kind?: unknown }).kind === "commit-unknown")
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface TelegramRichOutboundAttachmentPlan {
|
|
123
|
+
method: "sendRichMessage";
|
|
124
|
+
fields: Record<string, string>;
|
|
125
|
+
fileField: "rich_media_upload";
|
|
126
|
+
filePath: string;
|
|
127
|
+
fileName: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TelegramRichOutboundAttachmentSenderDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
|
|
131
|
+
sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
|
|
132
|
+
getRenderingMode: () => "rich" | "html";
|
|
133
|
+
recordOwnership?: (input: {
|
|
134
|
+
chatId: number;
|
|
135
|
+
messageId: number;
|
|
136
|
+
target?: TelegramTarget;
|
|
137
|
+
}) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function planTelegramRichOutboundAttachment(options: {
|
|
141
|
+
turn: TelegramQueuedOutboundAttachmentTurnView;
|
|
142
|
+
markdown: string;
|
|
143
|
+
renderingMode: "rich" | "html";
|
|
144
|
+
replyMarkup?: unknown;
|
|
145
|
+
}): TelegramRichOutboundAttachmentPlan | undefined {
|
|
146
|
+
if (options.renderingMode !== "rich") return undefined;
|
|
147
|
+
if (!options.markdown.trim()) return undefined;
|
|
148
|
+
if (options.turn.queuedAttachments.length !== 1) return undefined;
|
|
149
|
+
const attachment = options.turn.queuedAttachments[0]!;
|
|
150
|
+
const normalizedPath = attachment.path.toLowerCase();
|
|
151
|
+
const mediaType = normalizedPath.endsWith(".jpg") ||
|
|
152
|
+
normalizedPath.endsWith(".jpeg") ||
|
|
153
|
+
normalizedPath.endsWith(".png")
|
|
154
|
+
? "photo"
|
|
155
|
+
: normalizedPath.endsWith(".mp4")
|
|
156
|
+
? "video"
|
|
157
|
+
: normalizedPath.endsWith(".mp3")
|
|
158
|
+
? "audio"
|
|
159
|
+
: undefined;
|
|
160
|
+
if (!mediaType) return undefined;
|
|
161
|
+
const mediaId = "artifact";
|
|
162
|
+
const richMessage = {
|
|
163
|
+
markdown: `${normalizeTelegramNativeMarkdown(options.markdown)}\n\n`,
|
|
164
|
+
media: [
|
|
165
|
+
{
|
|
166
|
+
id: mediaId,
|
|
167
|
+
media: {
|
|
168
|
+
type: mediaType,
|
|
169
|
+
media: "attach://rich_media_upload",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
skip_entity_detection: true,
|
|
174
|
+
};
|
|
175
|
+
const replyParameters =
|
|
176
|
+
options.turn.replyToMessageId > 0
|
|
177
|
+
? JSON.stringify({
|
|
178
|
+
message_id: options.turn.replyToMessageId,
|
|
179
|
+
allow_sending_without_reply: true,
|
|
180
|
+
})
|
|
181
|
+
: undefined;
|
|
182
|
+
return {
|
|
183
|
+
method: "sendRichMessage",
|
|
184
|
+
fields: {
|
|
185
|
+
chat_id: String(options.turn.chatId),
|
|
186
|
+
...(replyParameters ? { reply_parameters: replyParameters } : {}),
|
|
187
|
+
...getTelegramMultipartTargetFields(options.turn.target),
|
|
188
|
+
rich_message: JSON.stringify(richMessage),
|
|
189
|
+
...(options.replyMarkup
|
|
190
|
+
? { reply_markup: JSON.stringify(options.replyMarkup) }
|
|
191
|
+
: {}),
|
|
192
|
+
},
|
|
193
|
+
fileField: "rich_media_upload",
|
|
194
|
+
filePath: attachment.path,
|
|
195
|
+
fileName: attachment.fileName,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createTelegramRichOutboundAttachmentSender(
|
|
200
|
+
deps: TelegramRichOutboundAttachmentSenderDeps,
|
|
201
|
+
) {
|
|
202
|
+
return async (
|
|
203
|
+
turn: TelegramQueuedOutboundAttachmentTurnView,
|
|
204
|
+
markdown: string,
|
|
205
|
+
options?: { replyMarkup?: unknown },
|
|
206
|
+
): Promise<boolean> => {
|
|
207
|
+
const plan = planTelegramRichOutboundAttachment({
|
|
208
|
+
turn,
|
|
209
|
+
markdown,
|
|
210
|
+
renderingMode: deps.getRenderingMode(),
|
|
211
|
+
replyMarkup: options?.replyMarkup,
|
|
212
|
+
});
|
|
213
|
+
if (!plan) return false;
|
|
214
|
+
try {
|
|
215
|
+
const result = await deps.sendMultipart(
|
|
216
|
+
plan.method,
|
|
217
|
+
plan.fields,
|
|
218
|
+
plan.fileField,
|
|
219
|
+
plan.filePath,
|
|
220
|
+
plan.fileName,
|
|
221
|
+
);
|
|
222
|
+
const messageId =
|
|
223
|
+
result && typeof result === "object" &&
|
|
224
|
+
Number.isInteger((result as { message_id?: unknown }).message_id)
|
|
225
|
+
? (result as { message_id: number }).message_id
|
|
226
|
+
: undefined;
|
|
227
|
+
if (messageId === undefined) {
|
|
228
|
+
throw new TelegramRichAttachmentCommitUnknownError(
|
|
229
|
+
new Error("Successful Rich media upload omitted message_id."),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
deps.recordOwnership?.({
|
|
233
|
+
chatId: turn.chatId,
|
|
234
|
+
messageId,
|
|
235
|
+
target: turn.target,
|
|
236
|
+
});
|
|
237
|
+
return true;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (isTelegramRichAttachmentCommitUnknownError(error)) throw error;
|
|
240
|
+
deps.recordRuntimeEvent?.("attachment", error, {
|
|
241
|
+
phase: "rich-media-known-failure",
|
|
242
|
+
fileName: plan.fileName,
|
|
243
|
+
});
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
100
249
|
export type TelegramGuestCachedAttachmentResult =
|
|
101
250
|
| {
|
|
102
251
|
type: "document";
|