@llblab/pi-telegram 0.10.8 → 0.11.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/AGENTS.md +11 -7
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +29 -1
- package/README.md +62 -23
- package/docs/README.md +3 -2
- package/docs/architecture.md +7 -6
- package/docs/extension-sections.md +6 -4
- package/docs/inbound-handlers.md +41 -1
- package/docs/outbound-handlers.md +32 -49
- package/docs/voice.md +210 -0
- package/index.ts +87 -1
- package/lib/api.ts +35 -2
- package/lib/config.ts +134 -0
- package/lib/extension-sections.ts +39 -20
- package/lib/external-handlers.ts +3 -4
- package/lib/inbound-handlers.ts +197 -0
- package/lib/media.ts +3 -0
- package/lib/menu-settings.ts +233 -9
- package/lib/menu-status.ts +17 -3
- package/lib/menu-thinking.ts +12 -1
- package/lib/menu.ts +10 -1
- package/lib/outbound-handlers.ts +719 -277
- package/lib/preview.ts +9 -0
- package/lib/prompts.ts +3 -1
- package/lib/queue.ts +84 -5
- package/lib/routing.ts +12 -1
- package/lib/time-injection.ts +78 -0
- package/lib/turns.ts +97 -20
- package/lib/voice.ts +295 -0
- package/package.json +1 -1
package/lib/preview.ts
CHANGED
|
@@ -20,8 +20,10 @@ import {
|
|
|
20
20
|
type TelegramRenderedChunk,
|
|
21
21
|
type TelegramRenderMode,
|
|
22
22
|
} from "./rendering.ts";
|
|
23
|
+
|
|
23
24
|
import { buildTelegramReplyParameters } from "./replies.ts";
|
|
24
25
|
import { stripTelegramCommentMarkupForPreview } from "./outbound-handlers.ts";
|
|
26
|
+
import { shouldSuppressPreviewForVoice } from "./voice.ts";
|
|
25
27
|
|
|
26
28
|
const TELEGRAM_PREVIEW_THROTTLE_MS = 750;
|
|
27
29
|
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
|
|
@@ -104,6 +106,8 @@ export interface TelegramPreviewRuntimeDeps<
|
|
|
104
106
|
|
|
105
107
|
export interface TelegramPreviewActiveTurn {
|
|
106
108
|
chatId: number;
|
|
109
|
+
voiceReplyPreferred?: boolean;
|
|
110
|
+
voiceReplyRequired?: boolean;
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
export interface TelegramAssistantMessagePreviewStartDeps<
|
|
@@ -504,6 +508,10 @@ export async function handleTelegramAssistantMessagePreviewStart<
|
|
|
504
508
|
): Promise<void> {
|
|
505
509
|
const turn = deps.getActiveTurn();
|
|
506
510
|
if (!turn || !deps.isAssistantMessage(message)) return;
|
|
511
|
+
if (shouldSuppressPreviewForVoice(turn)) {
|
|
512
|
+
deps.setState(undefined);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
507
515
|
const state = deps.getState();
|
|
508
516
|
if (
|
|
509
517
|
state &&
|
|
@@ -526,6 +534,7 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
|
|
|
526
534
|
): Promise<void> {
|
|
527
535
|
const turn = deps.getActiveTurn();
|
|
528
536
|
if (!turn || !deps.isAssistantMessage(message)) return;
|
|
537
|
+
if (shouldSuppressPreviewForVoice(turn)) return;
|
|
529
538
|
let state = deps.getState();
|
|
530
539
|
if (!state) {
|
|
531
540
|
state = deps.createPreviewState();
|
package/lib/prompts.ts
CHANGED
|
@@ -15,6 +15,8 @@ Inbound context:
|
|
|
15
15
|
- \`[telegram]\` marks Telegram-originated messages. Suffixes \`|from:user\` (sender) and \`|guest:group\` (guest mode — message from another chat where the bot is not a member) may be present; the bot sees the message as if forwarded from that user/chat.
|
|
16
16
|
- \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Suffix \`|from:user\` identifies the original author in guest-mode replies. Use it to resolve references like "this", "it", or "that message"; the actual instruction is before [reply] unless it explicitly asks to act on the quote.
|
|
17
17
|
- \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains inbound-handler stdout such as transcriptions or extracted text for those attachments.
|
|
18
|
+
- \`[time]\` gives the wall-clock time for this Telegram turn when the operator enabled time injection. Use it for relative-date requests like "today", "now", or scheduling; otherwise do not mention it.
|
|
19
|
+
- \`[voice]\` describes Telegram voice reply policy for this turn. \`manual\` means answer normally and use explicit \`telegram_voice\` markup only when a spoken reply is useful; \`mirror\` means voice input prefers a voice reply; \`always\` means the final reply is expected to be converted to voice, so keep it TTS-friendly.
|
|
18
20
|
- Unknown \`[callback] ...\` messages may be intended for another extension; if you see one, say the callback was not handled and the environment may be misconfigured.
|
|
19
21
|
|
|
20
22
|
Telegram-visible output:
|
|
@@ -25,7 +27,7 @@ Telegram-visible output:
|
|
|
25
27
|
|
|
26
28
|
Native outbound actions:
|
|
27
29
|
- Use top-level column-zero hidden Markdown comments outside code, quotes, and lists; the bridge handles them after agent_end, so do not call or register transport/TTS/text-to-OGG tools.
|
|
28
|
-
- \`telegram_voice\`: text is synthesized
|
|
30
|
+
- \`telegram_voice\`: text is synthesized by the registered voice synthesis provider and delivered by pi-telegram. Use body text for multiline voice, \`<!-- telegram_voice text="Short summary" -->\` for explicit one-line voice, or \`<!-- telegram_voice: Short summary -->\` for one-line voice with no attributes. A companion summary is optional, no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
|
|
29
31
|
- \`telegram_button\`: callback prompt is routed back as a normal Telegram turn. Use \`<!-- telegram_button: OK -->\` when prompt equals label, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\` for one-line prompts, or body form \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\` for multiline prompts.
|
|
30
32
|
- If only hidden action comments would remain, add visible parent text like "Choose one:".
|
|
31
33
|
`;
|
package/lib/queue.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Owns queue item contracts, lane admission, pure queue mutations, and dispatch planning
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { isVoiceTurn } from "./voice.ts";
|
|
8
|
+
|
|
7
9
|
// --- Queue Items ---
|
|
8
10
|
|
|
9
11
|
export interface QueuedAttachment {
|
|
@@ -80,6 +82,11 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
|
|
|
80
82
|
content: TelegramPromptContent[];
|
|
81
83
|
historyText: string;
|
|
82
84
|
priorityEmoji?: string;
|
|
85
|
+
|
|
86
|
+
/** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
|
|
87
|
+
voiceReplyPreferred?: boolean;
|
|
88
|
+
/** Turn must be delivered as voice (voice mode) */
|
|
89
|
+
voiceReplyRequired?: boolean;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
export interface PendingTelegramControlItem<
|
|
@@ -371,6 +378,24 @@ export function formatQueuedTelegramItemsStatus<TContext = unknown>(
|
|
|
371
378
|
return items.length === 0 ? "" : ` +${items.length}`;
|
|
372
379
|
}
|
|
373
380
|
|
|
381
|
+
export function truncateTelegramQueueSummary(
|
|
382
|
+
text: string,
|
|
383
|
+
maxWords = 5,
|
|
384
|
+
maxLength = 40,
|
|
385
|
+
): string {
|
|
386
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
387
|
+
if (!normalized) return "";
|
|
388
|
+
const words = normalized.split(" ");
|
|
389
|
+
let summary = words.slice(0, maxWords).join(" ");
|
|
390
|
+
if (summary.length === 0) summary = normalized;
|
|
391
|
+
if (summary.length > maxLength) {
|
|
392
|
+
summary = summary.slice(0, maxLength).trimEnd();
|
|
393
|
+
}
|
|
394
|
+
return summary.length < normalized.length || words.length > maxWords
|
|
395
|
+
? `${summary}…`
|
|
396
|
+
: summary;
|
|
397
|
+
}
|
|
398
|
+
|
|
374
399
|
export function canDispatchTelegramTurnState(
|
|
375
400
|
state: TelegramDispatchGuardState,
|
|
376
401
|
): boolean {
|
|
@@ -788,7 +813,11 @@ export interface TelegramAgentEndRuntimeDeps<
|
|
|
788
813
|
text: string,
|
|
789
814
|
) => Promise<unknown>;
|
|
790
815
|
sendQueuedAttachments: (turn: TTurn) => Promise<void>;
|
|
791
|
-
answerGuestQuery?: (
|
|
816
|
+
answerGuestQuery?: (
|
|
817
|
+
guestQueryId: string,
|
|
818
|
+
text?: string,
|
|
819
|
+
options?: { parseMode?: string },
|
|
820
|
+
) => Promise<void>;
|
|
792
821
|
sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
|
|
793
822
|
planOutboundReply?: (
|
|
794
823
|
markdown: string,
|
|
@@ -976,9 +1005,32 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
976
1005
|
>(deps: TelegramAgentEndRuntimeDeps<TTurn, TReplyMarkup>): Promise<void> {
|
|
977
1006
|
const { turn, assistant } = deps;
|
|
978
1007
|
const rawFinalText = assistant.text;
|
|
979
|
-
|
|
1008
|
+
let outboundReply = rawFinalText
|
|
980
1009
|
? deps.planOutboundReply?.(rawFinalText)
|
|
981
1010
|
: undefined;
|
|
1011
|
+
// Preserve the planned reply so voice-fallback can use stripped markdown + replyMarkup
|
|
1012
|
+
const plannedReply = outboundReply;
|
|
1013
|
+
|
|
1014
|
+
// Transparent voice interception: when the turn is voice-tagged and the agent
|
|
1015
|
+
// did not explicitly use <!-- telegram_voice --> markup, we automatically
|
|
1016
|
+
// convert the whole response to voice.
|
|
1017
|
+
const voiceInterceptionGuard =
|
|
1018
|
+
turn &&
|
|
1019
|
+
isVoiceTurn(turn) &&
|
|
1020
|
+
rawFinalText?.trim() &&
|
|
1021
|
+
deps.planOutboundReply &&
|
|
1022
|
+
(!outboundReply ||
|
|
1023
|
+
(!outboundReply.voiceText && !outboundReply.voiceReplies?.length));
|
|
1024
|
+
if (voiceInterceptionGuard) {
|
|
1025
|
+
const voiceText =
|
|
1026
|
+
plannedReply !== undefined
|
|
1027
|
+
? plannedReply.markdown?.trim() || ""
|
|
1028
|
+
: (rawFinalText ?? "");
|
|
1029
|
+
outboundReply = outboundReply
|
|
1030
|
+
? { ...outboundReply, voiceText, markdown: "" }
|
|
1031
|
+
: { markdown: "", voiceText };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
982
1034
|
const finalText = outboundReply ? outboundReply.markdown : rawFinalText;
|
|
983
1035
|
const hasOutboundArtifacts =
|
|
984
1036
|
!!outboundReply?.voiceText || !!outboundReply?.voiceReplies?.length;
|
|
@@ -1080,9 +1132,36 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
1080
1132
|
}
|
|
1081
1133
|
}
|
|
1082
1134
|
if (outboundReply && deps.sendOutboundReplyArtifacts) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1135
|
+
try {
|
|
1136
|
+
await deps.sendOutboundReplyArtifacts(turn, outboundReply, {
|
|
1137
|
+
replyToPrompt: !finalText,
|
|
1138
|
+
});
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
deps.recordRuntimeEvent?.("delivery", error, {
|
|
1141
|
+
phase: "voice-artifacts",
|
|
1142
|
+
chatId: turn.chatId,
|
|
1143
|
+
});
|
|
1144
|
+
// Fallback to planned text when voice delivery fails and text wasn't already delivered
|
|
1145
|
+
if (rawFinalText?.trim() && !finalText && hasOutboundArtifacts) {
|
|
1146
|
+
try {
|
|
1147
|
+
const fallbackMarkdown =
|
|
1148
|
+
plannedReply?.markdown || outboundReply?.voiceText || rawFinalText;
|
|
1149
|
+
await deps.sendMarkdownReply(
|
|
1150
|
+
turn.chatId,
|
|
1151
|
+
turn.replyToMessageId,
|
|
1152
|
+
fallbackMarkdown,
|
|
1153
|
+
plannedReply?.replyMarkup
|
|
1154
|
+
? { replyMarkup: plannedReply.replyMarkup }
|
|
1155
|
+
: undefined,
|
|
1156
|
+
);
|
|
1157
|
+
} catch (fallbackError) {
|
|
1158
|
+
deps.recordRuntimeEvent?.("delivery", fallbackError, {
|
|
1159
|
+
phase: "voice-fallback-text",
|
|
1160
|
+
chatId: turn.chatId,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1086
1165
|
}
|
|
1087
1166
|
if (endPlan.shouldSendAttachmentNotice) {
|
|
1088
1167
|
await deps.sendTextReply(
|
package/lib/routing.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as Queue from "./queue.ts";
|
|
|
18
18
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
19
19
|
import * as TextGroups from "./text-groups.ts";
|
|
20
20
|
import * as Turns from "./turns.ts";
|
|
21
|
+
import { getTelegramVoiceReplyMode } from "./voice.ts";
|
|
21
22
|
import type { TelegramUser } from "./updates.ts";
|
|
22
23
|
import * as Updates from "./updates.ts";
|
|
23
24
|
|
|
@@ -38,7 +39,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
38
39
|
> {
|
|
39
40
|
configStore: Pick<
|
|
40
41
|
TelegramConfigStore,
|
|
41
|
-
"getAllowedUserId" | "setAllowedUserId" | "persist"
|
|
42
|
+
"get" | "getAllowedUserId" | "setAllowedUserId" | "persist"
|
|
42
43
|
>;
|
|
43
44
|
bridgeRuntime: TelegramBridgeRuntime;
|
|
44
45
|
activeTurnRuntime: Queue.TelegramActiveTurnStore;
|
|
@@ -113,6 +114,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
113
114
|
typeof PromptTemplates.getTelegramPromptTemplateCommands
|
|
114
115
|
>[0];
|
|
115
116
|
downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"];
|
|
117
|
+
resolveTimeLine?: (chatId: number) => string | null;
|
|
116
118
|
getThinkingLevel: () => Model.ThinkingLevel;
|
|
117
119
|
setThinkingLevel: (level: Model.ThinkingLevel) => void;
|
|
118
120
|
persistScopedModelPatterns?: (
|
|
@@ -286,6 +288,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
286
288
|
allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
|
|
287
289
|
downloadFile: deps.downloadFile,
|
|
288
290
|
processAttachments: deps.inboundHandlerRuntime.process,
|
|
291
|
+
resolveTimeLine: deps.resolveTimeLine,
|
|
292
|
+
|
|
293
|
+
// Voice policy for the current turn. Missing config still behaves as manual,
|
|
294
|
+
// but only explicit telegram.json voice.replyMode is shown in prompt context.
|
|
295
|
+
getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
|
|
296
|
+
isVoiceReplyModeConfigured: () => {
|
|
297
|
+
const mode = deps.configStore.get().voice?.replyMode;
|
|
298
|
+
return mode === "manual" || mode === "mirror" || mode === "always";
|
|
299
|
+
},
|
|
289
300
|
});
|
|
290
301
|
const enqueueContinueTurn = async (
|
|
291
302
|
message: TMessage,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram per-chat time injection runtime
|
|
3
|
+
* Zones: telegram inbound, prompt content
|
|
4
|
+
* Owns the formatted `[time]` line and the per-chat interval bookkeeping that decides when to emit it
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ResolvedTelegramTimeConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
export interface TimeInjectionRuntime {
|
|
10
|
+
resolveLine: (chatId: number, now?: Date) => string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TimeInjectionRuntimeDeps {
|
|
14
|
+
getConfig: () => ResolvedTelegramTimeConfig;
|
|
15
|
+
recordRuntimeEvent?: (
|
|
16
|
+
category: string,
|
|
17
|
+
error: unknown,
|
|
18
|
+
details?: Record<string, unknown>,
|
|
19
|
+
) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatTelegramTimeInjectionLine(
|
|
23
|
+
now: Date,
|
|
24
|
+
timezone: string,
|
|
25
|
+
): string {
|
|
26
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
27
|
+
timeZone: timezone,
|
|
28
|
+
year: "numeric",
|
|
29
|
+
month: "2-digit",
|
|
30
|
+
day: "2-digit",
|
|
31
|
+
hour: "2-digit",
|
|
32
|
+
minute: "2-digit",
|
|
33
|
+
second: "2-digit",
|
|
34
|
+
hour12: false,
|
|
35
|
+
}).formatToParts(now);
|
|
36
|
+
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
37
|
+
parts.find((part) => part.type === type)?.value ?? "";
|
|
38
|
+
const year = get("year");
|
|
39
|
+
const month = get("month");
|
|
40
|
+
const day = get("day");
|
|
41
|
+
const hourRaw = get("hour");
|
|
42
|
+
const hour = hourRaw === "24" ? "00" : hourRaw;
|
|
43
|
+
const minute = get("minute");
|
|
44
|
+
const second = get("second");
|
|
45
|
+
return `${year}-${month}-${day} ${hour}:${minute}:${second} ${timezone}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createTimeInjectionRuntime(
|
|
49
|
+
deps: TimeInjectionRuntimeDeps,
|
|
50
|
+
): TimeInjectionRuntime {
|
|
51
|
+
const lastInjectedAt = new Map<number, number>();
|
|
52
|
+
return {
|
|
53
|
+
resolveLine: (chatId, now = new Date()) => {
|
|
54
|
+
const config = deps.getConfig();
|
|
55
|
+
if (config.injectionMode === "off") return null;
|
|
56
|
+
let line: string;
|
|
57
|
+
try {
|
|
58
|
+
line = formatTelegramTimeInjectionLine(now, config.timezone);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
deps.recordRuntimeEvent?.("time-injection", error, {
|
|
61
|
+
timezone: config.timezone,
|
|
62
|
+
});
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (config.injectionMode === "always") return line;
|
|
66
|
+
const previous = lastInjectedAt.get(chatId);
|
|
67
|
+
const nowMs = now.getTime();
|
|
68
|
+
if (
|
|
69
|
+
previous !== undefined &&
|
|
70
|
+
nowMs - previous < config.interval
|
|
71
|
+
) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
lastInjectedAt.set(chatId, nowMs);
|
|
75
|
+
return line;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/lib/turns.ts
CHANGED
|
@@ -8,16 +8,16 @@ import { readFile } from "node:fs/promises";
|
|
|
8
8
|
import { basename, dirname, join } from "node:path";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
appendTelegramReplyContext,
|
|
11
12
|
collectTelegramMessageIds,
|
|
12
|
-
type DownloadedTelegramMessageFile,
|
|
13
|
-
type DownloadTelegramMessageFilesDeps,
|
|
14
13
|
downloadTelegramMessageFiles,
|
|
15
14
|
extractTelegramMessagesPromptText,
|
|
16
15
|
extractTelegramMessagesText,
|
|
17
|
-
appendTelegramReplyContext,
|
|
18
16
|
extractTelegramReplyContextText,
|
|
19
17
|
formatTelegramHistoryText,
|
|
20
18
|
guessMediaType,
|
|
19
|
+
type DownloadedTelegramMessageFile,
|
|
20
|
+
type DownloadTelegramMessageFilesDeps,
|
|
21
21
|
type TelegramMediaMessage,
|
|
22
22
|
} from "./media.ts";
|
|
23
23
|
import type {
|
|
@@ -27,6 +27,21 @@ import type {
|
|
|
27
27
|
TelegramQueueStore,
|
|
28
28
|
} from "./queue.ts";
|
|
29
29
|
|
|
30
|
+
import {
|
|
31
|
+
computeVoicePromptContribution,
|
|
32
|
+
computeVoiceTurnFlags,
|
|
33
|
+
getTelegramVoiceReplyMode,
|
|
34
|
+
TELEGRAM_VOICE_REPLY_MODES,
|
|
35
|
+
type TelegramVoiceReplyMode,
|
|
36
|
+
} from "./voice.ts";
|
|
37
|
+
|
|
38
|
+
// Re-export for backward compatibility with existing namespace imports (e.g. Turns.getTelegramVoiceReplyMode in routing.ts)
|
|
39
|
+
export {
|
|
40
|
+
getTelegramVoiceReplyMode,
|
|
41
|
+
TELEGRAM_VOICE_REPLY_MODES,
|
|
42
|
+
type TelegramVoiceReplyMode,
|
|
43
|
+
};
|
|
44
|
+
|
|
30
45
|
export const TELEGRAM_PREFIX = "[telegram]";
|
|
31
46
|
|
|
32
47
|
export interface TelegramTurnMessage {
|
|
@@ -36,23 +51,8 @@ export interface TelegramTurnMessage {
|
|
|
36
51
|
|
|
37
52
|
export type DownloadedTelegramTurnFile = DownloadedTelegramMessageFile;
|
|
38
53
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
maxWords = 5,
|
|
42
|
-
maxLength = 40,
|
|
43
|
-
): string {
|
|
44
|
-
const normalized = text.replace(/\s+/g, " ").trim();
|
|
45
|
-
if (!normalized) return "";
|
|
46
|
-
const words = normalized.split(" ");
|
|
47
|
-
let summary = words.slice(0, maxWords).join(" ");
|
|
48
|
-
if (summary.length === 0) summary = normalized;
|
|
49
|
-
if (summary.length > maxLength) {
|
|
50
|
-
summary = summary.slice(0, maxLength).trimEnd();
|
|
51
|
-
}
|
|
52
|
-
return summary.length < normalized.length || words.length > maxWords
|
|
53
|
-
? `${summary}…`
|
|
54
|
-
: summary;
|
|
55
|
-
}
|
|
54
|
+
import { truncateTelegramQueueSummary } from "./queue.ts";
|
|
55
|
+
export { truncateTelegramQueueSummary };
|
|
56
56
|
|
|
57
57
|
export function formatTelegramTurnStatusSummary(
|
|
58
58
|
rawText: string,
|
|
@@ -103,6 +103,23 @@ function appendTelegramPromptText(prompt: string, rawText: string): string {
|
|
|
103
103
|
return `${prompt} ${rawText}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
function appendTelegramVoiceContext(
|
|
107
|
+
prompt: string,
|
|
108
|
+
entries: Record<string, string>,
|
|
109
|
+
): string {
|
|
110
|
+
const prefix = prompt.length > 0 ? `${prompt}\n\n` : "";
|
|
111
|
+
const pairs = Object.entries(entries);
|
|
112
|
+
if (pairs.length === 1) {
|
|
113
|
+
const [key, value] = pairs[0];
|
|
114
|
+
return `${prefix}[voice] ${key}: ${value}`;
|
|
115
|
+
}
|
|
116
|
+
return `${prefix}[voice]\n${pairs
|
|
117
|
+
.map(([key, value]) => `- ${key}: ${value}`)
|
|
118
|
+
.join("\n")}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Voice Policy And Tagging ---
|
|
122
|
+
|
|
106
123
|
export function buildTelegramTurnPrompt(options: {
|
|
107
124
|
telegramPrefix: string;
|
|
108
125
|
rawText: string;
|
|
@@ -110,6 +127,8 @@ export function buildTelegramTurnPrompt(options: {
|
|
|
110
127
|
promptFiles?: DownloadedTelegramTurnFile[];
|
|
111
128
|
handlerOutputs?: string[];
|
|
112
129
|
historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
|
|
130
|
+
timeLine?: string | null;
|
|
131
|
+
voiceContext?: Record<string, string>;
|
|
113
132
|
}): string {
|
|
114
133
|
let prompt = options.telegramPrefix;
|
|
115
134
|
if ((options.historyTurns?.length ?? 0) > 0) {
|
|
@@ -133,6 +152,12 @@ export function buildTelegramTurnPrompt(options: {
|
|
|
133
152
|
"outputs",
|
|
134
153
|
options.handlerOutputs ?? [],
|
|
135
154
|
);
|
|
155
|
+
if (options.voiceContext) {
|
|
156
|
+
prompt = appendTelegramVoiceContext(prompt, options.voiceContext);
|
|
157
|
+
}
|
|
158
|
+
if (options.timeLine) {
|
|
159
|
+
prompt = `${prompt}\n\n[time] ${options.timeLine}`;
|
|
160
|
+
}
|
|
136
161
|
return prompt;
|
|
137
162
|
}
|
|
138
163
|
|
|
@@ -309,8 +334,12 @@ export interface BuildTelegramPromptTurnOptions {
|
|
|
309
334
|
files: DownloadedTelegramTurnFile[];
|
|
310
335
|
promptFiles?: DownloadedTelegramTurnFile[];
|
|
311
336
|
handlerOutputs?: string[];
|
|
337
|
+
timeLine?: string | null;
|
|
312
338
|
readBinaryFile: (path: string) => Promise<Uint8Array>;
|
|
313
339
|
inferImageMimeType: (path: string) => string | undefined;
|
|
340
|
+
voiceReplyMode?: TelegramVoiceReplyMode;
|
|
341
|
+
voiceReplyModeConfigured?: boolean;
|
|
342
|
+
voicePromptContribution?: string;
|
|
314
343
|
}
|
|
315
344
|
|
|
316
345
|
export type BuildTelegramPromptTurnRuntimeOptions = Omit<
|
|
@@ -331,6 +360,9 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
|
|
|
331
360
|
promptFiles?: DownloadedTelegramTurnFile[];
|
|
332
361
|
handlerOutputs?: string[];
|
|
333
362
|
}>;
|
|
363
|
+
resolveTimeLine?: (chatId: number) => string | null;
|
|
364
|
+
getVoiceReplyMode?: () => TelegramVoiceReplyMode;
|
|
365
|
+
isVoiceReplyModeConfigured?: () => boolean;
|
|
334
366
|
}
|
|
335
367
|
|
|
336
368
|
export function createTelegramPromptTurnRuntimeBuilder<
|
|
@@ -358,6 +390,13 @@ export function createTelegramPromptTurnRuntimeBuilder<
|
|
|
358
390
|
processed.rawText,
|
|
359
391
|
replyContext,
|
|
360
392
|
);
|
|
393
|
+
// Compute voice mode once and pass it to both the turn builder and the prompt contribution helper
|
|
394
|
+
const voiceReplyMode = deps.getVoiceReplyMode?.();
|
|
395
|
+
const chatId = messages[0]?.chat.id;
|
|
396
|
+
const timeLine =
|
|
397
|
+
deps.resolveTimeLine && chatId !== undefined
|
|
398
|
+
? deps.resolveTimeLine(chatId)
|
|
399
|
+
: null;
|
|
361
400
|
return buildTelegramPromptTurnRuntime({
|
|
362
401
|
telegramPrefix: TELEGRAM_PREFIX,
|
|
363
402
|
messages,
|
|
@@ -368,11 +407,28 @@ export function createTelegramPromptTurnRuntimeBuilder<
|
|
|
368
407
|
files,
|
|
369
408
|
promptFiles: processed.promptFiles,
|
|
370
409
|
handlerOutputs: processed.handlerOutputs,
|
|
410
|
+
timeLine,
|
|
371
411
|
inferImageMimeType: guessMediaType,
|
|
412
|
+
voiceReplyMode,
|
|
413
|
+
voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
|
|
414
|
+
voicePromptContribution: computeVoicePromptContribution(
|
|
415
|
+
voiceReplyMode,
|
|
416
|
+
files,
|
|
417
|
+
rawText,
|
|
418
|
+
),
|
|
372
419
|
});
|
|
373
420
|
};
|
|
374
421
|
}
|
|
375
422
|
|
|
423
|
+
function getTelegramVoicePromptContext(
|
|
424
|
+
voiceReplyMode: TelegramVoiceReplyMode,
|
|
425
|
+
hasVoiceFile: boolean,
|
|
426
|
+
): Record<string, string> | undefined {
|
|
427
|
+
if (voiceReplyMode === "always") return { "reply mode": "always" };
|
|
428
|
+
if (!hasVoiceFile) return undefined;
|
|
429
|
+
return { "reply mode": voiceReplyMode };
|
|
430
|
+
}
|
|
431
|
+
|
|
376
432
|
export async function buildTelegramPromptTurn(
|
|
377
433
|
options: BuildTelegramPromptTurnOptions,
|
|
378
434
|
): Promise<PendingTelegramTurn> {
|
|
@@ -380,6 +436,12 @@ export async function buildTelegramPromptTurn(
|
|
|
380
436
|
if (!firstMessage) {
|
|
381
437
|
throw new Error("Missing Telegram message for turn creation");
|
|
382
438
|
}
|
|
439
|
+
const hasVoiceFile = options.files.some(
|
|
440
|
+
(f) => f.kind === "voice" || f.kind === "audio",
|
|
441
|
+
);
|
|
442
|
+
const voiceReplyMode = options.voiceReplyMode ?? getTelegramVoiceReplyMode();
|
|
443
|
+
const showVoiceContext =
|
|
444
|
+
options.voiceReplyModeConfigured ?? options.voiceReplyMode !== undefined;
|
|
383
445
|
const content: TelegramPromptContent[] = [
|
|
384
446
|
{
|
|
385
447
|
type: "text",
|
|
@@ -390,6 +452,10 @@ export async function buildTelegramPromptTurn(
|
|
|
390
452
|
promptFiles: options.promptFiles,
|
|
391
453
|
handlerOutputs: options.handlerOutputs,
|
|
392
454
|
historyTurns: options.historyTurns,
|
|
455
|
+
timeLine: options.timeLine,
|
|
456
|
+
voiceContext: showVoiceContext
|
|
457
|
+
? getTelegramVoicePromptContext(voiceReplyMode, hasVoiceFile)
|
|
458
|
+
: undefined,
|
|
393
459
|
}),
|
|
394
460
|
},
|
|
395
461
|
];
|
|
@@ -404,6 +470,15 @@ export async function buildTelegramPromptTurn(
|
|
|
404
470
|
mimeType: mediaType,
|
|
405
471
|
});
|
|
406
472
|
}
|
|
473
|
+
if (options.voicePromptContribution?.trim()) {
|
|
474
|
+
const textItem = content.find((c) => c.type === "text") as
|
|
475
|
+
| { type: "text"; text: string }
|
|
476
|
+
| undefined;
|
|
477
|
+
if (textItem) {
|
|
478
|
+
textItem.text = `${textItem.text}\n\n${options.voicePromptContribution.trim()}`;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
407
482
|
return {
|
|
408
483
|
kind: "prompt",
|
|
409
484
|
chatId: firstMessage.chat.id,
|
|
@@ -424,6 +499,8 @@ export async function buildTelegramPromptTurn(
|
|
|
424
499
|
options.promptFiles ?? options.files,
|
|
425
500
|
options.handlerOutputs,
|
|
426
501
|
),
|
|
502
|
+
// Voice tagging (used for preview suppression and prompt guidance)
|
|
503
|
+
...computeVoiceTurnFlags(voiceReplyMode, hasVoiceFile),
|
|
427
504
|
};
|
|
428
505
|
}
|
|
429
506
|
|