# telegram-bot

> Telegram bridge to the harness stack — polling or webhook ingress, live message edits, approvals, and configurable verbosity.

| field | value |
|-------|-------|
| version | 0.2.6 |
| type | binary |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, i686-pc-windows-msvc, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
iii worker add telegram-bot@0.2.6
```

## dependencies

- `state` @ `^0.21.2`
- `queue` @ `^0.2.0`
- `configuration` @ `^0.21.6`
- `session-manager` @ `^1.0.0`
- `harness` @ `^1.0.0`

## readme

# telegram-bot

A Telegram bridge to the harness agent stack. The worker owns Telegram UX — commands, inline keyboards, live message edits, and approval prompts — and delegates turns, streaming, and durability to `harness`, `session-manager`, and `approval-gate`.

Update ingress uses a session-manager-style `updates` adapter: **polling** (default, no public URL) or **webhook** (HTTPS production).

## Install

```bash
iii worker add harness session-manager llm-router context-manager approval-gate
iii worker add telegram-bot
```

## Quickstart (polling — default)

1. Set configuration (configuration id `telegram-bot`):

```yaml
bot_token: "${TELEGRAM_BOT_TOKEN}"
verbosity: minimal
default_model:
  provider: anthropic
  id: claude-sonnet-4
functions_allow:
  - "shell::*"
```

2. Message the bot on Telegram. The worker long-polls `getUpdates` in the background; user text is sent to `harness::send` with update-id idempotency; assistant output streams back via `session::message-updated` edits.

Commands: `/start` (new session + model picker or default model), `/stop` (cancel turn), `/model` (re-pick model), `/help`, `/thinking` (reasoning depth), `/verbosity` (transcript detail), `/settings`.

Each `/start` clears the chat's active harness session. The next message after model selection creates a fresh session (harness-assigned ID); later messages continue that session until the next `/start`.

Assistant output streams via `sendMessageDraft` when available (Bot API 9.3+), with `editMessageText` fallback. Model thinking blocks stream via `sendRichMessageDraft` (`RichBlockThinking`). Drafts are finalized to persistent messages on turn completion.

The worker does not send a `sendChatAction(typing)` indicator: Telegram has no stop-typing API and each action lingers ~5s on clients, leaving the indicator visible for seconds after the answer arrives. Progress is shown by streamed thinking/answer drafts (draft transport) or the streamed message bubble (edit transport) instead.

When a tool call needs approval, the bot posts an inline keyboard: **Approve**, **Reject**, and **Approve always** (per-session grant via `approval::approve-always`).

## Webhook mode (production)

```yaml
bot_token: "${TELEGRAM_BOT_TOKEN}"
updates:
  name: webhook
  config:
    base_url: "https://your-engine.example"   # iii engine root only
    secret: "your-webhook-secret"             # recommended
```

`base_url` is the **public root of your iii engine** — the bot appends its own
path (`/telegram-bot/webhook`) to build the URL handed to Telegram, so operators
never repeat the path. Selecting the `webhook` adapter (at boot or via
hot-reload) does everything automatically, with no restart:

1. registers the `telegram-bot/webhook` HTTP route on the engine, then
2. calls Telegram `setWebhook` with `{base_url}/telegram-bot/webhook`.

Switching back to `polling` reverses both — it calls `deleteWebhook` and then
removes the HTTP route. No manual step is required.

> The legacy full-URL form (`url: https://…/telegram-bot/webhook`) is still
> accepted for backward compatibility. A `secret` is strongly recommended:
> without it, anyone who learns the URL can inject forged updates.

`POST /telegram-bot/set-webhook` remains available to manually re-arm Telegram
(e.g. if it dropped the webhook) without changing configuration.

## Configuration

All fields hot-reload through the `configuration` worker — no restart required, including `bot_token` and `updates` adapter swaps.

