Go Telegram Multibot
A scalable, multi-bot solution for Telegram using Go, GORM, and the Anthropic API.
Design Considerations
- AI-powered (Anthropic Claude)
- Voice message support (ElevenLabs STT + TTS) — optional, enabled per bot via config
- Supports multiple bot profiles
- Uses SQLite for persistence
- Implements rate limiting and user management
- Modular architecture
- Comprehensive unit tests
Usage
Docker Deployment (Recommended)
-
Clone the repository:
git clone https://github.com/HugeFrog24/go-telegram-bot.git cd go-telegram-bot -
Copy the default config template and edit it:
cp config/default.json config/mybot.json nano config/mybot.json
Important
Keep your config files secret and do not commit them to version control.
- Create data directory and run:
mkdir -p data docker-compose up -d
Native Deployment
-
Install using
go get:go get -u github.com/HugeFrog24/go-telegram-bot cd go-telegram-bot -
Configure as above, then build:
go build -o telegram-bot
Trying Out New Behavior Safely
Want to experiment with a different personality, tone, or set of instructions without disturbing the bot your users already talk to? Run a second, separate bot just for testing.
Each bot profile is its own config file with its own Telegram token, and bots are fully independent — separate identity, separate chat history, separate settings. So a "test twin" is quick to set up:
- Create a new bot with @BotFather and copy its token.
- Copy your existing config to a new file, e.g.
cp config/mybot.json config/mybot-test.json. - In the new file, paste the new token, give it a different
id, and editsystem_promptsto try your changes. - Start it alongside your main bot. Chat with the test bot, tweak its prompt, and restart the test bot to try again — your real users never see the experiments.
- Happy with the result? Copy the same change into your main bot's config and restart it.
Note
A test bot always needs its own token. Telegram only lets one running bot listen on a given token, so you can't point a second copy at your live bot — give the twin its own @BotFather bot instead.
Configuration
Each bot is one JSON file in config/ (see config/default.json for the template). Keys of note:
| Key | Type | Default | Description |
|---|---|---|---|
max_tokens |
number | 1000 |
Maximum output tokens per reply. Thinking tokens count toward this limit — raise it (e.g. 4000+) whenever thinking is "adaptive", or a turn can spend the whole budget on reasoning and produce no text. |
thinking |
string | (omitted) | Reasoning mode: "adaptive" (the model decides when and how much to think) or "disabled". Omit the key entirely to use the model's own API default. If the configured model doesn't support the chosen mode, the API rejects the request with a 400 — owners/admins see the raw error, regular users get the generic fallback. Check Anthropic's model docs for per-model support. |
thinking_display |
string | (omitted) | "summarized" or "omitted". Only valid together with "thinking": "adaptive". Controls whether the API returns a readable summary of the reasoning (logged, never sent to chat). Thinking is billed the same either way; when omitted, the API's per-model default applies. |
debounce_ms |
number | (omitted) | Quiet window, in milliseconds, for coalescing rapid follow-up messages into a single turn. Omit or set 0 to disable. See Coalescing rapid messages below. |
cache_history |
bool | true |
Places a prompt-cache breakpoint on the trailing conversation block, so each turn reads the prior history from cache instead of reprocessing it at full price. Set false to cache only the system prompt. |
web_search |
object | (omitted) | Enables Anthropic's server-side web search (and, optionally, web fetch), sandboxed to a domain allowlist. Omit the key entirely to leave both tools off — an absent block sends byte-identical requests to before. See Web search / fetch below. |
Every reply logs one accounting line — [usage] model=... in=... out=... thinking=... cache_read=... cache_write=... stop=... — so thinking spend (billed even when its text is omitted) stays visible in journalctl/docker compose logs. A stop=max_tokens line is accompanied by an error-level warning that the reply was truncated.
Tip
Watch
cache_read/cache_writeafter changing prompts or models. A cache breakpoint below the model's minimum cacheable prefix fails silently — no error, justcache_write=0forever. The minimum is model-specific and not monotonic across generations (Haiku 4.5 needs 4096 tokens; Sonnet 4.6 needs 1024), so a short system prompt that caches fine on one model may never cache on another. Note also that chat memory is a sliding window: once it is full, each turn evicts the oldest message and changes the prefix, socache_readon long-running chats will be lower than on fresh ones.
Coalescing rapid messages
A user who sends "how do I do X", then "sorry typo", then "lmao" in five seconds would otherwise get three separate replies — the bot starts a full turn per message, because each Telegram update independently drives one. debounce_ms holds text messages in a per-chat buffer and resets the window on every new message, dispatching a single turn once the user stops typing.
"debounce_ms": 2500
Reasonable windows are 1500–3000ms for ordinary chat and up to 8000ms for Telegram Business, where a person writing to a business account tends to send longer bursts. The maximum is 30000ms.
Nothing is discarded while buffering. Each message is still persisted and added to chat memory the moment it arrives, so the single coalesced turn sees all of them — the buffer only decides when to answer, never what the model reads. Reply metadata (language, premium status, business connection) follows the most recent message in the batch.
What deliberately does not wait:
- Commands (
/stats,/clear, …) dispatch immediately. - Photos, albums, voice, and stickers dispatch immediately and cancel any pending text window. The buffered text is not lost — it is already in memory, so the media turn answers it too.
/clearand/clear_hardcancel the buffer outright. Without this, the window would fire seconds after the wipe and replay the just-deleted messages back into memory.
While a turn is running the bot shows Telegram's "typing…" indicator (or "recording audio" while synthesising a voice reply), refreshed every 4 seconds because Telegram expires the status after 5. Without it, a debounce window reads as the bot ignoring you — which is what prompts users to send more messages in the first place.
Note
Debouncing is the cheap fix and the reason there is no "cancel the in-flight request" mode. Anthropic bills input tokens plus any output already generated when a turn stops partway, and any web searches it already ran are billed and re-billed on the retry. A message that never dispatched costs nothing.
Web search / fetch
The web_search block wires in Anthropic's server-side web_search (and optionally web_fetch) tools, mirroring how mcp_servers wires in MCP toolsets: the engine provides the generic mechanism, and the per-bot config carries the policy. No domains are hardcoded in the engine — each profile declares its own allowlist. When the model decides a question needs current information, it runs a search server-side; results and any fetch targets are confined to the domains you list.
"web_search": {
"allowed_domains": [
"example.com/help",
"docs.example.com"
],
"max_uses": 3,
"fetch": true,
"max_content_tokens": 50000
}
| Field | Type | Description |
|---|---|---|
allowed_domains |
string[] | Restricts web_search results (and, by default, web_fetch targets) to these. Each entry is a domain with an optional path prefix and no scheme — example.com or example.com/help. Mutually exclusive with blocked_domains. The path only scopes web_search — see the asymmetry note below. |
blocked_domains |
string[] | Excludes these domains instead of allowlisting. Mutually exclusive with allowed_domains (setting both fails validation at boot, since the API 400s). |
fetch_allowed_domains |
string[] | Host-only allowlist for web_fetch, independent of the search list. Omit it and web_fetch reuses the hosts of allowed_domains. Set it to fetch a narrower set than you search — e.g. to keep a shared host (a social platform) search-only. Only meaningful with fetch: true. |
max_uses |
number | Caps how many searches the model may run per turn. Omit for no cap. |
fetch |
bool | Also enable web_fetch, which pulls full page content for URLs already surfaced by a search or pasted by the user (it cannot fetch model-invented URLs). Citations are always on when fetch is enabled, so fetched passages are sourceable. |
max_content_tokens |
number | Caps the tokens a single web_fetch may pull into context. Only meaningful with fetch: true. |
Search/fetch path asymmetry (important). Anthropic filters the two tools differently: web_search honors a path prefix (example.com/help matches only example.com/help/...), but web_fetch matches on the host only — an entry that includes a path never matches any fetch URL. The engine bridges this: web_search gets your entries verbatim, while web_fetch gets the host portion of each entry (path stripped, deduped). So a path-scoped allowed_domains still permits fetching across the whole host. If that host isn't wholly trusted, list the fetchable hosts explicitly in fetch_allowed_domains instead.
Search-only pattern for shared hosts (e.g. social). To let the model search a single account on a shared platform without ever fetching the wider platform, put the account path in allowed_domains and leave its host out of fetch_allowed_domains:
"web_search": {
"allowed_domains": ["helpcenter.example/hc", "x.com/youraccount"],
"fetch_allowed_domains": ["helpcenter.example"],
"fetch": true
}
Here search is confined to your help center and your one social account, but fetch can only ever reach helpcenter.example — a pasted x.com/someone-else URL is not fetchable. Note the search side only sandboxes cleanly on platforms that keep an account's content under its handle path (X/Twitter, TikTok @handle, Facebook); use the handle/ or handle/* form to avoid a prefix bleed. Instagram is an exception — posts live at instagram.com/p/<code>, not under the handle — so it can't be account-sandboxed by path.
The allowlist is a hard, server-side sandbox, not a prompt request — off-list results are dropped and an off-list fetch target returns url_not_allowed, which is the exfiltration mitigation Anthropic recommends for bots processing untrusted input. Keep the list tight. A fetch can still fail for site-side reasons (bot protection such as Cloudflare returns url_not_accessible); the search snippet plus citations remain the reliable signal, so a full fetch is a bonus, not a dependency.
web_fetch can only open a URL that a web_search result surfaced (or the user pasted) — never one the model invents or rebuilds from a page title; those return url_not_in_prior_context. So a fetch-enabled bot's prompt should steer it to search first, then fetch a returned URL, and to re-search with a more specific query (rather than guess a URL) when the exact page it wants isn't in the results.
Web search is not free — Anthropic bills per search (plus the tokens the results add) — so scope the allowlist and max_uses deliberately, and lean on a prompt that tells the bot to search only when a question actually turns on current or authoritative information. Search activity logs as [web] ... lines, and long multi-search turns are handled transparently (the engine follows Anthropic's pause_turn continuations up to a small cap, so a slow search doesn't truncate the reply).
Tip
For deep request/response debugging, the Anthropic Go SDK ships
option.WithDebugLog(...)(dumps full HTTP bodies with auth headers redacted). It is not wired into the bot — dev-only, add it temporarily to the client constructor if you ever need wire-level traces.
Future: persistent memory
The Anthropic memory tool (memory_20250818) is a candidate future feature for cross-conversation recall (a self-hosted analog of ChatGPT's "memory"). The Go SDK already ships the types (BetaMemoryTool20250818Param and its tool-union slot plus the six-command union: view/create/str_replace/insert/delete/rename), but — unlike the Python/TypeScript/Java SDKs — provides no handler helper: the bot would have to hand-write client-side command dispatch against per-chat storage, including strict path validation (canonicalize and confine every model-supplied path under a fixed memory root; reject ../symlink traversal) and a no-secrets policy for stored content. Not implemented yet.
Systemd Unit Setup
To enable the bot to start automatically on system boot and run in the background, set up a systemd unit.
-
Copy the systemd unit template and edit it:
sudo cp examples/systemd/telegram-bot.service /etc/systemd/system/telegram-bot.serviceEdit the service file:
sudo nano /etc/systemd/system/telegram-bot.serviceAdjust the following parameters:
- WorkingDirectory
- ExecStart
- User
-
Enable and start the service:
sudo systemctl daemon-reloadsudo systemctl enable telegram-botsudo systemctl start telegram-bot -
Check the status:
sudo systemctl status telegram-bot
For more details on the systemd setup, refer to the demo service file.
Logs
Docker
docker-compose logs -f telegram-bot
Systemd
journalctl -u telegram-bot -f
Commands
| Command | Access | Description |
|---|---|---|
/stats |
All users | Show global bot statistics (total users and messages) |
/stats user |
All users | Show your own message statistics |
/stats user <user_id> |
Admin/Owner | Show statistics for a specific user |
/whoami |
All users | Show your Telegram ID, username, and role |
/clear |
All users | Soft-delete your own chat history |
/clear <user_id> |
Admin/Owner | Soft-delete all messages for a user across every chat |
/clear <user_id> <chat_id> |
Admin/Owner | Soft-delete a user's messages in a specific chat |
/clear_hard |
All users | Permanently delete your own chat history |
/clear_hard <user_id> |
Admin/Owner | Permanently delete all messages for a user across every chat |
/clear_hard <user_id> <chat_id> |
Admin/Owner | Permanently delete a user's messages in a specific chat |
/set_model <model-id> |
Admin/Owner | Switch the AI model live without restarting |
Note: In private DMs each user's
chat_idequals theiruser_id. The scoped<chat_id>form is mainly useful for group chat moderation.
Testing
The GitHub actions workflow already runs tests on every commit:
However, you can run the tests locally using:
go test -race -v ./...
Storage
At the moment, a SQLite database (./data/bot.db) is used for persistent storage.
Remember to back it up regularly.
Future versions will support more robust storage backends.