chore: fork @llblab/[email protected].0 as @evandrodevbr/oh-my-pi-telegram
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:
commit
d1d04001d0
155 files changed
+107848
No files matched your search
+683
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+1366
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+839
@@ -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
@@ -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
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+331
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 "&";
|
||||
if (char === "<") return "<";
|
||||
if (char === ">") return ">";
|
||||
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;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+846
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+1197
File diff suppressed because it is too large.
Load diff
+915
@@ -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
File diff suppressed because it is too large.
Load diff
+631
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+696
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+341
@@ -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";
|
||||
Reference in new issue
Block a user