| Field | Description |
|---|---|
| `bot_token` | **Required.** Telegram Bot API token (`${TELEGRAM_BOT_TOKEN}`) |
| `updates` | Ingress adapter: `polling` (default) or `webhook` — see below |
| `default_model` | Skip model picker when set (`provider` + `id`) |
| `verbosity` | `none` \| `minimal` \| `high` \| `debug` — controls transcript mirroring |
| `default_thinking_level` | Optional harness reasoning depth: `minimal` \| `low` \| `medium` \| `high` \| `xhigh` |
| `streaming` | Draft streaming — `transport` (`auto`/`draft`/`edit`), `draft_id_seed`, `draft_throttle_ms`, `create_settle_ms` |
| `steering_mode` | `steering` (default, harness merge) or `fifo` (local queue) |
| `functions_allow` | Globs for `harness::send` `options.functions.allow` |
| `system_prompt` | Optional system prompt on every send |
| `system_prompt_mode` | `override` (default, replaces the harness prompt) or `enrich` (appends `system_prompt` to it) |
| `channel_context` | `auto` (default, injects the built-in Telegram channel-context prompt) or `off` |
| `timeout_ms` | Timeout for harness, approval, state, and configuration RPCs (default `10000`) |

### Telegram channel awareness

By default (`channel_context: auto`) the bot layers a built-in **channel-context
prompt** onto every send, telling the agent that it is talking to a Telegram
user: its reply text is the only thing the user sees (no visible console — so
"log a reminder" reaches no one), how to format for Telegram, and how to reach
the user *later* (reminders/schedules) by targeting this session. The prompt
carries the live `chat_id` and `session_id`.

The agent reaches the user out-of-band via **`telegram-bot::notify`**
(`{ session_id, text, parse_mode? }`) — a session-scoped send that delivers only
to the chat bound to that session. For reminders the agent binds a `cron`
trigger to `telegram-bot::notify` (fixed text) or `harness::send` (generated
reply) targeting this session.

Composition with `system_prompt_mode`: `enrich` layers `[channel context] +
[system_prompt]` on top of the harness's built-in prompt; `override` uses
`[channel context] + [system_prompt]` alone. Set `channel_context: off` to drop
the channel layer entirely.

### `updates` adapter

**Polling** (default):

```yaml
updates:
  name: polling
  config:
    timeout_seconds: 50   # optional, Telegram max 50
```

**Webhook**:

```yaml
updates:
  name: webhook
  config:
    base_url: "https://your-engine.example"   # iii engine root; required
    secret: "your-webhook-secret"             # recommended
```

The bot derives the Telegram webhook URL as `{base_url}/telegram-bot/webhook`
and registers/removes the HTTP route as the adapter is switched to/from
`webhook` — see [Webhook mode](#webhook-mode-production).

Verbosity levels:

- `none` — final assistant text, errors, approvals only (thinking still shown via native rich draft)
- `minimal` — same as `none` for transcript mirroring; thinking uses RichBlockThinking regardless
- `high` — also mirrors function call blocks
- `debug` — also mirrors function result entries

Persisted at `./data/configuration/telegram-bot.yaml` when using the default fs adapter.

Final assistant messages are rendered as Telegram HTML (LLM markdown converted to bold, code, links, lists). Live streaming edits stay plain text until finalization to avoid broken partial markup.

## Trace correlation

Telegram ingress and harness bindings share OpenTelemetry baggage so the console can group traces by session and turn:

- **`iii.session.id`** — harness session id (or `pending-{chat_id}` before the first send in a chat)
- **`iii.message.id`** — `tg-{update_id}` at ingress; harness `turn_id` after `harness::send` returns and for binding handlers

`harness::send` receives `options.metadata` with `{ session_id, message_id, surface: "telegram" }` for engine passthrough. Polling mode stamps baggage per update in the poller; webhook mode stamps it in the HTTP handler.

## Local development & testing

```bash
cargo run --release -- --url ws://127.0.0.1:49134 --config ./tests/fixtures/config.yaml
cargo test
UPDATE_GOLDENS=1 cargo test   # after schema changes
```

HTTP endpoints (engine default `http://127.0.0.1:3000`):

- `POST /telegram-bot/webhook` — Telegram update ingress. Registered **only while the `webhook` adapter is active**; removed in polling mode.
- `POST /telegram-bot/set-webhook` — manually (re-)register the Telegram webhook from config. Always available.

## api reference

```json
{
  "functions": [
    {
      "description": "Internal: track approval-gate worker add/remove.",
      "metadata": {},
      "name": "telegram-bot::__on-approval-gate-worker",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "operation": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "default": null
          },
          "stage": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          },
          "worker": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "WorkerLifecycleEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "WorkerLifecycleAck",
        "type": "object"
      }
    },
    {
      "description": "Send a message to the Telegram chat bound to a session. Use this to reach the user out-of-band (e.g. a scheduled reminder or when a long task finishes); the message is delivered only to that session's chat.",
      "metadata": {},
      "name": "telegram-bot::notify",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "parse_mode": {
            "description": "Optional Telegram `parse_mode` (`MarkdownV2` / `HTML`). When set, `text` is sent verbatim with that mode instead of being auto-formatted.",
            "type": [
              "string",
              "null"
            ]
          },
          "session_id": {
            "description": "The session whose chat receives the message. Must be a session this bot created (e.g. the `session_id` from the channel-context prompt).",
            "type": "string"
          },
          "text": {
            "description": "The message text. Markdown is auto-formatted for Telegram unless `parse_mode` is set explicitly. Long text is split across messages.",
            "type": "string"
          }
        },
        "required": [
          "session_id",
          "text"
        ],
        "title": "NotifyRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "delivered": {
            "type": "boolean"
          },
          "message_ids": {
            "description": "Telegram message ids of the (possibly chunked) messages sent.",
            "items": {
              "format": "int64",
              "type": "integer"
            },
            "type": "array"
          }
        },
        "required": [
          "delivered",
          "message_ids"
        ],
        "title": "NotifyResponse",
        "type": "object"
      }
    },
    {
      "description": "Internal: reload telegram-bot configuration from the authoritative store on change.",
      "metadata": {},
      "name": "telegram-bot::on-config-change",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ConfigChangeRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "ConfigChangeAck",
        "type": "object"
      }
    },
    {
      "description": "Create a Telegram message for each new assistant or function_result entry.",
      "metadata": {},
      "name": "telegram-bot::on-message-added",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "AgentMessage": {
            "properties": {
              "content": {
                "items": {
                  "$ref": "#/definitions/ContentBlock"
                },
                "type": "array"
              },
              "role": {
                "type": "string"
              }
            },
            "required": [
              "role"
            ],
            "type": "object"
          },
          "ContentBlock": {
            "oneOf": [
              {
                "properties": {
                  "text": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "text"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "text",
                  "type"
                ],
                "type": "object"
              },
              {
                "properties": {
                  "text": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "thinking"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "text",
                  "type"
                ],
                "type": "object"
              },
              {
                "properties": {
                  "arguments": true,
                  "function_id": {
                    "type": "string"
                  },
                  "id": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "function_call"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "arguments",
                  "function_id",
                  "id",
                  "type"
                ],
                "type": "object"
              },
              {
                "properties": {
                  "type": {
                    "enum": [
                      "other"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "type"
                ],
                "type": "object"
              }
            ]
          }
        },
        "properties": {
          "entry_id": {
            "type": "string"
          },
          "message": {
            "anyOf": [
              {
                "$ref": "#/definitions/AgentMessage"
              },
              {
                "type": "null"
              }
            ]
          },
          "parent_id": {
            "default": null,
            "description": "Parent entry in the session chain (authoritative append order).",
            "type": [
              "string",
              "null"
            ]
          },
          "session_id": {
            "type": "string"
          },
          "timestamp": {
            "default": 0,
            "description": "Append time in ms; used as the per-entry ordering key.",
            "format": "int64",
            "type": "integer"
          }
        },
        "required": [
          "entry_id",
          "session_id"
        ],
        "title": "MessageAddedEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "BindingAck",
        "type": "object"
      }
    },
    {
      "description": "Stream assistant edits into Telegram, throttled by revision.",
      "metadata": {},
      "name": "telegram-bot::on-message-updated",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "AgentMessage": {
            "properties": {
              "content": {
                "items": {
                  "$ref": "#/definitions/ContentBlock"
                },
                "type": "array"
              },
              "role": {
                "type": "string"
              }
            },
            "required": [
              "role"
            ],
            "type": "object"
          },
          "ContentBlock": {
            "oneOf": [
              {
                "properties": {
                  "text": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "text"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "text",
                  "type"
                ],
                "type": "object"
              },
              {
                "properties": {
                  "text": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "thinking"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "text",
                  "type"
                ],
                "type": "object"
              },
              {
                "properties": {
                  "arguments": true,
                  "function_id": {
                    "type": "string"
                  },
                  "id": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "function_call"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "arguments",
                  "function_id",
                  "id",
                  "type"
                ],
                "type": "object"
              },
              {
                "properties": {
                  "type": {
                    "enum": [
                      "other"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "type"
                ],
                "type": "object"
              }
            ]
          }
        },
        "properties": {
          "entry_id": {
            "type": "string"
          },
          "message": {
            "$ref": "#/definitions/AgentMessage"
          },
          "revision": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "session_id": {
            "type": "string"
          },
          "timestamp": {
            "default": 0,
            "description": "Mutation time in ms; refines the per-entry ordering key (min wins).",
            "format": "int64",
            "type": "integer"
          }
        },
        "required": [
          "entry_id",
          "message",
          "revision",
          "session_id"
        ],
        "title": "MessageUpdatedEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "BindingAck",
        "type": "object"
      }
    },
    {
      "description": "Send an inline approval keyboard when a function call is held.",
      "metadata": {},
      "name": "telegram-bot::on-pending-created",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "arguments_excerpt": {
            "default": null
          },
          "function_call_id": {
            "type": "string"
          },
          "function_id": {
            "type": "string"
          },
          "session_id": {
            "type": "string"
          }
        },
        "required": [
          "function_call_id",
          "function_id",
          "session_id"
        ],
        "title": "PendingApprovalRecord",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "BindingAck",
        "type": "object"
      }
    },
    {
      "description": "Clear the approval prompt when a held call is resolved.",
      "metadata": {},
      "name": "telegram-bot::on-pending-resolved",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "function_call_id": {
            "type": "string"
          },
          "outcome": {
            "type": "string"
          },
          "session_id": {
            "type": "string"
          }
        },
        "required": [
          "function_call_id",
          "outcome",
          "session_id"
        ],
        "title": "PendingResolvedEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "BindingAck",
        "type": "object"
      }
    },
    {
      "description": "Observe session status changes.",
      "metadata": {},
      "name": "telegram-bot::on-status-changed",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "session_id": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "status_reason": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "session_id",
          "status"
        ],
        "title": "StatusChangedEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "BindingAck",
        "type": "object"
      }
    },
    {
      "description": "Finalize streaming, drain FIFO queue, and post turn outcome toasts.",
      "metadata": {},
      "name": "telegram-bot::on-turn-completed",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "reason": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          },
          "result_error": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          },
          "session_id": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "terminal": {
            "default": true,
            "description": "`false` while the session still owns an armed wake: the run continues and a later turn carries the real outcome. Missing (legacy harness) → terminal.",
            "type": "boolean"
          },
          "turn_id": {
            "type": "string"
          }
        },
        "required": [
          "session_id",
          "status",
          "turn_id"
        ],
        "title": "TurnCompletedEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "BindingAck",
        "type": "object"
      }
    },
    {
      "description": "Register the Telegram webhook URL from configuration.",
      "metadata": {},
      "name": "telegram-bot::set-webhook",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "SetWebhookRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "url": {
            "type": "string"
          }
        },
        "required": [
          "ok",
          "url"
        ],
        "title": "SetWebhookResponse",
        "type": "object"
      }
    },
    {
      "description": "Receive Telegram updates; route commands, messages, and callbacks.",
      "metadata": {},
      "name": "telegram-bot::webhook",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "body": {
            "default": null
          },
          "headers": {
            "additionalProperties": true,
            "default": null,
            "type": [
              "object",
              "null"
            ]
          }
        },
        "title": "HttpTriggerRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "WebhookResponse",
        "type": "object"
      }
    }
  ],
  "triggers": []
}
```
