chore: fork @llblab/[email protected].0 as @evandrodevbr/oh-my-pi-telegram
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s

Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
This commit is contained in:
Evandro Fonseca Junior committed 2026-07-15 14:18:16 -03:00
commit d1d04001d0
155 files changed
+107848

No files matched your search

+683
View File
@@ -0,0 +1,683 @@
/**
* Telegram activity lifecycle normalization and extension dispatch
* Zones: pi agent lifecycle, extension API, operational delivery
* Owns stable handler registration, evidence-based activity/source identity, assistant segment and reasoning normalization, executed-tool and compaction events, isolated non-blocking queues, shutdown fencing, diagnostics, and fresh delivery contexts; excludes Pi hook wiring, visibility policy, Telegram rendering, and consumer-extension behavior
*/
import {
deleteTelegramView,
editTelegramView,
sendTelegramChatAction,
sendTelegramView,
type TelegramDeliveryChatAction,
type TelegramDeliveryHandle,
type TelegramDeliveryResult,
type TelegramDeliveryScope,
type TelegramDeliveryTarget,
type TelegramDeliveryView,
} from "./delivery.ts";
const TELEGRAM_ACTIVITY_REGISTRY_KEY = "__piTelegramActivityRegistry__";
export type TelegramActivitySource =
| "telegram"
| "local"
| "autonomous"
| "unknown";
export type TelegramActivityTarget = Readonly<TelegramDeliveryTarget>;
export interface TelegramActivityEnvelope {
activityId: string;
sequence: number;
source: TelegramActivitySource;
target?: TelegramActivityTarget;
timestamp: number;
}
export type TelegramActivityPayload =
| { type: "agent-start" }
| {
type: "assistant-text-delta";
contentIndex: number;
delta: string;
}
| {
type: "assistant-segment";
contentIndex: number;
text: string;
placement: "intermediate" | "final" | "terminal-partial";
}
| {
type: "reasoning-delta";
contentIndex: number;
delta: string;
}
| {
type: "reasoning-end";
contentIndex: number;
text: string;
}
| {
type: "tool-start";
toolCallId: string;
toolName: string;
args: unknown;
}
| {
type: "tool-update";
toolCallId: string;
toolName: string;
update: unknown;
}
| {
type: "tool-end";
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
}
| {
type: "compaction-start";
reason: "manual" | "threshold" | "overflow" | "unknown";
}
| {
type: "compaction-end";
reason: "manual" | "threshold" | "overflow" | "unknown";
}
| { type: "agent-end" }
| { type: "agent-settled" };
export type TelegramActivityEvent = TelegramActivityEnvelope &
TelegramActivityPayload;
export interface TelegramActivityContext {
activityId: string;
sequence: number;
source: TelegramActivitySource;
defaultScope: TelegramDeliveryScope;
send: (
view: TelegramDeliveryView,
options?: {
scope?: TelegramDeliveryScope;
replyToMessageId?: number;
},
) => Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
edit: (
handle: TelegramDeliveryHandle,
view: TelegramDeliveryView,
) => Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
delete: (
handle: TelegramDeliveryHandle,
) => Promise<TelegramDeliveryResult<void>>;
chatAction: (
action: TelegramDeliveryChatAction,
options?: { scope?: TelegramDeliveryScope },
) => Promise<TelegramDeliveryResult<void>>;
}
export interface TelegramActivityHandlerRegistration {
id: string;
order?: number;
handle: (
event: TelegramActivityEvent,
ctx: TelegramActivityContext,
) => void | Promise<void>;
}
interface RegisteredTelegramActivityHandler
extends TelegramActivityHandlerRegistration {
id: string;
order: number;
}
interface TelegramActivityRegistry {
handlers: Map<string, RegisteredTelegramActivityHandler>;
}
function getOrCreateTelegramActivityRegistry(): TelegramActivityRegistry {
const globals = globalThis as Record<string, unknown>;
const existing = globals[TELEGRAM_ACTIVITY_REGISTRY_KEY];
if (
existing &&
typeof existing === "object" &&
"handlers" in existing &&
existing.handlers instanceof Map
) {
return existing as TelegramActivityRegistry;
}
const registry: TelegramActivityRegistry = { handlers: new Map() };
globals[TELEGRAM_ACTIVITY_REGISTRY_KEY] = registry;
return registry;
}
export function registerTelegramActivityHandler(
registration: TelegramActivityHandlerRegistration,
): () => void {
const id = registration.id.trim();
if (!id) throw new Error("Telegram activity handler id is required.");
const registry = getOrCreateTelegramActivityRegistry();
if (registry.handlers.has(id)) {
throw new Error(`Telegram activity handler is already registered: ${id}`);
}
const handler: RegisteredTelegramActivityHandler = {
...registration,
id,
order: registration.order ?? 0,
};
registry.handlers.set(id, handler);
return () => {
if (registry.handlers.get(id) === handler) registry.handlers.delete(id);
};
}
/** @internal */
export function clearTelegramActivityHandlers(): void {
getOrCreateTelegramActivityRegistry().handlers.clear();
}
function getTelegramActivityHandlers(): RegisteredTelegramActivityHandler[] {
return Array.from(
getOrCreateTelegramActivityRegistry().handlers.values(),
).sort(function (left, right) {
return left.order - right.order || left.id.localeCompare(right.id);
});
}
function cloneActivityTarget(
target: TelegramActivityTarget,
): TelegramActivityTarget {
return Object.freeze(
target.threadId === undefined
? { chatId: target.chatId }
: { chatId: target.chatId, threadId: target.threadId },
);
}
function createTelegramActivityContext(
event: TelegramActivityEvent,
isActive: () => boolean,
): TelegramActivityContext {
const defaultScope: TelegramDeliveryScope = event.target
? { kind: "target", target: cloneActivityTarget(event.target) }
: event.source === "telegram"
? { kind: "active-turn" }
: { kind: "instance" };
const inactive = <T>(): Promise<TelegramDeliveryResult<T>> =>
Promise.resolve({
ok: false,
reason: "runtime-unavailable",
message: "Telegram activity context belongs to an inactive session.",
});
return {
activityId: event.activityId,
sequence: event.sequence,
source: event.source,
defaultScope,
send(view, options) {
if (!isActive()) return inactive();
return sendTelegramView(view, {
scope: options?.scope ?? defaultScope,
replyToMessageId: options?.replyToMessageId,
});
},
edit(handle, view) {
return isActive() ? editTelegramView(handle, view) : inactive();
},
delete(handle) {
return isActive() ? deleteTelegramView(handle) : inactive();
},
chatAction(action, options) {
if (!isActive()) return inactive();
return sendTelegramChatAction(action, {
scope: options?.scope ?? defaultScope,
});
},
};
}
interface TelegramActivityHandlerQueue {
registration: RegisteredTelegramActivityHandler;
events: TelegramActivityEvent[];
running: boolean;
active: boolean;
}
function canCoalesceActivityEvents(
previous: TelegramActivityEvent,
next: TelegramActivityEvent,
): boolean {
if (
previous.activityId !== next.activityId ||
previous.type !== next.type
) {
return false;
}
if (
previous.type === "assistant-text-delta" &&
next.type === "assistant-text-delta"
) {
return previous.contentIndex === next.contentIndex;
}
if (
previous.type === "reasoning-delta" &&
next.type === "reasoning-delta"
) {
return previous.contentIndex === next.contentIndex;
}
if (previous.type === "tool-update" && next.type === "tool-update") {
return previous.toolCallId === next.toolCallId;
}
return false;
}
function coalesceActivityEvents(
previous: TelegramActivityEvent,
next: TelegramActivityEvent,
): TelegramActivityEvent {
if (
previous.type === "assistant-text-delta" &&
next.type === "assistant-text-delta"
) {
return { ...next, delta: previous.delta + next.delta };
}
if (
previous.type === "reasoning-delta" &&
next.type === "reasoning-delta"
) {
return { ...next, delta: previous.delta + next.delta };
}
return next;
}
/** @internal */
export interface TelegramActivityDispatcher {
dispatch: (event: TelegramActivityEvent) => void;
stop: () => void;
}
/** @internal */
export function createTelegramActivityDispatcher(deps: {
recordFailure?: (
handlerId: string,
event: TelegramActivityEvent,
error: unknown,
) => void;
} = {}): TelegramActivityDispatcher {
const queues = new Map<string, TelegramActivityHandlerQueue>();
let stopped = false;
const drain = async (queue: TelegramActivityHandlerQueue): Promise<void> => {
if (queue.running || !queue.active) return;
queue.running = true;
try {
while (queue.active) {
const event = queue.events.shift();
if (!event) break;
if (
getOrCreateTelegramActivityRegistry().handlers.get(
queue.registration.id,
) !== queue.registration
) {
queue.active = false;
queue.events = [];
break;
}
try {
await queue.registration.handle(
event,
createTelegramActivityContext(event, () =>
queue.active &&
!stopped &&
getOrCreateTelegramActivityRegistry().handlers.get(
queue.registration.id,
) === queue.registration,
),
);
} catch (error) {
deps.recordFailure?.(queue.registration.id, event, error);
}
}
} finally {
queue.running = false;
}
};
return {
dispatch(event) {
if (stopped) return;
for (const registration of getTelegramActivityHandlers()) {
let queue = queues.get(registration.id);
if (!queue || queue.registration !== registration) {
queue = {
registration,
events: [],
running: false,
active: true,
};
queues.set(registration.id, queue);
}
const previous = queue.events.at(-1);
if (previous && canCoalesceActivityEvents(previous, event)) {
queue.events[queue.events.length - 1] = coalesceActivityEvents(
previous,
event,
);
} else {
queue.events.push(event);
}
queueMicrotask(function () {
void drain(queue!);
});
}
},
stop() {
stopped = true;
for (const queue of queues.values()) {
queue.active = false;
queue.events = [];
}
queues.clear();
},
};
}
/** @internal */
export function createTelegramActivityBridgeRuntime(deps: {
generation: string;
recordFailure?: (
handlerId: string,
event: TelegramActivityEvent,
error: unknown,
) => void;
now?: () => number;
}): TelegramActivityRuntime {
let generationSequence = 0;
let runtime: TelegramActivityRuntime | undefined;
const getRuntime = (): TelegramActivityRuntime | undefined => runtime;
return {
onSessionStart() {
runtime?.onSessionShutdown();
runtime = createTelegramActivityRuntime({
generation: `${deps.generation}:${++generationSequence}`,
dispatcher: createTelegramActivityDispatcher({
recordFailure: deps.recordFailure,
}),
now: deps.now,
});
},
recordInputSource(source) {
getRuntime()?.recordInputSource(source);
},
onAgentStart(target) {
getRuntime()?.onAgentStart(target);
},
onAssistantEvent(event) {
getRuntime()?.onAssistantEvent(event);
},
onToolStart(event) {
getRuntime()?.onToolStart(event);
},
onToolUpdate(event) {
getRuntime()?.onToolUpdate(event);
},
onToolEnd(event) {
getRuntime()?.onToolEnd(event);
},
onCompactionStart(reason) {
getRuntime()?.onCompactionStart(reason);
},
onCompactionEnd(reason) {
getRuntime()?.onCompactionEnd(reason);
},
onCompactionAbandoned() {
getRuntime()?.onCompactionAbandoned();
},
onAgentEnd() {
getRuntime()?.onAgentEnd();
},
onAgentSettled() {
getRuntime()?.onAgentSettled();
},
onSessionShutdown() {
runtime?.onSessionShutdown();
runtime = undefined;
},
};
}
export type TelegramActivityInputSource =
| "interactive"
| "rpc"
| "extension"
| "unknown";
export type TelegramAssistantStreamEvent =
| { type: "start" }
| { type: "text_start"; contentIndex: number }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| { type: "thinking_end"; contentIndex: number; content: string }
| { type: "thinking_start"; contentIndex: number }
| { type: "toolcall_start"; contentIndex: number }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number }
| { type: "done" }
| { type: "error" };
/** @internal */
export interface TelegramActivityRuntime {
onSessionStart?: () => void;
recordInputSource: (source: TelegramActivityInputSource) => void;
onAgentStart: (activeTelegramTarget?: TelegramActivityTarget) => void;
onAssistantEvent: (event: TelegramAssistantStreamEvent) => void;
onToolStart: (event: {
toolCallId: string;
toolName: string;
args: unknown;
}) => void;
onToolUpdate: (event: {
toolCallId: string;
toolName: string;
update: unknown;
}) => void;
onToolEnd: (event: {
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
}) => void;
onCompactionStart: (
reason: "manual" | "threshold" | "overflow" | "unknown",
) => void;
onCompactionEnd: (
reason: "manual" | "threshold" | "overflow" | "unknown",
) => void;
onCompactionAbandoned: () => void;
onAgentEnd: () => void;
onAgentSettled: () => void;
onSessionShutdown: () => void;
}
interface PendingAssistantSegment {
contentIndex: number;
text: string;
}
/** @internal */
export function createTelegramActivityRuntime(deps: {
generation: string;
dispatcher: TelegramActivityDispatcher;
now?: () => number;
}): TelegramActivityRuntime {
const now = deps.now ?? Date.now;
let nextActivityNumber = 0;
let activityId: string | undefined;
let activitySource: TelegramActivitySource = "unknown";
let activityTarget: TelegramActivityTarget | undefined;
let sequence = 0;
let pendingInputSource: TelegramActivityInputSource = "unknown";
let pendingAssistantSegment: PendingAssistantSegment | undefined;
let compactionInProgress = false;
let compactionOwnedActivity = false;
const ensureActivity = (
activeTelegramTarget?: TelegramActivityTarget,
): string => {
if (activityId) return activityId;
nextActivityNumber += 1;
activityId = `${deps.generation}:${nextActivityNumber}`;
activitySource = activeTelegramTarget
? "telegram"
: pendingInputSource === "interactive" || pendingInputSource === "rpc"
? "local"
: pendingInputSource === "extension"
? "autonomous"
: "unknown";
activityTarget = activeTelegramTarget
? cloneActivityTarget(activeTelegramTarget)
: undefined;
sequence = 0;
pendingInputSource = "unknown";
return activityId;
};
const emit = (event: TelegramActivityPayload): void => {
const currentActivityId = ensureActivity();
sequence += 1;
deps.dispatcher.dispatch({
...event,
activityId: currentActivityId,
sequence,
source: activitySource,
...(activityTarget ? { target: activityTarget } : {}),
timestamp: now(),
} as TelegramActivityEvent);
};
const flushPendingSegment = (
placement: "intermediate" | "final" | "terminal-partial",
): void => {
const segment = pendingAssistantSegment;
pendingAssistantSegment = undefined;
if (!segment?.text.trim()) return;
emit({
type: "assistant-segment",
contentIndex: segment.contentIndex,
text: segment.text,
placement,
});
};
const clearActivity = (): void => {
activityId = undefined;
activitySource = "unknown";
activityTarget = undefined;
sequence = 0;
pendingAssistantSegment = undefined;
compactionInProgress = false;
compactionOwnedActivity = false;
};
const abandonCompaction = (): void => {
if (!compactionInProgress) return;
const shouldClearActivity = compactionOwnedActivity;
compactionInProgress = false;
compactionOwnedActivity = false;
if (shouldClearActivity) clearActivity();
};
return {
recordInputSource(source) {
pendingInputSource = source;
},
onAgentStart(activeTelegramTarget) {
abandonCompaction();
ensureActivity(activeTelegramTarget);
emit({ type: "agent-start" });
},
onAssistantEvent(event) {
if (event.type === "text_start") {
flushPendingSegment("intermediate");
return;
}
if (event.type === "text_delta") {
if (!event.delta) return;
emit({
type: "assistant-text-delta",
contentIndex: event.contentIndex,
delta: event.delta,
});
return;
}
if (event.type === "text_end") {
pendingAssistantSegment = {
contentIndex: event.contentIndex,
text: event.content,
};
return;
}
if (event.type === "thinking_delta") {
if (!event.delta) return;
emit({
type: "reasoning-delta",
contentIndex: event.contentIndex,
delta: event.delta,
});
return;
}
if (event.type === "thinking_end") {
if (!event.content.trim()) return;
emit({
type: "reasoning-end",
contentIndex: event.contentIndex,
text: event.content,
});
return;
}
if (event.type === "toolcall_start") {
flushPendingSegment("intermediate");
return;
}
if (event.type === "done") {
flushPendingSegment("final");
return;
}
if (event.type === "error") flushPendingSegment("terminal-partial");
},
onToolStart(event) {
emit({ type: "tool-start", ...event });
},
onToolUpdate(event) {
emit({ type: "tool-update", ...event });
},
onToolEnd(event) {
emit({ type: "tool-end", ...event });
},
onCompactionStart(reason) {
abandonCompaction();
compactionOwnedActivity = !activityId;
compactionInProgress = true;
ensureActivity();
emit({ type: "compaction-start", reason });
},
onCompactionEnd(reason) {
if (!compactionInProgress || !activityId) return;
const shouldClearActivity = compactionOwnedActivity;
compactionInProgress = false;
compactionOwnedActivity = false;
emit({ type: "compaction-end", reason });
if (shouldClearActivity) clearActivity();
},
onCompactionAbandoned() {
abandonCompaction();
},
onAgentEnd() {
if (activityId) emit({ type: "agent-end" });
},
onAgentSettled() {
if (!activityId) return;
flushPendingSegment("terminal-partial");
emit({ type: "agent-settled" });
clearActivity();
},
onSessionShutdown() {
pendingInputSource = "unknown";
clearActivity();
deps.dispatcher.stop();
},
};
}
+648
View File
@@ -0,0 +1,648 @@
/**
* Telegram bridge binding composition
* Zones: telegram, pi agent, orchestration
* Owns pi-facing tool, command, and lifecycle hook registration for the entrypoint
*/
import * as Activity from "./activity.ts";
import * as CommandTemplates from "./command-templates.ts";
import * as Commands from "./commands.ts";
import * as Config from "./config.ts";
import * as Keyboard from "./keyboard.ts";
import * as Lifecycle from "./lifecycle.ts";
import * as Locks from "./locks.ts";
import * as Model from "./model.ts";
import * as OutboundAttachments from "./outbound-attachments.ts";
import * as OutboundHandlers from "./outbound.ts";
import * as Pi from "./pi.ts";
import * as Preview from "./preview.ts";
import * as Prompts from "./prompts.ts";
import * as Queue from "./queue.ts";
import * as Replies from "./replies.ts";
import * as Runtime from "./runtime.ts";
import * as Setup from "./setup.ts";
import * as Status from "./status.ts";
import * as TelegramApi from "./telegram-api.ts";
type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
type TelegramRuntimeEventRecorder = (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
type TelegramBridgeStatusUpdater =
Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
interface TelegramCommandsAndToolsBindingDeps {
pi: Pi.ExtensionAPI;
configStore: Config.TelegramConfigStore;
persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
setup: Setup.TelegramSetupGuard;
activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
stopPolling?: () => Promise<void | string>;
onTransportChanged?: () => Promise<void> | void;
getStatusLines: (
options?: Status.TelegramBridgeStatusLineOptions,
) => string[];
buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: unknown },
) => Promise<number | undefined>;
callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
getDefaultChatId: () => number | undefined;
getDefaultTarget?: () => OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"];
canSendDirect: () => boolean;
updateStatus: TelegramBridgeStatusUpdater;
recordRuntimeEvent: TelegramRuntimeEventRecorder;
}
export function registerTelegramCommandsAndTools({
pi,
configStore,
persistConfig,
setup,
activeTurnRuntime,
lockedPollingRuntime,
stopPolling,
onTransportChanged,
getStatusLines,
buttonActionStore,
sendMarkdownReply,
callMultipart,
getDefaultChatId,
getDefaultTarget,
canSendDirect,
recordRuntimeEvent,
updateStatus,
}: TelegramCommandsAndToolsBindingDeps): void {
OutboundAttachments.registerTelegramOutboundAttachmentTool(pi, {
getActiveTurn: activeTurnRuntime.get,
getDefaultChatId,
getDefaultTarget,
canSendDirect,
sendMultipart: callMultipart,
recordRuntimeEvent,
});
OutboundAttachments.registerTelegramOutboundMessageTool(pi, {
getDefaultChatId,
getDefaultTarget,
canSendDirect,
planMessage:
OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore),
sendMarkdownMessage: (chatId, markdown, options) =>
sendMarkdownReply(chatId, undefined, markdown, options),
recordRuntimeEvent,
});
Prompts.registerTelegramHelpTool(pi, {
getActiveProfileName: configStore.getActiveProfileName,
});
Commands.registerTelegramBridgeCommands(pi, {
promptForConfig: async (ctx, profileName) => {
const nextProfileName = profileName ?? undefined;
if (profileName && !Config.isValidTelegramProfileName(profileName)) {
ctx.ui.notify(`Invalid Telegram profile name: ${profileName}`, "error");
return;
}
const previousProfileName = configStore.getActiveProfileName();
let setupConfigStore = configStore;
let persistSetupConfig = persistConfig;
if (!profileName) {
if (previousProfileName !== nextProfileName) {
await (stopPolling ?? lockedPollingRuntime.stop)();
}
configStore.activateProfile(undefined);
await onTransportChanged?.();
} else {
const storedConfig = configStore.getStoredConfig();
setupConfigStore = Config.createTelegramConfigStore({
initialConfig: {
...storedConfig,
profiles: {
...(storedConfig.profiles ?? {}),
[profileName]: storedConfig.profiles?.[profileName] ?? {
botToken: "",
},
},
},
});
setupConfigStore.activateProfile(profileName);
persistSetupConfig = async () => {
try {
if (previousProfileName !== profileName) {
await (stopPolling ?? lockedPollingRuntime.stop)();
}
const profile = Config.getTelegramProfileFields(
setupConfigStore.get(),
);
if (!profile) {
throw new Error(
`Telegram profile "${profileName}" has no token.`,
);
}
await configStore.load();
const latestConfig = configStore.getStoredConfig();
configStore.activateProfile(undefined);
configStore.set({
...latestConfig,
profiles: {
...(latestConfig.profiles ?? {}),
[profileName]: profile,
},
});
configStore.activateProfile(profileName);
await onTransportChanged?.();
await persistConfig(configStore.get());
} catch (error) {
await configStore.load().catch(() => undefined);
configStore.activateProfile(previousProfileName);
await onTransportChanged?.();
throw error;
}
};
}
const runSetup = Setup.createTelegramSetupPromptRuntime({
getConfig: setupConfigStore.get,
setConfig: setupConfigStore.set,
setupGuard: setup,
getMe: TelegramApi.fetchTelegramBotIdentity,
persistConfig: persistSetupConfig,
startPolling: lockedPollingRuntime.start,
updateStatus,
recordRuntimeEvent,
});
const completion = await runSetup(ctx);
if (profileName && completion.status === "success") {
ctx.ui.notify(`Profile "${profileName}" saved and connected.`, "info");
}
},
getStatusLines,
reloadConfig: configStore.load,
hasBotToken: configStore.hasBotToken,
startPolling: lockedPollingRuntime.start,
stopPolling: stopPolling ?? lockedPollingRuntime.stop,
updateStatus,
getProfileNames: () =>
Config.getTelegramProfileNames(configStore.getStoredConfig()),
activateDefaultProfileConfig: async () => {
const previousProfileName = configStore.getActiveProfileName();
await configStore.load();
if (previousProfileName) {
await (stopPolling ?? lockedPollingRuntime.stop)();
}
configStore.activateProfile(undefined);
await onTransportChanged?.();
},
activateProfileConfig: async (_ctx, profileName) => {
const previousProfileName = configStore.getActiveProfileName();
await configStore.load();
if (!Config.isValidTelegramProfileName(profileName)) return false;
const storedConfig = configStore.getStoredConfig();
if (!storedConfig.profiles?.[profileName]) return false;
if (previousProfileName !== profileName) {
await (stopPolling ?? lockedPollingRuntime.stop)();
}
if (!configStore.activateProfile(profileName)) return false;
await onTransportChanged?.();
return true;
},
});
}
interface TelegramLifecycleBindingDeps {
pi: Pi.ExtensionAPI;
activityRuntime: Activity.TelegramActivityRuntime;
sessionLifecycleRuntime: Pick<
Lifecycle.TelegramLifecycleRegistrationDeps,
"onSessionStart" | "onSessionShutdown" | "onModelSelect"
>;
configStore: Pick<
Config.TelegramConfigStore,
"get" | "getOutboundHandlers" | "hasBotToken" | "load"
>;
abort: Runtime.TelegramRuntimeAbortPort;
typing: Runtime.TelegramRuntimeTypingPort;
lifecycle: Runtime.TelegramRuntimeLifecyclePort;
activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
telegramQueueStore: Queue.TelegramQueueStore<Pi.ExtensionContext>;
modelSwitchController: Model.TelegramModelSwitchController<
Pi.ExtensionContext,
Model.ScopedTelegramModel<ActivePiModel>
>;
previewRuntime: Preview.TelegramAssistantPreviewRuntime<
Pi.AgentEndEvent["messages"][number],
Keyboard.TelegramInlineKeyboardMarkup
>;
promptDispatchRuntime: Runtime.TelegramPromptDispatchRuntime<Pi.ExtensionContext>;
deferredQueueDispatchRuntime: Queue.TelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>;
lockOwnershipGuard: Pick<
Locks.TelegramLockOwnershipGuard<Pi.ExtensionContext>,
"ownsContext"
>;
buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
sendChatAction: NonNullable<
OutboundHandlers.TelegramVoiceReplySenderDeps["sendChatAction"]
>;
sendRecordVoiceAction: NonNullable<
OutboundHandlers.TelegramVoiceReplySenderDeps["sendRecordVoiceAction"]
>;
sendMarkdownReply: Queue.TelegramAgentEndHookRuntimeDeps<
Queue.PendingTelegramTurn,
Pi.ExtensionContext,
Pi.AgentEndEvent["messages"][number],
Keyboard.TelegramInlineKeyboardMarkup
>["sendMarkdownReply"];
sendTextReply: Queue.TelegramAgentEndHookRuntimeDeps<
Queue.PendingTelegramTurn,
Pi.ExtensionContext,
Pi.AgentEndEvent["messages"][number],
Keyboard.TelegramInlineKeyboardMarkup
>["sendTextReply"] &
NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
sendGuestReply: NonNullable<
Queue.TelegramAgentEndHookRuntimeDeps<
Queue.PendingTelegramTurn,
Pi.ExtensionContext,
Pi.AgentEndEvent["messages"][number],
Keyboard.TelegramInlineKeyboardMarkup
>["sendGuestReply"]
>;
finalizeMarkdownPreview: Queue.TelegramAgentEndHookRuntimeDeps<
Queue.PendingTelegramTurn,
Pi.ExtensionContext,
Pi.AgentEndEvent["messages"][number],
Keyboard.TelegramInlineKeyboardMarkup
>["finalizeMarkdownPreview"];
proactivePushChatIdGetter: () => number | undefined;
proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
isProactivePushEnabled: () => boolean;
canSendProactivePush: (ctx: Pi.ExtensionContext) => boolean;
canSendAgentActivity: (ctx: Pi.ExtensionContext) => boolean;
isSessionContextActive: (ctx: Pi.ExtensionContext) => boolean;
isTurnTransportActive?: (turn: Queue.PendingTelegramTurn) => boolean;
updateStatus: TelegramBridgeStatusUpdater;
recordRuntimeEvent: TelegramRuntimeEventRecorder;
}
export function registerTelegramLifecycleRuntimeHooks({
pi,
activityRuntime,
sessionLifecycleRuntime,
configStore,
abort,
typing,
lifecycle,
activeTurnRuntime,
telegramQueueStore,
modelSwitchController,
previewRuntime,
promptDispatchRuntime,
deferredQueueDispatchRuntime,
lockOwnershipGuard,
buttonActionStore,
callMultipart,
sendChatAction,
sendRecordVoiceAction,
sendMarkdownReply,
sendTextReply,
dispatchNextQueuedTelegramTurn,
answerGuestQuery,
deleteMessage,
sendGuestReply,
finalizeMarkdownPreview,
proactivePushChatIdGetter,
proactivePushTargetGetter,
isProactivePushEnabled,
canSendProactivePush,
canSendAgentActivity,
isSessionContextActive = () => true,
isTurnTransportActive,
updateStatus,
recordRuntimeEvent,
}: TelegramLifecycleBindingDeps): void {
const agentEndResetter = Runtime.createTelegramAgentEndResetter({
abort,
typing,
clearActiveTurn: activeTurnRuntime.clear,
resetToolExecutions: lifecycle.resetActiveToolExecutions,
clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
clearDispatchPending: lifecycle.clearDispatchPending,
});
const queuedAttachmentSender =
OutboundAttachments.createTelegramQueuedOutboundAttachmentSender({
sendMultipart: callMultipart,
sendTextReply,
recordRuntimeEvent,
});
const sendGuestAttachment = async (
turn: Queue.PendingTelegramTurn,
attachment: Queue.QueuedAttachment,
caption?: string,
): Promise<void> => {
const stagingTarget = proactivePushTargetGetter();
const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
if (stagingChatId === undefined) {
throw new Error(
"Guest attachment staging requires a paired Telegram chat",
);
}
await OutboundAttachments.deliverTelegramGuestCachedAttachment({
guestQueryId: turn.guestQueryId!,
stagingChatId,
stagingTarget,
attachment,
caption,
sendMultipart: callMultipart,
answerGuestQuery: (guestQueryId, result) =>
answerGuestQuery(guestQueryId, undefined, { result }),
answerGuestText: (guestQueryId, text) =>
answerGuestQuery(guestQueryId, text),
fallbackText:
caption ||
"Telegram bridge could not deliver the requested attachment.",
deleteMessage,
recordRuntimeEvent,
});
};
const outboundReplyPlanner =
OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
const voiceReplySenderDeps = {
execCommand: CommandTemplates.execCommandTemplate,
sendMultipart: callMultipart,
sendTextReply,
sendChatAction,
sendRecordVoiceAction,
getHandlers: configStore.getOutboundHandlers,
recordRuntimeEvent,
};
const outboundReplyArtifactSender =
OutboundHandlers.createTelegramOutboundReplyArtifactSender(
voiceReplySenderDeps,
);
const sendGuestVoiceReply = async (
turn: Queue.PendingTelegramTurn,
plan: OutboundHandlers.TelegramOutboundReplyPlan,
caption?: string,
): Promise<void> => {
const stagingTarget = proactivePushTargetGetter();
const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
if (stagingChatId === undefined) {
throw new Error("Guest voice staging requires a paired Telegram chat");
}
const guestVoiceSender =
OutboundHandlers.createTelegramOutboundReplyArtifactSender({
...voiceReplySenderDeps,
sendChatAction: undefined,
sendRecordVoiceAction: undefined,
sendMultipart: async (
_method,
_fields,
_fileField,
filePath,
fileName,
) => {
try {
await OutboundAttachments.deliverTelegramGuestCachedAttachment({
guestQueryId: turn.guestQueryId!,
stagingChatId,
stagingTarget,
attachment: { path: filePath, fileName },
caption,
sendMultipart: callMultipart,
answerGuestQuery: (guestQueryId, result) =>
answerGuestQuery(guestQueryId, undefined, { result }),
answerGuestText: (guestQueryId, text) =>
answerGuestQuery(guestQueryId, text),
fallbackText:
caption || "Telegram bridge could not deliver the voice reply.",
deleteMessage,
recordRuntimeEvent,
});
} catch (error) {
recordRuntimeEvent("delivery", error, {
phase: "guest-voice-answer",
guestQueryId: turn.guestQueryId,
});
}
return {};
},
});
await guestVoiceSender(
turn,
{
...plan,
...(plan.voiceReplies?.length
? { voiceReplies: [plan.voiceReplies[0]!] }
: {}),
},
{ replyToPrompt: false },
);
};
const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
Queue.PendingTelegramTurn,
Pi.ExtensionContext,
unknown,
Keyboard.TelegramInlineKeyboardMarkup
>({
setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(abort),
getQueuedItems: telegramQueueStore.getQueuedItems,
hasPendingDispatch: lifecycle.hasDispatchPending,
hasActiveTurn: activeTurnRuntime.has,
resetToolExecutions: lifecycle.resetActiveToolExecutions,
resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
setQueuedItems: telegramQueueStore.setQueuedItems,
clearDispatchPending: lifecycle.clearDispatchPending,
setFoldQueuedPromptsIntoHistory: lifecycle.setFoldQueuedPromptsIntoHistory,
setActiveTurn: activeTurnRuntime.set,
createPreviewState: previewRuntime.resetState,
startTypingLoop: (ctx) => {
const turn = activeTurnRuntime.get();
promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId, {
target: turn?.target,
});
},
updateStatus,
getActiveTurn: activeTurnRuntime.get,
loadConfig: configStore.load,
extractAssistant: Replies.extractLatestAssistantMessageText,
getFoldQueuedPromptsIntoHistory:
lifecycle.shouldFoldQueuedPromptsIntoHistory,
resetRuntimeState: agentEndResetter,
isSessionActive: isSessionContextActive,
isTurnTransportActive,
waitForTypingIdle: typing.waitForIdle,
dispatchNextQueuedTelegramTurn,
requestDeferredDispatchNextQueuedTelegramTurn:
deferredQueueDispatchRuntime.request,
scheduleActiveTurnDelivery(task) {
const timer = setTimeout(() => {
void task().catch((error) => {
recordRuntimeEvent("delivery", error, {
phase: "agent-end-background-delivery",
});
});
}, 0);
timer.unref?.();
},
clearPreview: previewRuntime.clear,
setPreviewPendingText: previewRuntime.setPendingText,
finalizeMarkdownPreview,
sendMarkdownReply,
sendTextReply,
sendQueuedAttachments: queuedAttachmentSender,
answerGuestQuery,
sendGuestReply,
sendGuestAttachment,
sendGuestVoiceReply,
planOutboundReply: outboundReplyPlanner,
sendOutboundReplyArtifacts: outboundReplyArtifactSender,
getDefaultChatId: proactivePushChatIdGetter,
getDefaultTarget: proactivePushTargetGetter,
isProactivePushEnabled,
canSendProactivePush,
recordRuntimeEvent,
getActiveToolExecutions: lifecycle.getActiveToolExecutions,
setActiveToolExecutions: lifecycle.setActiveToolExecutions,
triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
});
Lifecycle.setResetTransportReplyDedup(Replies.resetTransportReplyDedup);
const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
agentLifecycleHooks.onAgentStart,
);
const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): boolean => {
if (!canSendAgentActivity(ctx)) return false;
const turn = activeTurnRuntime.get();
const target = turn?.target ?? proactivePushTargetGetter();
promptDispatchRuntime.startTypingLoop(
ctx,
turn?.chatId ?? target?.chatId ?? proactivePushChatIdGetter(),
{ target },
);
return true;
};
const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
const turn = activeTurnRuntime.get();
promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId, {
target: turn?.target,
});
};
const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
isContextActive: isSessionContextActive,
setCompactionInProgress: lifecycle.setCompactionInProgress,
updateStatus,
startTypingLoop: startAgentActivityTypingLoop,
stopTypingLoop: typing.stop,
requestDeferredDispatchNextQueuedTelegramTurn:
deferredQueueDispatchRuntime.request,
dispatchNextQueuedTelegramTurn,
recordRuntimeEvent,
onCompactionAbandoned: activityRuntime.onCompactionAbandoned,
});
const messageActivityTypingHooks =
Lifecycle.createTelegramMessageActivityTypingHooks({
hasActiveTurn: activeTurnRuntime.has,
startTypingLoop: startActiveTurnTypingLoop,
onMessageStart: previewRuntime.onMessageStart,
onMessageUpdate: previewRuntime.onMessageUpdate,
recordRuntimeEvent,
});
const messageActivityHooks = messageActivityTypingHooks;
Lifecycle.registerTelegramLifecycleHooks(pi, {
isSessionActive: isSessionContextActive,
...sessionLifecycleRuntime,
...agentLifecycleHooks,
onInput(event) {
activityRuntime.recordInputSource(event.source ?? "unknown");
},
async onSessionStart(event, ctx) {
previewRuntime.invalidate();
activityRuntime.onSessionStart?.();
await sessionLifecycleRuntime.onSessionStart(event, ctx);
},
async onSessionShutdown(event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onSessionShutdown();
compactionObserver.onSessionShutdown();
await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
},
onSessionBeforeCompact(event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onCompactionStart(Pi.getSessionCompactionReason(event));
compactionObserver.onSessionBeforeCompact(event, ctx);
},
onSessionCompact(event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onCompactionEnd(Pi.getSessionCompactionReason(event));
compactionObserver.onSessionCompact(event, ctx);
},
async onAgentStart(event, ctx) {
if (!isSessionContextActive(ctx)) return;
await agentStartWithDedupReset(event, ctx);
activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
startAgentActivityTypingLoop(ctx);
},
async onToolExecutionStart(event, ctx) {
if (!isSessionContextActive(ctx)) return;
agentLifecycleHooks.onToolExecutionStart();
activityRuntime.onToolStart({
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
});
},
onToolExecutionUpdate(event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onToolUpdate({
toolCallId: event.toolCallId,
toolName: event.toolName,
update: event.partialResult,
});
},
async onToolExecutionEnd(event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onToolEnd({
toolCallId: event.toolCallId,
toolName: event.toolName,
result: event.result,
isError: event.isError,
});
agentLifecycleHooks.onToolExecutionEnd(event, ctx);
},
async onMessageStart(event, ctx) {
if (!isSessionContextActive(ctx)) return;
await messageActivityHooks.onMessageStart(event, ctx);
},
async onMessageUpdate(event, ctx) {
if (!isSessionContextActive(ctx)) return;
if (event.assistantMessageEvent) {
activityRuntime.onAssistantEvent(
event.assistantMessageEvent as Activity.TelegramAssistantStreamEvent,
);
}
await messageActivityHooks.onMessageUpdate(event, ctx);
},
async onAgentEnd(event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onAgentEnd();
await agentLifecycleHooks.onAgentEnd(event, ctx);
},
onAgentSettled(_event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onAgentSettled();
},
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
isConfigured: configStore.hasBotToken,
isProactivePushEnabled,
isCurrentOwner: lockOwnershipGuard.ownsContext,
}),
});
}
+327
View File
@@ -0,0 +1,327 @@
/**
* Telegram bus-aware API runtime
* Zones: multi-instance bus, telegram api transport, live instance routing
* Wraps the direct Telegram Bot API runtime so follower instances can route outbound calls through the bus leader
*/
import { stripTelegramBusApiMetadata } from "./bus.ts";
import type {
TelegramAnswerGuestQueryOptions,
TelegramApiCallOptions,
TelegramBridgeApiRuntime,
TelegramEditMessageTextBody,
TelegramSendMessageBody,
TelegramSendMessageDraftBody,
TelegramSendRichMessageBody,
TelegramSendRichMessageDraftBody,
TelegramSentMessage,
TelegramUpdate,
} from "./telegram-api.ts";
export type TelegramBusApiCall = (
method: string,
args: unknown[],
) => Promise<unknown>;
export interface TelegramBusAwareApiRuntimeDeps {
directRuntime: TelegramBridgeApiRuntime;
ownsDirect: () => boolean;
callFollowerApi: TelegramBusApiCall;
getDefaultTarget?: () => { chatId: number; threadId?: number } | undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asBoolean(value: unknown): boolean {
return Boolean(value);
}
function asSentMessage(value: unknown): TelegramSentMessage {
return asRecord(value) as unknown as TelegramSentMessage;
}
function withDefaultThreadTarget(
body: Record<string, unknown>,
target: { chatId: number; threadId?: number } | undefined,
): Record<string, unknown> {
if (target?.threadId === undefined) return body;
if (body.message_thread_id !== undefined) return body;
return body.chat_id === target.chatId
? { ...body, message_thread_id: target.threadId }
: body;
}
function rejectTelegramDirectOwnership(method: string): Promise<never> {
return Promise.reject(
new Error(`Telegram ${method} requires direct transport ownership.`),
);
}
export function createTelegramAggregateTypingActionSender(
runtime: Pick<TelegramBridgeApiRuntime, "call">,
): (chatId: number) => Promise<unknown> {
return (chatId) =>
runtime.call("sendChatAction", { chat_id: chatId, action: "typing" });
}
export function createTelegramBusAwareApiRuntime(
deps: TelegramBusAwareApiRuntimeDeps,
): TelegramBridgeApiRuntime {
return {
call<TResponse>(
method: string,
body: Record<string, unknown>,
options?: TelegramApiCallOptions,
): Promise<TResponse> {
return deps.ownsDirect()
? deps.directRuntime.call<TResponse>(method, body, options)
: (deps.callFollowerApi("call", [
method,
body,
options,
]) as Promise<TResponse>);
},
callMultipart<TResponse>(
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
options?: TelegramApiCallOptions,
): Promise<TResponse> {
return deps.ownsDirect()
? deps.directRuntime.callMultipart<TResponse>(
method,
fields,
fileField,
filePath,
fileName,
options,
)
: (deps.callFollowerApi("callMultipart", [
method,
fields,
fileField,
filePath,
fileName,
options,
]) as Promise<TResponse>);
},
downloadFile(fileId: string, suggestedName: string): Promise<string> {
return deps.ownsDirect()
? deps.directRuntime.downloadFile(fileId, suggestedName)
: (deps.callFollowerApi("downloadFile", [
fileId,
suggestedName,
]) as Promise<string>);
},
deleteWebhook(signal?: AbortSignal): Promise<boolean> {
return deps.ownsDirect()
? deps.directRuntime.deleteWebhook(signal)
: rejectTelegramDirectOwnership("deleteWebhook");
},
getUpdates(
body: Record<string, unknown>,
signal?: AbortSignal,
): Promise<TelegramUpdate[]> {
return deps.ownsDirect()
? deps.directRuntime.getUpdates(body, signal)
: rejectTelegramDirectOwnership("getUpdates");
},
setMyCommands(commands): Promise<boolean> {
return deps.ownsDirect()
? deps.directRuntime.setMyCommands(commands)
: deps
.callFollowerApi("call", ["setMyCommands", { commands }])
.then(asBoolean);
},
sendChatAction(
chatId: number,
action: string,
options?: { message_thread_id?: number },
): Promise<boolean> {
const body = withDefaultThreadTarget(
{
chat_id: chatId,
action,
...(options?.message_thread_id !== undefined
? { message_thread_id: options.message_thread_id }
: {}),
},
deps.getDefaultTarget?.(),
);
return deps.ownsDirect()
? deps.directRuntime.sendChatAction(chatId, action, options)
: deps
.callFollowerApi("call", ["sendChatAction", body])
.then(asBoolean);
},
sendTypingAction(
chatId: number,
options?: { message_thread_id?: number },
): Promise<unknown> {
const body = withDefaultThreadTarget(
{
chat_id: chatId,
action: "typing",
...(options?.message_thread_id !== undefined
? { message_thread_id: options.message_thread_id }
: {}),
},
deps.getDefaultTarget?.(),
);
return deps.ownsDirect()
? deps.directRuntime.sendTypingAction(chatId, options)
: deps
.callFollowerApi("call", ["sendChatAction", body])
.then(asBoolean);
},
sendRecordVoiceAction(
chatId: number,
options?: { message_thread_id?: number },
): Promise<unknown> {
const body = withDefaultThreadTarget(
{
chat_id: chatId,
action: "record_voice",
...(options?.message_thread_id !== undefined
? { message_thread_id: options.message_thread_id }
: {}),
},
deps.getDefaultTarget?.(),
);
return deps.ownsDirect()
? deps.directRuntime.sendRecordVoiceAction(chatId, options)
: deps
.callFollowerApi("call", ["sendChatAction", body])
.then(asBoolean);
},
sendMessageDraft(
chatId: number,
draftId: number,
text?: string,
options?: TelegramSendMessageDraftBody extends Record<string, unknown>
? {
parse_mode?: string;
entities?: unknown[];
message_thread_id?: number;
}
: never,
): Promise<boolean> {
const body: Record<string, unknown> = {
chat_id: chatId,
draft_id: draftId,
};
if (text !== undefined) body.text = text;
if (options?.parse_mode !== undefined)
body.parse_mode = options.parse_mode;
if (options?.entities !== undefined) body.entities = options.entities;
if (options?.message_thread_id !== undefined) {
body.message_thread_id = options.message_thread_id;
}
const scopedBody = withDefaultThreadTarget(
body,
deps.getDefaultTarget?.(),
);
return deps.ownsDirect()
? deps.directRuntime.sendMessageDraft(chatId, draftId, text, options)
: deps
.callFollowerApi("call", ["sendMessageDraft", scopedBody])
.then(asBoolean);
},
sendMessage(body: TelegramSendMessageBody): Promise<TelegramSentMessage> {
return deps.ownsDirect()
? deps.directRuntime.sendMessage(stripTelegramBusApiMetadata(body))
: deps
.callFollowerApi("call", ["sendMessage", body])
.then(asSentMessage);
},
sendRichMessage(
body: TelegramSendRichMessageBody,
): Promise<TelegramSentMessage> {
return deps.ownsDirect()
? deps.directRuntime.sendRichMessage(body)
: deps
.callFollowerApi("call", ["sendRichMessage", body])
.then(asSentMessage);
},
sendRichMessageDraft(
body: TelegramSendRichMessageDraftBody,
): Promise<boolean> {
return deps.ownsDirect()
? deps.directRuntime.sendRichMessageDraft(body)
: deps
.callFollowerApi("call", ["sendRichMessageDraft", body])
.then(asBoolean);
},
async editMessageText(
body: TelegramEditMessageTextBody,
): Promise<"edited" | "unchanged"> {
if (deps.ownsDirect()) return deps.directRuntime.editMessageText(body);
await deps.callFollowerApi("call", ["editMessageText", body]);
return "edited";
},
async answerCallbackQuery(
callbackQueryId: string,
text?: string,
): Promise<void> {
if (deps.ownsDirect()) {
await deps.directRuntime.answerCallbackQuery(callbackQueryId, text);
return;
}
await deps.callFollowerApi("call", [
"answerCallbackQuery",
{
callback_query_id: callbackQueryId,
...(text !== undefined ? { text } : {}),
},
]);
},
async answerGuestQuery(
guestQueryId: string,
text?: string,
options?: TelegramAnswerGuestQueryOptions,
): Promise<void> {
if (deps.ownsDirect()) {
await deps.directRuntime.answerGuestQuery(guestQueryId, text, options);
return;
}
const body: Record<string, unknown> = { guest_query_id: guestQueryId };
if (options?.result) {
body.result = options.result;
} else if (text !== undefined || options?.richMessage) {
const inputContent: Record<string, unknown> = options?.richMessage
? { rich_message: options.richMessage }
: { message_text: text };
if (!options?.richMessage && options?.parseMode) {
inputContent.parse_mode = options.parseMode;
}
body.result = {
type: "article",
id: "1",
title: "Response",
input_message_content: inputContent,
};
}
await deps.callFollowerApi("call", ["answerGuestQuery", body]);
},
async deleteMessage(chatId: number, messageId: number): Promise<void> {
if (deps.ownsDirect())
return deps.directRuntime.deleteMessage(chatId, messageId);
await deps.callFollowerApi("call", [
"deleteMessage",
{
chat_id: chatId,
message_id: messageId,
},
]);
},
prepareTempDir(): Promise<number> {
return deps.directRuntime.prepareTempDir();
},
};
}
+1328
View File
File diff suppressed because it is too large. Load diff
+1366
View File
File diff suppressed because it is too large. Load diff
+251
View File
@@ -0,0 +1,251 @@
/**
* Telegram bus local transport boundary
* Zones: multi-instance bus, IPC transport, Windows named pipes, Unix sockets
* Owns endpoint derivation, transport-kind detection, retry/error classification, and small timing policy.
*/
import { createHash } from "node:crypto";
import { createConnection } from "node:net";
import { join, resolve } from "node:path";
export type TelegramBusTransportKind = "pipe" | "socket";
export type TelegramBusTransportEventRecorder = (
phase: string,
details: Record<string, unknown>,
) => void;
export type TelegramBusTransportEndpointDiagnostics = Record<
string,
unknown
> & {
endpoint: string;
transport: TelegramBusTransportKind;
};
export interface TelegramBusTransportRetryPolicy {
attempts: number;
delayMs: number;
}
export interface TelegramBusTransportRetryPolicyOverrides {
attempts?: number;
delayMs?: number;
}
export type TelegramBusTransportOperation = "registration" | "operation";
export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy = {
attempts: 10,
delayMs: 150,
};
export const TELEGRAM_BUS_OPERATION_RETRY: TelegramBusTransportRetryPolicy = {
attempts: 3,
delayMs: 100,
};
export const TELEGRAM_BUS_PIPE_REGISTRATION_RETRY =
TELEGRAM_BUS_REGISTRATION_RETRY;
export const TELEGRAM_BUS_PIPE_OPERATION_RETRY = TELEGRAM_BUS_OPERATION_RETRY;
export function getTelegramBusPipePath(input: {
agentDir: string;
scope: string;
}): string {
const digest = createHash("sha256")
.update(resolve(input.agentDir))
.digest("base64url")
.slice(0, 16);
const scope = input.scope.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
return `\\\\.\\pipe\\pi-telegram-${digest}-${scope}`;
}
export function isTelegramBusPipePath(endpoint: string): boolean {
return /^\\\\[.?]\\pipe\\/i.test(endpoint);
}
export function getTelegramBusTransportKind(
endpoint: string,
): TelegramBusTransportKind {
return isTelegramBusPipePath(endpoint) ? "pipe" : "socket";
}
export function getTelegramBusEndpointDiagnostics(
endpoint: string,
): TelegramBusTransportEndpointDiagnostics {
return {
endpoint,
transport: getTelegramBusTransportKind(endpoint),
};
}
export function getTelegramBusTransportRetryPolicy(input: {
endpoint: string;
operation: TelegramBusTransportOperation;
overrides?: TelegramBusTransportRetryPolicyOverrides;
}): TelegramBusTransportRetryPolicy | undefined {
const base =
input.operation === "registration"
? TELEGRAM_BUS_REGISTRATION_RETRY
: isTelegramBusPipePath(input.endpoint)
? TELEGRAM_BUS_OPERATION_RETRY
: undefined;
if (!base && !input.overrides) return undefined;
return {
attempts: Math.max(1, input.overrides?.attempts ?? base?.attempts ?? 1),
delayMs: Math.max(0, input.overrides?.delayMs ?? base?.delayMs ?? 0),
};
}
function normalizeTelegramBusEndpointScope(value: string): string {
return value.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
}
export function getTelegramBusLeaderEndpoint(input: {
agentDir: string;
platform: NodeJS.Platform | string;
profileName?: string;
}): string {
const profileScope = input.profileName
? normalizeTelegramBusEndpointScope(input.profileName)
: undefined;
return input.platform === "win32"
? getTelegramBusPipePath({
agentDir: input.agentDir,
scope: profileScope ? `bus-${profileScope}` : "bus",
})
: join(
input.agentDir,
"tmp",
"telegram",
profileScope ? `bus.${profileScope}.sock` : "bus.sock",
);
}
export function getTelegramBusFollowerEndpoint(input: {
agentDir: string;
platform: NodeJS.Platform | string;
instanceId: string;
profileName?: string;
}): string {
const instanceScope = normalizeTelegramBusEndpointScope(input.instanceId);
const profileScope = input.profileName
? normalizeTelegramBusEndpointScope(input.profileName)
: undefined;
return input.platform === "win32"
? getTelegramBusPipePath({
agentDir: input.agentDir,
scope: profileScope
? `follower-${profileScope}-${instanceScope}`
: `follower-${instanceScope}`,
})
: join(
input.agentDir,
"tmp",
"telegram",
"followers",
...(profileScope ? [profileScope] : []),
`${instanceScope}.sock`,
);
}
export interface TelegramBusTransportErrorInfo {
message: string;
code?: string;
syscall?: string;
kind: "connect" | "timeout" | "auth" | "protocol" | "unknown";
retryable: boolean;
}
export function classifyTelegramBusTransportError(
error: unknown,
): TelegramBusTransportErrorInfo {
const message = error instanceof Error ? error.message : String(error);
const maybeNodeError = error as NodeJS.ErrnoException;
const code =
typeof maybeNodeError?.code === "string" ? maybeNodeError.code : undefined;
const syscall =
typeof maybeNodeError?.syscall === "string"
? maybeNodeError.syscall
: undefined;
const timeout = code === "ETIMEDOUT" || /timed out/i.test(message);
const retryable =
code === "ENOENT" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ECONNRESET" ||
code === "EBUSY" ||
timeout;
const kind = timeout
? "timeout"
: code === "ENOENT" || code === "ECONNREFUSED"
? "connect"
: "unknown";
return { message, code, syscall, kind, retryable };
}
export function isRetryableTelegramBusTransportError(error: unknown): boolean {
return classifyTelegramBusTransportError(error).retryable;
}
export function createTelegramBusTransportTimeoutError(
message: string,
): NodeJS.ErrnoException {
const error = new Error(message) as NodeJS.ErrnoException;
error.code = "ETIMEDOUT";
return error;
}
export function delayTelegramBusTransportRetry(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref?.();
});
}
export interface TelegramBusTransportProbeResult
extends TelegramBusTransportEndpointDiagnostics {
reachable: boolean;
error?: TelegramBusTransportErrorInfo;
}
export function probeTelegramBusEndpoint(input: {
endpoint: string;
timeoutMs?: number;
}): Promise<TelegramBusTransportProbeResult> {
const timeoutMs = input.timeoutMs ?? 250;
const diagnostics = getTelegramBusEndpointDiagnostics(input.endpoint);
return new Promise((resolve) => {
const socket = createConnection(input.endpoint);
let settled = false;
const settle = (result: TelegramBusTransportProbeResult): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
socket.destroy();
resolve(result);
};
const timeout = setTimeout(() => {
settle({
...diagnostics,
reachable: false,
error: classifyTelegramBusTransportError(
createTelegramBusTransportTimeoutError(
"Timed out probing Telegram bus endpoint",
),
),
});
}, timeoutMs);
timeout.unref?.();
socket.once("connect", () => settle({ ...diagnostics, reachable: true }));
socket.once("error", (error) =>
settle({
...diagnostics,
reachable: false,
error: classifyTelegramBusTransportError(error),
}),
);
});
}
+1491
View File
File diff suppressed because it is too large. Load diff
+938
View File
@@ -0,0 +1,938 @@
/**
* Command-template execution standard.
* Zones: shell-free command parsing, placeholder expansion, local process execution, composition semantics
* Owns portable command-template parsing, expansion, risk checks, retries, timeouts, and direct execution.
*/
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
export type CommandTemplateFailureScope = "continue" | "branch" | "root";
export interface CommandTemplateActorRecipeContext {
alias?: string;
file?: string;
name?: string;
path?: string;
role?: string;
}
export interface CommandTemplateObjectConfig {
actorRecipeContext?: CommandTemplateActorRecipeContext;
label?: string;
parallel?: boolean;
when?: boolean | string;
template?: CommandTemplateValue;
args?: string[];
defaults?: Record<string, unknown>;
timeout?: number | string;
delay?: number | string;
output?: string;
retry?: number | string;
failure?: CommandTemplateFailureScope;
recover?: CommandTemplateValue;
repeat?: number | string;
}
export type CommandTemplateValue =
| string
| CommandTemplateConfig[]
| CommandTemplateObjectConfig;
export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
export interface CommandTemplateLeafConfig extends CommandTemplateObjectConfig {
template: string;
}
export interface CommandTemplateInvocation {
command: string;
args: string[];
}
export interface CommandTemplateExecOptions {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
stdin?: string;
killGrace?: number;
retry?: number;
}
export interface CommandTemplateExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
export type CommandTemplateRiskLabel =
| "risk.shell"
| "risk.eval"
| "risk.broad_fs_write"
| "risk.destructive_fs"
| "risk.network"
| "risk.external_side_effect"
| "risk.long_running"
| "risk.platform_specific"
| "risk.secret_touching";
const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
"risk.shell",
"risk.eval",
"risk.destructive_fs",
"risk.broad_fs_write",
"risk.external_side_effect",
"risk.secret_touching",
"risk.network",
"risk.long_running",
"risk.platform_specific",
];
export type CommandTemplateExecCommand = (
command: string,
args: string[],
options?: CommandTemplateExecOptions,
) => Promise<CommandTemplateExecResult>;
function normalizeCommandTemplateArgs(value: string[] | undefined): string[] {
if (!Array.isArray(value)) return [];
return value.map(String).map((item) => item.trim());
}
export function normalizeCommandTemplateConfig(
config: CommandTemplateConfig,
): CommandTemplateObjectConfig {
return typeof config === "string" ? { template: config } : config;
}
function normalizeRecoverConfig(
config: CommandTemplateValue | undefined,
): CommandTemplateConfig | undefined {
if (config === undefined) return undefined;
return Array.isArray(config) ? { template: config } : config;
}
function normalizeCommandTemplateDefaults(
defaults: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!defaults) return undefined;
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(defaults)) {
normalized[key] = Array.isArray(value)
? value
: value === undefined || value === null
? ""
: String(value);
}
return normalized;
}
export function resolveInheritedDefaultReferences(
ownDefaults: Record<string, unknown> | undefined,
inheritedDefaults: Record<string, unknown> | undefined,
runtimeValues: Record<string, unknown> = {},
): Record<string, unknown> | undefined {
if (!ownDefaults || !inheritedDefaults) return ownDefaults;
const resolved = { ...ownDefaults };
for (const [key, value] of Object.entries(ownDefaults)) {
if (typeof value !== "string") continue;
const exact = /^\{([A-Za-z_][A-Za-z0-9_-]*)\}$/.exec(value);
if (
!exact ||
Object.hasOwn(runtimeValues, exact[1]) ||
!Object.hasOwn(inheritedDefaults, exact[1])
)
continue;
resolved[key] = inheritedDefaults[exact[1]];
}
return resolved;
}
export function resolveCommandTemplateRepeat(
value: number | string | undefined,
values: Record<string, unknown> = {},
): number | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 1)
throw new Error("Command template repeat must be a positive integer.");
return value;
}
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) return Number(trimmed);
const lengthMatch = trimmed.match(
/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/,
);
if (lengthMatch) {
const source = values[lengthMatch[1]];
if (Array.isArray(source)) return source.length;
if (source === undefined) return undefined;
}
throw new Error(
"Command template repeat must be a positive integer or {array.length}.",
);
}
function getExecutableName(command: string | undefined): string {
if (!command) return "";
return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
}
function matchesFlag(arg: string, flag: string): boolean {
if (arg === flag) return true;
if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
return arg.slice(1).includes(flag.slice(1));
return false;
}
function hasAnyFlag(args: string[], flags: string[]): boolean {
return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
}
function hasRiskyPathArg(args: string[]): boolean {
return args.some(
(arg) =>
arg === "/" ||
arg === "~" ||
arg === "./" ||
arg === "../" ||
arg.includes("{") ||
arg.startsWith("~/") ||
arg.startsWith("/"),
);
}
function sortRiskLabels(
labels: Iterable<CommandTemplateRiskLabel>,
): CommandTemplateRiskLabel[] {
const unique = new Set(labels);
return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
}
function hasAnyArg(args: string[], values: string[]): boolean {
return args.some((arg) => values.includes(arg.toLowerCase()));
}
function hasSecretTouchingText(parts: string[]): boolean {
return parts.some((part) =>
/(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
part,
),
);
}
function getLeafCommandTemplateRiskLabels(
config: CommandTemplateLeafConfig,
): CommandTemplateRiskLabel[] {
const parts = splitCommandTemplate(config.template);
const command = getExecutableName(parts[0]);
const args = parts.slice(1);
const labels = new Set<CommandTemplateRiskLabel>();
if (["bash", "sh", "zsh", "fish"].includes(command)) {
labels.add("risk.shell");
if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
}
if (
["node", "deno", "bun"].includes(command) &&
hasAnyFlag(args, ["-e", "--eval"])
) {
labels.add("risk.eval");
}
if (
["python", "python3", "perl", "ruby"].includes(command) &&
hasAnyFlag(args, ["-c", "-e"])
) {
labels.add("risk.eval");
}
if (
command === "rm" &&
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
hasRiskyPathArg(args))
) {
labels.add("risk.destructive_fs");
}
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
labels.add("risk.broad_fs_write");
}
if (
["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
command,
) ||
(command === "git" &&
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
) {
labels.add("risk.network");
}
if (
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
(command === "git" && hasAnyArg(args, ["push"])) ||
(["npm", "pnpm", "yarn"].includes(command) &&
hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
) {
labels.add("risk.external_side_effect");
}
if (
command === "sleep" ||
command === "watch" ||
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
hasAnyArg(args, ["--watch", "--serve", "serve"])
) {
labels.add("risk.long_running");
}
if (
["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
command,
)
) {
labels.add("risk.platform_specific");
}
if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
labels.add("risk.secret_touching");
}
return sortRiskLabels(labels);
}
function getLeafCommandTemplateWarnings(
config: CommandTemplateLeafConfig,
): string[] {
const parts = splitCommandTemplate(config.template);
const command = getExecutableName(parts[0]);
const args = parts.slice(1);
const warnings: string[] = [];
if (["bash", "sh", "zsh", "fish"].includes(command)) {
const shellContent = hasAnyFlag(args, ["-c"])
? "shell command strings"
: "shell scripts";
warnings.push(
`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting. Mitigation: keep scripts local, reviewed, and parameterized with explicit placeholders.`,
);
}
if (
["node", "deno", "bun"].includes(command) &&
hasAnyFlag(args, ["-e", "--eval"])
) {
warnings.push(
`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
);
}
if (
["python", "python3", "perl", "ruby"].includes(command) &&
hasAnyFlag(args, ["-c", "-e"])
) {
warnings.push(
`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
);
}
if (
command === "rm" &&
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
hasRiskyPathArg(args))
) {
warnings.push(
`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands. Mitigation: constrain path placeholders and consider dry-run or explicit confirmation.`,
);
}
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
warnings.push(
`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands. Mitigation: constrain path placeholders and prefer narrow source/destination paths.`,
);
}
return warnings;
}
function pad(value: number, width: number): string {
return String(value).padStart(width, "0");
}
export function isCommandTemplateRepeatPlaceholder(name: string): boolean {
return /^_{0,6}(?:index|prev|next|repeat)$/.test(name);
}
export function getCommandTemplateRepeatDefaults(
index: number,
repeat: number,
): Record<string, string> {
const prev = (index - 1 + repeat) % repeat;
const next = (index + 1) % repeat;
const values: Record<string, string> = {
index: String(index),
next: String(next),
prev: String(prev),
repeat: String(repeat),
};
for (const name of ["index", "prev", "next", "repeat"]) {
const numeric = Number(values[name]);
for (let underscores = 1; underscores <= 6; underscores += 1) {
values[`${"_".repeat(underscores)}${name}`] = pad(
numeric,
underscores + 1,
);
}
}
return values;
}
function expandRepeatConfig(
config: CommandTemplateObjectConfig,
context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
): CommandTemplateObjectConfig[] | undefined {
const repeat = resolveCommandTemplateRepeat(
config.repeat,
context.defaults ?? {},
);
if (repeat === undefined) return undefined;
return Array.from({ length: repeat }, (_unused, index0) => {
const { repeat: _repeat, ...rest } = config;
return {
...rest,
defaults: {
...(context.defaults ?? {}),
...(rest.defaults ?? {}),
...getCommandTemplateRepeatDefaults(index0, repeat),
},
};
});
}
export function expandCommandTemplateConfigs(
config: CommandTemplateConfig,
inherited: Pick<CommandTemplateObjectConfig, "args" | "defaults"> = {},
): CommandTemplateLeafConfig[] {
const normalizedConfig = normalizeCommandTemplateConfig(config);
const inheritedDefaults = normalizeCommandTemplateDefaults(
inherited.defaults,
);
const ownDefaults = resolveInheritedDefaultReferences(
normalizeCommandTemplateDefaults(normalizedConfig.defaults),
inheritedDefaults,
);
const context = {
...(inherited.args !== undefined ? { args: inherited.args } : {}),
...(inheritedDefaults ? { defaults: inheritedDefaults } : {}),
...(normalizedConfig.args !== undefined
? { args: normalizedConfig.args }
: {}),
...(ownDefaults
? { defaults: { ...(inheritedDefaults ?? {}), ...ownDefaults } }
: {}),
};
const repeated = expandRepeatConfig(normalizedConfig, context);
if (repeated) {
return repeated.flatMap((step) =>
expandCommandTemplateConfigs(step, context),
);
}
const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
const recoverSteps = recoverConfig
? expandCommandTemplateConfigs(recoverConfig, context)
: [];
if (Array.isArray(normalizedConfig.template)) {
return [
...normalizedConfig.template.flatMap((step) =>
expandCommandTemplateConfigs(step, context),
),
...recoverSteps,
];
}
if (typeof normalizedConfig.template !== "string") return recoverSteps;
return [
{
...normalizedConfig,
...context,
template: normalizedConfig.template,
retry: normalizedConfig.retry,
},
...recoverSteps,
];
}
export function getCommandTemplateWarnings(
config: CommandTemplateConfig,
): string[] {
return [
...new Set(
expandCommandTemplateConfigs(config).flatMap((leaf) =>
getLeafCommandTemplateWarnings(leaf),
),
),
];
}
export function getCommandTemplateRiskLabels(
config: CommandTemplateConfig,
): CommandTemplateRiskLabel[] {
return sortRiskLabels(
expandCommandTemplateConfigs(config).flatMap((leaf) =>
getLeafCommandTemplateRiskLabels(leaf),
),
);
}
function parseCommandTemplateArgToken(value: string): {
name: string;
defaultValue?: string;
} {
const separatorIndex = value.indexOf("=");
const rawName =
separatorIndex === -1 ? value : value.slice(0, separatorIndex);
const colonIndex = rawName.indexOf(":");
return {
name: (colonIndex === -1 ? rawName : rawName.slice(0, colonIndex)).trim(),
...(separatorIndex === -1
? {}
: { defaultValue: value.slice(separatorIndex + 1).trim() }),
};
}
function parseCommandTemplatePlaceholderContent(
content: string,
): { name: string; inlineDefault?: string } | undefined {
const match = content.match(
/^([A-Za-z_][A-Za-z0-9_-]*)(?::(?:string|path|int|number|bool|array|enum\([^)]*\)))?(?:=([^}]*))?$/,
);
if (!match) return undefined;
return {
name: match[1],
...(match[2] !== undefined ? { inlineDefault: match[2] } : {}),
};
}
export function getCommandTemplateDefaults(
config: CommandTemplateConfig | undefined,
): Record<string, string> {
const normalizedConfig = config
? normalizeCommandTemplateConfig(config)
: undefined;
const defaults: Record<string, string> = {};
for (const item of normalizeCommandTemplateArgs(normalizedConfig?.args)) {
if (!item) continue;
const parsed = parseCommandTemplateArgToken(item);
if (!parsed.name || parsed.defaultValue === undefined) continue;
defaults[parsed.name] = parsed.defaultValue;
}
for (const [key, value] of Object.entries(normalizedConfig?.defaults ?? {})) {
defaults[key] = value === undefined || value === null ? "" : String(value);
}
return defaults;
}
export function splitCommandTemplate(input: string): string[] {
const words: string[] = [];
let current = "";
let quote: "'" | '"' | undefined;
let escaped = false;
let active = false;
for (const char of input) {
if (escaped) {
current += char;
escaped = false;
active = true;
continue;
}
if (char === "\\" && quote !== "'") {
escaped = true;
active = true;
continue;
}
if (quote) {
if (char === quote) quote = undefined;
else current += char;
active = true;
continue;
}
if (char === "'" || char === '"') {
quote = char;
active = true;
continue;
}
if (/\s/.test(char)) {
if (active) words.push(current);
if (active) current = "";
active = false;
continue;
}
current += char;
active = true;
}
if (escaped) current += "\\";
if (active || current) words.push(current);
return words;
}
export function expandCommandTemplateExecutable(
command: string,
cwd: string,
): string {
if (command === "~") return homedir();
if (command.startsWith("~/")) return resolve(homedir(), command.slice(2));
if (command.includes("/") && !isAbsolute(command))
return resolve(cwd, command);
return command;
}
function evaluateCommandTemplateExpression(
expression: string,
values: Record<string, unknown>,
): number {
let index = 0;
const source = expression.replace(/\s+/g, "");
const peek = (): string | undefined => source[index];
const consume = (char: string): boolean => {
if (peek() !== char) return false;
index += 1;
return true;
};
const parsePrimary = (): number => {
if (consume("(")) {
const value = parseExpression();
if (!consume(")"))
throw new Error(`Invalid command template expression: ${expression}`);
return value;
}
const numberMatch = source.slice(index).match(/^\d+/);
if (numberMatch) {
index += numberMatch[0].length;
return Number(numberMatch[0]);
}
const nameMatch = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_-]*/);
if (nameMatch) {
index += nameMatch[0].length;
const value = values[nameMatch[0]];
if (value === undefined || !/^-?\d+$/.test(String(value)))
throw new Error(
`Invalid command template expression variable: ${nameMatch[0]}`,
);
return Number(value);
}
throw new Error(`Invalid command template expression: ${expression}`);
};
const parseTerm = (): number => {
let value = parsePrimary();
while (true) {
if (consume("*")) value *= parsePrimary();
else if (consume("/")) value = Math.trunc(value / parsePrimary());
else if (consume("%")) value %= parsePrimary();
else return value;
}
};
const parseExpression = (): number => {
let value = parseTerm();
while (true) {
if (consume("+")) value += parseTerm();
else if (consume("-")) value -= parseTerm();
else return value;
}
};
const value = parseExpression();
if (index !== source.length)
throw new Error(`Invalid command template expression: ${expression}`);
return value;
}
function substituteCommandTemplateExpression(
content: string,
values: Record<string, unknown>,
): string | undefined {
const padded = content.match(/^(_{1,6})\((.+)\)$/);
if (padded) {
return pad(
evaluateCommandTemplateExpression(padded[2], values),
padded[1].length + 1,
);
}
if (!/[()+\-*\/%]/.test(content)) return undefined;
return String(evaluateCommandTemplateExpression(content, values));
}
function shouldResolveEmbeddedCommandTemplateToken(
token: string,
values: Record<string, unknown>,
): boolean {
const matches = [...token.matchAll(/\{([^{}]+)\}/g)];
if (matches.length === 0) return false;
return matches.every((match) => {
const content = match[1];
if (resolveCommandTemplateNullish(content, values) !== undefined)
return true;
if (resolveCommandTemplateTernary(content, values) !== undefined)
return true;
const indexed = content.match(
/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/,
);
if (indexed) return Object.hasOwn(values, indexed[1]);
const simple = parseCommandTemplatePlaceholderContent(content);
if (simple)
return (
Object.hasOwn(values, simple.name) || simple.inlineDefault !== undefined
);
try {
return substituteCommandTemplateExpression(content, values) !== undefined;
} catch {
return false;
}
});
}
function isFalsyCommandTemplateValue(value: unknown): boolean {
if (value === undefined || value === null || value === false) return true;
const normalized = String(value).trim().toLowerCase();
return (
normalized === "" ||
normalized === "0" ||
normalized === "false" ||
normalized === "no"
);
}
function resolveCommandTemplateCondition(
condition: string,
values: Record<string, unknown>,
): unknown {
const trimmed = condition.trim();
const negated = trimmed.startsWith("!");
const name = negated ? trimmed.slice(1).trim() : trimmed;
const value = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)
? values[name]
: undefined;
return negated ? isFalsyCommandTemplateValue(value) : value;
}
export function shouldRunCommandTemplateNode(
value: boolean | string | undefined,
values: Record<string, unknown>,
): boolean {
if (value === undefined) return true;
if (typeof value === "boolean") return value;
const trimmed = value.trim();
if (!trimmed) return false;
const exact = /^\{([^{}]+)\}$/.exec(trimmed);
const resolved = exact
? resolveCommandTemplateValue(exact[1], values, "command template when")
: resolveCommandTemplateCondition(trimmed, values);
return !isFalsyCommandTemplateValue(resolved);
}
function resolveCommandTemplateNullish(
content: string,
values: Record<string, unknown>,
): string | undefined {
const coalescing = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\?\?(.*)$/);
if (!coalescing) return undefined;
const value = values[coalescing[1]];
return isFalsyCommandTemplateValue(value) ? coalescing[2] : String(value);
}
function resolveCommandTemplateTernary(
content: string,
values: Record<string, unknown>,
): string | undefined {
const ternary = content.match(/^([^?:]+)\?([^:]*):(.*)$/);
if (!ternary) return undefined;
const condition = resolveCommandTemplateCondition(ternary[1], values);
return isFalsyCommandTemplateValue(condition) ? ternary[3] : ternary[2];
}
function resolveCommandTemplateValue(
content: string,
values: Record<string, unknown>,
missingLabel: string,
depth = 0,
): string | undefined {
if (depth > 5)
throw new Error(`Command template value recursion exceeded: ${content}`);
const nullish = resolveCommandTemplateNullish(content, values);
if (nullish !== undefined) return nullish;
const ternary = resolveCommandTemplateTernary(content, values);
if (ternary !== undefined) return ternary;
const indexed = content.match(
/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/,
);
if (indexed) {
const source = values[indexed[1]];
const indexValue = /^\d+$/.test(indexed[2])
? indexed[2]
: values[indexed[2]];
const index = Number(indexValue);
if (
!Array.isArray(source) ||
!Number.isInteger(index) ||
index < 0 ||
index >= source.length
) {
throw new Error(`Missing ${missingLabel} value: ${content}`);
}
return String(source[index] ?? "");
}
const simple = parseCommandTemplatePlaceholderContent(content);
if (simple) {
if (Object.hasOwn(values, simple.name)) {
const raw = values[simple.name] ?? "";
if (
typeof raw === "string" &&
shouldResolveEmbeddedCommandTemplateToken(raw, values)
) {
return substituteCommandTemplateToken(
raw,
values,
missingLabel,
depth + 1,
);
}
return Array.isArray(raw) ? JSON.stringify(raw) : String(raw);
}
if (simple.inlineDefault !== undefined) return simple.inlineDefault;
}
const expression = substituteCommandTemplateExpression(content, values);
if (expression !== undefined) return expression;
return undefined;
}
export function substituteCommandTemplateToken(
token: string,
values: Record<string, unknown>,
missingLabel = "command template",
depth = 0,
): string {
return token.replace(/\{([^{}]+)\}/g, (_match, content: string) => {
const resolved = resolveCommandTemplateValue(
content,
values,
missingLabel,
depth,
);
if (resolved !== undefined) return resolved;
throw new Error(`Missing ${missingLabel} value: ${content}`);
});
}
export async function execCommandTemplate(
command: string,
args: string[],
options: CommandTemplateExecOptions = {},
): Promise<CommandTemplateExecResult> {
const maxAttempts = options.retry ?? 1;
let lastResult: CommandTemplateExecResult = {
stdout: "",
stderr: "",
code: 1,
killed: false,
};
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await execCommandTemplateOnce(command, args, options);
if (result.code === 0) return result;
lastResult = result;
}
return lastResult;
}
function execCommandTemplateOnce(
command: string,
args: string[],
options: CommandTemplateExecOptions = {},
): Promise<CommandTemplateExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd: options.cwd,
shell: false,
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let settled = false;
let timeoutId: NodeJS.Timeout | undefined;
let killTimeoutId: NodeJS.Timeout | undefined;
const killProcess = (): void => {
if (killed) return;
killed = true;
proc.kill("SIGTERM");
killTimeoutId = setTimeout(() => {
if (!settled) proc.kill("SIGKILL");
}, options.killGrace ?? 5000);
};
const settle = (code: number): void => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
if (killTimeoutId) clearTimeout(killTimeoutId);
if (options.signal)
options.signal.removeEventListener("abort", killProcess);
resolve({ stdout, stderr, code, killed });
};
if (options.signal) {
if (options.signal.aborted) killProcess();
else
options.signal.addEventListener("abort", killProcess, { once: true });
}
if (options.timeout !== undefined && options.timeout > 0)
timeoutId = setTimeout(killProcess, options.timeout);
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.stdin?.on("error", () => {});
if (options.stdin !== undefined) proc.stdin?.end(options.stdin);
proc.on("error", (error) => {
stderr += error instanceof Error ? error.message : String(error);
settle(1);
});
proc.on("close", (code) => {
settle(code ?? (killed ? 1 : 0));
});
});
}
export function buildCommandTemplateInvocation(
config: CommandTemplateConfig,
values: Record<string, unknown>,
cwd: string,
options: { emptyMessage?: string; missingLabel?: string } = {},
): CommandTemplateInvocation {
const normalizedConfig = normalizeCommandTemplateConfig(config);
if (Array.isArray(normalizedConfig.template)) {
throw new Error(
options.emptyMessage ??
"Command template sequence cannot be executed as one command",
);
}
if (!normalizedConfig.template)
throw new Error(options.emptyMessage ?? "Command template is required");
if (typeof normalizedConfig.template !== "string") {
throw new Error(
options.emptyMessage ??
"Command template object cannot be executed as one command",
);
}
const parts = splitCommandTemplate(normalizedConfig.template);
const commandPart = parts[0];
if (!commandPart)
throw new Error(options.emptyMessage ?? "Command template is empty");
const resolvedValues = {
...getCommandTemplateDefaults(normalizedConfig),
...values,
};
const command = expandCommandTemplateExecutable(
substituteCommandTemplateToken(
commandPart,
resolvedValues,
options.missingLabel,
),
cwd,
);
const args = parts
.slice(1)
.map((part) =>
substituteCommandTemplateToken(
part,
resolvedValues,
options.missingLabel,
),
)
.filter((part) => part !== "");
return { command, args };
}
+1610
View File
File diff suppressed because it is too large. Load diff
+839
View File
@@ -0,0 +1,839 @@
/**
* Telegram bridge config and pairing helpers
* Zones: telegram config, pairing, filesystem
* Owns persisted bot/session pairing state, local config storage, live config controls, authorization policy, and first-user pairing side effects
*/
import { randomUUID } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
writeFileSync,
} from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import { resolveAgentDir, resolveTelegramConfigPath } from "./paths.ts";
import type { TelegramInboundHandlerConfig } from "./inbound.ts";
import type { CommandTemplateObjectConfig } from "./command-templates.ts";
import { withTelegramFileTransaction } from "./locks.ts";
const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
function getConfigPath(): string {
return resolveTelegramConfigPath();
}
export type TelegramOutboundCommandTemplateConfig =
string | CommandTemplateObjectConfig;
export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
type?: string;
match?: string | string[];
output?: string;
timeout?: number | string;
}
export type TelegramTimeMode = "hidden" | "always" | "interval";
export interface TelegramTimeConfig {
injectionMode?: TelegramTimeMode;
interval?: number;
}
export interface ResolvedTelegramTimeConfig {
injectionMode: TelegramTimeMode;
interval: number;
timezone: string;
}
export type TelegramAssistantRenderingMode = "rich" | "html";
export interface TelegramConfig {
botToken?: string;
botUsername?: string;
botId?: number;
allowedUserId?: number;
lastUpdateId?: number;
inboundHandlers?: TelegramInboundHandlerConfig[];
attachmentHandlers?: TelegramInboundHandlerConfig[];
outboundHandlers?: TelegramOutboundHandlerConfig[];
proactivePush?: boolean;
assistant?: {
draftPreviews?: boolean;
rendering?: TelegramAssistantRenderingMode;
};
/** @deprecated use assistant.draftPreviews */
draftPreviews?: boolean;
/** @deprecated use assistant.draftPreviews */
richDraftPreviews?: boolean;
/** @deprecated use assistant.rendering */
assistantRendering?: TelegramAssistantRenderingMode;
voice?: {
replyMode?: "manual" | "mirror" | "always";
/** Whether to attach the provider's transcriptText as caption on voice messages */
sendTranscript?: boolean;
};
time?: TelegramTimeConfig;
/** Named bot/session profiles (e.g. "work", "omp"). */
profiles?: Record<string, TelegramBotProfile>;
}
/**
* Per-profile bot/session identity fields.
* Stored under `profiles.<name>` in telegram.json.
* Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
* assistant, proactivePush) stay at the top level.
*/
export interface TelegramBotProfile {
botToken: string;
botUsername?: string;
botId?: number;
allowedUserId?: number;
lastUpdateId?: number;
}
/** Profile names must contain only lowercase ASCII letters and digits; max 32 chars. */
const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9]{1,32}$/;
const TELEGRAM_RESERVED_PROFILE_NAMES: ReadonlySet<string> = new Set([
"default",
"main",
"active",
]);
export function isValidTelegramProfileName(name: string): boolean {
return (
TELEGRAM_PROFILE_NAME_PATTERN.test(name) &&
!TELEGRAM_RESERVED_PROFILE_NAMES.has(name)
);
}
/**
* Resolve the effective config for a named (or default) profile.
* Returns bot/session fields from the named profile, falling back to
* top-level fields for the default profile. Shared bridge settings
* always come from the top level.
*/
export function resolveTelegramActiveProfile(
config: TelegramConfig,
profileName?: string,
): {
botToken?: string;
botUsername?: string;
botId?: number;
allowedUserId?: number;
lastUpdateId?: number;
} {
if (!profileName || !config.profiles?.[profileName]) {
return {
botToken: config.botToken,
botUsername: config.botUsername,
botId: config.botId,
allowedUserId: config.allowedUserId,
lastUpdateId: config.lastUpdateId,
};
}
const profile = config.profiles[profileName];
return {
botToken: profile.botToken,
botUsername: profile.botUsername,
botId: profile.botId,
allowedUserId: profile.allowedUserId,
lastUpdateId: profile.lastUpdateId,
};
}
/** List defined profile names. */
export function getTelegramProfileNames(config: TelegramConfig): string[] {
return Object.keys(config.profiles ?? {}).sort();
}
export interface TelegramConfigStore {
get: () => TelegramConfig;
getStoredConfig: () => TelegramConfig;
set: (config: TelegramConfig) => void;
update: (mutate: (config: TelegramConfig) => void) => void;
activateProfile: (profileName: string | undefined) => boolean;
getActiveProfileName: () => string | undefined;
getBotToken: () => string | undefined;
hasBotToken: () => boolean;
getAllowedUserId: () => number | undefined;
getInboundHandlers: () => TelegramInboundHandlerConfig[] | undefined;
getAttachmentHandlers: () => TelegramInboundHandlerConfig[] | undefined;
getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
setAllowedUserId: (userId: number) => void;
load: () => Promise<void>;
persist: (config?: TelegramConfig) => Promise<void>;
}
export interface TelegramConfigStoreOptions {
initialConfig?: TelegramConfig;
agentDir?: string;
configPath?: string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramInvalidConfigRecovery {
configPath: string;
recoveryPath: string;
error: unknown;
}
export interface TelegramConfigRuntime {
updateVoiceConfig: (voice: NonNullable<TelegramConfig["voice"]>) => void;
}
export function setGlobalTelegramConfigRuntime(
runtime: TelegramConfigRuntime | undefined,
): void {
const globals = globalThis as Record<string, unknown>;
if (runtime) globals[CONFIG_RUNTIME_KEY] = runtime;
else delete globals[CONFIG_RUNTIME_KEY];
}
export function updateTelegramVoiceConfig(
voice: NonNullable<TelegramConfig["voice"]>,
): boolean {
const runtime = (globalThis as Record<string, unknown>)[
CONFIG_RUNTIME_KEY
] as TelegramConfigRuntime | undefined;
if (!runtime || typeof runtime.updateVoiceConfig !== "function") return false;
runtime.updateVoiceConfig(voice);
return true;
}
type TelegramMutableConfigStore = Pick<
TelegramConfigStore,
"get" | "set" | "persist"
> & {
load?: () => Promise<void>;
};
function isEmptyTelegramConfig(config: TelegramConfig): boolean {
return Object.keys(config).length === 0;
}
async function loadLatestTelegramConfig(
configStore: TelegramMutableConfigStore,
): Promise<void> {
if (!configStore.load) return;
const before = configStore.get();
await configStore.load();
if (
!isEmptyTelegramConfig(before) &&
isEmptyTelegramConfig(configStore.get())
) {
configStore.set(before);
}
}
export function bindGlobalTelegramConfigRuntime(
configStore: TelegramMutableConfigStore,
): void {
setGlobalTelegramConfigRuntime({
updateVoiceConfig(voice) {
const current = configStore.get();
const next = {
...current,
voice: { ...(current.voice ?? {}), ...voice },
};
configStore.set(next);
void configStore.persist(next);
},
});
}
function getInvalidTelegramConfigRecoveryPath(configPath: string): string {
return `${configPath}.invalid-${process.pid}-${Date.now()}`;
}
export async function readTelegramConfig(
configPath: string,
options: {
onInvalidConfig?: (recovery: TelegramInvalidConfigRecovery) => void;
} = {},
): Promise<TelegramConfig> {
return withTelegramFileTransaction(`${configPath}.transaction`, () => {
if (!existsSync(configPath)) return {};
const identity = statSync(configPath);
const content = readFileSync(configPath, "utf8");
try {
return JSON.parse(content) as TelegramConfig;
} catch (error) {
const currentIdentity = statSync(configPath);
if (
currentIdentity.dev !== identity.dev ||
currentIdentity.ino !== identity.ino ||
currentIdentity.size !== identity.size ||
currentIdentity.mtimeMs !== identity.mtimeMs
) {
throw new Error(
`Telegram config changed while validating invalid content: ${configPath}`,
{ cause: error },
);
}
const recoveryPath = getInvalidTelegramConfigRecoveryPath(configPath);
renameSync(configPath, recoveryPath);
options.onInvalidConfig?.({ configPath, recoveryPath, error });
return {};
}
});
}
export async function writeTelegramConfig(
agentDir: string,
configPath: string,
config: TelegramConfig,
): Promise<void> {
await mkdir(agentDir, { recursive: true });
const tempConfigPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempConfigPath, JSON.stringify(config, null, "\t") + "\n", {
encoding: "utf8",
mode: 0o600,
});
await chmod(tempConfigPath, 0o600);
await rename(tempConfigPath, configPath);
await chmod(configPath, 0o600);
}
function isPlainConfigRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function cloneTelegramConfig<T>(value: T): T {
return structuredClone(value);
}
function configValuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function mergeTelegramConfigDelta(
base: Record<string, unknown>,
desired: Record<string, unknown>,
latest: Record<string, unknown>,
): Record<string, unknown> {
const merged = cloneTelegramConfig(latest);
for (const key of new Set([...Object.keys(base), ...Object.keys(desired)])) {
const baseHas = Object.hasOwn(base, key);
const desiredHas = Object.hasOwn(desired, key);
const baseValue = base[key];
const desiredValue = desired[key];
if (baseHas === desiredHas && configValuesEqual(baseValue, desiredValue)) {
continue;
}
if (!desiredHas) {
if (key !== "lastUpdateId") delete merged[key];
continue;
}
if (
key === "lastUpdateId" &&
typeof desiredValue === "number" &&
typeof merged[key] === "number"
) {
merged[key] = Math.max(desiredValue, merged[key] as number);
continue;
}
if (
isPlainConfigRecord(desiredValue) &&
(!baseHas || isPlainConfigRecord(baseValue))
) {
merged[key] = mergeTelegramConfigDelta(
isPlainConfigRecord(baseValue) ? baseValue : {},
desiredValue,
isPlainConfigRecord(merged[key]) ? merged[key] : {},
);
continue;
}
merged[key] = cloneTelegramConfig(desiredValue);
}
return merged;
}
function readTelegramConfigForTransaction(configPath: string): TelegramConfig {
if (!existsSync(configPath)) return {};
const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8"));
if (!isPlainConfigRecord(parsed)) {
throw new Error(`Invalid Telegram config object: ${configPath}`);
}
return parsed as TelegramConfig;
}
function writeTelegramConfigInTransaction(
agentDir: string,
configPath: string,
config: TelegramConfig,
): void {
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
const tempConfigPath = `${configPath}.tmp-${process.pid}-${randomUUID()}`;
writeFileSync(tempConfigPath, `${JSON.stringify(config, null, "\t")}\n`, {
encoding: "utf8",
mode: 0o600,
});
chmodSync(tempConfigPath, 0o600);
renameSync(tempConfigPath, configPath);
chmodSync(configPath, 0o600);
}
export function getTelegramProfileFields(
config: TelegramConfig,
): TelegramBotProfile | undefined {
const token = config.botToken?.trim();
if (!token) return undefined;
return {
botToken: token,
botUsername: config.botUsername,
botId: config.botId,
allowedUserId: config.allowedUserId,
lastUpdateId: config.lastUpdateId,
};
}
function applyTelegramProfile(
config: TelegramConfig,
profileName: string | undefined,
): TelegramConfig {
if (!profileName) return config;
const profile = config.profiles?.[profileName];
if (!profile) return config;
return {
...config,
botToken: profile.botToken,
botUsername: profile.botUsername,
botId: profile.botId,
allowedUserId: profile.allowedUserId,
lastUpdateId: profile.lastUpdateId,
};
}
function storeTelegramEffectiveConfig(
baseConfig: TelegramConfig,
nextConfig: TelegramConfig,
profileName: string | undefined,
): TelegramConfig {
if (!profileName) return nextConfig;
const profile = getTelegramProfileFields(nextConfig);
const profiles = { ...(baseConfig.profiles ?? {}) };
if (profile) profiles[profileName] = profile;
else delete profiles[profileName];
return {
...nextConfig,
botToken: baseConfig.botToken,
botUsername: baseConfig.botUsername,
botId: baseConfig.botId,
allowedUserId: baseConfig.allowedUserId,
lastUpdateId: baseConfig.lastUpdateId,
profiles: Object.keys(profiles).length > 0 ? profiles : undefined,
};
}
export function createTelegramConfigStore(
options: TelegramConfigStoreOptions = {},
): TelegramConfigStore {
let config: TelegramConfig = cloneTelegramConfig(options.initialConfig ?? {});
let persistedConfig: TelegramConfig = {};
let mutationVersion = 0;
let persistQueue: Promise<void> = Promise.resolve();
let activeProfileName: string | undefined;
const agentDir = options.agentDir ?? resolveAgentDir();
const configPath = options.configPath ?? getConfigPath();
const getEffectiveConfig = () =>
applyTelegramProfile(config, activeProfileName);
const setEffectiveConfig = (nextConfig: TelegramConfig) => {
config = storeTelegramEffectiveConfig(
config,
nextConfig,
activeProfileName,
);
mutationVersion += 1;
};
return {
get: getEffectiveConfig,
getStoredConfig: () => config,
set: setEffectiveConfig,
update: (mutate) => {
const nextConfig = getEffectiveConfig();
mutate(nextConfig);
setEffectiveConfig(nextConfig);
},
activateProfile: (profileName) => {
if (profileName && !config.profiles?.[profileName]) return false;
activeProfileName = profileName;
return true;
},
getActiveProfileName: () => activeProfileName,
getBotToken: () => getEffectiveConfig().botToken,
hasBotToken: () => !!getEffectiveConfig().botToken,
getAllowedUserId: () => getEffectiveConfig().allowedUserId,
getInboundHandlers: () => [
...(config.inboundHandlers ?? []),
...(config.attachmentHandlers ?? []),
],
getAttachmentHandlers: () => config.attachmentHandlers,
getOutboundHandlers: () => config.outboundHandlers,
setAllowedUserId: (userId) => {
const nextConfig = getEffectiveConfig();
nextConfig.allowedUserId = userId;
setEffectiveConfig(nextConfig);
},
load: async () => {
config = await readTelegramConfig(configPath, {
onInvalidConfig: (recovery) => {
options.recordRuntimeEvent?.("config", recovery.error, {
phase: "load",
configPath: recovery.configPath,
recoveryPath: recovery.recoveryPath,
});
},
});
persistedConfig = cloneTelegramConfig(config);
mutationVersion += 1;
},
persist: (nextConfig = getEffectiveConfig()) => {
const profileName = activeProfileName;
const desiredConfig = storeTelegramEffectiveConfig(
config,
cloneTelegramConfig(nextConfig),
profileName,
);
const baseConfig = cloneTelegramConfig(persistedConfig);
const capturedMutationVersion = mutationVersion;
const persist = persistQueue.then(() => {
const mergedConfig = withTelegramFileTransaction(
`${configPath}.transaction`,
() => {
const latestConfig = readTelegramConfigForTransaction(configPath);
const merged = mergeTelegramConfigDelta(
baseConfig as Record<string, unknown>,
desiredConfig as Record<string, unknown>,
latestConfig as Record<string, unknown>,
) as TelegramConfig;
writeTelegramConfigInTransaction(agentDir, configPath, merged);
return merged;
},
);
persistedConfig = cloneTelegramConfig(mergedConfig);
if (mutationVersion === capturedMutationVersion) {
config = cloneTelegramConfig(mergedConfig);
} else {
config = mergeTelegramConfigDelta(
baseConfig as Record<string, unknown>,
config as Record<string, unknown>,
mergedConfig as Record<string, unknown>,
) as TelegramConfig;
}
});
persistQueue = persist.catch(() => undefined);
return persist;
},
};
}
export function createTelegramProactivePushChecker(
configStore: Pick<TelegramConfigStore, "get">,
): () => boolean {
return () => configStore.get().proactivePush ?? false;
}
export function createTelegramProactivePushSetter(
configStore: TelegramMutableConfigStore,
): (enabled: boolean) => Promise<void> {
return async (enabled) => {
await loadLatestTelegramConfig(configStore);
const config = { ...configStore.get(), proactivePush: enabled };
configStore.set(config);
await configStore.persist(config);
};
}
export function createTelegramDraftPreviewsChecker(
configStore: Pick<TelegramConfigStore, "get">,
): () => boolean {
return () => {
const config = configStore.get();
return (
config.assistant?.draftPreviews ??
config.draftPreviews ??
config.richDraftPreviews ??
false
);
};
}
export function createTelegramDraftPreviewsSetter(
configStore: TelegramMutableConfigStore,
): (enabled: boolean) => Promise<void> {
return async (enabled) => {
await loadLatestTelegramConfig(configStore);
const {
draftPreviews: _legacyDraftPreviews,
richDraftPreviews: _legacyRichDraftPreviews,
...current
} = configStore.get();
const config = {
...current,
assistant: { ...current.assistant, draftPreviews: enabled },
};
configStore.set(config);
await configStore.persist(config);
};
}
export function createTelegramAssistantRenderingModeGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => TelegramAssistantRenderingMode {
return () => {
const config = configStore.get();
const mode = config.assistant?.rendering ?? config.assistantRendering;
return mode === "html" ? "html" : "rich";
};
}
export function createTelegramAssistantRenderingModeSetter(
configStore: TelegramMutableConfigStore,
): (mode: TelegramAssistantRenderingMode) => Promise<void> {
return async (mode) => {
await loadLatestTelegramConfig(configStore);
const { assistantRendering: _legacyAssistantRendering, ...current } =
configStore.get();
const config = {
...current,
assistant: { ...current.assistant, rendering: mode },
};
configStore.set(config);
await configStore.persist(config);
};
}
export function createTelegramVoiceReplyModeGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => "manual" | "mirror" | "always" {
return () => {
const mode = configStore.get().voice?.replyMode;
return mode === "mirror" || mode === "always" || mode === "manual"
? mode
: "manual";
};
}
export function createTelegramVoiceReplyModeConfiguredChecker(
configStore: Pick<TelegramConfigStore, "get">,
): () => boolean {
return () => {
const mode = configStore.get().voice?.replyMode;
return mode === "mirror" || mode === "always" || mode === "manual";
};
}
export function createTelegramVoiceReplyModeSetter(
configStore: TelegramMutableConfigStore,
): (replyMode: "manual" | "mirror" | "always" | undefined) => Promise<void> {
return async (replyMode) => {
await loadLatestTelegramConfig(configStore);
const current = configStore.get();
if (replyMode === undefined) {
const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
const next = { ...current };
if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
else delete next.voice;
configStore.set(next);
await configStore.persist(next);
return;
}
const next = { ...current, voice: { ...(current.voice ?? {}), replyMode } };
configStore.set(next);
await configStore.persist(next);
};
}
function getSystemTimezone(): string {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz && tz.length > 0 ? tz : "UTC";
} catch {
return "UTC";
}
}
export function resolveTelegramTimeConfig(
raw: TelegramTimeConfig | undefined,
): ResolvedTelegramTimeConfig {
const injectionMode: TelegramTimeMode =
raw?.injectionMode === "always" || raw?.injectionMode === "interval"
? raw.injectionMode
: "hidden";
const interval =
typeof raw?.interval === "number" && raw.interval > 0
? raw.interval
: 60 * 60 * 1000;
const timezone = getSystemTimezone();
return { injectionMode, interval, timezone };
}
export function createTelegramTimeConfigGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => ResolvedTelegramTimeConfig {
return () => resolveTelegramTimeConfig(configStore.get().time);
}
export function createTelegramTimeInjectionModeGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => TelegramTimeMode {
return () => resolveTelegramTimeConfig(configStore.get().time).injectionMode;
}
export function createTelegramTimeInjectionModeSetter(
configStore: TelegramMutableConfigStore,
): (injectionMode: TelegramTimeMode) => Promise<void> {
return async (injectionMode) => {
await loadLatestTelegramConfig(configStore);
const current = configStore.get();
if (injectionMode === "hidden") {
const { injectionMode: _injectionMode, ...remainingTime } =
current.time ?? {};
const next = { ...current };
if (Object.keys(remainingTime).length > 0) next.time = remainingTime;
else delete next.time;
configStore.set(next);
await configStore.persist(next);
return;
}
const next = {
...current,
time: { ...(current.time ?? {}), injectionMode },
};
configStore.set(next);
await configStore.persist(next);
};
}
export interface TelegramProactivePushTarget {
chatId: number;
threadId?: number;
}
export function createTelegramProactivePushChatIdGetter(deps: {
getActiveTurnChatId: () => number | undefined;
getAllowedUserId: () => number | undefined;
}): () => number | undefined {
return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
}
export function createTelegramProactivePushTargetGetter(deps: {
getActiveTurnTarget: () => TelegramProactivePushTarget | undefined;
getAssignedTarget: () => TelegramProactivePushTarget | undefined;
getAllowedUserId: () => number | undefined;
}): () => TelegramProactivePushTarget | undefined {
return () => {
const activeTarget = deps.getActiveTurnTarget();
if (activeTarget) return activeTarget;
const assignedTarget = deps.getAssignedTarget();
if (assignedTarget) return assignedTarget;
const chatId = deps.getAllowedUserId();
return typeof chatId === "number" ? { chatId } : undefined;
};
}
export function createTelegramConfigControls(
configStore: TelegramMutableConfigStore,
) {
return {
isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
areDraftPreviewsEnabled: createTelegramDraftPreviewsChecker(configStore),
setDraftPreviewsEnabled: createTelegramDraftPreviewsSetter(configStore),
getAssistantRenderingMode:
createTelegramAssistantRenderingModeGetter(configStore),
setAssistantRenderingMode:
createTelegramAssistantRenderingModeSetter(configStore),
getVoiceReplyMode: createTelegramVoiceReplyModeGetter(configStore),
isVoiceReplyModeConfigured:
createTelegramVoiceReplyModeConfiguredChecker(configStore),
setVoiceReplyMode: createTelegramVoiceReplyModeSetter(configStore),
getTimeInjectionMode: createTelegramTimeInjectionModeGetter(configStore),
setTimeInjectionMode: createTelegramTimeInjectionModeSetter(configStore),
};
}
export type TelegramAuthorizationState =
{ kind: "pair"; userId: number } | { kind: "allow" } | { kind: "deny" };
export interface TelegramUserPairingDeps<TContext> {
allowedUserId?: number;
ctx: TContext;
setAllowedUserId: (userId: number) => void;
persistConfig: () => Promise<void>;
updateStatus: (ctx: TContext) => void;
}
export interface TelegramUserPairingRuntimeDeps<TContext> {
getAllowedUserId: () => number | undefined;
setAllowedUserId: (userId: number) => void;
persistConfig: () => Promise<void>;
updateStatus: (ctx: TContext) => void;
}
export interface TelegramUserPairingRuntime<TContext> {
pairIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
}
export function getTelegramAuthorizationState(
userId: number,
allowedUserId?: number,
): TelegramAuthorizationState {
if (allowedUserId === undefined) {
return { kind: "pair", userId };
}
if (userId === allowedUserId) {
return { kind: "allow" };
}
return { kind: "deny" };
}
function isTelegramStaleContextError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("stale after session") ||
error.message.includes("stale ctx"))
);
}
export async function pairTelegramUserIfNeeded<TContext>(
userId: number,
deps: TelegramUserPairingDeps<TContext>,
): Promise<boolean> {
const authorization = getTelegramAuthorizationState(
userId,
deps.allowedUserId,
);
if (authorization.kind !== "pair") return false;
deps.setAllowedUserId(authorization.userId);
await deps.persistConfig();
try {
deps.updateStatus(deps.ctx);
} catch (error) {
if (!isTelegramStaleContextError(error)) throw error;
}
return true;
}
export function createTelegramUserPairingRuntime<TContext>(
deps: TelegramUserPairingRuntimeDeps<TContext>,
): TelegramUserPairingRuntime<TContext> {
return {
pairIfNeeded: (userId, ctx) =>
pairTelegramUserIfNeeded(userId, {
allowedUserId: deps.getAllowedUserId(),
ctx,
setAllowedUserId: deps.setAllowedUserId,
persistConfig: deps.persistConfig,
updateStatus: deps.updateStatus,
}),
};
}
+887
View File
@@ -0,0 +1,887 @@
/**
* Telegram target-aware operational delivery and logical message lifecycle
* Zones: telegram delivery, extension API, runtime binding
* Owns the public extension delivery contract, authorized scope resolution, operational rendering adapter, per-target serialization, chunk reconciliation, generation-fenced logical handles, and process-local runtime membrane; composes the established reply renderer with bus-aware Telegram API ports and excludes bot clients, Pi contexts, and consumer-extension policy
*/
import { markTelegramBusAggregateDelivery } from "./bus.ts";
import {
assertTelegramInlineKeyboardCallbackData,
type TelegramInlineKeyboardMarkup,
} from "./keyboard.ts";
import {
buildTelegramReplyParameters,
renderTelegramMessage,
} from "./replies.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
import {
isTelegramApiCommitUnknownError,
type TelegramBridgeApiRuntime,
} from "./telegram-api.ts";
const TELEGRAM_DELIVERY_RUNTIME_KEY = "__piTelegramDeliveryRuntime__";
class TelegramDeliveryTransportGenerationError extends Error {
constructor() {
super("Telegram Delivery transport generation is no longer active.");
this.name = "TelegramDeliveryTransportGenerationError";
}
}
export type TelegramDeliveryParseMode = "plain" | "html" | "markdown";
export interface TelegramDeliveryView {
text: string;
parseMode?: TelegramDeliveryParseMode;
replyMarkup?: TelegramInlineKeyboardMarkup;
}
export type TelegramDeliveryTarget = TelegramTarget;
export type TelegramDeliveryScope =
| { kind: "active-turn" }
| { kind: "instance" }
| { kind: "aggregate" }
| { kind: "target"; target: TelegramDeliveryTarget };
export interface TelegramDeliveryHandle {
readonly target: TelegramDeliveryTarget;
readonly messageIds: readonly number[];
readonly generation: string;
}
export type TelegramDeliveryFailureReason =
| "runtime-unavailable"
| "target-unavailable"
| "target-unauthorized"
| "stale-handle"
| "invalid-view"
| "commit-unknown"
| "transport-failed";
export type TelegramDeliveryResult<T> =
| { ok: true; value: T }
| {
ok: false;
reason: TelegramDeliveryFailureReason;
message: string;
/** Successfully materialized state that callers may edit or delete to recover. */
partial?: T;
};
export interface SendTelegramViewOptions {
scope: TelegramDeliveryScope;
replyToMessageId?: number;
}
export type TelegramDeliveryChatAction =
"typing" | "upload_document" | "upload_photo" | "record_voice";
/** @internal */
export interface TelegramDeliveryRuntime {
readonly generation: string;
shutdown: () => void;
sendView: (
view: TelegramDeliveryView,
options: SendTelegramViewOptions,
) => Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
editView: (
handle: TelegramDeliveryHandle,
view: TelegramDeliveryView,
) => Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
deleteView: (
handle: TelegramDeliveryHandle,
) => Promise<TelegramDeliveryResult<void>>;
sendChatAction: (
action: TelegramDeliveryChatAction,
scope: TelegramDeliveryScope,
) => Promise<TelegramDeliveryResult<void>>;
}
/** @internal */
export interface TelegramDeliveryTargetResolverDeps {
getActiveTurnTarget: () => TelegramDeliveryTarget | undefined;
getInstanceTarget: () => TelegramDeliveryTarget | undefined;
getAggregateTarget: () => TelegramDeliveryTarget | undefined;
isExplicitTargetAuthorized: (target: TelegramDeliveryTarget) => boolean;
}
/** @internal */
export interface TelegramDeliveryRenderedChunk {
text: string;
parseMode: TelegramDeliveryParseMode;
}
/** @internal */
export interface TelegramDeliveryTransportOptions {
replyToMessageId?: number;
replyMarkup?: TelegramInlineKeyboardMarkup | null;
}
/** @internal */
export interface TelegramDeliveryRuntimeDeps extends TelegramDeliveryTargetResolverDeps {
generation: string;
renderView: (
view: TelegramDeliveryView,
) => readonly TelegramDeliveryRenderedChunk[];
sendChunk: (
target: TelegramDeliveryTarget,
chunk: TelegramDeliveryRenderedChunk,
options: TelegramDeliveryTransportOptions,
) => Promise<number>;
editChunk: (
target: TelegramDeliveryTarget,
messageId: number,
chunk: TelegramDeliveryRenderedChunk,
options: TelegramDeliveryTransportOptions,
) => Promise<void>;
deleteMessage: (
target: TelegramDeliveryTarget,
messageId: number,
) => Promise<void>;
sendChatAction: (
target: TelegramDeliveryTarget,
action: TelegramDeliveryChatAction,
) => Promise<void>;
recordFailure?: (
operation: "send" | "edit" | "delete" | "chat-action",
error: unknown,
target?: TelegramDeliveryTarget,
) => void;
}
/** @internal */
export interface TelegramBridgeDeliveryRuntimeDeps {
generation: string;
getTargetPolicyView: () => TelegramDeliveryTargetPolicyView;
getActiveTurnTarget: () => TelegramDeliveryTarget | undefined;
isTransportActive?: () => boolean;
api: Pick<
TelegramBridgeApiRuntime,
"sendMessage" | "editMessageText" | "deleteMessage" | "sendChatAction"
>;
recordOwnership: (input: {
chatId: number;
messageId: number;
target: TelegramDeliveryTarget;
}) => void;
recordFailure?: TelegramDeliveryRuntimeDeps["recordFailure"];
}
/** @internal */
export function createTelegramDeliveryLifecycleHooks(
createRuntime: () => TelegramDeliveryRuntime,
): {
onSessionStart: () => Promise<void>;
onSessionShutdown: () => Promise<void>;
} {
let runtime: TelegramDeliveryRuntime | undefined;
let unbind: ReturnType<typeof bindTelegramDeliveryRuntime> | undefined;
const stopCurrentRuntime = () => {
unbind?.();
unbind = undefined;
runtime?.shutdown();
runtime = undefined;
};
return {
onSessionStart: async () => {
stopCurrentRuntime();
runtime = createRuntime();
unbind = bindTelegramDeliveryRuntime(runtime);
},
onSessionShutdown: async () => {
stopCurrentRuntime();
},
};
}
/** @internal */
export function createTelegramBridgeDeliveryLifecycleHooks<TTransportStamp>(
deps: Omit<
TelegramBridgeDeliveryRuntimeDeps,
"generation" | "isTransportActive"
> & {
generationSeed: string;
getTransportStamp?: () => TTransportStamp;
isTransportStampActive?: (stamp: TTransportStamp) => boolean;
},
): ReturnType<typeof createTelegramDeliveryLifecycleHooks> {
let generationSequence = 0;
return createTelegramDeliveryLifecycleHooks(() => {
const transportStamp = deps.getTransportStamp?.();
return createTelegramBridgeDeliveryRuntime({
...deps,
generation: `${deps.generationSeed}:${++generationSequence}`,
isTransportActive:
transportStamp !== undefined && deps.isTransportStampActive
? () => deps.isTransportStampActive?.(transportStamp) ?? false
: undefined,
});
});
}
interface TelegramDeliveryRuntimeRegistry {
runtime?: TelegramDeliveryRuntime;
}
function getTelegramDeliveryRuntimeRegistry(): TelegramDeliveryRuntimeRegistry {
const globals = globalThis as Record<string, unknown>;
const existing = globals[TELEGRAM_DELIVERY_RUNTIME_KEY];
if (existing && typeof existing === "object" && "runtime" in existing) {
return existing as TelegramDeliveryRuntimeRegistry;
}
const registry: TelegramDeliveryRuntimeRegistry = {};
globals[TELEGRAM_DELIVERY_RUNTIME_KEY] = registry;
return registry;
}
function failure<T>(
reason: TelegramDeliveryFailureReason,
message: string,
partial?: T,
): TelegramDeliveryResult<T> {
return partial === undefined
? { ok: false, reason, message }
: { ok: false, reason, message, partial };
}
/** @internal */
export interface TelegramDeliveryTargetPolicyView {
canDeliver: boolean;
ownsDirect: boolean;
allowedChatId?: number;
followerTarget?: TelegramDeliveryTarget;
leaderTarget?: TelegramDeliveryTarget;
liveTargets?: readonly TelegramDeliveryTarget[];
}
/** @internal */
export interface TelegramDeliveryTargetPolicyRuntime {
getTargetPolicyView(): TelegramDeliveryTargetPolicyView;
getActiveTurnTarget(): TelegramDeliveryTarget | undefined;
}
/** @internal */
export function createTelegramDeliveryTargetPolicyRuntime(deps: {
ownsDirect(): boolean;
isFollowerRegistered(): boolean;
getAllowedChatId(): number | undefined;
getFollowerTarget(): TelegramDeliveryTarget | undefined;
getLeaderTarget(): TelegramDeliveryTarget | undefined;
listThreadRecords(): readonly { target: TelegramDeliveryTarget }[];
getActiveTurnTarget(): TelegramDeliveryTarget | undefined;
getActiveGuestQueryId(): string | undefined;
}): TelegramDeliveryTargetPolicyRuntime {
return {
getTargetPolicyView() {
const ownsDirect = deps.ownsDirect();
return {
canDeliver: ownsDirect || deps.isFollowerRegistered(),
ownsDirect,
allowedChatId: deps.getAllowedChatId(),
followerTarget: deps.getFollowerTarget(),
leaderTarget: deps.getLeaderTarget(),
liveTargets: deps.listThreadRecords().map((record) => record.target),
};
},
getActiveTurnTarget() {
return deps.getActiveGuestQueryId()
? undefined
: deps.getActiveTurnTarget();
},
};
}
/** @internal */
export function resolveTelegramDeliveryInstanceTarget(
view: TelegramDeliveryTargetPolicyView,
): TelegramDeliveryTarget | undefined {
if (!view.canDeliver) return undefined;
return (
view.followerTarget ??
view.leaderTarget ??
(view.allowedChatId === undefined
? undefined
: { chatId: view.allowedChatId })
);
}
/** @internal */
export function resolveTelegramDeliveryAggregateTarget(
view: TelegramDeliveryTargetPolicyView,
): TelegramDeliveryTarget | undefined {
return !view.canDeliver || view.allowedChatId === undefined
? undefined
: { chatId: view.allowedChatId };
}
/** @internal */
export function isTelegramDeliveryExplicitTargetAuthorized(
candidate: TelegramDeliveryTarget,
view: TelegramDeliveryTargetPolicyView,
): boolean {
if (
!view.canDeliver ||
view.allowedChatId === undefined ||
candidate.chatId !== view.allowedChatId
) {
return false;
}
if (candidate.threadId === undefined) return true;
if (view.followerTarget) {
return areDeliveryTargetsEqual(candidate, view.followerTarget);
}
if (!view.ownsDirect) return false;
if (
view.leaderTarget &&
areDeliveryTargetsEqual(candidate, view.leaderTarget)
) {
return true;
}
return (view.liveTargets ?? []).some(function (target) {
return areDeliveryTargetsEqual(candidate, target);
});
}
function areDeliveryTargetsEqual(
left: TelegramDeliveryTarget,
right: TelegramDeliveryTarget,
): boolean {
return left.chatId === right.chatId && left.threadId === right.threadId;
}
function cloneTarget(target: TelegramDeliveryTarget): TelegramDeliveryTarget {
return target.threadId === undefined
? { chatId: target.chatId }
: { chatId: target.chatId, threadId: target.threadId };
}
function targetKey(target: TelegramDeliveryTarget): string {
return `${target.chatId}:${target.threadId ?? "root"}`;
}
function resolveTelegramDeliveryTarget(
scope: TelegramDeliveryScope,
deps: TelegramDeliveryTargetResolverDeps,
): TelegramDeliveryResult<TelegramDeliveryTarget> {
if (scope.kind === "target") {
if (!deps.isExplicitTargetAuthorized(scope.target)) {
return failure(
"target-unauthorized",
"Telegram delivery target is not authorized for this runtime.",
);
}
return { ok: true, value: cloneTarget(scope.target) };
}
const target =
scope.kind === "active-turn"
? deps.getActiveTurnTarget()
: scope.kind === "instance"
? deps.getInstanceTarget()
: deps.getAggregateTarget();
if (!target) {
return failure(
"target-unavailable",
`Telegram delivery ${scope.kind} target is unavailable.`,
);
}
return { ok: true, value: cloneTarget(target) };
}
function createTelegramDeliveryTargetQueue() {
const queues = new Map<string, Promise<void>>();
return async function run<T>(
target: TelegramDeliveryTarget,
operation: () => Promise<T>,
): Promise<T> {
const key = targetKey(target);
const previous = queues.get(key) ?? Promise.resolve();
const current = previous.then(operation, operation);
const settled = current.then(
() => {},
() => {},
);
queues.set(key, settled);
try {
return await current;
} finally {
if (queues.get(key) === settled) queues.delete(key);
}
};
}
function getChunkTransportOptions(
view: TelegramDeliveryView,
index: number,
chunkCount: number,
replyToMessageId?: number,
editing = false,
): TelegramDeliveryTransportOptions {
const isFirst = index === 0;
const isLast = index === chunkCount - 1;
return {
...(isFirst && replyToMessageId !== undefined ? { replyToMessageId } : {}),
...(isLast
? { replyMarkup: view.replyMarkup ?? (editing ? null : undefined) }
: editing
? { replyMarkup: null }
: {}),
};
}
/** @internal */
export function createTelegramDeliveryRuntime(
deps: TelegramDeliveryRuntimeDeps,
): TelegramDeliveryRuntime {
let active = true;
const handleBindings = new WeakMap<
TelegramDeliveryHandle,
{ target: TelegramDeliveryTarget; messageIds: readonly number[] }
>();
const runForTarget = createTelegramDeliveryTargetQueue();
const render = (
view: TelegramDeliveryView,
): TelegramDeliveryResult<readonly TelegramDeliveryRenderedChunk[]> => {
const chunks = deps.renderView(view);
if (
chunks.length === 0 ||
chunks.some(
(chunk) =>
typeof chunk.text !== "string" || chunk.text.trim().length === 0,
)
) {
return failure(
"invalid-view",
"Telegram delivery view rendered no content.",
);
}
return { ok: true, value: chunks };
};
const inactive = <T>(): TelegramDeliveryResult<T> =>
failure(
"runtime-unavailable",
"Telegram delivery runtime generation is inactive.",
);
const transportFailure = <T>(
operation: "send" | "edit" | "delete" | "chat-action",
error: unknown,
target?: TelegramDeliveryTarget,
partial?: T,
): TelegramDeliveryResult<T> => {
deps.recordFailure?.(operation, error, target);
if (error instanceof TelegramDeliveryTransportGenerationError) {
return inactive();
}
return failure(
isTelegramApiCommitUnknownError(error)
? "commit-unknown"
: "transport-failed",
isTelegramApiCommitUnknownError(error)
? `Telegram delivery ${operation} may have committed before transport failed.`
: `Telegram delivery ${operation} failed.`,
partial,
);
};
const createHandle = (
target: TelegramDeliveryTarget,
messageIds: readonly number[],
): TelegramDeliveryHandle => {
const canonicalTarget = Object.freeze(cloneTarget(target));
const canonicalMessageIds = Object.freeze([...messageIds]);
const handle = Object.freeze({
target: canonicalTarget,
messageIds: canonicalMessageIds,
generation: deps.generation,
});
handleBindings.set(handle, {
target: canonicalTarget,
messageIds: canonicalMessageIds,
});
return handle;
};
const resolveHandle = (
handle: TelegramDeliveryHandle,
): TelegramDeliveryResult<{
target: TelegramDeliveryTarget;
messageIds: readonly number[];
}> => {
if (!active) return inactive();
const binding = handleBindings.get(handle);
if (!binding || handle.generation !== deps.generation) {
return failure(
"stale-handle",
"Telegram delivery handle belongs to an inactive runtime generation.",
);
}
const authorized = resolveTelegramDeliveryTarget(
{ kind: "target", target: binding.target },
deps,
);
if (!authorized.ok) {
return failure(authorized.reason, authorized.message);
}
return { ok: true, value: binding };
};
return {
generation: deps.generation,
shutdown() {
active = false;
},
async sendView(view, options) {
if (!active) return inactive();
const resolved = resolveTelegramDeliveryTarget(options.scope, deps);
if (!resolved.ok) return failure(resolved.reason, resolved.message);
const rendered = render(view);
if (!rendered.ok) return failure(rendered.reason, rendered.message);
const target = resolved.value;
return runForTarget(target, async () => {
if (!active) return inactive();
const messageIds: number[] = [];
try {
for (const [index, chunk] of rendered.value.entries()) {
if (!active) return inactive();
const messageId = await deps.sendChunk(
target,
chunk,
getChunkTransportOptions(
view,
index,
rendered.value.length,
options.replyToMessageId,
),
);
messageIds.push(messageId);
if (!active) return inactive();
}
return { ok: true, value: createHandle(target, messageIds) };
} catch (error) {
return active
? transportFailure(
"send",
error,
target,
messageIds.length > 0
? createHandle(target, messageIds)
: undefined,
)
: inactive();
}
});
},
async editView(handle, view) {
const resolved = resolveHandle(handle);
if (!resolved.ok) return failure(resolved.reason, resolved.message);
const rendered = render(view);
if (!rendered.ok) return failure(rendered.reason, rendered.message);
const target = resolved.value.target;
return runForTarget(target, async () => {
if (!active) return inactive();
const visibleMessageIds = [...resolved.value.messageIds];
try {
const sharedCount = Math.min(
visibleMessageIds.length,
rendered.value.length,
);
for (let index = 0; index < sharedCount; index += 1) {
if (!active) return inactive();
await deps.editChunk(
target,
visibleMessageIds[index]!,
rendered.value[index]!,
getChunkTransportOptions(
view,
index,
rendered.value.length,
undefined,
true,
),
);
if (!active) return inactive();
}
for (
let index = sharedCount;
index < rendered.value.length;
index += 1
) {
if (!active) return inactive();
visibleMessageIds.push(
await deps.sendChunk(
target,
rendered.value[index]!,
getChunkTransportOptions(view, index, rendered.value.length),
),
);
if (!active) return inactive();
}
const removedMessageIds = visibleMessageIds.slice(
rendered.value.length,
);
for (const messageId of removedMessageIds) {
if (!active) return inactive();
await deps.deleteMessage(target, messageId);
visibleMessageIds.splice(visibleMessageIds.indexOf(messageId), 1);
if (!active) return inactive();
}
return {
ok: true,
value: createHandle(target, visibleMessageIds),
};
} catch (error) {
return active
? transportFailure(
"edit",
error,
target,
createHandle(target, visibleMessageIds),
)
: inactive();
}
});
},
async deleteView(handle) {
const resolved = resolveHandle(handle);
if (!resolved.ok) return failure(resolved.reason, resolved.message);
const target = resolved.value.target;
return runForTarget(target, async () => {
if (!active) return inactive();
try {
for (const messageId of resolved.value.messageIds) {
if (!active) return inactive();
await deps.deleteMessage(target, messageId);
if (!active) return inactive();
}
return { ok: true, value: undefined };
} catch (error) {
return active
? transportFailure("delete", error, target)
: inactive();
}
});
},
async sendChatAction(action, scope) {
if (!active) return inactive();
const resolved = resolveTelegramDeliveryTarget(scope, deps);
if (!resolved.ok) return failure(resolved.reason, resolved.message);
const target = resolved.value;
return runForTarget(target, async () => {
if (!active) return inactive();
try {
await deps.sendChatAction(target, action);
if (!active) return inactive();
return { ok: true, value: undefined };
} catch (error) {
return active
? transportFailure("chat-action", error, target)
: inactive();
}
});
},
};
}
/** @internal */
export function createTelegramBridgeDeliveryRuntime(
deps: TelegramBridgeDeliveryRuntimeDeps,
): TelegramDeliveryRuntime {
const getPolicyView = deps.getTargetPolicyView;
const assertTransportActive = (): void => {
if (deps.isTransportActive?.() === false) {
throw new TelegramDeliveryTransportGenerationError();
}
};
return createTelegramDeliveryRuntime({
generation: deps.generation,
getActiveTurnTarget: deps.getActiveTurnTarget,
getInstanceTarget() {
return resolveTelegramDeliveryInstanceTarget(getPolicyView());
},
getAggregateTarget() {
return resolveTelegramDeliveryAggregateTarget(getPolicyView());
},
isExplicitTargetAuthorized(target) {
return isTelegramDeliveryExplicitTargetAuthorized(
target,
getPolicyView(),
);
},
renderView(view) {
assertTelegramInlineKeyboardCallbackData(view.replyMarkup);
return renderTelegramMessage(view.text, {
mode: view.parseMode ?? "plain",
}).map(function (chunk) {
return {
text: chunk.text,
parseMode: chunk.parseMode === "HTML" ? "html" : "plain",
} as const;
});
},
async sendChunk(target, chunk, options) {
assertTransportActive();
const replyParameters = buildTelegramReplyParameters(
target.chatId,
options.replyToMessageId,
target,
);
const body = {
chat_id: target.chatId,
text: chunk.text,
...(chunk.parseMode === "html" ? { parse_mode: "HTML" as const } : {}),
...getTelegramTargetThreadParams(target),
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(options.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
};
const sent = await deps.api.sendMessage(
target.threadId === undefined
? markTelegramBusAggregateDelivery(body)
: body,
);
assertTransportActive();
deps.recordOwnership({
chatId: target.chatId,
messageId: sent.message_id,
target,
});
return sent.message_id;
},
async editChunk(target, messageId, chunk, options) {
assertTransportActive();
await deps.api.editMessageText({
chat_id: target.chatId,
message_id: messageId,
text: chunk.text,
...(chunk.parseMode === "html" ? { parse_mode: "HTML" as const } : {}),
reply_markup:
options.replyMarkup === null
? { inline_keyboard: [] }
: options.replyMarkup,
});
},
deleteMessage(target, messageId) {
assertTransportActive();
return deps.api.deleteMessage(target.chatId, messageId);
},
async sendChatAction(target, action) {
assertTransportActive();
await deps.api.sendChatAction(target.chatId, action, {
message_thread_id: target.threadId,
});
},
recordFailure: deps.recordFailure,
});
}
function getBoundTelegramDeliveryRuntime():
TelegramDeliveryRuntime | TelegramDeliveryResult<never> {
const runtime = getTelegramDeliveryRuntimeRegistry().runtime;
return (
runtime ??
failure(
"runtime-unavailable",
"Telegram delivery runtime is unavailable in this Pi session.",
)
);
}
function isFailure(
value: TelegramDeliveryRuntime | TelegramDeliveryResult<never>,
): value is TelegramDeliveryResult<never> {
return "ok" in value;
}
function validateView<T>(
view: TelegramDeliveryView,
): TelegramDeliveryResult<T> | undefined {
if (typeof view.text !== "string" || view.text.trim().length === 0) {
return failure("invalid-view", "Telegram delivery view text is empty.");
}
return undefined;
}
async function runDeliveryOperation<T>(
operation: (
runtime: TelegramDeliveryRuntime,
) => Promise<TelegramDeliveryResult<T>>,
): Promise<TelegramDeliveryResult<T>> {
const runtime = getBoundTelegramDeliveryRuntime();
if (isFailure(runtime)) return runtime;
try {
return await operation(runtime);
} catch {
return failure("transport-failed", "Telegram delivery failed.");
}
}
/** @internal */
export function bindTelegramDeliveryRuntime(
runtime: TelegramDeliveryRuntime,
): () => void {
const registry = getTelegramDeliveryRuntimeRegistry();
if (registry.runtime !== runtime) registry.runtime?.shutdown();
registry.runtime = runtime;
return () => {
if (registry.runtime === runtime) registry.runtime = undefined;
};
}
/** @internal */
export function clearTelegramDeliveryRuntime(): void {
const registry = getTelegramDeliveryRuntimeRegistry();
registry.runtime?.shutdown();
registry.runtime = undefined;
}
export async function sendTelegramView(
view: TelegramDeliveryView,
options: SendTelegramViewOptions,
): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>> {
const invalid = validateView<TelegramDeliveryHandle>(view);
if (invalid) return invalid;
return runDeliveryOperation((runtime) => runtime.sendView(view, options));
}
export async function editTelegramView(
handle: TelegramDeliveryHandle,
view: TelegramDeliveryView,
): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>> {
const invalid = validateView<TelegramDeliveryHandle>(view);
if (invalid) return invalid;
return runDeliveryOperation((runtime) => {
if (runtime.generation !== handle.generation) {
return Promise.resolve(
failure(
"stale-handle",
"Telegram delivery handle belongs to an inactive runtime generation.",
),
);
}
return runtime.editView(handle, view);
});
}
export async function deleteTelegramView(
handle: TelegramDeliveryHandle,
): Promise<TelegramDeliveryResult<void>> {
return runDeliveryOperation((runtime) => {
if (runtime.generation !== handle.generation) {
return Promise.resolve(
failure(
"stale-handle",
"Telegram delivery handle belongs to an inactive runtime generation.",
),
);
}
return runtime.deleteView(handle);
});
}
export async function sendTelegramChatAction(
action: TelegramDeliveryChatAction,
options: { scope: TelegramDeliveryScope },
): Promise<TelegramDeliveryResult<void>> {
return runDeliveryOperation((runtime) =>
runtime.sendChatAction(action, options.scope),
);
}
+828
View File
@@ -0,0 +1,828 @@
/**
* Telegram inbound handler pipeline
* Zones: telegram inbound, command templates, prompt preparation
* Owns MIME/type matching, command-template execution, fallback handling, and prompt injection before prompt enqueueing
*/
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import {
buildCommandTemplateInvocation,
expandCommandTemplateConfigs,
normalizeCommandTemplateConfig,
substituteCommandTemplateToken,
type CommandTemplateConfig,
type CommandTemplateObjectConfig,
} from "./command-templates.ts";
import { getTelegramVoiceTranscriptionProviders } from "./voice.ts";
const DEFAULT_INBOUND_HANDLER_TIMEOUT_MS = 120_000;
const INBOUND_HANDLER_REGISTRY_KEY = "__piTelegramInboundHandlers__";
const MAX_INBOUND_HANDLER_OUTPUT_LENGTH = 12_000;
const MAX_INBOUND_HANDLER_FAILURE_STREAM_LENGTH = 4_000;
type TelegramInboundCommandTemplateConfig =
| string
| CommandTemplateObjectConfig;
export interface TelegramInboundHandlerConfig {
match?: string | string[];
mime?: string | string[];
type?: string | string[];
template?: string | TelegramInboundCommandTemplateConfig[];
args?: string[];
defaults?: Record<string, unknown>;
timeout?: number | string;
}
export interface TelegramInboundHandlerFile {
path: string;
fileName?: string;
mimeType?: string;
kind?: string;
isImage?: boolean;
}
export interface TelegramInboundHandlerOutput {
file: TelegramInboundHandlerFile;
output: string;
handler: TelegramInboundHandlerConfig;
}
export interface TelegramInboundHandlerProcessResult<
TFile extends TelegramInboundHandlerFile = TelegramInboundHandlerFile,
> {
rawText: string;
promptFiles: TFile[];
handlerOutputs: string[];
handledFiles: TelegramInboundHandlerOutput[];
}
export interface TelegramInboundHandlerExecOptions {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
stdin?: string;
retry?: number;
}
export interface TelegramInboundHandlerExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
export interface TelegramInboundHandlerRuntimeContext {
cwd: string;
}
export interface TelegramInboundHandlerRuntimeDeps<TContext> {
getHandlers: () => TelegramInboundHandlerConfig[] | undefined;
execCommand: (
command: string,
args: string[],
options?: TelegramInboundHandlerExecOptions,
) => Promise<TelegramInboundHandlerExecResult>;
getCwd: (ctx: TContext) => string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramInboundHandlerRuntime<TContext> {
process: <TFile extends TelegramInboundHandlerFile>(
files: TFile[],
rawText: string,
ctx: TContext,
) => Promise<TelegramInboundHandlerProcessResult<TFile>>;
}
export type TelegramInboundProgrammaticHandlerResult =
| string
| { text: string }
| undefined;
export interface TelegramInboundProgrammaticHandlerInput {
kind: string;
text?: string;
file?: TelegramInboundHandlerFile;
mimeType?: string;
}
export type TelegramInboundProgrammaticHandler = (
input: TelegramInboundProgrammaticHandlerInput,
options?: { cwd?: string },
) => Promise<TelegramInboundProgrammaticHandlerResult>;
export interface TelegramInboundHandlerRegistry {
handlers: Map<string, TelegramInboundProgrammaticHandler[]>;
}
interface InboundHandlerInvocation {
command: string;
args: string[];
}
const BUILT_IN_TEXT_ATTACHMENT_MAX_BYTES = 1_000_000;
function getOrCreateInboundHandlerRegistry(): TelegramInboundHandlerRegistry {
const existing = (globalThis as Record<string, unknown>)[
INBOUND_HANDLER_REGISTRY_KEY
];
if (
existing &&
typeof existing === "object" &&
existing !== null &&
"handlers" in existing &&
existing.handlers instanceof Map
) {
return existing as TelegramInboundHandlerRegistry;
}
const registry: TelegramInboundHandlerRegistry = {
handlers: new Map(),
};
(globalThis as Record<string, unknown>)[INBOUND_HANDLER_REGISTRY_KEY] =
registry;
return registry;
}
export function registerTelegramInboundHandler(
kind: string,
handler: TelegramInboundProgrammaticHandler,
): () => void {
const normalizedKind = kind.trim() || "*";
const registry = getOrCreateInboundHandlerRegistry();
const list = registry.handlers.get(normalizedKind) ?? [];
list.push(handler);
registry.handlers.set(normalizedKind, list);
return () => {
const updated = registry.handlers.get(normalizedKind) ?? [];
const index = updated.indexOf(handler);
if (index !== -1) {
updated.splice(index, 1);
registry.handlers.set(normalizedKind, updated);
}
};
}
export function getTelegramInboundProgrammaticHandlers(
kind: string,
): TelegramInboundProgrammaticHandler[] {
const registry = getOrCreateInboundHandlerRegistry();
return [
...(registry.handlers.get(kind) ?? []),
...(kind === "*" ? [] : (registry.handlers.get("*") ?? [])),
];
}
export function hasTelegramInboundHandler(kind?: string): boolean {
const registry = getOrCreateInboundHandlerRegistry();
if (kind !== undefined) return (registry.handlers.get(kind)?.length ?? 0) > 0;
return Array.from(registry.handlers.values()).some((list) => list.length > 0);
}
export function clearTelegramInboundHandlers(): void {
getOrCreateInboundHandlerRegistry().handlers.clear();
}
function truncateTelegramInboundText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trimEnd()}… [truncated ${text.length - maxLength} chars]`;
}
function truncateTelegramInboundOutput(text: string): string {
return truncateTelegramInboundText(text, MAX_INBOUND_HANDLER_OUTPUT_LENGTH);
}
function truncateTelegramInboundFailureStream(text: string): string {
return truncateTelegramInboundText(
text.trimEnd(),
MAX_INBOUND_HANDLER_FAILURE_STREAM_LENGTH,
);
}
function normalizeInboundProgrammaticHandlerText(
result: TelegramInboundProgrammaticHandlerResult,
): string | undefined {
const text = typeof result === "string" ? result : result?.text;
const normalized = text?.trim();
return normalized ? truncateTelegramInboundOutput(normalized) : undefined;
}
function normalizeStringList(value: string | string[] | undefined): string[] {
if (Array.isArray(value)) {
return value
.map(String)
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
function matchesWildcard(pattern: string, value: string | undefined): boolean {
if (!value) return false;
const normalizedPattern = pattern.toLowerCase();
const normalizedValue = value.toLowerCase();
if (normalizedPattern === "*") return true;
const escaped = normalizedPattern
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\\*/g, ".*");
return new RegExp(`^${escaped}$`).test(normalizedValue);
}
function handlerHasSelectors(handler: TelegramInboundHandlerConfig): boolean {
return (
normalizeStringList(handler.match).length > 0 ||
normalizeStringList(handler.mime).length > 0 ||
normalizeStringList(handler.type).length > 0
);
}
function matchesAnyPattern(
patterns: string[],
value: string | undefined,
): boolean {
return patterns.some((pattern) => matchesWildcard(pattern, value));
}
function isTelegramTextMimeType(mimeType: string | undefined): boolean {
return matchesWildcard("text/*", mimeType);
}
export function telegramInboundHandlerMatchesFile(
handler: TelegramInboundHandlerConfig,
file: TelegramInboundHandlerFile,
): boolean {
if (!handlerHasSelectors(handler)) return true;
const matchPatterns = normalizeStringList(handler.match);
const mimePatterns = normalizeStringList(handler.mime);
const typePatterns = normalizeStringList(handler.type);
if (matchesAnyPattern(mimePatterns, file.mimeType)) return true;
if (matchesAnyPattern(typePatterns, file.kind)) return true;
if (matchesAnyPattern(matchPatterns, file.mimeType)) return true;
return matchesAnyPattern(matchPatterns, file.kind);
}
export function findTelegramInboundHandlers(
handlers: TelegramInboundHandlerConfig[] | undefined,
file: TelegramInboundHandlerFile,
): TelegramInboundHandlerConfig[] {
if (!Array.isArray(handlers)) return [];
return handlers.filter(
(handler) =>
!!handler &&
typeof handler === "object" &&
telegramInboundHandlerMatchesFile(handler, file),
);
}
export function findTelegramInboundHandler(
handlers: TelegramInboundHandlerConfig[] | undefined,
file: TelegramInboundHandlerFile,
): TelegramInboundHandlerConfig | undefined {
return findTelegramInboundHandlers(handlers, file)[0];
}
function hasInboundFilePlaceholder(value: string): boolean {
return /\{file\}/.test(value);
}
function getTelegramInboundHandlerTemplateValues(
file: TelegramInboundHandlerFile,
text = "",
): Record<string, string> {
return {
file: file.path,
mime: file.mimeType ?? "",
text,
type: file.kind ?? "",
};
}
function buildTelegramInboundTemplateInvocation(
handler: CommandTemplateConfig,
file: TelegramInboundHandlerFile,
cwd: string,
appendFileIfMissing = true,
): InboundHandlerInvocation {
const values = getTelegramInboundHandlerTemplateValues(file);
const templateConfig = normalizeCommandTemplateConfig(handler);
const hadFilePlaceholder =
typeof templateConfig.template === "string"
? hasInboundFilePlaceholder(templateConfig.template)
: false;
const invocation = buildCommandTemplateInvocation(handler, values, cwd, {
emptyMessage: "Inbound handler template is empty",
missingLabel: "inbound handler template",
});
if (appendFileIfMissing && !hadFilePlaceholder)
invocation.args.push(file.path);
return invocation;
}
export function buildTelegramInboundHandlerInvocation(
handler: CommandTemplateConfig,
file: TelegramInboundHandlerFile,
cwd: string,
appendFileIfMissing = true,
): InboundHandlerInvocation {
const { template } = normalizeCommandTemplateConfig(handler);
if (!template) throw new Error("Inbound handler template is required");
return buildTelegramInboundTemplateInvocation(
handler,
file,
cwd,
appendFileIfMissing,
);
}
function resolveTelegramInboundNumericControlField(
value: number | string | undefined,
values: Record<string, unknown>,
label: string,
): number | undefined {
if (value === undefined) return undefined;
const resolved =
typeof value === "string"
? substituteCommandTemplateToken(value, values, label)
: value;
if (resolved === "") return undefined;
const numeric = Number(resolved);
if (!Number.isFinite(numeric) || numeric < 0)
throw new Error(`Command template ${label} must be a non-negative number.`);
return numeric;
}
function getTelegramInboundHandlerConfiguredTimeout(
handler: TelegramInboundCommandTemplateConfig,
): number | undefined {
const timeout = typeof handler === "string" ? undefined : handler.timeout;
return resolveTelegramInboundNumericControlField(timeout, {}, "timeout");
}
function getTelegramInboundHandlerTimeout(
handler: TelegramInboundCommandTemplateConfig,
): number {
return (
getTelegramInboundHandlerConfiguredTimeout(handler) ??
DEFAULT_INBOUND_HANDLER_TIMEOUT_MS
);
}
function getRemainingTelegramInboundTimeout(
timeout: number,
startedAt: number,
): number {
return Math.max(1, timeout - (Date.now() - startedAt));
}
function getTelegramInboundInitialCompositionStepTimeout(
handler: TelegramInboundHandlerConfig,
step: TelegramInboundCommandTemplateConfig,
): number {
const timeout = getTelegramInboundHandlerTimeout(handler);
const stepTimeout = getTelegramInboundHandlerConfiguredTimeout(step);
return stepTimeout === undefined ? timeout : Math.min(stepTimeout, timeout);
}
function getTelegramInboundCompositionStepTimeout(
handler: TelegramInboundHandlerConfig,
step: TelegramInboundCommandTemplateConfig,
startedAt: number,
): number {
const remaining = getRemainingTelegramInboundTimeout(
getTelegramInboundHandlerTimeout(handler),
startedAt,
);
const stepTimeout = getTelegramInboundHandlerConfiguredTimeout(step);
return stepTimeout === undefined
? remaining
: Math.min(stepTimeout, remaining);
}
function getTelegramInboundHandlerKind(
handler: TelegramInboundHandlerConfig,
): string {
if (Array.isArray(handler.template)) return "composition";
if (handler.template) return "template";
return "unknown";
}
function formatTelegramInboundHandlerFailure(
result: TelegramInboundHandlerExecResult,
): string {
const parts = [
`Inbound handler exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
];
if (result.stderr.trim())
parts.push(`stderr:\n${truncateTelegramInboundFailureStream(result.stderr)}`);
if (result.stdout.trim())
parts.push(`stdout:\n${truncateTelegramInboundFailureStream(result.stdout)}`);
return parts.join("\n\n");
}
async function executeTelegramInboundHandlerInvocation(
handler: TelegramInboundCommandTemplateConfig,
file: TelegramInboundHandlerFile,
cwd: string,
deps: Pick<TelegramInboundHandlerRuntimeDeps<unknown>, "execCommand">,
appendFileIfMissing = true,
timeout = getTelegramInboundHandlerTimeout(handler),
stdin?: string,
): Promise<string> {
const invocation = buildTelegramInboundHandlerInvocation(
handler,
file,
cwd,
appendFileIfMissing,
);
const result = await deps.execCommand(invocation.command, invocation.args, {
cwd,
timeout,
...(typeof handler === "object" && handler.retry !== undefined
? { retry: resolveTelegramInboundNumericControlField(handler.retry, {}, "retry") }
: {}),
...(stdin !== undefined ? { stdin } : {}),
});
if (result.code !== 0)
throw new Error(formatTelegramInboundHandlerFailure(result));
return truncateTelegramInboundOutput(result.stdout);
}
function getTelegramInboundHandlerCompositionSteps(
handler: TelegramInboundHandlerConfig,
): TelegramInboundCommandTemplateConfig[] {
if (Array.isArray(handler.template)) {
return expandCommandTemplateConfigs(
handler,
) as TelegramInboundCommandTemplateConfig[];
}
return [];
}
function getTelegramTextHandlerFile(): TelegramInboundHandlerFile {
return {
path: "",
fileName: "message.txt",
mimeType: "text/plain",
kind: "text",
isImage: false,
};
}
function findTelegramTextHandlers(
handlers: TelegramInboundHandlerConfig[] | undefined,
): TelegramInboundHandlerConfig[] {
if (!Array.isArray(handlers)) return [];
const textFile = getTelegramTextHandlerFile();
return handlers.filter(
(handler) =>
!!handler &&
typeof handler === "object" &&
handlerHasSelectors(handler) &&
telegramInboundHandlerMatchesFile(handler, textFile),
);
}
function buildTelegramTextHandlerInvocation(
handler: CommandTemplateConfig,
text: string,
cwd: string,
): InboundHandlerInvocation {
const values = getTelegramInboundHandlerTemplateValues(
getTelegramTextHandlerFile(),
text,
);
const { template } = normalizeCommandTemplateConfig(handler);
if (!template) throw new Error("Text handler template is required");
return buildCommandTemplateInvocation(handler, values, cwd, {
emptyMessage: "Text handler template is empty",
missingLabel: "text handler template",
});
}
async function executeTelegramTextHandlerInvocation(
handler: TelegramInboundCommandTemplateConfig,
text: string,
cwd: string,
deps: Pick<TelegramInboundHandlerRuntimeDeps<unknown>, "execCommand">,
timeout = getTelegramInboundHandlerTimeout(handler),
): Promise<string> {
const invocation = buildTelegramTextHandlerInvocation(handler, text, cwd);
const result = await deps.execCommand(invocation.command, invocation.args, {
cwd,
timeout,
stdin: text,
...(typeof handler === "object" && handler.retry !== undefined
? { retry: resolveTelegramInboundNumericControlField(handler.retry, {}, "retry") }
: {}),
});
if (result.code !== 0)
throw new Error(formatTelegramInboundHandlerFailure(result));
return truncateTelegramInboundOutput(result.stdout);
}
async function executeTelegramTextHandler(
handler: TelegramInboundHandlerConfig,
text: string,
cwd: string,
deps: Pick<TelegramInboundHandlerRuntimeDeps<unknown>, "execCommand">,
): Promise<string> {
const steps = getTelegramInboundHandlerCompositionSteps(handler);
if (steps.length === 0) {
return (
await executeTelegramTextHandlerInvocation(handler, text, cwd, deps)
).trim();
}
const startedAt = Date.now();
let output = text;
for (const [index, step] of steps.entries()) {
try {
output = await executeTelegramTextHandlerInvocation(
step,
output,
cwd,
deps,
index === 0
? getTelegramInboundInitialCompositionStepTimeout(handler, step)
: getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
);
} catch (error) {
if (typeof step === "object" && step.failure === "root") throw error;
output = "";
}
if (index > 0 && !output) output = text;
}
return output.trim();
}
async function processTelegramTextHandlers(options: {
rawText: string;
handlers?: TelegramInboundHandlerConfig[];
cwd: string;
execCommand: TelegramInboundHandlerRuntimeDeps<unknown>["execCommand"];
recordRuntimeEvent?: TelegramInboundHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
}): Promise<string> {
if (!options.rawText) return options.rawText;
let text = options.rawText;
for (const handler of findTelegramTextHandlers(options.handlers)) {
try {
const output = await executeTelegramTextHandler(
handler,
text,
options.cwd,
options,
);
if (output) text = output;
} catch (error) {
options.recordRuntimeEvent?.("inbound-text-handler", error, {
handler: getTelegramInboundHandlerKind(handler),
});
}
}
for (const handler of getTelegramInboundProgrammaticHandlers("text")) {
try {
const output = normalizeInboundProgrammaticHandlerText(
await handler(
{ kind: "text", text, mimeType: "text/plain" },
{ cwd: options.cwd },
),
);
if (output) text = output;
} catch (error) {
options.recordRuntimeEvent?.("inbound-programmatic-handler", error, {
kind: "text",
});
}
}
return text;
}
function isTelegramVoiceLikeFile(file: TelegramInboundHandlerFile): boolean {
return (
file.kind === "voice" ||
file.kind === "audio" ||
matchesWildcard("audio/*", file.mimeType)
);
}
async function processTelegramFileWithProgrammaticHandlers(
file: TelegramInboundHandlerFile,
options: {
cwd: string;
recordRuntimeEvent?: TelegramInboundHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
},
): Promise<string | undefined> {
const kind = file.kind || "*";
for (const handler of getTelegramInboundProgrammaticHandlers(kind)) {
try {
const output = normalizeInboundProgrammaticHandlerText(
await handler(
{
kind,
file,
mimeType: file.mimeType,
},
{ cwd: options.cwd },
),
);
if (output) return output;
} catch (error) {
options.recordRuntimeEvent?.("inbound-programmatic-handler", error, {
fileName: file.fileName || basename(file.path),
kind,
});
}
}
return undefined;
}
async function transcribeTelegramVoiceFileWithProviders(
file: TelegramInboundHandlerFile,
options: {
recordRuntimeEvent?: TelegramInboundHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
},
): Promise<string | undefined> {
if (!isTelegramVoiceLikeFile(file)) return undefined;
for (const provider of getTelegramVoiceTranscriptionProviders()) {
try {
const result = await provider(file, {});
const text = typeof result === "string" ? result : result?.text;
if (text?.trim()) return truncateTelegramInboundOutput(text.trim());
} catch (error) {
options.recordRuntimeEvent?.("voice-transcription-provider", error, {
fileName: file.fileName || basename(file.path),
});
}
}
return undefined;
}
async function readBuiltInTelegramTextAttachment(
file: TelegramInboundHandlerFile,
): Promise<string | undefined> {
if (!isTelegramTextMimeType(file.mimeType)) return undefined;
const content = await readFile(file.path, "utf8");
const normalized = content.trim();
if (
!normalized ||
Buffer.byteLength(normalized, "utf8") > BUILT_IN_TEXT_ATTACHMENT_MAX_BYTES
) {
return undefined;
}
const name = file.fileName || basename(file.path);
return truncateTelegramInboundOutput(`[${name}]\n${normalized}`);
}
async function executeTelegramInboundHandler(
handler: TelegramInboundHandlerConfig,
file: TelegramInboundHandlerFile,
cwd: string,
deps: Pick<TelegramInboundHandlerRuntimeDeps<unknown>, "execCommand">,
): Promise<string> {
const steps = getTelegramInboundHandlerCompositionSteps(handler);
if (steps.length === 0) {
const output = await executeTelegramInboundHandlerInvocation(
handler,
file,
cwd,
deps,
);
return output.trim();
}
const startedAt = Date.now();
let output = "";
for (const [index, step] of steps.entries()) {
try {
output = await executeTelegramInboundHandlerInvocation(
step,
file,
cwd,
deps,
false,
index === 0
? getTelegramInboundInitialCompositionStepTimeout(handler, step)
: getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
index === 0 ? undefined : output,
);
} catch (error) {
if (typeof step === "object" && step.failure === "root") throw error;
output = "";
}
}
return output.trim();
}
export async function processTelegramInboundHandlers<
TFile extends TelegramInboundHandlerFile,
>(options: {
files: TFile[];
rawText: string;
handlers?: TelegramInboundHandlerConfig[];
cwd: string;
execCommand: TelegramInboundHandlerRuntimeDeps<unknown>["execCommand"];
recordRuntimeEvent?: TelegramInboundHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
}): Promise<TelegramInboundHandlerProcessResult<TFile>> {
const rawText = await processTelegramTextHandlers({
rawText: options.rawText,
handlers: options.handlers,
cwd: options.cwd,
execCommand: options.execCommand,
recordRuntimeEvent: options.recordRuntimeEvent,
});
const promptFiles: TFile[] = [...options.files];
const outputs: TelegramInboundHandlerOutput[] = [];
for (const file of options.files) {
let hasOutput = false;
const handlers = findTelegramInboundHandlers(options.handlers, file);
for (const handler of handlers) {
try {
const output = await executeTelegramInboundHandler(
handler,
file,
options.cwd,
options,
);
if (output) {
outputs.push({ file, output, handler });
hasOutput = true;
}
break;
} catch (error) {
options.recordRuntimeEvent?.("inbound-handler", error, {
fileName: file.fileName || basename(file.path),
handler: getTelegramInboundHandlerKind(handler),
});
}
}
if (!hasOutput) {
try {
const output = await processTelegramFileWithProgrammaticHandlers(file, {
cwd: options.cwd,
recordRuntimeEvent: options.recordRuntimeEvent,
});
if (output) {
outputs.push({ file, output, handler: { type: "programmatic" } });
hasOutput = true;
}
} catch (error) {
options.recordRuntimeEvent?.("inbound-programmatic-handler", error, {
fileName: file.fileName || basename(file.path),
});
}
}
if (!hasOutput) {
try {
const output = await transcribeTelegramVoiceFileWithProviders(file, {
recordRuntimeEvent: options.recordRuntimeEvent,
});
if (output) {
outputs.push({ file, output, handler: { type: "voice-provider" } });
hasOutput = true;
}
} catch (error) {
options.recordRuntimeEvent?.("voice-transcription-provider", error, {
fileName: file.fileName || basename(file.path),
});
}
}
if (!hasOutput) {
try {
const output = await readBuiltInTelegramTextAttachment(file);
if (output) outputs.push({ file, output, handler: { type: "text" } });
} catch (error) {
options.recordRuntimeEvent?.("inbound-handler", error, {
fileName: file.fileName || basename(file.path),
handler: "built-in-text",
});
}
}
}
return {
rawText,
promptFiles,
handlerOutputs: outputs.map((output) => output.output),
handledFiles: outputs,
};
}
export function createTelegramInboundHandlerRuntime<TContext>(
deps: TelegramInboundHandlerRuntimeDeps<TContext>,
): TelegramInboundHandlerRuntime<TContext> {
return {
process: (files, rawText, ctx) =>
processTelegramInboundHandlers({
files,
rawText,
handlers: deps.getHandlers(),
cwd: deps.getCwd(ctx),
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
}),
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Telegram inline-keyboard structural contracts
* Zones: telegram ui, shared structure
* Owns the shared Bot API reply-markup shape while feature domains own their button semantics
*/
export interface TelegramInlineKeyboardButton {
text: string;
callback_data: string;
}
export interface TelegramInlineKeyboardMarkup {
inline_keyboard: TelegramInlineKeyboardButton[][];
}
export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
export function getTelegramCallbackDataByteLength(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
export function assertTelegramCallbackData(
callbackData: string,
context = "Telegram callback_data",
): string {
const byteLength = getTelegramCallbackDataByteLength(callbackData);
if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
throw new Error(
`${context} exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
);
}
return callbackData;
}
export function assertTelegramInlineKeyboardCallbackData(
replyMarkup: unknown,
context = "Telegram inline keyboard callback_data",
): void {
if (!replyMarkup || typeof replyMarkup !== "object") return;
const keyboard = (replyMarkup as { inline_keyboard?: unknown })
.inline_keyboard;
if (!Array.isArray(keyboard)) return;
for (const row of keyboard) {
if (!Array.isArray(row)) continue;
for (const button of row) {
if (!button || typeof button !== "object") continue;
const callbackData = (button as { callback_data?: unknown })
.callback_data;
if (typeof callbackData !== "string") continue;
assertTelegramCallbackData(callbackData, context);
}
}
}
+570
View File
@@ -0,0 +1,570 @@
/**
* Telegram lifecycle hook registration helpers
* Zones: pi agent lifecycle, telegram session
* Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
*/
import * as BusFollower from "./bus-follower.ts";
import * as Queue from "./queue.ts";
import type {
AgentEndEvent,
AgentSettledEvent,
AgentStartEvent,
AssistantMessageEvent,
BeforeAgentStartEvent,
ExtensionAPI,
ExtensionContext,
InputEvent,
SessionBeforeCompactEvent,
SessionCompactEvent,
SessionShutdownEvent,
SessionStartEvent,
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
} from "./pi.ts";
let resetTransportReplyDedupFn: (() => void) | undefined;
export function setResetTransportReplyDedup(fn: () => void): void {
resetTransportReplyDedupFn = fn;
}
export function createAgentStartDedupHook(
inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>,
): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void> {
return async (event, ctx) => {
if (resetTransportReplyDedupFn) resetTransportReplyDedupFn();
return inner(event, ctx);
};
}
export interface TelegramBeforeAgentStartResult {
systemPrompt?: string;
}
type TelegramBeforeAgentStartReturn =
| Promise<TelegramBeforeAgentStartResult | undefined>
| TelegramBeforeAgentStartResult
| undefined;
type TelegramLifecycleModel = ExtensionContext["model"];
type TelegramLifecycleMessage = AgentEndEvent["messages"][number];
export interface TelegramLifecycleRegistrationDeps {
isSessionActive?: (ctx: ExtensionContext) => boolean;
onInput?: (event: InputEvent, ctx: ExtensionContext) => Promise<void> | void;
onSessionStart: (
event: SessionStartEvent,
ctx: ExtensionContext,
) => Promise<void>;
onSessionShutdown: (
event: SessionShutdownEvent,
ctx: ExtensionContext,
) => Promise<void>;
onSessionBeforeCompact?: (
event: SessionBeforeCompactEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
onSessionCompact?: (
event: SessionCompactEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
onBeforeAgentStart: (
event: BeforeAgentStartEvent,
ctx: ExtensionContext,
) => TelegramBeforeAgentStartReturn;
onModelSelect: (
event: { model: TelegramLifecycleModel },
ctx: ExtensionContext,
) => Promise<void> | void;
onAgentStart: (
event: AgentStartEvent,
ctx: ExtensionContext,
) => Promise<void>;
onToolExecutionStart: (
event: ToolExecutionStartEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
onToolExecutionUpdate?: (
event: ToolExecutionUpdateEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
onToolExecutionEnd: (
event: ToolExecutionEndEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
onMessageStart: (
event: { message: TelegramLifecycleMessage },
ctx: ExtensionContext,
) => Promise<void>;
onMessageUpdate: (
event: {
message: TelegramLifecycleMessage;
assistantMessageEvent?: AssistantMessageEvent;
},
ctx: ExtensionContext,
) => Promise<void>;
onAgentEnd: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void>;
onAgentSettled?: (
event: AgentSettledEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
}
export interface TelegramSessionLifecycleHooks {
onSessionStart: (
event: SessionStartEvent,
ctx: ExtensionContext,
) => Promise<void>;
onSessionShutdown: (
event: SessionShutdownEvent,
ctx: ExtensionContext,
) => Promise<void>;
}
export interface TelegramSessionContextStore<TContext> {
get: () => TContext | undefined;
getGeneration: () => number;
isCurrent: (ctx: TContext, generation?: number) => boolean;
set: (ctx: TContext) => number;
clear: (ctx?: TContext) => boolean;
}
export function createTelegramSessionContextStore<TContext>(
options: {
getIdentity?: (ctx: TContext) => unknown;
} = {},
): TelegramSessionContextStore<TContext> {
let currentContext: TContext | undefined;
let currentIdentity: unknown;
let generation = 0;
const objectIdentityGenerations = new WeakMap<object, number>();
const primitiveIdentityGenerations = new Map<unknown, number>();
const resolveIdentity = (ctx: TContext): unknown =>
options.getIdentity?.(ctx) ?? ctx;
const getIdentityGeneration = (identity: unknown): number | undefined =>
(typeof identity === "object" && identity !== null) ||
typeof identity === "function"
? objectIdentityGenerations.get(identity as object)
: primitiveIdentityGenerations.get(identity);
const setIdentityGeneration = (identity: unknown, value: number): void => {
if (
(typeof identity === "object" && identity !== null) ||
typeof identity === "function"
) {
objectIdentityGenerations.set(identity as object, value);
} else {
primitiveIdentityGenerations.set(identity, value);
}
};
return {
get: () => currentContext,
getGeneration: () => generation,
isCurrent: (ctx, expectedGeneration) => {
if (currentContext === undefined) return false;
const identity = resolveIdentity(ctx);
return (
identity === currentIdentity &&
getIdentityGeneration(identity) === generation &&
(expectedGeneration === undefined || generation === expectedGeneration)
);
},
set: (ctx) => {
currentContext = ctx;
currentIdentity = resolveIdentity(ctx);
generation += 1;
setIdentityGeneration(currentIdentity, generation);
return generation;
},
clear: (ctx) => {
if (ctx !== undefined) {
const identity = resolveIdentity(ctx);
if (
identity !== currentIdentity ||
getIdentityGeneration(identity) !== generation
) {
return false;
}
}
if (currentContext === undefined) return false;
currentContext = undefined;
currentIdentity = undefined;
generation += 1;
return true;
},
};
}
export function createTelegramSessionContextTracker(
store: Pick<TelegramSessionContextStore<ExtensionContext>, "set" | "clear">,
): TelegramSessionLifecycleHooks {
return {
onSessionStart: async (_event, ctx) => {
store.set(ctx);
},
onSessionShutdown: async (_event, ctx) => {
store.clear(ctx);
},
};
}
export function createTelegramSessionGenerationFence(
store: TelegramSessionContextStore<ExtensionContext>,
hooks: TelegramSessionLifecycleHooks,
): TelegramSessionLifecycleHooks {
return {
async onSessionStart(event, ctx) {
const generation = store.set(ctx);
await hooks.onSessionStart(event, ctx);
if (!store.isCurrent(ctx, generation)) return;
},
async onSessionShutdown(event, ctx) {
const generation = store.getGeneration();
if (!store.isCurrent(ctx, generation)) return;
await hooks.onSessionShutdown(event, ctx);
store.clear(ctx);
},
};
}
export interface TelegramBridgeSessionServiceRuntime {
resumeGroupedInput(ctx: ExtensionContext): void;
suspendGroupedInput(): void;
delivery: {
onSessionStart(): Promise<void>;
onSessionShutdown(): Promise<void>;
};
polling: {
onSessionStart(
event: SessionStartEvent,
ctx: ExtensionContext,
): Promise<void>;
};
capabilityMonitor: { start(ctx: ExtensionContext): void; stop(): void };
queueWatchdog: { start(ctx: ExtensionContext): void; stop(): void };
}
export interface TelegramBridgeSessionLifecycleAssemblyDeps<
TQueueItem,
TModel = unknown,
> {
contextStore: TelegramSessionContextStore<ExtensionContext>;
queue: Omit<
Queue.TelegramSessionLifecycleRuntimeDeps<
ExtensionContext,
TQueueItem,
TModel
>,
"isSessionActive" | "stopPolling" | "clearPendingMediaGroups"
>;
follower: Omit<
BusFollower.TelegramBusFollowerSessionRefreshHookDeps<ExtensionContext>,
"isSessionActive"
> &
BusFollower.TelegramBusFollowerSessionReplacementSuspenderDeps;
services: TelegramBridgeSessionServiceRuntime;
}
export function createTelegramBridgeSessionLifecycleAssembly<
TQueueItem,
TModel = unknown,
>(
deps: TelegramBridgeSessionLifecycleAssemblyDeps<TQueueItem, TModel>,
): TelegramSessionLifecycleHooks {
const isSessionActive = deps.contextStore.isCurrent;
const suspendForReplacement =
BusFollower.createTelegramBusFollowerSessionReplacementSuspender({
registrationState: deps.follower.registrationState,
instanceId: deps.follower.instanceId,
suspendPolling: deps.follower.suspendPolling,
recordRuntimeEvent: deps.follower.recordRuntimeEvent,
});
const queueLifecycle = Queue.createTelegramSessionLifecycleRuntime({
...deps.queue,
isSessionActive,
stopPolling: suspendForReplacement,
clearPendingMediaGroups: deps.services.suspendGroupedInput,
});
const servicesLifecycle = appendTelegramLifecycleHooks(
queueLifecycle,
{
async onSessionStart(event, ctx) {
deps.services.resumeGroupedInput(ctx);
await deps.services.delivery.onSessionStart();
await deps.services.polling.onSessionStart(event, ctx);
deps.services.capabilityMonitor.start(ctx);
deps.services.queueWatchdog.start(ctx);
},
async onSessionShutdown() {
await deps.services.delivery.onSessionShutdown();
deps.services.queueWatchdog.stop();
deps.services.capabilityMonitor.stop();
},
},
isSessionActive,
);
const followerLifecycle = appendTelegramLifecycleHooks(
servicesLifecycle,
{
onSessionStart: BusFollower.createTelegramBusFollowerSessionRefreshHook({
registrationState: deps.follower.registrationState,
registrationRuntime: deps.follower.registrationRuntime,
getLeaderState: deps.follower.getLeaderState,
isSessionActive,
updateStatus: deps.follower.updateStatus,
recordRuntimeEvent: deps.follower.recordRuntimeEvent,
}),
},
isSessionActive,
);
return createTelegramSessionGenerationFence(
deps.contextStore,
followerLifecycle,
);
}
type TelegramLifecycleTimer = number | ReturnType<typeof setTimeout>;
function unrefTelegramLifecycleTimer(timer: TelegramLifecycleTimer): void {
if (!timer || typeof timer !== "object") return;
if (typeof timer.unref === "function") timer.unref();
}
export interface TelegramCompactionObserverRuntimeDeps<TContext> {
isContextActive?: (ctx: TContext) => boolean;
setCompactionInProgress: (inProgress: boolean) => void;
updateStatus: (ctx: TContext) => void;
startTypingLoop?: (ctx: TContext) => boolean | void;
stopTypingLoop?: () => void;
requestDeferredDispatchNextQueuedTelegramTurn: (
dispatch: (ctx: TContext) => void,
) => void;
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
recordRuntimeEvent?: (category: string, error: unknown) => void;
onCompactionAbandoned?: () => void;
timeoutMs?: number;
setTimer?: (callback: () => void, ms: number) => TelegramLifecycleTimer;
clearTimer?: (timer: TelegramLifecycleTimer) => void;
}
export interface TelegramCompactionObserverRuntime<TContext> {
onSessionBeforeCompact: (
event: SessionBeforeCompactEvent,
ctx: TContext,
) => void;
onSessionCompact: (event: SessionCompactEvent, ctx: TContext) => void;
onSessionShutdown: () => void;
}
export function createTelegramCompactionObserverRuntime<TContext>(
deps: TelegramCompactionObserverRuntimeDeps<TContext>,
): TelegramCompactionObserverRuntime<TContext> {
const timeoutMs = deps.timeoutMs ?? 300_000;
const setTimer = deps.setTimer ?? setTimeout;
const clearTimer = deps.clearTimer ?? clearTimeout;
let fallbackTimer: TelegramLifecycleTimer | undefined;
let typingStartedByObserver = false;
const clearFallbackTimer = (): void => {
if (!fallbackTimer) return;
clearTimer(fallbackTimer);
fallbackTimer = undefined;
};
const requestDispatch = (): void => {
deps.requestDeferredDispatchNextQueuedTelegramTurn(
deps.dispatchNextQueuedTelegramTurn,
);
};
return {
onSessionBeforeCompact: (_event, ctx) => {
if (deps.isContextActive && !deps.isContextActive(ctx)) return;
deps.setCompactionInProgress(true);
const typingStartResult = deps.startTypingLoop?.(ctx);
typingStartedByObserver =
!!deps.startTypingLoop && typingStartResult !== false;
deps.updateStatus(ctx);
clearFallbackTimer();
fallbackTimer = setTimer(() => {
fallbackTimer = undefined;
if (deps.isContextActive && !deps.isContextActive(ctx)) return;
deps.setCompactionInProgress(false);
if (typingStartedByObserver) deps.stopTypingLoop?.();
typingStartedByObserver = false;
deps.updateStatus(ctx);
deps.recordRuntimeEvent?.(
"compact",
new Error("Compaction observer timed out"),
);
deps.onCompactionAbandoned?.();
requestDispatch();
}, timeoutMs);
unrefTelegramLifecycleTimer(fallbackTimer);
},
onSessionCompact: (_event, ctx) => {
clearFallbackTimer();
if (deps.isContextActive && !deps.isContextActive(ctx)) return;
deps.setCompactionInProgress(false);
if (typingStartedByObserver) deps.stopTypingLoop?.();
typingStartedByObserver = false;
deps.updateStatus(ctx);
requestDispatch();
},
onSessionShutdown: () => {
clearFallbackTimer();
if (typingStartedByObserver) deps.stopTypingLoop?.();
typingStartedByObserver = false;
},
};
}
export interface TelegramMessageActivityTypingDeps<TContext> {
hasActiveTurn: () => boolean;
startTypingLoop: (ctx: TContext) => void;
onMessageStart: TelegramLifecycleRegistrationDeps["onMessageStart"];
onMessageUpdate: TelegramLifecycleRegistrationDeps["onMessageUpdate"];
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export function createTelegramMessageActivityTypingHooks<
TContext extends ExtensionContext,
>(
deps: TelegramMessageActivityTypingDeps<TContext>,
): Pick<
TelegramLifecycleRegistrationDeps,
"onMessageStart" | "onMessageUpdate"
> {
const ensureTyping = (ctx: TContext): void => {
if (deps.hasActiveTurn()) deps.startTypingLoop(ctx);
};
const handleMessageActivity = async <TEvent>(
phase: "start" | "update",
event: TEvent,
ctx: ExtensionContext,
inner: (event: TEvent, ctx: ExtensionContext) => Promise<void>,
): Promise<void> => {
const typedCtx = ctx as TContext;
ensureTyping(typedCtx);
try {
await inner(event, ctx);
} catch (error) {
deps.recordRuntimeEvent?.("message-activity", error, { phase });
} finally {
ensureTyping(typedCtx);
}
};
return {
onMessageStart: (event, ctx) =>
handleMessageActivity("start", event, ctx, deps.onMessageStart),
onMessageUpdate: (event, ctx) =>
handleMessageActivity("update", event, ctx, deps.onMessageUpdate),
};
}
export function createDedupAgentStartHook(
dedup: { reset(): void },
inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>,
): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void> {
return async (event, ctx) => {
dedup.reset();
await inner(event, ctx);
};
}
export interface TelegramExtraLifecycleHooks {
onSessionStart?: (
event: SessionStartEvent,
ctx: ExtensionContext,
) => Promise<void>;
onSessionShutdown?: (
event: SessionShutdownEvent,
ctx: ExtensionContext,
) => Promise<void>;
}
export function appendTelegramLifecycleHooks(
base: TelegramSessionLifecycleHooks,
extra: TelegramExtraLifecycleHooks,
isSessionActive?: (ctx: ExtensionContext) => boolean,
): TelegramSessionLifecycleHooks {
return {
onSessionStart: async (event, ctx) => {
await base.onSessionStart(event, ctx);
if (isSessionActive?.(ctx) === false) return;
await extra.onSessionStart?.(event, ctx);
},
onSessionShutdown: async (event, ctx) => {
await base.onSessionShutdown(event, ctx);
if (isSessionActive?.(ctx) === false) return;
await extra.onSessionShutdown?.(event, ctx);
},
};
}
export function registerTelegramLifecycleHooks(
pi: ExtensionAPI,
deps: TelegramLifecycleRegistrationDeps,
): void {
const isActive = (ctx: ExtensionContext): boolean =>
deps.isSessionActive?.(ctx) !== false;
pi.on("input", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onInput?.(event, ctx);
});
pi.on("session_start", async (event, ctx) => {
await deps.onSessionStart(event, ctx);
});
pi.on("session_shutdown", async (event, ctx) => {
await deps.onSessionShutdown(event, ctx);
});
pi.on("session_before_compact", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onSessionBeforeCompact?.(event, ctx);
});
pi.on("session_compact", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onSessionCompact?.(event, ctx);
});
pi.on("before_agent_start", async (event, ctx) => {
return deps.onBeforeAgentStart(event, ctx);
});
pi.on("model_select", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onModelSelect(event, ctx);
});
pi.on("agent_start", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onAgentStart(event, ctx);
});
pi.on("tool_execution_start", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onToolExecutionStart(event, ctx);
});
pi.on("tool_execution_update", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onToolExecutionUpdate?.(event, ctx);
});
pi.on("tool_execution_end", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onToolExecutionEnd(event, ctx);
});
pi.on("message_start", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onMessageStart(event, ctx);
});
pi.on("message_update", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onMessageUpdate(event, ctx);
});
pi.on("agent_end", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onAgentEnd(event, ctx);
});
pi.on("agent_settled", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onAgentSettled?.(event, ctx);
});
}
+1114
View File
File diff suppressed because it is too large. Load diff
+331
View File
@@ -0,0 +1,331 @@
/**
* Telegram diagnostics logs
* Zones: telegram diagnostics, filesystem, session observability
* Owns bounded JSONL runtime evidence files, previous-log preservation, and profile-aware log paths without becoming routing state
*/
import {
copyFileSync,
existsSync,
mkdirSync,
statSync,
writeFileSync,
appendFileSync,
} from "node:fs";
import { dirname } from "node:path";
import {
resolveAgentDir,
resolveTelegramProfileTempFilePath,
} from "./paths.ts";
import { withTelegramFileTransaction } from "./locks.ts";
import * as Status from "./status.ts";
export type TelegramLogPathInput = string | (() => string);
export interface TelegramRuntimeJsonlEvent {
at: number;
category: string;
message: string;
details?: Record<string, unknown>;
}
export interface TelegramRuntimeJsonlLogOptions {
path?: TelegramLogPathInput;
previousPath?: TelegramLogPathInput;
maxBytes?: number;
getNowMs?: () => number;
canReset?: () => boolean;
commitReset?: (commit: () => void) => boolean;
}
export interface TelegramRuntimeJsonlLog {
getPath: () => string;
reset: (reason: string, scope?: Record<string, unknown>) => void;
resetIfScopeChanged: (
scopeKey: string,
reason: string,
scope?: Record<string, unknown>,
) => void;
record: (event: TelegramRuntimeJsonlEvent) => void;
}
const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
export function getTelegramRuntimeLogPath(
agentDir = resolveAgentDir(),
profileName?: string,
): string {
return resolveTelegramProfileTempFilePath(
"logs",
"jsonl",
agentDir,
profileName,
);
}
export function getTelegramPreviousRuntimeLogPath(
agentDir = resolveAgentDir(),
profileName?: string,
): string {
return resolveTelegramProfileTempFilePath(
"logs",
"_prev.jsonl",
agentDir,
profileName,
);
}
function safeJsonLine(value: unknown): string {
return JSON.stringify(value, (_key, item) => {
if (item instanceof Error) return item.message;
if (typeof item === "bigint") return item.toString();
if (typeof item === "function" || typeof item === "symbol")
return undefined;
return item;
});
}
export function createTelegramRuntimeJsonlLog(
options: TelegramRuntimeJsonlLogOptions = {},
): TelegramRuntimeJsonlLog {
const resolvePath = () =>
typeof options.path === "function"
? options.path()
: (options.path ?? getTelegramRuntimeLogPath());
const resolvePreviousPath = () => {
if (typeof options.previousPath === "function")
return options.previousPath();
if (options.previousPath) return options.previousPath;
return resolvePath().replace(/\.jsonl$/u, "._prev.jsonl");
};
const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
const getNowMs = options.getNowMs ?? Date.now;
const scopeKeys = new Map<string, string | undefined>();
let pending: Promise<void> = Promise.resolve();
const ensureParent = (path: string) => {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
};
const preserveCurrentLog = (path: string, previousPath: string) => {
if (!existsSync(path)) return;
mkdirSync(dirname(previousPath), { recursive: true, mode: 0o700 });
copyFileSync(path, previousPath);
};
const writeResetLocked = (
path: string,
previousPath: string,
reason: string,
scope?: Record<string, unknown>,
) => {
ensureParent(path);
preserveCurrentLog(path, previousPath);
writeFileSync(
path,
safeJsonLine({
at: getNowMs(),
kind: "reset",
reason,
scope,
previousPath,
}) + "\n",
{ mode: 0o600 },
);
};
const writeReset = (
reason: string,
scope?: Record<string, unknown>,
): boolean => {
if (options.canReset && !options.canReset()) return false;
const path = resolvePath();
let didReset = false;
withTelegramFileTransaction(`${path}.transaction`, () => {
const commit = () => {
writeResetLocked(path, resolvePreviousPath(), reason, scope);
didReset = true;
};
if (options.commitReset) {
options.commitReset(commit);
} else {
commit();
}
});
return didReset;
};
const appendLine = (line: string) => {
const path = resolvePath();
const previousPath = resolvePreviousPath();
pending = pending
.catch(() => undefined)
.then(() => {
ensureParent(path);
withTelegramFileTransaction(`${path}.transaction`, () => {
if (
existsSync(path) &&
statSync(path).size > maxBytes &&
(!options.canReset || options.canReset())
) {
const rotate = () =>
writeResetLocked(path, previousPath, "max-bytes", { maxBytes });
if (options.commitReset) {
options.commitReset(rotate);
} else {
rotate();
}
}
appendFileSync(path, line, { mode: 0o600 });
});
});
};
return {
getPath: resolvePath,
reset(reason, scope) {
const path = resolvePath();
try {
if (writeReset(reason, scope)) {
scopeKeys.set(path, scope ? safeJsonLine(scope) : undefined);
}
} catch {
// Diagnostics must never break Telegram runtime behavior.
}
},
resetIfScopeChanged(nextScopeKey, reason, scope) {
const path = resolvePath();
if (scopeKeys.get(path) === nextScopeKey) return;
try {
if (writeReset(reason, scope)) scopeKeys.set(path, nextScopeKey);
} catch {
// Diagnostics must never break Telegram runtime behavior.
}
},
record(event) {
appendLine(safeJsonLine({ kind: "event", ...event }) + "\n");
},
};
}
export interface TelegramRuntimeDiagnosticsRuntime<TContext> {
events: Status.TelegramRuntimeEventRecorder;
recordRuntimeEvent(
category: string,
error: unknown,
details?: Record<string, unknown>,
): void;
bindStorage(ports: {
getBotToken(): string | undefined;
getProfileName(): string | undefined;
canReset(): boolean;
commitReset(commit: () => void): boolean;
}): void;
bindStatus(ports: {
instanceId: string;
updateStatus(ctx: TContext, error?: string): void;
getStatusState(): Status.TelegramBridgeStatusLineState;
persistSnapshot(
snapshot: ReturnType<typeof Status.createTelegramStatusSnapshot>,
): Promise<void>;
}): void;
updateStatus(ctx: TContext, error?: string): void;
getStatusLines(options?: Status.TelegramBridgeStatusLineOptions): string[];
scheduleSnapshotPersist(): void;
}
export function createTelegramRuntimeDiagnosticsRuntime<
TContext,
>(): TelegramRuntimeDiagnosticsRuntime<TContext> {
let getBotToken = (): string | undefined => undefined;
let getProfileName = (): string | undefined => undefined;
let canReset = (): boolean => false;
let commitReset = (_commit: () => void): boolean => false;
let statusPorts:
| {
instanceId: string;
updateStatus(ctx: TContext, error?: string): void;
getStatusState(): Status.TelegramBridgeStatusLineState;
persistSnapshot(
snapshot: ReturnType<typeof Status.createTelegramStatusSnapshot>,
): Promise<void>;
}
| undefined;
let requestSnapshotPersist = (): void => {};
const events = Status.createTelegramRuntimeEventRecorder({
getBotToken: () => getBotToken(),
});
const jsonl = createTelegramRuntimeJsonlLog({
path: () => getTelegramRuntimeLogPath(undefined, getProfileName()),
previousPath: () =>
getTelegramPreviousRuntimeLogPath(undefined, getProfileName()),
canReset: () => canReset(),
commitReset: (commit) => commitReset(commit),
});
const recordRuntimeEvent = function (
category: string,
error: unknown,
details?: Record<string, unknown>,
): void {
events.record(category, error, details);
const latestEvent = events.getEvents().at(-1);
if (latestEvent) jsonl.record(latestEvent);
requestSnapshotPersist();
};
const persistCurrentSnapshot = async (): Promise<void> => {
if (!statusPorts) return;
await statusPorts.persistSnapshot(
Status.createTelegramStatusSnapshot(statusPorts.getStatusState()),
);
};
const updateRuntimeLogScope = function (reason: string): void {
if (!statusPorts) return;
const scope = Status.createTelegramRuntimeLogScope({
state: statusPorts.getStatusState(),
instanceId: statusPorts.instanceId,
});
jsonl.resetIfScopeChanged(JSON.stringify(scope), reason, scope);
};
return {
events,
recordRuntimeEvent,
bindStorage(ports) {
getBotToken = ports.getBotToken;
getProfileName = ports.getProfileName;
canReset = ports.canReset;
commitReset = ports.commitReset;
},
bindStatus(ports) {
statusPorts = ports;
requestSnapshotPersist =
Status.createTelegramRuntimeDiagnosticsSnapshotScheduler({
persistSnapshot: persistCurrentSnapshot,
recordError(error) {
events.record("telegram", error, {
phase: "runtime-diagnostics-snapshot-persist",
});
},
});
},
updateStatus(ctx, error) {
if (!statusPorts) return;
statusPorts.updateStatus(ctx, error);
updateRuntimeLogScope("status-scope-change");
},
getStatusLines(options) {
if (!statusPorts) return [];
void persistCurrentSnapshot().catch((error) => {
recordRuntimeEvent("telegram", error, {
phase: "status-snapshot-persist",
});
});
return Status.buildTelegramBridgeStatusLines(
statusPorts.getStatusState(),
options,
);
},
scheduleSnapshotPersist() {
requestSnapshotPersist();
},
};
}
+790
View File
@@ -0,0 +1,790 @@
/**
* Telegram media and text extraction helpers
* Zones: telegram inbound, media groups, filesystem paths
* Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
*/
import { basename, dirname } from "node:path";
const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
const TELEGRAM_REPLY_CONTEXT_MAX_LENGTH = 1000;
export interface TelegramPhotoSize {
file_id: string;
file_size?: number;
}
export interface TelegramDocument {
file_id: string;
file_name?: string;
mime_type?: string;
}
export type TelegramVideo = TelegramDocument;
export type TelegramAudio = TelegramDocument;
export type TelegramAnimation = TelegramDocument;
export interface TelegramVoice {
file_id: string;
mime_type?: string;
}
export interface TelegramRichMessage {
blocks?: unknown[];
}
export interface TelegramMessageUser {
id?: number;
is_bot?: boolean;
first_name?: string;
last_name?: string;
username?: string;
}
export interface TelegramMessageForwardOrigin {
type?: string;
sender_user?: TelegramMessageUser;
sender_user_name?: string;
sender_chat?: { title?: string; username?: string; id?: number };
chat?: { title?: string; username?: string; id?: number };
author_signature?: string;
}
export interface TelegramReplyToMessage {
message_id?: number;
from?: TelegramMessageUser;
text?: string;
caption?: string;
rich_message?: TelegramRichMessage;
photo?: TelegramPhotoSize[];
document?: TelegramDocument;
video?: TelegramVideo;
audio?: TelegramAudio;
voice?: TelegramVoice;
animation?: TelegramAnimation;
sticker?: TelegramSticker;
}
export interface TelegramSticker {
file_id: string;
}
export interface TelegramMediaMessage {
message_id: number;
from?: TelegramMessageUser;
forward_origin?: TelegramMessageForwardOrigin;
forward_from?: TelegramMessageUser;
forward_sender_name?: string;
text?: string;
caption?: string;
rich_message?: TelegramRichMessage;
reply_to_message?: TelegramReplyToMessage;
media_group_id?: string;
photo?: TelegramPhotoSize[];
document?: TelegramDocument;
video?: TelegramVideo;
audio?: TelegramAudio;
voice?: TelegramVoice;
animation?: TelegramAnimation;
sticker?: TelegramSticker;
}
export interface TelegramMediaGroupMessage {
message_id: number;
chat: { id: number };
message_thread_id?: number;
media_group_id?: string;
}
export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
messages: TMessage[];
context?: TContext;
flushTimer?: ReturnType<typeof setTimeout>;
dispatching?: boolean;
suspended?: boolean;
reschedule?: () => void;
}
export interface TelegramMediaGroupController<
TMessage extends TelegramMediaGroupMessage,
TContext = unknown,
> {
queueMessage: (options: {
message: TMessage;
context?: TContext;
dispatchMessages: (
messages: TMessage[],
ctx?: TContext,
) => unknown | Promise<unknown>;
}) => boolean;
removeMessages: (messageIds: number[]) => number;
suspend: () => void;
resume: (context: TContext) => void;
clear: () => void;
}
export interface TelegramMediaGroupDispatchRuntimeDeps<
TMessage extends TelegramMediaGroupMessage,
TContext,
> {
mediaGroups: TelegramMediaGroupController<TMessage, TContext>;
dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
}
export interface TelegramMediaGroupDispatchRuntime<
TMessage extends TelegramMediaGroupMessage,
TContext,
> {
handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
}
export interface TelegramMediaGroupControllerOptions {
debounceMs?: number;
setTimer?: (
callback: () => void,
ms: number,
) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
}
export type TelegramAttachmentKind =
"photo" | "document" | "video" | "audio" | "voice" | "animation" | "sticker";
export interface TelegramFileInfo {
file_id: string;
fileName: string;
mimeType?: string;
kind: TelegramAttachmentKind;
isImage: boolean;
}
export interface DownloadedTelegramFile {
path: string;
fileName?: string;
isImage?: boolean;
mimeType?: string;
kind?: TelegramAttachmentKind;
}
export interface DownloadedTelegramMessageFile {
path: string;
fileName: string;
isImage: boolean;
mimeType?: string;
kind?: TelegramAttachmentKind;
}
export interface DownloadTelegramMessageFilesDeps {
downloadFile: (fileId: string, fileName: string) => Promise<string>;
}
export function guessExtensionFromMime(
mimeType: string | undefined,
fallback: string,
): string {
if (!mimeType) return fallback;
const normalized = mimeType.toLowerCase();
if (normalized === "image/jpeg") return ".jpg";
if (normalized === "image/png") return ".png";
if (normalized === "image/webp") return ".webp";
if (normalized === "image/gif") return ".gif";
if (normalized === "audio/ogg") return ".ogg";
if (normalized === "audio/mpeg") return ".mp3";
if (normalized === "audio/wav") return ".wav";
if (normalized === "video/mp4") return ".mp4";
if (normalized === "application/pdf") return ".pdf";
return fallback;
}
export function guessMediaType(path: string): string | undefined {
const normalized = path.toLowerCase();
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
return "image/jpeg";
}
if (normalized.endsWith(".png")) return "image/png";
if (normalized.endsWith(".webp")) return "image/webp";
if (normalized.endsWith(".gif")) return "image/gif";
return undefined;
}
function isImageMimeType(mimeType: string | undefined): boolean {
return mimeType?.toLowerCase().startsWith("image/") ?? false;
}
function getObjectField(value: unknown, field: string): unknown {
if (typeof value !== "object" || value === null || !(field in value)) {
return undefined;
}
return Reflect.get(value, field);
}
function joinRichTextParts(parts: string[], separator = ""): string {
return parts.filter(Boolean).join(separator).trim();
}
function extractTelegramRichText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return joinRichTextParts(value.map(extractTelegramRichText));
}
if (typeof value !== "object" || value === null) return "";
const text = getObjectField(value, "text");
if (text !== undefined) return extractTelegramRichText(text);
const expression = getObjectField(value, "expression");
if (typeof expression === "string") return expression;
const alternativeText = getObjectField(value, "alternative_text");
if (typeof alternativeText === "string") return alternativeText;
return "";
}
function extractTelegramRichBlockText(block: unknown): string {
if (typeof block !== "object" || block === null) return "";
const directText = extractTelegramRichText(getObjectField(block, "text"));
if (directText) return directText;
const summary = extractTelegramRichText(getObjectField(block, "summary"));
const nestedBlocks = extractTelegramRichMessageBlocksText(
getObjectField(block, "blocks"),
);
const items = getObjectField(block, "items");
const itemText = Array.isArray(items)
? items
.map((item) => {
const label = getObjectField(item, "label");
const body = extractTelegramRichMessageBlocksText(
getObjectField(item, "blocks"),
);
return typeof label === "string" && body ? `${label} ${body}` : body;
})
.filter(Boolean)
.join("\n")
: "";
const cells = getObjectField(block, "cells");
const cellText = Array.isArray(cells)
? cells
.map((row) =>
Array.isArray(row)
? row
.map((cell) =>
extractTelegramRichText(getObjectField(cell, "text")),
)
.filter(Boolean)
.join(" | ")
: "",
)
.filter(Boolean)
.join("\n")
: "";
const caption = extractTelegramRichText(getObjectField(block, "caption"));
return joinRichTextParts(
[summary, nestedBlocks, itemText, cellText, caption],
"\n",
);
}
function extractTelegramRichMessageBlocksText(blocks: unknown): string {
if (!Array.isArray(blocks)) return "";
return joinRichTextParts(blocks.map(extractTelegramRichBlockText), "\n\n");
}
function extractTelegramRichMessageText(
richMessage: TelegramRichMessage | undefined,
): string {
return extractTelegramRichMessageBlocksText(richMessage?.blocks);
}
export function extractTelegramMessageText(
message: TelegramMediaMessage,
): string {
return (
extractTelegramRichMessageText(message.rich_message) ||
message.text ||
message.caption ||
""
).trim();
}
function truncateTelegramReplyContextText(text: string): string {
if (text.length <= TELEGRAM_REPLY_CONTEXT_MAX_LENGTH) return text;
return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
}
function formatTelegramUser(
user: TelegramMessageUser | undefined,
): string | undefined {
if (!user) return undefined;
if (user.username) return user.username;
if (typeof user.id === "number") return String(user.id);
const name = [user.first_name, user.last_name]
.filter(Boolean)
.join(" ")
.trim();
return name || undefined;
}
function formatTelegramForwardOriginIdentifier(
message: TelegramMediaMessage,
): string | undefined {
const origin = message.forward_origin;
const user = origin?.sender_user ?? message.forward_from;
if (user?.username) return user.username;
if (typeof user?.id === "number") return String(user.id);
const chat = origin?.sender_chat ?? origin?.chat;
if (chat?.username) return chat.username;
if (typeof chat?.id === "number") return String(chat.id);
return origin?.sender_user_name ?? message.forward_sender_name;
}
export function extractTelegramForwardContextText(
message: TelegramMediaMessage,
allowedUserId?: number,
): string {
const originUser =
message.forward_origin?.sender_user ?? message.forward_from;
const isOwnerOrigin =
typeof allowedUserId === "number" && originUser?.id === allowedUserId;
const origin = formatTelegramForwardOriginIdentifier(message);
if (!origin || isOwnerOrigin) return "";
return `from: ${origin}`;
}
export function extractTelegramReplyContextText(
message: TelegramMediaMessage,
): string {
const quoted = (
extractTelegramRichMessageText(message.reply_to_message?.rich_message) ||
message.reply_to_message?.text ||
message.reply_to_message?.caption ||
""
).trim();
return quoted ? truncateTelegramReplyContextText(quoted) : "";
}
export function buildTelegramReplyContextBlock(
message: TelegramMediaMessage,
replyFiles: Pick<DownloadedTelegramFile, "path">[] = [],
): string {
const from = formatTelegramUser(message.reply_to_message?.from);
const header = from ? `[reply|from:${from}]` : "[reply]";
const text = extractTelegramReplyContextText(message);
const dirs = [...new Set(replyFiles.map((file) => dirname(file.path)))];
const sameDir = dirs.length === 1;
const attachmentHeader = sameDir
? `[attachments${from ? `|from:${from}` : ""}] ${dirs[0]}`
: `[attachments${from ? `|from:${from}` : ""}]`;
const fileLines = sameDir
? replyFiles.map((file) => `- /${basename(file.path)}`)
: replyFiles.map((file) => `- ${file.path}`);
const replyBlock = text ? `${header} ${text}` : header;
if (fileLines.length > 0) {
return `${replyBlock}\n\n${attachmentHeader}\n${fileLines.join("\n")}`;
}
if (text) return replyBlock;
return "";
}
export function appendTelegramReplyContext(
text: string,
replyContext: string,
): string {
if (!replyContext) return text;
return text ? `${text}\n\n${replyContext}` : `_\n\n${replyContext}`;
}
export function appendTelegramForwardContext(
text: string,
forwardContext: string,
): string {
if (!forwardContext) return text;
const forwardBlock = `[forward|${forwardContext.replace(/:\s+/g, ":")}]`;
return text ? `\n\n${forwardBlock} ${text}` : `\n\n${forwardBlock}`;
}
export function extractTelegramMessagePromptText(
message: TelegramMediaMessage,
): string {
return appendTelegramReplyContext(
extractTelegramMessageText(message),
buildTelegramReplyContextBlock(message),
);
}
export function extractTelegramMessagesText(
messages: TelegramMediaMessage[],
): string {
return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
}
export function extractTelegramMessagesPromptText(
messages: TelegramMediaMessage[],
): string {
const text = extractTelegramMessagesText(messages);
const firstMessage = messages[0];
if (!firstMessage) return text;
return appendTelegramReplyContext(
text,
buildTelegramReplyContextBlock(firstMessage),
);
}
export function extractFirstTelegramMessageText(
messages: TelegramMediaMessage[],
): string {
return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
}
export function hasTelegramMessagePromptContent(
message: TelegramMediaMessage,
): boolean {
return (
!!extractTelegramMessageText(message) ||
(Array.isArray(message.photo) && message.photo.length > 0) ||
!!message.document ||
!!message.video ||
!!message.audio ||
!!message.voice ||
!!message.animation ||
!!message.sticker
);
}
export function hasTelegramMessagesPromptContent(
messages: TelegramMediaMessage[],
): boolean {
return messages.some(hasTelegramMessagePromptContent);
}
export function collectTelegramMessageIds(
messages: TelegramMediaMessage[],
): number[] {
return [...new Set(messages.map((message) => message.message_id))];
}
export function getTelegramMediaGroupKey(
message: TelegramMediaGroupMessage,
): string | undefined {
if (!message.media_group_id) return undefined;
const threadKey =
typeof message.message_thread_id === "number"
? `thread:${message.message_thread_id}`
: "private";
return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
}
export function removePendingTelegramMediaGroupMessages<
TMessage extends TelegramMediaGroupMessage,
>(
groups: Map<string, TelegramMediaGroupState<TMessage, unknown>>,
messageIds: number[],
clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
): number {
if (messageIds.length === 0 || groups.size === 0) return 0;
const deletedMessageIds = new Set(messageIds);
let removedGroups = 0;
for (const [key, state] of groups.entries()) {
if (
!state.messages.some((message) =>
deletedMessageIds.has(message.message_id),
)
) {
continue;
}
if (state.flushTimer) clearTimer(state.flushTimer);
groups.delete(key);
removedGroups += 1;
}
return removedGroups;
}
export function queueTelegramMediaGroupMessage<
TMessage extends TelegramMediaGroupMessage,
TContext = unknown,
>(options: {
message: TMessage;
context?: TContext;
groups: Map<string, TelegramMediaGroupState<TMessage, TContext>>;
debounceMs: number;
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
dispatchMessages: (
messages: TMessage[],
ctx?: TContext,
) => unknown | Promise<unknown>;
}): boolean {
const key = getTelegramMediaGroupKey(options.message);
if (!key) return false;
const existing = options.groups.get(key) ?? { messages: [] };
existing.messages.push(options.message);
existing.context = options.context;
const scheduleDispatch = (): void => {
if (existing.suspended) return;
existing.flushTimer = options.setTimer(() => {
existing.flushTimer = undefined;
const state = options.groups.get(key);
if (!state) return;
if (state.dispatching) {
scheduleDispatch();
return;
}
const dispatchedMessages = [...state.messages];
const dispatchedIds = new Set(
dispatchedMessages.map((message) => message.message_id),
);
state.dispatching = true;
void Promise.resolve(
options.dispatchMessages(dispatchedMessages, state.context),
).then(
() => {
if (options.groups.get(key) !== state) return;
state.messages = state.messages.filter(
(message) => !dispatchedIds.has(message.message_id),
);
state.dispatching = false;
if (state.messages.length === 0) options.groups.delete(key);
else if (!state.flushTimer) scheduleDispatch();
},
() => {
if (options.groups.get(key) !== state) return;
state.dispatching = false;
if (!state.flushTimer) scheduleDispatch();
},
);
}, options.debounceMs);
existing.flushTimer.unref?.();
};
existing.reschedule = scheduleDispatch;
if (existing.flushTimer) options.clearTimer(existing.flushTimer);
scheduleDispatch();
options.groups.set(key, existing);
return true;
}
export function createTelegramMediaGroupController<
TMessage extends TelegramMediaGroupMessage,
TContext = unknown,
>(
options: TelegramMediaGroupControllerOptions = {},
): TelegramMediaGroupController<TMessage, TContext> {
const groups = new Map<string, TelegramMediaGroupState<TMessage, TContext>>();
const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
const setTimer =
options.setTimer ??
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
setTimeout(callback, ms));
const clearTimer = options.clearTimer ?? clearTimeout;
return {
queueMessage: ({ message, context, dispatchMessages }) =>
queueTelegramMediaGroupMessage({
message,
context,
groups,
debounceMs,
setTimer,
clearTimer,
dispatchMessages,
}),
removeMessages: (messageIds) =>
removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
suspend: () => {
for (const state of groups.values()) {
state.suspended = true;
if (state.flushTimer) clearTimer(state.flushTimer);
state.flushTimer = undefined;
}
},
resume: (context) => {
for (const state of groups.values()) {
state.context = context;
state.suspended = false;
if (!state.dispatching && !state.flushTimer) state.reschedule?.();
}
},
clear: () => {
for (const state of groups.values()) {
if (state.flushTimer) clearTimer(state.flushTimer);
}
groups.clear();
},
};
}
export function createTelegramMediaGroupDispatchRuntime<
TMessage extends TelegramMediaGroupMessage,
TContext,
>(
deps: TelegramMediaGroupDispatchRuntimeDeps<TMessage, TContext>,
): TelegramMediaGroupDispatchRuntime<TMessage, TContext> {
return {
handleMessage: async (message, ctx) => {
const queuedMediaGroup = deps.mediaGroups.queueMessage({
message,
context: ctx,
dispatchMessages: (messages, queuedCtx) =>
queuedCtx === undefined
? Promise.resolve()
: deps.dispatchMessages(messages, queuedCtx),
});
if (queuedMediaGroup) return;
await deps.dispatchMessages([message], ctx);
},
};
}
function appendTelegramListSection(
text: string,
title: string,
items: string[],
): string {
if (items.length === 0) return text;
const prefix = text.length > 0 ? `${text}\n\n` : "";
return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
}
function appendTelegramAttachmentSection(
text: string,
files: Pick<DownloadedTelegramFile, "path">[],
): string {
if (files.length === 0) return text;
const dirs = [...new Set(files.map((file) => dirname(file.path)))];
const sameDir = dirs.length === 1;
const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
const items = sameDir
? files.map((file) => `/${basename(file.path)}`)
: files.map((file) => file.path);
const prefix = text.length > 0 ? `${text}\n\n` : "";
return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
}
export function formatTelegramHistoryText(
rawText: string,
files: DownloadedTelegramFile[],
handlerOutputs: string[] = [],
): string {
let summary = rawText.length > 0 ? rawText : "(no text)";
summary = appendTelegramAttachmentSection(summary, files);
summary = appendTelegramListSection(summary, "outputs", handlerOutputs);
return summary;
}
export async function downloadTelegramMessageFiles(
messages: TelegramMediaMessage[],
deps: DownloadTelegramMessageFilesDeps,
): Promise<DownloadedTelegramMessageFile[]> {
const downloaded: DownloadedTelegramMessageFile[] = [];
for (const file of collectTelegramFileInfos(messages)) {
downloaded.push({
path: await deps.downloadFile(file.file_id, file.fileName),
fileName: file.fileName,
isImage: file.isImage,
mimeType: file.mimeType,
kind: file.kind,
});
}
return downloaded;
}
export function collectTelegramFileInfos(
messages: TelegramMediaMessage[],
): TelegramFileInfo[] {
const files: TelegramFileInfo[] = [];
for (const message of messages) {
if (Array.isArray(message.photo) && message.photo.length > 0) {
const photo = [...message.photo]
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
.pop();
if (photo) {
files.push({
file_id: photo.file_id,
fileName: `photo-${message.message_id}.jpg`,
mimeType: "image/jpeg",
kind: "photo",
isImage: true,
});
}
}
if (message.document) {
const fileName =
message.document.file_name ||
`document-${message.message_id}${guessExtensionFromMime(
message.document.mime_type,
"",
)}`;
files.push({
file_id: message.document.file_id,
fileName,
mimeType: message.document.mime_type,
kind: "document",
isImage: isImageMimeType(message.document.mime_type),
});
}
if (message.video) {
const fileName =
message.video.file_name ||
`video-${message.message_id}${guessExtensionFromMime(
message.video.mime_type,
".mp4",
)}`;
files.push({
file_id: message.video.file_id,
fileName,
mimeType: message.video.mime_type,
kind: "video",
isImage: false,
});
}
// Generic audio files (e.g. MP3 uploads) — can also trigger voice replies in "mirror" mode
if (message.audio) {
const fileName =
message.audio.file_name ||
`audio-${message.message_id}${guessExtensionFromMime(
message.audio.mime_type,
".mp3",
)}`;
files.push({
file_id: message.audio.file_id,
fileName,
mimeType: message.audio.mime_type,
kind: "audio",
isImage: false,
});
}
// Voice messages (recorded via microphone) — primary trigger for "mirror" voice reply mode
if (message.voice) {
files.push({
file_id: message.voice.file_id,
fileName: `voice-${message.message_id}${guessExtensionFromMime(
message.voice.mime_type,
".ogg",
)}`,
mimeType: message.voice.mime_type,
kind: "voice",
isImage: false,
});
}
if (message.animation) {
const fileName =
message.animation.file_name ||
`animation-${message.message_id}${guessExtensionFromMime(
message.animation.mime_type,
".mp4",
)}`;
files.push({
file_id: message.animation.file_id,
fileName,
mimeType: message.animation.mime_type,
kind: "animation",
isImage: false,
});
}
if (message.sticker) {
files.push({
file_id: message.sticker.file_id,
fileName: `sticker-${message.message_id}.webp`,
mimeType: "image/webp",
kind: "sticker",
isImage: true,
});
}
}
return files;
}
+1189
View File
File diff suppressed because it is too large. Load diff
+919
View File
@@ -0,0 +1,919 @@
/**
* Telegram queue menu UI helpers
* Zones: telegram ui, queue controls, menu composition
* Owns queue-menu rendering, queue item callbacks, and queue-menu runtime adapters while core queue mechanics stay in queue
*/
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
import type { TelegramModelMenuState } from "./menu.ts";
import type { MenuModel } from "./model.ts";
import * as Queue from "./queue.ts";
// --- Queue Menu ---
const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
const EMPTY_QUEUE_REFRESH_TITLES = [
"<b>⌛ Queue is still empty.</b>",
"<b>🫙 Still nothing in queue.</b>",
"<b>🍃 Queue remains empty.</b>",
"<b>🕳 Nothing queued yet.</b>",
"<b>🦗 Queue crickets continue.</b>",
"<b>🌙 Queue is peacefully idle.</b>",
"<b>🧘 Nothing waiting. Very zen.</b>",
"<b>🪐 Queue orbit is clear.</b>",
"<b>🧺 Basket is empty.</b>",
"<b>🔭 No prompts on the horizon.</b>",
"<b>🫧 Queue bubbles: none.</b>",
"<b>🛸 No queued signals detected.</b>",
] as const;
type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
interface TelegramQueueMenuItem {
chatId: number;
replyToMessageId: number;
queuePosition: number;
isPriority: boolean;
priorityEmoji?: string;
hasAttachments: boolean;
statusSummary: string;
promptText: string;
}
function getTelegramQueueItemPromptText<Context>(
item: Queue.TelegramQueueItem<Context>,
): string {
if (item.kind !== "prompt") return item.statusSummary;
return (
item.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim() || item.statusSummary
);
}
function toTelegramQueueMenuItems<Context>(
items: readonly Queue.TelegramQueueItem<Context>[],
): TelegramQueueMenuItem[] {
return items.map((item, index) => {
return {
chatId: item.chatId,
replyToMessageId: item.replyToMessageId,
queuePosition: index + 1,
isPriority: item.queueLane === "priority",
priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
hasAttachments:
item.kind === "prompt" && item.queuedAttachments.length > 0,
statusSummary: item.statusSummary,
promptText: getTelegramQueueItemPromptText(item),
};
});
}
function buildTelegramQueueMenuReplyMarkup(
items: readonly TelegramQueueMenuItem[],
emptyRefreshIndex = 0,
): TelegramQueueMenuReplyMarkup {
const backRow = [{ text: "⬆️ Main menu", callback_data: "menu:back" }];
const nextEmptyRefreshIndex =
(emptyRefreshIndex + 1) % EMPTY_QUEUE_REFRESH_TITLES.length;
const refreshData =
items.length === 0
? `queue:refresh:${nextEmptyRefreshIndex}`
: "queue:refresh";
const refreshRow = [{ text: "🌀 Refresh", callback_data: refreshData }];
if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
const rows = items.map((item, index) => {
const prefix = item.isPriority
? `${item.priorityEmoji ?? "⚡"} `
: item.hasAttachments
? "📎 "
: "";
const label = `${index + 1}. ${prefix}${item.statusSummary}`;
return [
{
text: label,
callback_data: `queue:pick:${item.chatId}:${item.replyToMessageId}`,
},
];
});
return { inline_keyboard: [backRow, refreshRow, ...rows] };
}
function findTelegramQueueItem<Context>(
items: readonly Queue.TelegramQueueItem<Context>[],
chatId: number,
replyToMessageId: number,
): Queue.TelegramQueueItem<Context> | undefined {
return items.find((item) => {
return item.chatId === chatId && item.replyToMessageId === replyToMessageId;
});
}
function findTelegramQueueMenuItem(
items: readonly TelegramQueueMenuItem[],
chatId: number,
replyToMessageId: number,
): TelegramQueueMenuItem | undefined {
return items.find((item) => {
return item.chatId === chatId && item.replyToMessageId === replyToMessageId;
});
}
function escapeTelegramQueueMenuHtmlChar(char: string): string {
if (char === "&") return "&amp;";
if (char === "<") return "&lt;";
if (char === ">") return "&gt;";
return char;
}
function escapeTelegramQueueMenuHtml(text: string): string {
return Array.from(text).map(escapeTelegramQueueMenuHtmlChar).join("");
}
function escapeTelegramQueueMenuHtmlPreview(text: string): string {
const suffix = escapeTelegramQueueMenuHtml(
QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX,
);
let escaped = "";
let truncated = false;
for (const char of text) {
const next = escapeTelegramQueueMenuHtmlChar(char);
if (
escaped.length + next.length + suffix.length >
QUEUE_ITEM_PROMPT_HTML_LIMIT
) {
truncated = true;
break;
}
escaped += next;
}
return truncated ? escaped + suffix : escaped;
}
function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
const badge = item.isPriority ? ` ${item.priorityEmoji ?? "⚡"}` : "";
const heading = `<b>${item.queuePosition}.</b>${badge}`;
const preview = `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
return `${heading}\n${preview}`;
}
function buildTelegramQueueItemSubmenuReplyMarkup(
chatId: number,
replyToMessageId: number,
isPriority: boolean,
): TelegramQueueMenuReplyMarkup {
return {
inline_keyboard: [
[{ text: "⬆️ Back", callback_data: "queue:list" }],
[
{
text: isPriority ? "🟡 Priority" : "⚫️ Priority",
callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
},
{
text: isPriority ? "⚫️ Normal" : "🟣 Normal",
callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
},
],
[
{
text: "🗑 Delete",
callback_data: `queue:delete:${chatId}:${replyToMessageId}`,
},
],
],
};
}
function buildTelegramQueueDeleteConfirmationReplyMarkup(
chatId: number,
replyToMessageId: number,
): TelegramQueueMenuReplyMarkup {
return {
inline_keyboard: [
[
{
text: "🗑 Yes, delete",
callback_data: `queue:confirm-delete:${chatId}:${replyToMessageId}`,
},
{
text: "❌ No",
callback_data: `queue:keep:${chatId}:${replyToMessageId}`,
},
],
],
};
}
interface TelegramQueueMenuCallbackDeps<Context = unknown> {
getQueuedItems: () => TelegramQueueMenuItem[];
findItem: (
chatId: number,
replyToMessageId: number,
) => TelegramQueueMenuItem | undefined;
togglePriority: (chatId: number, replyToMessageId: number) => boolean;
setPriority: (
chatId: number,
replyToMessageId: number,
enabled: boolean,
) => boolean;
cancelItem: (
chatId: number,
replyToMessageId: number,
ctx: Context,
) => boolean;
updateQueueMessage: (
chatId: number,
messageId: number,
text: string,
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<number | undefined>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
updateStatus: (ctx: Context) => void;
}
async function handleTelegramQueueMenuCallback<Context>(
callbackQueryId: string,
data: string,
replyChatId: number,
replyMessageId: number,
ctx: Context,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<boolean> {
if (!data.startsWith("queue:")) return false;
if (data === "queue:noop") {
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "queue:list") {
await updateTelegramQueueMenuList(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
return true;
}
const refreshMatch = data.match(/^queue:refresh(?::(\d+))?$/);
if (refreshMatch) {
await updateTelegramQueueMenuList(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
undefined,
refreshMatch[1] === undefined ? 0 : Number(refreshMatch[1]),
);
return true;
}
const pickMatch = data.match(/^queue:pick:(\d+):(\d+)$/);
if (pickMatch) {
await handleTelegramQueueMenuPick(
callbackQueryId,
replyChatId,
replyMessageId,
Number(pickMatch[1]),
Number(pickMatch[2]),
deps,
);
return true;
}
const prioSetMatch = data.match(
/^queue:prio-set:(\d+):(\d+):(priority|normal)$/,
);
if (prioSetMatch) {
await handleTelegramQueueMenuPrioritySet(
callbackQueryId,
replyChatId,
replyMessageId,
Number(prioSetMatch[1]),
Number(prioSetMatch[2]),
prioSetMatch[3] === "priority",
ctx,
deps,
);
return true;
}
const prioMatch = data.match(/^queue:prio:(\d+):(\d+)$/);
if (prioMatch) {
await handleTelegramQueueMenuPriority(
callbackQueryId,
replyChatId,
replyMessageId,
Number(prioMatch[1]),
Number(prioMatch[2]),
ctx,
deps,
);
return true;
}
const deleteMatch = data.match(/^queue:(?:delete|cancel):(\d+):(\d+)$/);
if (deleteMatch) {
await handleTelegramQueueMenuDeleteRequest(
callbackQueryId,
replyChatId,
replyMessageId,
Number(deleteMatch[1]),
Number(deleteMatch[2]),
deps,
);
return true;
}
const keepMatch = data.match(/^queue:keep:(\d+):(\d+)$/);
if (keepMatch) {
await handleTelegramQueueMenuKeep(
callbackQueryId,
replyChatId,
replyMessageId,
Number(keepMatch[1]),
Number(keepMatch[2]),
deps,
);
return true;
}
const confirmDeleteMatch = data.match(/^queue:confirm-delete:(\d+):(\d+)$/);
if (confirmDeleteMatch) {
await handleTelegramQueueMenuConfirmDelete(
callbackQueryId,
replyChatId,
replyMessageId,
Number(confirmDeleteMatch[1]),
Number(confirmDeleteMatch[2]),
ctx,
deps,
);
return true;
}
return false;
}
function getTelegramQueueMenuListText(
items: readonly TelegramQueueMenuItem[],
emptyRefreshIndex?: number,
): string {
if (items.length > 0) return "<b>⏳ Queue:</b>";
if (emptyRefreshIndex === undefined) return "<b>⌛ Queue is empty.</b>";
return EMPTY_QUEUE_REFRESH_TITLES[
emptyRefreshIndex % EMPTY_QUEUE_REFRESH_TITLES.length
];
}
async function updateTelegramQueueMenuList<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
deps: TelegramQueueMenuCallbackDeps<Context>,
notice?: string,
emptyRefreshIndex?: number,
): Promise<void> {
const items = deps.getQueuedItems();
await deps.updateQueueMessage(
replyChatId,
replyMessageId,
getTelegramQueueMenuListText(items, emptyRefreshIndex),
buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex),
);
await deps.answerCallbackQuery(callbackQueryId, notice);
}
async function refreshStaleTelegramQueueMenuItem<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
await updateTelegramQueueMenuList(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
"Item no longer in queue.",
);
}
async function handleTelegramQueueMenuPick<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
const item = deps.findItem(chatId, msgId);
if (!item) {
return refreshStaleTelegramQueueMenuItem(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
}
await deps.updateQueueMessage(
replyChatId,
replyMessageId,
getTelegramQueueMenuItemText(item),
buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, item.isPriority),
);
await deps.answerCallbackQuery(callbackQueryId);
}
async function handleTelegramQueueMenuPriority<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
ctx: Context,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
const item = deps.findItem(chatId, msgId);
if (!item) {
return refreshStaleTelegramQueueMenuItem(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
}
await updateTelegramQueueMenuPriority(
callbackQueryId,
replyChatId,
replyMessageId,
chatId,
msgId,
!item.isPriority,
ctx,
deps,
);
}
async function handleTelegramQueueMenuPrioritySet<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
enabled: boolean,
ctx: Context,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
const item = deps.findItem(chatId, msgId);
if (!item) {
return refreshStaleTelegramQueueMenuItem(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
}
await updateTelegramQueueMenuPriority(
callbackQueryId,
replyChatId,
replyMessageId,
chatId,
msgId,
enabled,
ctx,
deps,
);
}
async function updateTelegramQueueMenuPriority<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
enabled: boolean,
ctx: Context,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
deps.setPriority(chatId, msgId, enabled);
deps.updateStatus(ctx);
const updated = deps.findItem(chatId, msgId);
if (!updated) {
return refreshStaleTelegramQueueMenuItem(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
}
await deps.updateQueueMessage(
replyChatId,
replyMessageId,
getTelegramQueueMenuItemText(updated),
buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, updated.isPriority),
);
await deps.answerCallbackQuery(
callbackQueryId,
updated.isPriority ? "Prioritized." : "Normal priority.",
);
}
async function handleTelegramQueueMenuDeleteRequest<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
const item = deps.findItem(chatId, msgId);
if (!item) {
return refreshStaleTelegramQueueMenuItem(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
}
await deps.updateQueueMessage(
replyChatId,
replyMessageId,
"<b>Delete this queued prompt?</b>",
buildTelegramQueueDeleteConfirmationReplyMarkup(chatId, msgId),
);
await deps.answerCallbackQuery(callbackQueryId);
}
async function handleTelegramQueueMenuKeep<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
const item = deps.findItem(chatId, msgId);
if (!item) {
return refreshStaleTelegramQueueMenuItem(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
);
}
await deps.updateQueueMessage(
replyChatId,
replyMessageId,
getTelegramQueueMenuItemText(item),
buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, item.isPriority),
);
await deps.answerCallbackQuery(callbackQueryId, "Kept in queue.");
}
async function handleTelegramQueueMenuConfirmDelete<Context>(
callbackQueryId: string,
replyChatId: number,
replyMessageId: number,
chatId: number,
msgId: number,
ctx: Context,
deps: TelegramQueueMenuCallbackDeps<Context>,
): Promise<void> {
const removed = deps.cancelItem(chatId, msgId, ctx);
deps.updateStatus(ctx);
await updateTelegramQueueMenuList(
callbackQueryId,
replyChatId,
replyMessageId,
deps,
removed ? "Deleted from queue." : "Item not found.",
);
}
interface TelegramQueueMenuCallbackQuery {
id: string;
data?: string;
message?: { chat?: { id?: number }; message_id?: number };
}
interface TelegramQueueMenuRuntime<Context> {
openQueueMenu: (
chatId: number,
replyToMessageId: number,
ctx: Context,
) => Promise<void>;
handleCallbackQuery: (
query: TelegramQueueMenuCallbackQuery,
ctx: Context,
) => Promise<boolean>;
}
export function createTelegramQueueMenuRuntime<
Context,
TModel extends MenuModel = MenuModel,
>(deps: {
telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "html",
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<number | undefined>;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "html",
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
getModelMenuState: (
chatId: number,
ctx: Context,
) => Promise<TelegramModelMenuState<TModel>>;
getStoredModelMenuState: (
messageId: number | undefined,
chatId?: number,
) => TelegramModelMenuState<TModel> | undefined;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
updateStatusMessage: (
state: TelegramModelMenuState<TModel>,
ctx: Context,
) => Promise<void>;
updateStatus: (ctx: Context) => void;
}): TelegramQueueMenuRuntime<Context> {
const sendQueueMenuMessage = createQueueMenuSendMessageAdapter(
deps.sendInteractiveMessage,
);
const editQueueMenuMessage = createQueueMenuEditMessageAdapter(
deps.editInteractiveMessage,
);
return {
openQueueMenu: createOpenQueueMenu<Context, TModel>({
getQueuedItems: deps.telegramQueueStore.getQueuedItems,
getModelMenuState: deps.getModelMenuState,
storeModelMenuState: deps.storeModelMenuState,
sendInteractiveMessage: sendQueueMenuMessage,
}),
handleCallbackQuery: createQueueMenuCallbackHandler<Context, TModel>({
telegramQueueStore: deps.telegramQueueStore,
queueMutationRuntime: deps.queueMutationRuntime,
editInteractiveMessage: editQueueMenuMessage,
getStoredModelMenuState: deps.getStoredModelMenuState,
updateStatusMessage: deps.updateStatusMessage,
answerCallbackQuery: deps.answerCallbackQuery,
updateStatus: deps.updateStatus,
}),
};
}
function createOpenQueueMenu<
Context,
TModel extends MenuModel = MenuModel,
>(deps: {
getQueuedItems: () => Queue.TelegramQueueItem<Context>[];
getModelMenuState: (
chatId: number,
ctx: Context,
) => Promise<TelegramModelMenuState<TModel>>;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
sendInteractiveMessage: (
chatId: number,
replyToMessageId: number,
text: string,
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<number | undefined>;
}) {
return async (
chatId: number,
replyToMessageId: number,
ctx: Context,
): Promise<void> => {
const state = await deps.getModelMenuState(chatId, ctx);
const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems());
const text = getTelegramQueueMenuListText(menuItems);
const messageId = await deps.sendInteractiveMessage(
chatId,
replyToMessageId,
text,
buildTelegramQueueMenuReplyMarkup(menuItems),
);
if (messageId === undefined) return;
state.messageId = messageId;
state.mode = "queue";
deps.storeModelMenuState(state);
};
}
function createQueueMenuCallbackHandler<
Context,
TModel extends MenuModel = MenuModel,
>(deps: {
telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<number | undefined>;
getStoredModelMenuState: (
messageId: number | undefined,
chatId?: number,
) => TelegramModelMenuState<TModel> | undefined;
updateStatusMessage: (
state: TelegramModelMenuState<TModel>,
ctx: Context,
) => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
updateStatus: (ctx: Context) => void;
}) {
return async (
query: TelegramQueueMenuCallbackQuery,
ctx: Context,
): Promise<boolean> => {
const data = query.data;
const chatId = query.message?.chat?.id;
const messageId = query.message?.message_id;
if (!data || typeof chatId !== "number" || typeof messageId !== "number")
return false;
if (data === "menu:queue" || data === "status:queue") {
const state = deps.getStoredModelMenuState(messageId, chatId);
if (!state) {
await deps.answerCallbackQuery(
query.id,
"Interactive message expired.",
);
return true;
}
const menuItems = toTelegramQueueMenuItems(
deps.telegramQueueStore.getQueuedItems(),
);
await deps.editInteractiveMessage(
chatId,
messageId,
getTelegramQueueMenuListText(menuItems),
buildTelegramQueueMenuReplyMarkup(menuItems),
);
state.mode = "queue";
await deps.answerCallbackQuery(query.id);
return true;
}
if (!data.startsWith("queue:")) return false;
const getQueueSnapshot = () => {
return deps.telegramQueueStore.getQueuedItems();
};
const toMenuItems = () => {
return toTelegramQueueMenuItems(getQueueSnapshot());
};
const findItem = (cId: number, rId: number) => {
return findTelegramQueueMenuItem(toMenuItems(), cId, rId);
};
return handleTelegramQueueMenuCallback(
query.id,
data,
chatId,
messageId,
ctx,
{
getQueuedItems: toMenuItems,
findItem,
togglePriority: (cId, rId) => {
return toggleQueuedTelegramPromptPriority(cId, rId, ctx, {
getQueueSnapshot,
queueMutationRuntime: deps.queueMutationRuntime,
});
},
setPriority: (cId, rId, enabled) => {
return setQueuedTelegramPromptPriority(cId, rId, enabled, ctx, {
getQueueSnapshot,
queueMutationRuntime: deps.queueMutationRuntime,
});
},
cancelItem: (cId, rId, c) => {
return cancelQueuedTelegramItem(cId, rId, c, {
getQueueSnapshot,
queueMutationRuntime: deps.queueMutationRuntime,
});
},
updateQueueMessage: deps.editInteractiveMessage,
answerCallbackQuery: deps.answerCallbackQuery,
updateStatus: deps.updateStatus,
},
);
};
}
function toggleQueuedTelegramPromptPriority<Context>(
chatId: number,
replyToMessageId: number,
ctx: Context,
deps: {
getQueueSnapshot: () => Queue.TelegramQueueItem<Context>[];
queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
},
): boolean {
const item = findTelegramQueueItem(
deps.getQueueSnapshot(),
chatId,
replyToMessageId,
);
if (!item) return false;
if (item.queueLane === "priority") {
deps.queueMutationRuntime.clearPriorityByMessageId(replyToMessageId, ctx);
} else {
deps.queueMutationRuntime.prioritizeByMessageId(replyToMessageId, ctx);
}
return true;
}
function setQueuedTelegramPromptPriority<Context>(
chatId: number,
replyToMessageId: number,
enabled: boolean,
ctx: Context,
deps: {
getQueueSnapshot: () => Queue.TelegramQueueItem<Context>[];
queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
},
): boolean {
const item = findTelegramQueueItem(
deps.getQueueSnapshot(),
chatId,
replyToMessageId,
);
if (!item) return false;
if (enabled) {
deps.queueMutationRuntime.prioritizeByMessageId(replyToMessageId, ctx);
} else {
deps.queueMutationRuntime.clearPriorityByMessageId(replyToMessageId, ctx);
}
return true;
}
function cancelQueuedTelegramItem<Context>(
chatId: number,
replyToMessageId: number,
ctx: Context,
deps: {
getQueueSnapshot: () => Queue.TelegramQueueItem<Context>[];
queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
},
): boolean {
const item = findTelegramQueueItem(
deps.getQueueSnapshot(),
chatId,
replyToMessageId,
);
if (!item) return false;
return (
deps.queueMutationRuntime.removeByMessageIds([item.replyToMessageId], ctx) >
0
);
}
function createQueueMenuSendMessageAdapter(
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "html",
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<number | undefined>,
) {
return (
chatId: number,
_replyToMessageId: number,
text: string,
replyMarkup: TelegramQueueMenuReplyMarkup,
): Promise<number | undefined> => {
return sendInteractiveMessage(chatId, text, "html", replyMarkup);
};
}
function createQueueMenuEditMessageAdapter(
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "html",
replyMarkup: TelegramQueueMenuReplyMarkup,
) => Promise<void>,
) {
return (
chatId: number,
messageId: number,
text: string,
replyMarkup: TelegramQueueMenuReplyMarkup,
): Promise<number | undefined> => {
return editInteractiveMessage(
chatId,
messageId,
text,
"html",
replyMarkup,
).then(() => {
return undefined as number | undefined;
});
};
}
+762
View File
@@ -0,0 +1,762 @@
/**
* Telegram settings menu UI helpers
* Zones: telegram ui, settings controls, menu composition
* Owns hidden settings-menu rendering, settings callbacks, and persisted toggle wiring
*/
import {
getTelegramExtensionSettingsRows,
type TelegramSectionRegistry,
} from "./sections.ts";
import type {
TelegramAssistantRenderingMode,
TelegramTimeMode,
} from "./config.ts";
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
import type { TelegramModelMenuState } from "./menu-model.ts";
import type { MenuModel } from "./model.ts";
import type { TelegramVoiceReplyMode } from "./voice.ts";
export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
export interface TelegramSettingsStateDeps {
isProactivePushEnabled: () => boolean;
areDraftPreviewsEnabled: () => boolean;
getAssistantRenderingMode: () => TelegramAssistantRenderingMode;
getTimeInjectionMode: () => TelegramTimeMode;
getVoiceReplyMode: () => TelegramVoiceReplyMode;
isVoiceReplyModeConfigured: () => boolean;
}
export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps {
setProactivePushEnabled: (enabled: boolean) => Promise<void>;
setDraftPreviewsEnabled: (enabled: boolean) => Promise<void>;
setAssistantRenderingMode: (
mode: TelegramAssistantRenderingMode,
) => Promise<void>;
setVoiceReplyMode: (
mode: TelegramVoiceReplyMode | undefined,
) => Promise<void>;
setTimeInjectionMode: (mode: TelegramTimeMode) => Promise<void>;
}
export interface TelegramSettingsMenuOpenDeps<
TModel extends MenuModel = MenuModel,
> extends TelegramSettingsStateDeps {
getModelMenuState: () => Promise<TelegramModelMenuState<TModel>>;
sendSettingsMenu: (
state: TelegramModelMenuState<TModel>,
text: string,
replyMarkup: TelegramSettingsMenuReplyMarkup,
) => Promise<number | undefined>;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
}
export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutationDeps {
updateSettingsMessage: (
text: string,
replyMarkup: TelegramSettingsMenuReplyMarkup,
) => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
sectionRegistry?: TelegramSectionRegistry;
}
export interface TelegramSettingsMenuRuntime<TContext> {
openSettingsMenu: (
chatId: number,
replyToMessageId: number,
ctx: TContext,
) => Promise<void>;
handleCallbackQuery: (
query: {
id: string;
data?: string;
message?: { message_id?: number; chat?: { id?: number } };
},
ctx: TContext,
) => Promise<boolean>;
updateSettingsMenuMessage: (
state: TelegramModelMenuState,
ctx: TContext,
) => Promise<void>;
}
export interface TelegramSettingsMenuMessageUpdateDeps extends TelegramSettingsStateDeps {
updateSettingsMessage: (
text: string,
replyMarkup: TelegramSettingsMenuReplyMarkup,
) => Promise<void>;
}
export interface TelegramSettingsMenuRuntimeDeps<
TContext,
TModel extends MenuModel = MenuModel,
> extends TelegramSettingsMutationDeps {
getModelMenuState: (
chatId: number,
ctx: TContext,
) => Promise<TelegramModelMenuState<TModel>>;
getStoredModelMenuState: (
messageId: number | undefined,
chatId?: number,
) => TelegramModelMenuState<TModel> | undefined;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramSettingsMenuReplyMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramSettingsMenuReplyMarkup,
) => Promise<number | undefined>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
}
export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
export const DRAFT_PREVIEWS_SETTINGS_TITLE = "<b>📝 Draft previews:</b>";
export const ASSISTANT_RENDERING_SETTINGS_TITLE =
"<b>🧾 Assistant rendering:</b>";
export const TIME_INJECTION_MODE_SETTINGS_TITLE =
"<b>🕒 Time injection mode:</b>";
export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
type TelegramVoiceReplyModeSetting = TelegramVoiceReplyMode | "hidden";
function getVoiceReplyModeLabel(mode: TelegramVoiceReplyModeSetting): string {
return mode;
}
function getTelegramSettingsStateValueLabel(value: string): string {
return value.toLowerCase();
}
function getVoiceReplyModeSetting(
mode: TelegramVoiceReplyMode,
configured: boolean,
): TelegramVoiceReplyModeSetting {
return configured ? mode : "hidden";
}
export function buildTelegramSettingsMenuText(): string {
return SETTINGS_MENU_TITLE;
}
export function buildProactivePushSettingsText(
proactivePushEnabled: boolean,
): string {
return [
`${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
"",
"Send successful local Pi task results to Telegram when the bridge is connected.",
].join("\n");
}
export function buildDraftPreviewsSettingsText(enabled: boolean): string {
return [
`${DRAFT_PREVIEWS_SETTINGS_TITLE} <code>${enabled ? "on" : "off"}</code>`,
"",
"Show live answer drafts while the model is answering.",
"",
"<code>-</code> <code>off</code> (default): show native active status, then send one final answer.",
"<code>-</code> <code>on</code>: stream safe Telegram Rich Draft frames before the final answer.",
].join("\n");
}
export function buildAssistantRenderingSettingsText(
mode: TelegramAssistantRenderingMode,
): string {
return [
`${ASSISTANT_RENDERING_SETTINGS_TITLE} <code>${mode}</code>`,
"",
"Choose how final assistant Markdown answers are delivered.",
"",
"<code>-</code> <code>rich</code> (default): use Telegram Native Rich Markdown.",
"<code>-</code> <code>html</code>: use the legacy Markdown-to-HTML renderer.",
].join("\n");
}
export function buildVoiceReplyModeSettingsText(
mode: TelegramVoiceReplyMode,
configured = true,
): string {
return [
`${VOICE_REPLY_MODE_SETTINGS_TITLE} <code>${getVoiceReplyModeLabel(
getVoiceReplyModeSetting(mode, configured),
)}</code>`,
"",
"Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
"",
"<code>-</code> <code>hidden</code> (default): same behavior as 'manual', but no voice policy is added to prompt context.",
"<code>-</code> <code>manual</code>: agent decides; explicit 'telegram_voice' markup still works and reply mode is visible in prompt context.",
"<code>-</code> <code>mirror</code>: voice input prefers a voice reply; text input gracefully follows 'manual' behavior.",
"<code>-</code> <code>always</code>: every reply is converted to voice when delivery succeeds.",
].join("\n");
}
export function buildTimeInjectionModeSettingsText(
mode: TelegramTimeMode,
): string {
return [
`${TIME_INJECTION_MODE_SETTINGS_TITLE} <code>${mode}</code>`,
"",
"Controls whether Telegram-originated prompts include a compact wall-clock [time] line.",
"",
"<code>-</code> <code>hidden</code> (default): no time line is added to prompt context.",
"<code>-</code> <code>always</code>: add time to every Telegram turn.",
"<code>-</code> <code>interval</code>: add time at most once per chat interval (default: 1 hour).",
].join("\n");
}
export function buildTelegramSettingsMenuReplyMarkup(
proactivePushEnabled: boolean,
draftPreviewsEnabled: boolean,
assistantRenderingModeOrVoiceReplyMode: TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
voiceReplyModeOrTimeInjectionMode: TelegramVoiceReplyMode | TelegramTimeMode,
timeInjectionModeOrSectionRegistry?: TelegramTimeMode | TelegramSectionRegistry,
sectionRegistryOrVoiceReplyModeConfigured?: TelegramSectionRegistry | boolean,
voiceReplyModeConfigured = true,
): TelegramSettingsMenuReplyMarkup {
const hasRenderingMode =
assistantRenderingModeOrVoiceReplyMode === "rich" ||
assistantRenderingModeOrVoiceReplyMode === "html";
const assistantRenderingMode: TelegramAssistantRenderingMode = hasRenderingMode
? assistantRenderingModeOrVoiceReplyMode
: "rich";
const voiceReplyMode = hasRenderingMode
? (voiceReplyModeOrTimeInjectionMode as TelegramVoiceReplyMode)
: (assistantRenderingModeOrVoiceReplyMode as TelegramVoiceReplyMode);
const timeInjectionMode = hasRenderingMode
? (timeInjectionModeOrSectionRegistry as TelegramTimeMode)
: (voiceReplyModeOrTimeInjectionMode as TelegramTimeMode);
const sectionRegistry = hasRenderingMode
? (sectionRegistryOrVoiceReplyModeConfigured as
| TelegramSectionRegistry
| undefined)
: (timeInjectionModeOrSectionRegistry as TelegramSectionRegistry | undefined);
const effectiveVoiceReplyModeConfigured = hasRenderingMode
? voiceReplyModeConfigured
: typeof sectionRegistryOrVoiceReplyModeConfigured === "boolean"
? sectionRegistryOrVoiceReplyModeConfigured
: true;
const rows: Array<Array<{ text: string; callback_data: string }>> = [
[{ text: "⬆️ Main menu", callback_data: "menu:back" }],
];
// Extension settings rows before built-in controls
if (sectionRegistry) {
const settingsRows = getTelegramExtensionSettingsRows(sectionRegistry);
for (const row of settingsRows) {
rows.push([{ text: row.label, callback_data: row.callback_data }]);
}
}
rows.push(
[
{
text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
getVoiceReplyModeLabel(
getVoiceReplyModeSetting(voiceReplyMode, effectiveVoiceReplyModeConfigured),
),
)}`,
callback_data: "settings:open:voice-reply",
},
],
[
{
text: `🕒 Time injection: ${getTelegramSettingsStateValueLabel(timeInjectionMode)}`,
callback_data: "settings:open:time-injection",
},
],
[
{
text: `📝 Draft previews: ${draftPreviewsEnabled ? "on" : "off"}`,
callback_data: "settings:open:draft-previews",
},
],
[
{
text: `🧾 Rendering: ${assistantRenderingMode}`,
callback_data: "settings:open:assistant-rendering",
},
],
[
{
text: `📌 Proactive push: ${proactivePushEnabled ? "on" : "off"}`,
callback_data: "settings:open:proactive",
},
],
);
return { inline_keyboard: rows };
}
export async function openTelegramSettingsMenu<
TModel extends MenuModel = MenuModel,
>(
deps: TelegramSettingsMenuOpenDeps<TModel>,
sectionRegistry?: TelegramSectionRegistry,
): Promise<void> {
const state = await deps.getModelMenuState();
const messageId = await deps.sendSettingsMenu(
state,
buildTelegramSettingsMenuText(),
buildTelegramSettingsMenuReplyMarkup(
deps.isProactivePushEnabled(),
deps.areDraftPreviewsEnabled(),
deps.getAssistantRenderingMode(),
deps.getVoiceReplyMode(),
deps.getTimeInjectionMode(),
sectionRegistry,
deps.isVoiceReplyModeConfigured(),
),
);
if (messageId === undefined) return;
state.messageId = messageId;
state.mode = "settings";
deps.storeModelMenuState(state);
}
export function buildProactivePushSettingsReplyMarkup(
proactivePushEnabled: boolean,
): TelegramSettingsMenuReplyMarkup {
return {
inline_keyboard: [
[{ text: "⬆️ Back", callback_data: "settings:list" }],
[
{
text: proactivePushEnabled ? "🟢 On" : "⚫️ On",
callback_data: "settings:set:proactive:on",
},
{
text: proactivePushEnabled ? "⚫️ Off" : "🟡 Off",
callback_data: "settings:set:proactive:off",
},
],
],
};
}
export function buildDraftPreviewsSettingsReplyMarkup(
enabled: boolean,
): TelegramSettingsMenuReplyMarkup {
return {
inline_keyboard: [
[{ text: "⬆️ Back", callback_data: "settings:list" }],
[
{
text: enabled ? "🟢 On" : "⚫️ On",
callback_data: "settings:set:draft-previews:on",
},
{
text: enabled ? "⚫️ Off" : "🟡 Off",
callback_data: "settings:set:draft-previews:off"
},
],
],
};
}
export function buildAssistantRenderingSettingsReplyMarkup(
mode: TelegramAssistantRenderingMode,
): TelegramSettingsMenuReplyMarkup {
const modes: TelegramAssistantRenderingMode[] = ["rich", "html"];
return {
inline_keyboard: [
[{ text: "⬆️ Back", callback_data: "settings:list" }],
...modes.map((value) => [
{
text: `${value === mode ? "🟢 " : ""}${value}`,
callback_data: `settings:set:assistant-rendering:${value}`,
},
]),
],
};
}
export function buildTimeInjectionModeSettingsReplyMarkup(
mode: TelegramTimeMode,
): TelegramSettingsMenuReplyMarkup {
const modes: TelegramTimeMode[] = ["hidden", "always", "interval"];
return {
inline_keyboard: [
[{ text: "⬆️ Back", callback_data: "settings:list" }],
...modes.map((value) => [
{
text: `${value === mode ? "🟢 " : ""}${value}`,
callback_data: `settings:set:time-injection:${value}`,
},
]),
],
};
}
export function buildVoiceReplyModeSettingsReplyMarkup(
mode: TelegramVoiceReplyMode,
configured = true,
): TelegramSettingsMenuReplyMarkup {
const activeMode = getVoiceReplyModeSetting(mode, configured);
const modes: TelegramVoiceReplyModeSetting[] = [
"hidden",
"manual",
"mirror",
"always",
];
return {
inline_keyboard: [
[{ text: "⬆️ Back", callback_data: "settings:list" }],
...modes.map((value) => [
{
text: `${value === activeMode ? "🟢 " : ""}${getVoiceReplyModeLabel(value)}`,
callback_data: `settings:set:voice-reply:${value}`,
},
]),
],
};
}
export async function updateTelegramSettingsMenuMessage(
deps: TelegramSettingsMenuMessageUpdateDeps,
sectionRegistry?: TelegramSectionRegistry,
): Promise<void> {
await deps.updateSettingsMessage(
buildTelegramSettingsMenuText(),
buildTelegramSettingsMenuReplyMarkup(
deps.isProactivePushEnabled(),
deps.areDraftPreviewsEnabled(),
deps.getAssistantRenderingMode(),
deps.getVoiceReplyMode(),
deps.getTimeInjectionMode(),
sectionRegistry,
deps.isVoiceReplyModeConfigured(),
),
);
}
export async function updateProactivePushSettingsMessage(
deps: TelegramSettingsMenuCallbackDeps,
): Promise<void> {
const proactivePushEnabled = deps.isProactivePushEnabled();
await deps.updateSettingsMessage(
buildProactivePushSettingsText(proactivePushEnabled),
buildProactivePushSettingsReplyMarkup(proactivePushEnabled),
);
}
export async function updateDraftPreviewsSettingsMessage(
deps: TelegramSettingsMenuCallbackDeps,
): Promise<void> {
const enabled = deps.areDraftPreviewsEnabled();
await deps.updateSettingsMessage(
buildDraftPreviewsSettingsText(enabled),
buildDraftPreviewsSettingsReplyMarkup(enabled),
);
}
export async function updateAssistantRenderingSettingsMessage(
deps: TelegramSettingsMenuCallbackDeps,
): Promise<void> {
const mode = deps.getAssistantRenderingMode();
await deps.updateSettingsMessage(
buildAssistantRenderingSettingsText(mode),
buildAssistantRenderingSettingsReplyMarkup(mode),
);
}
export async function updateTimeInjectionModeSettingsMessage(
deps: TelegramSettingsMenuCallbackDeps,
): Promise<void> {
const mode = deps.getTimeInjectionMode();
await deps.updateSettingsMessage(
buildTimeInjectionModeSettingsText(mode),
buildTimeInjectionModeSettingsReplyMarkup(mode),
);
}
export async function updateVoiceReplyModeSettingsMessage(
deps: TelegramSettingsMenuCallbackDeps,
): Promise<void> {
const mode = deps.getVoiceReplyMode();
const configured = deps.isVoiceReplyModeConfigured();
await deps.updateSettingsMessage(
buildVoiceReplyModeSettingsText(mode, configured),
buildVoiceReplyModeSettingsReplyMarkup(mode, configured),
);
}
export async function handleTelegramSettingsMenuCallbackAction(
callbackQueryId: string,
data: string | undefined,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<boolean> {
if (!data?.startsWith("settings:")) return false;
if (data === "settings:list") {
await updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:proactive") {
await updateProactivePushSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
await updateDraftPreviewsSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:assistant-rendering") {
await updateAssistantRenderingSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:voice-reply") {
await updateVoiceReplyModeSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (
data === "settings:open:time-injection" ||
data === "settings:open:time"
) {
await updateTimeInjectionModeSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data.startsWith("settings:set:voice-reply:")) {
const mode = data.slice("settings:set:voice-reply:".length);
if (
mode === "hidden" ||
mode === "manual" ||
mode === "mirror" ||
mode === "always"
) {
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
await updateVoiceReplyModeSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Voice reply mode: ${mode}`,
);
return true;
}
}
if (
data.startsWith("settings:set:time-injection:") ||
data.startsWith("settings:set:time:")
) {
const mode = data.startsWith("settings:set:time-injection:")
? data.slice("settings:set:time-injection:".length)
: data.slice("settings:set:time:".length);
const normalizedMode = mode === "off" ? "hidden" : mode;
if (
normalizedMode === "hidden" ||
normalizedMode === "always" ||
normalizedMode === "interval"
) {
await deps.setTimeInjectionMode(normalizedMode);
await updateTimeInjectionModeSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Time injection: ${normalizedMode}`,
);
return true;
}
}
if (
data === "settings:set:draft-previews:on" ||
data === "settings:set:draft-previews:off" ||
data === "settings:set:rich-drafts:on" ||
data === "settings:set:rich-drafts:off"
) {
const enabled = data.endsWith(":on");
await deps.setDraftPreviewsEnabled(enabled);
await updateDraftPreviewsSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Draft previews ${enabled ? "enabled" : "disabled"}`,
);
return true;
}
if (data.startsWith("settings:set:assistant-rendering:")) {
const mode = data.slice("settings:set:assistant-rendering:".length);
if (mode === "rich" || mode === "html") {
await deps.setAssistantRenderingMode(mode);
await updateAssistantRenderingSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId, `Rendering: ${mode}`);
return true;
}
}
if (
data === "settings:set:proactive:on" ||
data === "settings:set:proactive:off"
) {
const enabled = data.endsWith(":on");
await deps.setProactivePushEnabled(enabled);
await updateProactivePushSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Proactive push ${enabled ? "enabled" : "disabled"}`,
);
return true;
}
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
export function createTelegramSettingsMenuRuntime<
TContext,
TModel extends MenuModel = MenuModel,
>(
deps: TelegramSettingsMenuRuntimeDeps<TContext, TModel>,
sectionRegistry?: TelegramSectionRegistry,
): TelegramSettingsMenuRuntime<TContext> {
return {
openSettingsMenu: (chatId, _replyToMessageId, ctx) =>
openTelegramSettingsMenu(
{
getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
isProactivePushEnabled: deps.isProactivePushEnabled,
areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
getAssistantRenderingMode: deps.getAssistantRenderingMode,
getVoiceReplyMode: deps.getVoiceReplyMode,
isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
getTimeInjectionMode: deps.getTimeInjectionMode,
sendSettingsMenu: (state, text, replyMarkup) =>
deps.sendInteractiveMessage(
state.chatId,
text,
"html",
replyMarkup,
),
storeModelMenuState: deps.storeModelMenuState,
},
sectionRegistry,
),
updateSettingsMenuMessage: (state) =>
updateTelegramSettingsMenuMessage(
{
isProactivePushEnabled: deps.isProactivePushEnabled,
areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
getAssistantRenderingMode: deps.getAssistantRenderingMode,
getVoiceReplyMode: deps.getVoiceReplyMode,
isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
getTimeInjectionMode: deps.getTimeInjectionMode,
updateSettingsMessage: (text, replyMarkup) =>
deps.editInteractiveMessage(
state.chatId,
state.messageId,
text,
"html",
replyMarkup,
),
},
sectionRegistry,
),
handleCallbackQuery: async (query) => {
if (!query.data?.startsWith("settings:")) return false;
const state = deps.getStoredModelMenuState(
query.message?.message_id,
query.message?.chat?.id,
);
if (!state) {
const voiceMode = query.data.slice("settings:set:voice-reply:".length);
if (
query.data.startsWith("settings:set:voice-reply:") &&
(voiceMode === "hidden" ||
voiceMode === "manual" ||
voiceMode === "mirror" ||
voiceMode === "always")
) {
await deps.setVoiceReplyMode(
voiceMode === "hidden" ? undefined : voiceMode,
);
await deps.answerCallbackQuery(
query.id,
`Voice reply mode: ${voiceMode}`,
);
return true;
}
if (
query.data === "settings:set:draft-previews:on" ||
query.data === "settings:set:draft-previews:off" ||
query.data === "settings:set:rich-drafts:on" ||
query.data === "settings:set:rich-drafts:off"
) {
const enabled = query.data.endsWith(":on");
await deps.setDraftPreviewsEnabled(enabled);
await deps.answerCallbackQuery(
query.id,
`Draft previews ${enabled ? "enabled" : "disabled"}`,
);
return true;
}
if (query.data.startsWith("settings:set:assistant-rendering:")) {
const mode = query.data.slice("settings:set:assistant-rendering:".length);
if (mode === "rich" || mode === "html") {
await deps.setAssistantRenderingMode(mode);
await deps.answerCallbackQuery(query.id, `Rendering: ${mode}`);
return true;
}
}
const hasTimeInjectionPrefix = query.data.startsWith(
"settings:set:time-injection:",
);
const timeMode = hasTimeInjectionPrefix
? query.data.slice("settings:set:time-injection:".length)
: query.data.slice("settings:set:time:".length);
if (
(hasTimeInjectionPrefix ||
query.data.startsWith("settings:set:time:")) &&
(timeMode === "off" ||
timeMode === "hidden" ||
timeMode === "always" ||
timeMode === "interval")
) {
const normalizedMode = timeMode === "off" ? "hidden" : timeMode;
await deps.setTimeInjectionMode(normalizedMode);
await deps.answerCallbackQuery(
query.id,
`Time injection: ${normalizedMode}`,
);
return true;
}
await deps.answerCallbackQuery(
query.id,
"Interactive message expired.",
);
return true;
}
return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
isProactivePushEnabled: deps.isProactivePushEnabled,
areDraftPreviewsEnabled: deps.areDraftPreviewsEnabled,
getAssistantRenderingMode: deps.getAssistantRenderingMode,
getVoiceReplyMode: deps.getVoiceReplyMode,
isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
getTimeInjectionMode: deps.getTimeInjectionMode,
setProactivePushEnabled: deps.setProactivePushEnabled,
setDraftPreviewsEnabled: deps.setDraftPreviewsEnabled,
setAssistantRenderingMode: deps.setAssistantRenderingMode,
setVoiceReplyMode: deps.setVoiceReplyMode,
setTimeInjectionMode: deps.setTimeInjectionMode,
updateSettingsMessage: (text, replyMarkup) =>
deps.editInteractiveMessage(
state.chatId,
state.messageId,
text,
"html",
replyMarkup,
),
answerCallbackQuery: deps.answerCallbackQuery,
sectionRegistry,
});
},
};
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Telegram status menu UI helpers
* Zones: telegram ui, status controls, menu composition
* Owns status-menu payloads, status callback handling, and status-menu message rendering
*/
import { formatTelegramCommandEmojiPrefix } from "./commands.ts";
import {
getTelegramSectionMainMenuRows,
type TelegramSectionRegistry,
} from "./sections.ts";
import {
formatStatusButtonLabel,
type TelegramMenuMessageRuntimeDeps,
type TelegramMenuRenderPayload,
type TelegramModelMenuState,
type TelegramReplyMarkup,
} from "./menu-model.ts";
import {
getCanonicalModelId,
type MenuModel,
type ThinkingLevel,
} from "./model.ts";
export interface TelegramStatusMenuCallbackDeps {
updateModelMenuMessage: () => Promise<void>;
updateThinkingMenuMessage: () => Promise<void>;
updateSettingsMenuMessage?: () => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
isVoiceReplyActive?: () => boolean;
}
export interface TelegramStatusMenuOpenDeps<
TModel extends MenuModel = MenuModel,
> {
isIdle: () => boolean;
sendBusyMessage: () => Promise<void>;
getModelMenuState: () => Promise<TelegramModelMenuState<TModel>>;
buildStatusHtml: () => string;
getActiveModel: () => TModel | undefined;
getThinkingLevel: () => ThinkingLevel;
getQueueItemCount?: () => number;
sendStatusMenu: (
state: TelegramModelMenuState<TModel>,
statusHtml: string,
activeModel: TModel | undefined,
thinkingLevel: ThinkingLevel,
queueItemCount: number,
) => Promise<number | undefined>;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
}
function isTelegramStatusMenuCallbackAction(
data: string | undefined,
action: "model" | "thinking" | "settings",
): boolean {
return data === `menu:${action}` || data === `status:${action}`;
}
function applyTelegramMenuRenderPayload(
state: TelegramModelMenuState,
payload: TelegramMenuRenderPayload,
): TelegramMenuRenderPayload {
state.mode = payload.nextMode;
return payload;
}
async function editTelegramMenuMessage(
state: TelegramModelMenuState,
payload: TelegramMenuRenderPayload,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
await deps.editInteractiveMessage(
state.chatId,
state.messageId,
appliedPayload.text,
appliedPayload.mode,
appliedPayload.replyMarkup,
);
}
function sendTelegramMenuMessage(
state: TelegramModelMenuState,
payload: TelegramMenuRenderPayload,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
return deps.sendInteractiveMessage(
state.chatId,
appliedPayload.text,
appliedPayload.mode,
appliedPayload.replyMarkup,
state.threadId !== undefined
? { target: { chatId: state.chatId, threadId: state.threadId } }
: undefined,
);
}
export async function openTelegramStatusMenu<
TModel extends MenuModel = MenuModel,
>(deps: TelegramStatusMenuOpenDeps<TModel>): Promise<void> {
const state = await deps.getModelMenuState();
const messageId = await deps.sendStatusMenu(
state,
deps.buildStatusHtml(),
deps.getActiveModel(),
deps.getThinkingLevel(),
deps.getQueueItemCount?.() ?? 0,
);
if (messageId === undefined) return;
state.messageId = messageId;
state.mode = "status";
deps.storeModelMenuState(state);
}
export async function handleTelegramStatusMenuCallbackAction(
callbackQueryId: string,
data: string | undefined,
activeModel: MenuModel | undefined,
deps: TelegramStatusMenuCallbackDeps,
): Promise<boolean> {
if (isTelegramStatusMenuCallbackAction(data, "model")) {
await deps.updateModelMenuMessage();
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (isTelegramStatusMenuCallbackAction(data, "settings")) {
if (!deps.updateSettingsMenuMessage) return false;
await deps.updateSettingsMenuMessage();
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (!isTelegramStatusMenuCallbackAction(data, "thinking")) return false;
if (deps.isVoiceReplyActive?.()) {
await deps.answerCallbackQuery(
callbackQueryId,
"Thinking controls are disabled during voice replies.",
);
return true;
}
if (!activeModel?.reasoning) {
await deps.answerCallbackQuery(
callbackQueryId,
"This model has no reasoning controls.",
);
return true;
}
await deps.updateThinkingMenuMessage();
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
export function buildStatusReplyMarkup(
activeModel: MenuModel | undefined,
currentThinkingLevel: ThinkingLevel,
queueItemCount = 0,
sectionRegistry?: TelegramSectionRegistry,
isVoiceReplyActive?: boolean,
): TelegramReplyMarkup {
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
rows.push([
{
text: formatStatusButtonLabel(
`${formatTelegramCommandEmojiPrefix("model")}Model`,
activeModel ? getCanonicalModelId(activeModel) : "unknown",
),
callback_data: "menu:model",
},
]);
if (activeModel?.reasoning && !isVoiceReplyActive) {
rows.push([
{
text: formatStatusButtonLabel(
`${formatTelegramCommandEmojiPrefix("thinking")}Thinking`,
currentThinkingLevel,
),
callback_data: "menu:thinking",
},
]);
}
rows.push([
{
text: `${queueItemCount === 0 ? "⌛" : "⏳"} Queue: ${queueItemCount}`,
callback_data: "menu:queue",
},
]);
if (sectionRegistry) {
const sectionRows = getTelegramSectionMainMenuRows(sectionRegistry);
for (const row of sectionRows) {
rows.push([row]);
}
}
rows.push([
{
text: "⚙️ Settings",
callback_data: "menu:settings",
},
]);
return { inline_keyboard: rows };
}
export function buildTelegramStatusMenuRenderPayload(
statusText: string,
activeModel: MenuModel | undefined,
currentThinkingLevel: ThinkingLevel,
queueItemCount = 0,
sectionRegistry?: TelegramSectionRegistry,
isVoiceReplyActive?: boolean,
): TelegramMenuRenderPayload {
return {
nextMode: "status",
text: statusText,
mode: "html",
replyMarkup: buildStatusReplyMarkup(
activeModel,
currentThinkingLevel,
queueItemCount,
sectionRegistry,
isVoiceReplyActive,
),
};
}
export async function updateTelegramStatusMessage(
state: TelegramModelMenuState,
statusText: string,
activeModel: MenuModel | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps,
queueItemCount = 0,
sectionRegistry?: TelegramSectionRegistry,
isVoiceReplyActive?: boolean,
): Promise<void> {
await editTelegramMenuMessage(
state,
buildTelegramStatusMenuRenderPayload(
statusText,
activeModel,
currentThinkingLevel,
queueItemCount,
sectionRegistry,
isVoiceReplyActive,
),
deps,
);
}
export function sendTelegramStatusMessage(
state: TelegramModelMenuState,
statusText: string,
activeModel: MenuModel | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps,
queueItemCount = 0,
sectionRegistry?: TelegramSectionRegistry,
isVoiceReplyActive?: boolean,
): Promise<number | undefined> {
return sendTelegramMenuMessage(
state,
buildTelegramStatusMenuRenderPayload(
statusText,
activeModel,
currentThinkingLevel,
queueItemCount,
sectionRegistry,
isVoiceReplyActive,
),
deps,
);
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Telegram thinking menu UI helpers
* Zones: telegram ui, thinking controls, menu composition
* Owns thinking-menu text, reply markup, callback handling, and thinking-menu message rendering
*/
import type {
TelegramMenuMessageRuntimeDeps,
TelegramMenuRenderPayload,
TelegramModelMenuState,
TelegramReplyMarkup,
} from "./menu-model.ts";
import {
isThinkingLevel,
type MenuModel,
THINKING_LEVELS,
type ThinkingLevel,
} from "./model.ts";
export interface TelegramThinkingMenuCallbackDeps {
setThinkingLevel: (level: ThinkingLevel) => void;
getCurrentThinkingLevel: () => ThinkingLevel;
updateStatusMessage: () => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
isVoiceReplyActive?: () => boolean;
}
export interface TelegramThinkingMenuOpenDeps<
TModel extends MenuModel = MenuModel,
> extends TelegramMenuMessageRuntimeDeps {
getModelMenuState: () => Promise<TelegramModelMenuState<TModel>>;
getActiveModel: () => TModel | undefined;
getThinkingLevel: () => ThinkingLevel;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
isVoiceReplyActive?: () => boolean;
}
function parseTelegramThinkingMenuCallbackAction(
data: string | undefined,
): { kind: "thinking:set"; level: string } | undefined {
if (!data?.startsWith("thinking:set:")) return undefined;
return { kind: "thinking:set", level: data.slice("thinking:set:".length) };
}
function applyTelegramMenuRenderPayload(
state: TelegramModelMenuState,
payload: TelegramMenuRenderPayload,
): TelegramMenuRenderPayload {
state.mode = payload.nextMode;
return payload;
}
async function editTelegramMenuMessage(
state: TelegramModelMenuState,
payload: TelegramMenuRenderPayload,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
await deps.editInteractiveMessage(
state.chatId,
state.messageId,
appliedPayload.text,
appliedPayload.mode,
appliedPayload.replyMarkup,
);
}
function sendTelegramMenuMessage(
state: TelegramModelMenuState,
payload: TelegramMenuRenderPayload,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
return deps.sendInteractiveMessage(
state.chatId,
appliedPayload.text,
appliedPayload.mode,
appliedPayload.replyMarkup,
state.threadId !== undefined
? { target: { chatId: state.chatId, threadId: state.threadId } }
: undefined,
);
}
export async function handleTelegramThinkingMenuCallbackAction(
callbackQueryId: string,
data: string | undefined,
activeModel: MenuModel | undefined,
deps: TelegramThinkingMenuCallbackDeps,
): Promise<boolean> {
const action = parseTelegramThinkingMenuCallbackAction(data);
if (!action) return false;
if (!isThinkingLevel(action.level)) {
await deps.answerCallbackQuery(callbackQueryId, "Invalid thinking level.");
return true;
}
if (deps.isVoiceReplyActive?.()) {
await deps.answerCallbackQuery(
callbackQueryId,
"Thinking controls are disabled during voice replies.",
);
return true;
}
if (!activeModel?.reasoning) {
await deps.answerCallbackQuery(
callbackQueryId,
"This model has no reasoning controls.",
);
return true;
}
deps.setThinkingLevel(action.level);
await deps.updateStatusMessage();
await deps.answerCallbackQuery(
callbackQueryId,
`Thinking: ${deps.getCurrentThinkingLevel()}`,
);
return true;
}
export function buildThinkingMenuText(): string {
return "<b>🧠 Choose a thinking level:</b>";
}
export function buildThinkingMenuReplyMarkup(
currentThinkingLevel: ThinkingLevel,
): TelegramReplyMarkup {
const rows = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
rows.push(
...THINKING_LEVELS.map((level) => [
{
text: level === currentThinkingLevel ? `🟢 ${level}` : level,
callback_data: `thinking:set:${level}`,
},
]),
);
return { inline_keyboard: rows };
}
export function buildTelegramThinkingMenuRenderPayload(
_activeModel: MenuModel | undefined,
currentThinkingLevel: ThinkingLevel,
): TelegramMenuRenderPayload {
return {
nextMode: "thinking",
text: buildThinkingMenuText(),
mode: "html",
replyMarkup: buildThinkingMenuReplyMarkup(currentThinkingLevel),
};
}
export async function openTelegramThinkingMenu<
TModel extends MenuModel = MenuModel,
>(deps: TelegramThinkingMenuOpenDeps<TModel>): Promise<void> {
if (deps.isVoiceReplyActive?.()) return;
const state = await deps.getModelMenuState();
const messageId = await sendTelegramMenuMessage(
state,
buildTelegramThinkingMenuRenderPayload(
deps.getActiveModel(),
deps.getThinkingLevel(),
),
deps,
);
if (messageId === undefined) return;
state.messageId = messageId;
state.mode = "thinking";
deps.storeModelMenuState(state);
}
export async function updateTelegramThinkingMenuMessage(
state: TelegramModelMenuState,
activeModel: MenuModel | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps & { isVoiceReplyActive?: () => boolean },
): Promise<void> {
if (deps.isVoiceReplyActive?.()) return;
await editTelegramMenuMessage(
state,
buildTelegramThinkingMenuRenderPayload(activeModel, currentThinkingLevel),
deps,
);
}
+884
View File
@@ -0,0 +1,884 @@
/**
* Telegram menu and inline-keyboard rendering helpers
* Zones: telegram ui, controls, status menu
* Owns app-menu/status state, inline UI text, and callback composition while model/thinking/queue menu details live in dedicated domains
*/
import {
createTelegramModelMenuStateBuilder,
handleTelegramModelMenuCallbackAction,
openTelegramModelMenu,
sendTelegramModelMenuMessage,
updateTelegramModelMenuMessage,
type TelegramMenuMessageRuntimeDeps,
type TelegramModelMenuState,
type TelegramModelMenuStateBuilderContext,
type TelegramModelMenuStateBuilderDeps,
type TelegramReplyMarkup,
} from "./menu-model.ts";
import {
handleTelegramStatusMenuCallbackAction,
openTelegramStatusMenu,
sendTelegramStatusMessage,
updateTelegramStatusMessage,
} from "./menu-status.ts";
import {
handleTelegramThinkingMenuCallbackAction,
openTelegramThinkingMenu,
updateTelegramThinkingMenuMessage,
} from "./menu-thinking.ts";
import {
type MenuModel,
type ScopedTelegramModel,
type ThinkingLevel,
} from "./model.ts";
import {
handleTelegramSectionCallback,
handleTelegramSectionOpen,
handleTelegramSectionSettingsOpen,
parseTelegramSectionCallback,
type TelegramSectionRegistry,
} from "./sections.ts";
export {
applyTelegramModelPageSelection,
applyTelegramModelScopeSelection,
buildModelMenuReplyMarkup,
buildModelPageMenuReplyMarkup,
buildTelegramModelCallbackPlan,
buildTelegramModelMenuRenderPayload,
buildTelegramModelMenuState,
buildTelegramModelMenuStateRuntime,
buildTelegramModelPageMenuRenderPayload,
createTelegramModelMenuRuntime,
createTelegramModelMenuStateBuilder,
formatScopedModelButtonText,
getModelMenuItems,
getStoredTelegramModelMenuState,
getTelegramModelMenuPage,
getTelegramModelSelection,
handleTelegramModelMenuCallbackAction,
MODEL_MENU_TITLE,
MODEL_PAGE_MENU_TITLE,
openTelegramModelMenu,
pruneStoredTelegramModelMenus,
resolveCachedTelegramModelMenuInputs,
sendTelegramModelMenuMessage,
storeTelegramModelMenuState,
TELEGRAM_MODEL_PAGE_SIZE,
updateTelegramModelMenuMessage,
} from "./menu-model.ts";
export type {
BuildTelegramModelCallbackPlanParams,
BuildTelegramModelMenuStateParams,
CachedTelegramModelMenuInputs,
MenuSettingsManager,
StoredTelegramModelMenuState,
TelegramMenuMessageRuntimeDeps,
TelegramMenuMutationResult,
TelegramMenuRenderPayload,
TelegramMenuSelectionResult,
TelegramModelCallbackPlan,
TelegramModelMenuCallbackDeps,
TelegramModelMenuInputCacheDeps,
TelegramModelMenuOpenDeps,
TelegramModelMenuPage,
TelegramModelMenuRuntime,
TelegramModelMenuRuntimeContext,
TelegramModelMenuRuntimeOptions,
TelegramModelMenuState,
TelegramModelMenuStateBuilderContext,
TelegramModelMenuStateBuilderDeps,
TelegramModelMenuStoreOptions,
TelegramModelScope,
TelegramReplyMarkup,
} from "./menu-model.ts";
export {
buildStatusReplyMarkup,
buildTelegramStatusMenuRenderPayload,
handleTelegramStatusMenuCallbackAction,
openTelegramStatusMenu,
sendTelegramStatusMessage,
updateTelegramStatusMessage,
} from "./menu-status.ts";
export type {
TelegramStatusMenuCallbackDeps,
TelegramStatusMenuOpenDeps,
} from "./menu-status.ts";
export {
buildTelegramThinkingMenuRenderPayload,
buildThinkingMenuReplyMarkup,
buildThinkingMenuText,
handleTelegramThinkingMenuCallbackAction,
openTelegramThinkingMenu,
updateTelegramThinkingMenuMessage,
} from "./menu-thinking.ts";
export type {
TelegramThinkingMenuCallbackDeps,
TelegramThinkingMenuOpenDeps,
} from "./menu-thinking.ts";
export interface TelegramMenuEffectPort<TModel extends MenuModel = MenuModel> {
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
updateModelMenuMessage: () => Promise<void>;
updateThinkingMenuMessage: () => Promise<void>;
updateStatusMessage: () => Promise<void>;
persistScopedModelPatterns?: (patterns: string[]) => Promise<void>;
setModel: (model: TModel) => Promise<boolean>;
setCurrentModel: (model: TModel) => void;
setThinkingLevel: (level: ThinkingLevel) => void;
getCurrentThinkingLevel: () => ThinkingLevel;
stagePendingModelSwitch: (selection: ScopedTelegramModel<TModel>) => void;
restartInterruptedTelegramTurn: (
selection: ScopedTelegramModel<TModel>,
) => Promise<boolean> | boolean;
}
export interface TelegramMenuCallbackEntryDeps {
handleStatusAction: () => Promise<boolean>;
handleThinkingAction: () => Promise<boolean>;
handleModelAction: () => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
}
export interface MenuCallbackQuery {
id: string;
data?: string;
message?: {
message_id?: number;
message_thread_id?: number;
chat?: { id?: number };
};
}
export interface StoredTelegramMenuCallbackDeps<
TModel extends MenuModel = MenuModel,
> {
getStoredModelMenuState: (
messageId: number | undefined,
chatId?: number,
) => TelegramModelMenuState<TModel> | undefined;
handleStatusAction: (
state: TelegramModelMenuState<TModel>,
) => Promise<boolean>;
handleThinkingAction: (
state: TelegramModelMenuState<TModel>,
) => Promise<boolean>;
handleModelAction: (
state: TelegramModelMenuState<TModel>,
) => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
}
export interface TelegramMenuCallbackRuntimeDeps<
TContext,
TModel extends MenuModel = MenuModel,
> {
getStoredModelMenuState: (
messageId: number | undefined,
chatId?: number,
) => TelegramModelMenuState<TModel> | undefined;
getActiveModel: (ctx: TContext) => TModel | undefined;
getThinkingLevel: () => ThinkingLevel;
setThinkingLevel: (level: ThinkingLevel) => void;
updateStatus: (ctx: TContext) => void;
updateModelMenuMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateThinkingMenuMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateStatusMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateSettingsMenuMessage?: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
isIdle: (ctx: TContext) => boolean;
hasActiveTelegramTurn: () => boolean;
hasAbortHandler: () => boolean;
hasActiveToolExecutions: () => boolean;
persistScopedModelPatterns?: (
patterns: string[],
ctx: TContext,
) => Promise<void>;
setModel: (model: TModel) => Promise<boolean>;
setCurrentModel: (model: TModel, ctx: TContext) => void;
stagePendingModelSwitch: (
selection: ScopedTelegramModel<TModel>,
ctx: TContext,
) => void;
restartInterruptedTelegramTurn: (
selection: ScopedTelegramModel<TModel>,
ctx: TContext,
) => Promise<boolean> | boolean;
sectionRegistry?: TelegramSectionRegistry;
editInteractiveMessage?: (
chatId: number,
messageId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramReplyMarkup,
) => Promise<void>;
sendInteractiveMessage?: (
chatId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramReplyMarkup,
options?: { target?: { chatId: number; threadId?: number } },
) => Promise<number | undefined>;
enqueueSectionPrompt?: (
prompt: string,
ctx: TContext,
target?: { chatId: number; threadId?: number },
) => Promise<void>;
deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
isVoiceReplyActive?: () => boolean;
}
export interface TelegramMenuActionRuntimeDeps<
TContext,
TModel extends MenuModel = MenuModel,
> extends TelegramMenuMessageRuntimeDeps {
getModelMenuState: (
chatId: number,
ctx: TContext,
threadId?: number,
) => Promise<TelegramModelMenuState<TModel>>;
getActiveModel: (ctx: TContext) => TModel | undefined;
getThinkingLevel: () => ThinkingLevel;
getQueueItemCount?: () => number;
buildStatusHtml: (ctx: TContext) => string;
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
isIdle: (ctx: TContext) => boolean;
canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
sendTextReply: (
chatId: number,
replyToMessageId: number,
text: string,
options?: { target?: { chatId: number; threadId?: number } },
) => Promise<unknown>;
sectionRegistry?: TelegramSectionRegistry;
isVoiceReplyActive?: () => boolean;
}
export interface TelegramMenuActionRuntime<
TContext,
TModel extends MenuModel = MenuModel,
> {
updateModelMenuMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateThinkingMenuMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateStatusMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
sendStatusMessage: (
chatId: number,
replyToMessageId: number,
ctx: TContext,
threadId?: number,
) => Promise<void>;
openModelMenu: (
chatId: number,
replyToMessageId: number,
ctx: TContext,
threadId?: number,
) => Promise<void>;
openThinkingMenu: (
chatId: number,
replyToMessageId: number,
ctx: TContext,
) => Promise<void>;
}
export type TelegramMenuCallbackAction =
| { kind: "ignore" }
| { kind: "status"; action: "model" | "thinking" | "queue" | "settings" }
| { kind: "thinking:set"; level: string }
| {
kind: "model";
action:
| "noop"
| "scope"
| "page"
| "pages"
| "open"
| "pick"
| "pick-selected"
| "scope-enable"
| "scope-disable"
| "scope-toggle";
value?: string;
};
export function parseTelegramMenuCallbackAction(
data: string | undefined,
): TelegramMenuCallbackAction {
if (data === "menu:model" || data === "status:model") {
return { kind: "status", action: "model" };
}
if (data === "menu:thinking" || data === "status:thinking") {
return { kind: "status", action: "thinking" };
}
if (data === "menu:queue" || data === "status:queue") {
return { kind: "status", action: "queue" };
}
if (data === "menu:settings" || data === "status:settings") {
return { kind: "status", action: "settings" };
}
if (data?.startsWith("thinking:set:")) {
return {
kind: "thinking:set",
level: data.slice("thinking:set:".length),
};
}
if (data?.startsWith("model:")) {
const [, action, value] = data.split(":");
if (
action === "noop" ||
action === "scope" ||
action === "page" ||
action === "pages" ||
action === "open" ||
action === "pick" ||
action === "pick-selected" ||
action === "scope-enable" ||
action === "scope-disable" ||
action === "scope-toggle"
) {
return { kind: "model", action, value };
}
}
return { kind: "ignore" };
}
export async function handleTelegramMenuCallbackEntry(
callbackQueryId: string,
data: string | undefined,
state: TelegramModelMenuState | undefined,
deps: TelegramMenuCallbackEntryDeps,
): Promise<void> {
if (!data) {
await deps.answerCallbackQuery(callbackQueryId);
return;
}
if (!state) {
await deps.answerCallbackQuery(
callbackQueryId,
"Interactive message expired.",
);
return;
}
const handled =
(await deps.handleStatusAction()) ||
(await deps.handleThinkingAction()) ||
(await deps.handleModelAction());
if (!handled) {
await deps.answerCallbackQuery(callbackQueryId);
}
}
export async function handleStoredTelegramMenuCallback<
TModel extends MenuModel = MenuModel,
>(
query: MenuCallbackQuery,
deps: StoredTelegramMenuCallbackDeps<TModel>,
): Promise<void> {
const state = deps.getStoredModelMenuState(
query.message?.message_id,
query.message?.chat?.id,
);
await handleTelegramMenuCallbackEntry(query.id, query.data, state, {
handleStatusAction: async () => {
if (!state) return false;
return deps.handleStatusAction(state);
},
handleThinkingAction: async () => {
if (!state) return false;
return deps.handleThinkingAction(state);
},
handleModelAction: async () => {
if (!state) return false;
return deps.handleModelAction(state);
},
answerCallbackQuery: deps.answerCallbackQuery,
});
}
export interface TelegramMenuCallbackRuntimeAdapterDeps<
TContext,
TModel extends MenuModel = MenuModel,
> {
getStoredModelMenuState: (
messageId: number | undefined,
chatId?: number,
) => TelegramModelMenuState<TModel> | undefined;
getActiveModel: (ctx: TContext) => TModel | undefined;
getThinkingLevel: () => ThinkingLevel;
setThinkingLevel: (level: ThinkingLevel) => void;
updateStatus: (ctx: TContext, error?: string) => void;
updateModelMenuMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateThinkingMenuMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateStatusMessage: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
updateSettingsMenuMessage?: (
state: TelegramModelMenuState<TModel>,
ctx: TContext,
) => Promise<void>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
isIdle: (ctx: TContext) => boolean;
hasActiveTelegramTurn: () => boolean;
hasAbortHandler: () => boolean;
getActiveToolExecutions: () => number;
persistScopedModelPatterns?: (
patterns: string[],
ctx: TContext,
) => Promise<void>;
setModel: (model: TModel) => Promise<boolean>;
setCurrentModel: (model: TModel, ctx: TContext) => void;
stagePendingModelSwitch: (
selection: ScopedTelegramModel<TModel>,
ctx: TContext,
) => void;
restartInterruptedTelegramTurn: (
selection: ScopedTelegramModel<TModel>,
ctx: TContext,
) => Promise<boolean> | boolean;
sectionRegistry?: TelegramSectionRegistry;
editInteractiveMessage?: (
chatId: number,
messageId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramReplyMarkup,
) => Promise<void>;
sendInteractiveMessage?: (
chatId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramReplyMarkup,
options?: { target?: { chatId: number; threadId?: number } },
) => Promise<number | undefined>;
enqueueSectionPrompt?: (
prompt: string,
ctx: TContext,
target?: { chatId: number; threadId?: number },
) => Promise<void>;
deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
}
export function createTelegramMenuCallbackHandler<
TQuery extends MenuCallbackQuery,
TContext,
TModel extends MenuModel = MenuModel,
>(
deps: TelegramMenuCallbackRuntimeDeps<TContext, TModel>,
): (query: TQuery, ctx: TContext) => Promise<void> {
return (query, ctx) => handleTelegramMenuCallbackRuntime(query, ctx, deps);
}
export function createTelegramMenuCallbackHandlerForContext<
TQuery extends MenuCallbackQuery,
TContext,
TModel extends MenuModel = MenuModel,
>(
deps: TelegramMenuCallbackRuntimeAdapterDeps<TContext, TModel>,
): (query: TQuery, ctx: TContext) => Promise<void> {
return createTelegramMenuCallbackHandler<TQuery, TContext, TModel>({
getStoredModelMenuState: deps.getStoredModelMenuState,
getActiveModel: deps.getActiveModel,
getThinkingLevel: deps.getThinkingLevel,
setThinkingLevel: deps.setThinkingLevel,
updateStatus: deps.updateStatus,
updateModelMenuMessage: deps.updateModelMenuMessage,
updateThinkingMenuMessage: deps.updateThinkingMenuMessage,
updateStatusMessage: deps.updateStatusMessage,
updateSettingsMenuMessage: deps.updateSettingsMenuMessage,
answerCallbackQuery: deps.answerCallbackQuery,
isIdle: deps.isIdle,
hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
hasAbortHandler: deps.hasAbortHandler,
hasActiveToolExecutions: () => deps.getActiveToolExecutions() > 0,
persistScopedModelPatterns: deps.persistScopedModelPatterns,
setModel: deps.setModel,
setCurrentModel: deps.setCurrentModel,
stagePendingModelSwitch: deps.stagePendingModelSwitch,
restartInterruptedTelegramTurn: deps.restartInterruptedTelegramTurn,
sectionRegistry: deps.sectionRegistry,
editInteractiveMessage: deps.editInteractiveMessage,
sendInteractiveMessage: deps.sendInteractiveMessage,
enqueueSectionPrompt: deps.enqueueSectionPrompt,
deleteMessage: deps.deleteMessage,
});
}
export async function handleTelegramMenuCallbackRuntime<
TQuery extends MenuCallbackQuery,
TContext,
TModel extends MenuModel = MenuModel,
>(
query: TQuery,
ctx: TContext,
deps: TelegramMenuCallbackRuntimeDeps<TContext, TModel>,
): Promise<void> {
if (query.data === "menu:back") {
const state = deps.getStoredModelMenuState(
query.message?.message_id,
query.message?.chat?.id,
);
if (!state) {
await deps.answerCallbackQuery(query.id, "Interactive message expired.");
return;
}
await deps.updateStatusMessage(state, ctx);
await deps.answerCallbackQuery(query.id);
return;
}
// Section callbacks: dispatch before built-in menu handling
if (deps.sectionRegistry && query.data?.startsWith("section:")) {
const parsed = parseTelegramSectionCallback(query.data);
if (parsed) {
const message = query.message;
const chatId = message?.chat?.id;
const messageId = message?.message_id;
const target =
typeof chatId === "number"
? typeof message?.message_thread_id === "number"
? { chatId, threadId: message.message_thread_id }
: { chatId }
: undefined;
if (typeof chatId === "number" && typeof messageId === "number") {
const { token, action, payload } = parsed;
if (action === "open") {
const state = deps.getStoredModelMenuState(messageId, chatId);
if (!state) {
await deps.answerCallbackQuery(
query.id,
"Interactive message expired.",
);
return;
}
const handled = await handleTelegramSectionOpen(
deps.sectionRegistry,
token,
chatId,
messageId,
query.id,
{
answerCallbackQuery: deps.answerCallbackQuery,
target,
editInteractiveMessage:
deps.editInteractiveMessage ?? (async () => {}),
sendInteractiveMessage:
deps.sendInteractiveMessage ?? (async () => undefined),
enqueuePrompt: deps.enqueueSectionPrompt
? (prompt: string) =>
deps.enqueueSectionPrompt!(prompt, ctx, target)
: async () => {},
deleteMessage: deps.deleteMessage ?? (async () => {}),
},
);
if (handled) return;
} else if (action === "settings") {
if (typeof chatId === "number" && typeof messageId === "number") {
const handled = await handleTelegramSectionSettingsOpen(
deps.sectionRegistry,
token,
chatId,
messageId,
query.id,
{
answerCallbackQuery: deps.answerCallbackQuery,
target,
editInteractiveMessage:
deps.editInteractiveMessage ?? (async () => {}),
sendInteractiveMessage:
deps.sendInteractiveMessage ?? (async () => undefined),
enqueuePrompt: deps.enqueueSectionPrompt
? (prompt: string) =>
deps.enqueueSectionPrompt!(prompt, ctx, target)
: async () => {},
deleteMessage: deps.deleteMessage ?? (async () => {}),
},
);
if (handled) return;
}
} else {
const handled = await handleTelegramSectionCallback(
deps.sectionRegistry,
token,
action,
payload,
chatId,
messageId,
query.id,
{
answerCallbackQuery: deps.answerCallbackQuery,
target,
editInteractiveMessage:
deps.editInteractiveMessage ?? (async () => {}),
sendInteractiveMessage:
deps.sendInteractiveMessage ?? (async () => undefined),
enqueuePrompt: deps.enqueueSectionPrompt
? (prompt: string) =>
deps.enqueueSectionPrompt!(prompt, ctx, target)
: async () => {},
deleteMessage: deps.deleteMessage ?? (async () => {}),
},
);
if (handled) return;
}
}
}
}
await handleStoredTelegramMenuCallback(query, {
getStoredModelMenuState: deps.getStoredModelMenuState,
handleStatusAction: async (state) =>
handleTelegramStatusMenuCallbackAction(
query.id,
query.data,
deps.getActiveModel(ctx),
{
updateModelMenuMessage: () => deps.updateModelMenuMessage(state, ctx),
updateThinkingMenuMessage: () =>
deps.updateThinkingMenuMessage(state, ctx),
updateSettingsMenuMessage: () =>
deps.updateSettingsMenuMessage?.(state, ctx) ?? Promise.resolve(),
answerCallbackQuery: deps.answerCallbackQuery,
isVoiceReplyActive: deps.isVoiceReplyActive,
},
),
handleThinkingAction: async (state) =>
handleTelegramThinkingMenuCallbackAction(
query.id,
query.data,
deps.getActiveModel(ctx),
{
setThinkingLevel: (level) => {
deps.setThinkingLevel(level);
deps.updateStatus(ctx);
},
getCurrentThinkingLevel: deps.getThinkingLevel,
updateStatusMessage: () => deps.updateStatusMessage(state, ctx),
answerCallbackQuery: deps.answerCallbackQuery,
isVoiceReplyActive: deps.isVoiceReplyActive,
},
),
handleModelAction: async (state) => {
try {
return await handleTelegramModelMenuCallbackAction(
query.id,
{
data: query.data,
state,
activeModel: deps.getActiveModel(ctx),
currentThinkingLevel: deps.getThinkingLevel(),
isIdle: deps.isIdle(ctx),
canRestartBusyRun:
deps.hasActiveTelegramTurn() && deps.hasAbortHandler(),
hasActiveToolExecutions: deps.hasActiveToolExecutions(),
},
{
updateModelMenuMessage: () =>
deps.updateModelMenuMessage(state, ctx),
updateStatusMessage: () => deps.updateStatusMessage(state, ctx),
answerCallbackQuery: deps.answerCallbackQuery,
persistScopedModelPatterns: deps.persistScopedModelPatterns
? (patterns) => deps.persistScopedModelPatterns!(patterns, ctx)
: undefined,
setModel: deps.setModel,
setCurrentModel: (model) => deps.setCurrentModel(model, ctx),
setThinkingLevel: (level) => {
deps.setThinkingLevel(level);
deps.updateStatus(ctx);
},
stagePendingModelSwitch: (selection) => {
deps.stagePendingModelSwitch(selection, ctx);
},
restartInterruptedTelegramTurn: (selection) =>
deps.restartInterruptedTelegramTurn(selection, ctx),
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await deps.answerCallbackQuery(query.id, message);
return true;
}
},
answerCallbackQuery: deps.answerCallbackQuery,
});
}
export interface TelegramMenuActionRuntimeWithStateBuilderDeps<
TModel extends MenuModel = MenuModel,
TContext extends TelegramModelMenuStateBuilderContext<TModel> =
TelegramModelMenuStateBuilderContext<TModel>,
>
extends
Omit<TelegramMenuActionRuntimeDeps<TContext, TModel>, "getModelMenuState">,
TelegramModelMenuStateBuilderDeps<TModel, TContext> {
isVoiceReplyActive?: () => boolean;
}
export function createTelegramMenuActionRuntimeWithStateBuilder<
TModel extends MenuModel = MenuModel,
TContext extends TelegramModelMenuStateBuilderContext<TModel> =
TelegramModelMenuStateBuilderContext<TModel>,
>(
deps: TelegramMenuActionRuntimeWithStateBuilderDeps<TModel, TContext>,
): TelegramMenuActionRuntime<TContext, TModel> {
return createTelegramMenuActionRuntime({
getModelMenuState: createTelegramModelMenuStateBuilder({
runtime: deps.runtime,
createSettingsManager: deps.createSettingsManager,
getActiveModel: deps.getActiveModel,
}),
getActiveModel: deps.getActiveModel,
getThinkingLevel: deps.getThinkingLevel,
getQueueItemCount: deps.getQueueItemCount,
buildStatusHtml: deps.buildStatusHtml,
storeModelMenuState: deps.storeModelMenuState,
isIdle: deps.isIdle,
canOfferInFlightModelSwitch: deps.canOfferInFlightModelSwitch,
sendTextReply: deps.sendTextReply,
editInteractiveMessage: deps.editInteractiveMessage,
sendInteractiveMessage: deps.sendInteractiveMessage,
sectionRegistry: deps.sectionRegistry,
});
}
export function createTelegramMenuActionRuntime<
TContext,
TModel extends MenuModel = MenuModel,
>(
deps: TelegramMenuActionRuntimeDeps<TContext, TModel>,
): TelegramMenuActionRuntime<TContext, TModel> {
return {
updateModelMenuMessage: (state, ctx) =>
updateTelegramModelMenuMessage(state, deps.getActiveModel(ctx), deps),
updateThinkingMenuMessage: (state, ctx) =>
updateTelegramThinkingMenuMessage(
state,
deps.getActiveModel(ctx),
deps.getThinkingLevel(),
deps,
),
updateStatusMessage: (state, ctx) =>
updateTelegramStatusMessage(
state,
deps.buildStatusHtml(ctx),
deps.getActiveModel(ctx),
deps.getThinkingLevel(),
deps,
deps.getQueueItemCount?.() ?? 0,
deps.sectionRegistry,
deps.isVoiceReplyActive?.(),
),
sendStatusMessage: (chatId, replyToMessageId, ctx, threadId) =>
openTelegramStatusMenu({
isIdle: () => deps.isIdle(ctx),
sendBusyMessage: async () => {
await deps.sendTextReply(
chatId,
replyToMessageId,
"Cannot open status while Pi is busy. Send /abort, /next, or /stop.",
{ target: { chatId, threadId } },
);
},
getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
buildStatusHtml: () => deps.buildStatusHtml(ctx),
getActiveModel: () => deps.getActiveModel(ctx),
getThinkingLevel: deps.getThinkingLevel,
getQueueItemCount: deps.getQueueItemCount,
sendStatusMenu: (
state,
statusHtml,
activeModel,
thinkingLevel,
queueItemCount,
) =>
sendTelegramStatusMessage(
state,
statusHtml,
activeModel,
thinkingLevel,
deps,
queueItemCount,
deps.sectionRegistry,
deps.isVoiceReplyActive?.(),
),
storeModelMenuState: deps.storeModelMenuState,
}),
openModelMenu: (chatId, replyToMessageId, ctx, threadId) =>
openTelegramModelMenu({
isIdle: () => deps.isIdle(ctx),
canOfferInFlightModelSwitch: () =>
deps.canOfferInFlightModelSwitch(ctx),
sendBusyMessage: async () => {
await deps.sendTextReply(
chatId,
replyToMessageId,
"Cannot switch model while Pi is busy. Send /abort, /next, or /stop.",
{ target: { chatId, threadId } },
);
},
sendNoModelsMessage: async () => {
await deps.sendTextReply(
chatId,
replyToMessageId,
"No available models with configured auth.",
{ target: { chatId, threadId } },
);
},
getModelMenuState: () => deps.getModelMenuState(chatId, ctx, threadId),
getActiveModel: () => deps.getActiveModel(ctx),
sendModelMenu: (state, activeModel) =>
sendTelegramModelMenuMessage(state, activeModel, deps),
storeModelMenuState: deps.storeModelMenuState,
}),
openThinkingMenu: (chatId, _replyToMessageId, ctx) =>
openTelegramThinkingMenu({
getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
getActiveModel: () => deps.getActiveModel(ctx),
getThinkingLevel: deps.getThinkingLevel,
storeModelMenuState: deps.storeModelMenuState,
editInteractiveMessage: deps.editInteractiveMessage,
sendInteractiveMessage: deps.sendInteractiveMessage,
isVoiceReplyActive: deps.isVoiceReplyActive,
}),
};
}
+645
View File
@@ -0,0 +1,645 @@
/**
* Telegram model control domain helpers
* Zones: pi agent model control, telegram controls, queue continuation
* Owns model identity, thinking levels, scoped resolution, current-model state, and in-flight model switching
*/
import type { PendingTelegramTurn } from "./queue.ts";
import { TELEGRAM_PREFIX } from "./turns.ts";
export interface MenuModel {
provider: string;
id: string;
name?: string;
reasoning?: boolean;
}
export type ThinkingLevel =
"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export interface ScopedTelegramModel<TModel extends MenuModel = MenuModel> {
model: TModel;
thinkingLevel?: ThinkingLevel;
}
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
];
export interface CurrentModelStore<
TContext,
TModel extends MenuModel = MenuModel,
> {
get: (ctx: TContext) => TModel | undefined;
getStored: () => TModel | undefined;
set: (model: TModel | undefined) => void;
}
export interface CurrentModelUpdateRuntime<
TContext,
TModel extends MenuModel = MenuModel,
> {
setCurrentModel: (model: TModel | undefined, ctx: TContext) => void;
onModelSelect: (event: { model: TModel | undefined }, ctx: TContext) => void;
}
export type CurrentModelRuntime<
TContext,
TModel extends MenuModel = MenuModel,
> = CurrentModelStore<TContext, TModel> &
CurrentModelUpdateRuntime<TContext, TModel>;
export function createCurrentModelStore<
TContext,
TModel extends MenuModel = MenuModel,
>(
getContextModel: (ctx: TContext) => TModel | undefined,
): CurrentModelStore<TContext, TModel> {
let currentModel: TModel | undefined;
return {
get: (ctx) => currentModel ?? getContextModel(ctx),
getStored: () => currentModel,
set: (model) => {
currentModel = model;
},
};
}
export function createCurrentModelUpdateRuntime<
TContext,
TModel extends MenuModel = MenuModel,
>(deps: {
setCurrentModel: (model: TModel | undefined) => void;
updateStatus: (ctx: TContext) => void;
}): CurrentModelUpdateRuntime<TContext, TModel> {
const setAndUpdate = (model: TModel | undefined, ctx: TContext): void => {
deps.setCurrentModel(model);
deps.updateStatus(ctx);
};
return {
setCurrentModel: setAndUpdate,
onModelSelect: (event, ctx) => {
setAndUpdate(event.model, ctx);
},
};
}
export function createCurrentModelRuntime<
TContext,
TModel extends MenuModel = MenuModel,
>(deps: {
getContextModel: (ctx: TContext) => TModel | undefined;
updateStatus: (ctx: TContext) => void;
}): CurrentModelRuntime<TContext, TModel> {
const store = createCurrentModelStore(deps.getContextModel);
return {
...store,
...createCurrentModelUpdateRuntime({
setCurrentModel: store.set,
updateStatus: deps.updateStatus,
}),
};
}
export function modelsMatch(
a: Pick<MenuModel, "provider" | "id"> | undefined,
b: Pick<MenuModel, "provider" | "id"> | undefined,
): boolean {
return !!a && !!b && a.provider === b.provider && a.id === b.id;
}
export function getCanonicalModelId(
model: Pick<MenuModel, "provider" | "id">,
): string {
return `${model.provider}/${model.id}`;
}
export function isThinkingLevel(value: string): value is ThinkingLevel {
return THINKING_LEVELS.includes(value as ThinkingLevel);
}
export function parseTelegramScopedModelPatternList(value: string): string[] {
return value
.split(",")
.map((pattern) => pattern.trim())
.filter(Boolean);
}
export function parseTelegramCliScopedModelPatterns(
args: string[],
): string[] | undefined {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--models") {
const patterns = parseTelegramScopedModelPatternList(args[i + 1] ?? "");
return patterns.length > 0 ? patterns : undefined;
}
if (arg.startsWith("--models=")) {
const patterns = parseTelegramScopedModelPatternList(
arg.slice("--models=".length),
);
return patterns.length > 0 ? patterns : undefined;
}
}
return undefined;
}
function escapeRegex(text: string): string {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function globMatches(text: string, pattern: string): boolean {
let regex = "^";
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i];
if (char === "*") {
regex += ".*";
continue;
}
if (char === "?") {
regex += ".";
continue;
}
if (char === "[") {
const end = pattern.indexOf("]", i + 1);
if (end !== -1) {
const content = pattern.slice(i + 1, end);
regex += content.startsWith("!")
? `[^${content.slice(1)}]`
: `[${content}]`;
i = end;
continue;
}
}
regex += escapeRegex(char);
}
regex += "$";
return new RegExp(regex, "i").test(text);
}
function isAliasModelId(id: string): boolean {
if (id.endsWith("-latest")) return true;
return !/-\d{8}$/.test(id);
}
function findExactModelReferenceMatch<TModel extends MenuModel = MenuModel>(
modelReference: string,
availableModels: TModel[],
): TModel | undefined {
const trimmedReference = modelReference.trim();
if (!trimmedReference) return undefined;
const normalizedReference = trimmedReference.toLowerCase();
const canonicalMatches = availableModels.filter(
(model) => getCanonicalModelId(model).toLowerCase() === normalizedReference,
);
if (canonicalMatches.length === 1) return canonicalMatches[0];
if (canonicalMatches.length > 1) return undefined;
const slashIndex = trimmedReference.indexOf("/");
if (slashIndex !== -1) {
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
const providerMatches = availableModels.filter(
(model) =>
model.provider.toLowerCase() === provider.toLowerCase() &&
model.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatches.length === 1) return providerMatches[0];
if (providerMatches.length > 1) return undefined;
}
}
const idMatches = availableModels.filter(
(model) => model.id.toLowerCase() === normalizedReference,
);
return idMatches.length === 1 ? idMatches[0] : undefined;
}
function tryMatchScopedModel<TModel extends MenuModel = MenuModel>(
modelPattern: string,
availableModels: TModel[],
): TModel | undefined {
const exactMatch = findExactModelReferenceMatch(
modelPattern,
availableModels,
);
if (exactMatch) return exactMatch;
const matches = availableModels.filter(
(model) =>
model.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
model.name?.toLowerCase().includes(modelPattern.toLowerCase()),
);
if (matches.length === 0) return undefined;
const aliases = matches.filter((model) => isAliasModelId(model.id));
const datedVersions = matches.filter((model) => !isAliasModelId(model.id));
if (aliases.length > 0) {
aliases.sort((a, b) => b.id.localeCompare(a.id));
return aliases[0];
}
datedVersions.sort((a, b) => b.id.localeCompare(a.id));
return datedVersions[0];
}
function parseScopedModelPattern<TModel extends MenuModel = MenuModel>(
pattern: string,
availableModels: TModel[],
): { model: TModel | undefined; thinkingLevel?: ThinkingLevel } {
const exactMatch = tryMatchScopedModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: undefined };
}
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
return { model: undefined, thinkingLevel: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
const suffix = pattern.substring(lastColonIndex + 1);
if (isThinkingLevel(suffix)) {
const parsedPrefix = parseScopedModelPattern(prefix, availableModels);
if (parsedPrefix.model) {
return { model: parsedPrefix.model, thinkingLevel: suffix };
}
return parsedPrefix;
}
return parseScopedModelPattern(prefix, availableModels);
}
export function resolveScopedModelPatterns<
TModel extends MenuModel = MenuModel,
>(
patterns: string[],
availableModels: TModel[],
): ScopedTelegramModel<TModel>[] {
const resolved: ScopedTelegramModel<TModel>[] = [];
const seen = new Set<string>();
for (const pattern of patterns) {
if (
pattern.includes("*") ||
pattern.includes("?") ||
pattern.includes("[")
) {
const colonIndex = pattern.lastIndexOf(":");
let globPattern = pattern;
let thinkingLevel: ThinkingLevel | undefined;
if (colonIndex !== -1) {
const suffix = pattern.substring(colonIndex + 1);
if (isThinkingLevel(suffix)) {
thinkingLevel = suffix;
globPattern = pattern.substring(0, colonIndex);
}
}
const matches = availableModels.filter(
(model) =>
globMatches(getCanonicalModelId(model), globPattern) ||
globMatches(model.id, globPattern),
);
for (const model of matches) {
const key = getCanonicalModelId(model);
if (seen.has(key)) continue;
seen.add(key);
resolved.push({ model, thinkingLevel });
}
continue;
}
const matched = parseScopedModelPattern(pattern, availableModels);
if (!matched.model) continue;
const key = getCanonicalModelId(matched.model);
if (seen.has(key)) continue;
seen.add(key);
resolved.push({
model: matched.model,
thinkingLevel: matched.thinkingLevel,
});
}
return resolved;
}
export function sortScopedModels<TModel extends MenuModel = MenuModel>(
models: ScopedTelegramModel<TModel>[],
currentModel: TModel | undefined,
): ScopedTelegramModel<TModel>[] {
const sorted = [...models];
sorted.sort((a, b) => {
const aIsCurrent = modelsMatch(a.model, currentModel);
const bIsCurrent = modelsMatch(b.model, currentModel);
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
const providerCompare = a.model.provider.localeCompare(b.model.provider);
if (providerCompare !== 0) return providerCompare;
return a.model.id.localeCompare(b.model.id);
});
return sorted;
}
// --- In-Flight Model Switching ---
export interface PendingModelSwitchStore<TSelection> {
get: () => TSelection | undefined;
set: (selection: TSelection | undefined) => void;
clear: () => void;
has: () => boolean;
}
export interface TelegramInFlightModelSwitchState {
isIdle: boolean;
hasActiveTelegramTurn: boolean;
hasAbortHandler: boolean;
}
export function createPendingModelSwitchStore<
TSelection,
>(): PendingModelSwitchStore<TSelection> {
let selection: TSelection | undefined;
return {
get: () => selection,
set: (nextSelection) => {
selection = nextSelection;
},
clear: () => {
selection = undefined;
},
has: () => selection !== undefined,
};
}
export function canRestartTelegramTurnForModelSwitch(
state: TelegramInFlightModelSwitchState,
): boolean {
return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
}
export function shouldTriggerPendingTelegramModelSwitchAbort(state: {
hasPendingModelSwitch: boolean;
hasActiveTelegramTurn: boolean;
hasAbortHandler: boolean;
activeToolExecutions: number;
}): boolean {
return (
state.hasPendingModelSwitch &&
state.hasActiveTelegramTurn &&
state.hasAbortHandler &&
state.activeToolExecutions === 0
);
}
export function restartTelegramModelSwitchContinuation<
TTurn,
TSelection,
>(state: {
activeTurn: TTurn | undefined;
abort: (() => void) | undefined;
selection: TSelection;
queueContinuation: (turn: TTurn, selection: TSelection) => void;
}): boolean {
if (!state.activeTurn || !state.abort) return false;
state.queueContinuation(state.activeTurn, state.selection);
state.abort();
return true;
}
function truncateTelegramModelSwitchStatusSummary(
text: string,
maxWords = 4,
maxLength = 32,
): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return "";
const words = normalized.split(" ");
let summary = words.slice(0, maxWords).join(" ");
if (summary.length === 0) summary = normalized;
if (summary.length > maxLength) {
summary = summary.slice(0, maxLength).trimEnd();
}
return summary.length < normalized.length || words.length > maxWords
? `${summary}…`
: summary;
}
export function buildTelegramModelSwitchContinuationText<
TModel extends MenuModel,
>(
telegramPrefix: string,
model: TModel,
thinkingLevel?: ScopedTelegramModel<TModel>["thinkingLevel"],
): string {
const modelLabel = `${model.provider}/${model.id}`;
const thinkingSuffix = thinkingLevel
? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
: "";
return `${telegramPrefix} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
}
export function buildTelegramModelSwitchContinuationTurn<
TModel extends MenuModel,
>(options: {
turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
selection: ScopedTelegramModel<TModel>;
telegramPrefix?: string;
queueOrder: number;
laneOrder: number;
}): PendingTelegramTurn {
const modelLabel = `${options.selection.model.provider}/${options.selection.model.id}`;
const statusLabel = truncateTelegramModelSwitchStatusSummary(
`continue on ${options.selection.model.id}`,
);
return {
kind: "prompt",
chatId: options.turn.chatId,
...(options.turn.target ? { target: { ...options.turn.target } } : {}),
replyToMessageId: options.turn.replyToMessageId,
sourceMessageIds: [],
queueOrder: options.queueOrder,
queueLane: "control",
laneOrder: options.laneOrder,
queuedAttachments: [],
content: [
{
type: "text",
text: buildTelegramModelSwitchContinuationText(
options.telegramPrefix ?? TELEGRAM_PREFIX,
options.selection.model,
options.selection.thinkingLevel,
),
},
],
historyText: `Continue interrupted Telegram request on ${modelLabel}`,
statusSummary: `↻ ${statusLabel || "continue"}`,
};
}
export function createTelegramModelSwitchContinuationTurnBuilder<
TModel extends MenuModel,
>(deps: {
telegramPrefix?: string;
allocateItemOrder: () => number;
allocateControlOrder: () => number;
}): (options: {
turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
selection: ScopedTelegramModel<TModel>;
}) => PendingTelegramTurn {
return (options) =>
buildTelegramModelSwitchContinuationTurn({
...options,
telegramPrefix: deps.telegramPrefix,
queueOrder: deps.allocateItemOrder(),
laneOrder: deps.allocateControlOrder(),
});
}
export function createTelegramModelSwitchContinuationQueue<
TContext,
TSelection extends ScopedTelegramModel,
>(deps: {
createContinuationTurn: (options: {
turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId" | "target">;
selection: TSelection;
}) => PendingTelegramTurn;
appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
}): (turn: PendingTelegramTurn, selection: TSelection, ctx: TContext) => void {
return (turn, selection, ctx) => {
deps.appendQueuedItem(
deps.createContinuationTurn({ turn, selection }),
ctx,
);
};
}
export function createTelegramModelSwitchContinuationQueueRuntime<
TContext,
TSelection extends ScopedTelegramModel,
>(deps: {
telegramPrefix?: string;
allocateItemOrder: () => number;
allocateControlOrder: () => number;
appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
}): (turn: PendingTelegramTurn, selection: TSelection, ctx: TContext) => void {
return createTelegramModelSwitchContinuationQueue<TContext, TSelection>({
createContinuationTurn: createTelegramModelSwitchContinuationTurnBuilder({
telegramPrefix: deps.telegramPrefix,
allocateItemOrder: deps.allocateItemOrder,
allocateControlOrder: deps.allocateControlOrder,
}),
appendQueuedItem: deps.appendQueuedItem,
});
}
export interface TelegramModelSwitchControllerDeps<TContext, TSelection> {
isIdle: (ctx: TContext) => boolean;
getPendingModelSwitch: () => TSelection | undefined;
setPendingModelSwitch: (selection: TSelection | undefined) => void;
getActiveTurn: () => PendingTelegramTurn | undefined;
getAbortHandler: () => (() => void) | undefined;
hasAbortHandler: () => boolean;
getActiveToolExecutions: () => number;
queueContinuation: (
turn: PendingTelegramTurn,
selection: TSelection,
ctx: TContext,
) => void;
updateStatus: (ctx: TContext) => void;
}
export interface TelegramModelSwitchController<TContext, TSelection> {
canOfferInFlightSwitch: (ctx: TContext) => boolean;
stagePendingSwitch: (selection: TSelection, ctx: TContext) => void;
clearPendingSwitch: () => void;
queueContinuation: (
turn: PendingTelegramTurn,
selection: TSelection,
ctx: TContext,
) => void;
triggerPendingAbort: (ctx: TContext) => boolean;
restartInterruptedTurn: (selection: TSelection, ctx: TContext) => boolean;
}
export interface TelegramModelSwitchControllerRuntimeDeps<
TContext,
TSelection extends ScopedTelegramModel,
> extends Omit<
TelegramModelSwitchControllerDeps<TContext, TSelection>,
"queueContinuation"
> {
telegramPrefix?: string;
allocateItemOrder: () => number;
allocateControlOrder: () => number;
appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
}
export function createTelegramModelSwitchControllerRuntime<
TContext,
TSelection extends ScopedTelegramModel,
>(
deps: TelegramModelSwitchControllerRuntimeDeps<TContext, TSelection>,
): TelegramModelSwitchController<TContext, TSelection> {
return createTelegramModelSwitchController({
isIdle: deps.isIdle,
getPendingModelSwitch: deps.getPendingModelSwitch,
setPendingModelSwitch: deps.setPendingModelSwitch,
getActiveTurn: deps.getActiveTurn,
getAbortHandler: deps.getAbortHandler,
hasAbortHandler: deps.hasAbortHandler,
getActiveToolExecutions: deps.getActiveToolExecutions,
queueContinuation: createTelegramModelSwitchContinuationQueueRuntime({
telegramPrefix: deps.telegramPrefix,
allocateItemOrder: deps.allocateItemOrder,
allocateControlOrder: deps.allocateControlOrder,
appendQueuedItem: deps.appendQueuedItem,
}),
updateStatus: deps.updateStatus,
});
}
export function createTelegramModelSwitchController<TContext, TSelection>(
deps: TelegramModelSwitchControllerDeps<TContext, TSelection>,
): TelegramModelSwitchController<TContext, TSelection> {
return {
canOfferInFlightSwitch: (ctx) =>
canRestartTelegramTurnForModelSwitch({
isIdle: deps.isIdle(ctx),
hasActiveTelegramTurn: !!deps.getActiveTurn(),
hasAbortHandler: deps.hasAbortHandler(),
}),
stagePendingSwitch: (selection, ctx) => {
deps.setPendingModelSwitch(selection);
deps.updateStatus(ctx);
},
clearPendingSwitch: () => {
deps.setPendingModelSwitch(undefined);
},
queueContinuation: deps.queueContinuation,
triggerPendingAbort: (ctx) => {
if (
!shouldTriggerPendingTelegramModelSwitchAbort({
hasPendingModelSwitch: !!deps.getPendingModelSwitch(),
hasActiveTelegramTurn: !!deps.getActiveTurn(),
hasAbortHandler: deps.hasAbortHandler(),
activeToolExecutions: deps.getActiveToolExecutions(),
})
) {
return false;
}
const selection = deps.getPendingModelSwitch();
const turn = deps.getActiveTurn();
const abort = deps.getAbortHandler();
if (!selection || !turn || !abort) return false;
deps.setPendingModelSwitch(undefined);
deps.queueContinuation(turn, selection, ctx);
abort();
return true;
},
restartInterruptedTurn: (selection, ctx) =>
restartTelegramModelSwitchContinuation({
activeTurn: deps.getActiveTurn(),
abort: deps.getAbortHandler(),
selection,
queueContinuation: (turn, nextSelection) => {
deps.queueContinuation(turn, nextSelection, ctx);
},
}),
};
}
+757
View File
@@ -0,0 +1,757 @@
/**
* Telegram outbound attachment helpers
* Zones: telegram outbound, pi agent tool, filesystem
* Owns telegram_attach registration, outbound attachment queueing, and delivery so Telegram file output stays in one domain module
*/
import { stat } from "node:fs/promises";
import { basename } from "node:path";
import { Type } from "@sinclair/typebox";
import type { ExtensionAPI } from "./pi.ts";
import { buildTelegramMultipartReplyParameters } from "./replies.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
const MAX_ATTACHMENTS_PER_TURN = 10;
export const TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
export function getTelegramOutboundAttachmentByteLimitFromEnv(
env: NodeJS.ProcessEnv,
names: string[],
defaultValue = TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
): number {
for (const name of names) {
const rawValue = env[name]?.trim();
if (!rawValue) continue;
const parsed = Number(rawValue);
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
}
return defaultValue;
}
export const TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES =
getTelegramOutboundAttachmentByteLimitFromEnv(process.env, [
"PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES",
"TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES",
]);
export interface TelegramOutboundAttachmentToolResult {
content: Array<{ type: "text"; text: string }>;
details: { paths: string[] };
}
export interface TelegramOutboundAttachmentRuntimeEventRecorderPort {
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramOutboundAttachmentToolRegistrationDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
maxAttachmentsPerTurn?: number;
maxAttachmentSizeBytes?: number;
getActiveTurn: () => TelegramOutboundAttachmentQueueTargetView | undefined;
getDefaultChatId?: () => number | undefined;
getDefaultTarget?: () => TelegramTarget | undefined;
canSendDirect?: () => boolean;
sendMultipart?: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
}
export interface TelegramOutboundMessagePlan {
markdown: string;
replyMarkup?: unknown;
}
export interface TelegramOutboundMessageToolRegistrationDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
getDefaultChatId: () => number | undefined;
getDefaultTarget?: () => TelegramTarget | undefined;
canSendDirect: () => boolean;
planMessage: (markdown: string) => TelegramOutboundMessagePlan;
sendMarkdownMessage: (
chatId: number,
markdown: string,
options?: { replyMarkup?: unknown; target?: TelegramTarget },
) => Promise<number | undefined>;
}
export interface TelegramQueuedOutboundAttachmentView {
path: string;
fileName: string;
}
export interface TelegramOutboundAttachmentQueueTargetView {
queuedAttachments: TelegramQueuedOutboundAttachmentView[];
guestQueryId?: string;
}
export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
chatId: number;
replyToMessageId: number;
target?: TelegramTarget;
}
export type TelegramGuestCachedAttachmentResult =
| {
type: "document";
id: string;
title: string;
document_file_id: string;
caption?: string;
}
| {
type: "photo";
id: string;
photo_file_id: string;
caption?: string;
}
| {
type: "audio";
id: string;
audio_file_id: string;
caption?: string;
}
| {
type: "voice";
id: string;
voice_file_id: string;
title: string;
caption?: string;
};
interface TelegramGuestStagingMessage {
message_id?: number;
document?: { file_id?: string };
photo?: Array<{ file_id?: string; file_size?: number }>;
audio?: { file_id?: string };
voice?: { file_id?: string };
}
function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
const normalized = path.toLowerCase();
return (
normalized.endsWith(".jpg") ||
normalized.endsWith(".jpeg") ||
normalized.endsWith(".png") ||
normalized.endsWith(".webp") ||
normalized.endsWith(".gif")
);
}
function getTelegramGuestAttachmentTransport(path: string): {
method: "sendDocument" | "sendPhoto" | "sendAudio" | "sendVoice";
fileField: "document" | "photo" | "audio" | "voice";
} {
const normalized = path.toLowerCase();
if (
normalized.endsWith(".jpg") ||
normalized.endsWith(".jpeg") ||
normalized.endsWith(".png")
) {
return { method: "sendPhoto", fileField: "photo" };
}
if (normalized.endsWith(".ogg") || normalized.endsWith(".opus")) {
return { method: "sendVoice", fileField: "voice" };
}
if (normalized.endsWith(".mp3")) {
return { method: "sendAudio", fileField: "audio" };
}
return { method: "sendDocument", fileField: "document" };
}
function formatTelegramOutboundAttachmentSizeLimitError(
size: number,
maxSize: number,
path?: string,
): string {
const message = `Attachment exceeds size limit (${size} bytes > ${maxSize} bytes)`;
return path ? `${message}: ${path}` : message;
}
function formatTelegramOutboundAttachmentToolResultText(
count: number,
mode: "queued" | "sent" = "queued",
): string {
// Pi's compact tool rows need one leading newline to visually separate header and result.
const verb = mode === "queued" ? "Queued" : "Sent";
return ["", `${verb} ${count} Telegram attachment(s).`].join("\n");
}
function formatTelegramOutboundMessageToolResultText(chatId: number): string {
return ["", `Sent Telegram message to ${chatId}.`].join("\n");
}
function getTelegramMultipartTargetFields(
target: TelegramTarget | undefined,
): Record<string, string> {
if (!target) return {};
return Object.fromEntries(
Object.entries(getTelegramTargetThreadParams(target)).map(
([key, value]) => [key, String(value)],
),
);
}
function assertTelegramDirectDeliveryAllowed(
canSendDirect: (() => boolean) | undefined,
): void {
if (canSendDirect?.()) return;
throw new Error(
"Telegram direct delivery requires this Pi instance to own /telegram-connect or be registered with the Telegram multi-instance bus",
);
}
function resolveTelegramOutboundTarget(options: {
chatId?: number;
threadId?: number;
target?: TelegramTarget;
getDefaultChatId?: () => number | undefined;
getDefaultTarget?: () => TelegramTarget | undefined;
}): { chatId: number; target?: TelegramTarget } {
if (options.target)
return { chatId: options.target.chatId, target: options.target };
if (options.chatId !== undefined) {
return {
chatId: options.chatId,
target:
options.threadId !== undefined
? { chatId: options.chatId, threadId: options.threadId }
: undefined,
};
}
const defaultTarget = options.getDefaultTarget?.();
if (defaultTarget) {
return { chatId: defaultTarget.chatId, target: defaultTarget };
}
const defaultChatId = options.getDefaultChatId?.();
if (defaultChatId === undefined) {
throw new Error(
"Telegram chat_id is required when no paired/default Telegram chat is available",
);
}
return { chatId: defaultChatId };
}
async function buildTelegramOutboundAttachmentViews(options: {
paths: string[];
maxAttachmentSizeBytes?: number;
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
}): Promise<TelegramQueuedOutboundAttachmentView[]> {
const pendingAttachments: TelegramQueuedOutboundAttachmentView[] = [];
for (const inputPath of options.paths) {
const stats = await (options.statPath ?? stat)(inputPath);
if (!stats.isFile()) {
throw new Error(`Not a file: ${inputPath}`);
}
if (
options.maxAttachmentSizeBytes !== undefined &&
stats.size !== undefined &&
stats.size > options.maxAttachmentSizeBytes
) {
throw new Error(
formatTelegramOutboundAttachmentSizeLimitError(
stats.size,
options.maxAttachmentSizeBytes,
inputPath,
),
);
}
pendingAttachments.push({
path: inputPath,
fileName: basename(inputPath),
});
}
return pendingAttachments;
}
export function registerTelegramOutboundAttachmentTool(
pi: ExtensionAPI,
deps: TelegramOutboundAttachmentToolRegistrationDeps,
): void {
const maxAttachmentsPerTurn =
deps.maxAttachmentsPerTurn ?? MAX_ATTACHMENTS_PER_TURN;
const maxAttachmentSizeBytes =
deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES;
pi.registerTool({
name: "telegram_attach",
label: "Telegram Attach",
description:
"Queue one or more local files for the active Telegram reply, or send them immediately to Telegram when no Telegram turn is active.",
promptSnippet:
"Queue files for the active Telegram reply; outside Telegram turns, send files directly to Telegram.",
promptGuidelines: [
"When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
"When a local/TUI user explicitly asks to send a generated file to Telegram, telegram_attach can deliver it to the paired/default Telegram chat even without an active Telegram turn.",
"For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
],
parameters: Type.Object({
paths: Type.Array(
Type.String({ description: "Local file path to attach" }),
{ minItems: 1, maxItems: maxAttachmentsPerTurn },
),
chat_id: Type.Optional(
Type.Number({
description:
"Optional Telegram chat id for immediate delivery when no Telegram turn is active",
}),
),
thread_id: Type.Optional(
Type.Number({
description:
"Optional Telegram topic thread id for immediate delivery with chat_id",
}),
),
caption: Type.Optional(
Type.String({
description:
"Optional caption for immediate delivery; ignored when queued for an active turn",
}),
),
}),
async execute(_toolCallId, params) {
try {
return await queueTelegramOutboundAttachments({
activeTurn: deps.getActiveTurn(),
paths: params.paths,
chatId: params.chat_id,
threadId: params.thread_id,
caption: params.caption,
maxAttachmentsPerTurn,
maxAttachmentSizeBytes,
sendMultipart: deps.sendMultipart,
getDefaultChatId: deps.getDefaultChatId,
getDefaultTarget: deps.getDefaultTarget,
canSendDirect: deps.canSendDirect,
statPath: deps.statPath,
});
} catch (error) {
deps.recordRuntimeEvent?.("attachment", error, {
phase: "queue",
count: params.paths.length,
});
throw error;
}
},
});
}
export function registerTelegramOutboundMessageTool(
pi: ExtensionAPI,
deps: TelegramOutboundMessageToolRegistrationDeps,
): void {
pi.registerTool({
name: "telegram_message",
label: "Telegram Message",
description:
"Send a Markdown text message directly to the paired/default Telegram chat or an explicit chat_id. Hidden telegram_button comments in the text become attached inline prompt buttons.",
promptSnippet:
"Send direct Telegram Markdown text when the user explicitly asks for Telegram delivery outside the normal reply flow.",
promptGuidelines: [
"Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
"For an explicit thread target, provide chat_id plus thread_id; registered multi-instance followers default to their assigned thread target.",
"Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
"Do not use this tool for ordinary Telegram-originated replies; answer normally so the bridge can deliver the active turn reply.",
],
parameters: Type.Object({
text: Type.String({ description: "Message text to send" }),
chat_id: Type.Optional(
Type.Number({ description: "Optional Telegram chat id" }),
),
thread_id: Type.Optional(
Type.Number({
description: "Optional Telegram topic thread id with chat_id",
}),
),
}),
async execute(_toolCallId, params) {
try {
return await sendTelegramOutboundMessage({
text: params.text,
chatId: params.chat_id,
threadId: params.thread_id,
getDefaultChatId: deps.getDefaultChatId,
getDefaultTarget: deps.getDefaultTarget,
canSendDirect: deps.canSendDirect,
planMessage: deps.planMessage,
sendMarkdownMessage: deps.sendMarkdownMessage,
});
} catch (error) {
deps.recordRuntimeEvent?.("message", error, { phase: "direct" });
throw error;
}
},
});
}
export interface TelegramQueuedOutboundAttachmentDeliveryDeps {
sendMultipart: (
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
) => Promise<unknown>;
sendTextReply: (
chatId: number,
replyToMessageId: number,
text: string,
options?: { target?: TelegramTarget },
) => Promise<unknown>;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
statPath?: (path: string) => Promise<{ size: number }>;
maxAttachmentSizeBytes?: number;
}
export async function queueTelegramOutboundAttachments(options: {
activeTurn: TelegramOutboundAttachmentQueueTargetView | undefined;
paths: string[];
chatId?: number;
threadId?: number;
caption?: string;
maxAttachmentsPerTurn: number;
maxAttachmentSizeBytes?: number;
sendMultipart?: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
getDefaultChatId?: () => number | undefined;
getDefaultTarget?: () => TelegramTarget | undefined;
canSendDirect?: () => boolean;
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
}): Promise<TelegramOutboundAttachmentToolResult> {
if (!options.activeTurn) {
if (!options.sendMultipart) {
throw new Error(
"telegram_attach can only queue files while replying to an active Telegram turn; provide Telegram send ports for immediate delivery",
);
}
return sendTelegramOutboundFiles({
paths: options.paths,
chatId: options.chatId,
threadId: options.threadId,
caption: options.caption,
maxAttachmentsPerTurn: options.maxAttachmentsPerTurn,
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
sendMultipart: options.sendMultipart,
getDefaultChatId: options.getDefaultChatId,
getDefaultTarget: options.getDefaultTarget,
canSendDirect: options.canSendDirect,
statPath: options.statPath,
});
}
if (
options.activeTurn.guestQueryId &&
options.activeTurn.queuedAttachments.length + options.paths.length > 1
) {
throw new Error(
"Telegram Guest Mode supports one attachment per reply; no attachment was queued",
);
}
if (
options.activeTurn.queuedAttachments.length + options.paths.length >
options.maxAttachmentsPerTurn
) {
throw new Error(
`Attachment limit reached (${options.maxAttachmentsPerTurn})`,
);
}
const pendingAttachments = await buildTelegramOutboundAttachmentViews({
paths: options.paths,
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
statPath: options.statPath,
});
options.activeTurn.queuedAttachments.push(...pendingAttachments);
const added = pendingAttachments.map((attachment) => attachment.path);
return {
content: [
{
type: "text",
text: formatTelegramOutboundAttachmentToolResultText(added.length),
},
],
details: { paths: added },
};
}
export async function deliverTelegramGuestCachedAttachment(options: {
guestQueryId: string;
stagingChatId: number;
stagingTarget?: TelegramTarget;
attachment: TelegramQueuedOutboundAttachmentView;
caption?: string;
sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
answerGuestQuery: (
guestQueryId: string,
result: TelegramGuestCachedAttachmentResult,
) => Promise<void>;
answerGuestText?: (guestQueryId: string, text: string) => Promise<void>;
fallbackText?: string;
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
recordRuntimeEvent?: TelegramOutboundAttachmentRuntimeEventRecorderPort["recordRuntimeEvent"];
}): Promise<void> {
const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
let stagingMessageId: number | undefined;
let answerAttempted = false;
try {
const message = (await options.sendMultipart(
transport.method,
{
chat_id: String(options.stagingChatId),
...getTelegramMultipartTargetFields(options.stagingTarget),
},
transport.fileField,
options.attachment.path,
options.attachment.fileName,
)) as TelegramGuestStagingMessage;
stagingMessageId = message.message_id;
const caption = options.caption
? Array.from(options.caption).slice(0, 1024).join("")
: undefined;
let result: TelegramGuestCachedAttachmentResult;
if (transport.fileField === "photo") {
const photo = [...(message.photo ?? [])]
.sort((left, right) => (left.file_size ?? 0) - (right.file_size ?? 0))
.at(-1);
if (!photo?.file_id) throw new Error("Guest staging upload returned no photo file_id");
result = {
type: "photo",
id: "attachment-1",
photo_file_id: photo.file_id,
...(caption ? { caption } : {}),
};
} else if (transport.fileField === "audio") {
if (!message.audio?.file_id)
throw new Error("Guest staging upload returned no audio file_id");
result = {
type: "audio",
id: "attachment-1",
audio_file_id: message.audio.file_id,
...(caption ? { caption } : {}),
};
} else if (transport.fileField === "voice") {
if (!message.voice?.file_id)
throw new Error("Guest staging upload returned no voice file_id");
result = {
type: "voice",
id: "attachment-1",
voice_file_id: message.voice.file_id,
title: options.attachment.fileName,
...(caption ? { caption } : {}),
};
} else {
if (!message.document?.file_id)
throw new Error("Guest staging upload returned no document file_id");
result = {
type: "document",
id: "attachment-1",
title: options.attachment.fileName,
document_file_id: message.document.file_id,
...(caption ? { caption } : {}),
};
}
answerAttempted = true;
await options.answerGuestQuery(options.guestQueryId, result);
} catch (error) {
if (
!answerAttempted &&
options.answerGuestText &&
options.fallbackText
) {
answerAttempted = true;
await options.answerGuestText(options.guestQueryId, options.fallbackText);
} else {
throw error;
}
} finally {
if (stagingMessageId !== undefined) {
try {
await options.deleteMessage(options.stagingChatId, stagingMessageId);
} catch (error) {
options.recordRuntimeEvent?.("attachment", error, {
phase: "guest-staging-cleanup",
chatId: options.stagingChatId,
messageId: stagingMessageId,
});
}
}
}
}
export async function sendTelegramOutboundMessage(options: {
text: string;
chatId?: number;
threadId?: number;
target?: TelegramTarget;
getDefaultChatId?: () => number | undefined;
getDefaultTarget?: () => TelegramTarget | undefined;
canSendDirect: () => boolean;
planMessage: (markdown: string) => TelegramOutboundMessagePlan;
sendMarkdownMessage: (
chatId: number,
markdown: string,
options?: { replyMarkup?: unknown; target?: TelegramTarget },
) => Promise<number | undefined>;
}): Promise<{
content: Array<{ type: "text"; text: string }>;
details: { chatId: number; messageId?: number };
}> {
assertTelegramDirectDeliveryAllowed(options.canSendDirect);
const { chatId, target } = resolveTelegramOutboundTarget({
chatId: options.chatId,
threadId: options.threadId,
target: options.target,
getDefaultChatId: options.getDefaultChatId,
getDefaultTarget: options.getDefaultTarget,
});
const plan = options.planMessage(options.text);
const messageId = await options.sendMarkdownMessage(chatId, plan.markdown, {
replyMarkup: plan.replyMarkup,
target,
});
return {
content: [
{
type: "text",
text: formatTelegramOutboundMessageToolResultText(chatId),
},
],
details: { chatId, messageId },
};
}
export async function sendTelegramOutboundFiles(options: {
paths: string[];
chatId?: number;
threadId?: number;
target?: TelegramTarget;
caption?: string;
maxAttachmentsPerTurn: number;
maxAttachmentSizeBytes?: number;
sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
getDefaultChatId?: () => number | undefined;
getDefaultTarget?: () => TelegramTarget | undefined;
canSendDirect?: () => boolean;
statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
}): Promise<
TelegramOutboundAttachmentToolResult & {
details: { paths: string[]; chatId: number };
}
> {
assertTelegramDirectDeliveryAllowed(options.canSendDirect);
if (options.paths.length > options.maxAttachmentsPerTurn) {
throw new Error(
`Attachment limit reached (${options.maxAttachmentsPerTurn})`,
);
}
const { chatId, target } = resolveTelegramOutboundTarget({
chatId: options.chatId,
threadId: options.threadId,
target: options.target,
getDefaultChatId: options.getDefaultChatId,
getDefaultTarget: options.getDefaultTarget,
});
const pendingAttachments = await buildTelegramOutboundAttachmentViews({
paths: options.paths,
maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
statPath: options.statPath,
});
for (const [index, attachment] of pendingAttachments.entries()) {
const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
const method = isPhoto ? "sendPhoto" : "sendDocument";
const fieldName = isPhoto ? "photo" : "document";
await options.sendMultipart(
method,
{
chat_id: String(chatId),
...(options.caption && index === 0 ? { caption: options.caption } : {}),
...getTelegramMultipartTargetFields(target),
},
fieldName,
attachment.path,
attachment.fileName,
);
}
const added = pendingAttachments.map((attachment) => attachment.path);
return {
content: [
{
type: "text",
text: formatTelegramOutboundAttachmentToolResultText(
added.length,
"sent",
),
},
],
details: { paths: added, chatId },
};
}
export function createTelegramQueuedOutboundAttachmentSender(
deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
) {
return async (turn: TelegramQueuedOutboundAttachmentTurnView): Promise<void> => {
await sendQueuedTelegramOutboundAttachments(turn, {
...deps,
maxAttachmentSizeBytes:
deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES,
});
};
}
export async function sendQueuedTelegramOutboundAttachments(
turn: TelegramQueuedOutboundAttachmentTurnView,
deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
): Promise<void> {
for (const attachment of turn.queuedAttachments) {
try {
if (deps.maxAttachmentSizeBytes !== undefined) {
const stats = await (deps.statPath ?? stat)(attachment.path);
if (stats.size > deps.maxAttachmentSizeBytes) {
throw new Error(
formatTelegramOutboundAttachmentSizeLimitError(
stats.size,
deps.maxAttachmentSizeBytes,
),
);
}
}
const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
const method = isPhoto ? "sendPhoto" : "sendDocument";
const fieldName = isPhoto ? "photo" : "document";
const replyParameters = buildTelegramMultipartReplyParameters(
turn.chatId,
turn.replyToMessageId,
turn.target,
);
await deps.sendMultipart(
method,
{
chat_id: String(turn.chatId),
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...getTelegramMultipartTargetFields(turn.target),
},
fieldName,
attachment.path,
attachment.fileName,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.recordRuntimeEvent?.("attachment", error, {
fileName: attachment.fileName,
});
await deps.sendTextReply(
turn.chatId,
turn.replyToMessageId,
`Failed to send attachment ${attachment.fileName}: ${message}`,
{ target: turn.target },
);
}
}
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Telegram outbound button helpers
* Zones: telegram outbound, assistant markup, callback routing
* Owns assistant-authored telegram_button extraction, button action storage, callback handling, and prompt-turn construction
*/
import { randomUUID } from "node:crypto";
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
import {
parseTelegramCommentAttributes,
parseTopLevelTelegramComment,
replaceTopLevelHtmlComments,
} from "./outbound-markup.ts";
import {
type PendingTelegramTurn,
type TelegramQueueTarget,
truncateTelegramQueueSummary,
} from "./queue.ts";
const TELEGRAM_BUTTON_CALLBACK_PREFIX = "tgbtn";
const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
export interface TelegramOutboundButtonAction {
text: string;
prompt: string;
}
export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
createdAt: number;
}
export type TelegramOutboundButtonMarkup = TelegramInlineKeyboardMarkup;
export interface TelegramButtonReplyPlan {
markdown: string;
replyMarkup?: TelegramOutboundButtonMarkup;
}
export interface TelegramButtonActionStore {
register: (action: TelegramOutboundButtonAction) => string;
resolve: (
callbackData: string | undefined,
) => TelegramOutboundButtonAction | undefined;
}
export interface TelegramButtonCallbackQuery {
id: string;
data?: string;
message?: {
message_id?: number;
message_thread_id?: number;
chat?: { id?: number };
};
}
export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
resolveAction: (
callbackData: string | undefined,
) => TelegramOutboundButtonAction | undefined;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
enqueueButtonPrompt: (
query: TelegramButtonCallbackQuery,
action: TelegramOutboundButtonAction,
ctx: TContext,
) => void;
}
function nowMs(): number {
return Date.now();
}
function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
return markdown.replace(/\n{3,}/g, "\n\n").trim();
}
function parseButtonsCommentAttributes(input: string): {
label?: string;
prompt?: string;
} {
const attributes = parseTelegramCommentAttributes(input);
return {
...(attributes.label ? { label: attributes.label } : {}),
...(attributes.prompt ? { prompt: attributes.prompt } : {}),
};
}
function parseButtonsCommentRows(
head: string,
body: string | undefined,
): TelegramOutboundButtonAction[][] {
const trimmedHead = head.trim();
if (body === undefined) {
if (trimmedHead.startsWith(":")) {
const label = trimmedHead.slice(1).trim();
return label ? [[{ text: label, prompt: label }]] : [];
}
const attributes = parseButtonsCommentAttributes(head);
return attributes.label && attributes.prompt
? [[{ text: attributes.label, prompt: attributes.prompt }]]
: [];
}
const label = parseButtonsCommentAttributes(head).label;
const prompt = body.trim();
if (!label || !prompt) return [];
return [[{ text: label, prompt }]];
}
export function createTelegramButtonActionStore(
options: { ttlMs?: number } = {},
): TelegramButtonActionStore {
const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
const actions = new Map<string, TelegramOutboundButtonStoredAction>();
const cleanup = (currentTime: number): void => {
for (const [key, action] of actions) {
if (currentTime - action.createdAt > ttlMs) actions.delete(key);
}
};
return {
register: (action) => {
const currentTime = nowMs();
cleanup(currentTime);
const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
actions.set(key, { ...action, createdAt: currentTime });
return key;
},
resolve: (callbackData) => {
if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
return undefined;
}
const currentTime = nowMs();
cleanup(currentTime);
const action = actions.get(callbackData);
if (!action) return undefined;
actions.delete(callbackData);
return { text: action.text, prompt: action.prompt };
},
};
}
export function planTelegramButtonReply(
markdown: string,
deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
): TelegramButtonReplyPlan {
const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
const command = parseTopLevelTelegramComment(comment, "telegram_button");
if (!command) return comment.raw;
const rows = parseButtonsCommentRows(command.head, command.body);
for (const row of rows) {
keyboard.push(
row.map((button) => ({
text: button.text,
callback_data: deps.registerAction(button),
})),
);
}
return "";
});
return {
markdown: normalizeMarkdownAfterButtonExtraction(stripped),
...(keyboard.length > 0
? { replyMarkup: { inline_keyboard: keyboard } }
: {}),
};
}
export function createTelegramButtonReplyPlanner(
store: Pick<TelegramButtonActionStore, "register">,
): (markdown: string) => TelegramButtonReplyPlan {
return (markdown) =>
planTelegramButtonReply(markdown, { registerAction: store.register });
}
export function createTelegramButtonPromptTurn(options: {
chatId: number;
replyToMessageId: number;
queueOrder: number;
action: TelegramOutboundButtonAction;
target?: TelegramQueueTarget;
}): PendingTelegramTurn {
const prompt = `[telegram] ${options.action.prompt}`;
return {
kind: "prompt",
chatId: options.chatId,
...(options.target ? { target: options.target } : {}),
replyToMessageId: options.replyToMessageId,
sourceMessageIds: [options.replyToMessageId],
queueOrder: options.queueOrder,
queueLane: "priority",
laneOrder: options.queueOrder,
queuedAttachments: [],
content: [{ type: "text", text: prompt }],
historyText: options.action.prompt,
statusSummary: truncateTelegramQueueSummary(
options.action.text || options.action.prompt,
),
};
}
export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
query: TelegramButtonCallbackQuery,
ctx: TContext,
deps: TelegramButtonCallbackHandlerDeps<TContext>,
): Promise<boolean> {
const action = deps.resolveAction(query.data);
if (!action) {
if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
await deps.answerCallbackQuery(query.id, "Button action expired.");
return true;
}
return false;
}
const chatId = query.message?.chat?.id;
const messageId = query.message?.message_id;
if (typeof chatId !== "number" || typeof messageId !== "number") {
await deps.answerCallbackQuery(query.id, "Button action expired.");
return true;
}
deps.enqueueButtonPrompt(query, action, ctx);
await deps.answerCallbackQuery(query.id, "Queued.");
return true;
}
+392
View File
@@ -0,0 +1,392 @@
/**
* Telegram outbound markup parsing helpers
* Zones: telegram outbound, assistant markup
* Owns top-level assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
*/
export interface TelegramTopLevelHtmlComment {
raw: string;
content: string;
start: number;
end: number;
}
interface TelegramTopLevelFenceState {
marker: "`" | "~";
length: number;
}
function isTelegramActionCommentContent(content: string): boolean {
const normalizedContent = content.replace(/^\s+/, "");
const [head = ""] = normalizedContent.split(/\r?\n/, 1);
return ["telegram_voice", "telegram_button"].some((command) => {
if (!head.startsWith(command)) return false;
const nextChar = head[command.length];
return nextChar === undefined || /\s|:/.test(nextChar);
});
}
function getMarkdownLineEnd(markdown: string, offset: number): number {
const newlineIndex = markdown.indexOf("\n", offset);
return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
}
function getMarkdownLineText(
markdown: string,
offset: number,
end: number,
): string {
return markdown.slice(offset, end).replace(/\r?\n$/, "");
}
function getTopLevelOpeningFence(
line: string,
): TelegramTopLevelFenceState | undefined {
const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
const sequence = match?.[1];
if (!sequence) return undefined;
return {
marker: sequence[0] as "`" | "~",
length: sequence.length,
};
}
function isTopLevelClosingFence(
line: string,
fence: TelegramTopLevelFenceState,
): boolean {
const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
const sequence = match?.[1];
return (
!!sequence &&
sequence[0] === fence.marker &&
sequence.length >= fence.length
);
}
function collectPairedTelegramVoiceActionBody(
markdown: string,
bodyStart: number,
commentContent: string,
): { content: string; end: number } | undefined {
const normalizedContent = commentContent.trim();
if (
!normalizedContent.startsWith("telegram_voice") ||
!isTelegramActionCommentContent(commentContent)
) {
return undefined;
}
let offset = bodyStart;
while (offset < markdown.length) {
const lineEnd = getMarkdownLineEnd(markdown, offset);
const line = getMarkdownLineText(markdown, offset, lineEnd);
if (line === "<!-- /telegram_voice -->") {
const body = markdown.slice(bodyStart, offset).trim();
if (!body) return undefined;
return {
content: `${commentContent.trimEnd()}\n${body}`,
end: lineEnd,
};
}
if (line.startsWith("<!--")) return undefined;
offset = lineEnd;
}
return undefined;
}
function collectInlineClosedTelegramActionBody(
markdown: string,
bodyStart: number,
commentContent: string,
): { content: string; end: number } | undefined {
const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
const hasRecoverableBody =
isTelegramActionCommentContent(commentContent) &&
bodyLine.trim() !== "" &&
!bodyLine.startsWith("<!--") &&
!bodyLine.startsWith("-->") &&
closeLine === "-->";
if (!hasRecoverableBody) return undefined;
return {
content: `${commentContent.trimEnd()}\n${bodyLine}`,
end: bodyLineEnd + 3,
};
}
export function collectTopLevelHtmlComments(markdown: string): {
comments: TelegramTopLevelHtmlComment[];
openCommentStart?: number;
} {
const comments: TelegramTopLevelHtmlComment[] = [];
let offset = 0;
let fence: TelegramTopLevelFenceState | undefined;
while (offset < markdown.length) {
const lineEnd = getMarkdownLineEnd(markdown, offset);
const line = getMarkdownLineText(markdown, offset, lineEnd);
if (fence) {
if (isTopLevelClosingFence(line, fence)) fence = undefined;
offset = lineEnd;
continue;
}
const nextFence = getTopLevelOpeningFence(line);
if (nextFence) {
fence = nextFence;
offset = lineEnd;
continue;
}
if (line.startsWith("<!--")) {
const closeIndex = markdown.indexOf("-->", offset + 4);
if (closeIndex === -1) return { comments, openCommentStart: offset };
let end = closeIndex + 3;
let raw = markdown.slice(offset, end);
let content = raw.slice(4, -3);
const closeColumn = closeIndex - offset;
const closesOnOpeningLine = closeIndex < lineEnd;
const hasOnlyWhitespaceAfterClose =
line.slice(closeColumn + 3).trim() === "";
const pairedVoiceBody =
closesOnOpeningLine && hasOnlyWhitespaceAfterClose
? collectPairedTelegramVoiceActionBody(markdown, lineEnd, content)
: undefined;
const inlineBody =
!pairedVoiceBody && closesOnOpeningLine && hasOnlyWhitespaceAfterClose
? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
: undefined;
const recoveredBody = pairedVoiceBody ?? inlineBody;
if (recoveredBody) {
end = recoveredBody.end;
raw = markdown.slice(offset, end);
content = recoveredBody.content;
}
comments.push({ raw, content, start: offset, end });
offset = getMarkdownLineEnd(markdown, end);
continue;
}
offset = lineEnd;
}
return { comments };
}
export function replaceTopLevelHtmlComments(
markdown: string,
replacer: (comment: TelegramTopLevelHtmlComment) => string,
): string {
const { comments } = collectTopLevelHtmlComments(markdown);
if (comments.length === 0) return markdown;
let result = "";
let offset = 0;
for (const comment of comments) {
result += markdown.slice(offset, comment.start);
result += replacer(comment);
offset = comment.end;
}
return result + markdown.slice(offset);
}
export function findTopLevelOpenOrPartialHtmlCommentIndex(
markdown: string,
): number {
const { openCommentStart } = collectTopLevelHtmlComments(markdown);
if (openCommentStart !== undefined) return openCommentStart;
let offset = 0;
let fence: TelegramTopLevelFenceState | undefined;
while (offset < markdown.length) {
const lineEnd = getMarkdownLineEnd(markdown, offset);
const line = getMarkdownLineText(markdown, offset, lineEnd);
const isLastLine = lineEnd >= markdown.length;
if (fence) {
if (isTopLevelClosingFence(line, fence)) fence = undefined;
offset = lineEnd;
continue;
}
const nextFence = getTopLevelOpeningFence(line);
if (nextFence) {
fence = nextFence;
offset = lineEnd;
continue;
}
if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
return offset;
}
offset = lineEnd;
}
return -1;
}
export function parseTopLevelTelegramComment(
comment: TelegramTopLevelHtmlComment,
command: string,
): { head: string; body?: string } | undefined {
let normalizedContent = comment.content.replace(/^\s+/, "");
normalizedContent = normalizedContent.replace(/^!/, "");
const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
let head = rawHead.trimStart();
if (!head.startsWith(command)) return undefined;
const nextChar = head[command.length];
if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
return {
head: head.slice(command.length),
...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
};
}
export function parseTelegramCommentAttributes(
input: string,
): Record<string, string> {
const attributes: Record<string, string> = {};
for (const match of input.matchAll(
/([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
)) {
const key = match[1];
const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
if (value) attributes[key] = value;
}
return attributes;
}
export function normalizeMarkdownAfterVoiceExtraction(
markdown: string,
): string {
return markdown.replace(/\n{3,}/g, "\n\n").trim();
}
export function stripTelegramCommentMarkupForPreview(markdown: string): string {
const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
const openBlockIndex =
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
const previewMarkdown =
openBlockIndex >= 0
? withoutClosedBlocks.slice(0, openBlockIndex)
: withoutClosedBlocks;
return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
}
export function stripTelegramCommentMarkupForDelivery(
markdown: string,
): string {
const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
const openBlockIndex =
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
const deliveryMarkdown =
openBlockIndex >= 0
? withoutClosedBlocks.slice(0, openBlockIndex)
: withoutClosedBlocks;
return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
}
export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
return stripTelegramCommentMarkupForPreview(markdown);
}
export interface TelegramVoiceReplyItem {
text: string;
lang?: string;
rate?: string;
}
export interface TelegramVoiceReplyPlan {
markdown: string;
voiceText?: string;
voiceReplies?: TelegramVoiceReplyItem[];
lang?: string;
rate?: string;
}
function parseVoiceReplyAttributes(input: string): {
lang?: string;
rate?: string;
text?: string;
} {
const attributes = parseTelegramCommentAttributes(input);
return {
...(attributes.lang ? { lang: attributes.lang } : {}),
...(attributes.rate ? { rate: attributes.rate } : {}),
...(attributes.text ? { text: attributes.text } : {}),
};
}
function parseVoiceCommentBody(
head: string,
body: string | undefined,
): {
attrs: string;
text: string;
} {
const trimmedHead = head.trim();
if (body !== undefined) {
return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
}
let colonIndex = -1;
let inQuote = false;
let quoteChar = "";
for (let i = 0; i < trimmedHead.length; i++) {
const char = trimmedHead[i];
if (inQuote) {
if (char === quoteChar) inQuote = false;
} else {
if (char === '"' || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === ":") {
colonIndex = i;
break;
}
}
}
if (colonIndex > 0) {
const attrsPart = trimmedHead.slice(0, colonIndex).trim();
const textPart = trimmedHead.slice(colonIndex + 1).trim();
const attrs = parseVoiceReplyAttributes(attrsPart);
return { attrs: attrsPart, text: textPart || attrs.text || "", ...attrs };
}
if (trimmedHead.startsWith(":")) {
return { attrs: "", text: trimmedHead.slice(1).trim() };
}
const attrs = parseVoiceReplyAttributes(trimmedHead);
return { attrs: trimmedHead, text: attrs.text ?? "" };
}
export function planTelegramVoiceReply(
markdown: string,
): TelegramVoiceReplyPlan {
const voiceReplies: TelegramVoiceReplyItem[] = [];
let lang: string | undefined;
let rate: string | undefined;
const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
let command = parseTopLevelTelegramComment(comment, "telegram_voice");
if (!command) {
let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
if (content.startsWith("telegram_voice")) {
const headPart = content.slice("telegram_voice".length).trim();
command = { head: headPart, body: undefined };
}
}
if (!command) return "";
const parsed = parseVoiceCommentBody(command.head, command.body);
const attrs = parseVoiceReplyAttributes(parsed.attrs);
if (parsed.text) {
voiceReplies.push({
text: parsed.text,
...(attrs.lang ? { lang: attrs.lang } : {}),
...(attrs.rate ? { rate: attrs.rate } : {}),
});
}
if (attrs.lang) lang = attrs.lang;
if (attrs.rate) rate = attrs.rate;
return "";
});
const voiceText = voiceReplies
.map((reply) => reply.text)
.join("\n\n")
.trim();
return {
markdown: stripTelegramCommentMarkupForDelivery(stripped),
...(voiceText ? { voiceText } : {}),
...(voiceReplies.length > 0 ? { voiceReplies } : {}),
...(lang ? { lang } : {}),
...(rate ? { rate } : {}),
};
}
+285
View File
@@ -0,0 +1,285 @@
/**
* Telegram outbound voice delivery helpers
* Zones: telegram outbound, voice delivery
* Owns native Telegram voice upload orchestration across configured voice handlers, programmatic outbound voice handlers, and registered synthesis providers
*/
import { unlink } from "node:fs/promises";
import { basename, extname } from "node:path";
import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
import { buildTelegramMultipartReplyParameters } from "./replies.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
export interface TelegramVoiceReplyTurnView {
chatId: number;
replyToMessageId: number;
target?: TelegramTarget;
}
export interface TelegramVoiceReplySenderDeps {
execCommand: (
command: string,
args: string[],
options?: {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
stdin?: string;
retry?: number;
},
) => Promise<{
stdout: string;
stderr: string;
code: number;
killed: boolean;
}>;
sendMultipart: (
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
) => Promise<unknown>;
sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
getHandlers?: () => unknown[] | undefined;
cwd?: string;
tempDir?: string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export type TelegramOutboundProgrammaticVoiceHandler = (
text: string,
options?: { lang?: string; rate?: string },
) => Promise<string>;
export interface TelegramVoiceReplySenderPorts<THandler = unknown> {
findVoiceHandlers?: (handlers: unknown[] | undefined) => THandler[];
generateVoiceFile?: (
text: string,
options: {
lang?: string;
rate?: string;
handler: THandler;
tempDir?: string;
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
) => Promise<string | undefined>;
getProgrammaticVoiceHandlers?: () => TelegramOutboundProgrammaticVoiceHandler[];
}
function buildVoiceReplyParameters(
chatId: number,
replyToPrompt: boolean | undefined,
replyToMessageId: number | undefined,
target?: TelegramTarget,
): string | undefined {
if (replyToPrompt === false || replyToMessageId === undefined)
return undefined;
return buildTelegramMultipartReplyParameters(chatId, replyToMessageId, target);
}
async function ensureTelegramVoiceFileFormat(
filePath: string,
): Promise<string> {
const ext = extname(filePath).toLowerCase();
if (ext === ".opus" || ext === ".ogg") return filePath;
throw new Error(
`Voice synthesis provider must return .ogg or .opus files, got ${ext}. ` +
`Providers should handle format conversion internally.`,
);
}
function extractVoiceResult(result: any): {
filePath: string;
transcriptText?: string;
} {
if (typeof result === "string") return { filePath: result };
return {
filePath: result.audioPath,
transcriptText: result.transcriptText,
};
}
async function sendVoiceChatAction(
deps: TelegramVoiceReplySenderDeps,
chatId: number,
) {
if (deps.sendRecordVoiceAction) {
await deps.sendRecordVoiceAction(chatId).catch(() => {});
} else {
await deps.sendChatAction?.(chatId, "record_voice").catch(() => {});
}
}
export function createTelegramVoiceReplySender<THandler = unknown>(
deps: TelegramVoiceReplySenderDeps,
ports: TelegramVoiceReplySenderPorts<THandler> = {},
) {
const uploadVoiceFile = async (
turn: TelegramVoiceReplyTurnView,
filePath: string,
options?: {
replyToPrompt?: boolean;
replyMarkup?: unknown;
transcriptText?: string;
},
): Promise<void> => {
const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
await sendVoiceChatAction(deps, turn.chatId);
const replyParameters = buildVoiceReplyParameters(
turn.chatId,
options?.replyToPrompt,
turn.replyToMessageId,
turn.target,
);
await deps.sendMultipart(
"sendVoice",
{
chat_id: String(turn.chatId),
...(options?.transcriptText ? { caption: options.transcriptText } : {}),
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(turn.target
? Object.fromEntries(
Object.entries(getTelegramTargetThreadParams(turn.target)).map(
([key, value]) => [key, String(value)],
),
)
: {}),
...(options?.replyMarkup !== undefined && options.replyMarkup !== null
? {
reply_markup:
typeof options.replyMarkup === "string"
? options.replyMarkup
: JSON.stringify(options.replyMarkup),
}
: {}),
},
"voice",
voiceFilePath,
basename(voiceFilePath),
);
};
return async (
turn: TelegramVoiceReplyTurnView,
text: string,
options?: {
lang?: string;
rate?: string;
replyToPrompt?: boolean;
replyMarkup?: unknown;
},
): Promise<void> => {
for (const handler of ports.findVoiceHandlers?.(deps.getHandlers?.()) ??
[]) {
try {
const filePath = await ports.generateVoiceFile?.(text, {
lang: options?.lang,
rate: options?.rate,
handler,
tempDir: deps.tempDir,
cwd: deps.cwd,
execCommand: deps.execCommand,
});
if (!filePath) continue;
await uploadVoiceFile(turn, filePath, {
replyToPrompt: options?.replyToPrompt,
replyMarkup: options?.replyMarkup,
});
return;
} catch (error) {
deps.recordRuntimeEvent?.("voice", error, {
phase: "template-handler-send",
});
}
}
for (const handler of ports.getProgrammaticVoiceHandlers?.() ?? []) {
try {
const filePath = await handler(text, {
lang: options?.lang,
rate: options?.rate,
});
if (!filePath) continue;
await uploadVoiceFile(turn, filePath, {
replyToPrompt: options?.replyToPrompt,
replyMarkup: options?.replyMarkup,
});
return;
} catch (error) {
deps.recordRuntimeEvent?.("voice", error, {
phase: "programmatic-handler-send",
});
}
}
const providers = getTelegramVoiceSynthesisProviders();
for (const provider of providers) {
let voiceFilePath: string | undefined;
let originalFilePath: string | undefined;
try {
if (typeof provider !== "function") {
deps.recordRuntimeEvent?.(
"voice",
new Error(
"Registered voice synthesis provider is not callable (policy-only object?)",
),
{ phase: "voice-provider-skip" },
);
continue;
}
const providerResult = await provider(text, {
lang: options?.lang,
rate: options?.rate,
});
if (!providerResult) {
deps.recordRuntimeEvent?.(
"voice",
new Error("Voice synthesis provider returned empty path"),
{ phase: "voice-provider-skip" },
);
continue;
}
const { filePath, transcriptText } = extractVoiceResult(providerResult);
voiceFilePath = filePath;
originalFilePath = filePath;
await uploadVoiceFile(turn, filePath, {
replyToPrompt: options?.replyToPrompt,
replyMarkup: options?.replyMarkup,
transcriptText,
});
return;
} catch (error) {
deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
} finally {
if (voiceFilePath && voiceFilePath !== originalFilePath) {
await unlink(voiceFilePath).catch(() => {});
}
}
}
const errorMessage =
"Failed to send voice reply: every voice synthesis provider and outbound voice handler failed.";
deps.recordRuntimeEvent?.("voice", new Error(errorMessage), {
phase: "send",
});
throw new Error(errorMessage);
};
}
+907
View File
@@ -0,0 +1,907 @@
/**
* Telegram outbound surface helpers
* Zones: telegram outbound, command templates, voice delivery
* Owns configured outbound handler execution, text transforms, voice-file generation/delivery, runtime-event bridge, and compatibility re-exports; assistant markup parsing lives in outbound-markup and button callback actions live in outbound-buttons
*/
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { resolveTelegramTempDir } from "./paths.ts";
import {
planTelegramButtonReply,
type TelegramButtonActionStore,
type TelegramOutboundButtonMarkup,
} from "./outbound-buttons.ts";
import {
planTelegramVoiceReply,
type TelegramVoiceReplyItem,
} from "./outbound-markup.ts";
import { createTelegramVoiceReplySender as createTelegramVoiceReplySenderWithPorts } from "./outbound-voice.ts";
import type { TelegramTarget } from "./target.ts";
const OUTBOUND_HANDLER_REGISTRY_KEY = "__piTelegramOutboundHandlers__";
const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
import {
buildCommandTemplateInvocation,
expandCommandTemplateConfigs,
substituteCommandTemplateToken,
type CommandTemplateObjectConfig,
} from "./command-templates.ts";
const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
// --- Types ---
/**
* Record a runtime event that appears in `/telegram-status`.
* Voice synthesis provider extensions can call this to surface diagnostics
* alongside pi-telegram's own events. Events are silently dropped
* when pi-telegram is not loaded.
*/
export type TelegramRuntimeEventRecorder = (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
export function bindTelegramRuntimeEventRecorder(
recorder: TelegramRuntimeEventRecorder,
): void {
(globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] = recorder;
}
export function recordTelegramRuntimeEvent(
category: string,
error: unknown,
details?: Record<string, unknown>,
): void {
const recorder = (globalThis as Record<string, unknown>)[
VOICE_EVENT_RECORDER_KEY
];
if (typeof recorder === "function") {
(recorder as TelegramRuntimeEventRecorder)(category, error, details);
}
}
export type TelegramOutboundCommandTemplateConfig =
string | CommandTemplateObjectConfig;
export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
type?: string;
match?: string | string[];
output?: string;
timeout?: number | string;
}
export {
normalizeMarkdownAfterVoiceExtraction,
planTelegramVoiceReply,
stripTelegramCommentMarkupForDelivery,
stripTelegramCommentMarkupForPreview,
stripTelegramVoiceMarkupForPreview,
type TelegramVoiceReplyItem,
type TelegramVoiceReplyPlan,
} from "./outbound-markup.ts";
export interface TelegramVoiceExecOptions {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
stdin?: string;
retry?: number;
}
export interface TelegramVoiceExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
export interface TelegramVoiceReplyTurnView {
chatId: number;
replyToMessageId: number;
target?: TelegramTarget;
}
export interface TelegramVoiceReplySenderDeps {
execCommand: (
command: string,
args: string[],
options?: TelegramVoiceExecOptions,
) => Promise<TelegramVoiceExecResult>;
sendMultipart: (
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
) => Promise<unknown>;
sendTextReply?: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: { parseMode?: "HTML" },
) => Promise<unknown>;
sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
cwd?: string;
tempDir?: string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
// --- Programmatic Outbound Handler Registry ---
export type TelegramOutboundProgrammaticHandler = (
text: string,
options?: { lang?: string; rate?: string },
) => Promise<string>;
export interface TelegramOutboundHandlerRegistry {
handlers: Map<string, TelegramOutboundProgrammaticHandler[]>;
}
// --- Programmatic Outbound Handler Registry Runtime ---
function getOrCreateOutboundHandlerRegistry(): TelegramOutboundHandlerRegistry {
const existing = (globalThis as Record<string, unknown>)[
OUTBOUND_HANDLER_REGISTRY_KEY
];
if (
existing &&
typeof existing === "object" &&
existing !== null &&
"handlers" in existing &&
existing.handlers instanceof Map
) {
return existing as TelegramOutboundHandlerRegistry;
}
const registry: TelegramOutboundHandlerRegistry = {
handlers: new Map(),
};
(globalThis as Record<string, unknown>)[OUTBOUND_HANDLER_REGISTRY_KEY] =
registry;
return registry;
}
export function registerTelegramOutboundHandler(
kind: string,
handler: TelegramOutboundProgrammaticHandler,
): () => void {
const registry = getOrCreateOutboundHandlerRegistry();
const list = registry.handlers.get(kind) ?? [];
list.push(handler);
registry.handlers.set(kind, list);
return () => {
const updated = registry.handlers.get(kind) ?? [];
const index = updated.indexOf(handler);
if (index !== -1) {
updated.splice(index, 1);
registry.handlers.set(kind, updated);
}
};
}
export function hasTelegramOutboundHandler(kind: string): boolean {
const registry = getOrCreateOutboundHandlerRegistry();
const list = registry.handlers.get(kind);
return !!list && list.length > 0;
}
export function getTelegramOutboundProgrammaticHandlers(
kind: string,
): TelegramOutboundProgrammaticHandler[] {
const registry = getOrCreateOutboundHandlerRegistry();
return [...(registry.handlers.get(kind) ?? [])];
}
export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
sendTextReply: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: { parseMode?: "HTML"; target?: TelegramTarget },
) => Promise<number | undefined>;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<number | undefined>;
cwd?: string;
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
}
export interface TelegramInlineKeyboardLike {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
}
export interface TelegramOutboundTextTransformOptions<TReplyMarkup = unknown> {
handlers?: TelegramOutboundHandlerConfig[];
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
replyMarkup?: TReplyMarkup;
}
export interface TelegramOutboundTextTransformResult<TReplyMarkup = unknown> {
text: string;
replyMarkup?: TReplyMarkup;
}
export interface TelegramOutboundTextPreviewRuntimeDeps<
TReplyMarkup = unknown,
> {
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
finalizeMarkdownPreview: (
chatId: number,
markdown: string,
replyToMessageId: number,
options?: { replyMarkup?: TReplyMarkup },
) => Promise<boolean>;
cwd?: string;
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
}
// --- Voice Reply Timeout Helpers ---
function resolveOutboundNumericControlField(
value: number | string | undefined,
values: Record<string, unknown>,
label: string,
): number | undefined {
if (value === undefined) return undefined;
const resolved =
typeof value === "string"
? substituteCommandTemplateToken(value, values, label)
: value;
if (resolved === "") return undefined;
const numeric = Number(resolved);
if (!Number.isFinite(numeric) || numeric < 0)
throw new Error(`Command template ${label} must be a non-negative number.`);
return numeric;
}
function getVoiceReplyConfiguredTimeout(
config: TelegramOutboundCommandTemplateConfig | undefined,
): number | undefined {
const timeout = typeof config === "string" ? undefined : config?.timeout;
return resolveOutboundNumericControlField(timeout, {}, "timeout");
}
function getVoiceReplyTimeout(
config: TelegramOutboundCommandTemplateConfig | undefined,
): number {
return getVoiceReplyConfiguredTimeout(config) ?? DEFAULT_VOICE_TIMEOUT_MS;
}
function getRemainingVoiceReplyTimeout(
timeout: number,
startedAt: number,
): number {
return Math.max(1, timeout - (Date.now() - startedAt));
}
function getVoiceReplyCompositionStepTimeout(
handlerTimeout: number,
step: TelegramOutboundCommandTemplateConfig,
startedAt: number,
): number {
const remaining = getRemainingVoiceReplyTimeout(handlerTimeout, startedAt);
const stepTimeout = getVoiceReplyConfiguredTimeout(step);
return stepTimeout === undefined
? remaining
: Math.min(stepTimeout, remaining);
}
function formatVoiceReplyExecutionFailure(
label: string,
result: TelegramVoiceExecResult,
): string {
const parts = [
`${label} exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
];
if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
return parts.join("\n\n");
}
async function runVoiceReplyCommand(
label: string,
config: TelegramOutboundCommandTemplateConfig,
values: Record<string, string>,
options: {
cwd: string;
timeout: number;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
stdin?: string;
},
): Promise<TelegramVoiceExecResult> {
if (!options.execCommand) {
throw new Error("execCommand is required for command template execution");
}
const invocation = buildCommandTemplateInvocation(
config,
values,
options.cwd,
{
emptyMessage: "Outbound voice template is empty",
missingLabel: "outbound voice template",
},
);
const result = await options.execCommand(
invocation.command,
invocation.args,
{
cwd: options.cwd,
timeout: options.timeout,
...(typeof config === "object" && config.retry !== undefined
? {
retry: resolveOutboundNumericControlField(
config.retry,
{},
"retry",
),
}
: {}),
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
},
);
if (result.code !== 0)
throw new Error(formatVoiceReplyExecutionFailure(label, result));
return result;
}
function normalizeOutboundHandlerStringList(
value: string | string[] | undefined,
): string[] {
if (Array.isArray(value))
return value
.map(String)
.map((item) => item.trim())
.filter(Boolean);
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
function outboundHandlerMatchesType(
handler: TelegramOutboundHandlerConfig,
type: string,
): boolean {
const selectors = [
...normalizeOutboundHandlerStringList(handler.type),
...normalizeOutboundHandlerStringList(handler.match),
];
if (selectors.length === 0) return false;
return selectors.includes(type);
}
export function findTelegramOutboundHandlers(
handlers: TelegramOutboundHandlerConfig[] | undefined,
type: string,
): TelegramOutboundHandlerConfig[] {
if (!Array.isArray(handlers)) return [];
return handlers.filter(
(handler) =>
!!handler &&
typeof handler === "object" &&
outboundHandlerMatchesType(handler, type),
);
}
function getTelegramVoiceHandlerCompositionSteps(
handler: TelegramOutboundHandlerConfig,
): TelegramOutboundCommandTemplateConfig[] {
if (Array.isArray(handler.template)) {
return expandCommandTemplateConfigs(
handler,
) as TelegramOutboundCommandTemplateConfig[];
}
return [];
}
function extractVoiceReplyPath(stdout: string): string {
const path = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
if (!path) throw new Error("Voice generator did not print an output path");
return path;
}
function getVoiceReplyOutputPath(
config: TelegramOutboundHandlerConfig,
values: Record<string, string>,
stdout: string,
): string {
const output = config.output ?? "stdout";
if (output === "stdout") return extractVoiceReplyPath(stdout);
const keyMatch = output.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\}?$/);
if (keyMatch && Object.hasOwn(values, keyMatch[1])) {
return values[keyMatch[1]] ?? "";
}
return output.replace(
/\{([A-Za-z_][A-Za-z0-9_-]*)\}/g,
(_match, key: string) => values[key] ?? "",
);
}
function getVoiceReplyTemplateValues(
text: string,
options: { lang?: string; rate?: string; mp3Path: string; oggPath: string },
): Record<string, string> {
return {
text,
type: "voice",
mp3: options.mp3Path,
ogg: options.oggPath,
...(options.lang ? { lang: options.lang } : {}),
...(options.rate ? { rate: options.rate } : {}),
};
}
function getDefaultTelegramVoiceTempDir(): string {
return resolveTelegramTempDir();
}
async function generateTelegramVoiceReplyFileWithHandler(
text: string,
options: {
lang?: string;
rate?: string;
handler: TelegramOutboundHandlerConfig;
tempDir: string;
cwd: string;
timeout: number;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
): Promise<string> {
await mkdir(options.tempDir, { recursive: true });
const artifactId = randomUUID();
const values = getVoiceReplyTemplateValues(text, {
lang: options.lang,
rate: options.rate,
mp3Path: join(options.tempDir, `${artifactId}-voice.mp3`),
oggPath: join(options.tempDir, `${artifactId}-voice.ogg`),
});
const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
if (steps.length > 0) {
const startedAt = Date.now();
let stdout = text;
for (const [index, step] of steps.entries()) {
try {
const result = await runVoiceReplyCommand(
`Outbound voice template step ${index + 1}`,
step,
values,
{
cwd: options.cwd,
timeout: getVoiceReplyCompositionStepTimeout(
options.timeout,
step,
startedAt,
),
execCommand: options.execCommand,
stdin: stdout,
},
);
stdout = result.stdout;
} catch (error) {
if (typeof step === "object" && step.failure === "root") throw error;
stdout = "";
}
}
return getVoiceReplyOutputPath(options.handler, values, stdout);
}
const result = await runVoiceReplyCommand(
"Outbound voice template",
options.handler,
values,
{
cwd: options.cwd,
timeout: options.timeout,
execCommand: options.execCommand,
stdin: text,
},
);
return getVoiceReplyOutputPath(options.handler, values, result.stdout);
}
export async function generateTelegramVoiceReplyFile(
text: string,
options: {
lang?: string;
rate?: string;
handler?: TelegramOutboundHandlerConfig;
tempDir?: string;
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
): Promise<string | undefined> {
const handler = options.handler;
if (!handler?.template) return undefined;
return generateTelegramVoiceReplyFileWithHandler(text, {
lang: options.lang,
rate: options.rate,
handler,
tempDir: options.tempDir ?? getDefaultTelegramVoiceTempDir(),
cwd: options.cwd ?? process.cwd(),
timeout: getVoiceReplyTimeout(handler),
execCommand: options.execCommand,
});
}
function getOutboundTextTemplateValues(text: string): Record<string, string> {
return { text, type: "text" };
}
async function transformTelegramOutboundTextWithHandler(
text: string,
options: {
handler: TelegramOutboundHandlerConfig;
cwd: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
): Promise<string> {
const values = getOutboundTextTemplateValues(text);
const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
if (steps.length > 0) {
const startedAt = Date.now();
let stdout = text;
for (const [index, step] of steps.entries()) {
try {
const result = await runVoiceReplyCommand(
`Outbound text template step ${index + 1}`,
step,
values,
{
cwd: options.cwd,
timeout: getVoiceReplyCompositionStepTimeout(
getVoiceReplyTimeout(options.handler),
step,
startedAt,
),
execCommand: options.execCommand,
stdin: stdout,
},
);
stdout = result.stdout;
} catch (error) {
if (typeof step === "object" && step.failure === "root") throw error;
stdout = "";
}
if (!stdout) stdout = text;
}
return stdout.trim() || text;
}
const result = await runVoiceReplyCommand(
"Outbound text template",
options.handler,
values,
{
cwd: options.cwd,
timeout: getVoiceReplyTimeout(options.handler),
execCommand: options.execCommand,
stdin: text,
},
);
return result.stdout.trim() || text;
}
export async function transformTelegramOutboundText(
text: string,
options: {
handlers?: TelegramOutboundHandlerConfig[];
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
},
): Promise<string> {
let transformed = text;
for (const handler of findTelegramOutboundHandlers(
options.handlers,
"text",
)) {
try {
transformed = await transformTelegramOutboundTextWithHandler(
transformed,
{
handler,
cwd: options.cwd ?? process.cwd(),
execCommand: options.execCommand,
},
);
} catch (error) {
options.recordRuntimeEvent?.("outbound-text-handler", error, {
handler: outboundHandlerMatchesType(handler, "text")
? "text"
: "unknown",
});
}
}
return transformed;
}
function isTelegramInlineKeyboardLike(
replyMarkup: unknown,
): replyMarkup is TelegramInlineKeyboardLike {
if (!replyMarkup || typeof replyMarkup !== "object") return false;
const keyboard = (replyMarkup as { inline_keyboard?: unknown })
.inline_keyboard;
return Array.isArray(keyboard);
}
async function transformTelegramOutboundReplyMarkup<TReplyMarkup>(
replyMarkup: TReplyMarkup | undefined,
options: Omit<TelegramOutboundTextTransformOptions, "replyMarkup">,
): Promise<TReplyMarkup | undefined> {
if (!isTelegramInlineKeyboardLike(replyMarkup)) return replyMarkup;
const translatedRows = [];
for (const row of replyMarkup.inline_keyboard) {
const translatedRow = [];
for (const button of row) {
const text = await transformTelegramOutboundText(button.text, options);
translatedRow.push({ ...button, text });
}
translatedRows.push(translatedRow);
}
return { ...replyMarkup, inline_keyboard: translatedRows } as TReplyMarkup;
}
export async function transformTelegramOutboundTextReply<
TReplyMarkup = unknown,
>(
text: string,
options: TelegramOutboundTextTransformOptions<TReplyMarkup>,
): Promise<TelegramOutboundTextTransformResult<TReplyMarkup>> {
const transformOptions = {
handlers: options.handlers,
cwd: options.cwd,
execCommand: options.execCommand,
recordRuntimeEvent: options.recordRuntimeEvent,
};
const transformedText = await transformTelegramOutboundText(
text,
transformOptions,
);
const replyMarkup = await transformTelegramOutboundReplyMarkup(
options.replyMarkup,
transformOptions,
);
return { text: transformedText, ...(replyMarkup ? { replyMarkup } : {}) };
}
export function createTelegramOutboundTextReplyRuntime<TReplyMarkup = unknown>(
deps: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
): Pick<
TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
"sendTextReply" | "sendMarkdownReply"
> {
return {
sendTextReply: async (chatId, replyToMessageId, text, options) => {
const transformed = await transformTelegramOutboundText(text, {
handlers: deps.getHandlers?.(),
cwd: deps.cwd,
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
});
return deps.sendTextReply(chatId, replyToMessageId, transformed, options);
},
sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
const transformed = await transformTelegramOutboundTextReply(markdown, {
handlers: deps.getHandlers?.(),
cwd: deps.cwd,
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
replyMarkup: options?.replyMarkup,
});
return deps.sendMarkdownReply(
chatId,
replyToMessageId,
transformed.text,
{
...options,
...(transformed.replyMarkup
? { replyMarkup: transformed.replyMarkup }
: {}),
},
);
},
};
}
export function createTelegramOutboundTextPreviewRuntime<
TReplyMarkup = unknown,
>(
deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
): Pick<
TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
"finalizeMarkdownPreview"
> {
return {
finalizeMarkdownPreview: async (
chatId,
markdown,
replyToMessageId,
options,
) => {
const transformed = await transformTelegramOutboundTextReply(markdown, {
handlers: deps.getHandlers?.(),
cwd: deps.cwd,
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
replyMarkup: options?.replyMarkup,
});
return deps.finalizeMarkdownPreview(
chatId,
transformed.text,
replyToMessageId,
{
...options,
...(transformed.replyMarkup
? { replyMarkup: transformed.replyMarkup }
: {}),
},
);
},
};
}
export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
markdown: string;
replyMarkup?: TReplyMarkup;
voiceText?: string;
voiceReplies?: TelegramVoiceReplyItem[];
lang?: string;
rate?: string;
}
// --- Voice Policy Re-Exports ---
export {
clearTelegramVoiceSynthesisProviders,
clearTelegramVoiceTranscriptionProviders,
computeVoicePromptContribution,
computeVoiceTurnFlags,
getTelegramVoiceReplyMode,
getTelegramVoiceSynthesisProviders,
getTelegramVoiceTranscriptionProviders,
hasTelegramVoiceSynthesisProvider,
hasTelegramVoiceTranscriptionProvider,
isVoiceTurn,
registerTelegramVoiceSynthesisProvider,
registerTelegramVoiceTranscriptionProvider,
shouldSuppressPreviewForVoice,
type TelegramVoiceReplyMode,
type TelegramVoiceSynthesisProvider,
type TelegramVoiceSynthesisProviderResult,
type TelegramVoiceTranscriptionFile,
type TelegramVoiceTranscriptionProvider,
type TelegramVoiceTranscriptionProviderResult,
type TelegramVoiceTurnView,
} from "./voice.ts";
export function createTelegramVoiceReplySender(
deps: TelegramVoiceReplySenderDeps,
) {
return createTelegramVoiceReplySenderWithPorts(deps, {
findVoiceHandlers: (handlers) =>
findTelegramOutboundHandlers(
handlers as TelegramOutboundHandlerConfig[] | undefined,
"voice",
),
generateVoiceFile: (text, options) =>
generateTelegramVoiceReplyFile(text, {
lang: options.lang,
rate: options.rate,
handler: options.handler,
tempDir: options.tempDir,
cwd: options.cwd,
execCommand: options.execCommand,
}),
getProgrammaticVoiceHandlers: () =>
getTelegramOutboundProgrammaticHandlers("voice"),
});
}
export {
createTelegramButtonActionStore,
createTelegramButtonPromptTurn,
createTelegramButtonReplyPlanner,
handleTelegramButtonCallbackQuery,
planTelegramButtonReply,
type TelegramButtonActionStore,
type TelegramButtonCallbackHandlerDeps,
type TelegramButtonCallbackQuery,
type TelegramButtonReplyPlan,
type TelegramOutboundButtonAction,
type TelegramOutboundButtonMarkup,
type TelegramOutboundButtonStoredAction,
} from "./outbound-buttons.ts";
export function createTelegramOutboundReplyPlanner(
store: Pick<TelegramButtonActionStore, "register">,
): (
markdown: string,
) => TelegramOutboundReplyPlan<TelegramOutboundButtonMarkup> {
return (markdown) => {
const buttonReply = planTelegramButtonReply(markdown, {
registerAction: store.register,
});
// Button replies can also contain <!-- telegram_voice --> markup
const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
return {
markdown: voiceReply.markdown,
...(buttonReply.replyMarkup
? { replyMarkup: buttonReply.replyMarkup }
: {}),
...(voiceReply.voiceText ? { voiceText: voiceReply.voiceText } : {}),
...(voiceReply.voiceReplies
? { voiceReplies: voiceReply.voiceReplies }
: {}),
...(voiceReply.lang ? { lang: voiceReply.lang } : {}),
...(voiceReply.rate ? { rate: voiceReply.rate } : {}),
};
};
}
/**
* Create an artifact sender that delivers planned voice replies for a turn.
* Iterates over `voiceReplies` (or a single `voiceText`) and sends each as
* a Telegram voice message via the voice reply sender. Throws if no voice
* reply could be delivered.
*/
// --- Outbound Reply Artifacts ---
export function createTelegramOutboundReplyArtifactSender(
deps: TelegramVoiceReplySenderDeps,
) {
const sendVoiceReply = createTelegramVoiceReplySender(deps);
return async (
turn: TelegramVoiceReplyTurnView,
plan: Pick<
TelegramOutboundReplyPlan,
"voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
>,
options?: { replyToPrompt?: boolean },
): Promise<void> => {
// Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
const voiceReplies = plan.voiceReplies?.length
? plan.voiceReplies
: plan.voiceText
? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
: [];
let anyDelivered = false;
for (const reply of voiceReplies) {
try {
await sendVoiceReply(turn, reply.text, {
lang: reply.lang ?? plan.lang,
rate: reply.rate ?? plan.rate,
// Only attach reply parameters to the first voice message
replyToPrompt: options?.replyToPrompt === true && !anyDelivered,
replyMarkup: !anyDelivered ? plan.replyMarkup : undefined,
});
anyDelivered = true;
} catch {
// sendVoiceReply already recorded the error; continue to next reply
}
}
if (!anyDelivered) {
throw new Error(
"Failed to send voice reply: every voice synthesis provider failed.",
);
}
};
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Telegram message ownership helpers
* Zones: telegram routing, multi-instance bus, in-memory coordination
* Owns live message-id ownership lookup so callbacks/reactions can resolve a Telegram UI surface back to the instance/target that produced it
*/
import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
export interface TelegramMessageOwnershipRecord {
chatId: number;
messageId: number;
target: TelegramTarget;
instanceId: string;
profileKey?: string;
ownerGeneration?: string;
createdAt: number;
updatedAt: number;
}
export interface TelegramMessageOwnershipStore {
record: (input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
instanceId: string;
profileKey?: string;
ownerGeneration?: string;
now?: number;
}) => TelegramMessageOwnershipRecord;
get: (
chatId: number,
messageId: number,
) => TelegramMessageOwnershipRecord | undefined;
forget: (chatId: number, messageId: number) => boolean;
forgetTarget: (target: TelegramTarget) => number;
prune: (options: {
now: number;
maxAgeMs?: number;
maxRecords?: number;
}) => number;
entries: () => TelegramMessageOwnershipRecord[];
clear: () => void;
}
export interface TelegramFollowerOwnershipView {
instanceId: string;
connectedAtMs: number;
registrationGeneration?: string;
}
export interface TelegramBusMessageOwnershipRuntime {
store: TelegramMessageOwnershipStore;
recordLocal(input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
}): TelegramMessageOwnershipRecord;
recordRouted(input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
instanceId: string;
}): TelegramMessageOwnershipRecord;
recordFollower(input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
follower: TelegramFollowerOwnershipView;
}): TelegramMessageOwnershipRecord;
isOwnedByFollower(input: {
chatId: number;
messageId: number;
follower: TelegramFollowerOwnershipView;
}): boolean;
}
function getTelegramFollowerOwnershipGeneration(
follower: TelegramFollowerOwnershipView,
): string {
return (
follower.registrationGeneration ??
`${follower.instanceId}:${follower.connectedAtMs}`
);
}
export function createTelegramBusMessageOwnershipRuntime(deps: {
instanceId: string;
getProfileKey(): string;
listFollowers(): readonly TelegramFollowerOwnershipView[];
}): TelegramBusMessageOwnershipRuntime {
const store = createTelegramMessageOwnershipStore({
getProfileKey: deps.getProfileKey,
isOwnerGenerationLive(record) {
if (!record.ownerGeneration) return true;
return deps.listFollowers().some((follower) => {
return (
follower.instanceId === record.instanceId &&
getTelegramFollowerOwnershipGeneration(follower) ===
record.ownerGeneration
);
});
},
});
const recordFollower = function (input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
follower: TelegramFollowerOwnershipView;
}): TelegramMessageOwnershipRecord {
return store.record({
chatId: input.chatId,
messageId: input.messageId,
target: input.target,
instanceId: input.follower.instanceId,
ownerGeneration: getTelegramFollowerOwnershipGeneration(input.follower),
});
};
return {
store,
recordLocal(input) {
return store.record({ ...input, instanceId: deps.instanceId });
},
recordRouted(input) {
const follower = deps
.listFollowers()
.find((candidate) => candidate.instanceId === input.instanceId);
return follower
? recordFollower({ ...input, follower })
: store.record(input);
},
recordFollower,
isOwnedByFollower({ chatId, messageId, follower }) {
const ownership = store.get(chatId, messageId);
return (
ownership?.instanceId === follower.instanceId &&
ownership.ownerGeneration ===
getTelegramFollowerOwnershipGeneration(follower)
);
},
};
}
function getTelegramMessageOwnershipKey(
chatId: number,
messageId: number,
profileKey?: string,
): string {
return `${profileKey ?? ""}:${chatId}:${messageId}`;
}
function createTelegramMessageOwnershipRecord(input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
instanceId: string;
profileKey?: string;
ownerGeneration?: string;
now: number;
previous?: TelegramMessageOwnershipRecord;
}): TelegramMessageOwnershipRecord {
return {
chatId: input.chatId,
messageId: input.messageId,
target: input.target ?? { chatId: input.chatId },
instanceId: input.instanceId,
...(input.profileKey ? { profileKey: input.profileKey } : {}),
...(input.ownerGeneration
? { ownerGeneration: input.ownerGeneration }
: {}),
createdAt: input.previous?.createdAt ?? input.now,
updatedAt: input.now,
};
}
export function createTelegramMessageOwnershipStore(
options: {
getProfileKey?: () => string | undefined;
isOwnerGenerationLive?: (record: TelegramMessageOwnershipRecord) => boolean;
} = {},
): TelegramMessageOwnershipStore {
const records = new Map<string, TelegramMessageOwnershipRecord>();
return {
record: (input) => {
const profileKey = input.profileKey ?? options.getProfileKey?.();
const key = getTelegramMessageOwnershipKey(
input.chatId,
input.messageId,
profileKey,
);
const now = input.now ?? Date.now();
const record = createTelegramMessageOwnershipRecord({
...input,
profileKey,
now,
previous: records.get(key),
});
records.set(key, record);
return record;
},
get: (chatId, messageId) => {
const record = records.get(
getTelegramMessageOwnershipKey(
chatId,
messageId,
options.getProfileKey?.(),
),
);
if (!record) return undefined;
if (
record.ownerGeneration &&
options.isOwnerGenerationLive &&
!options.isOwnerGenerationLive(record)
) {
return undefined;
}
return record;
},
forget: (chatId, messageId) =>
records.delete(
getTelegramMessageOwnershipKey(
chatId,
messageId,
options.getProfileKey?.(),
),
),
forgetTarget: (target) => {
const targetKey = getTelegramTargetKey(target);
const profileKey = options.getProfileKey?.();
let removed = 0;
for (const [key, record] of records) {
if ((record.profileKey ?? undefined) !== profileKey) continue;
if (getTelegramTargetKey(record.target) !== targetKey) continue;
records.delete(key);
removed += 1;
}
return removed;
},
prune: ({ now, maxAgeMs, maxRecords }) => {
let removed = 0;
if (maxAgeMs !== undefined) {
for (const [key, record] of records) {
if (now - record.updatedAt <= maxAgeMs) continue;
records.delete(key);
removed += 1;
}
}
if (maxRecords !== undefined && records.size > maxRecords) {
const oldest = [...records.entries()].sort(
(left, right) => left[1].updatedAt - right[1].updatedAt,
);
for (const [key] of oldest.slice(0, records.size - maxRecords)) {
records.delete(key);
removed += 1;
}
}
return removed;
},
entries: () => [...records.values()],
clear: () => {
records.clear();
},
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Telegram bridge path resolution for Pi-compatible runtimes
* Zones: telemetry paths, filesystem, runtime identity
* Owns agent-dir detection and extension-local path derivation
*
* This domain is pure/path-only: it resolves directories and file paths
* from environment and runtime identity. It does not read config, manage
* state, or import broader Telegram domains.
*/
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export interface TelegramAgentDirResolutionInput {
env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
execPath?: string;
argv?: readonly string[];
}
/**
* Resolve the agent data directory for the current Pi-compatible runtime.
*
* Precedence:
* 1. `PI_CODING_AGENT_DIR` env variable, when explicitly set.
* 2. Detect Pi-compatible runtime identity from the executable or argv[1]
* (e.g. OMP vs standard Pi agent).
* 3. Fallback: `~/.pi/agent`.
*/
export function resolveAgentDir(
input: TelegramAgentDirResolutionInput = {},
): string {
const env = input.env ?? process.env;
if (env.PI_CODING_AGENT_DIR) return resolve(env.PI_CODING_AGENT_DIR);
const execPath = input.execPath ?? process.execPath;
const argv = input.argv ?? process.argv;
const execBasename = execPath.toLowerCase().split(/[\\/]/u).pop() ?? "";
const argv1Last = (argv[1] ?? "").toLowerCase().split(/[\\/]/u).pop() ?? "";
if (execBasename.startsWith("omp") || argv1Last.startsWith("omp")) {
return join(homedir(), ".omp", "agent");
}
return join(homedir(), ".pi", "agent");
}
/** Telegram bridge configuration file (<agentDir>/telegram.json). */
export function resolveTelegramConfigPath(): string {
return join(resolveAgentDir(), "telegram.json");
}
/** Telegram singleton lock file (<agentDir>/locks.json). */
export function resolveTelegramLocksPath(): string {
return join(resolveAgentDir(), "locks.json");
}
/** Telegram bridge temporary directory (<agentDir>/tmp/telegram). */
export function resolveTelegramTempDir(agentDir = resolveAgentDir()): string {
return join(agentDir, "tmp", "telegram");
}
export function getTelegramProfilePathSuffix(profileName?: string): string {
return profileName ? `.${profileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}` : "";
}
export function resolveTelegramProfileTempFilePath(
baseName: string,
extension: string,
agentDir = resolveAgentDir(),
profileName?: string,
): string {
return join(
resolveTelegramTempDir(agentDir),
`${baseName}${getTelegramProfilePathSuffix(profileName)}.${extension}`,
);
}
export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
state: string;
logs: string;
} {
const suffix = getTelegramProfilePathSuffix(profileName);
const profileSlug = suffix.slice(1);
return {
state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
};
}
/** Runtime event log (<agentDir>/tmp/telegram/logs.jsonl). */
export function resolveTelegramRuntimeLogPath(): string {
return resolveTelegramProfileTempFilePath("logs", "jsonl");
}
+195
View File
@@ -0,0 +1,195 @@
/**
* pi SDK adapter boundary
* Zones: pi agent sdk boundary, shared adapters
* Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
*/
import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
import {
type AgentEndEvent,
type AgentSettledEvent,
type AgentStartEvent,
type BeforeAgentStartEvent,
type ExtensionAPI,
type ExtensionCommandContext,
type ExtensionContext,
type InputEvent,
type SessionBeforeCompactEvent,
type SessionCompactEvent,
type SessionShutdownEvent,
type SessionStartEvent,
type SlashCommandInfo,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
export type {
AgentEndEvent,
AgentSettledEvent,
AgentStartEvent,
AssistantMessageEvent,
BeforeAgentStartEvent,
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
InputEvent,
SessionBeforeCompactEvent,
SessionCompactEvent,
SessionShutdownEvent,
SessionStartEvent,
SlashCommandInfo,
};
export interface ToolExecutionStartEvent {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
}
export interface ToolExecutionUpdateEvent {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args: unknown;
partialResult: unknown;
}
export interface ToolExecutionEndEvent {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
}
export interface PiSettingsManager {
reload: () => Promise<void>;
flush: () => Promise<void>;
getEnabledModels: () => string[] | undefined;
setEnabledModels: (patterns: string[] | undefined) => void;
}
export type PiSlashCommandInfo = SlashCommandInfo;
export type PiRunMode = "tui" | "rpc" | "json" | "print";
function isPiRunMode(value: unknown): value is PiRunMode {
return (
value === "tui" || value === "rpc" || value === "json" || value === "print"
);
}
export function getExtensionContextMode(ctx: unknown): PiRunMode | undefined {
const mode =
typeof ctx === "object" && ctx !== null
? (ctx as { mode?: unknown }).mode
: undefined;
return isPiRunMode(mode) ? mode : undefined;
}
export function isExtensionContextPassiveRunMode(ctx: unknown): boolean {
const mode = getExtensionContextMode(ctx);
return mode === "print" || mode === "json";
}
export function canStartPollingInExtensionContext(ctx: unknown): boolean {
return !isExtensionContextPassiveRunMode(ctx);
}
export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
const mode = getExtensionContextMode(ctx);
return mode
? `Telegram polling is unavailable in Pi ${mode} mode. Use /telegram-connect from a long-lived Pi session.`
: "Telegram polling is unavailable in this Pi run mode.";
}
export function getSessionCompactionReason(
event: unknown,
): "manual" | "threshold" | "overflow" | "unknown" {
const reason =
event && typeof event === "object" && "reason" in event
? (event as { reason?: unknown }).reason
: undefined;
return reason === "manual" || reason === "threshold" || reason === "overflow"
? reason
: "unknown";
}
export type PiSendUserMessageOptions = NonNullable<
Parameters<ExtensionAPI["sendUserMessage"]>[1]
>;
export interface PiExtensionApiRuntimePorts {
sendUserMessage: ExtensionAPI["sendUserMessage"];
exec: ExtensionAPI["exec"];
getCommands: ExtensionAPI["getCommands"];
getThinkingLevel: ExtensionAPI["getThinkingLevel"];
setThinkingLevel: ExtensionAPI["setThinkingLevel"];
setModel: ExtensionAPI["setModel"];
}
export function createExtensionApiRuntimePorts(
api: Pick<
ExtensionAPI,
| "sendUserMessage"
| "exec"
| "getCommands"
| "getThinkingLevel"
| "setThinkingLevel"
| "setModel"
>,
): PiExtensionApiRuntimePorts {
return {
sendUserMessage: (content, options) =>
api.sendUserMessage(content, options),
exec: (command, args, options) => api.exec(command, args, options),
getCommands: () => api.getCommands(),
getThinkingLevel: () => api.getThinkingLevel(),
setThinkingLevel: (level) => api.setThinkingLevel(level),
setModel: (model) => api.setModel(model),
};
}
export function createSettingsManager(cwd: string): PiSettingsManager {
return SettingsManager.create(cwd);
}
export function createScopedModelPatternPersister(deps: {
createSettingsManager: (cwd: string) => PiSettingsManager;
clearCachedModelMenuInputs: () => void;
}): (patterns: string[], ctx: ExtensionContext) => Promise<void> {
return async (patterns, ctx) => {
const settingsManager = deps.createSettingsManager(ctx.cwd);
settingsManager.setEnabledModels(
patterns.length > 0 ? patterns : undefined,
);
await settingsManager.flush();
deps.clearCachedModelMenuInputs();
};
}
export function getExtensionContextModel(
ctx: ExtensionContext,
): ExtensionContext["model"] {
return ctx.model;
}
export function getExtensionContextCwd(ctx: ExtensionContext): string {
return ctx.cwd;
}
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
return ctx.isIdle();
}
export function hasExtensionContextPendingMessages(
ctx: ExtensionContext,
): boolean {
return ctx.hasPendingMessages();
}
export function compactExtensionContext(
ctx: ExtensionContext,
callbacks: Parameters<ExtensionContext["compact"]>[0],
): ReturnType<ExtensionContext["compact"]> {
return ctx.compact(callbacks);
}
+1106
View File
File diff suppressed because it is too large. Load diff
+846
View File
@@ -0,0 +1,846 @@
/**
* Telegram preview streaming helpers
* Zones: telegram outbound, native rich markdown drafts
* Owns safe draft preview selection, runtime updates, and preview finalization
*/
import { normalizeTelegramNativeMarkdown } from "./replies.ts";
import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
import { shouldSuppressPreviewForVoice } from "./voice.ts";
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
export type TelegramDraftSupport = "unknown" | "supported";
export interface TelegramPreviewState {
mode: "draft";
draftId?: number;
pendingText: string;
lastSentText: string;
}
export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
flushPromise?: Promise<void>;
flushRequested?: boolean;
}
export type TelegramPreviewReplyMarkup = unknown;
export interface TelegramPreviewRuntimeDeps {
getState: () => TelegramPreviewRuntimeState | undefined;
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
maxMessageLength: number;
getDraftSupport: () => TelegramDraftSupport;
setDraftSupport: (support: TelegramDraftSupport) => void;
allocateDraftId: () => number;
sendDraft: (
chatId: number,
draftId: number,
text?: string,
options?: {
parse_mode?: string;
entities?: unknown[];
message_thread_id?: number;
},
) => Promise<unknown>;
canSend?: () => boolean;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramPreviewActiveTurn {
chatId: number;
replyToMessageId?: number;
target?: TelegramTarget;
voiceReplyPreferred?: boolean;
voiceReplyRequired?: boolean;
}
export interface TelegramAssistantMessagePreviewStartDeps<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
> {
getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
isAssistantMessage: (message: TMessage) => boolean;
getState: () => TelegramPreviewRuntimeState | undefined;
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
createPreviewState: () => TelegramPreviewRuntimeState;
canSend?: () => boolean;
finalizePreview: (chatId: number) => Promise<boolean>;
finalizeMarkdownPreview: (
chatId: number,
markdown: string,
replyToMessageId?: number,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<boolean>;
}
export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
isAssistantMessage: (message: TMessage) => boolean;
getState: () => TelegramPreviewRuntimeState | undefined;
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
createPreviewState: () => TelegramPreviewRuntimeState;
canSend?: () => boolean;
getMessageText: (message: TMessage) => string;
schedulePreviewFlush: (
chatId: number,
options?: { target?: TelegramTarget },
) => void;
}
export type TelegramAssistantMessagePreviewHookDeps<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
> = TelegramAssistantMessagePreviewStartDeps<TMessage, TReplyMarkup> &
TelegramAssistantMessagePreviewUpdateDeps<TMessage>;
export interface TelegramAssistantMessagePreviewHookEvent<TMessage> {
message: TMessage;
}
export interface TelegramAssistantMessagePreviewHooks<TMessage> {
onMessageStart: (
event: TelegramAssistantMessagePreviewHookEvent<TMessage>,
) => Promise<void>;
onMessageUpdate: (
event: TelegramAssistantMessagePreviewHookEvent<TMessage>,
) => Promise<void>;
}
export interface TelegramPreviewControllerDeps {
getDefaultReplyToMessageId?: () => number | undefined;
maxMessageLength?: number;
initialDraftSupport?: TelegramDraftSupport;
sendDraft: (
chatId: number,
draftId: number,
text?: string,
options?: {
parse_mode?: string;
entities?: unknown[];
message_thread_id?: number;
},
) => Promise<unknown>;
canSend?: () => boolean;
maxDraftId?: number;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramPreviewController {
getState: () => TelegramPreviewRuntimeState | undefined;
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
setPendingText: (text: string) => void;
createState: () => TelegramPreviewRuntimeState;
resetState: () => void;
invalidate: () => void;
clear: (
chatId: number,
options?: { awaitFlush?: boolean; target?: TelegramTarget },
) => Promise<void>;
flush: (
chatId: number,
options?: { target?: TelegramTarget },
) => Promise<void>;
scheduleFlush: (
chatId: number,
options?: { target?: TelegramTarget },
) => void;
finalize: (
chatId: number,
replyToMessageId?: number,
options?: { target?: TelegramTarget },
) => Promise<boolean>;
}
export type TelegramPreviewControllerRuntimeDeps =
TelegramPreviewControllerDeps;
export function createTelegramPreviewControllerRuntime(
deps: TelegramPreviewControllerRuntimeDeps,
): TelegramPreviewController {
return createTelegramPreviewController({
getDefaultReplyToMessageId: deps.getDefaultReplyToMessageId,
maxMessageLength: deps.maxMessageLength,
initialDraftSupport: deps.initialDraftSupport,
sendDraft: deps.sendDraft,
canSend: deps.canSend,
maxDraftId: deps.maxDraftId,
recordRuntimeEvent: deps.recordRuntimeEvent,
});
}
export interface TelegramAssistantPreviewRuntimeDeps<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
> extends TelegramPreviewControllerRuntimeDeps {
getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
isAssistantMessage: (message: TMessage) => boolean;
getMessageText: (message: TMessage) => string;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<number | undefined>;
}
export type TelegramAssistantPreviewRuntime<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
> = TelegramPreviewController &
TelegramAssistantMessagePreviewHooks<TMessage> & {
finalizeMarkdown: (
chatId: number,
markdown: string,
replyToMessageId?: number,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<boolean>;
};
export function createTelegramNativeMarkdownPreviewFinalizer<
TReplyMarkup,
>(deps: {
getState: () => TelegramPreviewRuntimeState | undefined;
clear: (
chatId: number,
options?: { awaitFlush?: boolean; target?: TelegramTarget },
) => Promise<void>;
discard?: () => void;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<number | undefined>;
}): (
chatId: number,
markdown: string,
replyToMessageId?: number,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<boolean> {
return async (chatId, markdown, replyToMessageId, options) => {
const state = deps.getState();
if (state?.flushPromise) {
await state.flushPromise.catch(() => {});
if (deps.getState() !== state) return false;
}
await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
if (deps.getState() === state) deps.discard?.();
return true;
};
}
export function createTelegramAssistantPreviewRuntime<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
>(
deps: TelegramAssistantPreviewRuntimeDeps<TMessage, TReplyMarkup>,
): TelegramAssistantPreviewRuntime<TMessage, TReplyMarkup> {
const controller = createTelegramPreviewControllerRuntime(deps);
const finalizeMarkdownPreview = createTelegramNativeMarkdownPreviewFinalizer({
getState: controller.getState,
clear: controller.clear,
discard: () => controller.setState(undefined),
sendMarkdownReply: deps.sendMarkdownReply,
});
return {
...controller,
finalizeMarkdown: finalizeMarkdownPreview,
...createTelegramAssistantMessagePreviewHooks({
getActiveTurn: deps.getActiveTurn,
isAssistantMessage: deps.isAssistantMessage,
getState: controller.getState,
setState: controller.setState,
createPreviewState: controller.createState,
canSend: deps.canSend,
finalizePreview: controller.finalize,
finalizeMarkdownPreview,
getMessageText: deps.getMessageText,
schedulePreviewFlush: controller.scheduleFlush,
}),
};
}
export function createTelegramPreviewController(
deps: TelegramPreviewControllerDeps,
): TelegramPreviewController {
let state: TelegramPreviewRuntimeState | undefined;
let generation = 0;
const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
const maxMessageLength =
deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
let draftSupport = deps.initialDraftSupport ?? "unknown";
let nextDraftId = 0;
const getRuntimeDeps = (
operationGeneration = generation,
): TelegramPreviewRuntimeDeps => ({
getState: () => state,
setState: (nextState) => {
state = nextState;
},
maxMessageLength,
getDraftSupport: () => draftSupport,
setDraftSupport: (support) => {
draftSupport = support;
},
allocateDraftId: () => {
nextDraftId = allocateTelegramDraftId(nextDraftId, maxDraftId);
return nextDraftId;
},
sendDraft: deps.sendDraft,
canSend: () =>
operationGeneration === generation && (deps.canSend?.() ?? true),
recordRuntimeEvent: deps.recordRuntimeEvent,
});
return {
getState: () => state,
setState: (nextState) => {
state = nextState;
},
setPendingText: (text) => {
if (state) state.pendingText = text;
},
createState: () => createTelegramPreviewRuntimeState(),
resetState: () => {
generation += 1;
state = createTelegramPreviewRuntimeState();
},
invalidate: () => {
generation += 1;
state = undefined;
},
clear: (chatId, options) =>
clearTelegramPreview(chatId, getRuntimeDeps(), options),
flush: (chatId, options) =>
flushTelegramPreview(chatId, getRuntimeDeps(), options),
scheduleFlush: (chatId, options) => {
if (!state) return;
void flushTelegramPreview(chatId, getRuntimeDeps(), options);
},
finalize: (chatId, _replyToMessageId, options) =>
finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
};
}
export function createTelegramAssistantMessagePreviewHooks<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
>(
deps: TelegramAssistantMessagePreviewHookDeps<TMessage, TReplyMarkup>,
): TelegramAssistantMessagePreviewHooks<TMessage> {
return {
onMessageStart: async (
event: TelegramAssistantMessagePreviewHookEvent<TMessage>,
): Promise<void> => {
await handleTelegramAssistantMessagePreviewStart(event.message, deps);
},
onMessageUpdate: async (
event: TelegramAssistantMessagePreviewHookEvent<TMessage>,
): Promise<void> => {
await handleTelegramAssistantMessagePreviewUpdate(event.message, deps);
},
};
}
export async function handleTelegramAssistantMessagePreviewStart<
TMessage,
TReplyMarkup = TelegramPreviewReplyMarkup,
>(
message: TMessage,
deps: TelegramAssistantMessagePreviewStartDeps<TMessage, TReplyMarkup>,
): Promise<void> {
const turn = deps.getActiveTurn();
if (!turn || !deps.isAssistantMessage(message)) return;
if (deps.canSend && !deps.canSend()) {
deps.setState(undefined);
return;
}
if (shouldSuppressPreviewForVoice(turn)) {
deps.setState(undefined);
return;
}
const state = deps.getState();
if (
state &&
(state.pendingText.trim().length > 0 ||
state.lastSentText.trim().length > 0)
) {
const previousText = state.pendingText.trim();
if (previousText.length > 0) {
await deps.finalizeMarkdownPreview(
turn.chatId,
previousText,
turn.replyToMessageId,
{
target: turn.target,
},
);
} else {
await deps.finalizePreview(turn.chatId);
}
}
deps.setState(deps.createPreviewState());
}
export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
message: TMessage,
deps: TelegramAssistantMessagePreviewUpdateDeps<TMessage>,
): Promise<void> {
const turn = deps.getActiveTurn();
if (!turn || !deps.isAssistantMessage(message)) return;
if (deps.canSend && !deps.canSend()) {
deps.setState(undefined);
return;
}
if (shouldSuppressPreviewForVoice(turn)) return;
let state = deps.getState();
if (!state) {
state = deps.createPreviewState();
deps.setState(state);
}
state.pendingText = stripTelegramCommentMarkupForPreview(
deps.getMessageText(message),
);
deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
}
export function buildTelegramPreviewFinalText(
state: TelegramPreviewState,
): string | undefined {
const finalText = state.pendingText.trim();
if (finalText) return finalText;
return state.lastSentText.trim() || undefined;
}
export function createTelegramPreviewRuntimeState(): TelegramPreviewRuntimeState {
return {
mode: "draft",
pendingText: "",
lastSentText: "",
};
}
export function allocateTelegramDraftId(
currentDraftId: number,
maxDraftId: number,
): number {
return currentDraftId >= maxDraftId ? 1 : currentDraftId + 1;
}
interface TelegramNativeMarkdownPreviewSnapshot {
text: string;
}
export function shouldUseTelegramDraftPreview(_options: {
draftSupport: TelegramDraftSupport;
snapshot?: TelegramNativeMarkdownPreviewSnapshot;
}): boolean {
return true;
}
export async function clearTelegramPreview(
chatId: number,
deps: TelegramPreviewRuntimeDeps,
options: { awaitFlush?: boolean; target?: TelegramTarget } = {},
): Promise<void> {
const state = deps.getState();
if (!state) return;
if (state.flushPromise && options.awaitFlush !== false) {
state.flushRequested = false;
await state.flushPromise.catch(() => {});
if (deps.getState() !== state) return;
}
deps.setState(undefined);
if (state.mode === "draft" && state.draftId !== undefined) {
try {
await deps.sendDraft(chatId, state.draftId, undefined, {
...getTelegramTargetThreadParams(options.target ?? { chatId }),
});
} catch (error) {
deps.recordRuntimeEvent?.("preview", error, {
phase: "clear-draft",
chatId,
draftId: state.draftId,
});
}
}
}
interface TelegramDraftInlineState {
codeTicks: number;
htmlComment: boolean;
displayMath: boolean;
fence?: { marker: "`" | "~"; length: number };
strongAsterisk: boolean;
emphasisAsterisk: boolean;
strongUnderscore: boolean;
emphasisUnderscore: boolean;
strike: boolean;
linkText: boolean;
linkDestination: boolean;
}
function createTelegramDraftInlineState(): TelegramDraftInlineState {
return {
codeTicks: 0,
htmlComment: false,
displayMath: false,
strongAsterisk: false,
emphasisAsterisk: false,
strongUnderscore: false,
emphasisUnderscore: false,
strike: false,
linkText: false,
linkDestination: false,
};
}
function isTelegramDraftInlineStateClosed(
state: TelegramDraftInlineState,
): boolean {
return (
state.codeTicks === 0 &&
!state.htmlComment &&
!state.displayMath &&
!state.fence &&
!state.strongAsterisk &&
!state.emphasisAsterisk &&
!state.strongUnderscore &&
!state.emphasisUnderscore &&
!state.strike &&
!state.linkText &&
!state.linkDestination
);
}
function countRepeatedChars(text: string, index: number, char: string): number {
let count = 0;
while (text[index + count] === char) count += 1;
return count;
}
function isEscapedMarkdownChar(text: string, index: number): boolean {
let slashCount = 0;
for (
let cursor = index - 1;
cursor >= 0 && text[cursor] === "\\";
cursor -= 1
) {
slashCount += 1;
}
return slashCount % 2 === 1;
}
function isInlineDelimiterCandidate(
text: string,
index: number,
length: number,
): boolean {
const previous = text[index - 1] ?? "";
const next = text[index + length] ?? "";
if (!next || /\s/.test(next))
return previous.length > 0 && !/\s/.test(previous);
if (!previous || /\s/.test(previous)) return true;
return /[\p{P}\p{S}]/u.test(previous) || /[\p{P}\p{S}]/u.test(next);
}
function updateTelegramDraftInlineStateForLine(
line: string,
state: TelegramDraftInlineState,
): void {
if (state.fence || state.displayMath) return;
for (let index = 0; index < line.length; index += 1) {
if (state.htmlComment) {
const closeIndex = line.indexOf("-->", index);
if (closeIndex === -1) return;
state.htmlComment = false;
index = closeIndex + 2;
continue;
}
if (state.codeTicks > 0) {
const ticks = countRepeatedChars(line, index, "`");
if (ticks >= state.codeTicks) {
state.codeTicks = 0;
index += ticks - 1;
}
continue;
}
if (isEscapedMarkdownChar(line, index)) continue;
if (line.startsWith("<!--", index)) {
const closeIndex = line.indexOf("-->", index + 4);
if (closeIndex === -1) {
state.htmlComment = true;
return;
}
index = closeIndex + 2;
continue;
}
const ticks = countRepeatedChars(line, index, "`");
if (ticks > 0) {
state.codeTicks = ticks;
index += ticks - 1;
continue;
}
if (line.startsWith("][", index) || line.startsWith("](", index)) {
state.linkText = false;
state.linkDestination = true;
index += 1;
continue;
}
if (line[index] === "[" && !state.linkDestination) {
state.linkText = true;
continue;
}
if (line[index] === ")" && state.linkDestination) {
state.linkDestination = false;
continue;
}
if (
line.startsWith("~~", index) &&
isInlineDelimiterCandidate(line, index, 2)
) {
state.strike = !state.strike;
index += 1;
continue;
}
if (
line.startsWith("**", index) &&
isInlineDelimiterCandidate(line, index, 2)
) {
state.strongAsterisk = !state.strongAsterisk;
index += 1;
continue;
}
if (line[index] === "*" && isInlineDelimiterCandidate(line, index, 1)) {
state.emphasisAsterisk = !state.emphasisAsterisk;
continue;
}
if (
line.startsWith("__", index) &&
isInlineDelimiterCandidate(line, index, 2)
) {
state.strongUnderscore = !state.strongUnderscore;
index += 1;
continue;
}
if (line[index] === "_" && isInlineDelimiterCandidate(line, index, 1)) {
state.emphasisUnderscore = !state.emphasisUnderscore;
}
}
}
function updateTelegramDraftBlockStateForLine(
line: string,
state: TelegramDraftInlineState,
): boolean {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (state.fence) {
if (
new RegExp(
`^ {0,3}${state.fence.marker}{${state.fence.length},}\\s*$`,
).test(line)
) {
state.fence = undefined;
}
return true;
}
if (state.displayMath) {
if (line.trim() === "$$") state.displayMath = false;
return true;
}
if (fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
state.fence = {
marker: markerText[0] as "`" | "~",
length: markerText.length,
};
return true;
}
if (line.trim() === "$$") {
state.displayMath = true;
return true;
}
return false;
}
function findSafeTelegramRichMarkdownDraftEnd(markdown: string): number {
const state = createTelegramDraftInlineState();
let offset = 0;
let safeEnd = 0;
for (const line of markdown.split("\n")) {
const lineEnd = offset + line.length;
const consumedAsBlock = updateTelegramDraftBlockStateForLine(line, state);
if (!consumedAsBlock) updateTelegramDraftInlineStateForLine(line, state);
const nextOffset = lineEnd + 1;
if (isTelegramDraftInlineStateClosed(state)) safeEnd = lineEnd;
offset = nextOffset;
}
if (isTelegramDraftInlineStateClosed(state)) return markdown.length;
return safeEnd;
}
function hasTelegramPreviewVisibleContent(markdown: string): boolean {
return /[\p{L}\p{N}]/u.test(markdown);
}
export function getSafeTelegramRichMarkdownDraftPrefix(
markdown: string,
maxMessageLength: number,
): string | undefined {
const source = markdown.trim();
if (!source) return undefined;
const limited =
source.length > maxMessageLength
? source.slice(0, maxMessageLength)
: source;
const safeEnd = findSafeTelegramRichMarkdownDraftEnd(limited);
if (safeEnd > 0) {
const safePrefix = limited.slice(0, safeEnd).trimEnd();
return hasTelegramPreviewVisibleContent(safePrefix)
? safePrefix
: undefined;
}
let candidateEnd = limited.length;
while (candidateEnd > 0) {
candidateEnd = limited.lastIndexOf(" ", candidateEnd - 1);
if (candidateEnd <= 0) return undefined;
const candidate = limited.slice(0, candidateEnd).trimEnd();
if (
hasTelegramPreviewVisibleContent(candidate) &&
findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length
) {
return candidate || undefined;
}
}
return undefined;
}
function buildTelegramNativeMarkdownPreviewSnapshot(
state: TelegramPreviewState,
maxMessageLength: number,
): TelegramNativeMarkdownPreviewSnapshot | undefined {
const safeText = getSafeTelegramRichMarkdownDraftPrefix(
state.pendingText,
maxMessageLength,
);
if (!safeText || safeText === state.lastSentText) return undefined;
return { text: safeText };
}
async function performTelegramPreviewFlush(
chatId: number,
state: TelegramPreviewRuntimeState,
deps: TelegramPreviewRuntimeDeps,
options: { target?: TelegramTarget } = {},
): Promise<void> {
if (deps.canSend && !deps.canSend()) {
await clearTelegramPreview(chatId, deps, {
awaitFlush: false,
target: options.target,
});
return;
}
const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(
state,
deps.maxMessageLength,
);
if (!snapshot) return;
if (
shouldUseTelegramDraftPreview({
draftSupport: deps.getDraftSupport(),
snapshot,
})
) {
const draftId = state.draftId ?? deps.allocateDraftId();
state.draftId = draftId;
try {
await deps.sendDraft(
chatId,
draftId,
normalizeTelegramNativeMarkdown(snapshot.text),
{ ...getTelegramTargetThreadParams(options.target ?? { chatId }) },
);
deps.setDraftSupport("supported");
state.mode = "draft";
state.lastSentText = snapshot.text;
return;
} catch (error) {
deps.recordRuntimeEvent?.("preview", error, {
phase: "draft",
chatId,
draftId,
});
return;
}
}
}
export async function flushTelegramPreview(
chatId: number,
deps: TelegramPreviewRuntimeDeps,
options: { target?: TelegramTarget } = {},
): Promise<void> {
const state = deps.getState();
if (!state) return;
if (state.flushPromise) {
state.flushRequested = true;
await state.flushPromise;
return;
}
state.flushPromise = (async () => {
do {
state.flushRequested = false;
try {
await performTelegramPreviewFlush(chatId, state, deps, options);
} catch (error) {
deps.recordRuntimeEvent?.("preview", error, {
phase: "flush",
chatId,
draftId: state.draftId,
});
break;
}
} while (deps.getState() === state && state.flushRequested);
})();
try {
await state.flushPromise;
} finally {
if (deps.getState() === state) {
state.flushPromise = undefined;
}
}
}
export async function finalizeTelegramPreview(
chatId: number,
deps: TelegramPreviewRuntimeDeps,
options: { target?: TelegramTarget } = {},
): Promise<boolean> {
const state = deps.getState();
if (!state) return false;
if (deps.canSend && !deps.canSend()) {
await clearTelegramPreview(chatId, deps, options);
return false;
}
await flushTelegramPreview(chatId, deps, options);
const finalText = buildTelegramPreviewFinalText(state);
if (!finalText) {
await clearTelegramPreview(chatId, deps, options);
return false;
}
deps.setState(undefined);
return false;
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Pi prompt-template bridge helpers
* Zones: pi agent prompts, telegram controls, filesystem
* Discovers Pi prompt-template slash commands and expands them before Telegram queue dispatch
*/
import { readFileSync } from "node:fs";
import type { PiSlashCommandInfo } from "./pi.ts";
export interface TelegramPromptTemplateCommand {
command: string;
description?: string;
path: string;
}
export type TelegramPromptTemplateReader = (path: string) => string;
const TELEGRAM_BOT_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
function stripPromptTemplateFrontmatter(content: string): string {
if (!content.startsWith("---")) return content;
const lines = content.split("\n");
if (lines[0]?.trim() !== "---") return content;
for (let index = 1; index < lines.length; index += 1) {
if (lines[index]?.trim() === "---")
return lines.slice(index + 1).join("\n");
}
return content;
}
export function parsePromptTemplateArgs(argsString: string): string[] {
const args: string[] = [];
let current = "";
let quote: string | undefined;
for (const char of argsString) {
if (quote) {
if (char === quote) {
quote = undefined;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === " " || char === "\t") {
if (current) args.push(current);
current = "";
continue;
}
current += char;
}
if (current) args.push(current);
return args;
}
export function substitutePromptTemplateArgs(
content: string,
args: readonly string[],
): string {
let result = content.replace(/\$(\d+)/g, (_, num: string) => {
const index = Number.parseInt(num, 10) - 1;
return args[index] ?? "";
});
result = result.replace(
/\$\{@:(\d+)(?::(\d+))?\}/g,
(_, startValue: string, lengthValue: string | undefined) => {
const start = Math.max(Number.parseInt(startValue, 10) - 1, 0);
if (lengthValue) {
const length = Number.parseInt(lengthValue, 10);
return args.slice(start, start + length).join(" ");
}
return args.slice(start).join(" ");
},
);
const allArgs = args.join(" ");
return result.replace(/\$ARGUMENTS/g, allArgs).replace(/\$@/g, allArgs);
}
export function isTelegramPromptTemplateCommandName(name: string): boolean {
return TELEGRAM_BOT_COMMAND_NAME_PATTERN.test(name);
}
export function mapPiPromptTemplateNameToTelegramCommandName(
name: string,
): string | undefined {
const command = name
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 32)
.replace(/_+$/g, "");
return isTelegramPromptTemplateCommandName(command) ? command : undefined;
}
export interface TelegramPromptTemplateCommandGetterDeps {
getCommands: () => readonly PiSlashCommandInfo[];
reservedCommandNames?: readonly string[];
getReservedCommandNames?: () => readonly string[];
}
export function getTelegramPromptTemplateCommands(
commands: readonly PiSlashCommandInfo[],
reservedNames: ReadonlySet<string> = new Set(),
): TelegramPromptTemplateCommand[] {
const seen = new Set<string>();
const promptCommands: TelegramPromptTemplateCommand[] = [];
for (const command of commands) {
if (command.source !== "prompt") continue;
const telegramCommand = mapPiPromptTemplateNameToTelegramCommandName(
command.name,
);
if (!telegramCommand) continue;
if (reservedNames.has(telegramCommand)) continue;
if (seen.has(telegramCommand)) continue;
const sourcePath = command.sourceInfo?.path;
if (!sourcePath) continue;
seen.add(telegramCommand);
promptCommands.push({
command: telegramCommand,
description: command.description,
path: sourcePath,
});
}
return promptCommands.sort((a, b) => a.command.localeCompare(b.command));
}
export function createTelegramPromptTemplateCommandGetter(
deps: TelegramPromptTemplateCommandGetterDeps,
): () => TelegramPromptTemplateCommand[] {
return () => {
return getTelegramPromptTemplateCommands(
deps.getCommands(),
new Set([
...(deps.reservedCommandNames ?? []),
...(deps.getReservedCommandNames?.() ?? []),
]),
);
};
}
export function expandTelegramPromptTemplateCommand(
commandName: string,
args: string,
commands: readonly TelegramPromptTemplateCommand[],
readTemplate: TelegramPromptTemplateReader = (path) =>
readFileSync(path, "utf-8"),
): string | undefined {
const command = commands.find(
(candidate) => candidate.command === commandName,
);
if (!command) return undefined;
const content = stripPromptTemplateFrontmatter(readTemplate(command.path));
return substitutePromptTemplateArgs(content, parsePromptTemplateArgs(args));
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Telegram prompt injection helpers
* Zones: pi agent prompts, telegram guidance
* Owns Telegram-specific system prompt suffixes injected into pi agent turns
*/
import { Type } from "@sinclair/typebox";
import { getTelegramDiagnosticsDisplayPaths } from "./paths.ts";
import type { BeforeAgentStartEvent, ExtensionAPI } from "./pi.ts";
import { TELEGRAM_PREFIX } from "./turns.ts";
const LOCAL_SYSTEM_PROMPT_SUFFIX = `
Telegram bridge available. Do not use it from local/TUI prompts unless explicitly asked.`;
const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload. For voice use a top-level HTML action: \`<!-- telegram_voice: Speak this. -->\`, multiline \`<!-- telegram_voice lang=ru\nSpeak this.\n-->\`, or paired \`<!-- telegram_voice lang=ru -->\nSpeak this.\n<!-- /telegram_voice -->\`.`;
function buildTelegramHelpText(profileName?: string): string {
const diagnosticsPaths = getTelegramDiagnosticsDisplayPaths(profileName);
return `--- TELEGRAM BRIDGE HELP ---
How to understand Telegram turns:
- \`[telegram|thread:name|from:user|guest:group]\` marks Telegram origin and attributes.
- \`thread\` is the visible Thread identity in Threaded Mode; it is not a bus role.
- \`[reply]\` is quoted context only; act on the user's current instruction.
- \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context; \`[voice]\` gives reply-mode policy.
How to answer Telegram turns:
- Reply in concise, scannable mobile Telegram Rich Markdown.
- Use \`$...$\` for inline math and \`$$...$$\` for block math.
- Real code blocks must stay literal.
- For generated/requested files, call \`telegram_attach(local_path)\`; do not only mention the path.
Assistant-authored Telegram actions:
- \`telegram_voice\` and \`telegram_button\` are hidden top-level HTML comments, not Pi tools.
- Put action comments at column zero, outside code, quotes, lists, and indented examples.
- Voice forms: \`<!-- telegram_voice text="Short summary" -->\`, \`<!-- telegram_voice: Short summary -->\`, multiline \`<!-- telegram_voice lang=ru\nShort summary.\n-->\`, or paired \`<!-- telegram_voice lang=ru -->\nShort summary.\n<!-- /telegram_voice -->\`.
- Keep the complete action at top level and include a non-empty voice payload.
- Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
- Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
- Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
- If hidden comments would be the whole reply, add visible text such as \`Choose one:\`.
Local/TUI direct delivery:
- Do not send Telegram actions from local/TUI prompts unless explicitly asked.
- Use \`telegram_attach\` for files and \`telegram_message\` for direct Markdown text.
- Direct delivery requires this Pi instance to own \`/telegram-connect\` or be registered with the Threaded Mode bus.
- For explicit targets, pass \`chat_id\` plus optional \`thread_id\`; registered followers default to their assigned Thread target.
- Do not use \`telegram_message\` for ordinary Telegram-originated replies; answer normally and let the bridge deliver the active turn reply.
Threaded Mode:
- pi-telegram supports private-chat Threaded Mode when Telegram exposes thread support for the bot.
- Product/user language is Thread; Bot API primitive names may still say topic.
- Threaded Mode has one leader transport and visible follower Pi processes joined manually through \`/telegram-connect\`.
- Thread names are bridge-assigned or preserved identities; do not invent rename prompts or use a rename tool.
- The \`All\` surface is for routing/control, not hidden Pi process creation.
Configurable handlers:
- \`telegram.json\` can add no-code \`inboundHandlers\`/\`outboundHandlers\` using command templates before writing an extension.
- For speech-to-text, configure an \`inboundHandlers\` entry matching \`type: "voice"\` or \`mime: "audio/*"\`; stdout becomes \`[outputs]\` prompt context.
- If command-template config is not enough, build a companion extension through the public pi-telegram APIs; do not import package-private \`lib/*\` paths.
Debugging pi-telegram:
- Inspect \`${diagnosticsPaths.state}\` for runtime state, roster, bindings, slots, reservations, and diagnostics.
- Inspect \`${diagnosticsPaths.logs}\` for redacted runtime event evidence.
- Use terminal \`telegram-status\` for compact human health; use \`telegram-status --debug\` for the full human-readable diagnostic dump.`;
}
export function getTelegramHelpText(profileName?: string): string {
return buildTelegramHelpText(profileName);
}
export function registerTelegramHelpTool(
pi: ExtensionAPI,
options: { getActiveProfileName?: () => string | undefined } = {},
): void {
pi.registerTool({
name: "telegram_help",
label: "Telegram Help",
description:
"Read pi-telegram usage guidance for delivery actions, Threaded Mode, handlers, formatting, and debugging.",
parameters: Type.Object({}),
async execute() {
return {
content: [
{
type: "text",
text: getTelegramHelpText(options.getActiveProfileName?.()),
},
],
details: {},
};
},
});
}
export function buildTelegramBridgeSystemPrompt(options: {
prompt: string;
systemPrompt: string;
telegramPrefix?: string;
localSystemPromptSuffix: string;
telegramTurnSystemPromptSuffix: string;
}): { systemPrompt: string } {
const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
const telegramHead = telegramPrefix.endsWith("]")
? telegramPrefix.slice(0, -1)
: telegramPrefix;
const trimmedPrompt = options.prompt.trimStart();
const telegramTurn =
trimmedPrompt.startsWith(`${telegramHead}]`) ||
trimmedPrompt.startsWith(`${telegramHead}|`);
const telegramSuffix = telegramTurn
? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
: "";
return {
systemPrompt:
options.systemPrompt + options.localSystemPromptSuffix + telegramSuffix,
};
}
export function createTelegramBeforeAgentStartHook(
options: {
telegramPrefix?: string;
localSystemPromptSuffix?: string;
telegramTurnSystemPromptSuffix?: string;
} = {},
): (event: BeforeAgentStartEvent) => { systemPrompt: string } {
return (event) =>
buildTelegramBridgeSystemPrompt({
prompt: event.prompt,
systemPrompt: event.systemPrompt,
telegramPrefix: options.telegramPrefix,
localSystemPromptSuffix:
options.localSystemPromptSuffix ?? LOCAL_SYSTEM_PROMPT_SUFFIX,
telegramTurnSystemPromptSuffix:
options.telegramTurnSystemPromptSuffix ??
TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
});
}
export interface TelegramProactivePromptHookDeps<TContext> {
baseHook?: (event: BeforeAgentStartEvent) => { systemPrompt: string };
isConfigured: () => boolean;
isProactivePushEnabled: () => boolean;
isCurrentOwner: (ctx: TContext) => boolean;
}
export function createTelegramProactiveBeforeAgentStartHook<TContext>(
deps: TelegramProactivePromptHookDeps<TContext>,
): (
event: BeforeAgentStartEvent,
ctx: TContext,
) => Promise<{ systemPrompt: string }> {
const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
return async (event, ctx) => {
if (!deps.isConfigured()) return { systemPrompt: event.systemPrompt };
const result = baseHook(event);
if (!deps.isProactivePushEnabled()) return result;
if (!deps.isCurrentOwner(ctx)) return result;
return result;
};
}
+2361
View File
File diff suppressed because it is too large. Load diff
+1197
View File
File diff suppressed because it is too large. Load diff
+915
View File
@@ -0,0 +1,915 @@
/**
* Telegram reply delivery helpers
* Zones: telegram outbound, native rich markdown, UI/compat rendering transport
* Owns native assistant replies, rendered UI delivery, reply transport wiring, and plain text replies
*/
import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
import type {
TelegramInputRichMessage,
TelegramReplyParameters,
TelegramSendRichMessageBody,
TelegramSentMessage,
} from "./telegram-api.ts";
import {
renderTelegramMessage,
type TelegramRenderedChunk,
type TelegramRenderMode,
} from "./rendering.ts";
export {
renderTelegramMessage,
type TelegramRenderedChunk,
type TelegramRenderMode,
};
export function renderTelegramMarkdownToHtmlDraft(markdown: string): string {
return renderTelegramMessage(markdown, { mode: "markdown" })
.map((chunk) => chunk.text)
.join("\n");
}
export const TELEGRAM_RICH_MESSAGE_MAX_CHARS = 32768;
export const TELEGRAM_RICH_MESSAGE_MAX_BLOCKS = 500;
// --- Reply Dedup ---
/** Non-persistent reply deduplication for a single agent turn.
* First reply to a prompt gets `reply_parameters.message_id`;
* subsequent replies in the same turn skip it to avoid stacking
* duplicate reply headers in the chat viewport. */
export interface ReplyDedupRuntime {
/** Returns true if this is the first reply for the given prompt
* message id in the current turn. Side-effect: marks it replied. */
shouldReply(promptMessageId: number): boolean;
/** Reset the tracker when a new prompt enters the queue. */
reset(): void;
}
export function createReplyDedupRuntime(): ReplyDedupRuntime {
const replied = new Map<number, boolean>();
return {
shouldReply(promptMessageId: number): boolean {
if (replied.has(promptMessageId)) return false;
replied.set(promptMessageId, true);
return true;
},
reset(): void {
replied.clear();
},
};
}
// --- Transport-level dedup ---
const lastRepliedToMessageIdByTarget = new Map<string, number>();
function getReplyDedupTargetKey(
chatId: number,
target?: TelegramTarget,
): string {
const threadId = target?.threadId;
return typeof threadId === "number"
? `${chatId}:thread:${threadId}`
: `${chatId}:private`;
}
export function resetTransportReplyDedup(): void {
lastRepliedToMessageIdByTarget.clear();
}
export function buildTelegramReplyParameters(
chatId: number,
messageId: number | undefined,
target?: TelegramTarget,
): TelegramReplyParameters | undefined {
if (messageId === undefined || messageId <= 0) return undefined;
const key = getReplyDedupTargetKey(chatId, target);
if (lastRepliedToMessageIdByTarget.get(key) === messageId) {
return undefined;
}
lastRepliedToMessageIdByTarget.set(key, messageId);
return {
message_id: messageId,
allow_sending_without_reply: true,
};
}
export function buildTelegramMultipartReplyParameters(
chatId: number,
messageId: number | undefined,
target?: TelegramTarget,
): string | undefined {
const parameters = buildTelegramReplyParameters(chatId, messageId, target);
return parameters ? JSON.stringify(parameters) : undefined;
}
function getAgentMessageField(message: unknown, field: string): unknown {
if (typeof message !== "object" || message === null || !(field in message)) {
return undefined;
}
return Reflect.get(message, field);
}
export function isAssistantAgentMessage(message: unknown): boolean {
return getAgentMessageField(message, "role") === "assistant";
}
function extractAgentTextContent(content: unknown): string {
const blocks = Array.isArray(content) ? content : [];
return blocks
.filter(
(block): block is { type: string; text?: string } =>
typeof block === "object" && block !== null && "type" in block,
)
.filter((block) => block.type === "text" && typeof block.text === "string")
.map((block) => block.text as string)
.join("")
.trim();
}
export function getAgentMessageText(message: unknown): string {
return extractAgentTextContent(getAgentMessageField(message, "content"));
}
export function extractLatestAssistantMessageText(
messages: readonly unknown[],
): {
text?: string;
stopReason?: string;
errorMessage?: string;
} {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || !isAssistantAgentMessage(message)) continue;
const rawStopReason = getAgentMessageField(message, "stopReason");
const rawErrorMessage = getAgentMessageField(message, "errorMessage");
const stopReason =
typeof rawStopReason === "string" ? rawStopReason : undefined;
const errorMessage =
typeof rawErrorMessage === "string" ? rawErrorMessage : undefined;
const text = getAgentMessageText(message);
return { text: text || undefined, stopReason, errorMessage };
}
return {};
}
export interface TelegramReplyOwnershipRecorder {
record: (input: {
chatId: number;
messageId: number;
target?: TelegramTarget;
}) => void;
}
export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
recordOwnership?: TelegramReplyOwnershipRecorder["record"];
sendMessage: (body: {
chat_id: number;
text: string;
parse_mode?: "HTML";
reply_markup?: TReplyMarkup;
reply_parameters?: TelegramReplyParameters;
reply_to_message_id?: number;
message_thread_id?: number;
}) => Promise<TelegramSentMessage>;
editMessage: (body: {
chat_id: number;
message_id: number;
text?: string;
rich_message?: TelegramInputRichMessage;
parse_mode?: "HTML";
reply_markup?: TReplyMarkup;
message_thread_id?: number;
}) => Promise<unknown>;
}
export interface TelegramReplyTargetOptions {
target?: TelegramTarget;
replyToMessageId?: number;
}
export interface TelegramReplyTransport<TReplyMarkup> {
sendRenderedChunks: (
chatId: number,
chunks: TelegramRenderedChunk[],
options?: TelegramReplyTargetOptions & {
replyMarkup?: TReplyMarkup;
},
) => Promise<number | undefined>;
editRenderedMessage: (
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>;
}
export function buildTelegramReplyTransport<TReplyMarkup>(
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
): TelegramReplyTransport<TReplyMarkup> {
return {
sendRenderedChunks: async (chatId, chunks, options) => {
return sendTelegramRenderedChunks(chatId, chunks, deps, options);
},
editRenderedMessage: async (chatId, messageId, chunks, options) => {
return editTelegramRenderedMessage(
chatId,
messageId,
chunks,
deps,
options,
);
},
};
}
export async function sendTelegramRenderedChunks<TReplyMarkup>(
chatId: number,
chunks: TelegramRenderedChunk[],
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
options?: TelegramReplyTargetOptions & {
replyMarkup?: TReplyMarkup;
},
): Promise<number | undefined> {
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
let lastMessageId: number | undefined;
for (const [index, chunk] of chunks.entries()) {
const replyParameters =
index === 0
? buildTelegramReplyParameters(
chatId,
options?.replyToMessageId,
options?.target,
)
: undefined;
const body = {
chat_id: chatId,
text: chunk.text,
parse_mode: chunk.parseMode,
reply_markup:
index === chunks.length - 1 ? options?.replyMarkup : undefined,
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
};
const sent = await deps.sendMessage(body);
lastMessageId = sent.message_id;
deps.recordOwnership?.({
chatId,
messageId: sent.message_id,
target: options?.target,
});
}
return lastMessageId;
}
export async function editTelegramRenderedMessage<TReplyMarkup>(
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
): Promise<number | undefined> {
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
if (chunks.length === 0) return messageId;
const [firstChunk, ...remainingChunks] = chunks;
deps.recordOwnership?.({ chatId, messageId, target: options?.target });
await deps.editMessage({
chat_id: chatId,
message_id: messageId,
text: firstChunk.text,
parse_mode: firstChunk.parseMode,
reply_markup:
remainingChunks.length === 0 ? options?.replyMarkup : undefined,
...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
});
if (remainingChunks.length > 0) {
return sendTelegramRenderedChunks(chatId, remainingChunks, deps, {
replyMarkup: options?.replyMarkup,
target: options?.target,
});
}
return messageId;
}
export interface TelegramTextReplyOptions extends TelegramReplyTargetOptions {
parseMode?: "HTML";
}
export interface TelegramReplyRuntimeDeps<TReplyMarkup = unknown> {
renderTelegramMessage: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
sendRenderedChunks: (
chunks: TelegramRenderedChunk[],
options?: { replyMarkup?: TReplyMarkup } & TelegramReplyTargetOptions,
) => Promise<number | undefined>;
}
export async function sendTelegramPlainReply(
text: string,
deps: TelegramReplyRuntimeDeps,
options?: TelegramTextReplyOptions,
): Promise<number | undefined> {
const chunks = deps.renderTelegramMessage(text, {
mode: options?.parseMode === "HTML" ? "html" : "plain",
});
return deps.sendRenderedChunks(chunks, {
target: options?.target,
replyToMessageId: options?.replyToMessageId,
});
}
function normalizeIndentedTelegramNativeMarkdownList(line: string): string {
return line.replace(
/^( +|\t+)([-*+] |\d+\. )/,
(_match, indent: string, marker: string) => {
const visibleIndent = indent
.replace(/ /g, "\u00A0")
.replace(/\t/g, "\u00A0\u00A0");
return `${visibleIndent}${marker}`;
},
);
}
function normalizeTelegramNativeMarkdownLine(line: string): string {
let result = normalizeIndentedTelegramNativeMarkdownList(
line.replace(/^( {0,3}>)[ \t]/, "$1"),
);
const codeSpans: string[] = [];
result = result.replace(/`+[^`]*`+/g, (code) => {
const token = `\u0000${codeSpans.length}\u0000`;
codeSpans.push(code);
return token;
});
result = result.replace(
/(^|[^\\$])\$([A-Z][A-Z0-9]{1,})(?!\$)(?=\b|[.,;:)/-])/g,
(_match, prefix: string, ticker: string) => `${prefix}\\$${ticker}`,
);
return result.replace(
/\u0000(\d+)\u0000/g,
(_match, index) => codeSpans[Number(index)] ?? "",
);
}
function hasClosingDisplayMathDelimiter(
lines: readonly string[],
startIndex: number,
): boolean {
let fence: { marker: "`" | "~"; length: number } | undefined;
for (let index = startIndex + 1; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && line.trim() === "$$") return true;
if (!fence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
continue;
}
if (
fence &&
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
) {
fence = undefined;
}
}
return false;
}
export function normalizeTelegramNativeMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
let fence: { marker: "`" | "~"; length: number } | undefined;
let displayMath = false;
return lines
.map((line, index) => {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
const inFence = fence !== undefined;
if (!inFence && line.trim() === "$$") {
if (displayMath) {
displayMath = false;
return "```";
}
if (hasClosingDisplayMathDelimiter(lines, index)) {
displayMath = true;
return "```math";
}
}
if (displayMath) return line;
if (!inFence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = {
marker: markerText[0] as "`" | "~",
length: markerText.length,
};
return line;
}
if (
inFence &&
new RegExp(`^ {0,3}${fence?.marker}{${fence?.length},}\\s*$`).test(line)
) {
fence = undefined;
return line;
}
if (!inFence) return normalizeTelegramNativeMarkdownLine(line);
return line;
})
.join("\n");
}
export function splitTelegramNativeMarkdown(markdown: string): string[] {
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
if (
normalizedMarkdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
countTelegramNativeMarkdownBlocks(normalizedMarkdown) <=
TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
) {
return [normalizedMarkdown];
}
const chunks: string[] = [];
let current = "";
let currentBlockCount = 0;
for (const rawBlock of splitTelegramNativeMarkdownBlocks(
normalizedMarkdown,
)) {
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
const blockCount = countTelegramNativeMarkdownBlocks(block);
const candidate = current ? `${current}\n\n${block}` : block;
const exceedsChars = candidate.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS;
const exceedsBlocks =
currentBlockCount + blockCount > TELEGRAM_RICH_MESSAGE_MAX_BLOCKS;
if (!exceedsChars && !exceedsBlocks) {
current = candidate;
currentBlockCount += blockCount;
continue;
}
if (current) chunks.push(current.trimEnd());
if (
block.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
blockCount <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
) {
current = block;
currentBlockCount = blockCount;
continue;
}
chunks.push(...splitTelegramNativeMarkdownLongBlock(block));
current = "";
currentBlockCount = 0;
}
}
if (current) chunks.push(current.trimEnd());
return chunks;
}
function splitTelegramNativeMarkdownBlocks(markdown: string): string[] {
const blocks: string[] = [];
const current: string[] = [];
let fence: { marker: "`" | "~"; length: number } | undefined;
const flush = (): void => {
if (current.length === 0) return;
blocks.push(current.join("\n"));
current.length = 0;
};
for (const line of markdown.split("\n")) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && line.trim().length === 0) {
flush();
continue;
}
current.push(line);
if (!fence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
continue;
}
if (
fence &&
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
) {
fence = undefined;
}
}
flush();
return blocks;
}
function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
if (
countTelegramNativeMarkdownBlocks(block) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
) {
return [block];
}
const chunks: string[] = [];
let current: string[] = [];
let fence: { marker: "`" | "~"; length: number } | undefined;
for (const line of block.split("\n")) {
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!fence && current.length >= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS) {
chunks.push(current.join("\n"));
current = [];
}
current.push(line);
if (!fence && fenceMatch) {
const markerText = fenceMatch[1] ?? "```";
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
continue;
}
if (
fence &&
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
) {
fence = undefined;
}
}
if (current.length > 0) chunks.push(current.join("\n"));
return chunks;
}
function countTelegramNativeMarkdownBlocks(block: string): number {
if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1;
const lines = block.split("\n").filter((line) => line.trim().length > 0);
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
return Math.max(1, lines.length);
}
return 1;
}
function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
return (
splitTelegramNativeMarkdownLongFenceBlock(block) ??
splitTelegramNativeMarkdownLongWrappedInlineBlock(block) ??
splitTelegramNativeMarkdownLongPlainBlock(block)
);
}
function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
const chunks: string[] = [];
let remaining = block;
while (remaining.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS) {
const window = remaining.slice(0, TELEGRAM_RICH_MESSAGE_MAX_CHARS + 1);
const splitIndex = findTelegramNativeMarkdownSplitIndex(window);
chunks.push(remaining.slice(0, splitIndex).trimEnd());
remaining = remaining.slice(splitIndex).trimStart();
}
if (remaining.length > 0) chunks.push(remaining);
return chunks;
}
function splitTelegramNativeMarkdownLongFenceBlock(
block: string,
): string[] | undefined {
const lines = block.split("\n");
const opening = lines[0] ?? "";
const closing = lines[lines.length - 1] ?? "";
const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/);
if (!openingMatch || !closing || lines.length < 2) return undefined;
const markerText = openingMatch[1] ?? "```";
const marker = markerText[0] as "`" | "~";
if (
!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)
) {
return undefined;
}
const maxContentLength =
TELEGRAM_RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2;
if (maxContentLength <= 0) return undefined;
const content = lines.slice(1, -1).join("\n");
return splitTelegramNativeMarkdownWrappedContent(
content,
maxContentLength,
(chunk) =>
`${opening}\n${chunk}${chunk.endsWith("\n") ? "" : "\n"}${closing}`,
);
}
function splitTelegramNativeMarkdownLongWrappedInlineBlock(
block: string,
): string[] | undefined {
const delimiter = ["**", "__", "~~", "`", "*", "_"].find(
(candidate) =>
block.startsWith(candidate) &&
block.endsWith(candidate) &&
block.length > candidate.length * 2,
);
if (!delimiter) return undefined;
const maxContentLength =
TELEGRAM_RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
if (maxContentLength <= 0) return undefined;
return splitTelegramNativeMarkdownWrappedContent(
block.slice(delimiter.length, -delimiter.length),
maxContentLength,
(chunk) => `${delimiter}${chunk}${delimiter}`,
);
}
function splitTelegramNativeMarkdownWrappedContent(
content: string,
maxContentLength: number,
wrap: (chunk: string) => string,
): string[] {
const chunks: string[] = [];
let remaining = content;
while (remaining.length > maxContentLength) {
const window = remaining.slice(0, maxContentLength + 1);
const splitIndex = findTelegramNativeMarkdownSplitIndex(
window,
maxContentLength,
);
chunks.push(wrap(remaining.slice(0, splitIndex)));
remaining = remaining.slice(splitIndex);
}
if (remaining.length > 0) chunks.push(wrap(remaining));
return chunks;
}
function findTelegramNativeMarkdownSplitIndex(
text: string,
hardLimit = TELEGRAM_RICH_MESSAGE_MAX_CHARS,
): number {
const paragraphIndex = text.lastIndexOf("\n\n", hardLimit);
if (paragraphIndex > 0) return paragraphIndex + 2;
const lineIndex = text.lastIndexOf("\n", hardLimit);
if (lineIndex > 0) return lineIndex + 1;
const spaceIndex = text.lastIndexOf(" ", hardLimit);
if (spaceIndex > 0) return spaceIndex + 1;
return hardLimit;
}
export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
deps: {
recordOwnership?: TelegramReplyOwnershipRecorder["record"];
sendRichMessage: (
body: TelegramSendRichMessageBody,
) => Promise<TelegramSentMessage>;
},
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
): Promise<number | undefined> {
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
let lastMessageId: number | undefined;
const chunks = splitTelegramNativeMarkdown(markdown);
for (const [index, chunk] of chunks.entries()) {
const replyParameters =
index === 0
? buildTelegramReplyParameters(
chatId,
replyToMessageId,
options?.target,
)
: undefined;
const sent = await deps.sendRichMessage({
chat_id: chatId,
rich_message: { markdown: chunk, skip_entity_detection: true },
reply_markup:
index === chunks.length - 1 ? options?.replyMarkup : undefined,
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(options?.target ? getTelegramTargetThreadParams(options.target) : {}),
});
lastMessageId = sent.message_id;
deps.recordOwnership?.({
chatId,
messageId: sent.message_id,
target: options?.target,
});
}
return lastMessageId;
}
// UI/compat regular-message runtime for bridge-owned text and interactive
// surfaces. Assistant and guest Markdown delivery bypass this path and use
// native Rich Message helpers above.
export type TelegramAssistantRenderingMode = "rich" | "html";
export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
renderTelegramMessage: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
replyTransport: TelegramReplyTransport<TReplyMarkup>;
recordOwnership?: TelegramReplyOwnershipRecorder["record"];
getAssistantRenderingMode?: () => TelegramAssistantRenderingMode;
sendRichMessage: (
body: TelegramSendRichMessageBody,
) => Promise<TelegramSentMessage>;
}
export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
sendTextReply: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: TelegramTextReplyOptions,
) => Promise<number | undefined>;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: TelegramReplyTargetOptions & { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: TelegramRenderMode,
replyMarkup: TReplyMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: TelegramRenderMode,
replyMarkup: TReplyMarkup,
options?: TelegramReplyTargetOptions,
) => Promise<number | undefined>;
}
export interface TelegramRenderedMessageDeliveryRuntime<
TReplyMarkup,
> extends TelegramRenderedMessageRuntime<TReplyMarkup> {
replyTransport: TelegramReplyTransport<TReplyMarkup>;
}
export interface TelegramRenderedMessageDeliveryRuntimeDeps<
TReplyMarkup,
> extends TelegramReplyDeliveryDeps<TReplyMarkup> {
renderTelegramMessage?: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
getAssistantRenderingMode?: () => TelegramAssistantRenderingMode;
sendRichMessage: (
body: TelegramSendRichMessageBody,
) => Promise<TelegramSentMessage>;
}
export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
deps: TelegramRenderedMessageDeliveryRuntimeDeps<TReplyMarkup>,
): TelegramRenderedMessageDeliveryRuntime<TReplyMarkup> {
const replyTransport = buildTelegramReplyTransport({
recordOwnership: deps.recordOwnership,
sendMessage: deps.sendMessage,
editMessage: deps.editMessage,
});
return {
replyTransport,
...createTelegramRenderedMessageRuntime({
renderTelegramMessage:
deps.renderTelegramMessage ?? renderTelegramMessage,
replyTransport,
recordOwnership: deps.recordOwnership,
getAssistantRenderingMode: deps.getAssistantRenderingMode,
sendRichMessage: deps.sendRichMessage,
}),
};
}
export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
deps: TelegramRenderedMessageRuntimeDeps<TReplyMarkup>,
): TelegramRenderedMessageRuntime<TReplyMarkup> {
return {
sendTextReply: async (chatId, replyToMessageId, text, options) => {
return sendTelegramPlainReply(
text,
{
renderTelegramMessage: deps.renderTelegramMessage,
sendRenderedChunks: (chunks, chunkOptions) =>
deps.replyTransport.sendRenderedChunks(chatId, chunks, {
target: chunkOptions?.target,
replyToMessageId:
chunkOptions?.replyToMessageId ?? replyToMessageId,
}),
},
options,
);
},
sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
const renderingMode = deps.getAssistantRenderingMode?.() ?? "rich";
if (renderingMode === "html") {
return deps.replyTransport.sendRenderedChunks(
chatId,
deps.renderTelegramMessage(markdown, { mode: "markdown" }),
{
replyMarkup: options?.replyMarkup,
target: options?.target,
replyToMessageId,
},
);
}
return sendTelegramNativeMarkdownReply(
chatId,
replyToMessageId,
markdown,
{
recordOwnership: deps.recordOwnership,
sendRichMessage: deps.sendRichMessage,
},
options,
);
},
editInteractiveMessage: async (
chatId,
messageId,
text,
mode,
replyMarkup,
) => {
await deps.replyTransport.editRenderedMessage(
chatId,
messageId,
deps.renderTelegramMessage(text, { mode }),
{ replyMarkup },
);
},
sendInteractiveMessage: async (
chatId,
text,
mode,
replyMarkup,
options,
) => {
return deps.replyTransport.sendRenderedChunks(
chatId,
deps.renderTelegramMessage(text, { mode }),
{
replyMarkup,
target: options?.target,
replyToMessageId: options?.replyToMessageId,
},
);
},
};
}
// --- Dedup-wrapped Reply Wrappers ---
/** Wrap a sendTextReply with reply dedup so only the first message
* in a turn carries reply metadata. */
export function dedupSendTextReply(
dedup: ReplyDedupRuntime,
inner: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: TelegramTextReplyOptions,
) => Promise<number | undefined>,
): (
chatId: number,
replyToMessageId: number,
text: string,
options?: TelegramTextReplyOptions,
) => Promise<number | undefined> {
return async (chatId, replyToMessageId, text, options) => {
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
? replyToMessageId
: undefined;
return inner(chatId, effectiveReplyTo, text, options);
};
}
/** Wrap a sendMarkdownReply with reply dedup. */
export function dedupSendMarkdownReply<TReplyMarkup = unknown>(
dedup: ReplyDedupRuntime,
inner: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>,
): (
chatId: number,
replyToMessageId: number,
markdown: string,
options?: { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined> {
return async (chatId, replyToMessageId, markdown, options) => {
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
? replyToMessageId
: undefined;
return inner(chatId, effectiveReplyTo, markdown, options);
};
}
/**
* Guest reply sender: answers guest queries with native Rich Markdown content.
* Guest queries use InlineQueryResult input_message_content rather than chat
* sendRichMessage, so this stays as a dedicated guest transport adapter.
*/
export function createGuestMarkdownReplySender(deps: {
answerGuestQuery: (
guestQueryId: string,
text?: string,
options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
) => Promise<void>;
}) {
return async (guestQueryId: string, markdown: string) => {
const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
await deps.answerGuestQuery(guestQueryId, undefined, {
richMessage: { markdown: richMarkdown, skip_entity_detection: true },
});
};
}
+2473
View File
File diff suppressed because it is too large. Load diff
+631
View File
@@ -0,0 +1,631 @@
/**
* Telegram bridge runtime-state helpers
* Zones: pi agent runtime state, telegram session, shared coordination
* Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
*/
const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 2500;
const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
export interface TelegramRuntimeQueueCounters {
nextQueuedTelegramItemOrder: number;
nextQueuedTelegramControlOrder: number;
nextPriorityReactionOrder: number;
}
export interface TelegramRuntimeLifecycleFlags {
activeTelegramToolExecutions: number;
telegramTurnDispatchPending: boolean;
compactionInProgress: boolean;
foldQueuedPromptsIntoHistory: boolean;
setupInProgress: boolean;
}
export interface TelegramBridgeRuntimeState
extends TelegramRuntimeQueueCounters, TelegramRuntimeLifecycleFlags {
abortHandler?: () => void;
typingInterval?: ReturnType<typeof setInterval>;
typingInFlight?: Promise<void>;
typingLoopDeps?: TelegramTypingLoopDeps;
typingLoopKey?: string;
}
export interface TelegramRuntimeQueuePort {
syncCounters: (counters: Partial<TelegramRuntimeQueueCounters>) => void;
allocateItemOrder: () => number;
allocateControlOrder: () => number;
getNextPriorityReactionOrder: () => number;
incrementNextPriorityReactionOrder: () => void;
}
export interface TelegramRuntimeLifecyclePort {
syncFlags: (flags: Partial<TelegramRuntimeLifecycleFlags>) => void;
getActiveToolExecutions: () => number;
setActiveToolExecutions: (count: number) => void;
resetActiveToolExecutions: () => void;
hasDispatchPending: () => boolean;
setDispatchPending: (pending: boolean) => void;
clearDispatchPending: () => void;
isCompactionInProgress: () => boolean;
setCompactionInProgress: (inProgress: boolean) => void;
shouldFoldQueuedPromptsIntoHistory: () => boolean;
setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
}
export interface TelegramRuntimeSetupPort {
isInProgress: () => boolean;
start: () => boolean;
finish: () => void;
}
export interface TelegramRuntimeAbortPort {
hasHandler: () => boolean;
setHandler: (abortHandler: () => void) => void;
clearHandler: () => void;
getHandler: () => (() => void) | undefined;
abortTurn: () => boolean;
}
export interface TelegramRuntimeTypingPort {
start: (deps: TelegramTypingLoopDeps) => boolean;
stop: () => boolean;
waitForIdle: () => Promise<void>;
}
export interface TelegramBridgeRuntime {
state: TelegramBridgeRuntimeState;
queue: TelegramRuntimeQueuePort;
lifecycle: TelegramRuntimeLifecyclePort;
setup: TelegramRuntimeSetupPort;
abort: TelegramRuntimeAbortPort;
typing: TelegramRuntimeTypingPort;
}
export function createTelegramBridgeRuntimeState(): TelegramBridgeRuntimeState {
return {
nextQueuedTelegramItemOrder: 0,
nextQueuedTelegramControlOrder: 0,
nextPriorityReactionOrder: 0,
activeTelegramToolExecutions: 0,
telegramTurnDispatchPending: false,
compactionInProgress: false,
foldQueuedPromptsIntoHistory: false,
setupInProgress: false,
};
}
export function createTelegramBridgeRuntime(
state = createTelegramBridgeRuntimeState(),
): TelegramBridgeRuntime {
return {
state,
queue: {
syncCounters: (counters) =>
syncTelegramQueueRuntimeCounters(state, counters),
allocateItemOrder: () => allocateTelegramQueueItemOrder(state),
allocateControlOrder: () => allocateTelegramQueueControlOrder(state),
getNextPriorityReactionOrder: () =>
getNextTelegramPriorityReactionOrder(state),
incrementNextPriorityReactionOrder: () =>
incrementNextTelegramPriorityReactionOrder(state),
},
lifecycle: {
syncFlags: (flags) => syncTelegramLifecycleRuntimeFlags(state, flags),
getActiveToolExecutions: () => getActiveTelegramToolExecutions(state),
setActiveToolExecutions: (count) =>
setActiveTelegramToolExecutions(state, count),
resetActiveToolExecutions: () => resetActiveTelegramToolExecutions(state),
hasDispatchPending: () => hasTelegramDispatchPending(state),
setDispatchPending: (pending) =>
setTelegramDispatchPending(state, pending),
clearDispatchPending: () => clearTelegramDispatchPending(state),
isCompactionInProgress: () => isTelegramCompactionInProgress(state),
setCompactionInProgress: (inProgress) =>
setTelegramCompactionInProgress(state, inProgress),
shouldFoldQueuedPromptsIntoHistory: () =>
shouldFoldQueuedPromptsIntoHistory(state),
setFoldQueuedPromptsIntoHistory: (fold) =>
setFoldQueuedPromptsIntoHistory(state, fold),
},
setup: {
isInProgress: () => isTelegramSetupInProgress(state),
start: () => startTelegramSetup(state),
finish: () => finishTelegramSetup(state),
},
abort: {
hasHandler: () => hasTelegramAbortHandler(state),
setHandler: (abortHandler) =>
setTelegramAbortHandler(state, abortHandler),
clearHandler: () => clearTelegramAbortHandler(state),
getHandler: () => getTelegramAbortHandler(state),
abortTurn: () => abortTelegramTurn(state),
},
typing: {
start: (deps) => startTelegramTypingLoop(state, deps),
stop: () => stopTelegramTypingLoop(state),
waitForIdle: () => waitForTelegramTypingLoopIdle(state),
},
};
}
export function syncTelegramQueueRuntimeCounters(
state: TelegramBridgeRuntimeState,
counters: Partial<TelegramRuntimeQueueCounters>,
): void {
if (counters.nextQueuedTelegramItemOrder !== undefined) {
state.nextQueuedTelegramItemOrder = counters.nextQueuedTelegramItemOrder;
}
if (counters.nextQueuedTelegramControlOrder !== undefined) {
state.nextQueuedTelegramControlOrder =
counters.nextQueuedTelegramControlOrder;
}
if (counters.nextPriorityReactionOrder !== undefined) {
state.nextPriorityReactionOrder = counters.nextPriorityReactionOrder;
}
}
export function allocateTelegramQueueItemOrder(
state: TelegramBridgeRuntimeState,
): number {
return state.nextQueuedTelegramItemOrder++;
}
export function allocateTelegramQueueControlOrder(
state: TelegramBridgeRuntimeState,
): number {
return state.nextQueuedTelegramControlOrder++;
}
export function getNextTelegramPriorityReactionOrder(
state: TelegramBridgeRuntimeState,
): number {
return state.nextPriorityReactionOrder;
}
export function incrementNextTelegramPriorityReactionOrder(
state: TelegramBridgeRuntimeState,
): void {
state.nextPriorityReactionOrder += 1;
}
export function syncTelegramLifecycleRuntimeFlags(
state: TelegramBridgeRuntimeState,
flags: Partial<TelegramRuntimeLifecycleFlags>,
): void {
if (flags.activeTelegramToolExecutions !== undefined) {
state.activeTelegramToolExecutions = flags.activeTelegramToolExecutions;
}
if (flags.telegramTurnDispatchPending !== undefined) {
state.telegramTurnDispatchPending = flags.telegramTurnDispatchPending;
}
if (flags.compactionInProgress !== undefined) {
state.compactionInProgress = flags.compactionInProgress;
}
if (flags.foldQueuedPromptsIntoHistory !== undefined) {
state.foldQueuedPromptsIntoHistory = flags.foldQueuedPromptsIntoHistory;
}
if (flags.setupInProgress !== undefined) {
state.setupInProgress = flags.setupInProgress;
}
}
export function getActiveTelegramToolExecutions(
state: TelegramBridgeRuntimeState,
): number {
return state.activeTelegramToolExecutions;
}
export function setActiveTelegramToolExecutions(
state: TelegramBridgeRuntimeState,
count: number,
): void {
state.activeTelegramToolExecutions = count;
}
export function resetActiveTelegramToolExecutions(
state: TelegramBridgeRuntimeState,
): void {
state.activeTelegramToolExecutions = 0;
}
export function hasTelegramDispatchPending(
state: TelegramBridgeRuntimeState,
): boolean {
return state.telegramTurnDispatchPending;
}
function setTelegramDispatchPending(
state: TelegramBridgeRuntimeState,
pending: boolean,
): void {
state.telegramTurnDispatchPending = pending;
}
export function clearTelegramDispatchPending(
state: TelegramBridgeRuntimeState,
): void {
state.telegramTurnDispatchPending = false;
}
export function isTelegramCompactionInProgress(
state: TelegramBridgeRuntimeState,
): boolean {
return state.compactionInProgress;
}
export function setTelegramCompactionInProgress(
state: TelegramBridgeRuntimeState,
inProgress: boolean,
): void {
state.compactionInProgress = inProgress;
}
export function shouldFoldQueuedPromptsIntoHistory(
state: TelegramBridgeRuntimeState,
): boolean {
return state.foldQueuedPromptsIntoHistory;
}
export function setFoldQueuedPromptsIntoHistory(
state: TelegramBridgeRuntimeState,
fold: boolean,
): void {
state.foldQueuedPromptsIntoHistory = fold;
}
export function isTelegramSetupInProgress(
state: TelegramBridgeRuntimeState,
): boolean {
return state.setupInProgress;
}
export function startTelegramSetup(state: TelegramBridgeRuntimeState): boolean {
if (state.setupInProgress) return false;
state.setupInProgress = true;
return true;
}
export function finishTelegramSetup(state: TelegramBridgeRuntimeState): void {
state.setupInProgress = false;
}
export function hasTelegramAbortHandler(
state: TelegramBridgeRuntimeState,
): boolean {
return !!state.abortHandler;
}
export function setTelegramAbortHandler(
state: TelegramBridgeRuntimeState,
abortHandler: () => void,
): void {
state.abortHandler = abortHandler;
}
export function clearTelegramAbortHandler(
state: TelegramBridgeRuntimeState,
): void {
state.abortHandler = undefined;
}
export function getTelegramAbortHandler(
state: TelegramBridgeRuntimeState,
): (() => void) | undefined {
return state.abortHandler;
}
export function abortTelegramTurn(state: TelegramBridgeRuntimeState): boolean {
if (!state.abortHandler) return false;
state.abortHandler();
return true;
}
export interface TelegramTypingLoopTarget {
chatId: number;
threadId?: number;
}
function getTelegramTypingLoopThreadParams(
target: TelegramTypingLoopTarget | undefined,
): { message_thread_id?: number } | undefined {
const threadId = target?.threadId;
return Number.isInteger(threadId)
? { message_thread_id: threadId }
: undefined;
}
export interface TelegramTypingLoopDeps {
chatId: number | undefined;
target?: TelegramTypingLoopTarget;
intervalMs: number;
sendTypingAction: (
chatId: number,
options?: { message_thread_id?: number },
) => Promise<unknown>;
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
}
export interface TelegramRuntimeEventRecorderPort {
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
function updateTelegramRuntimeStatusSafely<TContext>(
updateStatus: (ctx: TContext, error?: string) => void,
ctx: TContext,
options: {
error?: string;
category: string;
phase: string;
recordRuntimeEvent?: TelegramRuntimeEventRecorderPort["recordRuntimeEvent"];
},
): void {
try {
updateStatus(ctx, options.error);
} catch (statusError) {
options.recordRuntimeEvent?.(options.category, statusError, {
phase: options.phase,
});
}
}
export interface TelegramTypingLoopStarterDeps<
TContext,
> extends TelegramRuntimeEventRecorderPort {
typing: TelegramRuntimeTypingPort;
getDefaultChatId: () => number | undefined;
sendTypingAction: (
chatId: number,
options?: { message_thread_id?: number },
) => Promise<unknown>;
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
updateStatus: (ctx: TContext, error?: string) => void;
intervalMs?: number;
}
export function createTelegramTypingLoopStarter<TContext>(
deps: TelegramTypingLoopStarterDeps<TContext>,
): (
ctx: TContext,
chatId?: number,
options?: { target?: TelegramTypingLoopTarget },
) => void {
return (ctx, chatId, options) => {
deps.typing.start({
chatId: chatId ?? deps.getDefaultChatId(),
target: options?.target,
intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
sendTypingAction: async (targetChatId, actionOptions) => {
try {
await deps.sendTypingAction(targetChatId, actionOptions);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
error: message,
category: "typing",
phase: "status-update",
recordRuntimeEvent: deps.recordRuntimeEvent,
});
deps.recordRuntimeEvent?.("typing", error, {
chatId: targetChatId,
});
}
},
sendAggregateTypingAction: deps.sendAggregateTypingAction
? async (targetChatId) => {
try {
await deps.sendAggregateTypingAction?.(targetChatId);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
error: message,
category: "typing",
phase: "status-update",
recordRuntimeEvent: deps.recordRuntimeEvent,
});
deps.recordRuntimeEvent?.("typing", error, {
chatId: targetChatId,
aggregate: true,
});
}
}
: undefined,
});
};
}
function getTelegramTypingLoopKey(deps: TelegramTypingLoopDeps): string {
const threadId = deps.target?.threadId;
return `${deps.chatId ?? 0}:${Number.isInteger(threadId) ? threadId : "all"}`;
}
export function startTelegramTypingLoop(
state: TelegramBridgeRuntimeState,
deps: TelegramTypingLoopDeps,
): boolean {
if (deps.chatId === undefined || deps.chatId === 0) return false;
const previousKey = state.typingLoopKey;
const nextKey = getTelegramTypingLoopKey(deps);
state.typingLoopDeps = deps;
state.typingLoopKey = nextKey;
const sendTyping = (): void => {
const activeDeps = state.typingLoopDeps;
if (!activeDeps || activeDeps.chatId === undefined || activeDeps.chatId === 0)
return;
const targetChatId = activeDeps.chatId;
const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
const typing = Promise.resolve()
.then(async () => {
await activeDeps.sendTypingAction(targetChatId, threadParams);
if (threadParams?.message_thread_id !== undefined) {
await activeDeps.sendAggregateTypingAction?.(targetChatId);
}
})
.then(() => undefined)
.catch(() => undefined);
state.typingInFlight = typing;
void typing.finally(() => {
if (state.typingInFlight === typing) state.typingInFlight = undefined;
});
};
if (state.typingInterval) {
if (previousKey === nextKey) return false;
sendTyping();
return true;
}
sendTyping();
state.typingInterval = setInterval(sendTyping, deps.intervalMs);
state.typingInterval.unref?.();
return true;
}
export function stopTelegramTypingLoop(
state: TelegramBridgeRuntimeState,
): boolean {
if (!state.typingInterval) return false;
clearInterval(state.typingInterval);
state.typingInterval = undefined;
state.typingLoopDeps = undefined;
state.typingLoopKey = undefined;
return true;
}
export async function waitForTelegramTypingLoopIdle(
state: TelegramBridgeRuntimeState,
timeoutMs = TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS,
): Promise<void> {
const inFlight = state.typingInFlight;
if (!inFlight) return;
if (timeoutMs <= 0) {
await Promise.race([inFlight, Promise.resolve()]);
return;
}
await Promise.race([
inFlight,
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
}),
]);
}
export function createTelegramContextAbortHandlerSetter<
TContext extends { abort: () => void },
>(
abort: Pick<TelegramRuntimeAbortPort, "setHandler">,
): (ctx: TContext) => void {
return (ctx) => {
abort.setHandler(() => ctx.abort());
};
}
export interface TelegramAgentEndResetDeps {
abort: Pick<TelegramRuntimeAbortPort, "clearHandler">;
typing: Pick<TelegramRuntimeTypingPort, "stop">;
clearActiveTurn: () => void;
resetToolExecutions: () => void;
clearPendingModelSwitch: () => void;
clearDispatchPending: () => void;
}
export function createTelegramAgentEndResetter(
deps: TelegramAgentEndResetDeps,
): () => void {
return () => {
deps.abort.clearHandler();
deps.typing.stop();
deps.clearActiveTurn();
deps.resetToolExecutions();
deps.clearPendingModelSwitch();
deps.clearDispatchPending();
};
}
export interface TelegramPromptDispatchLifecycleDeps<
TContext,
> extends TelegramRuntimeEventRecorderPort {
lifecycle: Pick<
TelegramRuntimeLifecyclePort,
"setDispatchPending" | "clearDispatchPending"
>;
typing: Pick<TelegramRuntimeTypingPort, "stop">;
startTypingLoop: (
ctx: TContext,
chatId?: number,
options?: { target?: TelegramTypingLoopTarget },
) => void;
updateStatus: (ctx: TContext, error?: string) => void;
}
export interface TelegramPromptDispatchRuntimeDeps<
TContext,
> extends TelegramRuntimeEventRecorderPort {
lifecycle: TelegramPromptDispatchLifecycleDeps<TContext>["lifecycle"];
typing: TelegramRuntimeTypingPort;
getDefaultChatId: () => number | undefined;
sendTypingAction: (
chatId: number,
options?: { message_thread_id?: number },
) => Promise<unknown>;
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
updateStatus: (ctx: TContext, error?: string) => void;
intervalMs?: number;
}
export interface TelegramPromptDispatchRuntime<TContext> {
startTypingLoop: (
ctx: TContext,
chatId?: number,
options?: { target?: TelegramTypingLoopTarget },
) => void;
onPromptDispatchStart: (ctx: TContext, chatId?: number) => void;
onPromptDispatchFailure: (ctx: TContext, message: string) => void;
}
export function createTelegramPromptDispatchRuntime<TContext>(
deps: TelegramPromptDispatchRuntimeDeps<TContext>,
): TelegramPromptDispatchRuntime<TContext> {
const startTypingLoop = createTelegramTypingLoopStarter(deps);
return {
startTypingLoop,
...createTelegramPromptDispatchLifecycle({
lifecycle: deps.lifecycle,
typing: deps.typing,
startTypingLoop,
updateStatus: deps.updateStatus,
recordRuntimeEvent: deps.recordRuntimeEvent,
}),
};
}
export function createTelegramPromptDispatchLifecycle<TContext>(
deps: TelegramPromptDispatchLifecycleDeps<TContext>,
) {
return {
onPromptDispatchStart: (ctx: TContext, chatId?: number): void => {
deps.lifecycle.setDispatchPending(true);
deps.startTypingLoop(ctx, chatId);
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
category: "dispatch",
phase: "status-update",
recordRuntimeEvent: deps.recordRuntimeEvent,
});
},
onPromptDispatchFailure: (ctx: TContext, message: string): void => {
deps.lifecycle.clearDispatchPending();
deps.typing.stop();
deps.recordRuntimeEvent?.("dispatch", new Error(message));
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
error: `dispatch failed: ${message}`,
category: "dispatch",
phase: "status-update",
recordRuntimeEvent: deps.recordRuntimeEvent,
});
},
};
}
+757
View File
@@ -0,0 +1,757 @@
/**
* Telegram Extension Sections registry and callback routing
* Zones: telegram ui, extension platform, callback routing
* Owns section registration, global registry binding, token mapping, main-menu/settings row injection, and section callback dispatch
*/
import {
assertTelegramCallbackData,
type TelegramInlineKeyboardMarkup,
} from "./keyboard.ts";
const SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__";
// --- Core Types ---
/** @internal */
export type TelegramSectionId = string;
/** @internal */
export type TelegramSectionToken = string;
/** @internal */
export type TelegramSectionCallbackResult = "handled" | "pass";
export interface TelegramSectionView {
text: string;
/**
* Source format for extension-provided section content.
* Defaults to "html" for explicit Telegram UI markup; use "markdown"
* when a section naturally owns Markdown content, or "plain" for text.
*/
parseMode?: "markdown" | "html" | "plain";
replyMarkup?: TelegramInlineKeyboardMarkup;
}
export interface TelegramSectionSettingsRegistration {
label: string;
order?: number;
getLabel?: () => string;
open: (
ctx: TelegramSectionContext,
) => TelegramSectionView | Promise<TelegramSectionView>;
handleCallback?: (
ctx: TelegramSectionCallbackContext,
) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
}
export interface TelegramSectionRegistration {
id: TelegramSectionId;
label: string;
order?: number;
getLabel?: () => string;
render: (
ctx: TelegramSectionContext,
) => TelegramSectionView | Promise<TelegramSectionView>;
handleCallback?: (
ctx: TelegramSectionCallbackContext,
) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
settings?: TelegramSectionSettingsRegistration;
}
export interface TelegramSectionContext {
sectionId: string;
chatId: number;
messageId?: number;
answerCallback(text?: string): Promise<void>;
edit(view: TelegramSectionView): Promise<void>;
open(view: TelegramSectionView): Promise<void>;
enqueuePrompt(prompt: string): Promise<void>;
callbackData(action: string, payload?: string): string;
/** Delete the message that triggered this callback (dialog cleanup) */
deleteMessage(): Promise<void>;
}
export interface TelegramSectionCallbackContext {
sectionId: string;
chatId: number;
messageId?: number;
action: string;
payload: string;
answerCallback(text?: string): Promise<void>;
edit(view: TelegramSectionView): Promise<void>;
open(view: TelegramSectionView): Promise<void>;
enqueuePrompt(prompt: string): Promise<void>;
callbackData(action: string, payload?: string): string;
/** Delete the message that triggered this callback (dialog cleanup) */
deleteMessage(): Promise<void>;
}
/** @internal */
export interface RegisteredTelegramSection {
id: TelegramSectionId;
token: TelegramSectionToken;
label: string;
order: number;
registration: TelegramSectionRegistration;
}
/** @internal */
export interface TelegramSectionDiagnostic {
id: TelegramSectionId;
token: TelegramSectionToken;
label: string;
status: "active" | "error";
lastError?: string;
}
/** @internal */
export interface TelegramSectionRegistry {
register(section: TelegramSectionRegistration): () => void;
getSections(): RegisteredTelegramSection[];
getByToken(
token: TelegramSectionToken,
): RegisteredTelegramSection | undefined;
getDiagnostics(): TelegramSectionDiagnostic[];
recordError(
token: TelegramSectionToken,
message: string,
source?: string,
): void;
clearError(token: TelegramSectionToken, source?: string): void;
clear(): void;
}
/** @internal */
export interface TelegramSectionMainMenuRow {
text: string;
callback_data: string;
}
/** @internal */
export interface TelegramSectionSettingsRow {
label: string;
callback_data: string;
}
// --- Runtime Port Builders ---
/** @internal */
export interface TelegramSectionTarget {
chatId: number;
threadId?: number;
}
/** @internal */
export interface TelegramSectionRuntimeDeps {
answerCallbackQuery: (id: string, text?: string) => Promise<void>;
target?: TelegramSectionTarget;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramInlineKeyboardMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramInlineKeyboardMarkup,
options?: { target?: TelegramSectionTarget },
) => Promise<number | undefined>;
enqueuePrompt: (prompt: string) => Promise<void>;
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
}
function buildTelegramSectionContext(
sectionId: string,
token: TelegramSectionToken,
chatId: number,
messageId: number | undefined,
callbackQueryId: string | undefined,
deps: TelegramSectionRuntimeDeps,
backCallback = "menu:back",
backLabel = "⬆️ Main menu",
): TelegramSectionContext {
return {
sectionId,
chatId,
messageId,
answerCallback: (text) =>
callbackQueryId
? deps.answerCallbackQuery(callbackQueryId, text)
: Promise.resolve(),
edit: (view) =>
messageId !== undefined
? deps.editInteractiveMessage(
chatId,
messageId,
view.text,
view.parseMode ?? "html",
prependBackRow(view.replyMarkup, backCallback, backLabel),
)
: Promise.resolve(),
open: (view) =>
deps
.sendInteractiveMessage(
chatId,
view.text,
view.parseMode ?? "html",
view.replyMarkup ?? { inline_keyboard: [] },
deps.target ? { target: deps.target } : undefined,
)
.then(() => {}),
enqueuePrompt: deps.enqueuePrompt,
callbackData: (action, payload) =>
buildTelegramSectionCallbackData(token, action, payload),
deleteMessage: () =>
messageId !== undefined
? deps.deleteMessage(chatId, messageId)
: Promise.resolve(),
};
}
function buildTelegramSectionCallbackContext(
sectionId: string,
token: TelegramSectionToken,
chatId: number,
messageId: number | undefined,
action: string,
payload: string,
callbackQueryId: string,
deps: TelegramSectionRuntimeDeps,
backCallback = "menu:back",
backLabel = "⬆️ Back",
): TelegramSectionCallbackContext {
return {
sectionId,
chatId,
messageId,
action,
payload,
answerCallback: (text) => deps.answerCallbackQuery(callbackQueryId, text),
edit: (view) =>
messageId !== undefined
? deps.editInteractiveMessage(
chatId,
messageId,
view.text,
view.parseMode ?? "html",
prependBackRow(view.replyMarkup, backCallback, backLabel),
)
: Promise.resolve(),
open: (view) =>
deps
.sendInteractiveMessage(
chatId,
view.text,
view.parseMode ?? "html",
view.replyMarkup ?? { inline_keyboard: [] },
deps.target ? { target: deps.target } : undefined,
)
.then(() => {}),
enqueuePrompt: deps.enqueuePrompt,
callbackData: (action, payload) =>
buildTelegramSectionCallbackData(token, action, payload),
deleteMessage: () =>
messageId !== undefined
? deps.deleteMessage(chatId, messageId)
: Promise.resolve(),
};
}
// --- GlobalThis Bridge ---
/** @internal */
export function setGlobalTelegramSectionRegistry(
registry: TelegramSectionRegistry,
): void {
(globalThis as Record<string, unknown>)[SECTION_REGISTRY_KEY] = registry;
}
/** @internal */
export function createAndBindTelegramSectionRegistry(): TelegramSectionRegistry {
const registry = createTelegramExtensionSectionRegistry();
setGlobalTelegramSectionRegistry(registry);
return registry;
}
/**
* Register a Telegram Extension Section from any pi extension.
* Returns a disposer. Throws if no section registry is active.
*/
export function registerTelegramSection(
section: TelegramSectionRegistration,
): () => void {
const registry = (globalThis as Record<string, unknown>)[
SECTION_REGISTRY_KEY
] as TelegramSectionRegistry | undefined;
if (!registry) {
throw new Error(
"Telegram section registry not available. Is pi-telegram loaded and initialized?",
);
}
return registry.register(section);
}
/**
* Get current section diagnostics. Returns empty array when registry is absent.
* @internal
*/
export function getTelegramSectionDiagnostics(): TelegramSectionDiagnostic[] {
const registry = (globalThis as Record<string, unknown>)[
SECTION_REGISTRY_KEY
] as TelegramSectionRegistry | undefined;
return registry ? registry.getDiagnostics() : [];
}
// --- Registry ---
const BACK_NAV_ROW = {
text: "⬆️ Back",
} as const;
function sectionErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildTelegramSectionCallbackData(
token: TelegramSectionToken,
action: string,
payload?: string,
): string {
const data = payload
? `section:${token}:${action}:${payload}`
: `section:${token}:${action}`;
return assertTelegramCallbackData(data, "Telegram section callback_data");
}
function prependBackRow(
replyMarkup: TelegramInlineKeyboardMarkup | undefined,
backCallback: string,
backLabel?: string,
): TelegramInlineKeyboardMarkup {
const backRow = {
text: backLabel ?? BACK_NAV_ROW.text,
callback_data: backCallback,
};
if (!replyMarkup || replyMarkup.inline_keyboard.length === 0) {
return { inline_keyboard: [[backRow]] };
}
const firstRow = replyMarkup.inline_keyboard[0];
const firstIsBack =
firstRow.length === 1 && firstRow[0].callback_data === backCallback;
if (firstIsBack) return replyMarkup;
return {
inline_keyboard: [[backRow], ...replyMarkup.inline_keyboard],
};
}
/** @internal */
export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistry {
const sections = new Map<TelegramSectionToken, RegisteredTelegramSection>();
const errors = new Map<
TelegramSectionToken,
{ message: string; source: string }
>();
let nextToken = 0;
const register = (section: TelegramSectionRegistration): () => void => {
const duplicate = [...sections.values()].find((s) => s.id === section.id);
if (duplicate) {
throw new Error(`Telegram section id already registered: ${section.id}`);
}
const token = String(nextToken++);
const registered: RegisteredTelegramSection = {
id: section.id,
token,
label: section.label,
order: section.order ?? 0,
registration: section,
};
sections.set(token, registered);
return () => {
sections.delete(token);
errors.delete(token);
};
};
const getSections = (): RegisteredTelegramSection[] => {
return [...sections.values()].sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.id.localeCompare(b.id);
});
};
const getByToken = (
token: TelegramSectionToken,
): RegisteredTelegramSection | undefined => {
return sections.get(token);
};
const getDiagnostics = (): TelegramSectionDiagnostic[] => {
return getSections().map((s) => ({
id: s.id,
token: s.token,
label: s.label,
status: errors.has(s.token) ? "error" : "active",
lastError: errors.get(s.token)?.message,
}));
};
const recordError = (
token: TelegramSectionToken,
message: string,
source = "runtime",
): void => {
if (sections.has(token)) errors.set(token, { message, source });
};
const clearError = (token: TelegramSectionToken, source?: string): void => {
const current = errors.get(token);
if (!source || !current || current.source === source) errors.delete(token);
};
const clear = (): void => {
sections.clear();
errors.clear();
nextToken = 0;
};
return {
register,
getSections,
getByToken,
getDiagnostics,
recordError,
clearError,
clear,
};
}
/** @internal */
export function getTelegramExtensionSettingsRows(
registry: TelegramSectionRegistry,
): TelegramSectionSettingsRow[] {
const rows: TelegramSectionSettingsRow[] = [];
for (const section of registry
.getSections()
.filter((s) => s.registration.settings)
.sort((a, b) => {
const orderA = a.registration.settings!.order ?? 0;
const orderB = b.registration.settings!.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.id.localeCompare(b.id);
})) {
try {
rows.push({
label:
section.registration.settings!.getLabel?.() ??
section.registration.settings!.label,
callback_data: `section:${section.token}:settings:open`,
});
registry.clearError(section.token, "settings_label");
} catch (error) {
registry.recordError(
section.token,
sectionErrorMessage(error),
"settings_label",
);
}
}
return rows;
}
/** @internal */
export function getTelegramSectionMainMenuRows(
registry: TelegramSectionRegistry,
): TelegramSectionMainMenuRow[] {
const rows: TelegramSectionMainMenuRow[] = [];
for (const section of registry.getSections()) {
try {
rows.push({
text: section.registration.getLabel?.() ?? section.label,
callback_data: `section:${section.token}:open`,
});
registry.clearError(section.token, "main_label");
} catch (error) {
registry.recordError(
section.token,
sectionErrorMessage(error),
"main_label",
);
}
}
return rows;
}
/** @internal */
export function parseTelegramSectionCallback(
data: string,
): { token: string; action: string; payload: string } | undefined {
if (!data.startsWith("section:")) return undefined;
const rest = data.slice("section:".length);
const firstColon = rest.indexOf(":");
if (firstColon === -1) return undefined;
const token = rest.slice(0, firstColon);
const afterToken = rest.slice(firstColon + 1);
const secondColon = afterToken.indexOf(":");
if (secondColon === -1) {
return { token, action: afterToken, payload: "" };
}
return {
token,
action: afterToken.slice(0, secondColon),
payload: afterToken.slice(secondColon + 1),
};
}
/** @internal */
export interface TelegramSectionCallbackHandlerDeps {
answerCallbackQuery: (id: string, text?: string) => Promise<void>;
target?: TelegramSectionTarget;
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramInlineKeyboardMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "markdown" | "html" | "plain",
replyMarkup: TelegramInlineKeyboardMarkup,
options?: { target?: TelegramSectionTarget },
) => Promise<number | undefined>;
enqueuePrompt: (prompt: string) => Promise<void>;
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
}
export async function handleTelegramSectionOpen(
registry: TelegramSectionRegistry,
token: TelegramSectionToken,
chatId: number,
messageId: number,
callbackQueryId: string,
deps: TelegramSectionCallbackHandlerDeps,
): Promise<boolean> {
const section = registry.getByToken(token);
if (!section) {
await deps.answerCallbackQuery(
callbackQueryId,
"This section is no longer available.",
);
return true;
}
try {
const sectionCtx = buildTelegramSectionContext(
section.id,
token,
chatId,
messageId,
callbackQueryId,
deps,
);
const view = await section.registration.render(sectionCtx);
const viewWithBack = {
...view,
replyMarkup: prependBackRow(
view.replyMarkup,
"menu:back",
"⬆️ Main menu",
),
};
await deps.editInteractiveMessage(
chatId,
messageId,
viewWithBack.text,
viewWithBack.parseMode ?? "html",
viewWithBack.replyMarkup,
);
await deps.answerCallbackQuery(callbackQueryId);
registry.clearError(token, "render");
return true;
} catch (error) {
const message = sectionErrorMessage(error);
registry.recordError(token, message, "render");
await deps.answerCallbackQuery(
callbackQueryId,
`Section error: ${message}`,
);
return true;
}
}
export async function handleTelegramSectionCallback(
registry: TelegramSectionRegistry,
token: TelegramSectionToken,
action: string,
payload: string,
chatId: number,
messageId: number,
callbackQueryId: string,
deps: TelegramSectionCallbackHandlerDeps,
): Promise<boolean> {
const section = registry.getByToken(token);
if (!section) {
await deps.answerCallbackQuery(
callbackQueryId,
"This section is no longer available.",
);
return true;
}
// Try main handleCallback first, then settings handleCallback as fallback.
const mainHandler = section.registration.handleCallback;
const settingsHandler = section.registration.settings?.handleCallback;
const handler = mainHandler ?? settingsHandler;
if (!handler) {
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
try {
const ctx = buildTelegramSectionCallbackContext(
section.id,
token,
chatId,
messageId,
action,
payload,
callbackQueryId,
deps,
mainHandler ? `section:${token}:open` : "settings:list",
);
let result = await handler(ctx);
// Fallback: if main handler passed and settings handler exists, try settings
// with the correct navigation context (back → settings list).
if (result === "pass" && mainHandler && settingsHandler) {
const settingsCtx = buildTelegramSectionCallbackContext(
section.id,
token,
chatId,
messageId,
action,
payload,
callbackQueryId,
deps,
"settings:list",
);
result = await settingsHandler(settingsCtx);
}
if (result === "pass") {
await deps.answerCallbackQuery(callbackQueryId);
}
registry.clearError(token, "callback");
return true;
} catch (error) {
const message = sectionErrorMessage(error);
registry.recordError(token, message, "callback");
await deps.answerCallbackQuery(
callbackQueryId,
`Section error: ${message}`,
);
return true;
}
}
export async function handleTelegramSectionSettingsOpen(
registry: TelegramSectionRegistry,
token: TelegramSectionToken,
chatId: number,
messageId: number,
callbackQueryId: string,
deps: TelegramSectionCallbackHandlerDeps,
): Promise<boolean> {
const section = registry.getByToken(token);
if (!section || !section.registration.settings) {
await deps.answerCallbackQuery(
callbackQueryId,
"This section is no longer available.",
);
return true;
}
try {
const sectionCtx = buildTelegramSectionContext(
section.id,
token,
chatId,
messageId,
callbackQueryId,
deps,
"settings:list",
"⬆️ Back",
);
const view = await section.registration.settings.open(sectionCtx);
const viewWithBack = {
...view,
replyMarkup: prependBackRow(view.replyMarkup, "settings:list", "⬆️ Back"),
};
await deps.editInteractiveMessage(
chatId,
messageId,
viewWithBack.text,
viewWithBack.parseMode ?? "html",
viewWithBack.replyMarkup,
);
await deps.answerCallbackQuery(callbackQueryId);
registry.clearError(token, "settings_open");
return true;
} catch (error) {
const message = sectionErrorMessage(error);
registry.recordError(token, message, "settings_open");
await deps.answerCallbackQuery(
callbackQueryId,
`Section error: ${message}`,
);
return true;
}
}
export async function handleTelegramSectionSettingsCallback(
registry: TelegramSectionRegistry,
token: TelegramSectionToken,
action: string,
payload: string,
chatId: number,
messageId: number,
callbackQueryId: string,
deps: TelegramSectionCallbackHandlerDeps,
): Promise<boolean> {
const section = registry.getByToken(token);
if (!section || !section.registration.settings?.handleCallback) {
await deps.answerCallbackQuery(
callbackQueryId,
"This section is no longer available.",
);
return true;
}
try {
const ctx = buildTelegramSectionCallbackContext(
section.id,
token,
chatId,
messageId,
action,
payload,
callbackQueryId,
deps,
`settings:list`,
);
const result = await section.registration.settings.handleCallback(ctx);
if (result === "pass") {
await deps.answerCallbackQuery(callbackQueryId);
}
registry.clearError(token, "settings_callback");
return true;
} catch (error) {
const message = sectionErrorMessage(error);
registry.recordError(token, message, "settings_callback");
await deps.answerCallbackQuery(
callbackQueryId,
`Section error: ${message}`,
);
return true;
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Telegram setup prompt helpers
* Zones: pi agent command ui, telegram config
* Computes token-prefill defaults and prompt mode selection for /telegram-setup
*/
export interface TelegramSetupConfig {
botToken?: string;
botId?: number;
botUsername?: string;
allowedUserId?: number;
lastUpdateId?: number;
}
export interface TelegramBotTokenPromptSpec {
method: "input" | "editor";
value: string;
}
export interface TelegramSetupUser {
id: number;
username?: string;
}
export interface TelegramPollingStartResult {
ok: boolean;
message?: string;
}
export type TelegramSetupCompletion =
| { status: "success"; config: TelegramSetupConfig }
| { status: "cancelled" | "unavailable" | "busy" | "validation-failed" }
| { status: "polling-failed"; config: TelegramSetupConfig };
export interface TelegramSetupDeps {
hasUI: boolean;
env: NodeJS.ProcessEnv;
config: TelegramSetupConfig;
promptInput: (label: string, value: string) => Promise<string | undefined>;
promptEditor: (label: string, value: string) => Promise<string | undefined>;
getMe: (botToken: string) => Promise<{
ok: boolean;
result?: TelegramSetupUser;
description?: string;
}>;
persistConfig: (config: TelegramSetupConfig) => Promise<void>;
notify: (message: string, level: "info" | "error") => void;
startPolling: () => unknown | Promise<unknown>;
updateStatus: () => void;
}
export interface TelegramSetupPromptContext {
hasUI: boolean;
ui: {
input: (label: string, value: string) => Promise<string | undefined>;
editor: (label: string, value: string) => Promise<string | undefined>;
notify: (message: string, level: "info" | "error") => void;
};
}
export interface TelegramSetupGuard {
start: () => boolean;
finish: () => void;
}
export interface TelegramSetupPromptRuntimeDeps<
TContext extends TelegramSetupPromptContext,
> {
env?: NodeJS.ProcessEnv;
getConfig: () => TelegramSetupConfig;
setConfig: (config: TelegramSetupConfig) => void;
setupGuard: TelegramSetupGuard;
getMe: TelegramSetupDeps["getMe"];
persistConfig: (config: TelegramSetupConfig) => Promise<void>;
startPolling: (ctx: TContext) => unknown | Promise<unknown>;
updateStatus: (ctx: TContext) => void;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export const TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER = "123456:ABCDEF...";
const TELEGRAM_BOT_TOKEN_ENV_VARS = [
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_BOT_KEY",
"TELEGRAM_TOKEN",
"TELEGRAM_KEY",
] as const;
function isTelegramPollingStartResult(
value: unknown,
): value is TelegramPollingStartResult {
return (
!!value &&
typeof value === "object" &&
typeof (value as { ok?: unknown }).ok === "boolean"
);
}
export function getTelegramBotTokenInputDefault(
env: NodeJS.ProcessEnv = process.env,
configToken?: string,
): string {
const trimmedConfigToken = configToken?.trim();
if (trimmedConfigToken) return trimmedConfigToken;
for (const key of TELEGRAM_BOT_TOKEN_ENV_VARS) {
const value = env[key]?.trim();
if (value) return value;
}
return TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER;
}
export function getTelegramBotTokenPromptSpec(
env: NodeJS.ProcessEnv = process.env,
configToken?: string,
): TelegramBotTokenPromptSpec {
const value = getTelegramBotTokenInputDefault(env, configToken);
return {
method: value === TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER ? "input" : "editor",
value,
};
}
export async function runTelegramSetup(
deps: TelegramSetupDeps,
): Promise<TelegramSetupCompletion> {
if (!deps.hasUI) return { status: "unavailable" };
const tokenPrompt = getTelegramBotTokenPromptSpec(
deps.env,
deps.config.botToken,
);
const token =
tokenPrompt.method === "editor"
? await deps.promptEditor("Telegram bot token", tokenPrompt.value)
: await deps.promptInput("Telegram bot token", tokenPrompt.value);
if (!token) return { status: "cancelled" };
const nextConfig: TelegramSetupConfig = {
...deps.config,
botToken: token.trim(),
};
let data: Awaited<ReturnType<TelegramSetupDeps["getMe"]>>;
try {
data = await deps.getMe(nextConfig.botToken ?? "");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.notify(`Telegram API check failed: ${message}`, "error");
return { status: "validation-failed" };
}
if (!data.ok || !data.result) {
deps.notify(data.description || "Invalid Telegram bot token", "error");
return { status: "validation-failed" };
}
nextConfig.botId = data.result.id;
nextConfig.botUsername = data.result.username;
await deps.persistConfig(nextConfig);
deps.notify(
`Telegram bot connected: @${nextConfig.botUsername ?? "unknown"}`,
"info",
);
deps.notify(
"Send /start to your bot in Telegram to pair this extension with your account.",
"info",
);
let startResult: unknown;
try {
startResult = await deps.startPolling();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.notify(`Telegram polling failed: ${message}`, "error");
deps.updateStatus();
return { status: "polling-failed", config: nextConfig };
}
if (isTelegramPollingStartResult(startResult) && startResult.message) {
deps.notify(startResult.message, startResult.ok ? "info" : "error");
}
deps.updateStatus();
if (isTelegramPollingStartResult(startResult) && !startResult.ok) {
return { status: "polling-failed", config: nextConfig };
}
return { status: "success", config: nextConfig };
}
export function createTelegramSetupPromptRuntime<
TContext extends TelegramSetupPromptContext,
>(deps: TelegramSetupPromptRuntimeDeps<TContext>) {
return async (ctx: TContext): Promise<TelegramSetupCompletion> => {
if (!ctx.hasUI) return { status: "unavailable" };
if (!deps.setupGuard.start()) return { status: "busy" };
try {
return await runTelegramSetup({
hasUI: ctx.hasUI,
env: deps.env ?? process.env,
config: deps.getConfig(),
promptInput: (label, value) => ctx.ui.input(label, value),
promptEditor: (label, value) => ctx.ui.editor(label, value),
getMe: deps.getMe,
persistConfig: async (config) => {
const previousConfig = deps.getConfig();
deps.setConfig(config);
try {
await deps.persistConfig(config);
} catch (error) {
deps.setConfig(previousConfig);
throw error;
}
},
notify: (message, level) => ctx.ui.notify(message, level),
startPolling: () => deps.startPolling(ctx),
updateStatus: () => deps.updateStatus(ctx),
});
} catch (error) {
deps.recordRuntimeEvent?.("setup", error);
throw error;
} finally {
deps.setupGuard.finish();
}
};
}
+1297
View File
File diff suppressed because it is too large. Load diff
+696
View File
@@ -0,0 +1,696 @@
/**
* Telegram synchronization helpers
* Zones: Telegram bot reality mirror, demand-driven reconciliation, status diagnostics
* Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
*/
import type { TelegramTarget } from "./target.ts";
import * as ThreadReconciler from "./thread-reconciler.ts";
import {
getTelegramTargetFromApiBody,
isTelegramTopicTargetStaleError,
provisionOwnBusTopic,
type TelegramOwnTopicProvisionResult,
type TelegramTopicTargetStore,
} from "./threads.ts";
export interface TelegramTopicLifecycleSyncUpdate<TMessage = unknown> {
kind: "created" | "closed" | "reopened";
target: TelegramTarget & { threadId: number };
message: TMessage;
}
export interface TelegramLeaderThreadSyncDeps {
getAllowedUserId: () => number | undefined;
instanceId: string;
cwd?: string;
telegramProfile?: string;
forceFreshUnnamed?: boolean;
getNowMs?: () => number;
getRandom?: () => number;
getCurrentLeaderEpoch?: () => number | string | undefined;
getThreadReconciliationMachineState?: () =>
ThreadReconciler.ThreadReconciliationMachineState | undefined;
recordThreadReconciliationPlan?: (
plan: ThreadReconciler.ThreadReconciliationPlan,
) => void;
topicTargetStore: TelegramTopicTargetStore;
callApi: <TResponse>(
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
recordEvent: (
category: string,
message: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramTopicLifecycleSyncDeps {
topicTargetStore: Pick<
TelegramTopicTargetStore,
| "load"
| "list"
| "listReservations"
| "listPendingProvisions"
| "markStaleByTarget"
| "markActiveByTarget"
| "removePendingProvision"
| "persist"
>;
isBusEnabled: () => boolean;
callApi: <TResponse>(
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
isTopicProvisioningActive?: () => boolean;
getCurrentLeaderEpoch?: () => number | string | undefined;
getThreadReconciliationMachineState?: () =>
ThreadReconciler.ThreadReconciliationMachineState | undefined;
recordThreadReconciliationPlan?: (
plan: ThreadReconciler.ThreadReconciliationPlan,
) => void;
recordEvent?: (
category: string,
message: unknown,
details?: Record<string, unknown>,
) => void;
}
export type TelegramTopicLifecycleSyncHandler<TMessage = unknown> = (
lifecycle: TelegramTopicLifecycleSyncUpdate<TMessage>,
) => Promise<void>;
export interface TelegramObservedTopicLifecycleSyncDeps<
TSyncState,
> extends TelegramTopicLifecycleSyncDeps {
getSyncState: () => TSyncState;
setSyncState: (state: TSyncState) => void;
getNowMs?: () => number;
}
export interface TelegramLeaderHealthRuntimeDeps<TSyncState> {
getNowMs?: () => number;
intervalMs?: number;
callGetMe: () => Promise<unknown>;
getSyncState: () => TSyncState;
setSyncState: (state: TSyncState) => void;
recordEvent: (
category: string,
message: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramLeaderHealthRuntime {
start: () => void;
stop: () => void;
}
export interface TelegramManualThreadDisconnectDeps<TSyncState> {
instanceId: string;
getCurrentThreadRecord: () =>
| { target: TelegramTarget; instanceId?: string; owner?: { kind?: string } }
| undefined;
topicTargetStore: Pick<
TelegramTopicTargetStore,
"markOfflineByInstanceId" | "persist"
>;
callApi: <TResponse>(
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
getLeaderTarget: () => TelegramTarget | undefined;
getCurrentLeaderEpoch?: () => number | string | undefined;
clearLeaderTarget: () => void;
getSyncState: () => TSyncState;
setSyncState: (state: TSyncState) => void;
stopPolling: () => Promise<string>;
recordRuntimeEvent: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
getNowMs?: () => number;
}
export function markTelegramConfigSyncChange<
TSyncState extends TelegramSyncState,
>(state: TSyncState, action: string, options?: { nowMs?: number }): TSyncState {
const nowMs = options?.nowMs ?? Date.now();
let nextState = markTelegramSyncSliceFresh(state, "pairing", {
nowMs,
action,
}) as TSyncState;
nextState = markTelegramSyncSliceFresh(nextState, "allowed-user", {
nowMs,
action,
}) as TSyncState;
nextState = markTelegramSyncSliceFresh(nextState, "bot-identity", {
nowMs,
action,
}) as TSyncState;
return nextState;
}
export function createTelegramManualThreadDisconnectHandler<
TSyncState extends TelegramSyncState,
>(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
return async () => {
const currentRecord = deps.getCurrentThreadRecord();
if (currentRecord?.target.threadId) {
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
if (!isManualFollower) {
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
const stillOwnsLeaderEpoch = () =>
!deps.getCurrentLeaderEpoch ||
(leaderEpoch !== undefined &&
deps.getCurrentLeaderEpoch() === leaderEpoch);
await ThreadReconciler.applyThreadReconciliationPlan(
ThreadReconciler.planDisconnectedInstanceThreadCleanup({
target: currentRecord.target as TelegramTarget & {
threadId: number;
},
instanceId: deps.instanceId,
leaderEpoch,
}),
{
callApi(method, body) {
return deps.callApi(method, body);
},
persist() {
return deps.topicTargetStore.persist();
},
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
recordRuntimeEvent: deps.recordRuntimeEvent,
},
);
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
const offlineChanged =
deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0;
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
if (offlineChanged) {
await deps.topicTargetStore.persist();
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
}
}
const leaderTarget = deps.getLeaderTarget();
if (
leaderTarget?.chatId === currentRecord.target.chatId &&
leaderTarget.threadId === currentRecord.target.threadId
) {
deps.clearLeaderTarget();
}
deps.setSyncState(
markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
nowMs: (deps.getNowMs ?? Date.now)(),
action: "manual-disconnect",
}) as TSyncState,
);
}
return deps.stopPolling();
};
}
export function createTelegramLeaderHealthRuntime<
TSyncState extends TelegramSyncState,
>(
deps: TelegramLeaderHealthRuntimeDeps<TSyncState>,
): TelegramLeaderHealthRuntime {
const intervalMs = deps.intervalMs ?? 60_000;
const getNowMs = deps.getNowMs ?? Date.now;
let interval: ReturnType<typeof setInterval> | undefined;
const markFresh = (): void => {
let state = markTelegramSyncSliceFresh(
deps.getSyncState(),
"transport-health",
{ nowMs: getNowMs(), action: "leader-health-tick" },
) as TSyncState;
state = markTelegramSyncSliceFresh(state, "bot-identity", {
nowMs: getNowMs(),
action: "leader-health-tick",
}) as TSyncState;
deps.setSyncState(state);
};
const markSuspect = (error: unknown): void => {
deps.setSyncState(
markTelegramSyncSliceSuspect(deps.getSyncState(), "transport-health", {
nowMs: getNowMs(),
reason: String(error),
action: "leader-health-tick",
}) as TSyncState,
);
deps.recordEvent("telegram", error, { phase: "leader-health-tick" });
};
const stop = (): void => {
if (!interval) return;
clearInterval(interval);
interval = undefined;
};
return {
start() {
stop();
interval = setInterval(() => {
void deps.callGetMe().then(markFresh).catch(markSuspect);
}, intervalMs);
interval.unref?.();
},
stop,
};
}
export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
topicTargetStore: Pick<
TelegramTopicTargetStore,
"load" | "markStaleByTarget" | "persist"
>;
getSyncState: () => TSyncState;
setSyncState: (state: TSyncState) => void;
recordEvent: (
category: string,
message: unknown,
details?: Record<string, unknown>,
) => void;
getNowMs?: () => number;
}
export async function recoverStaleTelegramTopicApiError<
TSyncState extends TelegramSyncState,
>(
apiBody: unknown,
error: unknown,
deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
): Promise<boolean> {
const target = getTelegramTargetFromApiBody(apiBody);
if (!target || !isTelegramTopicTargetStaleError(error)) return false;
await deps.topicTargetStore.load();
if (
!deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error))
) {
return false;
}
const nowMs = (deps.getNowMs ?? Date.now)();
let state = markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
nowMs,
reason: "stale-api-error",
action: "topic-target-stale",
}) as TSyncState;
state = markTelegramSyncSliceSuspect(state, "transport-health", {
nowMs,
reason: "stale-api-error",
action: "topic-target-stale",
}) as TSyncState;
deps.setSyncState(state);
await deps.topicTargetStore.persist();
deps.recordEvent("bus", error, {
phase: "topic-target-stale",
chatId: target.chatId,
threadId: target.threadId,
});
return true;
}
export async function ensureTelegramLeaderThreadBinding(
deps: TelegramLeaderThreadSyncDeps,
): Promise<TelegramOwnTopicProvisionResult | undefined> {
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
const assertLeaderEpoch = (phase: string): void => {
if (
deps.getCurrentLeaderEpoch &&
(leaderEpoch === undefined ||
deps.getCurrentLeaderEpoch() !== leaderEpoch)
) {
throw new Error(
`Telegram leader thread binding lost ownership (${phase}).`,
);
}
};
assertLeaderEpoch("start");
await deps.topicTargetStore.load();
assertLeaderEpoch("after-load");
const priorTargets = deps.topicTargetStore.list().filter((record) => {
return (
record.instanceId === deps.instanceId &&
(record.status === "active" || record.status === "starting")
);
});
// Short-circuit: when the instance already has an active thread and we are not
// force-freshing, reuse it without re-provisioning. A thread belongs to the
// live instance binding, not to one transient Pi session lifecycle.
if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
const record = priorTargets[0];
deps.recordEvent(
"telegram",
"Leader thread preserved after session lifecycle change",
{
phase: "leader-thread-reused",
instanceId: deps.instanceId,
chatId: record.target.chatId,
threadId: record.target.threadId,
slot: record.slot,
},
);
assertLeaderEpoch("before-reuse");
return {
target: record.target,
slot: record.slot ?? "A",
...(record.threadName ? { threadName: record.threadName } : {}),
reused: true,
};
}
let forcedUnnamedStale = false;
if (deps.forceFreshUnnamed) {
for (const record of priorTargets) {
const isLeaderOwned =
record.owner?.kind === "leader" ||
(!record.owner &&
(record.profileKey.startsWith("cwd:") ||
record.profileKey.startsWith("leader:")));
if (!isLeaderOwned) continue;
if (record.threadName) continue;
forcedUnnamedStale =
deps.topicTargetStore.markStaleByTarget(record.target) ||
forcedUnnamedStale;
deps.recordEvent("telegram", "Unnamed leader thread binding refreshed", {
phase: "leader-thread-force-fresh-unnamed",
instanceId: deps.instanceId,
chatId: record.target.chatId,
threadId: record.target.threadId,
slot: record.slot,
});
}
if (forcedUnnamedStale) await deps.topicTargetStore.persist();
}
assertLeaderEpoch("before-provision");
const ownTarget = await provisionOwnBusTopic({
getAllowedUserId: deps.getAllowedUserId,
instanceId: deps.instanceId,
cwd: deps.cwd,
telegramProfile: deps.telegramProfile,
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
getThreadReconciliationMachineState:
deps.getThreadReconciliationMachineState,
recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
store: deps.topicTargetStore,
callApi: deps.callApi,
getNowMs: deps.getNowMs,
getRandom: deps.getRandom,
recordEvent: deps.recordEvent,
});
assertLeaderEpoch("after-provision");
if (!ownTarget) return undefined;
const replacementPlan = ThreadReconciler.planThreadReconciliation({
nowMs: Date.now(),
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
previousState: deps.getThreadReconciliationMachineState?.(),
records: priorTargets,
pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
replacedBindings: [
{
instanceId: deps.instanceId,
replacementTarget: ownTarget.target,
},
],
});
deps.recordThreadReconciliationPlan?.(replacementPlan);
await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
callApi: deps.callApi,
markStaleByTarget: (target, syncStatus, lastSyncError) =>
deps.topicTargetStore.markStaleByTarget(
target,
syncStatus,
lastSyncError,
),
persist: () => deps.topicTargetStore.persist(),
removePendingProvisionById: (id) =>
deps.topicTargetStore.removePendingProvision(id),
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
recordRuntimeEvent: deps.recordEvent,
});
assertLeaderEpoch("before-final-persist");
await deps.topicTargetStore.persist();
assertLeaderEpoch("after-final-persist");
return ownTarget;
}
export const TELEGRAM_SYNC_SLICES = [
"bot-identity",
"bot-capabilities",
"pairing",
"allowed-user",
"topic-capability",
"topic-state",
"target-bindings",
"reservations",
"transport-health",
] as const;
export type TelegramSyncSlice = (typeof TELEGRAM_SYNC_SLICES)[number];
export type TelegramSyncTrigger =
| "startup"
| "reload"
| "topic-lifecycle"
| "stale-api-error"
| "setup-change"
| "pairing-change"
| "follower-register"
| "follower-prune"
| "status-request"
| "leader-health-tick"
| "ordinary-message"
| "ordinary-send";
export interface TelegramSyncSliceState {
status: "fresh" | "suspect" | "unknown";
updatedAtMs?: number;
suspectAtMs?: number;
reason?: string;
lastReconcileAction?: string;
}
export type TelegramSyncState = Partial<
Record<TelegramSyncSlice, TelegramSyncSliceState>
>;
export function createUnknownTelegramSyncState(): TelegramSyncState {
return Object.fromEntries(
TELEGRAM_SYNC_SLICES.map((slice) => [slice, { status: "unknown" }]),
) as TelegramSyncState;
}
export interface TelegramSyncStateRuntime {
getState(): TelegramSyncState;
setState(state: TelegramSyncState): void;
markConfigChange(action: string): void;
markSliceFresh(
slice: TelegramSyncSlice,
options: { nowMs: number; action: string },
): void;
}
export function createTelegramSyncStateRuntime(
initialState = createUnknownTelegramSyncState(),
): TelegramSyncStateRuntime {
let state = initialState;
return {
getState: () => state,
setState(nextState) {
state = nextState;
},
markConfigChange(action) {
state = markTelegramConfigSyncChange(state, action);
},
markSliceFresh(slice, options) {
state = markTelegramSyncSliceFresh(state, slice, options);
},
};
}
export interface TelegramProvisioningActivityRuntime {
isActive(): boolean;
start(): void;
end(): void;
}
export function createTelegramProvisioningActivityRuntime(): TelegramProvisioningActivityRuntime {
let activeCount = 0;
return {
isActive: () => activeCount > 0,
start() {
activeCount += 1;
},
end() {
activeCount = Math.max(0, activeCount - 1);
},
};
}
const RECONCILE_TRIGGERS = new Set<TelegramSyncTrigger>([
"startup",
"reload",
"topic-lifecycle",
"stale-api-error",
"setup-change",
"pairing-change",
"follower-register",
"follower-prune",
"status-request",
"leader-health-tick",
]);
export function shouldReconcileTelegramSync(
trigger: TelegramSyncTrigger,
): boolean {
return RECONCILE_TRIGGERS.has(trigger);
}
export function markTelegramSyncSliceSuspect(
state: TelegramSyncState,
slice: TelegramSyncSlice,
input: {
reason: string;
nowMs: number;
action?: string;
},
): TelegramSyncState {
return {
...state,
[slice]: {
...(state[slice] ?? { status: "unknown" }),
status: "suspect",
suspectAtMs: input.nowMs,
reason: input.reason,
lastReconcileAction: input.action,
},
};
}
export function markTelegramSyncSliceFresh(
state: TelegramSyncState,
slice: TelegramSyncSlice,
input: {
nowMs: number;
action: string;
},
): TelegramSyncState {
return {
...state,
[slice]: {
status: "fresh",
updatedAtMs: input.nowMs,
lastReconcileAction: input.action,
},
};
}
export function createTelegramObservedTopicLifecycleSyncHandler<
TMessage = unknown,
TSyncState extends TelegramSyncState = TelegramSyncState,
>(
deps: TelegramObservedTopicLifecycleSyncDeps<TSyncState>,
): TelegramTopicLifecycleSyncHandler<TMessage> {
const syncTopicLifecycle =
createTelegramTopicLifecycleSyncHandler<TMessage>(deps);
return async (lifecycle) => {
const nowMs = deps.getNowMs ?? Date.now;
deps.setSyncState(
markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
nowMs: nowMs(),
reason: `topic-${lifecycle.kind}`,
action: "topic-lifecycle",
}) as TSyncState,
);
await syncTopicLifecycle(lifecycle);
deps.setSyncState(
markTelegramSyncSliceFresh(deps.getSyncState(), "topic-state", {
nowMs: nowMs(),
action: "topic-lifecycle",
}) as TSyncState,
);
};
}
export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(
deps: TelegramTopicLifecycleSyncDeps,
): TelegramTopicLifecycleSyncHandler<TMessage> {
return async (lifecycle) => {
await deps.topicTargetStore.load();
const nowMs = Date.now();
const plan = ThreadReconciler.planThreadReconciliation({
nowMs,
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
previousState: deps.getThreadReconciliationMachineState?.(),
records: deps.topicTargetStore.list(),
reservations: deps.topicTargetStore.listReservations(),
pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
observations: [
{
target: lifecycle.target,
syncStatus: lifecycle.kind === "closed" ? "closed" : "open",
observedAtMs: nowMs,
},
],
});
deps.recordThreadReconciliationPlan?.(plan);
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
markActiveByTarget: (target) =>
deps.topicTargetStore.markActiveByTarget(target),
markStaleByTarget: (target, syncStatus, lastSyncError) =>
deps.topicTargetStore.markStaleByTarget(
target,
syncStatus,
lastSyncError,
),
persist: () => deps.topicTargetStore.persist(),
removePendingProvisionById: (id) =>
deps.topicTargetStore.removePendingProvision(id),
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
recordRuntimeEvent: deps.recordEvent,
});
const changed = result.changed;
if (lifecycle.kind === "created" && deps.isBusEnabled()) {
const target = lifecycle.target;
const isKnownInRecords = deps.topicTargetStore.list().some((record) => {
return (
record.target.chatId === target.chatId &&
record.target.threadId === target.threadId
);
});
const isKnownInReservations = deps.topicTargetStore
.listReservations()
.some((reservation) => {
return (
reservation.target.chatId === target.chatId &&
reservation.target.threadId === target.threadId
);
});
if (!isKnownInRecords && !isKnownInReservations) {
deps.recordEvent?.(
"telegram",
deps.isTopicProvisioningActive?.()
? "Telegram unknown topic creation observed during provisioning"
: "Telegram unknown topic creation observed",
{
phase: deps.isTopicProvisioningActive?.()
? "topic-lifecycle-provisioning-skip"
: "topic-lifecycle-unknown-created-observed",
chatId: target.chatId,
threadId: target.threadId,
},
);
}
}
deps.recordEvent?.("telegram", "Telegram topic lifecycle update", {
phase: "topic-lifecycle",
lifecycle: lifecycle.kind,
chatId: lifecycle.target.chatId,
threadId: lifecycle.target.threadId,
changed,
});
};
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Telegram transport destination value helpers
* Zones: Bot API transport, routing, replies/previews, ownership, multi-instance bus
* Owns the minimal `{ chatId, threadId? }` address shape shared by classic private chats
* and Telegram UI threads mapped through Bot API `message_thread_id`.
*/
export interface TelegramTarget {
chatId: number;
threadId?: number;
}
const PRIVATE_TARGET_THREAD_KEY = "private";
export function createTelegramPrivateTarget(chatId: number): TelegramTarget {
return { chatId };
}
export function createTelegramThreadTarget(
chatId: number,
threadId: number,
): TelegramTarget {
return { chatId, threadId };
}
export function getTelegramTargetKey(target: TelegramTarget): string {
return `${target.chatId}:${target.threadId ?? PRIVATE_TARGET_THREAD_KEY}`;
}
export function isTelegramThreadTarget(
target: TelegramTarget,
): target is TelegramTarget & { threadId: number } {
return Number.isInteger(target.threadId);
}
export function areTelegramTargetsEqual(
left: TelegramTarget,
right: TelegramTarget,
): boolean {
return left.chatId === right.chatId && left.threadId === right.threadId;
}
export function getTelegramTargetThreadParams(
target: TelegramTarget,
): { message_thread_id?: number } {
return isTelegramThreadTarget(target)
? { message_thread_id: target.threadId }
: {};
}
+1509
View File
File diff suppressed because it is too large. Load diff
+266
View File
@@ -0,0 +1,266 @@
/**
* Telegram text-group coalescing helpers
* Zones: telegram inbound, queue admission, split-message recovery
* Owns conservative delayed grouping for Telegram text messages that look like automatic long-message splits
*/
const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 1000;
const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 12;
export interface TelegramTextGroupMessage {
message_id: number;
media_group_id?: string;
chat: { id: number };
message_thread_id?: number;
from?: { id: number; is_bot?: boolean };
text?: string;
caption?: string;
}
export interface TelegramTextGroupState<TMessage, TContext = unknown> {
messages: TMessage[];
context?: TContext;
flushTimer?: ReturnType<typeof setTimeout>;
dispatching?: boolean;
suspended?: boolean;
reschedule?: () => void;
}
export interface TelegramTextGroupController<TMessage, TContext = unknown> {
queueMessage: (options: {
message: TMessage;
context: TContext;
dispatchMessages: (
messages: TMessage[],
ctx: TContext,
) => unknown | Promise<unknown>;
}) => boolean;
suspend: () => void;
resume: (context: TContext) => void;
clear: () => void;
}
export interface TelegramTextGroupControllerOptions {
debounceMs?: number;
minSplitLength?: number;
setTimer?: (
callback: () => void,
ms: number,
) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
}
export interface TelegramTextGroupDispatchRuntime<
TMessage extends TelegramTextGroupMessage,
TContext,
> {
handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
}
export interface TelegramGroupedInputClearerDeps {
clearMediaGroups: () => void;
clearTextGroups: () => void;
}
function extractTelegramTextGroupText(
message: TelegramTextGroupMessage,
): string {
return typeof message.text === "string" ? message.text : "";
}
function isTelegramTextGroupCommand(text: string): boolean {
return text.trimStart().startsWith("/");
}
function getTelegramTextGroupKey(
message: TelegramTextGroupMessage,
): string | undefined {
if (message.media_group_id) return undefined;
if (!message.from || message.from.is_bot) return undefined;
if (typeof message.text !== "string") return undefined;
const threadKey =
typeof message.message_thread_id === "number"
? `thread:${message.message_thread_id}`
: "private";
return `${message.chat.id}:${threadKey}:${message.from.id}`;
}
function canStartTelegramTextGroup(
message: TelegramTextGroupMessage,
minSplitLength: number,
): boolean {
const text = extractTelegramTextGroupText(message);
return text.length >= minSplitLength && !isTelegramTextGroupCommand(text);
}
function canAppendTelegramTextGroupMessage<
TMessage extends TelegramTextGroupMessage,
>(
state: TelegramTextGroupState<TMessage, unknown>,
message: TMessage,
): boolean {
const text = extractTelegramTextGroupText(message);
const previous = state.messages.at(-1);
return (
!!previous &&
message.message_id > previous.message_id &&
message.message_id <=
previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
text.length > 0 &&
!isTelegramTextGroupCommand(text)
);
}
export function queueTelegramTextGroupMessage<
TMessage extends TelegramTextGroupMessage,
TContext = unknown,
>(options: {
message: TMessage;
context: TContext;
groups: Map<string, TelegramTextGroupState<TMessage, TContext>>;
debounceMs: number;
minSplitLength: number;
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
dispatchMessages: (
messages: TMessage[],
ctx: TContext,
) => unknown | Promise<unknown>;
}): boolean {
const key = getTelegramTextGroupKey(options.message);
if (!key) return false;
const existing = options.groups.get(key);
if (
!existing &&
!canStartTelegramTextGroup(options.message, options.minSplitLength)
)
return false;
if (existing && !canAppendTelegramTextGroupMessage(existing, options.message))
return false;
const state = existing ?? { messages: [] };
state.messages.push(options.message);
state.context = options.context;
const scheduleDispatch = (): void => {
if (state.suspended) return;
state.flushTimer = options.setTimer(() => {
state.flushTimer = undefined;
const queued = options.groups.get(key);
if (!queued || queued.context === undefined) return;
if (queued.dispatching) {
scheduleDispatch();
return;
}
const dispatchedMessages = [...queued.messages];
const dispatchedIds = new Set(
dispatchedMessages.map((message) => message.message_id),
);
queued.dispatching = true;
void Promise.resolve(
options.dispatchMessages(dispatchedMessages, queued.context),
).then(
() => {
if (options.groups.get(key) !== queued) return;
queued.messages = queued.messages.filter(
(message) => !dispatchedIds.has(message.message_id),
);
queued.dispatching = false;
if (queued.messages.length === 0) options.groups.delete(key);
else if (!queued.flushTimer) scheduleDispatch();
},
() => {
if (options.groups.get(key) !== queued) return;
queued.dispatching = false;
if (!queued.flushTimer) scheduleDispatch();
},
);
}, options.debounceMs);
state.flushTimer.unref?.();
};
state.reschedule = scheduleDispatch;
if (state.flushTimer) options.clearTimer(state.flushTimer);
scheduleDispatch();
options.groups.set(key, state);
return true;
}
export function createTelegramTextGroupController<
TMessage extends TelegramTextGroupMessage,
TContext = unknown,
>(
options: TelegramTextGroupControllerOptions = {},
): TelegramTextGroupController<TMessage, TContext> {
const groups = new Map<string, TelegramTextGroupState<TMessage, TContext>>();
const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
const minSplitLength =
options.minSplitLength ?? TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH;
const setTimer =
options.setTimer ??
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
setTimeout(callback, ms));
const clearTimer = options.clearTimer ?? clearTimeout;
return {
queueMessage: ({ message, context, dispatchMessages }) =>
queueTelegramTextGroupMessage({
message,
context,
groups,
debounceMs,
minSplitLength,
setTimer,
clearTimer,
dispatchMessages,
}),
suspend: () => {
for (const state of groups.values()) {
state.suspended = true;
if (state.flushTimer) clearTimer(state.flushTimer);
state.flushTimer = undefined;
}
},
resume: (context) => {
for (const state of groups.values()) {
state.context = context;
state.suspended = false;
if (!state.dispatching && !state.flushTimer) state.reschedule?.();
}
},
clear: () => {
for (const state of groups.values()) {
if (state.flushTimer) clearTimer(state.flushTimer);
}
groups.clear();
},
};
}
export function createTelegramTextGroupDispatchRuntime<
TMessage extends TelegramTextGroupMessage,
TContext,
>(deps: {
textGroups: TelegramTextGroupController<TMessage, TContext>;
dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
dispatchSingleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
}): TelegramTextGroupDispatchRuntime<TMessage, TContext> {
return {
handleMessage: async (message, ctx) => {
const queuedTextGroup = deps.textGroups.queueMessage({
message,
context: ctx,
dispatchMessages: (messages, queuedCtx) =>
deps.dispatchMessages(messages, queuedCtx),
});
if (queuedTextGroup) return;
await deps.dispatchSingleMessage(message, ctx);
},
};
}
export function createTelegramGroupedInputClearer(
deps: TelegramGroupedInputClearerDeps,
): () => void {
return () => {
deps.clearMediaGroups();
deps.clearTextGroups();
};
}
+928
View File
@@ -0,0 +1,928 @@
/**
* Telegram thread lifecycle reconciler
* Zones: telegram, multi-instance bus, synchronization control plane
* Owns pure planning for Telegram thread/tab lifecycle reconciliation and destructive topic cleanup authority
* Excludes live Telegram API calls, inbound routing, menu rendering, and direct thread-store persistence
*/
import type { TelegramTarget } from "./target.ts";
type ThreadTarget = TelegramTarget & { threadId: number };
type ThreadRecordStatus =
"active" | "offline" | "stale" | "pending" | "starting" | "failed";
export interface ThreadReconciliationRecord {
target: ThreadTarget;
status: ThreadRecordStatus;
instanceId?: string;
profileKey?: string;
ownerKind?: "leader" | "manual-follower" | "pending-topic" | "legacy";
}
export interface ThreadReconciliationReservation {
target: ThreadTarget;
expiresAtMs?: number;
}
export interface ThreadReconciliationObservation {
target: ThreadTarget;
syncStatus: "open" | "closed" | "deleted" | "unknown";
observedAtMs: number;
}
export interface TelegramThreadPendingProvision {
id: string;
owner: "leader" | "manual-follower";
instanceId: string;
slot?: string;
target?: TelegramTarget & { threadId: number };
startedAtMs: number;
expiresAtMs?: number;
leaderEpoch?: number | string;
}
export interface TelegramUnboundThreadMessageObservation {
target: TelegramTarget & { threadId: number };
observedAtMs: number;
messageId?: number;
leaderEpoch?: number | string;
}
export interface TelegramReservedThreadMessageObservation {
target: TelegramTarget & { threadId: number };
observedAtMs: number;
messageId?: number;
leaderEpoch?: number | string;
}
export interface ThreadReservationProbeResult {
target: ThreadTarget;
stale: boolean;
}
export interface ReplacedInstanceBindingInput {
instanceId: string;
replacementTarget: ThreadTarget;
}
export interface PreviousLeaderCleanupInput {
currentInstanceId: string;
}
export type ThreadReconciliationAction =
| {
kind: "mark-topic-active";
target: TelegramTarget & { threadId: number };
reason: "observed-open";
}
| {
kind: "mark-topic-stale";
target: TelegramTarget & { threadId: number };
syncStatus: "closed" | "deleted";
reason: "observed-closed" | "observed-deleted";
}
| {
kind: "close-delete-unbound-topic";
target: TelegramTarget & { threadId: number };
observedAtMs: number;
messageId?: number;
reason: "unbound-user-message";
leaderEpoch?: number | string;
}
| {
kind: "close-delete-reserved-topic";
target: TelegramTarget & { threadId: number };
observedAtMs: number;
messageId?: number;
reason: "reserved-user-message" | "startup-reservation";
leaderEpoch?: number | string;
}
| {
kind: "close-stale-replaced-topic";
target: TelegramTarget & { threadId: number };
reason: "replaced-instance-binding";
instanceId?: string;
leaderEpoch?: number | string;
}
| {
kind: "close-delete-pruned-follower-topic";
target: TelegramTarget & { threadId: number };
reason: "pruned-follower";
instanceId?: string;
messageId?: number;
leaderEpoch?: number | string;
}
| {
kind: "close-delete-replaced-follower-topic";
target: TelegramTarget & { threadId: number };
reason: "replaced-follower";
instanceId?: string;
messageId?: number;
leaderEpoch?: number | string;
}
| {
kind: "close-delete-previous-leader-topic";
target: TelegramTarget & { threadId: number };
reason: "previous-leader";
instanceId?: string;
messageId?: number;
leaderEpoch?: number | string;
}
| {
kind: "close-delete-disconnected-instance-topic";
target: TelegramTarget & { threadId: number };
reason: "manual-disconnect";
instanceId?: string;
messageId?: number;
leaderEpoch?: number | string;
}
| {
kind: "close-delete-expired-pending-provision-topic";
target: TelegramTarget & { threadId: number };
reason: "expired-pending-provision";
pendingProvisionId: string;
instanceId?: string;
leaderEpoch?: number | string;
}
| {
kind: "remove-reservation";
target: TelegramTarget & { threadId: number };
reason: "reservation-probe-stale";
};
export type ThreadReconciliationPhase =
"stable" | "provisioning" | "sync-required" | "cleanup-required";
export type ThreadReconciliationEvent =
"settled" | "pending-provision" | "sync-required" | "cleanup-required";
export interface ThreadReconciliationMachineState {
phase: ThreadReconciliationPhase;
event: ThreadReconciliationEvent;
atMs: number;
leaderEpoch?: number | string;
pendingProvisionCount: number;
syncActionCount: number;
cleanupActionCount: number;
}
export interface ThreadReconciliationTransition {
from: ThreadReconciliationPhase;
to: ThreadReconciliationPhase;
event: ThreadReconciliationEvent;
atMs: number;
}
export interface ThreadReconciliationPlan {
actions: ThreadReconciliationAction[];
state?: ThreadReconciliationMachineState;
transition?: ThreadReconciliationTransition;
}
export interface ThreadReconciliationApplyResult {
changed: boolean;
}
export interface ThreadReconciliationApplyPorts {
callApi?: <TResponse>(
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
markActiveByTarget?: (target: ThreadTarget) => boolean;
markStaleByTarget?: (
target: ThreadTarget,
syncStatus?: "closed" | "deleted",
lastSyncError?: string,
) => boolean;
persist?: () => Promise<void>;
removeReservationByTarget?: (target: ThreadTarget) => boolean;
removePendingProvisionById?: (id: string) => boolean;
getCurrentLeaderEpoch?: () => number | string | undefined;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface ThreadReconciliationInput {
nowMs: number;
currentLeaderEpoch?: number | string;
records: readonly ThreadReconciliationRecord[];
reservations?: readonly ThreadReconciliationReservation[];
observations?: readonly ThreadReconciliationObservation[];
pendingProvisions?: readonly TelegramThreadPendingProvision[];
unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
proactiveReservationCleanup?: boolean;
reservationProbeResults?: readonly ThreadReservationProbeResult[];
prunedFollowerInstanceIds?: readonly string[];
replacedBindings?: readonly ReplacedInstanceBindingInput[];
previousLeaderCleanup?: PreviousLeaderCleanupInput;
previousState?: ThreadReconciliationMachineState;
freshCreationGraceMs?: number;
}
const DEFAULT_FRESH_CREATION_GRACE_MS = 30_000;
function targetKey(target: ThreadTarget): string {
return `${target.chatId}:${target.threadId}`;
}
function isCurrentRecord(record: ThreadReconciliationRecord): boolean {
return (
record.status === "active" ||
record.status === "starting" ||
record.status === "pending"
);
}
function isActiveOrStartingRecord(record: ThreadReconciliationRecord): boolean {
return record.status === "active" || record.status === "starting";
}
function isPreviousLeaderRecord(record: ThreadReconciliationRecord): boolean {
return (
record.ownerKind === "leader" ||
(!record.ownerKind &&
(record.profileKey?.startsWith("cwd:") === true ||
record.profileKey?.startsWith("leader:") === true))
);
}
function isReservationAlive(
reservation: ThreadReconciliationReservation,
nowMs: number,
): boolean {
return (
reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs
);
}
function isPendingProvisionExpired(
provision: TelegramThreadPendingProvision,
nowMs: number,
): boolean {
return provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs;
}
function isPendingProvisionAlive(
provision: TelegramThreadPendingProvision,
nowMs: number,
currentLeaderEpoch: number | string | undefined,
): boolean {
if (isPendingProvisionExpired(provision, nowMs)) {
return false;
}
if (
currentLeaderEpoch !== undefined &&
provision.leaderEpoch !== undefined &&
provision.leaderEpoch !== currentLeaderEpoch
) {
return false;
}
return true;
}
function isRecentOpenObservation(
observation: ThreadReconciliationObservation,
nowMs: number,
graceMs: number,
): boolean {
return (
observation.syncStatus === "open" &&
nowMs - observation.observedAtMs >= 0 &&
nowMs - observation.observedAtMs <= graceMs
);
}
function isCleanupAction(action: ThreadReconciliationAction): boolean {
return (
action.kind === "close-delete-unbound-topic" ||
action.kind === "close-delete-reserved-topic" ||
action.kind === "close-stale-replaced-topic" ||
action.kind === "close-delete-pruned-follower-topic" ||
action.kind === "close-delete-replaced-follower-topic" ||
action.kind === "close-delete-previous-leader-topic" ||
action.kind === "close-delete-disconnected-instance-topic" ||
action.kind === "close-delete-expired-pending-provision-topic"
);
}
function isSyncAction(action: ThreadReconciliationAction): boolean {
return (
action.kind === "mark-topic-active" ||
action.kind === "mark-topic-stale" ||
action.kind === "remove-reservation"
);
}
function createThreadReconciliationMachineState(
input: ThreadReconciliationInput,
actions: readonly ThreadReconciliationAction[],
): ThreadReconciliationMachineState {
const pendingProvisionCount = (input.pendingProvisions ?? []).filter(
(provision) =>
isPendingProvisionAlive(provision, input.nowMs, input.currentLeaderEpoch),
).length;
const cleanupActionCount = actions.filter(isCleanupAction).length;
const syncActionCount = actions.filter(isSyncAction).length;
const phase: ThreadReconciliationPhase =
cleanupActionCount > 0
? "cleanup-required"
: pendingProvisionCount > 0
? "provisioning"
: syncActionCount > 0
? "sync-required"
: "stable";
const event: ThreadReconciliationEvent =
phase === "cleanup-required"
? "cleanup-required"
: phase === "provisioning"
? "pending-provision"
: phase === "sync-required"
? "sync-required"
: "settled";
return {
phase,
event,
atMs: input.nowMs,
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
pendingProvisionCount,
syncActionCount,
cleanupActionCount,
};
}
function createThreadReconciliationTransition(
previous: ThreadReconciliationMachineState | undefined,
current: ThreadReconciliationMachineState,
): ThreadReconciliationTransition | undefined {
if (!previous || previous.phase === current.phase) return undefined;
return {
from: previous.phase,
to: current.phase,
event: current.event,
atMs: current.atMs,
};
}
function getActionLeaderEpoch(
action: ThreadReconciliationAction,
): number | string | undefined {
return "leaderEpoch" in action ? action.leaderEpoch : undefined;
}
function isTelegramTopicTargetGoneError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
return (
message.includes("topic_id_invalid") ||
message.includes("message thread not found") ||
message.includes("thread not found") ||
message.includes("topic not found") ||
message.includes("topic deleted") ||
message.includes("topic closed") ||
message.includes("thread closed") ||
message.includes("forum topic closed") ||
message.includes("message thread closed")
);
}
function shouldSkipForStaleLeaderEpoch(
action: ThreadReconciliationAction,
ports: ThreadReconciliationApplyPorts,
): boolean {
const actionLeaderEpoch = getActionLeaderEpoch(action);
const currentLeaderEpoch = ports.getCurrentLeaderEpoch?.();
if (actionLeaderEpoch === undefined) {
if (!ports.getCurrentLeaderEpoch) return false;
ports.recordRuntimeEvent?.(
"telegram",
"Thread reconciliation destructive action has no leader epoch",
{
phase: "thread-reconciler-missing-leader-epoch",
currentLeaderEpoch,
action: action.kind,
chatId: action.target.chatId,
threadId: action.target.threadId,
},
);
return true;
}
if (currentLeaderEpoch === actionLeaderEpoch) return false;
ports.recordRuntimeEvent?.(
"telegram",
"Skipped stale-epoch thread reconciliation action",
{
phase: "thread-reconciler-stale-epoch-skip",
action: action.kind,
actionLeaderEpoch,
currentLeaderEpoch,
chatId: action.target.chatId,
threadId: action.target.threadId,
},
);
return true;
}
export interface ThreadReconciliationRuntime {
getState: () => ThreadReconciliationMachineState | undefined;
recordPlan: (plan: ThreadReconciliationPlan) => void;
plan: (
input: Omit<ThreadReconciliationInput, "previousState">,
) => ThreadReconciliationPlan;
}
export function createThreadReconciliationRuntime(deps: {
recordRuntimeEvent: (
category: string,
message: unknown,
details?: Record<string, unknown>,
) => void;
scheduleSnapshotPersist: () => void;
}): ThreadReconciliationRuntime {
let state: ThreadReconciliationMachineState | undefined;
const recordPlan = (plan: ThreadReconciliationPlan): void => {
if (!plan.state) return;
state = plan.state;
if (plan.transition) {
deps.recordRuntimeEvent(
"telegram",
"Thread reconciliation phase changed",
{
phase: "thread-reconciliation-transition",
from: plan.transition.from,
to: plan.transition.to,
event: plan.transition.event,
},
);
}
deps.scheduleSnapshotPersist();
};
return {
getState: () => state,
recordPlan,
plan(input) {
const plan = planThreadReconciliation({
...input,
previousState: state,
});
recordPlan(plan);
return plan;
},
};
}
export function planDisconnectedInstanceThreadCleanup(input: {
target: TelegramTarget & { threadId: number };
instanceId?: string;
leaderEpoch?: number | string;
}): ThreadReconciliationPlan {
return {
actions: [
{
kind: "close-delete-disconnected-instance-topic",
target: input.target,
reason: "manual-disconnect",
instanceId: input.instanceId,
...(input.leaderEpoch !== undefined
? { leaderEpoch: input.leaderEpoch }
: {}),
},
],
};
}
export async function applyThreadReconciliationPlan(
plan: ThreadReconciliationPlan,
ports: ThreadReconciliationApplyPorts,
): Promise<ThreadReconciliationApplyResult> {
let shouldPersist = false;
const persistFences: ThreadReconciliationAction[] = [];
for (const action of plan.actions) {
if (action.kind === "remove-reservation") {
shouldPersist =
ports.removeReservationByTarget?.(action.target) || shouldPersist;
continue;
}
if (action.kind === "mark-topic-active") {
shouldPersist =
ports.markActiveByTarget?.(action.target) || shouldPersist;
continue;
}
if (action.kind === "mark-topic-stale") {
shouldPersist =
ports.markStaleByTarget?.(action.target, action.syncStatus) ||
shouldPersist;
continue;
}
if (action.kind === "close-stale-replaced-topic") {
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
if (!ports.callApi) {
ports.recordRuntimeEvent?.(
"telegram",
"Skipped topic close without API port",
{
phase: "thread-reconciler-close-skip-no-api",
reason: action.reason,
chatId: action.target.chatId,
threadId: action.target.threadId,
},
);
continue;
}
try {
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
await ports.callApi("closeForumTopic", {
chat_id: action.target.chatId,
message_thread_id: action.target.threadId,
});
if (!shouldSkipForStaleLeaderEpoch(action, ports)) {
const changed =
ports.markStaleByTarget?.(action.target, "closed") ?? false;
if (changed) persistFences.push(action);
shouldPersist = changed || shouldPersist;
}
} catch (error) {
ports.recordRuntimeEvent?.("telegram", error, {
phase: `thread-reconciler-${action.reason}-closeForumTopic`,
instanceId: action.instanceId,
chatId: action.target.chatId,
threadId: action.target.threadId,
});
}
continue;
}
if (
action.kind === "close-delete-unbound-topic" ||
action.kind === "close-delete-reserved-topic" ||
action.kind === "close-delete-pruned-follower-topic" ||
action.kind === "close-delete-replaced-follower-topic" ||
action.kind === "close-delete-previous-leader-topic" ||
action.kind === "close-delete-disconnected-instance-topic" ||
action.kind === "close-delete-expired-pending-provision-topic"
) {
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
if (!ports.callApi) {
ports.recordRuntimeEvent?.(
"telegram",
"Skipped topic cleanup without API port",
{
phase: "thread-reconciler-cleanup-skip-no-api",
reason: action.reason,
chatId: action.target.chatId,
threadId: action.target.threadId,
},
);
continue;
}
let deleteConfirmed = false;
for (const method of ["closeForumTopic", "deleteForumTopic"]) {
if (shouldSkipForStaleLeaderEpoch(action, ports)) break;
try {
await ports.callApi(method, {
chat_id: action.target.chatId,
message_thread_id: action.target.threadId,
});
if (method === "deleteForumTopic") deleteConfirmed = true;
} catch (error) {
if (isTelegramTopicTargetGoneError(error)) {
if (method === "deleteForumTopic") deleteConfirmed = true;
} else {
ports.recordRuntimeEvent?.("telegram", error, {
phase: `thread-reconciler-${action.reason}-${method}`,
chatId: action.target.chatId,
threadId: action.target.threadId,
});
}
}
}
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
if (!deleteConfirmed) {
ports.recordRuntimeEvent?.(
"telegram",
"Telegram topic cleanup incomplete",
{
phase: "thread-reconciler-cleanup-incomplete",
action: action.kind,
reason: action.reason,
chatId: action.target.chatId,
threadId: action.target.threadId,
...("messageId" in action ? { messageId: action.messageId } : {}),
},
);
continue;
}
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
if (
action.kind !== "close-delete-previous-leader-topic" &&
action.kind !== "close-delete-pruned-follower-topic" &&
action.kind !== "close-delete-replaced-follower-topic" &&
action.kind !== "close-delete-disconnected-instance-topic" &&
action.kind !== "close-delete-expired-pending-provision-topic"
) {
const changed =
ports.markStaleByTarget?.(action.target, "deleted") ?? false;
if (changed) persistFences.push(action);
shouldPersist = changed || shouldPersist;
}
ports.recordRuntimeEvent?.(
"telegram",
action.kind === "close-delete-unbound-topic"
? "Unbound Telegram topic deleted"
: action.kind === "close-delete-reserved-topic"
? "Reserved Telegram topic deleted"
: action.kind === "close-delete-pruned-follower-topic"
? "Pruned follower Telegram topic deleted"
: action.kind === "close-delete-replaced-follower-topic"
? "Replaced follower Telegram topic deleted"
: action.kind === "close-delete-previous-leader-topic"
? "Previous leader Telegram topic deleted"
: action.kind === "close-delete-disconnected-instance-topic"
? "Disconnected instance Telegram topic deleted"
: "Expired pending provision Telegram topic deleted",
{
phase:
action.kind === "close-delete-unbound-topic"
? "thread-reconciler-unbound-topic-delete"
: action.kind === "close-delete-reserved-topic"
? "thread-reconciler-reserved-topic-delete"
: action.kind === "close-delete-pruned-follower-topic"
? "thread-reconciler-pruned-follower-topic-delete"
: action.kind === "close-delete-replaced-follower-topic"
? "thread-reconciler-replaced-follower-topic-delete"
: action.kind === "close-delete-previous-leader-topic"
? "thread-reconciler-previous-leader-topic-delete"
: action.kind ===
"close-delete-disconnected-instance-topic"
? "thread-reconciler-disconnected-instance-topic-delete"
: "thread-reconciler-expired-pending-provision-topic-delete",
chatId: action.target.chatId,
threadId: action.target.threadId,
...("messageId" in action ? { messageId: action.messageId } : {}),
},
);
if (
action.kind === "close-delete-expired-pending-provision-topic" &&
deleteConfirmed
) {
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
const changed =
ports.removePendingProvisionById?.(action.pendingProvisionId) ??
false;
if (changed) persistFences.push(action);
shouldPersist = changed || shouldPersist;
}
}
}
if (shouldPersist) {
for (const action of persistFences) {
if (shouldSkipForStaleLeaderEpoch(action, ports)) {
return { changed: true };
}
}
await ports.persist?.();
}
return { changed: shouldPersist };
}
export function planThreadReconciliation(
input: ThreadReconciliationInput,
): ThreadReconciliationPlan {
const graceMs = input.freshCreationGraceMs ?? DEFAULT_FRESH_CREATION_GRACE_MS;
const knownTargets = new Set(
input.records.map((record) => targetKey(record.target)),
);
const currentTargets = new Set(
input.records
.filter(isCurrentRecord)
.map((record) => targetKey(record.target)),
);
const allReservationTargets = new Set(
(input.reservations ?? []).map((reservation) =>
targetKey(reservation.target),
),
);
const reservedTargets = new Set(
(input.reservations ?? [])
.filter((reservation) => isReservationAlive(reservation, input.nowMs))
.map((reservation) => targetKey(reservation.target)),
);
const pendingTargets = new Set(
(input.pendingProvisions ?? [])
.filter((provision) =>
isPendingProvisionAlive(
provision,
input.nowMs,
input.currentLeaderEpoch,
),
)
.flatMap((provision) =>
provision.target ? [targetKey(provision.target)] : [],
),
);
const recentOpenTargets = new Set(
(input.observations ?? [])
.filter((observation) =>
isRecentOpenObservation(observation, input.nowMs, graceMs),
)
.map((observation) => targetKey(observation.target)),
);
const actions: ThreadReconciliationAction[] = [];
for (const provision of input.pendingProvisions ?? []) {
if (!provision.target) continue;
if (!isPendingProvisionExpired(provision, input.nowMs)) continue;
const key = targetKey(provision.target);
if (currentTargets.has(key)) continue;
if (reservedTargets.has(key)) continue;
if (recentOpenTargets.has(key)) continue;
actions.push({
kind: "close-delete-expired-pending-provision-topic",
target: provision.target,
reason: "expired-pending-provision",
pendingProvisionId: provision.id,
instanceId: provision.instanceId,
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
for (const observation of input.observations ?? []) {
if (observation.syncStatus === "open") {
if (knownTargets.has(targetKey(observation.target))) {
actions.push({
kind: "mark-topic-active",
target: observation.target,
reason: "observed-open",
});
}
continue;
}
if (
observation.syncStatus === "closed" ||
observation.syncStatus === "deleted"
) {
if (currentTargets.has(targetKey(observation.target))) {
actions.push({
kind: "mark-topic-stale",
target: observation.target,
syncStatus: observation.syncStatus,
reason:
observation.syncStatus === "closed"
? "observed-closed"
: "observed-deleted",
});
}
}
}
for (const prunedInstanceId of input.prunedFollowerInstanceIds ?? []) {
for (const record of input.records) {
if (record.instanceId !== prunedInstanceId) continue;
if (!isActiveOrStartingRecord(record)) continue;
actions.push({
kind: "close-delete-pruned-follower-topic",
target: record.target,
reason: "pruned-follower",
instanceId: prunedInstanceId,
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
}
for (const replacement of input.replacedBindings ?? []) {
for (const record of input.records) {
if (record.instanceId !== replacement.instanceId) continue;
if (!isActiveOrStartingRecord(record)) continue;
if (
targetKey(record.target) === targetKey(replacement.replacementTarget)
) {
continue;
}
actions.push({
kind: "close-stale-replaced-topic",
target: record.target,
reason: "replaced-instance-binding",
instanceId: replacement.instanceId,
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
}
if (input.previousLeaderCleanup) {
for (const record of input.records) {
if (!record.instanceId) continue;
if (record.instanceId === input.previousLeaderCleanup.currentInstanceId) {
continue;
}
if (!isActiveOrStartingRecord(record)) continue;
if (!isPreviousLeaderRecord(record)) continue;
actions.push({
kind: "close-delete-previous-leader-topic",
target: record.target,
reason: "previous-leader",
instanceId: input.previousLeaderCleanup.currentInstanceId,
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
}
if (input.proactiveReservationCleanup) {
for (const reservation of input.reservations ?? []) {
const key = targetKey(reservation.target);
if (currentTargets.has(key)) continue;
if (pendingTargets.has(key)) continue;
actions.push({
kind: "close-delete-reserved-topic",
target: reservation.target,
observedAtMs: input.nowMs,
reason: "startup-reservation",
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
}
for (const probe of input.reservationProbeResults ?? []) {
if (!probe.stale) continue;
if (!allReservationTargets.has(targetKey(probe.target))) continue;
actions.push({
kind: "remove-reservation",
target: probe.target,
reason: "reservation-probe-stale",
});
}
for (const message of input.reservedMessages ?? []) {
const key = targetKey(message.target);
if (!reservedTargets.has(key)) continue;
if (currentTargets.has(key)) continue;
if (pendingTargets.has(key)) continue;
if (
input.currentLeaderEpoch !== undefined &&
message.leaderEpoch !== undefined &&
message.leaderEpoch !== input.currentLeaderEpoch
) {
continue;
}
actions.push({
kind: "close-delete-reserved-topic",
target: message.target,
observedAtMs: message.observedAtMs,
messageId: message.messageId,
reason: "reserved-user-message",
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
for (const message of input.unboundMessages ?? []) {
const key = targetKey(message.target);
if (currentTargets.has(key)) continue;
if (reservedTargets.has(key)) continue;
if (pendingTargets.has(key)) continue;
if (recentOpenTargets.has(key)) continue;
if (
input.currentLeaderEpoch !== undefined &&
message.leaderEpoch !== undefined &&
message.leaderEpoch !== input.currentLeaderEpoch
) {
continue;
}
actions.push({
kind: "close-delete-unbound-topic",
target: message.target,
observedAtMs: message.observedAtMs,
messageId: message.messageId,
reason: "unbound-user-message",
...(input.currentLeaderEpoch !== undefined
? { leaderEpoch: input.currentLeaderEpoch }
: {}),
});
}
const state = createThreadReconciliationMachineState(input, actions);
const transition = createThreadReconciliationTransition(
input.previousState,
state,
);
return {
actions,
state,
...(transition ? { transition } : {}),
};
}
+2882
View File
File diff suppressed because it is too large. Load diff
+78
View File
@@ -0,0 +1,78 @@
/**
* Telegram per-chat time injection runtime
* Zones: telegram inbound, prompt content
* Owns the formatted `[time]` line and the per-chat interval bookkeeping that decides when to emit it
*/
import type { ResolvedTelegramTimeConfig } from "./config.ts";
export interface TimeInjectionRuntime {
resolveLine: (chatId: number, now?: Date) => string | null;
}
export interface TimeInjectionRuntimeDeps {
getConfig: () => ResolvedTelegramTimeConfig;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export function formatTelegramTimeInjectionLine(
now: Date,
timezone: string,
): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(now);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((part) => part.type === type)?.value ?? "";
const year = get("year");
const month = get("month");
const day = get("day");
const hourRaw = get("hour");
const hour = hourRaw === "24" ? "00" : hourRaw;
const minute = get("minute");
const second = get("second");
return `${year}-${month}-${day} ${hour}:${minute}:${second} ${timezone}`;
}
export function createTimeInjectionRuntime(
deps: TimeInjectionRuntimeDeps,
): TimeInjectionRuntime {
const lastInjectedAt = new Map<number, number>();
return {
resolveLine: (chatId, now = new Date()) => {
const config = deps.getConfig();
if (config.injectionMode === "hidden") return null;
let line: string;
try {
line = formatTelegramTimeInjectionLine(now, config.timezone);
} catch (error) {
deps.recordRuntimeEvent?.("time-injection", error, {
timezone: config.timezone,
});
return null;
}
if (config.injectionMode === "always") return line;
const previous = lastInjectedAt.get(chatId);
const nowMs = now.getTime();
if (
previous !== undefined &&
nowMs - previous < config.interval
) {
return null;
}
lastInjectedAt.set(chatId, nowMs);
return line;
},
};
}
+591
View File
@@ -0,0 +1,591 @@
/**
* Telegram turn-building helpers
* Zones: telegram inbound, pi agent prompt content, queue
* Owns prompt-turn summary and content construction so queued Telegram turns are assembled consistently
*/
import { readFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import {
buildTelegramReplyContextBlock,
collectTelegramMessageIds,
downloadTelegramMessageFiles,
extractTelegramForwardContextText,
extractTelegramMessagesPromptText,
extractTelegramMessagesText,
formatTelegramHistoryText,
guessMediaType,
type DownloadedTelegramMessageFile,
type DownloadTelegramMessageFilesDeps,
type TelegramMediaMessage,
} from "./media.ts";
import type {
PendingTelegramTurn,
TelegramPromptContent,
TelegramQueueItem,
TelegramQueueStore,
} from "./queue.ts";
import {
computeVoicePromptContribution,
computeVoiceTurnFlags,
getTelegramVoiceReplyMode,
TELEGRAM_VOICE_REPLY_MODES,
type TelegramVoiceReplyMode,
} from "./voice.ts";
// Re-export for backward compatibility with existing namespace imports (e.g. Turns.getTelegramVoiceReplyMode in routing.ts)
export {
getTelegramVoiceReplyMode,
TELEGRAM_VOICE_REPLY_MODES,
type TelegramVoiceReplyMode,
};
export const TELEGRAM_PREFIX = "[telegram]";
export interface TelegramTurnTarget {
chatId: number;
threadId?: number;
}
export interface TelegramTurnMessage {
message_id: number;
message_thread_id?: number;
chat: { id: number; type?: string };
}
export type DownloadedTelegramTurnFile = DownloadedTelegramMessageFile;
function getTelegramTurnTarget(message: TelegramTurnMessage): TelegramTurnTarget {
return Number.isInteger(message.message_thread_id)
? { chatId: message.chat.id, threadId: message.message_thread_id }
: { chatId: message.chat.id };
}
function formatTelegramPrefixAttributeValue(value: string): string {
return value.replace(/[\]\n\r|]+/g, " ").replace(/\s+/g, " ").trim();
}
export function createTelegramTurnPrefix(
attributes: Record<string, string | undefined> = {},
): string {
const parts = [TELEGRAM_PREFIX.slice(1, -1)];
for (const [key, value] of Object.entries(attributes)) {
const normalized = value ? formatTelegramPrefixAttributeValue(value) : "";
if (normalized) parts.push(`${key}:${normalized}`);
}
return `[${parts.join("|")}]`;
}
export function formatTelegramTurnPrefix(
_message: TelegramTurnMessage,
basePrefix = TELEGRAM_PREFIX,
): string {
return basePrefix;
}
import { truncateTelegramQueueSummary } from "./queue.ts";
export { truncateTelegramQueueSummary };
export function formatTelegramTurnStatusSummary(
rawText: string,
files: DownloadedTelegramTurnFile[],
handlerOutputs: string[] = [],
): string {
const textSummary = truncateTelegramQueueSummary(rawText);
if (textSummary) return textSummary;
const handlerSummary = truncateTelegramQueueSummary(handlerOutputs.join(" "));
if (handlerSummary) return handlerSummary;
if (files.length === 1) {
const fileName = basename(
files[0]?.fileName || files[0]?.path || "attachment",
);
return `📎 ${truncateTelegramQueueSummary(fileName, 4, 32) || "attachment"}`;
}
if (files.length > 1) return `📎 ${files.length} attachments`;
return "(empty message)";
}
function appendTelegramListSection(
text: string,
title: string,
items: string[],
): string {
if (items.length === 0) return text;
const prefix = text.length > 0 ? `${text}\n\n` : "";
return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
}
function appendTelegramAttachmentSection(
text: string,
files: Pick<DownloadedTelegramTurnFile, "path">[],
): string {
if (files.length === 0) return text;
const dirs = [...new Set(files.map((file) => dirname(file.path)))];
const sameDir = dirs.length === 1;
const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
const items = sameDir
? files.map((file) => `/${basename(file.path)}`)
: files.map((file) => file.path);
const prefix = text.length > 0 ? `${text}\n\n` : "";
return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
}
function appendTelegramSourceContext(text: string, sourceContext: string | undefined): string {
if (!sourceContext) return text;
return text ? `${text}\n\n${sourceContext}` : sourceContext;
}
function appendTelegramPromptText(prompt: string, rawText: string): string {
if (!rawText) return prompt;
if (rawText.startsWith("\n")) return `${prompt}${rawText}`;
return `${prompt} ${rawText}`;
}
function appendTelegramVoiceContext(
prompt: string,
entries: Record<string, string>,
): string {
const prefix = prompt.length > 0 ? `${prompt}\n\n` : "";
const pairs = Object.entries(entries);
if (pairs.length === 1) {
const [key, value] = pairs[0];
return `${prefix}[voice] ${key}: ${value}`;
}
return `${prefix}[voice]\n${pairs
.map(([key, value]) => `- ${key}: ${value}`)
.join("\n")}`;
}
// --- Voice Policy And Tagging ---
export function buildTelegramTurnPrompt(options: {
telegramPrefix: string;
rawText: string;
files: DownloadedTelegramTurnFile[];
promptFiles?: DownloadedTelegramTurnFile[];
handlerOutputs?: string[];
sourceContext?: string;
historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
timeLine?: string | null;
voiceContext?: Record<string, string>;
}): string {
let prompt = options.telegramPrefix;
if ((options.historyTurns?.length ?? 0) > 0) {
prompt +=
"\n\nEarlier Telegram messages arrived after an aborted turn. Treat them as prior user messages, in order:";
for (const [index, turn] of (options.historyTurns ?? []).entries()) {
prompt += `\n\n${index + 1}. ${turn.historyText}`;
}
prompt += "\n\nCurrent Telegram message:";
}
if (options.rawText.length > 0) {
prompt =
(options.historyTurns?.length ?? 0) > 0
? `${prompt}\n${options.rawText}`
: appendTelegramPromptText(prompt, options.rawText);
}
const promptFiles = options.promptFiles ?? options.files;
prompt = appendTelegramAttachmentSection(prompt, promptFiles);
prompt = appendTelegramSourceContext(prompt, options.sourceContext);
prompt = appendTelegramListSection(
prompt,
"outputs",
options.handlerOutputs ?? [],
);
if (options.voiceContext) {
prompt = appendTelegramVoiceContext(prompt, options.voiceContext);
}
if (options.timeLine) {
prompt = `${prompt}\n\n[time] ${options.timeLine}`;
}
return prompt;
}
function splitTelegramPromptAttachmentSuffix(prompt: string): {
promptWithoutAttachments: string;
attachmentSuffix: string;
attachmentFiles: DownloadedTelegramTurnFile[];
} {
const marker = "\n\n[attachments]";
const markerIndex = prompt.indexOf(marker);
if (markerIndex === -1) {
return {
promptWithoutAttachments: prompt,
attachmentSuffix: "",
attachmentFiles: [],
};
}
const promptWithoutAttachments = prompt.slice(0, markerIndex);
const attachmentSuffix = prompt.slice(markerIndex);
const attachmentLines: string[] = [];
let readingAttachments = false;
let attachmentDir: string | undefined;
for (const line of attachmentSuffix.split("\n")) {
const trimmed = line.trim();
const attachmentMatch = trimmed.match(/^\[attachments\](?:\s+(.+))?$/);
if (attachmentMatch) {
readingAttachments = true;
attachmentDir = attachmentMatch[1]?.trim();
continue;
}
if (readingAttachments && /^\[[^\]]+\](?:\s+.*)?$/.test(trimmed)) break;
if (readingAttachments) attachmentLines.push(line);
}
const attachmentFiles = attachmentLines
.map((line) => line.match(/^- (.+)$/)?.[1]?.trim())
.filter((path): path is string => !!path)
.map((path) =>
attachmentDir ? join(attachmentDir, path.replace(/^\/+/, "")) : path,
)
.map((path) => ({
path,
fileName: basename(path),
isImage: false,
kind: "document" as const,
}));
return { promptWithoutAttachments, attachmentSuffix, attachmentFiles };
}
function buildEditedTelegramPromptText(options: {
existingPrompt: string;
telegramPrefix: string;
rawText: string;
}): { text: string; attachmentFiles: DownloadedTelegramTurnFile[] } {
const { promptWithoutAttachments, attachmentSuffix, attachmentFiles } =
splitTelegramPromptAttachmentSuffix(options.existingPrompt);
const currentMessageMarker = "Current Telegram message:";
const currentMessageIndex =
promptWithoutAttachments.lastIndexOf(currentMessageMarker);
if (currentMessageIndex !== -1) {
const prefix = promptWithoutAttachments.slice(
0,
currentMessageIndex + currentMessageMarker.length,
);
const separator = options.rawText.length > 0 ? "\n" : "";
return {
text: `${prefix}${separator}${options.rawText}${attachmentSuffix}`,
attachmentFiles,
};
}
return {
text: `${appendTelegramPromptText(
options.telegramPrefix,
options.rawText,
)}${attachmentSuffix}`,
attachmentFiles,
};
}
export function updateTelegramPromptTurnText(options: {
turn: PendingTelegramTurn;
telegramPrefix: string;
rawText: string;
statusText?: string;
}): PendingTelegramTurn {
let attachmentFiles: DownloadedTelegramTurnFile[] = [];
const nextContent = options.turn.content.map((block, index) => {
if (index !== 0 || block.type !== "text") return block;
const updated = buildEditedTelegramPromptText({
existingPrompt: block.text,
telegramPrefix: options.telegramPrefix,
rawText: options.rawText,
});
attachmentFiles = updated.attachmentFiles;
return {
...block,
text: updated.text,
};
});
return {
...options.turn,
content: nextContent,
historyText: formatTelegramHistoryText(options.rawText, attachmentFiles),
statusSummary: formatTelegramTurnStatusSummary(
options.statusText ?? options.rawText,
attachmentFiles,
),
};
}
export function updateQueuedTelegramPromptTurnText<
TContext = unknown,
>(options: {
items: TelegramQueueItem<TContext>[];
sourceMessageId: number | undefined;
telegramPrefix: string;
rawText: string;
statusText?: string;
}): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
if (options.sourceMessageId === undefined) {
return { items: options.items, changed: false };
}
let changed = false;
const items = options.items.map((item) => {
if (
item.kind !== "prompt" ||
!item.sourceMessageIds.includes(options.sourceMessageId as number)
) {
return item;
}
changed = true;
return updateTelegramPromptTurnText({
turn: item,
telegramPrefix: options.telegramPrefix,
rawText: options.rawText,
statusText: options.statusText,
});
});
return { items, changed };
}
export interface TelegramQueuedPromptEditRuntimeDeps<
TContext = unknown,
> extends TelegramQueueStore<TContext> {
updateStatus: (ctx: TContext) => void;
}
export function createTelegramQueuedPromptEditRuntime<
TMessage extends TelegramMediaMessage,
TContext = unknown,
>(deps: TelegramQueuedPromptEditRuntimeDeps<TContext>) {
return {
updateFromEditedMessage: (message: TMessage, ctx: TContext): boolean => {
const { changed, items } = updateQueuedTelegramPromptTurnText({
items: deps.getQueuedItems(),
sourceMessageId: message.message_id,
telegramPrefix: TELEGRAM_PREFIX,
rawText: extractTelegramMessagesPromptText([message]),
statusText: extractTelegramMessagesText([message]),
});
deps.setQueuedItems(items);
if (changed) deps.updateStatus(ctx);
return changed;
},
};
}
export interface BuildTelegramPromptTurnOptions {
telegramPrefix: string;
messages: TelegramTurnMessage[];
historyTurns?: PendingTelegramTurn[];
queueOrder: number;
rawText: string;
statusText?: string;
files: DownloadedTelegramTurnFile[];
promptFiles?: DownloadedTelegramTurnFile[];
handlerOutputs?: string[];
sourceContext?: string;
timeLine?: string | null;
readBinaryFile: (path: string) => Promise<Uint8Array>;
inferImageMimeType: (path: string) => string | undefined;
voiceReplyMode?: TelegramVoiceReplyMode;
voiceReplyModeConfigured?: boolean;
voicePromptContribution?: string;
}
export type BuildTelegramPromptTurnRuntimeOptions = Omit<
BuildTelegramPromptTurnOptions,
"readBinaryFile"
>;
export interface TelegramPromptTurnRuntimeBuilderDeps<
TContext = unknown,
> extends DownloadTelegramMessageFilesDeps {
allocateQueueOrder: () => number;
processAttachments?: (
files: DownloadedTelegramTurnFile[],
rawText: string,
ctx: TContext,
) => Promise<{
rawText: string;
promptFiles?: DownloadedTelegramTurnFile[];
handlerOutputs?: string[];
}>;
resolveTimeLine?: (chatId: number) => string | null;
getVoiceReplyMode?: () => TelegramVoiceReplyMode;
isVoiceReplyModeConfigured?: () => boolean;
/** Returns the visible thread label for a message target, used to add thread context to the prompt prefix. */
getTelegramThreadLabel?: (message: { chat: { id: number }; message_thread_id?: number }) => string | undefined;
getAllowedUserId?: () => number | undefined;
}
export function createTelegramPromptTurnRuntimeBuilder<
TMessage extends TelegramTurnMessage & TelegramMediaMessage,
TContext = unknown,
>(
deps: TelegramPromptTurnRuntimeBuilderDeps<TContext>,
): (
messages: TMessage[],
historyTurns?: PendingTelegramTurn[],
ctx?: TContext,
) => Promise<PendingTelegramTurn> {
return async (messages, historyTurns = [], ctx) => {
const rawText = extractTelegramMessagesText(messages);
const firstMessage = messages[0];
const replyFiles = firstMessage?.reply_to_message
? await downloadTelegramMessageFiles(
[firstMessage.reply_to_message as typeof firstMessage],
{ downloadFile: deps.downloadFile },
)
: [];
const replyContext = firstMessage
? buildTelegramReplyContextBlock(firstMessage, replyFiles)
: "";
const forwardContext = firstMessage
? extractTelegramForwardContextText(firstMessage, deps.getAllowedUserId?.())
: "";
const files = await downloadTelegramMessageFiles(messages, {
downloadFile: deps.downloadFile,
});
const processed = deps.processAttachments
? await deps.processAttachments(files, rawText, ctx as TContext)
: { rawText, promptFiles: files };
const sourceBlocks: string[] = [];
let promptRawText = processed.rawText;
if (forwardContext) {
sourceBlocks.push(
`[forward|${forwardContext.replace(/:\s+/g, ":")}]${
processed.rawText ? ` ${processed.rawText}` : ""
}`,
);
promptRawText = "";
}
if (replyContext) sourceBlocks.push(replyContext);
const sourceContext = sourceBlocks.join("\n\n");
// Compute voice mode once and pass it to both the turn builder and the prompt contribution helper
const voiceReplyMode = deps.getVoiceReplyMode?.();
const chatId = messages[0]?.chat.id;
const timeLine =
deps.resolveTimeLine && chatId !== undefined
? deps.resolveTimeLine(chatId)
: null;
const threadLabel = firstMessage
? deps.getTelegramThreadLabel?.(firstMessage)
: undefined;
const telegramPrefix = createTelegramTurnPrefix({ thread: threadLabel });
return buildTelegramPromptTurnRuntime({
telegramPrefix,
messages,
historyTurns,
queueOrder: deps.allocateQueueOrder(),
rawText: promptRawText,
sourceContext,
statusText: processed.rawText,
files,
promptFiles: processed.promptFiles,
handlerOutputs: processed.handlerOutputs,
timeLine,
inferImageMimeType: guessMediaType,
voiceReplyMode,
voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
voicePromptContribution: computeVoicePromptContribution(
voiceReplyMode,
files,
rawText,
),
});
};
}
function getTelegramVoicePromptContext(
voiceReplyMode: TelegramVoiceReplyMode,
hasVoiceFile: boolean,
): Record<string, string> | undefined {
if (voiceReplyMode === "always") return { "reply mode": "always" };
if (!hasVoiceFile) return undefined;
return { "reply mode": voiceReplyMode };
}
export async function buildTelegramPromptTurn(
options: BuildTelegramPromptTurnOptions,
): Promise<PendingTelegramTurn> {
const firstMessage = options.messages[0];
if (!firstMessage) {
throw new Error("Missing Telegram message for turn creation");
}
const hasVoiceFile = options.files.some(
(f) => f.kind === "voice" || f.kind === "audio",
);
const voiceReplyMode = options.voiceReplyMode ?? getTelegramVoiceReplyMode();
const showVoiceContext =
options.voiceReplyModeConfigured ?? options.voiceReplyMode !== undefined;
const content: TelegramPromptContent[] = [
{
type: "text",
text: buildTelegramTurnPrompt({
telegramPrefix: formatTelegramTurnPrefix(
firstMessage,
options.telegramPrefix,
),
rawText: options.rawText,
files: options.files,
promptFiles: options.promptFiles,
handlerOutputs: options.handlerOutputs,
sourceContext: options.sourceContext,
historyTurns: options.historyTurns,
timeLine: options.timeLine,
voiceContext: showVoiceContext
? getTelegramVoicePromptContext(voiceReplyMode, hasVoiceFile)
: undefined,
}),
},
];
for (const file of options.files) {
if (!file.isImage) continue;
const mediaType = file.mimeType || options.inferImageMimeType(file.path);
if (!mediaType) continue;
const buffer = await options.readBinaryFile(file.path);
content.push({
type: "image",
data: Buffer.from(buffer).toString("base64"),
mimeType: mediaType,
});
}
if (options.voicePromptContribution?.trim()) {
const textItem = content.find((c) => c.type === "text") as
| { type: "text"; text: string }
| undefined;
if (textItem) {
textItem.text = `${textItem.text}\n\n${options.voicePromptContribution.trim()}`;
}
}
return {
kind: "prompt",
chatId: firstMessage.chat.id,
target: getTelegramTurnTarget(firstMessage),
replyToMessageId: firstMessage.message_id,
sourceMessageIds: collectTelegramMessageIds(options.messages),
queueOrder: options.queueOrder,
queueLane: "default",
laneOrder: options.queueOrder,
queuedAttachments: [],
content,
historyText: appendTelegramSourceContext(
formatTelegramHistoryText(
options.rawText,
options.promptFiles ?? options.files,
options.handlerOutputs,
),
options.sourceContext,
),
statusSummary: formatTelegramTurnStatusSummary(
options.statusText ?? options.rawText,
options.promptFiles ?? options.files,
options.handlerOutputs,
),
// Voice tagging (used for preview suppression and prompt guidance)
...computeVoiceTurnFlags(voiceReplyMode, hasVoiceFile),
};
}
export async function buildTelegramPromptTurnRuntime(
options: BuildTelegramPromptTurnRuntimeOptions,
): Promise<PendingTelegramTurn> {
return buildTelegramPromptTurn({
...options,
readBinaryFile: readFile,
});
}
+1338
View File
File diff suppressed because it is too large. Load diff
+341
View File
@@ -0,0 +1,341 @@
/**
* Voice Domain
* Zones: telegram, voice
*
* This module is the single owner of all Voice-specific coordination logic:
* - Voice reply policy (mirror / voice / manual) via getTelegramVoiceReplyMode()
* - Voice turn tagging (voiceReplyPreferred / voiceReplyRequired)
* - Voice-specific prompt contributions for the LLM
* - Voice synthesis provider registry (registration + policy/prompt hooks)
* - Voice markup parsing (planTelegramVoiceReply + helpers)
* - Voice suppression helpers (isVoiceTurn, shouldSuppressPreviewForVoice)
*
* Separation of concerns:
* - All decision logic and domain rules live here.
* - Actual delivery (sending the audio via Telegram) stays in outbound.ts.
*
* Keeps voice policy, turn tagging, prompt contributions, and markup helpers
* out of the queue, preview, turn-building, and delivery domains.
*/
const VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY =
"__piTelegramVoiceSynthesisProviders__";
const VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY =
"__piTelegramVoiceTranscriptionProviders__";
let nextGeneratedVoiceSynthesisProviderId = 0;
let nextGeneratedVoiceTranscriptionProviderId = 0;
function getNextAvailableProviderId<T>(
registry: Map<string, T>,
prefix: string,
nextId: () => number,
): string {
let id: string;
do {
id = `${prefix}-${nextId()}`;
} while (registry.has(id));
return id;
}
export type TelegramVoiceReplyMode = "mirror" | "always" | "manual";
export type TelegramVoiceSynthesisProviderResult =
| string
| {
audioPath: string;
transcriptText?: string;
}
| undefined;
export interface TelegramVoiceTurnView {
voiceReplyPreferred?: boolean;
voiceReplyRequired?: boolean;
hasVoiceInput?: boolean;
userText?: string;
}
export interface TelegramVoiceSynthesisProvider {
(
text: string,
options?: { lang?: string; rate?: string },
): Promise<TelegramVoiceSynthesisProviderResult>;
getVoicePolicy?: () => { replyMode?: TelegramVoiceReplyMode };
getVoicePromptContribution?: (
view: TelegramVoiceTurnView,
) => string | undefined;
}
export type TelegramVoiceTranscriptionProviderResult =
| string
| { text: string; language?: string }
| undefined;
export interface TelegramVoiceTranscriptionFile {
path: string;
fileName?: string;
mimeType?: string;
kind?: string;
}
export interface TelegramVoiceTranscriptionProvider {
(
file: TelegramVoiceTranscriptionFile,
options?: { language?: string },
): Promise<TelegramVoiceTranscriptionProviderResult>;
}
// --- Voice Synthesis Provider Registry ---
function getOrCreateVoiceSynthesisProviderRegistry(): Map<
string,
TelegramVoiceSynthesisProvider
> {
const existing = (globalThis as Record<string, unknown>)[
VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY
];
if (existing instanceof Map)
return existing as Map<string, TelegramVoiceSynthesisProvider>;
const registry = new Map<string, TelegramVoiceSynthesisProvider>();
(globalThis as Record<string, unknown>)[
VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY
] = registry;
return registry;
}
/**
* Register a high-level Telegram voice synthesis provider.
*
* Stable public API callers must pass a stable `options.id` so diagnostics,
* replacement, and cleanup can identify the provider. Omitted ids remain a
* compatibility path for pre-matrix callers and receive generated session-local
* ids.
*/
export function registerTelegramVoiceSynthesisProvider(
provider:
| TelegramVoiceSynthesisProvider
| ((
text: string,
options?: { lang?: string; rate?: string },
) => Promise<TelegramVoiceSynthesisProviderResult>),
options?: { id?: string },
): () => void {
const registry = getOrCreateVoiceSynthesisProviderRegistry();
const id =
options?.id ??
getNextAvailableProviderId(
registry,
"voice-synthesis-provider",
() => nextGeneratedVoiceSynthesisProviderId++,
);
const normalized =
typeof provider === "function"
? (Object.assign(
(text: string, options?: { lang?: string; rate?: string }) =>
provider(text, options),
{
getVoicePolicy: (provider as TelegramVoiceSynthesisProvider)
.getVoicePolicy,
getVoicePromptContribution: (
provider as TelegramVoiceSynthesisProvider
).getVoicePromptContribution,
},
) as TelegramVoiceSynthesisProvider)
: provider;
registry.set(id, normalized);
return () => {
if (registry.get(id) === normalized) registry.delete(id);
};
}
export function getTelegramVoiceSynthesisProviders(): TelegramVoiceSynthesisProvider[] {
return Array.from(getOrCreateVoiceSynthesisProviderRegistry().values());
}
export function hasTelegramVoiceSynthesisProvider(): boolean {
return getOrCreateVoiceSynthesisProviderRegistry().size > 0;
}
export function clearTelegramVoiceSynthesisProviders(): void {
getOrCreateVoiceSynthesisProviderRegistry().clear();
}
function getOrCreateVoiceTranscriptionProviderRegistry(): Map<
string,
TelegramVoiceTranscriptionProvider
> {
const existing = (globalThis as Record<string, unknown>)[
VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY
];
if (existing instanceof Map) {
return existing as Map<string, TelegramVoiceTranscriptionProvider>;
}
const registry = new Map<string, TelegramVoiceTranscriptionProvider>();
(globalThis as Record<string, unknown>)[
VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY
] = registry;
return registry;
}
/**
* Register a high-level Telegram voice transcription provider.
*
* Stable public API callers must pass a stable `options.id`. Omitted ids remain
* a compatibility path for pre-matrix callers and receive generated
* session-local ids.
*/
export function registerTelegramVoiceTranscriptionProvider(
provider: TelegramVoiceTranscriptionProvider,
options?: { id?: string },
): () => void {
const registry = getOrCreateVoiceTranscriptionProviderRegistry();
const id =
options?.id ??
getNextAvailableProviderId(
registry,
"voice-transcription-provider",
() => nextGeneratedVoiceTranscriptionProviderId++,
);
registry.set(id, provider);
return () => {
if (registry.get(id) === provider) registry.delete(id);
};
}
export function getTelegramVoiceTranscriptionProviders(): TelegramVoiceTranscriptionProvider[] {
return Array.from(getOrCreateVoiceTranscriptionProviderRegistry().values());
}
export function hasTelegramVoiceTranscriptionProvider(): boolean {
return getOrCreateVoiceTranscriptionProviderRegistry().size > 0;
}
export function clearTelegramVoiceTranscriptionProviders(): void {
getOrCreateVoiceTranscriptionProviderRegistry().clear();
}
// --- Voice Reply Modes ---
export const TELEGRAM_VOICE_REPLY_MODES = [
"mirror",
"always",
"manual",
] as const;
/**
* Returns the active voice reply mode for the current session.
*
* Pi-telegram owns reply-mode policy through telegram.json. If
* config.voice.replyMode is missing or invalid, the safe default is manual.
*/
export function getTelegramVoiceReplyMode(config?: {
voice?: { replyMode?: string };
}): TelegramVoiceReplyMode {
const configMode = config?.voice?.replyMode;
if (
configMode &&
(TELEGRAM_VOICE_REPLY_MODES as readonly string[]).includes(configMode)
) {
return configMode as TelegramVoiceReplyMode;
}
return "manual";
}
/**
* Returns whether the user wants the voice synthesis provider's transcript attached
* as a caption on the voice message.
*
* Reads from `config.voice.sendTranscript`.
* Default: false (no transcript text sent at all).
*/
export function getTelegramVoiceSendTranscript(config?: {
voice?: { sendTranscript?: boolean };
}): boolean {
return !!config?.voice?.sendTranscript;
}
// --- Voice Turn Helpers ---
/** Small helper to compute the two voice flags from mode + hasVoiceFile */
export function computeVoiceTurnFlags(
voiceReplyMode: TelegramVoiceReplyMode | undefined,
hasVoiceFile: boolean,
) {
return {
voiceReplyPreferred: hasVoiceFile && voiceReplyMode === "mirror",
voiceReplyRequired: voiceReplyMode === "always",
};
}
/** Returns true if the given turn is tagged as a voice turn */
export function isVoiceTurn(
turn:
| { voiceReplyPreferred?: boolean; voiceReplyRequired?: boolean }
| null
| undefined,
): boolean {
return !!(turn?.voiceReplyPreferred || turn?.voiceReplyRequired);
}
// --- Voice Prompt Contribution ---
export function computeVoicePromptContribution(
voiceReplyMode: TelegramVoiceReplyMode | undefined,
files: Array<{ kind?: string }>,
rawText: string,
): string | undefined {
const hasVoiceFile = files.some(
(f) => f.kind === "voice" || f.kind === "audio",
);
const isVoiceTagged =
voiceReplyMode === "always" ||
(voiceReplyMode === "mirror" && hasVoiceFile);
if (!isVoiceTagged) return undefined;
const view: TelegramVoiceTurnView = {
...computeVoiceTurnFlags(voiceReplyMode, hasVoiceFile),
hasVoiceInput: hasVoiceFile,
userText: rawText,
};
// Let the voice synthesis provider supply additional instructions for the LLM when in voice mode.
// When multiple providers are registered, the first one (in registration order)
// that returns a non-empty string wins.
for (const provider of getTelegramVoiceSynthesisProviders()) {
if (typeof provider.getVoicePromptContribution === "function") {
const contribution = provider.getVoicePromptContribution(view);
if (contribution?.trim()) {
return contribution.trim();
}
}
}
return undefined;
}
// --- Preview Suppression ---
/**
* Returns true if the current turn should not show a text preview
* (e.g. because it's a voice reply).
*/
export function shouldSuppressPreviewForVoice(
turn:
| { voiceReplyPreferred?: boolean; voiceReplyRequired?: boolean }
| null
| undefined,
): boolean {
return !!(turn?.voiceReplyPreferred || turn?.voiceReplyRequired);
}
// --- Outbound Markup Re-Exports ---
export {
normalizeMarkdownAfterVoiceExtraction,
planTelegramVoiceReply,
stripTelegramCommentMarkupForDelivery,
stripTelegramCommentMarkupForPreview,
stripTelegramVoiceMarkupForPreview,
} from "./outbound-markup.ts";