export declare const MEDIA_DIR: string; /** The binding key for a chat + optional forum topic thread. */ export declare function bindKey(chat: { id: number; type: string; }, threadId?: number): string; /** Split text into Telegram-sized chunks, preferring line boundaries. */ export declare function chunk(text: string, max?: number): string[]; /** Parse a leading bot command: "/spawn foo" → {cmd:"spawn", arg:"foo"}. */ export declare function parseCommand(text: string): { cmd: string; arg: string; } | null; /** * The state `/tools [on|off]` aims at: `on`/`off` force it, anything else — * including no argument at all — toggles. A convenience command must not turn * into a syntax puzzle: `/tools yes` toggles rather than failing. * Pure — unit tested. */ /** * May we (re)open a Telegram bridge for this channel right now? * * The whole rule, in one place, because two reconcilers ask it: the boot pass * and the 5s loop. They used to answer it separately, and only the boot one knew * how to rebuild a bridge that had died — so a session restarted afterwards * stayed deaf towards Telegram until something unrelated restarted the server. * * `sessionAlive` is the load-bearing term. Without it a dormant channel would * get a `claude` respawned under it just to fill a topic: mirroring an idle * channel is the topic's job, not a live process's. */ export declare function shouldReattachBridge(o: { /** The bound chat: a topic's group, the board's General, or a DM. Absent = * the channel has no Telegram binding at all. */ chatId?: number | null; threadId?: number | null; hasBridge: boolean; sessionAlive: boolean; }): boolean; /** * The display name of whoever sent a Telegram message, for the web cockpit's * author label. Telegram guarantees none of these fields, so the caller must be * able to fall back: an empty string would print a blank author above a bubble, * which reads as a bug rather than as "unknown". Pure — unit tested. */ export declare function senderName(from?: { first_name?: string; last_name?: string; username?: string; }): string | undefined; /** * Should a refused prompt take the hand from the running turn? * * Writing in a topic while the agent is mid-turn used to answer "a response is * already in progress" and DROP what was typed — the message had to be sent * again by hand once the agent went quiet. Telegram has no "interrupt" button, * so the refusal was a dead end. * * Only `busy` qualifies: it is the one refusal an interrupt actually resolves. * And only for a message we still hold — killing a turn for a prompt we cannot * resend would destroy work and replace it with nothing. * * `lastRetried` bounds it to ONE attempt per message: the resend can itself be * refused (another client claimed the session in between), and retrying then * would interrupt again, be refused again — a loop that burns the quota and * never delivers. Pure — unit tested. */ export declare function shouldPreempt(o: { code?: string; text?: string; lastRetried?: string; }): boolean; export declare function nextToolsState(arg: string, current: boolean): boolean; /** * Who may write to the bot in private? * * A group is already bounded to the bound group; a DM was not: with no * allowlist configured, anyone who found the bot got a Claude session on the * machine. So the first to write claims DMs, and everyone else is refused. * * A sender with no id is refused even when nobody has claimed yet: there is * nothing to store, hence nothing that would keep the next one out. * Pure — unit tested. */ export declare function dmGate(owner: number | null, from: number | undefined): "claim" | "allow" | "deny"; /** * The header of a prompt coming from ELSEWHERE — the web, a cron, the CLI. * Without it a Telegram channel only saw the answers: the agent looked like it * was talking to itself, and a cron firing was indistinguishable from a human * message. Pure — unit tested. */ export declare function promptEchoLabel(origin: string | undefined, auto?: boolean): string; /** * When a group is upgraded to a supergroup (enabling Topics), Telegram sends a * `migrate_to_chat_id` service message in the OLD group and changes its chat id. * Returns the new supergroup id iff this message migrates the currently-bound * board group (so we can follow the binding), else null. Pure — unit tested. */ export declare function migratedGroupId(msg: any, currentBound: number | null): number | null; /** A downloadable attachment found in a Telegram message. */ export interface TgAttachment { fileId: string; fileUniqueId: string; kind: "image" | "file"; fileName?: string; fileSize?: number; } /** Extract the attachment of a message: a photo (largest size — Telegram * sorts sizes small → large) or any document (PDF, zip, image sent as * file…). Text-only messages → null. */ export declare function attachmentOf(msg: any): TgAttachment | null; /** * The file extension for an image pasted into the cockpit, from its * `content-type`. * * A WHITELIST, not a split on "/": the header is attacker-controlled and the * result ends up in a filename — `image/../../etc/passwd` must not become a * path. Anything unrecognised gets a neutral `bin`; the agent reads the file by * content anyway, so a wrong-but-harmless extension beats a clever one. */ export declare function pasteExtension(contentType: string): string; /** * The on-disk name for a file pasted into the composer. Keeps the ORIGINAL name * (and extension) when the browser supplied one — Claude reads by extension, so * a real `.pdf`/`.csv` matters — prefixed with a uuid to avoid collisions and * stripped of anything path-ish or shell-hostile. Falls back to the content * type, then a neutral `bin`; the agent reads by content anyway. Pure/testable. */ export declare function pasteFileName(id: string, name: string, contentType: string): string; /** Storage name under ~/.shadok-ai/media: keep the original name so Claude * has context, prefix with file_unique_id to avoid collisions, and strip * anything path-ish or shell-hostile. */ export declare function mediaFileName(att: TgAttachment): string; /** The prompt sent to the session for downloaded attachments: one absolute * path per line (Claude reads them itself — Read for images/PDF/text, * Bash for the rest), then the user's caption if any. */ export declare function attachmentPrompt(items: { path: string; kind: "image" | "file"; }[], caption?: string): string; /** * Render an agent's Markdown into the small HTML subset Telegram accepts * (`parse_mode: "HTML"`): code fences →
, inline `code`, **bold**, *italic*,
* headings → bold, [links](url), and `- ` bullets → •. Code is extracted first
* so its contents aren't reformatted; everything is HTML-escaped so the output
* is always well-formed (unmatched markers stay literal — the caller can safely
* fall back to plain text if Telegram ever rejects it).
*/
export declare function mdToTelegramHtml(md: string): string;
/** Telegram's "typing…" indicator lives ~5 s per sendChatAction and dies on
* every sendMessage — a single beat can't cover a turn that runs for minutes.
* This keeps it alive: an immediate beat, then one every `intervalMs` until
* stop(). start() while beating is a no-op; stop() is idempotent. */
export declare function makeTyping(beat: () => void, intervalMs?: number): {
start: () => void;
stop: () => void;
};
/** Telegram delivers an album (media_group_id) as separate messages, the
* caption often on only one of them. Buffer them per group: each add
* re-arms a short timer; when it fires, the whole group is flushed at once
* so a 3-photo album costs one turn, not three. */
export declare function makeAlbumBuffer(flush: (groupId: string, items: T[]) => void, delayMs?: number): {
add: (groupId: string, item: T) => void;
};
/**
* Serial send queue. Each `send`/`edit` of a bridge fired its own `fetch`
* unawaited, so two calls close together did not necessarily arrive in the
* order they were issued (that is what could put the keyboard ahead of the
* preface). Here operations chain FIFO, and a rejection does not break the
* queue — the caller still gets its error.
*/
export declare function makeSendQueue(): (op: () => Promise) => Promise;
/**
* Are the preface read off the screen and the transcript's authoritative text
* the same block?
*
* We do not require the whole block to coincide: some constructs cannot. A
* Markdown link keeps its URL in the source while the screen shows only the
* label — depending on the rendering, it is one side or the other that carries
* extra text, so no strict inclusion holds both ways. So we compare the
* **opening fingerprint**: the preface's first alphanumeric characters must be
* found in the authoritative text. A divergence further into the paragraph
* becomes inconsequential.
*
* Inclusion (rather than prefix) covers the case where the block's start has
* scrolled off the screen: the preface is then only an inner fragment of it.
*
* Deliberately tolerant; what bounds false positives is that only one preface
* is armed at a time, consumed by the first `stream-text` following its
* question and disarmed at the end of the turn.
*/
export declare function prefaceMatches(preface: string, authoritative: string): boolean;
/**
* Does the preface describe a block that was **already broadcast**?
*
* A preface is read off the screen, and the screen keeps the previous turn's
* answer until the new turn writes anything. Without this guard, every question
* was preceded by a copy of the previous answer — and the duplicate never
* repaired itself: `prefaceMatches` waits for an authoritative twin which, in
* that case, arrived on the previous turn and will not come back.
*
* Same comparator as `prefaceMatches`: one definition of "this is the same
* block" across the project, safety rails included (too short a fragment
* matches nothing).
*/
export declare function isStalePreface(preface: string, recent: string[]): boolean;
interface DialogOption {
n: number;
label: string;
hint?: string;
checked?: boolean;
}
interface Dialog {
question: string;
options: DialogOption[];
multi: boolean;
}
/** Inline keyboard for a TUI dialog. Single-select → one button per option
* (choose); multi-select → toggle buttons (with ☑/☐) + a Submit row. */
/**
* AskUserQuestion's "free text" option. **Exactly the web's rule**
* (`public/index.html`, the `freetext-row`): both clients must agree on what a
* free-text option is, otherwise the same dialog behaves differently depending
* on the screen.
*/
export declare function isFreetextOption(label: string): boolean;
export declare function dialogKeyboard(d: Dialog): {
inline_keyboard: {
text: string;
callback_data: string;
}[][];
};
/** Parse a dialog callback_data → an action for the WS. */
export declare function parseCallback(data: string): {
kind: "choose" | "toggle" | "confirm" | "freetext";
n?: number;
} | null;
export declare function renameTelegramTopic(chatId: number, threadId: number, name: string): void;
export declare function closeTelegramTopic(chatId: number, threadId: number): void;
/**
* Tell the board group, ONCE, that the instance needs signing in again.
*
* Called from the server's spawn refusal, so it covers every door at once: the
* web, a cron that could not fire, `pilotctl`. Silent when Telegram is off or
* no board group is bound.
*
* The flag latches only when a message really went out — otherwise a refusal
* that happened while Telegram was down would burn the one announcement the
* user was ever going to get.
*/
export declare function announceLoggedOut(): void;
/**
* Pure: may we announce a sign-out right now?
*
* Two conditions, and the second is the one that is easy to get wrong. We must
* NOT latch the flag when there is nobody to speak to (Telegram off, no board
* group) — otherwise a refusal that happened while the bridge was down would
* burn the single announcement the user was ever going to get, and the real
* sign-out would then be silent forever.
*/
export declare function shouldAnnounceLoggedOut(alreadyAnnounced: boolean, canSpeak: boolean): boolean;
/** Called when a sign-in succeeds, so the NEXT sign-out is announced again. */
export declare function resetLoggedOutNotice(): void;
export interface TelegramHandle {
stop(): void;
running(): boolean;
status(): {
username: string | null;
tokenError: string | null;
};
}
export declare function startTelegram(port: number, authCookie?: string): TelegramHandle;
/** One-shot getMe against a candidate token — for the /telegram status view. */
export declare function probeToken(token: string | null): Promise<{
username: string | null;
error: string | null;
}>;
export {};