Skip to content

feat: add Telegram chat tools - #2

Draft
tdakkota wants to merge 42 commits into
gotd:mainfrom
tdakkota:feat/more-tools
Draft

feat: add Telegram chat tools#2
tdakkota wants to merge 42 commits into
gotd:mainfrom
tdakkota:feat/more-tools

Conversation

@tdakkota

@tdakkota tdakkota commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Grows tgmcp from "read unread channels" into a Telegram client an agent can actually use: read any chat, send to it, move files in and out, and mark what an agent sent. Every capability stays opt-in, and the server is read-only unless a right is granted.

Tools

Reading: get_me, resolve_peer, search_chats, list_chats, get_chat_messages, search_chat_messages, mark_chat_read, mark_all_channels_read, get_file.

Sending, behind TG_ALLOW_SEND: send_message, edit_message, preview_format, send_file, send_poll, vote_poll, send_reaction, send_chat_action, send_screenshot_notification, list_inline_results, send_inline_result.

Profile, behind TG_ALLOW_PROFILE_EDIT: update_profile, update_profile_photo.

Formatting

parse_mode takes plain (default), markdown, html, or rich_markdown / rich_html. The rich modes send a rich message — page blocks rather than entities, so headings, lists, checklists, tables and block math work; there is no table entity, so this is how a table gets into a chat. Reading goes the other way: a rich message leaves the flat text empty, so its blocks are rendered back to Markdown and flagged rich: true, without which such a message reads as blank.

preview_format renders any of those without sending, returning the text Telegram will display, the entities with the substring each covers, and the length in UTF-16 units — so an agent can catch a markup error before it reaches a chat.

Files

get_file stores media in a blob store and returns a reference: a URL for the agent to fetch and an id another MCP server sharing the store can read. Nothing large enters the model's context. The store is either an S3 bucket (TG_BLOB_S3_*), which works when the agent cannot reach this process, or the server's own HTTP listener (TG_BLOB_BASE_URL). Neither configured means storage is denied outright rather than minting a URL that resolves nowhere.

send_file reads from that store too, so one server's output is another's input without a shared filesystem. kind picks how Telegram renders the bytes — auto, document, photo, video, gif, audio, voice, video_note, sticker — and reading names media the same way, so a voice message no longer reads as "document".

Agent attribution

TG_ATTRIBUTION marks messages as agent-sent: footer appends an italic line, bot routes them through an inline echo bot so Telegram renders a via @bot header. The bot is a second process (tgmcp echobot) with its own access list. Uploads and rich messages fall back to the footer, since an inline result cannot carry them. It marks by convention, not by proof.

Telemetry

serve runs under go-faster/sdk/app: OTLP traces, metrics and logs, with a span and metrics per MCP call. Payloads are not logged — that would be the contents of your chats — unless TG_LOG_PAYLOADS=true.

Notable fixes along the way

  • Deadlock: floodwait.Waiter serialises through one goroutine, and gotd re-enters the middleware chain on DC migration, wedging the whole server. Replaced with a waiter that retries in the calling goroutine. Reported as Deadlock: DC migration re-enters the middleware chain, hangs with floodwait.Waiter td#1842.
  • Unclassifiable uploads: message.FromReader cannot know the length, and gotd sends an unsized upload as inputFileBig with no checksum, after which Telegram will not classify the media — animations arrived as videos and round messages as flat ones, whatever attributes were attached. Uploads now carry their size. Reported as uploader: FromReader always uploads as inputFileBig, and Telegram then refuses to classify the media td#1845.
  • Leaks: get_me, resolve_peer and search_chats returned phone numbers and access hashes, copied wholesale from tg.User; the MCP middleware logged every request and response body at debug, which reaches any configured log exporter. Both fixed, with a test asserting on the serialized form.

@tdakkota
tdakkota marked this pull request as draft July 6, 2026 09:29
tdakkota and others added 9 commits August 2, 2026 22:54
Co-authored-by: Codex <codex@openai.com>
Generalize mark-read to any dialog (mark_chat_read replaces
mark_channel_read) and namespace cache mutations by peer kind, so marking
a user or group read no longer misses the cache. Add a query filter to
list_chats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Server-side peer search, so public chats absent from the dialog cache are
findable. Reuses the resolve_peer output shape, annotated with cached
dialog state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read paths dropped every *tg.MessageService, so pins, joins and screenshot
events were silently missing from history. They are now returned with
service=true and a snake_case action name derived from the TL type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Let Telegram decide which peers accept the notification instead of
guessing from the TL schema comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_file gains inline mode, returning an image content block for
vision-capable clients. It picks the smallest photo variant reaching
1280px on the long edge, since smaller is unreadable and larger only
costs context, and caps the result at 5 MiB.

