Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<chat_id>:<digits>"`. 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.
Expand Down
46 changes: 43 additions & 3 deletions docs/UPSTREAM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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**. |
Expand Down
Loading
Loading