Repository navigation
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
.trackingDisabledbefore the closure runs. - The post is never lost. If the send succeeds but waiting fails
(timeout, cancellation,getChaterror), the call throws
TelerouteDiscussionPostError. It carries the sentpostand the
underlyingreason. 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 withgetChatand cached. A channel with none returnsnil
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 thesendMessageresponse.
Recent copies are kept in a buffer, so it is still found. - Only real automatic forwards count. The message must have
is_automatic_forwardset 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 throwsCancellationError.
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_commandentity, as a real one would.TelerouteTestSupport.linkedChatFallback([channelId: linkedChatId])is a
TelerouteRecordingTransportfallback that answersgetChatwith a
channel's linked chat, or with none.
Other changes
TelerouteBot.clientreplacesTelerouteBot.bot, so the Bot API
client is no longer spelledbot.bot.context.boton 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
- 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. - Tracking is on by default. It keeps up to 512 recent forwards in memory
for 120 seconds. The only network cost is onegetChatper channel per
cache period, and only when you actually wait. SetdiscussionForwards: .disabledto opt out. onErrorcan now be called for observer failures. Until now it only
saw route failures. An observer error reachesonErrorwith a context for
the forward, but no.failedevent 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)
+}