TG_ALLOW_SEND now defaults to off like the other rights, so a server
started with no configuration is read-only. Existing deployments relying
on the old default must set TG_ALLOW_SEND=true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds OpenTelemetry traces, metrics and logs: a span per MCP request and per
MTProto call via gotd/contrib/oteltg, plus mcp.request.*, tg.rpc.* and
tg.flood_wait.count. The MCP receiving middleware replaces the per-tool logged()
wrapper, and injectLogger puts the zctx base logger on request contexts.

Exporters keep the SDK defaults (OTLP/gRPC to localhost:4317), so deployments
without a collector must set OTEL_*_EXPORTER=none. Binary grows 27 MB to 81 MB.
"tgmcp auth" stays outside app.Run to keep its interactive prompts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
send_message and send_file accept parse_mode: plain (default, verbatim) or
markdown, parsed by gotd's telegram/message/markdown into Telegram entities.
Default stays plain so text containing "_" or "*" is not silently reformatted.

Mentions resolve the access hash from the dialog cache, since a user session
cannot mention by ID alone; unknown users fail instead of sending a broken
mention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tdakkota and others added 18 commits August 6, 2026 13:56
TG_ATTRIBUTION selects how sends are marked: "footer" appends an italic line,
"bot" routes the message through `tgmcp echobot` so Telegram renders a
"via @bot" header. TG_ATTRIBUTION_STRICT fails the send when bot attribution is
unavailable instead of degrading to the footer, and both send tools report the
attribution actually applied.

Telegram caps inline queries at 256 characters, so the query carries a
single-use key and the payload travels through a spool directory shared by the
two processes; bbolt cannot be used since it locks to one process. The bot
publishes its username to echobot.json because a bot that is not already a
dialog cannot be resolved by the numeric ID in its token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spooled payloads record the account that wrote them, and the echo bot answers
only that account, so a leaked key is useless to anyone else and the usual
single-account setup needs no configuration. TG_BOT_ALLOWED_USERS extends this
to further user IDs.

Payloads are now read without being consumed and dropped only once the
requester is authorized: consuming on read let anyone holding a key destroy a
pending message without being able to send it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anyone can open the bot and type into it. Such a query is now answered with an
empty result set, so the client stops waiting, and logged at debug rather than
reported as a fault. Denied queries are answered the same way, without
revealing whether the key existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
send_poll and vote_poll cover polls and quizzes; votes are mapped through the
answer list rather than assuming option bytes match the index, since the
creator chooses them. Messages carrying a poll now return its question, options
and tally.

list_inline_results and send_inline_result query any inline bot and send one of
its results, which marks the message "via @bot". Note the query text is
delivered to the bot's operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Media results carry no title or description, so a caller could not tell what
sending one would post. Results now report mime type, file name, size,
dimensions and duration, and articles report their URL.

send_inline_result no longer falls back to the first result when several are
offered: picking blindly posts unknown content. A lone result is still sent
without an explicit id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_file with inline now returns audio as audio content and anything else as an
embedded resource, instead of refusing everything that is not an image. This
lets a caller retrieve a file without a writable TG_FILE_ROOT, which is the only
way to see what a message actually contains when the deployment has no file
root configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adapts to the API changes: telegram.Options.Logger and updates.Config.Logger
now take a gotd/log.Logger, bridged with logzap, and peer.Resolve takes only
the target, binding the resolver separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
floodwait.Waiter runs invocations one at a time from a single goroutine. gotd
handles a datacenter migration by calling auth.exportAuthorization through the
same middleware chain, so the nested call waits for a worker blocked on the
outer call: downloading any file hosted on another datacenter deadlocked the
whole client, not just that request. See gotd/td#1842.

floodWaiter retries FLOOD_WAIT in the calling goroutine and holds no queue, so
nesting cannot deadlock. Callers no longer wrap client.Run in waiter.Run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_file with inline now hands back a URL for anything too large or too binary
to be worth a content block, backed by go-faster/gooners/blob. The bytes stream
from Telegram into the store and reach the client out of band, so a file no
longer has to fit in the model's context or a shared filesystem: a container
with no writable TG_FILE_ROOT could previously return nothing but images.

TG_BLOB_BASE_URL is what gets advertised and MCP_ADDR is where we listen, so the
handler can sit behind a reverse proxy. Unset, the store is blob.Deny and the
tool fails naming the variable rather than returning a URL that resolves
nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
preview_format renders text through the same styling pipeline as sending,
without any RPC, so markup can be checked and markup errors caught before a
message reaches a chat. edit_message replaces the text or media caption of a
message, re-applying the agent footer.

Both accept a new html parse mode, backed by gotd's html package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rich message keeps its content in page blocks and leaves the flat text
empty, so tgmcp reported it as an empty message: headings, lists and tables
were invisible. Render the blocks back to Markdown into text, and flag the
message with rich and truncated.

The rendering is lossy on purpose: blocks with no textual form, such as photos
and embeds, become a bracketed placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add rich_markdown and rich_html parse modes to send_message and edit_message.
The source is handed to Telegram to parse, which is what the official clients
do; gotd can parse locally but documents that as best-effort.

preview_format returns the block outline for those modes, answering the
question that matters — did the table parse as a table — and carries a note
that the preview is parsed locally. Rich messages cannot go through the echo
bot, so attribution degrades to a footer appended in the source syntax.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emphasis markers wrapped their own padding, so a styled run ending in a
newline produced "**\ntext\n**", which renders literally. Hoist whitespace
out of the markers, and trim headings, which cannot span lines.

Editing a message sent through the echo bot fails with INLINE_BOT_REQUIRED:
only the bot may edit it, and that needs an inline message id the account
never receives. Say so instead of surfacing the raw RPC error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update gooners to v0.8.0-alpha.v3 and wire its blob/s3 store, which works when
the agent cannot reach this process and lets several MCP servers share one
store. A configured bucket takes precedence over the local HTTP store;
configuring neither still denies, as before.

Credentials stay out of tgmcp's configuration: the store takes them from the
ambient AWS chain. get_file now also returns blob_id, which names the object
for another server sharing the bucket and does not expire with the URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PeerInfo copied every field of tg.User, so get_me returned the caller's own
number and resolve_peer and search_chats returned other people's. A tool result
is read by a model, kept in a transcript and, with the OTLP exporters on,
written to a log.

Nothing needed it: a phone is an input to resolve_peer, never an output. Test
asserts on the serialized form, since the leak would return the way it came.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MCP middleware logged every request and response body at debug, which is
the contents of chats, and reaches whatever OTEL_LOGS_EXPORTER points at. Log
the method, tool, duration and error instead; TG_LOG_PAYLOADS=true restores the
bodies for debugging.

PeerInfo also carried the access hash for the same reason it carried the phone
number: it was copied from tg.User wholesale. No tool takes one as input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_file could store a file but send_file could not read one back, so the
store was output-only and an upload still needed a filesystem shared with this
process. send_file now takes blob_id as an alternative to path, which is what
makes another MCP server's output usable as a Telegram upload.

Set the filename and MIME type explicitly while here. gotd sets neither, and
Telegram shows a document without a filename as its numeric id, so an uploaded
video arrived as "Unknown Track".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
send_file took a single as_photo flag, so every other upload arrived as a
plain attachment: a video was not playable and an audio file was not a track.
Replace it with kind — auto, document, photo, video, gif, audio, voice,
video_note or sticker — defaulting to auto, which reads the MIME type. auto
never infers voice, video_note or sticker: those say how the sender means the
bytes, not what they are.

Reading gains the same names. Everything that is not a photo arrives as a
document, so reporting "document" for a voice message told a reader nothing.

gif no longer goes through gotd's GIF helper, which forces the MIME type to
image/gif and made an mp4 arrive as an unreadable document. gif and video_note
are still downgraded by Telegram; a tgmock test pins what we send so the cause
can be looked for on the right side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tdakkota and others added 15 commits August 7, 2026 21:40
An animation carried an animated attribute and nosound_video, neither of which
tdlib sends: there an animation is a plain mp4 document, and the server infers
it from the missing audio track. A video note was missing nosound_video, which
tdlib does set.

