diff --git a/.env.example b/.env.example index a3f55f8..6fb6db3 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,11 @@ SLACK_BOT_PUBLIC_URL= # optional: public HTTPS URL for Bolt's Expres SLACK_PORT=3000 # optional: HTTP port for Bolt's ExpressReceiver (webhook mode only, default 3000) SLACK_ALLOWED_USER_IDS=U123,U456 # optional: comma-separated Slack user IDs allowed to use slash commands SLACK_MENTION_USER_ID= # optional: Slack user ID to @mention when --mention is used + +# --- Telegram provider (loaded only if 'telegram' is in ENABLED_PROVIDERS) --- +# Setup walkthrough: see docs/telegram-setup.md +TELEGRAM_BOT_TOKEN= # from @BotFather (https://t.me/BotFather → /newbot) +TELEGRAM_CHAT_ID= # the supergroup ID (negative number, e.g. -1001234567890) or DM chat ID +TELEGRAM_AGENT_ID= # the Maestro agent this bot is bound to (one bot = one agent) +TELEGRAM_ALLOWED_USER_IDS= # comma-separated Telegram user IDs allowed to interact with the bot +TELEGRAM_MENTION_USER_ID= # optional: Telegram user ID to @mention when --mention is used diff --git a/AGENTS-providers.md b/AGENTS-providers.md index b6e5895..07630f9 100644 --- a/AGENTS-providers.md +++ b/AGENTS-providers.md @@ -1,8 +1,10 @@ # Provider development guide -This document is the deep-dive companion to [`AGENTS.md`](AGENTS.md) (and [`docs/architecture.md`](docs/architecture.md)) for adding a new chat-platform provider to Maestro Relay. Discord and Slack are already built-in (see [`docs/discord.md`](docs/discord.md) and [`docs/slack.md`](docs/slack.md)); everything below is what you'd need to know to ship a Teams, Matrix, etc. adapter without touching the kernel. +This document is the deep-dive companion to [`AGENTS.md`](AGENTS.md) (and [`docs/architecture.md`](docs/architecture.md)) for adding a new chat-platform provider to Maestro Relay. Discord, Slack, and Telegram are already built-in (see [`docs/discord.md`](docs/discord.md), [`docs/slack.md`](docs/slack.md), and [`docs/telegram-setup.md`](docs/telegram-setup.md)); everything below is what you'd need to know to ship a Teams, Matrix, etc. adapter without touching the kernel. -If you're adding behavior to an existing provider rather than building a new one, work in `src/providers/discord/` or `src/providers/slack/` and consult the matching `docs/.md` instead. +If you're adding behavior to an existing provider rather than building a new one, work in `src/providers/discord/`, `src/providers/slack/`, or `src/providers/telegram/` and consult the matching `docs/.md` instead. + +> **Single-agent providers**: Some providers (like Telegram) bind one bot to exactly one agent. In that case, enforce the binding in `findOrCreateAgentChannel` by throwing when the requested `agentId` doesn't match the bound one — this keeps `/api/send` from leaking cross-agent traffic into the wrong chat. Users running the provider standalone set `ENABLED_PROVIDERS=`; users running it alongside Discord/Slack set `ENABLED_PROVIDERS=discord,` (or any subset). ## The kernel/provider boundary diff --git a/README.md b/README.md index 556dd44..318057f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ [![Made with Maestro](https://raw.githubusercontent.com/RunMaestro/Maestro/main/docs/assets/made-with-maestro.svg)](https://github.com/RunMaestro/Maestro) -**Maestro Relay** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord and Slack ship in the box; Teams, Matrix, and others can be added by dropping in a provider adapter — the kernel is provider-agnostic. +**Maestro Relay** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord, Slack, and Telegram ship in the box; Teams, Matrix, and others can be added by dropping in a provider adapter — the kernel is provider-agnostic. > **Migrating from `discord-maestro`?** Same codebase, new name. The legacy `maestro-discord` binary is preserved as an alias and all `DISCORD_*` env vars work unchanged. See "Migration" below. ## Features -- Provider-pluggable kernel — Discord and Slack today, Teams/Matrix next +- Provider-pluggable kernel — Discord, Slack, and Telegram today, Teams/Matrix next - Creates dedicated channels for Maestro agents - Per-user session threads (`/session new` or by mentioning the bot) - Per-conversation FIFO queue with typing/reaction indicators @@ -18,7 +18,7 @@ ## Prerequisites - Node.js 22+ -- A bot token for at least one supported provider (Discord or Slack) +- A bot token for at least one supported provider (Discord, Slack, or Telegram) - [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` ## Install (production one-liner) @@ -49,7 +49,7 @@ The legacy aliases `maestro-bridge-ctl` and `maestro-discord-ctl` still work for | systemd user / launchd agent | Auto-start unit | Override any of these with `MAESTRO_RELAY_HOME`, `XDG_CONFIG_HOME`, or `MAESTRO_RELAY_BIN_DIR`. Pin a specific version with `MAESTRO_RELAY_VERSION=v1.0.0`. -Choose a provider module at install time via `MAESTRO_RELAY_MODULE` (`discord` or `slack`). +Choose a provider module at install time via `MAESTRO_RELAY_MODULE` (`discord`, `slack`, or `telegram`). ## Install (development from source) @@ -70,11 +70,11 @@ cp .env.example .env Set core values in `.env`: ``` -ENABLED_PROVIDERS=discord # comma-separated; default 'discord'. Use 'slack' or 'discord,slack' for multi-provider deployments +ENABLED_PROVIDERS=discord # comma-separated; default 'discord'. Use 'slack', 'telegram', or any combination (e.g. 'discord,slack') API_PORT=3457 # optional, default 3457 ``` -Then fill in the provider-specific keys. The Discord provider needs `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID`, and `DISCORD_GUILD_ID` — see [docs/discord.md](docs/discord.md) for bot setup, the full env-var reference, and slash-command deployment. The Slack provider needs `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_TEAM_ID`, and `SLACK_APP_ID` — see [docs/slack.md](docs/slack.md). For optional voice transcription (Discord), see [docs/voice.md](docs/voice.md). +Then fill in the provider-specific keys. The Discord provider needs `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID`, and `DISCORD_GUILD_ID` — see [docs/discord.md](docs/discord.md) for bot setup, the full env-var reference, and slash-command deployment. The Slack provider needs `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_TEAM_ID`, and `SLACK_APP_ID` — see [docs/slack.md](docs/slack.md). The Telegram provider needs `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, and `TELEGRAM_AGENT_ID` — see [docs/telegram-setup.md](docs/telegram-setup.md) for the full BotFather walkthrough. For optional voice transcription, see [docs/voice.md](docs/voice.md). 3. Deploy slash commands (Discord): @@ -119,10 +119,23 @@ npm run build && node --test --experimental-test-coverage dist/__tests__/**/*.te | -------- | ---- | ------ | | Discord | [docs/discord.md](docs/discord.md) — bot setup, env vars, slash commands, runtime behavior | Built-in | | Slack | [docs/slack.md](docs/slack.md) — app setup, env vars, slash commands, runtime behavior | Built-in | +| Telegram | [docs/telegram-setup.md](docs/telegram-setup.md) — BotFather walkthrough, forum-topic-per-session, DM fallback, bot-per-agent binding | Built-in | | Teams / Matrix / … | [AGENTS-providers.md](AGENTS-providers.md) — provider development guide | Add your own | Optional voice transcription (whisper.cpp, Discord-only today): [docs/voice.md](docs/voice.md). +## Telegram + +Bot-per-agent model: each Telegram bot represents one Maestro agent. Recommended setup is a forum supergroup where each session becomes its own topic; DM mode is supported for single-session use. + +### Quick start + +```bash +MAESTRO_RELAY_MODULE=telegram bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh)" +``` + +The full newcomer walkthrough — creating a bot via @BotFather, picking a chat, collecting the IDs the installer asks for — lives in [docs/telegram-setup.md](docs/telegram-setup.md). + ## How it works Mention the bot or run `/session new` in an agent channel to create a thread, then chat — messages are queued and forwarded to the agent via `maestro-cli`. See [docs/architecture.md](docs/architecture.md) for the full message flow and kernel/provider split, and [AGENTS-providers.md](AGENTS-providers.md) for the provider-development guide. diff --git a/bin/maestro-relay-ctl.sh b/bin/maestro-relay-ctl.sh index c4de906..3b98439 100755 --- a/bin/maestro-relay-ctl.sh +++ b/bin/maestro-relay-ctl.sh @@ -68,7 +68,7 @@ Commands: restart Restart the relay service status Show service status logs Tail service logs (Ctrl+C to stop) - deploy Deploy slash commands to Discord + deploy Deploy chat commands for enabled providers (Discord slash commands, Telegram bot commands) update Reinstall the latest release (preserves config) uninstall Remove the relay, service files, and CLI symlinks version Print installed version @@ -143,18 +143,33 @@ cmd_logs() { } config_complete() { - local file="$1" key value + local file="$1" key value enabled_providers provider [ -f "$file" ] || return 1 - local enabled_module - enabled_module="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" - enabled_module="${enabled_module#\"}"; enabled_module="${enabled_module%\"}" - enabled_module="${enabled_module#\'}"; enabled_module="${enabled_module%\'}" - local required_keys - if [ "$enabled_module" = "slack" ]; then - required_keys="SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" - else - required_keys="DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID" - fi + enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" + enabled_providers="${enabled_providers#\"}"; enabled_providers="${enabled_providers%\"}" + enabled_providers="${enabled_providers#\'}"; enabled_providers="${enabled_providers%\'}" + [ -n "$enabled_providers" ] || enabled_providers="discord" + # Validate every enabled provider's required env vars (split CSV), not just + # the first match — ENABLED_PROVIDERS=discord,telegram must pass only when + # both credential sets are present. + local IFS=',' + local required_keys="" + for provider in $enabled_providers; do + provider="${provider// /}" + case "$provider" in + telegram) + required_keys="$required_keys TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID TELEGRAM_AGENT_ID" + ;; + slack) + required_keys="$required_keys SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" + ;; + discord|'') + required_keys="$required_keys DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID" + ;; + *) return 1 ;; + esac + done + unset IFS for key in $required_keys; do value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" [ -n "$value" ] || return 1 @@ -172,15 +187,7 @@ cmd_deploy() { if ! config_complete "$env_file"; then die "Config at $env_file is incomplete or contains template values. Edit it before running deploy." fi - local enabled_providers - enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" - enabled_providers="${enabled_providers#\"}"; enabled_providers="${enabled_providers%\"}" - enabled_providers="${enabled_providers#\'}"; enabled_providers="${enabled_providers%\'}" - [ -z "$enabled_providers" ] && enabled_providers="discord" - case ",$enabled_providers," in - *,discord,*) (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js) ;; - *) die "Discord is not enabled in ENABLED_PROVIDERS=$enabled_providers" ;; - esac + (cd "$INSTALL_DIR" && npm run deploy-commands --silent) } cmd_update() { diff --git a/docs/architecture.md b/docs/architecture.md index 99046c4..5d98c73 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,17 @@ The kernel speaks only in `IncomingMessage` / `OutgoingMessage` / `ChannelTarget - Persists the maestro session id on the first response via `conv.persistSession` 5. Errors are logged to `logs/errors.log` and surfaced as a `⚠️` reply in the channel. +## Message flow (Telegram) + +Telegram uses a **bot-per-agent** model: at install time the bot is bound to one Maestro agent (`TELEGRAM_AGENT_ID`) and one chat (`TELEGRAM_CHAT_ID`). One bot serves exactly one agent for its lifetime. + +1. **Forum mode**: user sends `/session new` in the supergroup main feed → adapter calls `bot.api.createForumTopic`, registers the new topic in `telegram_agent_topics`, and treats that topic as one Maestro session. Subsequent messages in the topic are routed to that session. +2. **DM mode**: the bound chat is a single shared session. `/session new` clears the stored session id so the next message starts a fresh maestro session. +3. Each message becomes an `IncomingMessage` with `channelId = chatId` (DM) or `chatId:topicId` (forum) and is passed to `ctx.enqueue`. +4. The kernel queue serializes per `(provider, channelId)` exactly as for Discord — reactions/typing, `resolveConversation`, attachment download, `maestro.send`, response splitting, usage footer, session persistence. +5. Outbound: `provider.send` posts via `bot.api.sendMessage`, attaching `message_thread_id` when the target is a forum topic. Long responses are split at the 4096-char Telegram message limit. +6. `findOrCreateAgentChannel(agentId)` enforces the single-agent binding by throwing if `agentId !== TELEGRAM_AGENT_ID` — agent-initiated messages from `/api/send` for any other agent are rejected. + ## Thread ownership (Discord) Each thread is bound to the user who created it (via mention or `/session new`). @@ -52,6 +63,7 @@ Each thread is bound to the user who created it (via mention or `/session new`). | ------------------------- | --------------------- | --------------------------------------------------- | | `agent_channels` | core | `(provider, channel_id)` → agent + session + flags | | `discord_agent_threads` | discord provider | Thread → channel + agent + owner + session | +| `telegram_agent_topics` | telegram provider | `(chat_id, topic_id)` → agent + session | The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_id`) is rebuilt with composite PK `(provider, channel_id)` and existing rows defaulted to `discord`; legacy `agent_threads` is renamed to `discord_agent_threads`. @@ -73,6 +85,13 @@ The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_ | `src/providers/discord/voice.ts` | Discord voice-message detection | | `src/providers/discord/commands/` | Slash command handlers | | `src/providers/discord/deploy.ts` | Registers slash commands with Discord API | +| `src/providers/telegram/adapter.ts` | TelegramProvider implementing BridgeProvider | +| `src/providers/telegram/messageHandler.ts` | Telegram update → IncomingMessage | +| `src/providers/telegram/voice.ts` | Telegram voice-message detection + download | +| `src/providers/telegram/topicsDb.ts` | `telegram_agent_topics` accessor | +| `src/providers/telegram/commands/` | Slash command handlers (dispatched by messageHandler) | +| `src/providers/telegram/deploy.ts` | Registers commands via `bot.api.setMyCommands` | +| `src/providers/telegram/config.ts` | `TELEGRAM_*` env loading | | `src/cli/maestro-relay.ts` | CLI tool for agent → chat messaging | | `src/index.ts` | Kernel orchestrator (entry point) | diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md new file mode 100644 index 0000000..a3860cc --- /dev/null +++ b/docs/telegram-setup.md @@ -0,0 +1,165 @@ +# Telegram bot setup + +This guide walks you through creating a Telegram bot, picking a chat for it, +and binding it to a Maestro agent. No prior Telegram-bot experience required — +if you've used Telegram as a chat app, you have everything you need. + +## What you'll end up with + +- A Telegram bot (your own, with a name and avatar you choose) that represents + one Maestro agent. +- A chat where you talk to that agent — either: + - A **forum supergroup** where each Maestro session becomes its own topic + (recommended; mirrors how Discord channels + threads feel), OR + - A **private DM** with the bot (simpler; one running session at a time, + use `/session new` to reset). + +One bot is bound to one agent for its lifetime. To bridge multiple agents, +create one bot per agent (BotFather makes this cheap — see "Multiple agents" +at the bottom). + +## Step 1 — create the bot via @BotFather + +@BotFather is Telegram's official bot for creating other bots. + +1. Open Telegram and search for `@BotFather`, or click + [https://t.me/BotFather](https://t.me/BotFather). +2. Tap **Start** (or send `/start`) to open the conversation. +3. Send `/newbot`. +4. BotFather asks for a **display name** — this is what users see in chats. + Example: `Alice's Coding Agent`. +5. BotFather asks for a **username** — must end in `bot` and be globally + unique. Example: `alice_coding_bot`. +6. BotFather replies with a **bot token** that looks like + `1234567890:ABCdefGHI-jklMNO_pqrSTU_vwxYZ`. + + **Save this token somewhere safe.** You'll paste it into the installer in + Step 4. Treat it like a password — anyone with the token can control your + bot. +7. _(Optional)_ Send `/setuserpic` in the BotFather chat to give the bot an + avatar, or `/setdescription` to give it a description that appears on its + profile. + +## Step 2 — pick a chat for the bot + +Choose **Option A** (forum supergroup) for the smoothest experience, or +**Option B** (private DM) if you just want to start fast. + +### Option A: forum supergroup (recommended) + +Forum supergroups let each Maestro session live in its own _topic_, similar to +how Discord uses channels + threads. You'll be able to start fresh sessions +with `/session new` without losing previous conversations. + +1. In Telegram, tap the menu and choose **New Group**. Add at least one other + contact (you can remove them after creating the group) — Telegram requires + at least one other member to create a group. +2. Once the group exists, open its **Info** panel (tap the group name at the + top), then tap **Edit** (pencil icon) → **Group Type** and pick either + **Public Group** or **Private Group**. Either choice converts it to a + _supergroup_ under the hood, which is required for topics. +3. Open the Info panel again → **Topics** → toggle **on**. The group's main + feed is now the "General" topic, and you can create more. +4. Add your bot to the group: Info → **Add Members** → search for your bot's + `@username` (the one you set in Step 1) → tap to add. +5. Promote the bot to admin with the **Manage Topics** permission: + Info → **Administrators** → **Add Admin** → pick your bot → grant + **Manage Topics**. Leave every other admin permission off — the bot + doesn't need them. +6. Get the supergroup's **chat ID**: + - Forward any message from the supergroup to + [@userinfobot](https://t.me/userinfobot). + - It replies with `Forwarded from chat: ... id: -1001234567890`. Copy that + negative number — that's your `TELEGRAM_CHAT_ID`. + +### Option B: private DM with the bot + +Simpler, single-session at a time, no topic management. Good for solo use. + +1. In Telegram, search for your bot's `@username` and tap **Start** (or send + `/start`). +2. Forward your `/start` message (or any other message you sent the bot) to + [@userinfobot](https://t.me/userinfobot). +3. It replies with your DM chat ID — a positive number. That's your + `TELEGRAM_CHAT_ID`. + +## Step 3 — note your own Telegram user ID + +This goes into the access allowlist so only you (and people you list) can use +the bot. Skipping the allowlist is fine for a private bot, but recommended for +anything in a shared supergroup. + +- Send any message to [@userinfobot](https://t.me/userinfobot). It replies + with your numeric user ID (e.g. `987654321`). +- Repeat for any other allowed users — collect their IDs into a comma- + separated list (e.g. `987654321,123456789`). + +## Step 4 — run the installer + +The installer prompts for each of the values you collected. Run it with the +`MAESTRO_RELAY_MODULE=telegram` flag so it picks the Telegram walkthrough: + +```sh +MAESTRO_RELAY_MODULE=telegram bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh)" +``` + +You'll be asked for: + +- The **bot token** from Step 1. +- The **chat ID** from Step 2. +- A **Maestro agent** to bind the bot to. The installer lists your local + agents and lets you pick by number. +- _(Optional)_ The **allowed user IDs** from Step 3. + +The bot is bound to the chosen agent for its lifetime. To bridge a different +agent, run a separate bridge instance with its own `.env` (and a separate bot +from BotFather — see "Multiple agents" below). + +## Step 5 — start the bridge + +```sh +maestro-relay-ctl start +maestro-relay-ctl logs +``` + +In your Telegram chat: + +- **Forum mode**: send `/session new` in the supergroup's main feed — a new + topic appears. Send messages inside that topic to talk to the agent. Run + `/session new` again to start another session in a fresh topic. +- **DM mode**: just send a message. `/session new` resets the session in + place. + +Try `/health` to confirm the bridge is reaching `maestro-cli`. + +## Multiple agents + +One Telegram bot = one Maestro agent. To bridge multiple agents: + +1. Create a separate bot in BotFather for each agent (Step 1, repeated). +2. Run a separate bridge instance per bot, each with its own: + - `.env` file (different `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, and + `TELEGRAM_AGENT_ID`). + - `API_PORT` (so they don't conflict with each other on the same machine). + - systemd unit (override the unit name when installing). + +This is intentional — Telegram bots take ~30 seconds to create in BotFather, +and giving each agent its own bot identity makes for a much cleaner UX than +multiplexing many agents through a single bot. + +## Troubleshooting + +- **Bot doesn't reply in a forum supergroup**: confirm the bot is an admin + with the **Manage Topics** permission. Without it, the bridge cannot create + forum topics and the call fails silently. +- **"Telegram bot is bound to agent X; cannot serve agent Y" in logs**: + `maestro-relay send --agent Y --provider telegram` was called against a bot + bound to a different agent. Use the bridge for agent Y instead, or run a + second bridge instance for agent Y with its own bot. +- **No reactions appear (⏳)**: Telegram only allows a curated set of emoji + reactions on messages. The bridge already falls back gracefully — typing + indicators continue to work even if the reaction emoji isn't accepted in + your chat type. +- **Voice messages aren't transcribed**: confirm `ffmpeg` and `whisper-cli` + are on your `PATH` and `WHISPER_MODEL_PATH` points at a real model file. + The setup is identical to the Discord provider's voice support. diff --git a/install.sh b/install.sh index ab64d51..bbe5818 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,8 @@ # Usage: # curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh | bash # Re-run to upgrade to the latest release. Existing config is preserved. -# Optional: MAESTRO_RELAY_MODULE=discord (currently the only supported module). +# Optional: MAESTRO_RELAY_MODULE=discord|telegram (default: discord). +# Run `MAESTRO_RELAY_MODULE=telegram bash install.sh` to install the Telegram bridge. # # Legacy MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* env vars are accepted as fallback so v0.0.x # installs upgrading via `maestro-discord-ctl update` keep working. @@ -96,8 +97,9 @@ normalize_module() { raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" case "$raw" in discord|'') echo "discord" ;; - slack) echo "slack" ;; - *) die "Unsupported module/provider: $raw (supported: discord, slack)" ;; + slack) echo "slack" ;; + telegram) echo "telegram" ;; + *) die "Unsupported module/provider: $raw (supported: discord, slack, telegram)" ;; esac } @@ -225,23 +227,78 @@ write_config() { return fi + MODULE="$(normalize_module "$MODULE")" + case "$MODULE" in + discord) write_config_discord "$env_file" ;; + slack) write_config_slack "$env_file" ;; + telegram) write_config_telegram "$env_file" ;; + esac +} + +write_config_discord() { + local env_file="$1" + local interactive=0 can_read_tty && interactive=1 local have_required=0 - MODULE="$(normalize_module "$MODULE")" - if [ "$MODULE" = "slack" ]; then - if [ -n "${SLACK_BOT_TOKEN:-}" ] \ - && [ -n "${SLACK_SIGNING_SECRET:-}" ] \ - && [ -n "${SLACK_TEAM_ID:-}" ] \ - && [ -n "${SLACK_APP_ID:-}" ]; then - have_required=1 - fi + if [ -n "${DISCORD_BOT_TOKEN:-}" ] \ + && [ -n "${DISCORD_CLIENT_ID:-}" ] \ + && [ -n "${DISCORD_GUILD_ID:-}" ]; then + have_required=1 + fi + + if [ "$interactive" -eq 0 ] && [ "$have_required" -eq 0 ]; then + info "Non-interactive shell — writing template to $env_file (edit before starting)" + cp "$INSTALL_DIR/.env.example" "$env_file" + chmod 600 "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" + return + fi + + if [ "$interactive" -eq 1 ]; then + info "Configuring $env_file" + echo " Find these values in https://discord.com/developers/applications" else - if [ -n "${DISCORD_BOT_TOKEN:-}" ] \ - && [ -n "${DISCORD_CLIENT_ID:-}" ] \ - && [ -n "${DISCORD_GUILD_ID:-}" ]; then - have_required=1 - fi + info "Writing config from environment to $env_file" + fi + + local tmp_env + tmp_env="$(mktemp "${env_file}.XXXXXX")" + chmod 600 "$tmp_env" + { + printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'ENABLED_PROVIDERS=%s\n' "$MODULE" + printf 'API_PORT=3457\n' + local token client_id guild_id allowed + token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" + client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" + guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" + allowed="$(prompt_var DISCORD_ALLOWED_USER_IDS 'Allowed user IDs (comma-separated, optional)')" + printf 'DISCORD_BOT_TOKEN=%s\n' "$token" + printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" + printf 'DISCORD_GUILD_ID=%s\n' "$guild_id" + printf 'DISCORD_ALLOWED_USER_IDS=%s\n' "$allowed" + printf 'DISCORD_MENTION_USER_ID=\n' + printf 'FFMPEG_PATH=%s\n' "${VOICE_FFMPEG:-ffmpeg}" + printf 'WHISPER_CLI_PATH=%s\n' "${VOICE_WHISPER:-whisper-cli}" + printf 'WHISPER_MODEL_PATH=%s\n' "${VOICE_MODEL:-models/${DEFAULT_MODEL_NAME}}" + } > "$tmp_env" + mv "$tmp_env" "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" + ok "Wrote $env_file" +} + +write_config_slack() { + local env_file="$1" + + local interactive=0 + can_read_tty && interactive=1 + local have_required=0 + if [ -n "${SLACK_BOT_TOKEN:-}" ] \ + && [ -n "${SLACK_SIGNING_SECRET:-}" ] \ + && [ -n "${SLACK_TEAM_ID:-}" ] \ + && [ -n "${SLACK_APP_ID:-}" ]; then + have_required=1 fi if [ "$interactive" -eq 0 ] && [ "$have_required" -eq 0 ]; then @@ -253,13 +310,8 @@ write_config() { fi if [ "$interactive" -eq 1 ]; then - if [ "$MODULE" = "slack" ]; then - info "Configuring $env_file" - echo " Find these values in https://api.slack.com/apps" - else - info "Configuring $env_file" - echo " Find these values in https://discord.com/developers/applications" - fi + info "Configuring $env_file" + echo " Find these values in https://api.slack.com/apps" else info "Writing config from environment to $env_file" fi @@ -271,36 +323,23 @@ write_config() { printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'ENABLED_PROVIDERS=%s\n' "$MODULE" printf 'API_PORT=3457\n' - if [ "$MODULE" = "slack" ]; then - local bot_token signing_secret team_id app_id socket_token slack_allowed mention_user slack_port - bot_token="$(prompt_var SLACK_BOT_TOKEN 'Slack bot token (xoxb-...)')" - signing_secret="$(prompt_var SLACK_SIGNING_SECRET 'Slack signing secret')" - team_id="$(prompt_var SLACK_TEAM_ID 'Slack team (workspace) ID')" - app_id="$(prompt_var SLACK_APP_ID 'Slack app ID')" - socket_token="$(prompt_var SLACK_SOCKET_MODE_TOKEN 'Slack Socket Mode app-level token (xapp-..., optional)')" - slack_allowed="$(prompt_var SLACK_ALLOWED_USER_IDS 'Allowed Slack user IDs (comma-separated, optional)')" - mention_user="$(prompt_var SLACK_MENTION_USER_ID 'Slack mention user ID (optional)')" - slack_port="$(prompt_var SLACK_PORT 'Slack HTTP port (optional, default 3000)')" - printf 'SLACK_BOT_TOKEN=%s\n' "$bot_token" - printf 'SLACK_SIGNING_SECRET=%s\n' "$signing_secret" - printf 'SLACK_TEAM_ID=%s\n' "$team_id" - printf 'SLACK_APP_ID=%s\n' "$app_id" - printf 'SLACK_SOCKET_MODE_TOKEN=%s\n' "$socket_token" - printf 'SLACK_ALLOWED_USER_IDS=%s\n' "$slack_allowed" - printf 'SLACK_MENTION_USER_ID=%s\n' "$mention_user" - printf 'SLACK_PORT=%s\n' "${slack_port:-3000}" - else - local token client_id guild_id allowed - token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" - client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" - guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" - allowed="$(prompt_var DISCORD_ALLOWED_USER_IDS 'Allowed user IDs (comma-separated, optional)')" - printf 'DISCORD_BOT_TOKEN=%s\n' "$token" - printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" - printf 'DISCORD_GUILD_ID=%s\n' "$guild_id" - printf 'DISCORD_ALLOWED_USER_IDS=%s\n' "$allowed" - printf 'DISCORD_MENTION_USER_ID=\n' - fi + local bot_token signing_secret team_id app_id socket_token slack_allowed mention_user slack_port + bot_token="$(prompt_var SLACK_BOT_TOKEN 'Slack bot token (xoxb-...)')" + signing_secret="$(prompt_var SLACK_SIGNING_SECRET 'Slack signing secret')" + team_id="$(prompt_var SLACK_TEAM_ID 'Slack team (workspace) ID')" + app_id="$(prompt_var SLACK_APP_ID 'Slack app ID')" + socket_token="$(prompt_var SLACK_SOCKET_MODE_TOKEN 'Slack Socket Mode app-level token (xapp-..., optional)')" + slack_allowed="$(prompt_var SLACK_ALLOWED_USER_IDS 'Allowed Slack user IDs (comma-separated, optional)')" + mention_user="$(prompt_var SLACK_MENTION_USER_ID 'Slack mention user ID (optional)')" + slack_port="$(prompt_var SLACK_PORT 'Slack HTTP port (optional, default 3000)')" + printf 'SLACK_BOT_TOKEN=%s\n' "$bot_token" + printf 'SLACK_SIGNING_SECRET=%s\n' "$signing_secret" + printf 'SLACK_TEAM_ID=%s\n' "$team_id" + printf 'SLACK_APP_ID=%s\n' "$app_id" + printf 'SLACK_SOCKET_MODE_TOKEN=%s\n' "$socket_token" + printf 'SLACK_ALLOWED_USER_IDS=%s\n' "$slack_allowed" + printf 'SLACK_MENTION_USER_ID=%s\n' "$mention_user" + printf 'SLACK_PORT=%s\n' "${slack_port:-3000}" printf 'FFMPEG_PATH=%s\n' "${VOICE_FFMPEG:-ffmpeg}" printf 'WHISPER_CLI_PATH=%s\n' "${VOICE_WHISPER:-whisper-cli}" printf 'WHISPER_MODEL_PATH=%s\n' "${VOICE_MODEL:-models/${DEFAULT_MODEL_NAME}}" @@ -310,20 +349,224 @@ write_config() { ok "Wrote $env_file" } -config_complete() { - local file="$1" key value - [ -f "$file" ] || return 1 - local enabled_module - enabled_module="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" - enabled_module="${enabled_module#\"}"; enabled_module="${enabled_module%\"}" - enabled_module="${enabled_module#\'}"; enabled_module="${enabled_module%\'}" - local required_keys - if [ "$enabled_module" = "slack" ]; then - required_keys="SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" +write_config_telegram() { + local env_file="$1" + + local interactive=0 + can_read_tty && interactive=1 + local have_required=0 + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] \ + && [ -n "${TELEGRAM_CHAT_ID:-}" ] \ + && [ -n "${TELEGRAM_AGENT_ID:-}" ]; then + have_required=1 + fi + + if [ "$interactive" -eq 0 ] && [ "$have_required" -eq 0 ]; then + info "Non-interactive shell — writing template to $env_file (edit before starting)" + cp "$INSTALL_DIR/.env.example" "$env_file" + chmod 600 "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" + return + fi + + if [ "$interactive" -eq 1 ]; then + info "Configuring Telegram provider" + echo + echo " $(c_bold 'Step 1 — create your bot via @BotFather')" + echo " 1. Open Telegram and search for @BotFather (https://t.me/BotFather)" + echo " 2. Send /newbot and follow the prompts (give it a display name and username)" + echo " 3. Copy the bot token BotFather returns (looks like 1234567890:ABC-XYZ...)" + echo " Full walkthrough with screenshots: docs/telegram-setup.md" + echo + else + info "Writing Telegram config from environment to $env_file" + fi + local token + token="$(prompt_var TELEGRAM_BOT_TOKEN 'Telegram bot token')" + + if [ "$interactive" -eq 1 ]; then + echo + echo " $(c_bold 'Step 2 — pick a chat for your bot')" + echo " Recommended: a $(c_bold 'forum supergroup') (each Maestro session becomes its own topic)." + echo " a. Create a new Telegram group, then convert it to a supergroup (Group Settings → Group Type → Public/Private supergroup)" + echo " b. Enable Topics: Group Settings → Topics → toggle on" + echo " c. Add your bot as a member, promote to admin with 'Manage Topics' permission" + echo " d. Find the chat ID: forward any message from the group to @userinfobot — it replies with the supergroup ID (a negative number like -1001234567890)" + echo + echo " Alternative: a private DM with your bot (simpler; no topics, sessions reset in place via /new)." + echo " a. In Telegram, search your bot's @username and send /start" + echo " b. Forward your /start message to @userinfobot to get your DM chat ID (positive number)" + echo + fi + local chat_id + chat_id="$(prompt_var TELEGRAM_CHAT_ID 'Telegram chat ID (supergroup or DM)')" + + if [ "$interactive" -eq 1 ]; then + echo + echo " $(c_bold 'Step 3 — bind this bot to a Maestro agent')" + fi + local agent_id + agent_id="$(pick_telegram_agent)" + + if [ "$interactive" -eq 1 ]; then + echo + echo " $(c_bold 'Step 4 — restrict access (recommended)')" + echo " Comma-separated list of Telegram user IDs allowed to interact with the bot." + echo " Find your own user ID by sending any message to @userinfobot." + echo " Leave empty for no allowlist (anyone in the chat can use the bot)." + fi + local allowed + allowed="$(prompt_var TELEGRAM_ALLOWED_USER_IDS 'Allowed user IDs (optional)')" + + local tmp_env + tmp_env="$(mktemp "${env_file}.XXXXXX")" + chmod 600 "$tmp_env" + { + printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'ENABLED_PROVIDERS=telegram\n' + printf 'API_PORT=3457\n' + printf 'TELEGRAM_BOT_TOKEN=%s\n' "$token" + printf 'TELEGRAM_CHAT_ID=%s\n' "$chat_id" + printf 'TELEGRAM_AGENT_ID=%s\n' "$agent_id" + printf 'TELEGRAM_ALLOWED_USER_IDS=%s\n' "$allowed" + printf 'TELEGRAM_MENTION_USER_ID=\n' + printf 'FFMPEG_PATH=%s\n' "${VOICE_FFMPEG:-ffmpeg}" + printf 'WHISPER_CLI_PATH=%s\n' "${VOICE_WHISPER:-whisper-cli}" + printf 'WHISPER_MODEL_PATH=%s\n' "${VOICE_MODEL:-models/${DEFAULT_MODEL_NAME}}" + } > "$tmp_env" + mv "$tmp_env" "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" + ok "Wrote $env_file" +} + +pick_telegram_agent() { + if [ -n "${TELEGRAM_AGENT_ID:-}" ]; then + echo "$TELEGRAM_AGENT_ID" + return + fi + if ! command -v maestro-cli >/dev/null 2>&1; then + warn "maestro-cli not found on PATH — type the Maestro agent ID manually." + prompt_var TELEGRAM_AGENT_ID 'Maestro agent ID' + return + fi + + local json + json="$(maestro-cli list agents --json 2>/dev/null || echo '[]')" + + local parsed="" + if command -v jq >/dev/null 2>&1; then + parsed="$(printf '%s' "$json" | jq -r '.[] | (.id + "\t" + (.name // "(unnamed)"))' 2>/dev/null || true)" else - required_keys="DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID" + parsed="$(printf '%s' "$json" | node -e ' +let s = ""; +process.stdin.on("data", d => s += d); +process.stdin.on("end", () => { + try { + const a = JSON.parse(s); + if (!Array.isArray(a)) return; + for (const x of a) { + process.stdout.write((x.id || "") + "\t" + (x.name || "(unnamed)") + "\n"); + } + } catch (e) {} +}); +' 2>/dev/null || true)" + fi + + if [ -z "$parsed" ]; then + warn "No Maestro agents available — type the agent ID manually." + prompt_var TELEGRAM_AGENT_ID 'Maestro agent ID' + return + fi + + local i=0 + local -a ids=() + printf ' Available Maestro agents:\n' >&2 + while IFS=$'\t' read -r id name; do + [ -z "$id" ] && continue + i=$((i + 1)) + ids+=("$id") + printf ' %d) %s (id: %s)\n' "$i" "$name" "$id" >&2 + done <<< "$parsed" + + if [ "$i" -eq 0 ]; then + warn "No Maestro agents parsed — type the agent ID manually." + prompt_var TELEGRAM_AGENT_ID 'Maestro agent ID' + return + fi + + local choice="" + if can_read_tty; then + read -r -p " Pick an agent by number (or paste an agent ID): " choice =24" } }, + "node_modules/@grammyjs/types": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", + "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1324,6 +1331,18 @@ "npm": ">=7.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2129,6 +2148,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2489,6 +2517,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/grammy": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", + "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.26.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2988,6 +3031,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3708,6 +3771,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3937,6 +4006,22 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2c42a7e..277c2ff 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev": "tsx src/index.ts", "build": "rm -rf dist && tsc", "start": "node dist/index.js", - "deploy-commands": "tsx src/providers/discord/deploy.ts", + "deploy-commands": "node dist/scripts/deploy-commands.js", "test": "npm run build && node --test dist/__tests__/**/*.test.js", "maestro-relay": "tsx src/cli/maestro-relay.ts", "maestro-bridge": "tsx src/cli/maestro-relay.ts", @@ -25,7 +25,8 @@ "@slack/bolt": "^4.6.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", - "dotenv": "^16.0.0" + "dotenv": "^16.0.0", + "grammy": "^1.42.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/__tests__/attachments.test.ts b/src/__tests__/attachments.test.ts index 1d3c429..5b527e8 100644 --- a/src/__tests__/attachments.test.ts +++ b/src/__tests__/attachments.test.ts @@ -182,6 +182,64 @@ test('downloadAttachments reports all files as failed when mkdir fails', async ( assert.deepEqual(failed, ['a.txt', 'b.txt']); }); +test('downloadAttachments calls resolveUrl just-in-time and downloads from its result', async () => { + let resolveCalls = 0; + let fetchedUrl = ''; + globalThis.fetch = (url: string | URL | Request) => { + fetchedUrl = String(url); + return Promise.resolve(okResponse('lazy content')); + }; + + const { downloaded, failed } = await downloadAttachments( + [ + makeAttachment({ + name: 'lazy.bin', + url: '', + size: 100, + resolveUrl: async () => { + resolveCalls++; + return 'https://api.example.com/files/short-lived-token-abc'; + }, + }), + ], + tmpDir, + ); + + assert.equal(resolveCalls, 1, 'resolveUrl must be called once per attachment'); + assert.equal( + fetchedUrl, + 'https://api.example.com/files/short-lived-token-abc', + 'fetch must receive the just-in-time URL, not the empty placeholder', + ); + assert.equal(downloaded.length, 1); + assert.deepEqual(failed, []); + const content = await readFile(downloaded[0].savedPath, 'utf-8'); + assert.equal(content, 'lazy content'); +}); + +test('downloadAttachments reports the file as failed when resolveUrl throws', async () => { + globalThis.fetch = () => { + throw new Error('fetch must not be called if resolveUrl fails'); + }; + + const { downloaded, failed } = await downloadAttachments( + [ + makeAttachment({ + name: 'expired.bin', + url: '', + size: 100, + resolveUrl: async () => { + throw new Error('upstream getFile failed'); + }, + }), + ], + tmpDir, + ); + + assert.equal(downloaded.length, 0); + assert.deepEqual(failed, ['expired.bin']); +}); + test('downloadAttachments handles partial failures', async () => { let callCount = 0; globalThis.fetch = () => { diff --git a/src/__tests__/telegramDispatchCommand.test.ts b/src/__tests__/telegramDispatchCommand.test.ts new file mode 100644 index 0000000..d189b4b --- /dev/null +++ b/src/__tests__/telegramDispatchCommand.test.ts @@ -0,0 +1,188 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + COMMANDS, + dispatchCommand, + type DispatchCommandContext, +} from '../providers/telegram/commands'; +import type { TelegramCommandContext } from '../providers/telegram/commands/types'; + +function makeCtx(overrides: Partial = {}): { + ctx: DispatchCommandContext; + replies: string[]; +} { + const replies: string[] = []; + const ctx: DispatchCommandContext = { + bot: { botInfo: { username: 'MyBot' } } as DispatchCommandContext['bot'], + chatId: 'chat-1', + threadId: undefined, + fromUserId: 'user-1', + boundAgentId: 'agent-1', + boundAgentName: 'My Agent', + chatMode: 'dm', + reply: async (text: string) => { + replies.push(text); + }, + ...overrides, + }; + return { ctx, replies }; +} + +test('COMMANDS exposes all seven Telegram commands with descriptions', () => { + const expected = ['health', 'agents', 'session', 'gist', 'playbook', 'notes', 'auto-run']; + for (const name of expected) { + const entry = COMMANDS[name]; + assert.ok(entry, `missing command ${name}`); + assert.equal(typeof entry.description, 'string'); + assert.ok(entry.description.length > 0, `${name} description should be non-empty`); + assert.equal(typeof entry.execute, 'function'); + } +}); + +test('dispatchCommand returns false for non-slash text', async () => { + const { ctx } = makeCtx(); + assert.equal(await dispatchCommand('hello there', ctx), false); + assert.equal(await dispatchCommand('', ctx), false); + assert.equal(await dispatchCommand('/', ctx), false); +}); + +test('dispatchCommand returns false for unknown commands', async () => { + const { ctx, replies } = makeCtx(); + assert.equal(await dispatchCommand('/notarealcommand', ctx), false); + assert.deepEqual(replies, [], 'unknown command should not produce a reply'); +}); + +test('dispatchCommand parses bare command with no args', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.health.execute; + COMMANDS.health.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/health', ctx); + assert.equal(handled, true); + assert.equal(seen.length, 1); + assert.deepEqual(seen[0].args, []); + assert.equal(seen[0].rawText, '/health'); + assert.equal(seen[0].chatId, 'chat-1'); + assert.equal(seen[0].boundAgentName, 'My Agent'); + } finally { + COMMANDS.health.execute = original; + } +}); + +test('dispatchCommand splits positional args on whitespace', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents list one two', ctx); + assert.equal(handled, true); + assert.deepEqual(seen[0].args, ['list', 'one', 'two']); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand accepts @ suffix matching our bot username', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents@MyBot list', ctx); + assert.equal(handled, true); + assert.equal(seen.length, 1); + assert.deepEqual(seen[0].args, ['list']); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand is case-insensitive for the @ suffix', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents@mybot list', ctx); + assert.equal(handled, true); + assert.deepEqual(seen[0].args, ['list']); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand returns false when @ suffix targets a different bot', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.agents.execute; + COMMANDS.agents.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/agents@OtherBot list', ctx); + assert.equal(handled, false, 'command targeting another bot must not dispatch'); + assert.equal(seen.length, 0); + } finally { + COMMANDS.agents.execute = original; + } +}); + +test('dispatchCommand is case-insensitive for command name', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.health.execute; + COMMANDS.health.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/HEALTH', ctx); + assert.equal(handled, true); + assert.equal(seen.length, 1); + } finally { + COMMANDS.health.execute = original; + } +}); + +test('dispatchCommand handles hyphenated command name auto-run', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS['auto-run'].execute; + COMMANDS['auto-run'].execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const handled = await dispatchCommand('/auto-run start docs/foo.md', ctx); + assert.equal(handled, true); + assert.deepEqual(seen[0].args, ['start', 'docs/foo.md']); + } finally { + COMMANDS['auto-run'].execute = original; + } +}); + +test('dispatchCommand preserves rawText including args', async () => { + const seen: TelegramCommandContext[] = []; + const original = COMMANDS.gist.execute; + COMMANDS.gist.execute = async (cmdCtx) => { + seen.push(cmdCtx); + }; + try { + const { ctx } = makeCtx(); + const text = '/gist --public a custom description'; + const handled = await dispatchCommand(text, ctx); + assert.equal(handled, true); + assert.equal(seen[0].rawText, text); + assert.deepEqual(seen[0].args, ['--public', 'a', 'custom', 'description']); + } finally { + COMMANDS.gist.execute = original; + } +}); diff --git a/src/__tests__/telegramSessionNew.test.ts b/src/__tests__/telegramSessionNew.test.ts new file mode 100644 index 0000000..faea7d9 --- /dev/null +++ b/src/__tests__/telegramSessionNew.test.ts @@ -0,0 +1,155 @@ +import test, { afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMessageHandler } from '../providers/telegram/messageHandler'; +import { topicDb } from '../providers/telegram/topicsDb'; +import { channelDb as coreChannelDb } from '../core/db'; + +const BOUND_CHAT = 'tg-chat-session-new'; +const BOUND_AGENT = 'agent-session-new'; + +afterEach(() => { + try { + coreChannelDb.remove('telegram', BOUND_CHAT); + } catch { + /* ignore */ + } + for (const row of topicDb.listByChat(BOUND_CHAT)) { + try { + topicDb.remove(BOUND_CHAT, row.topic_id); + } catch { + /* ignore */ + } + } +}); + +type FakeBotApiCall = { method: string; args: unknown[] }; + +function makeFakeBot(opts: { newTopicId?: number } = {}) { + const calls: FakeBotApiCall[] = []; + const api = { + createForumTopic: async (chatId: string, name: string) => { + calls.push({ method: 'createForumTopic', args: [chatId, name] }); + return { message_thread_id: opts.newTopicId ?? 555, name }; + }, + sendMessage: async (chatId: string, text: string, options?: unknown) => { + calls.push({ method: 'sendMessage', args: [chatId, text, options] }); + return { message_id: 1 }; + }, + }; + return { bot: { api } as any, calls }; +} + +function makeCtx(text: string, threadId?: number) { + const message: Record = { + message_id: 42, + text, + }; + if (threadId !== undefined) { + message.message_thread_id = threadId; + message.is_topic_message = true; + } + return { + message, + from: { id: 7, is_bot: false, username: 'tester' }, + chat: { id: BOUND_CHAT, type: 'supergroup' }, + } as any; +} + +function baseDeps(overrides: Partial[0]>) { + return { + boundChatId: BOUND_CHAT, + boundAgentId: BOUND_AGENT, + chatMode: 'forum' as const, + resolveAgentName: async () => 'My Agent', + allowedUserIds: [], + enqueue: () => undefined, + isVoiceMessage: () => false, + downloadVoice: async () => { + throw new Error('not used'); + }, + attachmentsFromMessage: () => [], + transcribeVoiceAttachment: async () => '', + isTranscriberAvailable: () => false, + logger: { warn: () => undefined, error: () => undefined }, + ...overrides, + } as Parameters[0]; +} + +test('forum mode: /session new creates a topic, registers it, replies in topic, and skips enqueue', async () => { + const { bot, calls } = makeFakeBot({ newTopicId: 901 }); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('/session new')); + + assert.equal(enqueued, 0, 'should not enqueue the slash command'); + const created = calls.find((c) => c.method === 'createForumTopic'); + assert.ok(created, 'should call createForumTopic'); + assert.equal((created!.args[0] as string), BOUND_CHAT); + assert.match(created!.args[1] as string, /^My Agent session /); + + const sent = calls.find((c) => c.method === 'sendMessage'); + assert.ok(sent, 'should send a confirmation message'); + assert.equal(sent!.args[1], 'Started a new session in this topic. Send a message to begin.'); + assert.deepEqual(sent!.args[2], { message_thread_id: 901 }); + + const row = topicDb.get(BOUND_CHAT, 901); + assert.ok(row, 'created topic should be persisted'); + assert.equal(row!.agent_id, BOUND_AGENT); +}); + +test('dm mode: /session new clears the bound channel session and replies', async () => { + coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); + coreChannelDb.updateSession('telegram', BOUND_CHAT, 'old-session-123'); + assert.equal(coreChannelDb.get('telegram', BOUND_CHAT)!.session_id, 'old-session-123'); + + const { bot, calls } = makeFakeBot(); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('/session new')); + + assert.equal(enqueued, 0, 'should not enqueue the slash command'); + const created = calls.find((c) => c.method === 'createForumTopic'); + assert.equal(created, undefined, 'should not create a forum topic in dm mode'); + + const sent = calls.find((c) => c.method === 'sendMessage'); + assert.ok(sent); + assert.equal(sent!.args[1], 'Started a new session. Send a message to begin.'); + + assert.equal(coreChannelDb.get('telegram', BOUND_CHAT)!.session_id, null); +}); + +test('non-/ messages bypass dispatcher and are enqueued', async () => { + coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); + + const { bot, calls } = makeFakeBot(); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('hello there')); + + assert.equal(enqueued, 1, 'normal message should be enqueued'); + assert.equal(calls.length, 0, 'no telegram api calls for normal message'); +}); + +test('unknown slash command (/news today) falls through to enqueue', async () => { + coreChannelDb.register('telegram', BOUND_CHAT, BOUND_AGENT, 'My Agent'); + + const { bot, calls } = makeFakeBot(); + let enqueued = 0; + const handler = createMessageHandler( + baseDeps({ bot, chatMode: 'dm', enqueue: () => (enqueued += 1) }), + ); + + await handler(makeCtx('/news today')); + + assert.equal(enqueued, 1, 'unknown command should fall through to enqueue'); + assert.equal(calls.length, 0); +}); diff --git a/src/__tests__/telegramTopicsDb.test.ts b/src/__tests__/telegramTopicsDb.test.ts new file mode 100644 index 0000000..fafcb63 --- /dev/null +++ b/src/__tests__/telegramTopicsDb.test.ts @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { topicDb } from '../providers/telegram/topicsDb'; +import { db } from '../core/db'; + +test('topicDb.getByAgentIdInChat returns only rows for the given chat', () => { + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-scoped-agent'); + topicDb.register(101, 'chat-A', 'test-scoped-agent'); + topicDb.register(102, 'chat-A', 'test-scoped-agent'); + topicDb.register(201, 'chat-B', 'test-scoped-agent'); + + const aOnly = topicDb.getByAgentIdInChat('chat-A', 'test-scoped-agent'); + assert.equal(aOnly.length, 2); + assert.deepEqual( + aOnly.map((r) => r.topic_id).sort(), + [101, 102], + 'must include only chat-A rows', + ); + + const bOnly = topicDb.getByAgentIdInChat('chat-B', 'test-scoped-agent'); + assert.equal(bOnly.length, 1); + assert.equal(bOnly[0].topic_id, 201); + + const cOnly = topicDb.getByAgentIdInChat('chat-C', 'test-scoped-agent'); + assert.equal(cOnly.length, 0, 'unknown chat returns empty'); + + // getByAgentId still returns everything across chats + const all = topicDb.getByAgentId('test-scoped-agent'); + assert.equal(all.length, 3); + + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-scoped-agent'); +}); + +test('topicDb.getByAgentIdInChat orders ascending by created_at (default topic is oldest)', () => { + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-order-agent'); + topicDb.register(700, 'chat-X', 'test-order-agent'); + // Wait a tick so created_at differs (better-sqlite3 + Date.now in ms) + const start = Date.now(); + while (Date.now() === start) { + /* tight spin */ + } + topicDb.register(800, 'chat-X', 'test-order-agent'); + + const topics = topicDb.getByAgentIdInChat('chat-X', 'test-order-agent'); + assert.equal(topics.length, 2); + assert.equal(topics[0].topic_id, 700, 'oldest topic must come first'); + assert.equal(topics[1].topic_id, 800); + + db.prepare('DELETE FROM telegram_agent_topics WHERE agent_id = ?').run('test-order-agent'); +}); diff --git a/src/core/attachments.ts b/src/core/attachments.ts index e2b0a33..5a03b13 100644 --- a/src/core/attachments.ts +++ b/src/core/attachments.ts @@ -51,7 +51,24 @@ export async function downloadAttachments( const savedPath = path.join(targetDir, filename); try { - const response = await fetch(attachment.url); + // Resolve the URL lazily if the provider supplied a resolver. This is + // how Telegram avoids stale getFile URLs (~1h expiry) when the queue + // backlog stretches across long agent runs. + let downloadUrl: string; + try { + downloadUrl = attachment.resolveUrl + ? await attachment.resolveUrl() + : attachment.url; + } catch (err) { + console.warn( + `[attachments] Failed to resolve URL for "${attachment.name}":`, + err, + ); + failed.push(attachment.name); + continue; + } + + const response = await fetch(downloadUrl); if (!response.ok) { console.warn( `[attachments] Failed to download "${attachment.name}": HTTP ${response.status}`, diff --git a/src/core/db/migrations.ts b/src/core/db/migrations.ts index 7986d5e..65a1b8c 100644 --- a/src/core/db/migrations.ts +++ b/src/core/db/migrations.ts @@ -9,12 +9,14 @@ import type Database from 'better-sqlite3'; * 3. Add `provider` column + composite PK (provider, channel_id) to agent_channels * 4. Rename `agent_threads` → `discord_agent_threads` * 5. Add `slack_agent_conversations` thread/timestamp registry + * 6. Create `telegram_agent_topics` for forum-topic-per-session tracking */ export function runMigrations(db: Database.Database): void { ensureReadOnlyColumn(db); ensureProviderColumn(db); renameAgentThreadsTable(db); ensureDiscordThreadsTable(db); + ensureTelegramTopicsTable(db); ensureOwnerUserIdColumn(db); ensureSlackConversationsTable(db); } @@ -134,3 +136,16 @@ function ensureSlackConversationsTable(database: Database.Database): void { ) `); } + +function ensureTelegramTopicsTable(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS telegram_agent_topics ( + topic_id INTEGER NOT NULL, + chat_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_id TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (chat_id, topic_id) + ) + `); +} diff --git a/src/core/providers.ts b/src/core/providers.ts index a724533..9d1b5c8 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -26,6 +26,10 @@ async function loadProvider(name: string): Promise { const { SlackProvider } = await import('../providers/slack/adapter'); return new SlackProvider(); } + case 'telegram': { + const { TelegramProvider } = await import('../providers/telegram/adapter'); + return new TelegramProvider(); + } default: console.warn(`[providers] Unknown provider "${name}" — ignoring.`); return null; diff --git a/src/core/types.ts b/src/core/types.ts index ca67730..f8e2810 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,10 +20,21 @@ export interface MessageTarget extends ChannelTarget { } export interface IncomingAttachment { + /** + * Direct download URL. For platforms that mint short-lived URLs (e.g. + * Telegram's getFile, which expires after ~1 hour), prefer `resolveUrl` + * so the URL is fetched just-in-time at download. + */ url: string; name: string; size: number; contentType?: string; + /** + * Optional lazy URL resolver. When present, `downloadAttachments` calls + * this just before fetching to avoid using a stale pre-resolved URL. Use + * this for providers whose download URLs are time-limited. + */ + resolveUrl?: () => Promise; } export interface IncomingMessage { diff --git a/src/providers/telegram/adapter.ts b/src/providers/telegram/adapter.ts new file mode 100644 index 0000000..28dfe15 --- /dev/null +++ b/src/providers/telegram/adapter.ts @@ -0,0 +1,279 @@ +import { Bot } from 'grammy'; +import type { ReactionTypeEmoji } from 'grammy/types'; +import type { + AgentChannelInfo, + BridgeProvider, + ChannelTarget, + ConversationRecord, + IncomingMessage, + KernelContext, + MessageTarget, + OutgoingMessage, + ReactionHandle, +} from '../../core/types'; +import { + isTranscriberAvailable, + transcribeVoiceAttachment, +} from '../../core/transcription'; +import { splitMessage } from '../../core/splitMessage'; +import { channelDb as coreChannelDb } from '../../core/db'; +import { maestro } from '../../core/maestro'; +import { telegramConfig } from './config'; +import { createMessageHandler } from './messageHandler'; +import { topicDb } from './topicsDb'; +import { + attachmentsFromMessage, + downloadVoice, + isVoiceMessage, +} from './voice'; + +function parseChannelId(channelId: string): { chatId: string; threadId?: number } { + const [chatId, topicStr] = channelId.split(':'); + if (!topicStr || !/^-?\d+$/.test(topicStr)) return { chatId }; + const threadId = Number(topicStr); + if (!Number.isFinite(threadId)) return { chatId }; + return { chatId, threadId }; +} + +export class TelegramProvider implements BridgeProvider { + readonly name = 'telegram'; + private bot: Bot | null = null; + private ctx: KernelContext | null = null; + private ready = false; + private chatMode: 'forum' | 'dm' = 'dm'; + private agentNameCache = new Map(); + + async start(ctx: KernelContext): Promise { + this.ctx = ctx; + const token = telegramConfig.token; + const chatId = telegramConfig.chatId; + const agentId = telegramConfig.agentId; + + const bot = new Bot(token); + this.bot = bot; + + await bot.init(); + console.log( + `[telegram] connected as @${bot.botInfo.username} (bound to agent ${agentId}, chat ${chatId})`, + ); + + const chat = await bot.api.getChat(chatId); + this.chatMode = + chat.type === 'supergroup' && chat.is_forum ? 'forum' : 'dm'; + console.log(`[telegram] chat mode: ${this.chatMode}`); + if (this.chatMode === 'dm') { + console.log( + '[telegram] tip: enable forum topics on a supergroup for topic-per-session UX', + ); + } + + const existing = coreChannelDb.get('telegram', chatId); + if (!existing || existing.agent_id !== agentId) { + let agentName = agentId; + try { + const agents = await maestro.listAgents(); + const match = agents.find((a) => a.id === agentId); + if (match?.name) agentName = match.name; + } catch (err) { + console.warn( + `[telegram] could not resolve agent name from maestro-cli; falling back to agent id (${(err as Error).message})`, + ); + } + if (existing && existing.agent_id !== agentId) { + console.warn( + `[telegram] bound channel ${chatId} was registered to agent ${existing.agent_id}; ` + + `reconciling to ${agentId} (TELEGRAM_AGENT_ID changed). Forum topics from the previous binding are left in place but will no longer resolve.`, + ); + coreChannelDb.remove('telegram', chatId); + } + coreChannelDb.register('telegram', chatId, agentId, agentName); + console.log( + `[telegram] registered bound channel ${chatId} → agent ${agentName} (${agentId})`, + ); + } + + const handler = createMessageHandler({ + bot, + boundChatId: chatId, + boundAgentId: agentId, + chatMode: this.chatMode, + resolveAgentName: async () => (await this.resolveAgentName(agentId)) ?? agentId, + allowedUserIds: telegramConfig.allowedUserIds, + enqueue: (msg) => ctx.enqueue(msg), + isVoiceMessage, + downloadVoice, + attachmentsFromMessage, + transcribeVoiceAttachment, + isTranscriberAvailable, + logger: console, + }); + bot.on('message', handler); + + // Long-polling runs forever; don't await, but surface failures. + bot + .start({ + onStart: () => { + this.ready = true; + }, + }) + .catch((err) => { + this.ready = false; + console.error('[telegram] long-polling stopped with error:', err); + }); + } + + async stop(): Promise { + if (this.bot) { + await this.bot.stop(); + } + this.ready = false; + } + + isReady(): boolean { + return this.ready; + } + + resolveConversation(message: IncomingMessage): ConversationRecord | null { + const { chatId, threadId } = parseChannelId(message.channelId); + if (chatId !== telegramConfig.chatId) return null; + + if (this.chatMode === 'forum') { + if (threadId === undefined) return null; + const row = topicDb.get(chatId, threadId); + if (!row) return null; + return { + agentId: row.agent_id, + sessionId: row.session_id, + readOnly: false, + persistSession: (sid) => topicDb.updateSession(chatId, threadId, sid), + }; + } + + const row = coreChannelDb.get('telegram', chatId); + if (!row) return null; + return { + agentId: row.agent_id, + sessionId: row.session_id, + readOnly: row.read_only === 1, + persistSession: (sid) => coreChannelDb.updateSession('telegram', chatId, sid), + }; + } + + async send(target: ChannelTarget, msg: OutgoingMessage): Promise { + if (!this.bot) throw new Error('telegram bot not started'); + const { chatId, threadId } = parseChannelId(target.channelId); + + let text = msg.text; + if (msg.mention && telegramConfig.mentionUserId) { + text = `[mention requested for user ${telegramConfig.mentionUserId}]\n${text}`; + } + + const parts = splitMessage(text, 4096); + for (const part of parts) { + await this.bot.api.sendMessage( + chatId, + part, + threadId ? { message_thread_id: threadId } : {}, + ); + } + } + + async findOrCreateAgentChannel(agentId: string): Promise { + if (!this.bot) throw new Error('telegram bot not started'); + if (agentId !== telegramConfig.agentId) { + throw new Error( + `Telegram bot is bound to agent ${telegramConfig.agentId}; cannot serve agent ${agentId}. ` + + `Run a separate bridge instance for that agent.`, + ); + } + + const agentName = (await this.resolveAgentName(agentId)) ?? agentId; + + if (this.chatMode === 'forum') { + // Use the oldest topic for this agent *in the currently bound chat* as + // the stable "default", or create one if none exist. Scoping to + // telegramConfig.chatId prevents a stale row from a previous + // TELEGRAM_CHAT_ID binding from being combined with the new chat id, + // which would yield a (currentChat, oldTopicId) pair that doesn't + // exist on Telegram. + const topics = topicDb.getByAgentIdInChat(telegramConfig.chatId, agentId); + let topicId: number; + if (topics.length === 0) { + const created = await this.bot.api.createForumTopic( + telegramConfig.chatId, + `${agentName} (default)`, + ); + topicId = created.message_thread_id; + topicDb.register(topicId, telegramConfig.chatId, agentId); + } else { + topicId = topics[0].topic_id; + } + return { + channelId: `${telegramConfig.chatId}:${topicId}`, + agentId, + agentName, + }; + } + + return { + channelId: telegramConfig.chatId, + agentId, + agentName, + }; + } + + private async resolveAgentName(agentId: string): Promise { + const cached = this.agentNameCache.get(agentId); + if (cached) return cached; + try { + const agents = await maestro.listAgents(); + const match = agents.find((a) => a.id === agentId); + if (match?.name) { + this.agentNameCache.set(agentId, match.name); + return match.name; + } + return null; + } catch (err) { + console.warn( + `[telegram] resolveAgentName: maestro-cli unavailable (${(err as Error).message})`, + ); + return null; + } + } + + async react(target: MessageTarget, emoji: string): Promise { + if (!this.bot) throw new Error('telegram bot not started'); + const { chatId } = parseChannelId(target.channelId); + const messageId = Number(target.messageId); + try { + await this.bot.api.setMessageReaction(chatId, messageId, [ + { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] }, + ]); + } catch (err) { + console.warn(`[telegram] setMessageReaction(${emoji}) failed:`, err); + } + return { + remove: async () => { + try { + await this.bot!.api.setMessageReaction(chatId, messageId, []); + } catch { + /* gentle degradation */ + } + }, + }; + } + + async sendTyping(target: ChannelTarget): Promise { + if (!this.bot) return; + const { chatId, threadId } = parseChannelId(target.channelId); + try { + await this.bot.api.sendChatAction( + chatId, + 'typing', + threadId ? { message_thread_id: threadId } : {}, + ); + } catch { + // best-effort; typing indicator is non-critical + } + } +} diff --git a/src/providers/telegram/commands/agents.ts b/src/providers/telegram/commands/agents.ts new file mode 100644 index 0000000..b6f3968 --- /dev/null +++ b/src/providers/telegram/commands/agents.ts @@ -0,0 +1,118 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'agents'; +export const description = 'Show the bound agent and its details'; + +const BOUND_NOTICE = + 'This Telegram bot is bound to a single agent at install time. ' + + 'To bind a different agent, run a separate bridge instance.'; + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0] ?? 'list'; + + if (sub === 'list') { + await handleList(ctx); + return; + } + if (sub === 'show') { + await handleShow(ctx); + return; + } + if (sub === 'new' || sub === 'disconnect' || sub === 'readonly') { + await ctx.reply( + `⚠️ /agents ${sub} is not supported on Telegram.\n${BOUND_NOTICE}`, + ); + return; + } + + await ctx.reply( + 'Usage: /agents [list|show]\n' + + '• list — show the bound agent\n' + + '• show — show details, stats, recent activity', + ); +} + +async function handleList(ctx: TelegramCommandContext): Promise { + let agents; + try { + agents = await maestro.listAgents(); + } catch (err) { + await ctx.reply(`❌ Could not list agents: ${(err as Error).message}`); + return; + } + + const bound = agents.find((a) => a.id === ctx.boundAgentId); + if (!bound) { + await ctx.reply( + `Bound agent ${ctx.boundAgentName} (${ctx.boundAgentId}) ` + + `was not returned by maestro-cli list agents.\n${BOUND_NOTICE}`, + ); + return; + } + + await ctx.reply( + `Bound agent: ${bound.name}\n` + + `id: ${bound.id}\n` + + `tool: ${bound.toolType}\n` + + `cwd: ${bound.cwd}\n\n` + + BOUND_NOTICE, + ); +} + +async function handleShow(ctx: TelegramCommandContext): Promise { + let detail; + try { + detail = await maestro.showAgent(ctx.boundAgentId); + } catch (err) { + await ctx.reply(`❌ Could not load agent: ${(err as Error).message}`); + return; + } + + const lines: string[] = [ + `Agent: ${detail.name}`, + `id: ${detail.id}`, + `tool: ${detail.toolType}`, + `cwd: ${detail.cwd}`, + ]; + if (detail.groupName) lines.push(`group: ${detail.groupName}`); + + const stats = detail.stats; + if (stats) { + const statLines: string[] = []; + if (typeof stats.historyEntries === 'number') { + const ok = stats.successCount ?? 0; + const fail = stats.failureCount ?? 0; + statLines.push(`History: ${stats.historyEntries} entries (${ok} ok · ${fail} failed)`); + } + if ( + typeof stats.totalInputTokens === 'number' || + typeof stats.totalOutputTokens === 'number' + ) { + statLines.push( + `Tokens: ${stats.totalInputTokens ?? 0}↓ ${stats.totalOutputTokens ?? 0}↑`, + ); + } + if (typeof stats.totalCost === 'number' && stats.totalCost > 0) { + statLines.push(`Cost: $${stats.totalCost.toFixed(4)}`); + } + if (typeof stats.totalElapsedMs === 'number' && stats.totalElapsedMs > 0) { + statLines.push(`Total elapsed: ${(stats.totalElapsedMs / 1000).toFixed(1)}s`); + } + if (statLines.length) { + lines.push('', 'Stats:', ...statLines); + } + } + + if (detail.recentHistory && detail.recentHistory.length > 0) { + lines.push('', 'Recent activity:'); + for (const h of detail.recentHistory.slice(0, 5)) { + const when = new Date(h.timestamp).toLocaleString(); + const status = h.success === false ? '⚠️' : '•'; + const summary = (h.summary ?? '').slice(0, 90); + lines.push(`${status} ${when} — ${summary}`); + } + } + + await ctx.reply(lines.join('\n')); +} diff --git a/src/providers/telegram/commands/auto-run.ts b/src/providers/telegram/commands/auto-run.ts new file mode 100644 index 0000000..5c615ab --- /dev/null +++ b/src/providers/telegram/commands/auto-run.ts @@ -0,0 +1,82 @@ +import path from 'path'; +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'auto-run'; +export const description = "Launch one of this agent's Auto Run documents"; + +/** + * Resolve `doc` (a user-supplied filename, relative path, or absolute path) + * to a normalized path strictly contained within `folder`. Returns null when + * the resolved path escapes the folder. + */ +export function resolveContainedDocPath(folder: string, doc: string): string | null { + const folderResolved = path.resolve(folder); + const candidate = path.isAbsolute(doc) ? doc : path.join(folderResolved, doc); + const resolved = path.resolve(candidate); + if (resolved === folderResolved) return null; + const prefix = folderResolved.endsWith(path.sep) ? folderResolved : folderResolved + path.sep; + if (!resolved.startsWith(prefix)) return null; + return resolved; +} + +async function getAgentFolder(agentId: string): Promise { + try { + const agent = await maestro.showAgent(agentId); + return typeof agent.autoRunFolderPath === 'string' ? agent.autoRunFolderPath : null; + } catch { + return null; + } +} + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0]; + if (sub !== 'start') { + await ctx.reply( + 'Usage: /auto-run start \n' + + 'doc-path is a filename or relative path inside the bound agent\'s Auto Run folder.', + ); + return; + } + + const doc = ctx.args[1]; + if (!doc) { + await ctx.reply('Missing document. Usage: /auto-run start '); + return; + } + + const folder = await getAgentFolder(ctx.boundAgentId); + if (!folder) { + await ctx.reply( + "❌ Could not determine this agent's Auto Run folder. " + + 'Open the agent in Maestro and configure one, then try again.', + ); + return; + } + + const docPath = resolveContainedDocPath(folder, doc); + if (!docPath) { + await ctx.reply( + "❌ Document must live inside this agent's Auto Run folder. " + + 'Use a filename or relative subpath (no `..` traversal or absolute paths outside the folder).', + ); + return; + } + + try { + await maestro.startAutoRun({ + agentId: ctx.boundAgentId, + docs: [docPath], + }); + } catch (err) { + await ctx.reply( + `❌ Auto Run failed to launch: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + await ctx.reply( + `▶️ Launched Auto Run for ${ctx.boundAgentName} with ${path.basename(docPath)}.\n` + + 'Watch this chat for progress.', + ); +} diff --git a/src/providers/telegram/commands/gist.ts b/src/providers/telegram/commands/gist.ts new file mode 100644 index 0000000..0ef1721 --- /dev/null +++ b/src/providers/telegram/commands/gist.ts @@ -0,0 +1,37 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'gist'; +export const description = "Publish this agent's session transcript as a GitHub gist"; + +export async function execute(ctx: TelegramCommandContext): Promise { + let isPublic = false; + const descriptionParts: string[] = []; + for (const arg of ctx.args) { + if (arg === '--public' || arg === '-p') { + isPublic = true; + } else { + descriptionParts.push(arg); + } + } + const gistDescription = descriptionParts.length ? descriptionParts.join(' ') : undefined; + + let result; + try { + result = await maestro.createGist(ctx.boundAgentId, { + description: gistDescription, + isPublic, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await ctx.reply(`❌ Could not publish gist: ${message.slice(0, 1500)}`); + return; + } + + const visibility = isPublic ? 'public' : 'private'; + await ctx.reply( + `📎 Gist published — ${ctx.boundAgentName}\n` + + `${result.url}\n` + + `Visibility: ${visibility}`, + ); +} diff --git a/src/providers/telegram/commands/health.ts b/src/providers/telegram/commands/health.ts new file mode 100644 index 0000000..0f5f511 --- /dev/null +++ b/src/providers/telegram/commands/health.ts @@ -0,0 +1,33 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'health'; +export const description = 'Verify maestro-cli is reachable'; + +export async function execute(ctx: TelegramCommandContext): Promise { + const installed = await maestro.isInstalled(); + if (!installed) { + await ctx.reply( + '❌ maestro-cli not found. Install Maestro and ensure it is on PATH.\n' + + 'See https://maestro.sh for instructions.', + ); + return; + } + + let agentCount: number; + try { + agentCount = (await maestro.listAgents()).length; + } catch (err) { + await ctx.reply( + `⚠️ maestro-cli is installed but failed to list agents. ` + + `Make sure Maestro is running.\n${(err as Error).message}`, + ); + return; + } + + await ctx.reply( + `✅ maestro-cli is healthy.\n` + + `Found ${agentCount} agent${agentCount !== 1 ? 's' : ''}. ` + + `Bound to ${ctx.boundAgentName} (${ctx.boundAgentId}).`, + ); +} diff --git a/src/providers/telegram/commands/index.ts b/src/providers/telegram/commands/index.ts new file mode 100644 index 0000000..4c79c27 --- /dev/null +++ b/src/providers/telegram/commands/index.ts @@ -0,0 +1,70 @@ +import * as health from './health'; +import * as agents from './agents'; +import * as session from './session'; +import * as gist from './gist'; +import * as playbook from './playbook'; +import * as notes from './notes'; +import * as autoRun from './auto-run'; +import type { TelegramCommandContext, TelegramCommandModule } from './types'; + +type CommandEntry = { + description: string; + execute: (ctx: TelegramCommandContext) => Promise; +}; + +const modules: TelegramCommandModule[] = [ + health, + agents, + session, + gist, + playbook, + notes, + autoRun, +]; + +export const COMMANDS: Record = Object.fromEntries( + modules.map((m) => [m.command, { description: m.description, execute: m.execute }]), +); + +export type DispatchCommandContext = Omit< + TelegramCommandContext, + 'args' | 'rawText' | 'reply' +> & { + reply: TelegramCommandContext['reply']; +}; + +const COMMAND_PATTERN = /^\/(\S+)\s*([\s\S]*)$/; + +export async function dispatchCommand( + rawText: string, + ctx: DispatchCommandContext, +): Promise { + const trimmed = rawText.trimStart(); + const match = trimmed.match(COMMAND_PATTERN); + if (!match) return false; + + const [, head, rest] = match; + // Telegram group convention: `/cmd@BotUsername` disambiguates among multiple + // bots in a chat. Only handle the command when the suffix targets *this* + // bot — otherwise return false so the message handler can ignore it. + const atIdx = head.indexOf('@'); + if (atIdx !== -1) { + const targetBot = head.slice(atIdx + 1).toLowerCase(); + const ourBot = ctx.bot.botInfo?.username?.toLowerCase(); + if (!ourBot || targetBot !== ourBot) return false; + } + const command = head.slice(0, atIdx === -1 ? head.length : atIdx).toLowerCase(); + + const entry = COMMANDS[command]; + if (!entry) return false; + + const args = rest.length ? rest.split(/\s+/).filter(Boolean) : []; + + const fullCtx: TelegramCommandContext = { + ...ctx, + args, + rawText, + }; + await entry.execute(fullCtx); + return true; +} diff --git a/src/providers/telegram/commands/notes.ts b/src/providers/telegram/commands/notes.ts new file mode 100644 index 0000000..39bd0d8 --- /dev/null +++ b/src/providers/telegram/commands/notes.ts @@ -0,0 +1,91 @@ +import { maestro } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'notes'; +export const description = "Director's Notes: AI synopsis or unified history"; + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0]; + + if (sub === 'synopsis') { + await handleSynopsis(ctx); + return; + } + if (sub === 'history') { + await handleHistory(ctx); + return; + } + + await ctx.reply( + 'Usage: /notes [synopsis|history]\n' + + '• synopsis [days] — AI synopsis of recent activity (slow)\n' + + '• history [days] [limit] [filter] — recent unified history (filter: auto|user|cue)', + ); +} + +function parseInteger(value: string | undefined, min: number, max: number): number | undefined { + if (!value) return undefined; + if (!/^\d+$/.test(value)) return undefined; + const num = Number(value); + if (num < min || num > max) return undefined; + return num; +} + +async function handleSynopsis(ctx: TelegramCommandContext): Promise { + const days = parseInteger(ctx.args[1], 1, 30); + + let result; + try { + result = await maestro.directorSynopsis({ days }); + } catch (err) { + await ctx.reply(`❌ Synopsis failed: ${(err as Error).message.slice(0, 1500)}`); + return; + } + + const text = result.markdown ?? result.synopsis ?? result.text ?? '(empty synopsis)'; + const truncated = text.length > 3500 ? text.slice(0, 3500) + '\n\n…truncated' : text; + const header = `🎬 Director's synopsis${days ? ` — last ${days}d` : ''}`; + const footer = + typeof result.entriesAnalyzed === 'number' + ? `\n\nAnalyzed ${result.entriesAnalyzed} entries${ + typeof result.daysCovered === 'number' ? ` over ${result.daysCovered}d` : '' + }` + : ''; + await ctx.reply(`${header}\n\n${truncated}${footer}`); +} + +async function handleHistory(ctx: TelegramCommandContext): Promise { + const days = parseInteger(ctx.args[1], 1, 30); + const limit = parseInteger(ctx.args[2], 1, 50) ?? 20; + const filterArg = ctx.args[3]; + const filter = + filterArg === 'auto' || filterArg === 'user' || filterArg === 'cue' ? filterArg : undefined; + + let entries; + try { + entries = await maestro.directorHistory({ days, limit, filter }); + } catch (err) { + await ctx.reply( + `❌ History fetch failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + if (entries.length === 0) { + await ctx.reply('No history entries in the requested window.'); + return; + } + + const header = `📜 Director history${days ? ` — last ${days}d` : ''}`; + const lines = entries.map((e) => { + const when = e.timestamp ? new Date(e.timestamp).toLocaleString() : '—'; + const type = e.type ?? '?'; + const agent = e.agentName ? ` · ${e.agentName}` : ''; + const status = e.success === false ? '⚠️' : '•'; + const summary = (e.summary ?? '').slice(0, 100); + return `${status} [${type}] ${when}${agent}\n${summary}`; + }); + const body = lines.join('\n\n'); + const truncated = body.length > 3500 ? body.slice(0, 3500) + '\n\n…truncated' : body; + await ctx.reply(`${header}\n\n${truncated}`); +} diff --git a/src/providers/telegram/commands/playbook.ts b/src/providers/telegram/commands/playbook.ts new file mode 100644 index 0000000..9e111c5 --- /dev/null +++ b/src/providers/telegram/commands/playbook.ts @@ -0,0 +1,236 @@ +import { maestro, MaestroPlaybook } from '../../../core/maestro'; +import type { TelegramCommandContext } from './types'; + +export const command = 'playbook'; +export const description = 'Run and inspect Maestro playbooks'; + +type PendingSelection = { + playbooks: MaestroPlaybook[]; + action: 'show' | 'run'; + ts: number; +}; + +const PENDING_TTL_MS = 10 * 60 * 1000; // 10 minutes +const PENDING_MAX_ENTRIES = 256; +const pendingSelections = new Map(); + +function selectionKey(ctx: TelegramCommandContext): string { + return `${ctx.chatId}:${ctx.threadId ?? 0}:${ctx.fromUserId}`; +} + +function pruneExpiredSelections(now: number = Date.now()): void { + for (const [key, value] of pendingSelections) { + if (now - value.ts > PENDING_TTL_MS) pendingSelections.delete(key); + } +} + +function getActiveSelection(key: string): PendingSelection | undefined { + const value = pendingSelections.get(key); + if (!value) return undefined; + if (Date.now() - value.ts > PENDING_TTL_MS) { + pendingSelections.delete(key); + return undefined; + } + return value; +} + +function setSelection(key: string, value: Omit): void { + pruneExpiredSelections(); + if (pendingSelections.size >= PENDING_MAX_ENTRIES) { + // Evict the oldest entry. + let oldestKey: string | undefined; + let oldestTs = Infinity; + for (const [k, v] of pendingSelections) { + if (v.ts < oldestTs) { + oldestTs = v.ts; + oldestKey = k; + } + } + if (oldestKey) pendingSelections.delete(oldestKey); + } + pendingSelections.set(key, { ...value, ts: Date.now() }); +} + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0]; + + if (sub === 'list') { + await handleList(ctx); + return; + } + if (sub === 'show') { + await handleShow(ctx); + return; + } + if (sub === 'run') { + await handleRun(ctx); + return; + } + + // No subcommand: try resolving as a numeric reply to a previous list + if (sub && /^\d+$/.test(sub)) { + await handleNumberReply(ctx, Number(sub)); + return; + } + + await ctx.reply( + 'Usage: /playbook [list|show |run ]\n' + + '• list — list available playbooks\n' + + '• show — show playbook details\n' + + '• run — run a playbook and post the result', + ); +} + +async function handleList(ctx: TelegramCommandContext): Promise { + let playbooks: MaestroPlaybook[]; + try { + playbooks = await maestro.listPlaybooks(); + } catch (err) { + await ctx.reply(`❌ Could not list playbooks: ${(err as Error).message}`); + return; + } + + if (playbooks.length === 0) { + await ctx.reply('No playbooks found. Create one in the Maestro app first.'); + return; + } + + const lines = playbooks.map((p, idx) => { + const owner = p.agentName ? ` · ${p.agentName}` : ''; + return `${idx + 1}. ${p.name}${owner}\n ${p.id} · ${p.documentCount} docs · ${p.taskCount} tasks`; + }); + await ctx.reply( + `Playbooks:\n${lines.join('\n')}\n\n` + + 'Reply with /playbook show or /playbook run .', + ); + setSelection(selectionKey(ctx), { playbooks, action: 'run' }); +} + +async function handleShow(ctx: TelegramCommandContext): Promise { + const playbookId = await resolvePlaybookId(ctx, ctx.args.slice(1)); + if (!playbookId) return; + + let detail; + try { + detail = await maestro.showPlaybook(playbookId); + } catch (err) { + await ctx.reply(`❌ Could not load playbook: ${(err as Error).message}`); + return; + } + + const lines: string[] = [ + `Playbook: ${detail.name}`, + `id: ${detail.id}`, + `description: ${detail.description || '(none)'}`, + `tasks: ${detail.taskCount} (${detail.documentCount} docs)`, + ]; + if (detail.agentName) lines.push(`agent: ${detail.agentName}`); + + if (detail.documents.length) { + lines.push('', 'Documents:'); + for (const d of detail.documents.slice(0, 15)) { + lines.push(`• ${d.path} — ${d.completedCount}/${d.taskCount} tasks`); + } + if (detail.documents.length > 15) { + lines.push(`… and ${detail.documents.length - 15} more`); + } + } + await ctx.reply(lines.join('\n')); +} + +async function handleRun(ctx: TelegramCommandContext): Promise { + const playbookId = await resolvePlaybookId(ctx, ctx.args.slice(1)); + if (!playbookId) return; + + let detail; + try { + detail = await maestro.showPlaybook(playbookId); + } catch { + detail = null; + } + const label = detail?.name ?? playbookId; + await ctx.reply(`▶️ Running playbook ${label}…`); + + let event; + try { + event = await maestro.runPlaybook(playbookId); + } catch (err) { + await ctx.reply( + `❌ Playbook ${label} failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + const lines: string[] = [ + event.success === false + ? `⚠️ Playbook ${label} finished with errors.` + : `✅ Playbook ${label} complete.`, + ]; + if (typeof event.totalTasksCompleted === 'number') { + lines.push(`Tasks completed: ${event.totalTasksCompleted}`); + } + if (typeof event.totalElapsedMs === 'number') { + lines.push(`Elapsed: ${(event.totalElapsedMs / 1000).toFixed(1)}s`); + } + if (typeof event.totalCost === 'number' && event.totalCost > 0) { + lines.push(`Cost: $${event.totalCost.toFixed(4)}`); + } + if (event.summary) { + lines.push('', String(event.summary).slice(0, 1500)); + } + await ctx.reply(lines.join('\n')); +} + +async function resolvePlaybookId( + ctx: TelegramCommandContext, + args: string[], +): Promise { + const arg = args[0]; + if (!arg) { + await ctx.reply( + 'Missing playbook id. Use /playbook list first, then run /playbook run .', + ); + return null; + } + + if (/^\d+$/.test(arg)) { + const pending = getActiveSelection(selectionKey(ctx)); + if (!pending) { + await ctx.reply( + 'No recent /playbook list to pick from. Run /playbook list first.', + ); + return null; + } + const idx = Number(arg) - 1; + if (idx < 0 || idx >= pending.playbooks.length) { + await ctx.reply(`Number out of range. Pick 1-${pending.playbooks.length}.`); + return null; + } + return pending.playbooks[idx].id; + } + return arg; +} + +async function handleNumberReply( + ctx: TelegramCommandContext, + num: number, +): Promise { + const pending = getActiveSelection(selectionKey(ctx)); + if (!pending) { + await ctx.reply( + 'No recent /playbook list to pick from. Run /playbook list first.', + ); + return; + } + const idx = num - 1; + if (idx < 0 || idx >= pending.playbooks.length) { + await ctx.reply(`Number out of range. Pick 1-${pending.playbooks.length}.`); + return; + } + // Re-dispatch as a run with the selected id + const newCtx: TelegramCommandContext = { + ...ctx, + args: ['run', pending.playbooks[idx].id], + }; + await handleRun(newCtx); +} diff --git a/src/providers/telegram/commands/session.ts b/src/providers/telegram/commands/session.ts new file mode 100644 index 0000000..5e07456 --- /dev/null +++ b/src/providers/telegram/commands/session.ts @@ -0,0 +1,97 @@ +import { maestro, MaestroSession } from '../../../core/maestro'; +import { channelDb as coreChannelDb } from '../../../core/db'; +import { topicDb } from '../topicsDb'; +import type { TelegramCommandContext } from './types'; + +export const command = 'session'; +export const description = 'Manage agent sessions (new, list)'; + +export async function execute(ctx: TelegramCommandContext): Promise { + const sub = ctx.args[0] ?? 'list'; + + if (sub === 'new') { + await handleNew(ctx); + return; + } + if (sub === 'list') { + await handleList(ctx); + return; + } + + await ctx.reply( + 'Usage: /session [new|list]\n' + + '• new — start a fresh session (forum: new topic; dm: clears current session)\n' + + '• list — list known sessions for the bound agent', + ); +} + +async function handleNew(ctx: TelegramCommandContext): Promise { + if (ctx.chatMode === 'forum') { + const topicName = `${ctx.boundAgentName} session ${new Date() + .toISOString() + .slice(0, 16)}`; + const created = await ctx.bot.api.createForumTopic(ctx.chatId, topicName); + topicDb.register(created.message_thread_id, ctx.chatId, ctx.boundAgentId); + await ctx.bot.api.sendMessage( + ctx.chatId, + 'Started a new session in this topic. Send a message to begin.', + { message_thread_id: created.message_thread_id }, + ); + return; + } + + coreChannelDb.updateSession('telegram', ctx.chatId, null); + await ctx.reply('Started a new session. Send a message to begin.'); +} + +async function handleList(ctx: TelegramCommandContext): Promise { + let maestroSessions: MaestroSession[] = []; + try { + maestroSessions = await maestro.listSessions(ctx.boundAgentId); + } catch { + // fall through with empty list + } + const sessionMap = new Map( + maestroSessions.map((s) => [s.sessionId, s]), + ); + + if (ctx.chatMode === 'forum') { + const topics = topicDb.listByChat(ctx.chatId); + if (topics.length === 0) { + await ctx.reply('No session topics yet. Use /session new to create one.'); + return; + } + const lines = topics.map((t) => { + const info = sessionMap.get(t.session_id ?? ''); + const shortId = t.session_id ? t.session_id.slice(0, 8) : 'no session yet'; + const stats = info + ? `${info.messageCount} msgs · $${info.costUsd.toFixed(4)} · ${new Date( + info.modifiedAt, + ).toLocaleDateString()}` + : 'No messages yet'; + return `topic ${t.topic_id} — ${shortId} · ${stats}`; + }); + await ctx.reply(`Sessions — ${ctx.boundAgentName}\n${lines.join('\n')}`); + return; + } + + // DM mode: single shared session + const row = coreChannelDb.get('telegram', ctx.chatId); + if (!row?.session_id) { + await ctx.reply( + `Single shared session for ${ctx.boundAgentName}. ` + + `No session yet — send a message to start one.`, + ); + return; + } + const info = sessionMap.get(row.session_id); + const shortId = row.session_id.slice(0, 8); + const stats = info + ? `${info.messageCount} msgs · $${info.costUsd.toFixed(4)} · ${new Date( + info.modifiedAt, + ).toLocaleDateString()}` + : 'No stats available'; + await ctx.reply( + `Single shared session for ${ctx.boundAgentName}.\n${shortId} · ${stats}`, + ); +} diff --git a/src/providers/telegram/commands/types.ts b/src/providers/telegram/commands/types.ts new file mode 100644 index 0000000..306cb5c --- /dev/null +++ b/src/providers/telegram/commands/types.ts @@ -0,0 +1,20 @@ +import type { Bot } from 'grammy'; + +export interface TelegramCommandContext { + bot: Bot; + chatId: string; + threadId?: number; + fromUserId: string; + args: string[]; + rawText: string; + boundAgentId: string; + boundAgentName: string; + chatMode: 'forum' | 'dm'; + reply: (text: string) => Promise; +} + +export interface TelegramCommandModule { + command: string; + description: string; + execute: (ctx: TelegramCommandContext) => Promise; +} diff --git a/src/providers/telegram/config.ts b/src/providers/telegram/config.ts new file mode 100644 index 0000000..fa55b3c --- /dev/null +++ b/src/providers/telegram/config.ts @@ -0,0 +1,33 @@ +import { required } from '../../core/config'; + +function csv(key: string): string[] { + const val = process.env[key]; + if (!val) return []; + return val + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +/** + * Telegram adapter configuration. Loaded lazily so a deployment that + * disables Telegram (ENABLED_PROVIDERS=discord) does not fail at startup + * for missing TELEGRAM_BOT_TOKEN. + */ +export const telegramConfig = { + get token() { + return required('TELEGRAM_BOT_TOKEN'); + }, + get chatId() { + return required('TELEGRAM_CHAT_ID'); + }, + get agentId() { + return required('TELEGRAM_AGENT_ID'); + }, + get allowedUserIds() { + return csv('TELEGRAM_ALLOWED_USER_IDS'); + }, + get mentionUserId() { + return process.env.TELEGRAM_MENTION_USER_ID || ''; + }, +}; diff --git a/src/providers/telegram/deploy.ts b/src/providers/telegram/deploy.ts new file mode 100644 index 0000000..e276035 --- /dev/null +++ b/src/providers/telegram/deploy.ts @@ -0,0 +1,19 @@ +import { Bot } from 'grammy'; +import { telegramConfig } from './config'; +import { COMMANDS } from './commands'; + +async function main() { + const bot = new Bot(telegramConfig.token); + const list = Object.entries(COMMANDS).map(([command, { description }]) => ({ + command, + description, + })); + console.log(`Registering ${list.length} Telegram commands…`); + await bot.api.setMyCommands(list); + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/providers/telegram/messageHandler.ts b/src/providers/telegram/messageHandler.ts new file mode 100644 index 0000000..bed4f3a --- /dev/null +++ b/src/providers/telegram/messageHandler.ts @@ -0,0 +1,135 @@ +import type { Bot, Context as GrammyContext } from 'grammy'; +import type { EnqueueOptions, IncomingAttachment, IncomingMessage } from '../../core/types'; +import { + isTranscriberAvailable, + transcribeVoiceAttachment, +} from '../../core/transcription'; +import { dispatchCommand } from './commands'; +import { attachmentsFromMessage, downloadVoice, isVoiceMessage } from './voice'; + +type Enqueue = (msg: IncomingMessage, options?: EnqueueOptions) => void; + +export type MessageHandlerDeps = { + bot: Bot; + boundChatId: string; + boundAgentId: string; + chatMode: 'forum' | 'dm'; + resolveAgentName: () => Promise; + allowedUserIds: string[]; + enqueue: Enqueue; + isVoiceMessage: typeof isVoiceMessage; + downloadVoice: typeof downloadVoice; + attachmentsFromMessage: typeof attachmentsFromMessage; + transcribeVoiceAttachment: typeof transcribeVoiceAttachment; + isTranscriberAvailable: typeof isTranscriberAvailable; + logger?: Pick; +}; + +export function createMessageHandler(deps: MessageHandlerDeps) { + return async function handleMessage(ctx: GrammyContext): Promise { + try { + const message = ctx.message; + const from = ctx.from; + const chat = ctx.chat; + if (!message || !from || !chat) return; + + if (from.is_bot) return; + + if (String(chat.id) !== deps.boundChatId) return; + + if ( + deps.allowedUserIds.length > 0 && + !deps.allowedUserIds.includes(String(from.id)) + ) { + return; + } + + const text = message.text ?? ''; + const threadId = message.message_thread_id; + const isThread = !!message.is_topic_message && typeof threadId === 'number'; + // In forum supergroups the "General" topic has no `message_thread_id` + // and is_topic_message is false. Topic-scoped messages have both set. + // We only route topic-scoped messages to maestro; General-feed messages + // are ignored (use `/session new` from anywhere to spawn a topic). + const isForumGeneralFeed = + deps.chatMode === 'forum' && !isThread; + + if (text.trimStart().startsWith('/')) { + const boundAgentName = await deps.resolveAgentName(); + const reply = async (replyText: string) => { + await deps.bot.api.sendMessage( + deps.boundChatId, + replyText, + isThread && threadId !== undefined + ? { message_thread_id: threadId } + : {}, + ); + }; + const handled = await dispatchCommand(text, { + bot: deps.bot, + chatId: deps.boundChatId, + threadId: isThread ? threadId : undefined, + fromUserId: String(from.id), + boundAgentId: deps.boundAgentId, + boundAgentName, + chatMode: deps.chatMode, + reply, + }); + if (handled) return; + } + + // Drop non-command General-feed messages with a visible warning rather + // than failing silently downstream in resolveConversation. Users can + // start a topic via `/session new` (handled above before this point). + if (isForumGeneralFeed) { + const trimmed = (text || message.caption || '').trim(); + if (trimmed.length > 0) { + const log = deps.logger?.warn ?? console.warn; + log( + `[telegram] ignoring message in supergroup General feed (chat=${chat.id}) — start a topic with /session new to chat with the agent`, + ); + } + return; + } + + const channelId = isThread ? `${chat.id}:${threadId}` : `${chat.id}`; + + let content = message.text ?? message.caption ?? ''; + let attachments: IncomingAttachment[] = []; + + if (deps.isVoiceMessage(message) && deps.isTranscriberAvailable()) { + const voiceAttachment = await deps.downloadVoice(deps.bot, message); + try { + const transcription = await deps.transcribeVoiceAttachment(voiceAttachment); + content = transcription; + } catch (err) { + const log = deps.logger?.error ?? console.error; + log('[telegram] voice transcription failed:', err); + attachments = [voiceAttachment]; + } + } else { + attachments = deps.attachmentsFromMessage(deps.bot, message); + } + + const authorName = + from.username ?? from.first_name ?? String(from.id); + + const incoming: IncomingMessage = { + provider: 'telegram', + messageId: String(message.message_id), + channelId, + authorId: String(from.id), + authorName, + content, + attachments, + isThread, + raw: ctx, + }; + + deps.enqueue(incoming); + } catch (err) { + const log = deps.logger?.error ?? console.error; + log('[telegram] messageHandler', err); + } + }; +} diff --git a/src/providers/telegram/topicsDb.ts b/src/providers/telegram/topicsDb.ts new file mode 100644 index 0000000..16dc880 --- /dev/null +++ b/src/providers/telegram/topicsDb.ts @@ -0,0 +1,68 @@ +import { db } from '../../core/db'; + +export interface TelegramAgentTopic { + topic_id: number; + chat_id: string; + agent_id: string; + session_id: string | null; + created_at: number; +} + +export const topicDb = { + register(topicId: number, chatId: string, agentId: string): void { + // Idempotent: ignore conflicts on (chat_id, topic_id) so reprocessing the + // same forum topic doesn't throw. created_at is preserved from the first + // insert via INSERT OR IGNORE. + db.prepare( + `INSERT OR IGNORE INTO telegram_agent_topics (topic_id, chat_id, agent_id, created_at) + VALUES (?, ?, ?, ?)`, + ).run(topicId, chatId, agentId, Date.now()); + }, + + get(chatId: string, topicId: number): TelegramAgentTopic | undefined { + return db + .prepare('SELECT * FROM telegram_agent_topics WHERE chat_id = ? AND topic_id = ?') + .get(chatId, topicId) as TelegramAgentTopic | undefined; + }, + + getByAgentId(agentId: string): TelegramAgentTopic[] { + // ORDER BY created_at ASC so topics[0] is the original/default topic for + // the agent. NOTE: returns rows from *every* chat the agent has ever had + // topics in. Use `getByAgentIdInChat` when you need to scope to the + // current bound chat (e.g. `findOrCreateAgentChannel`). + return db + .prepare('SELECT * FROM telegram_agent_topics WHERE agent_id = ? ORDER BY created_at ASC') + .all(agentId) as TelegramAgentTopic[]; + }, + + getByAgentIdInChat(chatId: string, agentId: string): TelegramAgentTopic[] { + // Chat-scoped variant of getByAgentId. Always prefer this in routing / + // outbound paths so a stale row from a previous TELEGRAM_CHAT_ID can't + // produce a (currentChatId, oldTopicId) pair that doesn't exist on + // Telegram. + return db + .prepare( + 'SELECT * FROM telegram_agent_topics WHERE chat_id = ? AND agent_id = ? ORDER BY created_at ASC', + ) + .all(chatId, agentId) as TelegramAgentTopic[]; + }, + + updateSession(chatId: string, topicId: number, sessionId: string | null): void { + db.prepare( + 'UPDATE telegram_agent_topics SET session_id = ? WHERE chat_id = ? AND topic_id = ?', + ).run(sessionId, chatId, topicId); + }, + + remove(chatId: string, topicId: number): void { + db.prepare('DELETE FROM telegram_agent_topics WHERE chat_id = ? AND topic_id = ?').run( + chatId, + topicId, + ); + }, + + listByChat(chatId: string): TelegramAgentTopic[] { + return db + .prepare('SELECT * FROM telegram_agent_topics WHERE chat_id = ? ORDER BY created_at DESC') + .all(chatId) as TelegramAgentTopic[]; + }, +}; diff --git a/src/providers/telegram/voice.ts b/src/providers/telegram/voice.ts new file mode 100644 index 0000000..db84a04 --- /dev/null +++ b/src/providers/telegram/voice.ts @@ -0,0 +1,117 @@ +import type { Bot } from 'grammy'; +import type { Message as TelegramMessage } from 'grammy/types'; +import type { IncomingAttachment } from '../../core/types'; + +/** Telegram-specific: a message is a voice message when the `voice` field is present. */ +export function isVoiceMessage(msg: Pick): boolean { + return !!msg.voice; +} + +async function fileUrl(bot: Bot, fileId: string): Promise { + const file = await bot.api.getFile(fileId); + if (!file.file_path) { + throw new Error(`Telegram file ${fileId} returned no file_path`); + } + return `https://api.telegram.org/file/bot${bot.token}/${file.file_path}`; +} + +export async function downloadVoice( + bot: Bot, + msg: TelegramMessage, +): Promise { + if (!msg.voice) { + throw new Error('downloadVoice called on a message without a voice payload'); + } + // Voice is consumed immediately by transcribeVoiceAttachment in the + // messageHandler (before enqueue), so pre-resolving the URL is fine. + const url = await fileUrl(bot, msg.voice.file_id); + return { + url, + name: `voice-${msg.message_id}.ogg`, + size: msg.voice.file_size ?? 0, + contentType: msg.voice.mime_type ?? 'audio/ogg', + }; +} + +/** + * Build a deferred-URL `IncomingAttachment` for a Telegram file. The actual + * `getFile` call is delayed until the kernel's `downloadAttachments` runs + * via the `resolveUrl` callback. This avoids using a stale URL when the + * per-conversation queue is backlogged behind long agent runs (Telegram + * getFile URLs are only valid for ~1h). + */ +function lazyTelegramAttachment( + bot: Bot, + fileId: string, + name: string, + size: number, + contentType: string | undefined, +): IncomingAttachment { + return { + url: '', + name, + size, + contentType, + resolveUrl: () => fileUrl(bot, fileId), + }; +} + +export function attachmentsFromMessage( + bot: Bot, + msg: TelegramMessage, +): IncomingAttachment[] { + const attachments: IncomingAttachment[] = []; + + if (msg.document) { + attachments.push( + lazyTelegramAttachment( + bot, + msg.document.file_id, + msg.document.file_name ?? `document-${msg.message_id}`, + msg.document.file_size ?? 0, + msg.document.mime_type, + ), + ); + } + + if (msg.photo && msg.photo.length > 0) { + const largest = msg.photo.reduce((a, b) => + (a.file_size ?? a.width * a.height) >= (b.file_size ?? b.width * b.height) ? a : b, + ); + attachments.push( + lazyTelegramAttachment( + bot, + largest.file_id, + `photo-${msg.message_id}.jpg`, + largest.file_size ?? 0, + 'image/jpeg', + ), + ); + } + + if (msg.audio) { + attachments.push( + lazyTelegramAttachment( + bot, + msg.audio.file_id, + msg.audio.file_name ?? `audio-${msg.message_id}`, + msg.audio.file_size ?? 0, + msg.audio.mime_type ?? 'audio/mpeg', + ), + ); + } + + if (msg.video) { + attachments.push( + lazyTelegramAttachment( + bot, + msg.video.file_id, + msg.video.file_name ?? `video-${msg.message_id}.mp4`, + msg.video.file_size ?? 0, + msg.video.mime_type ?? 'video/mp4', + ), + ); + } + + return attachments; +} diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts new file mode 100644 index 0000000..5100dd5 --- /dev/null +++ b/src/scripts/deploy-commands.ts @@ -0,0 +1,81 @@ +import 'dotenv/config'; +import { spawn } from 'child_process'; +import path from 'path'; + +const PROVIDER_DEPLOY_SCRIPTS: Record = { + discord: path.resolve(__dirname, '..', 'providers', 'discord', 'deploy.js'), + telegram: path.resolve(__dirname, '..', 'providers', 'telegram', 'deploy.js'), +}; + +const KNOWN_PROVIDERS = new Set(['discord', 'slack', 'telegram']); + +function parseEnabledProviders(): string[] { + const raw = process.env.ENABLED_PROVIDERS; + if (!raw) return ['discord']; + const names = raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const unknown = names.filter((n) => !KNOWN_PROVIDERS.has(n)); + if (unknown.length > 0) { + console.error( + `[deploy-commands] Unknown provider name(s) in ENABLED_PROVIDERS: ${unknown.join(', ')}. ` + + `Allowed: ${[...KNOWN_PROVIDERS].join(', ')}.`, + ); + process.exit(1); + } + return names; +} + +function runDeploy(provider: string, scriptPath: string): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath], { + stdio: 'inherit', + env: process.env, + }); + child.on('close', (code) => resolve(code === 0)); + child.on('error', (err) => { + console.error(`[deploy-commands] Failed to spawn ${provider} deploy:`, err); + resolve(false); + }); + }); +} + +type Outcome = 'ok' | 'failed' | 'skipped'; + +async function main() { + const enabled = parseEnabledProviders(); + if (enabled.length === 0) { + console.error('[deploy-commands] ENABLED_PROVIDERS is empty — nothing to deploy.'); + process.exitCode = 1; + return; + } + + console.log(`[deploy-commands] Enabled providers: ${enabled.join(', ')}`); + const results: Array<{ provider: string; status: Outcome }> = []; + + for (const provider of enabled) { + const script = PROVIDER_DEPLOY_SCRIPTS[provider]; + if (!script) { + console.log( + `[deploy-commands] Provider "${provider}" has no deploy script — skipping.`, + ); + results.push({ provider, status: 'skipped' }); + continue; + } + console.log(`\n[deploy-commands] Running ${provider} deploy (${script})...`); + const ok = await runDeploy(provider, script); + results.push({ provider, status: ok ? 'ok' : 'failed' }); + } + + console.log('\n[deploy-commands] Summary:'); + for (const r of results) { + console.log(` ${r.provider}: ${r.status}`); + } + + if (results.some((r) => r.status === 'failed')) { + process.exitCode = 1; + } +} + +main();