Skip to content

3.3.0

Latest

Choose a tag to compare

@tixster tixster released this 26 Sep 01:08

Teleroute 3.3.0

Channel discussion forwards. When a channel has a linked discussion chat,
Telegram copies every new post into it. Replying to that copy is how a bot
comments under its own post. Until now, pairing a post with its copy was
left to each application. Teleroute now does it for you.

Source-compatible. The existing test suite passes unmodified. bot.bot is
deprecated in favour of bot.client.

Discussion forwards

Send a post and get its copy back

let (post, forward) = try await bot.sendWithDiscussionForward { client in
    try await client.sendPhoto(chatId: .id(channelId), photo: .upload(filename: "cover.jpg", data: cover))
}
if let forward {
    try await bot.client.sendDocument(
        chatId: .id(forward.discussionChatId),
        document: .upload(filename: "chapter.pdf", data: pdf),
        replyParameters: .init(messageId: forward.discussionMessageId)
    )
}

Any send method that returns one Message works inside the closure.
forward is nil when the channel has no linked chat.

  • Nothing is sent when tracking is off. In that case the call throws
    .trackingDisabled before the closure runs.
  • The post is never lost. If the send succeeds but waiting fails
    (timeout, cancellation, getChat error), the call throws
    TelerouteDiscussionPostError. It carries the sent post and the
    underlying reason. Errors from the send itself pass through unchanged.

Or wait for a post you already sent

let forward = try await bot.discussionMessage(for: post, timeout: .seconds(20))
let forward = try await bot.discussionMessage(channelId: id, messageId: 42)

Both methods are also available on every request context
(context.discussionMessage(for:), context.sendWithDiscussionForward { }).

  • No polling. The waiter is resumed the moment the copy arrives.
  • Channels without a discussion chat return at once. The linked chat is
    looked up with getChat and cached. A channel with none returns nil
    immediately instead of waiting out the timeout.
  • Early copies are not missed. A copy can arrive before you start
    waiting, for example when a webhook beats the sendMessage response.
    Recent copies are kept in a buffer, so it is still found.
  • Only real automatic forwards count. The message must have
    is_automatic_forward set and must land in the channel's linked chat.
    Manual forwards and copies in other groups are ignored.
  • Errors are TelerouteDiscussionForwardError: .timeout,
    .notAChannelPost, .trackingDisabled, .shutdown. Cancelling the
    waiting task throws CancellationError.

Observing every forward

router.onDiscussionForward { forward, context in
    context.logger.info("post \(forward.channelMessageId) → \(forward.discussionMessageId)")
}

Observers are not routes. They run for every automatic forward, before
replay protection and routing:

  • A command route cannot swallow them. A post that happens to start with
    / still reaches the observer, and the command still runs.
  • They leave routing alone. An observer does not change the update's
    handled/unmatched outcome or emit events. Guards and core middleware do not
    apply.
  • Errors do not stop routing. A thrown error is logged and passed to
    onError, and routing continues.
  • Child contexts work. Observers registered in a child-context group
    receive that group's context.
  • Tracking is not required. Observers run with tracking on or off.

Configuration

TelerouteConfiguration(
    discussionForwards: .enabled(retention: .seconds(120), capacity: 512, linkedChatCacheTTL: .seconds(600))
)

Tracking is on by default, and those values are the defaults. Use
.disabled to turn it off.

Tracking does not change allowed_updates. Automatic forwards arrive as plain
messages, so the bot must already ask for message updates. Any command or
message route does that, and registering an onDiscussionForward observer
adds message. If neither applies, the first wait logs a warning so the
timeout has a visible cause.

The bot must also be able to see the discussion chat's messages: make it an
administrator there or disable its privacy mode.

Test support

  • TelerouteTestSupport.makeAutomaticForwardUpdate(channelId:channelMessageId:discussionChatId:…)
    builds a synthetic automatic forward. A post starting with / gets a
    bot_command entity, as a real one would.
  • TelerouteTestSupport.linkedChatFallback([channelId: linkedChatId]) is a
    TelerouteRecordingTransport fallback that answers getChat with a
    channel's linked chat, or with none.

Other changes

  • TelerouteBot.client replaces TelerouteBot.bot, so the Bot API
    client is no longer spelled bot.bot. context.bot on request contexts is
    unchanged.

Upgrade notes

Deprecated: TelerouteBot.bot

It still compiles, with a warning and a fix-it.

-try await bot.bot.sendMessage(chatId: .id(channelId), text: "Hello")
+try await bot.client.sendMessage(chatId: .id(channelId), text: "Hello")

Things that compile but behave differently

  1. A new pipeline step runs first. Every update is now checked for an
    automatic channel forward before replay protection. Updates that are not
    automatic forwards are unaffected, and routing is unchanged.
  2. Tracking is on by default. It keeps up to 512 recent forwards in memory
    for 120 seconds. The only network cost is one getChat per channel per
    cache period, and only when you actually wait. Set discussionForwards: .disabled to opt out.
  3. onError can now be called for observer failures. Until now it only
    saw route failures. An observer error reaches onError with a context for
    the forward, but no .failed event or metric is emitted, because the
    update's outcome is still decided by routing.

Replacing a hand-rolled tracker

If you matched forward_origin in a router.message route that returned
.unhandled, drop that route and its buffer. Other routes that returned
.unhandled only so the tracker could still see the message, like a ping
command, no longer need to:

-let post = try await client.sendMessage(chatId: .id(channelId), text: text)
-let discussionId = try await tracker.waitForDiscussionMessage(
-    channelId: channelId, channelMessageId: post.messageId, discussionChatId: chatId
-)
+let (_, forward) = try await routedBot.sendWithDiscussionForward { client in
+    try await client.sendMessage(chatId: .id(channelId), text: text)
+}