Build Messenger Abstraction

Friday has four ways to send a message – grammY's bot API for Telegram, a raw fetch for the HUD, appendFileSync for crash logs, and a callback closure for background task notifications. Every send site knows exactly which transport it's talking to, and every one is coupled to that transport's API, its error handling, and its lifecycle.

The BackgroundTaskNotifier is the most visible symptom. Its constructor takes a (text: string) => void callback for Telegram sends and a hudBaseUrl string for HUD posts:

export class BackgroundTaskNotifier {
    #send: (text: string) => void;
    #hudBaseUrl: string | null;

    constructor(send: (text: string) => void, hudBaseUrl?: string) {
        this.#send = send;
        this.#hudBaseUrl = hudBaseUrl ?? null;
    }
}

This is from src/background-tasks/notifier.ts

It checks task.post_to – a three-value enum of 'telegram', 'hud', or 'none' – and branches accordingly. Adding a new output means touching the notifier's branching logic, the enum type, and every caller that constructs a notifier.

Then there's sendToAll in index.ts, which loops over allowed chat IDs and calls bot.api.sendMessage for each. The boot message does the same loop. The privileged-init callback does the same loop. Three separate fan-out implementations, each with its own error handling – or lack thereof.

The Channel Abstraction

We can fix this with a named-channel system. Each channel type – telegram, hud, file, composed – is a concrete class implementing a ChannelImpl interface:

export interface TextMessage {
    text: string;
    parseMode?: 'HTML' | 'Markdown';
}

export interface HudMessage {
    type: string;
    title: string;
    body?: string;
}

export type Message = TextMessage | HudMessage | ImageMessage | string;

export interface ChannelImpl {
    send(message: Message): Promise<void>;
    sendSync(message: Message): void;
}

This is from src/messenger/types.ts

send() handles normal use. sendSync() is for one specific caller we'll get to shortly. The Message union type lets callers pass a plain string, a structured HUD message, or a text message with parse mode – and each channel type handles the coercion internally.

Let's look at how fan-out works. ComposedChannel holds an array of member channels and delegates to all of them:

export class ComposedChannel implements ChannelImpl {
    #members: ChannelImpl[];

    constructor(members: ChannelImpl[]) {
        this.#members = members;
    }

    async send(message: Message): Promise<void> {
        await Promise.all(
            this.#members.map((member) =>
                member.send(message).catch((err) => {
                    console.error(
                        '[messenger] composed member send failed:',
                        err
                    );
                })
            )
        );
    }
}

This is from src/messenger/channels/composed-channel.ts

Each member's send() is wrapped in .catch() before being passed to Promise.all. If Telegram is down, the HUD post still goes through. The telegram:all channel is a composed channel containing one TelegramChannel per allowed chat ID – so we can replace all three fan-out loops with a single messenger.send('telegram:all', text).

The Registry

We construct channels at startup from config definitions and store them in a registry. The buildRegistry function walks the config, instantiates the right class for each channel type, and returns a Map<string, ChannelImpl>:

export function buildRegistry(deps: RegistryDeps): Map<string, ChannelImpl> {
    const registry = new Map<string, ChannelImpl>();
    const users = deps.users ?? telegramUsers();
    const defs = deps.channelDefs ?? channels();

    for (const [name, def] of Object.entries(defs)) {
        if (def.type === 'telegram') {
            const user = users.find((u) => u.name === def.name);
            if (!user) {
                throw new Error(
                    `telegram channel '${name}' references unknown user '${def.name}'`
                );
            }
            registry.set(
                name,
                new TelegramChannel(deps.sendFnForChatId(user.id))
            );
        } else if (def.type === 'hud') {
            registry.set(
                name,
                new HudChannel(`http://localhost:${deps.port}`, deps.fetchFn)
            );
        } else if (def.type === 'file') {
            registry.set(name, new FileChannel(def.path, deps.fsFns));
        } else if (def.type === 'composed') {
            const members = def.channels.map((memberName) => {
                const member = registry.get(memberName);
                if (!member) {
                    throw new Error(
                        `composed channel '${name}' references unknown member '${memberName}'`
                    );
                }
                return member;
            });
            registry.set(name, new ComposedChannel(members));
        }
    }

    return registry;
}

This is from src/messenger/registry.ts

The telegram:<chatId> channels aren't hard-coded – the registry creates them dynamically from telegram.allowed-chat-ids in the config. When a new user gets added to the allowed list, they automatically get a channel. No duplication between auth config and channel roster.

Composed channels reference their members by name, so those members need to exist in the registry first. The config ordering matters – leaf channels first, composed channels after. It's explicit and predictable. You can look at the channel map and see every output path Friday has.

The Crash Handler Problem

sendSync() exists for one reason – crash handlers.

The uncaughtException handler runs when the event loop may be broken. You can't await a promise if the runtime is dying. FileChannel.sendSync() uses appendFileSync – truly synchronous. The Telegram and HUD versions fire-and-forget their promises, because there's nothing useful to do with the result in a crash context. The method lives on the interface so the messenger doesn't need to know which channel type it's talking to.

There's a chicken-and-egg problem too. We call setupCrashHandler before the messenger exists – it's the first thing in index.ts, because crashes during startup need to be caught. The handler receives a getMessenger() callback that returns null until the messenger is constructed. Pre-messenger crashes fall back to raw appendFileSync. Post-messenger crashes go through the crash-log channel.

Migrating Send Sites

The notifier refactor was the biggest payoff. The BackgroundTaskNotifier went from taking a callback-plus-URL to taking a Messenger instance:

export class BackgroundTaskNotifier {
    #messenger: Messenger;

    constructor(messenger: Messenger) {
        this.#messenger = messenger;
    }

    #send(postTo: string, text: string, hudOpts?: HudMessage): void {
        if (postTo === 'none') {
            return;
        }
        let message: Message;
        if (postTo === 'hud' && hudOpts) {
            message = hudOpts;
        } else {
            message = text;
        }
        this.#messenger.send(postTo, message);
    }
}

This is from src/background-tasks/notifier.ts

Every notification method collapsed into the same pattern – check post_to, pick the message format, call messenger.send(). The old code had a design bug where completions respected post_to but failures, pauses, and interruptions always went straight to Telegram. Making the abstraction uniform fixed that for free.

Removing sendToAll required an audit. Three locations in index.ts had their own fan-out loops, each slightly different. The boot message used for...of with await. The privileged-init callback used .forEach. The sendToAll helper used Promise.all. Finding all three meant grepping for every occurrence of bot.api.sendMessage outside the bridge. Replacing them with messenger.send('telegram:all', ...) was the easy part.

We explicitly left the TelegramBridge alone. Editing messages as Claude streams, adding reactions, showing typing indicators – these are transport-specific operations that don't map to a generic send(channel, message) abstraction. The messenger handles broadcasts and notifications. The bridge handles conversation rendering. Different jobs.

Conclusion

There's one way to send a message now – messenger.send(channelName, message). The messenger resolves the channel name, the channel handles the transport, and the caller doesn't know or care whether it's going to Telegram, the HUD, a file, or all three.

The abstraction didn't enable new capabilities – Friday could already do all of this. What it did was fix a design bug, eliminate three fan-out implementations, and give the notifier a clean injection point. The messenger made the next feature – per-task channel overrides – trivial, because the notifier already spoke in channel names.