diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a0e7be..5323ce90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -417,6 +417,13 @@ Ports upstream `32687038` (vercel/chat#830, chat@4.38.1), the Google Chat part o - Matching on untrusted text stays linear (results identical to upstream). See `docs/UPSTREAM_SYNC.md`. - A live Slack-loop check is pending: `@bot` in a channel and in a DM must still trigger, and a code-only `` `<@bot>` `` must not trigger `on_mention`. - Fidelity: no mapped files change (`adapter-slack/src/index.test.ts` is an adapter test). The target report is unchanged: missing 167 -> 167 (+0). +- **Telegram: native replies, replied-to context, reply-to-bot as a mention** (#228; Telegram halves of vercel/chat `0f24cc30` #802, chat@4.38.0, and `26a06ca5` #834, `d5ebec12` #833, `eddcd7e4` #828, chat@4.39.0). + - **New:** `thread.reply(target, message)` works on Telegram. `TelegramAdapter.reply(thread_id, message_id, message)` sends one API call with Bot API `reply_parameters` (`allow_sending_without_reply: true`, so a deleted target delivers unthreaded) on text, rich, document and attachment sends. `post_message` gains a keyword-only `reply_to_message_id`; `thread.post(...)` stays unthreaded. A target from another chat raises `ValidationError` before anything is sent or downloaded. Outbound media groups get it with #278. + - **New:** inbound messages that reply to another message carry `message.reply_to` (the parsed parent, one level deep). `TelegramMessage` gains `reply_to_message`. + - **New option `TelegramAdapterConfig.mention_on_reply`** (or `TELEGRAM_MENTION_ON_REPLY=true`; only the exact string `"true"` counts, and an explicit `False` wins over the env var). Off by default. When on, a reply to one of the bot's own messages sets `is_mention=True`, including a reply that carries only a photo or document. A forum topic's implicit reply to the topic-creation message and the bot's own echoed replies do not count. + - **Consumer-visible (stricter ids):** Telegram message ids passed to `edit_message`, `delete_message`, `add_reaction`, `remove_reaction` and `reply` must be ASCII digits (`"7"`) or `":"`. Bare ids with whitespace, a sign or underscores (`" 7"`, `"+7"`, `"1_0"`), non-ASCII digits, and ids with a trailing newline now raise `ValidationError("Invalid Telegram message ID: ...")`, as upstream. Ids produced by the adapter are unaffected. + - A rich-message send rejected because of `reply_parameters` falls back to a regular `sendMessage` that keeps the reply. + - `Attachment.fetch_data()` already returns `bytes` on Telegram; upstream's portable-ArrayBuffer fix (`eddcd7e4`) is N/A, and its regression test is ported. - **Telegram polling is at-least-once, and albums arrive as one message** (#227; ports vercel/chat `629e6555` #760, chat@4.37.0, and the Telegram half of `91683e52` #942, chat@4.41.0). Outbound multi-file `sendMediaGroup` (#605) is split out to #278. - **Consumer-visible (polling):** the poller now waits for every handler of a batch before advancing `offset`. A handler failure no longer loses the update: it is saved in a `telegram:polling:{sha256(bot_user_id)}` state checkpoint and retried with backoff (`max(retry_delay_ms, 1000) * 2^(attempts-1)`, capped at 30 s, at least `retry_after` on rate limits), also after a restart. Handlers must tolerate redelivery: a handler that failed (or finished just before a crash) can run again. Polled updates bypass core dedupe (`WebhookOptions(deduplicate=False)`); the checkpoint deduplicates them. A batch is handled before the next `getUpdates`, so one slow handler delays the next poll. A failed startup `getMe` is retried on every poll. - **Consumer-visible (albums):** the parts of an album (`media_group_id`) are buffered in state for 1 s after the newest part and reach handlers as **one** `Message` (newest part's id and raw, first non-empty text, all attachments in order, `is_mention` if any part mentions the bot), on the webhook and polling paths. Handlers that saw N messages per album now see one, about 1 s later (about 2 s when polling). An album caption starting with `/command` is the album's text, not a slash command. diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 5f718cd5..485aa4ae 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -986,7 +986,7 @@ Parity with the core halves of `2531a422` (#621, chat@4.34.0), `46681f50` `chat._ThreadHistoryCache`) null `raw` along the whole `replyTo` chain. `create_sent_message_from_message` and the sent-message → history `Message` copy carry `reply_to` (upstream `new Message(sent)`). There is no depth cap, - as upstream. Telegram population is #228. + as upstream. Telegram populates it from `reply_to_message` (#228). - **`_rehydrate_message` with a `Message` input is now parity.** `0f24cc30` removed upstream's `if (raw instanceof Message) return raw;` early return, so a `Message` falls through to the `rehydrateAttachment` pass, as Python @@ -1368,7 +1368,7 @@ split out to #278. first part with text, every attachment in `message_id` order, links concatenated (`None` when no part has any), `is_mention` if any part mentions the bot. `reply_to` is the first part's non-`None` `reply_to`, as - upstream; Telegram parsing does not populate it until #228. Typing starts once, for + upstream (populated from `reply_to_message` since #228). Typing starts once, for the first part, after the album settles. On the webhook path the album task is held in `_media_group_tasks` (no GC mid-settle); `wait_until` gets a wrapper that never raises, a failure is logged as "Failed to process @@ -1423,6 +1423,46 @@ split out to #278. acknowledgement never reached Telegram (a crash before the next `getUpdates`) is delivered again, as upstream. +### Telegram replies (chat@4.38–4.39, #228) + +Parity with the Telegram halves of `0f24cc30` (vercel/chat#802, chat@4.38.0), +`26a06ca5` (#834), `d5ebec12` (#833) and `eddcd7e4` (#828), all chat@4.39.0. + +- **Replied-to context (`0f24cc30`).** `parse_telegram_message` sets + `reply_to=self.parse_telegram_message(raw["reply_to_message"], thread_id)` + when the key is present and not `None` (upstream's truthy `?` on an + object). The Bot API never nests a further `reply_to_message`, so the + recursion is one level deep. `TelegramMessage` gains `reply_to_message`. +- **`mention_on_reply` (`26a06ca5`).** `TelegramAdapterConfig.mention_on_reply` + (keyword-only, so positional callers of the existing fields are unaffected) + or `TELEGRAM_MENTION_ON_REPLY` (exact `"true"`; an explicit `False` wins over + the env var, `??` semantics). `is_bot_mentioned` checks it before the + empty-text guard: a known bot id, a replied-to message from the bot, a + replied-to `message_id` different from `message_thread_id` (a forum topic's + implicit reply to its creation message), and a sender that is not the bot. + The sender is read through the `from_user` / `from` dual key. The lazy + polling identity retry from the same commit landed in #227. +- **Native replies (`d5ebec12`).** `reply(thread_id, message_id, message)` + calls `post_message(..., reply_to_message_id=message_id)` (keyword-only). + `_build_reply_parameters` decodes the target once, before any rendering or + attachment download, so a malformed or foreign-chat id raises + `ValidationError` with no API call. `{"message_id", "allow_sending_without_reply": True}` + rides on `sendMessage`, `sendRichMessage`, `sendDocument` and the + `send_attachment` URL/upload paths: a dict in JSON bodies (omitted when + there is no target), a JSON string in multipart bodies. A rich-endpoint + `ValidationError` mentioning `reply_parameters` falls back to a regular + send. Outbound media groups (#278) are not ported yet, so they get + `reply_parameters` when #278 lands. +- **Message-id parsing.** Bare ids must fully match `[0-9]+` and composite ids + `([^:]+):([0-9]+)` (`re.fullmatch`), the ASCII-digit, end-anchored meaning of + upstream's `/^\d+$/` and `/^([^:]+):(\d+)$/`. Python's `\d` and `$` would + also accept non-ASCII digits and a trailing newline, and `int()` alone + accepts `" 7"`, `"+7"` and `"1_0"`. This applies to `edit_message`, + `delete_message` and the reaction methods too, as upstream. +- **Portable file data (`eddcd7e4`): N/A.** Upstream stopped converting the + download to a Node `Buffer`. Python's `download_file` already returns + `bytes`; only the regression test is ported. + ### Telegram streaming and MarkdownV2 truncation (chat@4.32–4.41, #226) Ports `3bbf3ff5` (vercel/chat#822) and the Telegram half of `745fdf5a` @@ -2794,7 +2834,7 @@ stay explicit instead of being rediscovered in code review. | Teams adapter on `microsoft-teams-apps` (official MS Python SDK) | Inbound webhook + JWT auth, outbound send/edit/delete/typing, and native DM streaming all delegate to the official `microsoft-teams-apps` SDK `App`; Graph reads stay hand-rolled over `aiohttp` | `@microsoft/teams.apps` owns the wire format, throttling, and activity routing | **Delivered in 0.4.30** (issue #93, PRs 1–4). The migration shipped as four PRs: inbound + auth (#143), outbound (#144), native streaming via the SDK `IStreamer` (#145), and this release cut. The 3.12 floor bump (#111) — the migration's prerequisite — landed in 0.4.29. The residual adapter-level divergences (we keep the SDK as auth + transport but route the authenticated activity ourselves; close the streamer in our own `finally` because our bridge owns dispatch) are documented in the Teams divergence rows above. Graph stays hand-rolled (no `msgraph-sdk` / `[graph]` extra). | | Telegram `get_user().is_bot` | Always `False` (matches upstream — `getChat` does not expose `is_bot`) | Always `false` (same caveat documented in upstream code comment) | The Telegram Bot API's `getChat` endpoint does not surface the `is_bot` field that's available on the `User` object inside incoming `Message` updates. Callers needing bot detection must use `message.author.is_bot` from webhooks instead of `chat.get_user(...).is_bot`. | | Telegram webhook verification + `update_id` dedupe (4.41 wave, #224) | **Parity** with upstream `c4a359e7` (vercel/chat#858, chat@4.39), `1d2b78d9` (#799, chat@4.38) and the bot-identity scope helper from `7a1150ce` (#813): webhook mode requires `secret_token` unless `allow_unverified_webhooks` / `TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=true`; each integer `update_id` is claimed as `telegram:webhook-update:{sha256(bot_user_id)}:{update_id}` (24h) before dispatch; state/identity failure → 503. Python-surface adaptations only: the `ValidationError` text names options in snake_case (`secret_token`, `allow_unverified_webhooks=True`); the shared `getMe` is an `asyncio.Task` awaited through `asyncio.shield` so a cancelled webhook does not cancel it for other waiters; `update_id` claiming mirrors `Number.isInteger`: an integral JSON float (`7.0`, `7e0` — floats under `json.loads`, the number 7 in JS) is normalised to `int` and shares the `...:7` key, while `bool` (an `int` subclass) and fractional floats are not claimed. **Deliberate Python divergence:** a non-`bool` `allow_unverified_webhooks` (e.g. the string `"false"`) raises `ValidationError` instead of being coerced truthy — upstream's TS `boolean` type rules this out at compile time, Python has no such guard and `bool("false")` would silently fail open. `secret_token` now resolves with `??` semantics (`is not None`), replacing the earlier `or` fallback, so an explicit `""` no longer silently picks up the env secret. **Deliberate Python divergence (fixes an upstream bug):** the `getMe` username is cached and reapplied on a repeat `initialize()` — upstream's `ensureBotIdentity` returns early once `webhookScope` is set, so after `initialize` re-applies `chat.getUserName()` the Telegram username is never restored and `@real_bot` / `/cmd@real_bot` stop routing after a shutdown + re-initialize. `allow_unverified_webhooks` is the *last* `TelegramAdapterConfig` field (not alphabetical) so positional callers are not shifted. | Same | Not ported here: the Vercel Connect async `bot_token` resolver (#189) and the polling checkpoint keyed on the same scope (#227, now ported: see the Telegram polling section above). Cross-instance dedupe on Postgres relies on #240, which makes `set_if_not_exists` reclaim expired rows. | -| Telegram inbound parsing, allowlist and typing (4.41 wave, #225) | **Parity** with upstream `4ee187ac` (#612), `2531a422` (#621, Telegram half), `0701679e` (#706, regex cache only), `54eea715` (#742), `53bf73db` (#752), `a0ba9868` (#835), `a18e7922` (#836) and the Telegram half of `b6fa24c6` (#865). Python-surface adaptations only, no behavior divergence beyond the mention-regex fold and the `allowed_user_ids` type check noted here: location coordinates and dice values go through `_js_number_str`, which renders a JSON number as JS `String()` does (`51.0` -> `"51"`, `1e-05` -> `"0.00001"`, `1.5e-07` -> `"1.5e-7"`), since `json.loads` keeps floats that JS prints differently; invoice amounts use `f"{amount / 10**e:.{e}f}"` for `toFixed(e)`; the mention pattern is `@{re.escape(name)}(?![A-Za-z0-9_-])` with `re.IGNORECASE`, because JS `\w` without the `u` flag is ASCII-only while Python's is Unicode (JS `/i` still folds non-ASCII letters, so `re.ASCII` is not used: `@ботик` matches `@БОТИК` on both sides; one residual difference is that Python's Unicode folding also equates the Kelvin sign with `k` and long s `ſ` with `s`, which JS's non-`u` canonicalization never maps onto ASCII, so `@` + U+212A + `bot` mentions a `kbot` in Python only); allowlist ids are stringified with the same `_js_number_str` (`456.0` -> `"456"`); a non-list `allowed_user_ids` (e.g. a bare `"123,456"` string, which Python would iterate per character, where upstream's `.map` throws) raises `ValidationError`; and the acting-user chain (`callback_query.from` -> `message_reaction.user` -> first non-`None` of `message`/`edited_message`/`channel_post`/`edited_channel_post` `.from`) uses `is not None` at every step; the typing action runs as an `asyncio` task created before `Chat.process_message`/`process_slash_command` schedules the handler task (JS fires the request synchronously; in Python both are tasks and run FIFO, so typing still goes first), is passed to `wait_until` when given and otherwise held in `_typing_tasks` so it is not garbage-collected (`disconnect()` cancels and awaits any still pending before closing the aiohttp session, so none reopens it); the download uses the shared aiohttp session with `ClientTimeout(total=30)` (covers the body read) and a running byte count over `response.content.iter_chunked`, raising `NetworkError`. It deliberately does **not** use `chat_sdk.shared.download` (#204): its HTTPS/public-address checks would break self-hosted Bot API servers, which upstream also exempts, and `read_attachment_body` decodes `Content-Encoding` itself while the shared aiohttp session already auto-decompresses. | Same | The media-group (album) path, its typing and the `pollingGroup` allowlist check landed in #227 (see the Telegram polling section above). Not ported here: `reply_to_message` parsing and `mentionOnReply` (#228); `business_message.from` in the allowlist chain (#189). | +| Telegram inbound parsing, allowlist and typing (4.41 wave, #225) | **Parity** with upstream `4ee187ac` (#612), `2531a422` (#621, Telegram half), `0701679e` (#706, regex cache only), `54eea715` (#742), `53bf73db` (#752), `a0ba9868` (#835), `a18e7922` (#836) and the Telegram half of `b6fa24c6` (#865). Python-surface adaptations only, no behavior divergence beyond the mention-regex fold and the `allowed_user_ids` type check noted here: location coordinates and dice values go through `_js_number_str`, which renders a JSON number as JS `String()` does (`51.0` -> `"51"`, `1e-05` -> `"0.00001"`, `1.5e-07` -> `"1.5e-7"`), since `json.loads` keeps floats that JS prints differently; invoice amounts use `f"{amount / 10**e:.{e}f}"` for `toFixed(e)`; the mention pattern is `@{re.escape(name)}(?![A-Za-z0-9_-])` with `re.IGNORECASE`, because JS `\w` without the `u` flag is ASCII-only while Python's is Unicode (JS `/i` still folds non-ASCII letters, so `re.ASCII` is not used: `@ботик` matches `@БОТИК` on both sides; one residual difference is that Python's Unicode folding also equates the Kelvin sign with `k` and long s `ſ` with `s`, which JS's non-`u` canonicalization never maps onto ASCII, so `@` + U+212A + `bot` mentions a `kbot` in Python only); allowlist ids are stringified with the same `_js_number_str` (`456.0` -> `"456"`); a non-list `allowed_user_ids` (e.g. a bare `"123,456"` string, which Python would iterate per character, where upstream's `.map` throws) raises `ValidationError`; and the acting-user chain (`callback_query.from` -> `message_reaction.user` -> first non-`None` of `message`/`edited_message`/`channel_post`/`edited_channel_post` `.from`) uses `is not None` at every step; the typing action runs as an `asyncio` task created before `Chat.process_message`/`process_slash_command` schedules the handler task (JS fires the request synchronously; in Python both are tasks and run FIFO, so typing still goes first), is passed to `wait_until` when given and otherwise held in `_typing_tasks` so it is not garbage-collected (`disconnect()` cancels and awaits any still pending before closing the aiohttp session, so none reopens it); the download uses the shared aiohttp session with `ClientTimeout(total=30)` (covers the body read) and a running byte count over `response.content.iter_chunked`, raising `NetworkError`. It deliberately does **not** use `chat_sdk.shared.download` (#204): its HTTPS/public-address checks would break self-hosted Bot API servers, which upstream also exempts, and `read_attachment_body` decodes `Content-Encoding` itself while the shared aiohttp session already auto-decompresses. | Same | The media-group (album) path, its typing and the `pollingGroup` allowlist check landed in #227 (see the Telegram polling section above). `reply_to_message` parsing and `mentionOnReply` landed in #228 (see the Telegram replies section). Not ported here: `business_message.from` in the allowlist chain (#189). | | WhatsApp `get_user` | Raises `ChatNotImplementedError` (`Chat.get_user` translates to "does not support get_user") | Not implemented upstream either (no `getUser` on the WhatsApp adapter) | WhatsApp Cloud API has no user lookup endpoint — phone numbers are the only stable identifier and there's no equivalent of `users.info` exposed to business apps. Documented explicitly so callers don't expect parity with Slack/Teams/Discord. | | Messenger `get_user` | Raising stub (`ChatNotImplementedError`); a Graph-API-backed impl is tracked as issue #132 | No `getUser` method on the Messenger adapter | **Parity — upstream Messenger has no user-lookup method**; the Python raising stub matches. (Meta's Graph API *could* back a real implementation, unlike WhatsApp — hence #132 stays open as an enhancement.) | | Linear agent sessions | **Complete** (5-PR wave, **#151** — Wave D done). All five landed on `main`: L1 agent-session types (`LinearAgentSessionThreadId`, `LinearAgentSessionCommentRawMessage`, `mode`/`kind`), L2 the `:s:{session}` thread-id encode/decode, L3 the webhook PARSE + routing (`_parse_message_from_agent_session_event`, `_handle_agent_session_event`), L4 the agent-activity EMIT path (`post_message`/`start_typing`/`stream` session branches as raw GraphQL — see the "Linear agent-activity emit" divergence row above), and **L5 (this change)**: the agent-session FETCH path (`fetch_messages` → `_fetch_agent_session_messages`, the `edit_message`/`delete_message` append-only guards, and `fetch_thread` `agentSessionId` metadata as raw GraphQL — see the "Linear agent-session fetch" divergence row above). | Full agent-sessions support (`adapter-linear` 4.27.0, `bc94f0a`): parses agent-session webhook events into messages, emits agent activity, fetches the session thread, and routes the agent-session thread id | Largest single gap from the 0.4.30 audit; pre-existing (present since 0.4.29). Closed across the 4.31 wave — tracked in **#151**. | diff --git a/src/chat_sdk/adapters/telegram/adapter.py b/src/chat_sdk/adapters/telegram/adapter.py index 1125021d..8a76e5e6 100644 --- a/src/chat_sdk/adapters/telegram/adapter.py +++ b/src/chat_sdk/adapters/telegram/adapter.py @@ -50,6 +50,7 @@ TelegramMessageReactionUpdated, TelegramRawMessage, TelegramReactionType, + TelegramReplyParameters, TelegramStickerFile, TelegramThreadId, TelegramUpdate, @@ -123,7 +124,12 @@ # Claimed ``update_id`` keys live for 24h — Telegram stops redelivering a # failed webhook update well within that window (vercel/chat#799). TELEGRAM_WEBHOOK_UPDATE_TTL_MS = 24 * 60 * 60 * 1000 -MESSAGE_ID_PATTERN = re.compile(r"^([^:]+):(\d+)$") +# Used with ``fullmatch``: upstream's ``/^([^:]+):(\d+)$/`` and +# ``/^\d+$/`` are ASCII-digit, end-anchored patterns, whereas Python's +# ``\d`` matches any Unicode digit and ``$`` matches before a trailing +# newline. +MESSAGE_ID_PATTERN = re.compile(r"([^:]+):([0-9]+)") +BARE_MESSAGE_ID_PATTERN = re.compile(r"[0-9]+") TELEGRAM_MARKDOWN_PARSE_MODE = "MarkdownV2" MESSAGE_SEQUENCE_PATTERN = re.compile(r":(\d+)$") # Map a normalized Attachment.type to the Telegram Bot API media method and @@ -785,6 +791,13 @@ def __init__(self, config: TelegramAdapterConfig | None = None) -> None: else os.environ.get("TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS") == "true" ) self._warned_no_verification: bool = False + # ``config.mentionOnReply ?? env === "true"`` (vercel/chat#834): an + # explicit config value (including False) wins over the env var. + self._mention_on_reply: bool = ( + config.mention_on_reply + if config.mention_on_reply is not None + else os.environ.get("TELEGRAM_MENTION_ON_REPLY") == "true" + ) # User allowlist (vercel/chat#742): ``allowedUserIds ?? # TELEGRAM_ALLOWED_USER_IDS.split(",")``, each id stringified and # trimmed, empties dropped. An empty result allows everyone (None). @@ -1940,7 +1953,6 @@ async def _process_incoming_media_group( author=latest_message.author, metadata=latest_message.metadata, attachments=[attachment for message in parsed_messages for attachment in message.attachments], - # Telegram parsing populates ``reply_to`` from #228. reply_to=next((message.reply_to for message in parsed_messages if message.reply_to is not None), None), is_mention=any(message.is_mention for message in parsed_messages), links=links or None, @@ -2250,9 +2262,18 @@ async def post_message( self, thread_id: str, message: AdapterPostableMessage, + *, + reply_to_message_id: str | None = None, ) -> RawMessage: - """Post a message to a Telegram thread.""" + """Post a message to a Telegram thread. + + With *reply_to_message_id* the message is sent as a native reply to + that message (see :meth:`reply`). + """ parsed_thread = self._resolve_thread_id(thread_id) + # Resolve the reply target once so a malformed id fails before any + # rendering or attachment downloads, and every send path threads it. + reply_parameters = self._build_reply_parameters(reply_to_message_id, parsed_thread.chat_id) card = extract_card(message) reply_markup = card_to_telegram_inline_keyboard(card) if card else None @@ -2303,7 +2324,9 @@ async def post_message( file = files[0] if not file: raise ValidationError("telegram", "File upload payload is empty") - raw_message = await self.send_document(parsed_thread, file, text, plain_text, reply_markup, parse_mode) + raw_message = await self.send_document( + parsed_thread, file, text, plain_text, reply_markup, parse_mode, reply_parameters + ) elif len(attachments) == 1: attachment = attachments[0] if not attachment: @@ -2315,6 +2338,7 @@ async def post_message( plain_text, reply_markup, parse_mode, + reply_parameters, ) else: if not text.strip(): @@ -2328,21 +2352,22 @@ async def _send_regular() -> TelegramMessage: parse_mode, reply_markup, thread_id, + reply_parameters, ) if rich is not None: rich_payload = rich async def _send_rich() -> TelegramMessage: - return await self.telegram_fetch( - "sendRichMessage", - { - "chat_id": parsed_thread.chat_id, - "message_thread_id": parsed_thread.message_thread_id, - "rich_message": {"markdown": rich_payload.markdown}, - "reply_markup": reply_markup, - }, - ) + rich_body: dict[str, Any] = { + "chat_id": parsed_thread.chat_id, + "message_thread_id": parsed_thread.message_thread_id, + "rich_message": {"markdown": rich_payload.markdown}, + "reply_markup": reply_markup, + } + if reply_parameters is not None: + rich_body["reply_parameters"] = reply_parameters + return await self.telegram_fetch("sendRichMessage", rich_body) raw_message = await self.with_telegram_rich_fallback( _send_rich, @@ -2385,6 +2410,21 @@ async def post_channel_message( """Post a message to a Telegram channel.""" return await self.post_message(channel_id, message) + async def reply( + self, + thread_id: str, + message_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Post *message* as a native Telegram reply to *message_id*. + + Port of upstream ``reply`` (vercel/chat#833), the adapter hook behind + ``Thread.reply()``. Every Telegram send is a single API call (text is + truncated, not split), so ``reply_parameters`` always rides on that + one call. + """ + return await self.post_message(thread_id, message, reply_to_message_id=message_id) + async def edit_message( self, thread_id: str, @@ -3278,10 +3318,7 @@ def parse_telegram_message( text = content.text if content is not None and content.text else apply_telegram_entities(plain_text, entities) # Determine author -- Telegram uses 'from' key which is a reserved word - from_user = cast( - "TelegramUser | None", - raw.get("from_user") or raw.get("from"), # type: ignore[call-overload] - ) + from_user = self._message_sender(raw) sender_chat = raw.get("sender_chat") if from_user: @@ -3299,6 +3336,10 @@ def parse_telegram_message( ) edit_date = raw.get("edit_date") + # ``raw.reply_to_message ? parse(...) : undefined`` (vercel/chat#802). + # The Bot API never nests a further reply inside it, so this recursion + # is one level deep. + reply_to_message = raw.get("reply_to_message") # `content?.formatted ?? this.formatConverter.toAst(richMarkdown || text)`: # `??` (nullish) for the supplied AST, then `richMarkdown || text` is a @@ -3321,6 +3362,9 @@ def parse_telegram_message( edited_at=(datetime.fromtimestamp(edit_date, tz=timezone.utc) if edit_date is not None else None), ), attachments=self.extract_attachments(raw), + reply_to=( + self.parse_telegram_message(reply_to_message, thread_id) if reply_to_message is not None else None + ), is_mention=self.is_bot_mentioned(raw, plain_text), ) @@ -3607,6 +3651,7 @@ async def send_document( plain_text: str, reply_markup: TelegramInlineKeyboardMarkup | None = None, parse_mode: str | None = None, + reply_parameters: TelegramReplyParameters | None = None, ) -> TelegramMessage: """Send a document (file upload) to Telegram.""" data = getattr(file, "data", b"") @@ -3627,6 +3672,7 @@ async def _send(resolved_parse_mode: str | None, resolved_text: str) -> Telegram resolved_text, reply_markup, resolved_parse_mode, + reply_parameters, ), ) @@ -3647,12 +3693,14 @@ def _create_telegram_document_form_data( text: str, reply_markup: TelegramInlineKeyboardMarkup | None = None, parse_mode: str | None = None, + reply_parameters: TelegramReplyParameters | None = None, ) -> Any: """Build the multipart form body for a ``sendDocument`` call.""" import aiohttp form_data = aiohttp.FormData() form_data.add_field("chat_id", thread.chat_id) + self._append_reply_parameters(form_data, reply_parameters) if isinstance(thread.message_thread_id, int): form_data.add_field("message_thread_id", str(thread.message_thread_id)) @@ -3684,6 +3732,7 @@ async def send_attachment( plain_text: str, reply_markup: TelegramInlineKeyboardMarkup | None = None, parse_mode: str | None = None, + reply_parameters: TelegramReplyParameters | None = None, ) -> TelegramMessage: """Send a typed attachment using the Telegram media method for its type. @@ -3721,6 +3770,8 @@ async def _send(resolved_parse_mode: str | None, resolved_text: str) -> Telegram "chat_id": thread.chat_id, upload["field"]: attachment.url, } + if reply_parameters is not None: + payload["reply_parameters"] = reply_parameters if isinstance(thread.message_thread_id, int): payload["message_thread_id"] = thread.message_thread_id @@ -3768,6 +3819,7 @@ async def _send(resolved_parse_mode: str | None, resolved_text: str) -> Telegram if reply_markup: form_data.add_field("reply_markup", json.dumps(reply_markup)) + self._append_reply_parameters(form_data, reply_parameters) return await self.telegram_fetch(upload["method"], form_data) @@ -3883,6 +3935,33 @@ def encode_message_id(self, chat_id: str, message_id: int) -> str: """Encode a chat ID and message ID into a composite string.""" return f"{chat_id}:{message_id}" + def _build_reply_parameters( + self, + reply_to_message_id: str | None, + expected_chat_id: str, + ) -> TelegramReplyParameters | None: + """Build Bot API ``reply_parameters`` for an optional reply target. + + Port of upstream ``buildReplyParameters`` (vercel/chat#833). + ``allow_sending_without_reply`` keeps delivery working when the target + was deleted in the meantime: the message arrives unthreaded instead of + the send failing. The same applies to a message id that never existed + in the chat or lives in another forum topic -- only the chat half of a + composite target is validated here. + """ + # Truthy check, as upstream's ``if (!replyToMessageId)``: an empty id + # sends unthreaded. + if not reply_to_message_id: + return None + decoded = self.decode_composite_message_id(reply_to_message_id, expected_chat_id) + return {"message_id": decoded["message_id"], "allow_sending_without_reply": True} + + @staticmethod + def _append_reply_parameters(form_data: Any, reply_parameters: TelegramReplyParameters | None) -> None: + """Add ``reply_parameters`` to a multipart body as a JSON string.""" + if reply_parameters is not None: + form_data.add_field("reply_parameters", json.dumps(reply_parameters)) + def decode_composite_message_id( self, message_id: str, @@ -3892,7 +3971,7 @@ def decode_composite_message_id( Returns a dict with ``chat_id``, ``message_id`` (int), and ``composite_id``. """ - composite_match = MESSAGE_ID_PATTERN.match(message_id) + composite_match = MESSAGE_ID_PATTERN.fullmatch(message_id) if composite_match: chat_id = composite_match.group(1) @@ -3917,19 +3996,13 @@ def decode_composite_message_id( f"Telegram message ID must be in : format, got: {message_id}", ) - try: - parsed_message_id = int(message_id) - except (ValueError, TypeError) as exc: - raise ValidationError( - "telegram", - f"Invalid Telegram message ID: {message_id}", - ) from exc - - if not math.isfinite(parsed_message_id): + # ``int()`` alone would also accept " 7", "+7" and "1_0". + if not BARE_MESSAGE_ID_PATTERN.fullmatch(message_id): raise ValidationError( "telegram", f"Invalid Telegram message ID: {message_id}", ) + parsed_message_id = int(message_id) return { "chat_id": expected_chat_id, @@ -3976,8 +4049,38 @@ def chat_display_name(self, chat: TelegramChat) -> str | None: # -- Mention detection --------------------------------------------------- + @staticmethod + def _message_sender(message: TelegramMessage) -> TelegramUser | None: + """The message's ``from`` user (``from_user`` in the TypedDict).""" + return cast( + "TelegramUser | None", + message.get("from_user") or message.get("from"), # type: ignore[call-overload] + ) + def is_bot_mentioned(self, message: TelegramMessage, text: str) -> bool: """Check if the bot is mentioned in a message.""" + # Replying to one of the bot's own messages addresses it as directly + # as an @mention does (vercel/chat#834). Opt-in, and checked before + # the empty-text guard so a reply carrying only a photo or a document + # still counts. Two payloads look like a reply to the bot but are not: + # - in forum topics every message replies implicitly to the + # topic-creation service message (its message_id equals + # message_thread_id), authored by the bot when the bot created it; + # - the Bot API echoes the bot's own outbound replies back in send + # responses, and the bot replying to itself is not a user + # addressing it. + replied_to = message.get("reply_to_message") + if self._mention_on_reply and self._bot_user_id and replied_to is not None: + replied_to_sender = self._message_sender(replied_to) + sender = self._message_sender(message) + if ( + replied_to_sender + and str(replied_to_sender.get("id", "")) == self._bot_user_id + and replied_to.get("message_id") != message.get("message_thread_id") + and not (sender and str(sender.get("id", "")) == self._bot_user_id) + ): + return True + if not text: return False @@ -4207,6 +4310,7 @@ async def send_regular_message( parse_mode: str | None, reply_markup: TelegramInlineKeyboardMarkup | None, thread_id: str, + reply_parameters: TelegramReplyParameters | None = None, ) -> TelegramMessage: """Send a non-rich message via ``sendMessage`` with markdown fallback. @@ -4217,16 +4321,16 @@ async def send_regular_message( """ async def _send_message(resolved_parse_mode: str | None, resolved_text: str) -> TelegramMessage: - return await self.telegram_fetch( - "sendMessage", - { - "chat_id": thread.chat_id, - "message_thread_id": thread.message_thread_id, - "text": resolved_text, - "reply_markup": reply_markup, - "parse_mode": resolved_parse_mode, - }, - ) + body: dict[str, Any] = { + "chat_id": thread.chat_id, + "message_thread_id": thread.message_thread_id, + "text": resolved_text, + "reply_markup": reply_markup, + "parse_mode": resolved_parse_mode, + } + if reply_parameters is not None: + body["reply_parameters"] = reply_parameters + return await self.telegram_fetch("sendMessage", body) return await self.with_telegram_markdown_fallback( parse_mode, @@ -4247,7 +4351,8 @@ def can_fallback_from_rich_message(self, error: object, method: str) -> bool: * a ``ResourceNotFoundError`` raised by a ``sendRichMessage*`` call (the 404 ``method not found``); OR * a :class:`ValidationError` whose message contains ``can't parse``, - or ``method`` + ``not found``, or ``rich message`` + ``unsupported``. + or ``method`` + ``not found``, or ``rich message`` + ``unsupported``, + or ``reply_parameters`` (a gateway that rejects reply targets). Distinct from :meth:`remember_rich_message_failure`, which decides the *narrower* set that additionally latches rich off permanently. A @@ -4256,10 +4361,17 @@ def can_fallback_from_rich_message(self, error: object, method: str) -> bool: message = str(error).lower() if isinstance(error, ValidationError) else "" missing_method = "method" in message and "not found" in message unsupported_rich = "rich message" in message and "unsupported" in message + # A gateway that predates reply support may reject the extra + # ``reply_parameters`` field; degrade to a regular send, which threads + # it (vercel/chat#833). + rejected_reply_parameters = "reply_parameters" in message return bool( (method.startswith("sendRichMessage") and isinstance(error, ResourceNotFoundError)) - or (isinstance(error, ValidationError) and ("can't parse" in message or missing_method or unsupported_rich)) + or ( + isinstance(error, ValidationError) + and ("can't parse" in message or missing_method or unsupported_rich or rejected_reply_parameters) + ) ) def remember_rich_message_failure(self, error: object, method: str) -> None: diff --git a/src/chat_sdk/adapters/telegram/types.py b/src/chat_sdk/adapters/telegram/types.py index a6b4f45d..c5c454ba 100644 --- a/src/chat_sdk/adapters/telegram/types.py +++ b/src/chat_sdk/adapters/telegram/types.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Literal, TypeAlias, TypedDict from chat_sdk.logger import Logger @@ -113,6 +113,18 @@ class TelegramAdapterConfig: non-numeric or non-finite values are ignored (vercel/chat#822). """ + # Keyword-only so adding it does not shift existing positional callers. + mention_on_reply: bool | None = field(default=None, kw_only=True) + """Treat a reply to one of the bot's own messages as a mention. + + Telegram users continue a conversation by replying rather than repeating + the handle, so a bot that only reacts to ``@name`` looks unresponsive in a + group. Off by default: turning it on changes which messages report + ``is_mention``. Defaults to the ``TELEGRAM_MENTION_ON_REPLY`` env var, + where only the exact string ``"true"`` opts in. An explicit ``False`` here + wins over the env var. + """ + # ============================================================================= # Thread ID @@ -826,6 +838,9 @@ class TelegramMessage(TypedDict, total=False): message_thread_id: int photo: list[TelegramPhotoSize] poll: TelegramPoll + # The message this one replies to. The Bot API never nests a further + # ``reply_to_message`` inside it, so parsing it recursively is bounded. + reply_to_message: TelegramMessage rich_message: TelegramRichMessage sender_chat: TelegramChat sticker: TelegramStickerFile @@ -857,6 +872,16 @@ class TelegramInlineKeyboardMarkup(TypedDict): inline_keyboard: list[list[TelegramInlineKeyboardButton]] +class TelegramReplyParameters(TypedDict): + """Bot API ``reply_parameters`` sent with a native reply. + + See https://core.telegram.org/bots/api#replyparameters + """ + + allow_sending_without_reply: bool + message_id: int + + class TelegramCallbackQuery(TypedDict, total=False): """Telegram callback query (inline keyboard button click). diff --git a/tests/test_telegram_api.py b/tests/test_telegram_api.py index f8506d93..0ca9ffeb 100644 --- a/tests/test_telegram_api.py +++ b/tests/test_telegram_api.py @@ -11,6 +11,7 @@ from __future__ import annotations import hashlib +import json from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -32,6 +33,8 @@ AuthenticationError, ValidationError, ) +from chat_sdk.shared.mock_adapter import create_mock_state +from chat_sdk.thread import ThreadImpl, _ThreadImplConfig from chat_sdk.types import Attachment, FileUpload, PostableMarkdown, PostableRaw # ============================================================================= @@ -1734,6 +1737,14 @@ def test_chat_id_mismatch_raises(self): with pytest.raises(ValidationError, match="mismatch"): adapter.decode_composite_message_id(f"wrong:{MESSAGE_ID_INT}", CHAT_ID) + # Python-specific: ``re.$`` matches before a trailing newline and ``\d`` + # matches non-ASCII digits; upstream's JS pattern accepts neither. + @pytest.mark.parametrize("message_id", ["123:7\n", "123:7", "123:٧"]) + def test_rejects_composite_ids_upstream_would_not_match(self, message_id: str): + adapter = _make_adapter() + with pytest.raises(ValidationError, match=": format"): + adapter.decode_composite_message_id(message_id) + # ============================================================================= # Tests -- throw_telegram_api_error additional branches @@ -2185,3 +2196,246 @@ def test_username_fallback(self): def test_none_fallback(self): adapter = _make_adapter() assert adapter.chat_display_name({"id": 1, "type": "private"}) is None + + +# ============================================================================= +# Tests -- native replies (vercel/chat#833, #228) +# ============================================================================= + +REPLY_THREAD_ID = "telegram:123" +EXPECTED_REPLY_PARAMETERS = {"message_id": 7, "allow_sending_without_reply": True} + + +def _reply_adapter(*responses: Any) -> TelegramAdapter: + """``createReplyAdapter`` with ``telegram_fetch`` answering *responses* in order.""" + adapter = _make_adapter(user_name="mybot") + _init_adapter(adapter) + adapter.telegram_fetch = AsyncMock( # type: ignore[method-assign] + side_effect=list(responses) or [_make_telegram_message(chat_id="123", message_id=11)] + ) + return adapter + + +def _only_call(adapter: TelegramAdapter) -> tuple[str, Any]: + """The single Bot API call a reply made.""" + adapter.telegram_fetch.assert_awaited_once() # type: ignore[attr-defined] + method, payload = adapter.telegram_fetch.await_args.args # type: ignore[attr-defined] + return method, payload + + +class TestReply: + """Ports of ``describe("reply")``: every send path carries ``reply_parameters``.""" + + @pytest.mark.asyncio + async def test_threads_a_rich_text_message_to_its_target(self): + adapter = _reply_adapter() + + await adapter.reply(REPLY_THREAD_ID, "123:7", {"markdown": "hello"}) + + method, payload = _only_call(adapter) + assert method == "sendRichMessage" + assert payload["reply_parameters"] == EXPECTED_REPLY_PARAMETERS + + @pytest.mark.asyncio + async def test_threads_a_plain_string_message_through_send_message(self): + adapter = _reply_adapter() + + await adapter.reply(REPLY_THREAD_ID, "123:7", "hello") + + method, payload = _only_call(adapter) + assert method == "sendMessage" + assert payload["reply_parameters"] == EXPECTED_REPLY_PARAMETERS + + @pytest.mark.asyncio + async def test_threads_a_document_upload_to_its_target(self): + adapter = _reply_adapter() + + await adapter.reply( + REPLY_THREAD_ID, + "123:7", + PostableRaw(raw="", files=[FileUpload(data=b"doc", filename="doc.txt")]), + ) + + method, form_data = _only_call(adapter) + assert method == "sendDocument" + assert json.loads(_form_fields(form_data)["reply_parameters"]) == EXPECTED_REPLY_PARAMETERS + + @pytest.mark.asyncio + async def test_threads_a_url_attachment_without_an_inline_keyboard(self): + adapter = _reply_adapter() + + await adapter.reply( + REPLY_THREAD_ID, + "123:7", + PostableMarkdown( + markdown="picture", + attachments=[ + Attachment( + type="image", + mime_type="image/png", + name="pic.png", + url="https://cdn.example.com/pic.png", + ) + ], + ), + ) + + method, payload = _only_call(adapter) + assert method == "sendPhoto" + assert payload["reply_parameters"] == EXPECTED_REPLY_PARAMETERS + assert "reply_markup" not in payload + + @pytest.mark.asyncio + async def test_threads_a_buffer_attachment_to_its_target(self): + adapter = _reply_adapter() + + await adapter.reply( + REPLY_THREAD_ID, + "123:7", + PostableMarkdown( + markdown="picture", + attachments=[Attachment(type="image", data=b"payload", mime_type="image/png", name="pic.png")], + ), + ) + + method, form_data = _only_call(adapter) + assert method == "sendPhoto" + assert json.loads(_form_fields(form_data)["reply_parameters"]) == EXPECTED_REPLY_PARAMETERS + + @pytest.mark.asyncio + async def test_falls_back_to_a_regular_send_when_the_rich_endpoint_rejects_reply_parameters(self): + # The error a 400 from the Bot API maps to. + with pytest.raises(ValidationError) as rejected: + _make_adapter().throw_telegram_api_error( + "sendRichMessage", + 400, + {"ok": False, "error_code": 400, "description": "Bad Request: unknown field reply_parameters"}, + ) + adapter = _reply_adapter(rejected.value, _make_telegram_message(chat_id="123", message_id=11)) + + await adapter.reply(REPLY_THREAD_ID, "123:7", {"markdown": "hello"}) + + calls = adapter.telegram_fetch.await_args_list # type: ignore[attr-defined] + assert [call.args[0] for call in calls] == ["sendRichMessage", "sendMessage"] + assert calls[1].args[1]["reply_parameters"] == EXPECTED_REPLY_PARAMETERS + + @pytest.mark.asyncio + async def test_leaves_a_plain_post_message_unthreaded(self): + adapter = _reply_adapter() + + await adapter.post_message(REPLY_THREAD_ID, {"markdown": "hello"}) + + _method, payload = _only_call(adapter) + assert "reply_parameters" not in payload + + # Python-specific: upstream's ``JSON.stringify`` drops ``undefined``; here the + # ``is not None`` guards are what keep ``reply_parameters`` off upload paths. + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("message", "expected_method"), + [ + pytest.param( + PostableMarkdown( + markdown="picture", + attachments=[ + Attachment( + type="image", + mime_type="image/png", + name="pic.png", + url="https://cdn.example.com/pic.png", + ) + ], + ), + "sendPhoto", + id="url-attachment", + ), + pytest.param( + PostableMarkdown( + markdown="picture", + attachments=[Attachment(type="image", data=b"payload", mime_type="image/png", name="pic.png")], + ), + "sendPhoto", + id="bytes-attachment", + ), + pytest.param( + PostableRaw(raw="", files=[FileUpload(data=b"doc", filename="doc.txt")]), + "sendDocument", + id="file-upload", + ), + ], + ) + async def test_leaves_plain_post_message_uploads_unthreaded(self, message: Any, expected_method: str): + adapter = _reply_adapter() + + await adapter.post_message(REPLY_THREAD_ID, message) + + method, body = _only_call(adapter) + assert method == expected_method + fields = body if isinstance(body, dict) else _form_fields(body) + assert "reply_parameters" not in fields + # Guard against a vacuous pass: the body really is the upload request. + assert fields["chat_id"] in ("123", 123) + + @pytest.mark.asyncio + async def test_refuses_a_target_that_belongs_to_another_chat(self): + adapter = _reply_adapter() + # Python-specific: the target is checked before any attachment download. + fetch_data = AsyncMock(return_value=b"payload") + + with pytest.raises(ValidationError, match="chat mismatch"): + await adapter.reply( + REPLY_THREAD_ID, + "999:7", + PostableMarkdown(markdown="hello", attachments=[Attachment(type="image", fetch_data=fetch_data)]), + ) + + adapter.telegram_fetch.assert_not_awaited() # type: ignore[attr-defined] + fetch_data.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("target", ["7abc", "7.9", " 7"]) + async def test_rejects_the_malformed_bare_message_id(self, target: str): + adapter = _reply_adapter() + + with pytest.raises(ValidationError, match="Invalid Telegram message ID"): + await adapter.reply(REPLY_THREAD_ID, target, {"markdown": "hello"}) + + adapter.telegram_fetch.assert_not_awaited() # type: ignore[attr-defined] + + # Python-specific: ``int()`` accepts these, upstream's ``/^\d+$/`` does not. + @pytest.mark.asyncio + @pytest.mark.parametrize("target", ["+7", "1_0", "7", "7\n", "123:7\n"]) + async def test_rejects_ids_that_python_int_would_accept(self, target: str): + adapter = _reply_adapter() + + with pytest.raises(ValidationError, match="Invalid Telegram message ID"): + await adapter.reply(REPLY_THREAD_ID, target, {"markdown": "hello"}) + + adapter.telegram_fetch.assert_not_awaited() # type: ignore[attr-defined] + + # Python-specific: the ``Thread.reply`` -> adapter hook path end to end. + @pytest.mark.asyncio + async def test_thread_reply_sends_one_threaded_call_and_post_stays_unthreaded(self): + adapter = _reply_adapter( + _make_telegram_message(chat_id="123", message_id=11), + _make_telegram_message(chat_id="123", message_id=12), + ) + thread = ThreadImpl( + _ThreadImplConfig( + id=REPLY_THREAD_ID, + adapter=adapter, # type: ignore[arg-type] + state_adapter=create_mock_state(), + channel_id="telegram:123", + ) + ) + target = adapter.parse_message(_make_telegram_message(chat_id="123", message_id=7, text="question")) + + sent = await thread.reply(target, "answer") + await thread.post("not a reply") + + calls = adapter.telegram_fetch.await_args_list # type: ignore[attr-defined] + assert [call.args[0] for call in calls] == ["sendMessage", "sendMessage"] + assert calls[0].args[1]["reply_parameters"] == EXPECTED_REPLY_PARAMETERS + assert "reply_parameters" not in calls[1].args[1] + assert sent.id == "123:11" + assert sent.reply_to is target diff --git a/tests/test_telegram_webhook.py b/tests/test_telegram_webhook.py index 46c353d7..23edd9ed 100644 --- a/tests/test_telegram_webhook.py +++ b/tests/test_telegram_webhook.py @@ -14,6 +14,7 @@ import json import os from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -252,6 +253,18 @@ def test_explicit_empty_secret_does_not_fall_back_to_env_secret(self, monkeypatc with pytest.raises(ValidationError, match="secret_token is required in webhook mode"): TelegramAdapter(TelegramAdapterConfig(bot_token="token", mode="webhook", secret_token="")) + # -- Python-specific: dataclass positional back-compat (#228) ----------- + + def test_mention_on_reply_does_not_shift_existing_positional_fields(self): + # ``mention_on_reply`` is keyword-only, so a caller passing every + # pre-existing field positionally keeps its streaming settings. + config = TelegramAdapterConfig(None, "token", None, None, None, None, None, None, None, True, 0) + assert config.native_streaming is True + assert config.streaming_edit_interval_ms == 0 + assert config.mention_on_reply is None + with pytest.raises(TypeError): + TelegramAdapterConfig(None, "token", None, None, None, None, None, None, None, True, 0, True) # type: ignore[misc] + # --------------------------------------------------------------------------- # Thread ID encode / decode @@ -3203,7 +3216,27 @@ async def test_combines_an_album_spanning_a_full_polling_response(self): @pytest.mark.asyncio async def test_recovers_bot_identity_while_polling_after_a_failed_startup_get_me(self): - fixture = _PollingFixture([_polled_message(1)]) + # A group reply to the bot is a mention only once the identity is + # known (``mention_on_reply``, #228), so the failing ``on_mention`` + # handler below runs only if polling recovered it. + group = {"id": -100123, "type": "supergroup", "title": "General"} + fixture = _PollingFixture( + [ + _polled_message( + 1, + chat_id=group["id"], + chat=group, + text="and the second one?", + reply_to_message={ + "message_id": 5, + "date": 1, + "chat": group, + "from": {"id": 999, "is_bot": True, "first_name": "Bot", "username": "mybot"}, + }, + ) + ] + ) + fixture.adapter._mention_on_reply = True api = fixture.api get_me_calls = 0 @@ -3216,11 +3249,15 @@ async def flaky_get_me(method: str, payload: Any = None, **kwargs: Any) -> Any: return await api(method, payload, **kwargs) fixture.adapter.telegram_fetch = flaky_get_me # type: ignore[method-assign] - fixture.chat.on_mention(AsyncMock(side_effect=RuntimeError("Reply failed"))) + handler = AsyncMock(side_effect=RuntimeError("Reply failed")) + fixture.chat.on_mention(handler) try: await fixture.start() await _wait_until(lambda: len(fixture.api.polls) >= 2) assert get_me_calls == 2 + assert fixture.adapter.bot_user_id == "999" + handler.assert_awaited_once() + assert handler.await_args.args[1].is_mention is True # The failure is saved under the recovered bot's scope. saved = await fixture.checkpoint() assert [entry["update"]["update_id"] for entry in saved["pending"]] == [1] @@ -3383,3 +3420,198 @@ async def handler(*_args: Any) -> None: async def _is_none(value: Any) -> bool: return (await value) is None + + +# --------------------------------------------------------------------------- +# 4.41 replies (#228): replied-to context, reply-to-bot as mention, portable +# file data +# --------------------------------------------------------------------------- + + +class TestTelegramReplyContext: + """Port of the Telegram half of vercel/chat#802.""" + + def test_parses_replied_to_message_context(self): + adapter = _make_adapter(mode="webhook", allow_unverified_webhooks=True, user_name="mybot") + parsed = adapter.parse_message( + _sample_message( + message_id=12, + text="reply", + reply_to_message=_sample_message( + message_id=11, + text="original", + date=1735689500, + **{"from": {"id": 789, "is_bot": False, "first_name": "Original", "username": "original"}}, + ), + ) + ) + + assert isinstance(parsed.reply_to, Message) + assert parsed.reply_to.id == "123:11" + assert parsed.reply_to.thread_id == "telegram:123" + assert parsed.reply_to.text == "original" + assert parsed.reply_to.author.user_name == "original" + assert parsed.reply_to.metadata.date_sent == datetime.fromtimestamp(1735689500, tz=timezone.utc) + + +_REPLY_BOT_USER_ID = 8981792219 +_REPLY_BOT_USER = {"id": _REPLY_BOT_USER_ID, "is_bot": True, "first_name": "Bot", "username": "mybot"} +_GROUP_CHAT = {"id": -100123, "type": "supergroup", "title": "General"} + + +async def _deliver_reply( + *, + mention_on_reply: bool | None = None, + reply_from_bot: bool, + from_bot: bool = False, + message_thread_id: int | None = None, + reply_to_message_id: int = 5, + drop_text: bool = False, + **message_overrides: Any, +) -> Message: + """``deliverReply`` from the upstream suite: one group reply via the webhook.""" + chat = _mock_chat(create_mock_state()) + adapter = _dedupe_adapter( + get_me=AsyncMock(return_value=dict(_REPLY_BOT_USER)), + mention_on_reply=mention_on_reply, + ) + await adapter.initialize(chat) + + replied_to_sender = ( + dict(_REPLY_BOT_USER) + if reply_from_bot + else {"id": 777, "is_bot": False, "first_name": "Someone", "username": "someone"} + ) + message = _sample_message( + chat=dict(_GROUP_CHAT), + text="and the second one?", + reply_to_message=_sample_message( + message_id=reply_to_message_id, + chat=dict(_GROUP_CHAT), + **{"from": replied_to_sender}, + ), + ) + message.update(message_overrides) + if drop_text: + del message["text"] + if message_thread_id is not None: + message["message_thread_id"] = message_thread_id + if from_bot: + message["from"] = dict(_REPLY_BOT_USER) + + response = await adapter.handle_webhook(_update_request({"update_id": 1, "message": message})) + assert response["status"] == 200 + assert chat.process_message.call_count == 1 + return chat.process_message.call_args.args[2] + + +class TestTelegramMentionOnReply: + """Ports of vercel/chat#834 (``mentionOnReply``).""" + + @pytest.fixture(autouse=True) + def _isolate_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + _clear_telegram_env(monkeypatch) + + @pytest.mark.asyncio + async def test_counts_a_reply_to_the_bot_as_a_mention_when_enabled(self): + parsed = await _deliver_reply(mention_on_reply=True, reply_from_bot=True) + assert parsed.is_mention is True + + @pytest.mark.asyncio + async def test_ignores_a_reply_to_somebody_else(self): + parsed = await _deliver_reply(mention_on_reply=True, reply_from_bot=False) + assert parsed.is_mention is False + + @pytest.mark.asyncio + async def test_stays_off_by_default_so_existing_bots_keep_mention_only_behaviour(self): + parsed = await _deliver_reply(reply_from_bot=True) + assert parsed.is_mention is False + + @pytest.mark.asyncio + async def test_ignores_a_forum_topics_implicit_reply_to_the_bots_topic_creation_message(self): + # In forum topics every message replies to the topic-creation service + # message, whose message_id equals message_thread_id. + parsed = await _deliver_reply( + mention_on_reply=True, reply_from_bot=True, message_thread_id=5, reply_to_message_id=5 + ) + assert parsed.is_mention is False + + @pytest.mark.asyncio + async def test_counts_an_explicit_reply_to_the_bot_inside_a_forum_topic(self): + parsed = await _deliver_reply( + mention_on_reply=True, reply_from_bot=True, message_thread_id=5, reply_to_message_id=42 + ) + assert parsed.is_mention is True + + @pytest.mark.asyncio + async def test_does_not_flag_the_bots_own_reply_to_one_of_its_messages(self): + # The Bot API echoes outbound sends back; the echo replies to the + # bot's earlier message but is authored by the bot itself. + parsed = await _deliver_reply(mention_on_reply=True, reply_from_bot=True, from_bot=True) + assert parsed.is_mention is False + + # Python-specific: the reply check runs before the empty-text guard, so a + # reply carrying only a photo counts. + @pytest.mark.asyncio + async def test_counts_a_photo_only_reply_to_the_bot(self): + parsed = await _deliver_reply( + mention_on_reply=True, + reply_from_bot=True, + drop_text=True, + photo=[{"file_id": "p1", "file_unique_id": "u1", "width": 10, "height": 10}], + ) + assert parsed.text == "" + assert len(parsed.attachments) == 1 + assert parsed.is_mention is True + + # Python-specific: ``config ?? env === "true"`` -- an explicit False wins + # over the env var, and only the exact string "true" opts in. + @pytest.mark.parametrize( + ("config_value", "env_value", "expected"), + [ + (None, "true", True), + (None, "TRUE", False), + (None, "1", False), + (False, "true", False), + (True, None, True), + ], + ) + def test_resolves_mention_on_reply_from_config_then_env( + self, + monkeypatch: pytest.MonkeyPatch, + config_value: bool | None, + env_value: str | None, + expected: bool, + ): + if env_value is not None: + monkeypatch.setenv("TELEGRAM_MENTION_ON_REPLY", env_value) + adapter = _dedupe_adapter(mention_on_reply=config_value) + adapter._bot_user_id = str(_REPLY_BOT_USER_ID) + reply = _sample_message( + chat=dict(_GROUP_CHAT), + text="no handle here", + reply_to_message=_sample_message(message_id=5, chat=dict(_GROUP_CHAT), **{"from": dict(_REPLY_BOT_USER)}), + ) + assert adapter.is_bot_mentioned(reply, reply["text"]) is expected # type: ignore[arg-type] + + +class TestTelegramPortableFileData: + """Telegram half of vercel/chat#828 (N/A in substance: Python returns ``bytes``).""" + + @pytest.mark.asyncio + async def test_downloads_file_bytes_without_the_node_buffer_global(self): + # Upstream asserts an ``ArrayBuffer``; the Python contract is plain + # ``bytes`` (not ``bytearray`` / ``memoryview``) from ``fetch_data``. + expected = bytes([0, 127, 255]) + session = _FakeSession(_FakeResponse([expected[:1], expected[1:]], content_length=3)) + adapter = _download_adapter(session) + parsed = adapter.parse_message( + _message_without_text(document={"file_id": "f1", "file_unique_id": "u1", "file_name": "file.bin"}) + ) + [attachment] = parsed.attachments + assert attachment.fetch_data is not None + + data = await attachment.fetch_data() + + assert type(data) is bytes + assert data == expected