Telegram still returns both as ordinary videos, so this is not the whole
story, but the request now matches the reference implementation and the test
pins it there. Re-uploading the bytes of a message Telegram already serves as
an animation reproduces it, so the file is not the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag means "send as a video even without an audio track", and is
documented to suppress documentAttributeAnimated when set, so every attempt
that set it was asking Telegram not to make an animation. Send the attribute
the way tdesktop does and leave the flag to video notes, where tdlib sets it.

Animations are still not classified as such; this removes one wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
send_file takes thumbnail_path or thumbnail_blob_id and sends it as the
document's cover, which is the one flag every real client sets and tgmcp did
not. Making one here would mean decoding the video, so the caller prepares it,
as the Bot API also requires.

It does not fix animation classification: the exact bytes of a message
Telegram serves as an animation still come back as a video, now with the
thumbnail too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
message.FromReader cannot know the length, and gotd sends an unsized upload as
inputFileBig with no MD5 checksum, which no real client does for a small file.
Telegram then declines to classify it: an animation arrived as a video and a
round message as a plain one, whatever attributes were attached.

The blob store knows the size, so upload through message.FromFile and report
it. gif and video_note now arrive as themselves. Also set nosound_video on
video, its documented purpose, so a silent mp4 is not promoted to an animation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tgmcp named the argument blob_id and hand-rolled the result fields, while the
blob package defines Source and Result for exactly this and documents why: an
agent that learns the shape from one server should know it for the rest.
Embed them instead, so the argument is blob and the result carries name too.

Source.Open also gives the same refusal everywhere for an unset store or an
empty id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reader could not tell a forward from an original, an edited message from
its first version, or six photos of one album from six separate messages,
and could send reactions without ever seeing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A chat resolved by @username but absent from the dialog cache reported an
empty type with broadcast and megagroup both false, which reads as a known
"not a channel" for a channel. The resolver already fetched the answer:
contacts.resolveUsername returns the whole chat and gotd keeps only the
input peer, so it is now filed away and used on a cache miss. No extra RPC.

Broadcast and megagroup are dropped from the tool output; type carried the
same fact and three fields for one fact are what let it disagree. They stay
in the session file, so an older build still reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the channel updates were handled, so nothing incremented the unread
count of a direct message or a legacy group after the initial dialog fetch:
list_chats missed every DM that arrived while the process ran, and one that
was already unread stayed frozen. updateDialogUnreadMark was handled for
channels only for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Telegram encodes and distributes the frames, so the account only pushes to
an ingest URL and never speaks WebRTC. start_stream opens the call and
returns the url and key for ffmpeg or OBS; stop_stream discards it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bootstrapDialogs only ran when nothing was persisted, so after the first run
the cache was a frozen snapshot: getDifference reports activity on dialogs
already known and never enumerates one joined since, while the handlers drop
the traffic that would introduce it (own, service and supergroup messages).

Also learn a channel from updateChannel when the container carries the entity,
which catches one created while running without waiting for a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comparing a cached entry before writing it back raced: a concurrent unread
increment could land between the read and the write and be lost, and two
updates for one channel could both decide it was unknown. learn does both
under the same lock and returns early when nothing changed, which keeps
updateChannel from persisting a dialog per update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The revoke flag on start_stream returned the replacement key, so every
rotation published a fresh secret into the transcript it was meant to
contain. Split it into a tool that reports nothing but success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The HTTP mux had no authentication: anything reaching MCP_ADDR called
tools as the account, with the loopback bind doing the access control.
MCP_AUTH_TOKEN now guards it through gooners/mcpauth, and the serving
itself moves to gooners/mcpcmd, which brings TLS, client certificates,
a cloudflared tunnel and /health and /readyz.

Auth stays off by default, so an existing loopback deployment is
unchanged apart from a warning. A tunnel refuses to start without it:
publishing the listener retires the only protection there was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the pseudo-version pinned to the branch that carried mcpauth,
mcpcmd and tunnel out of gooners' internal packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bot was a second container running the same image with a different
argv, which meant a second thing to redeploy; it had drifted four commits
behind. serve now runs it as a second Telegram account in its own
process, gated on bot attribution and a token.

It is supervised rather than joined to the errgroup: attribution is best
effort with the footer as its fallback, so a bot that cannot start must
not take the MCP server down. The subcommand stays for a bot run
somewhere other than serve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant