feat: add Telegram chat tools - #2
Draft
tdakkota wants to merge 42 commits into
Draft
Conversation
tdakkota
marked this pull request as draft
July 6, 2026 09:29
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
force-pushed
the
feat/more-tools
branch
from
August 3, 2026 07:46
07fb300 to
14eca80
Compare
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_modetakesplain(default),markdown,html, orrich_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 flaggedrich: true, without which such a message reads as blank.preview_formatrenders 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_filestores 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_filereads from that store too, so one server's output is another's input without a shared filesystem.kindpicks 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_ATTRIBUTIONmarks messages as agent-sent:footerappends an italic line,botroutes 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
serveruns undergo-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 — unlessTG_LOG_PAYLOADS=true.Notable fixes along the way
floodwait.Waiterserialises 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.message.FromReadercannot know the length, and gotd sends an unsized upload asinputFileBigwith 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.get_me,resolve_peerandsearch_chatsreturned phone numbers and access hashes, copied wholesale fromtg.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.