Skip to content

[Feature]: Add a minimal local extension loader for native Telegram commands #201

Description

@NANAMINER

Problem

There is currently no update-safe way to add a small bot-native Telegram command that executes local code without sending a prompt to OpenCode.

My concrete use case is a /quota command that:

  • calls a local quota API or executable;
  • displays provider limits directly in Telegram;
  • optionally provides a Refresh button;
  • does not create an OpenCode session message;
  • does not invoke an LLM or consume model tokens.

Editing the installed npm package works only temporarily because the changes are replaced when the bot is updated. Maintaining a permanent fork also seems excessive for a small local integration.

The existing /commands feature does not cover this case because it executes OpenCode custom commands through session.command. This proposal is specifically about native Telegram handlers implemented locally by the bot.

This is also different from forwarding arbitrary unknown slash commands, which was discussed in #56.

Current architecture

From reviewing the current main branch:

  • bot commands are defined centrally in src/bot/commands/definitions.ts;
  • handlers are registered in src/bot/routers/command-router.ts;
  • callbacks are registered separately through the callback router;
  • BOT_COMMANDS is used for Telegram setMyCommands;
  • authentication and the interaction guard run before the command routers;
  • the bot already has a persistent application directory through getRuntimePaths() and OPENCODE_TELEGRAM_HOME.

Because the application directory survives npm updates, it could also contain trusted local extensions.

Proposal

Add a minimal, opt-in loader for trusted local .mjs extensions.

For example:

<appHome>/extensions/*.mjs

Typical Linux location:

~/.config/opencode-telegram-bot/extensions/

An extension could export a small manifest and a setup function:

export default {
  apiVersion: 1,
  id: "omniroute-quota",

  commands: [
    {
      command: "quota",
      description: "Show provider quota limits",
      allowWhenBusy: true,
    },
  ],

  setup({ composer, logger }) {
    composer.command("quota", async (ctx) => {
      const text = await loadQuotaStatus();
      await ctx.reply(text);
    });

    composer.callbackQuery(
      "ext:omniroute-quota:refresh",
      async (ctx) => {
        const text = await loadQuotaStatus();
        await ctx.editMessageText(text);
        await ctx.answerCallbackQuery();
      },
    );
  },
};

The exact API is open for discussion. The important requirements are:

  • extension files live outside the installed npm package;
  • extensions can register native Telegram commands;
  • extensions can register namespaced callback handlers;
  • extension commands are included in setMyCommands;
  • handlers remain behind the existing authorization middleware;
  • one broken extension does not prevent the bot from starting;
  • built-in commands cannot be overridden;
  • the API is versioned from the beginning.

Suggested MVP boundaries

To keep the first implementation small, the MVP does not need:

  • hot reload;
  • an extension marketplace;
  • automatic extension downloads;
  • dependency installation;
  • sandboxing;
  • project-local extensions;
  • a complex lifecycle API;
  • compatibility with arbitrary npm plugin packages.

Restarting the bot after adding or changing an extension is sufficient.

Extensions should be documented as fully trusted local code running with the same OS permissions as the bot.

Interaction guard

Some local read-only commands, such as /quota, should be usable while an OpenCode task is running.

An extension command could explicitly request this:

{
  command: "quota",
  description: "Show provider quota limits",
  allowWhenBusy: true,
}

The default should remain false.

Possible integration points

A minimal implementation would likely involve:

  • adding extensionsDirPath to src/runtime/paths.ts;
  • loading extension manifests before createBot();
  • merging extension command definitions with the built-in command list;
  • mounting extension composers after authentication and the interaction guard;
  • mounting them before unknown-command and unknown-callback fallbacks;
  • allowing explicitly marked commands through the busy-state guard;
  • adding loader, collision and failure-isolation tests.

Backward compatibility

When the extensions directory does not exist or is empty, the bot should behave exactly as it does today.

Done criteria

  • The bot loads local .mjs extensions from its persistent application directory.
  • npm updates do not delete installed extensions.
  • An extension can register a native Telegram slash command.
  • The command appears in Telegram setMyCommands.
  • The command runs without invoking OpenCode or an LLM.
  • An extension can register a namespaced callback handler.
  • Extension handlers run behind the existing authorization middleware.
  • Built-in commands cannot be overridden.
  • A broken extension does not stop the bot.
  • Unsupported apiVersion values are rejected with a clear log message.
  • The behavior is covered by tests and documented.

I would be willing to work on a PR after confirming that this direction fits the project.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions