-
-
Notifications
You must be signed in to change notification settings - Fork 17
Sessions
Added in
9.5.
A Session is a handle on a single logical conversation slice. Every message that flows through it — both incoming user messages and outgoing bot sends — is recorded so the bot can later replay or bulk-delete them in one call.
The subsystem is always on with sensible defaults. Bots that never touch sessions pay effectively zero per-update cost (the pipeline interceptor short-circuits whenever no sessions are open).
flowchart LR
A["bot.sessions.get(...)"] --> B[Auto-subscribe SessionKey]
B --> C["SessionTrackingInterceptor<br/>auto-records Incoming"]
A --> S["session-aware send / sendReturning<br/>auto-records Outgoing"]
C --> D[SessionStorage backend]
S --> D
D --> E["session.messages() / forget() / clear()"]
E --> F["session.close() → unsubscribe + drop"]
val session = bot.sessions.get(chatId = chat.id, userId = user.id)
// Both incoming and outgoing are tracked automatically — just send through the session.
with(session) {
message { "Hi, what's up?" }.send(bot) // auto-tracked as Outgoing
}
// later — wipe everything we sent and received in this slice
session.clear()Tracking is automatic on both directions:
- Sessions are obtained from
bot.sessions, aSessionManagerexposed on everyTelegramBot. - Incoming updates are recorded by the pipeline interceptor for every key that has an open session subscription.
-
Outgoing messages are recorded whenever the send carries a session — either by passing it explicitly (
action.send(to, bot, session)/sendReturning(to, bot, session)) or by performing the send inside awith(session) { ... }block (the context-parameter overloads thread the session through for you).
Calling session.track(message, Direction.Outgoing) by hand is only needed when you want to record a Message that wasn't produced through a session-aware send (e.g. a message obtained from another source, or one sent before the session existed).
A SessionKey identifies a logical session. It is a sealed type:
-
SessionKey.Chat(chatId, qualifier?)— chat-wide session shared by everyone in the chat. -
SessionKey.ChatUser(chatId, userId, qualifier?)— per-user session scoped to a specific user inside a chat.
The optional qualifier lets multiple independent sessions coexist for the same chat / user (e.g. "wizard" and "support" running side-by-side).
A SessionKeyStrategy decides which key applies to a given update. Three strategies are built in:
| Strategy | Behaviour |
|---|---|
SessionKeyStrategy.ChatUser (default)
|
ChatUser(chat, user) when both are present, else Chat(chat). |
SessionKeyStrategy.Chat |
Always Chat(chat). Useful for broadcast-style bots and channels. |
SessionKeyStrategy.Auto |
Chat(chat) in private chats, ChatUser(chat, user) everywhere else. |
SessionKeyStrategy is a fun interface — you can supply a custom one if you need exotic scoping (for example, per business connection or per topic).
Each entry is recorded as either incoming or outgoing via Direction:
-
Direction.Incoming— received from the user / chat. Recorded automatically by theSessionTrackingInterceptorfor every key with an open subscription. -
Direction.Outgoing— sent by the bot. Recorded automatically whenever the send is session-aware (eitheraction.send(to, bot, session)directly, or insidewith(session) { … }).
Manual session.track(message, direction) is a fallback for the rare cases where neither path applies (e.g. backfilling a Message you obtained from somewhere else).
Recorded entries are stored as TrackedMessage values carrying messageId, chatId, optional userId, MessageKind, direction, optional businessConnectionId, and an Instant timestamp.
interface Session {
val key: SessionKey
val chatId: Long
val userId: Long?
val bot: TelegramBot
suspend fun track(message: Message, direction: Direction = Direction.Outgoing)
suspend fun track(update: ProcessedUpdate, direction: Direction = Direction.Incoming)
suspend fun messages(): List<TrackedMessage>
suspend fun clear(
bot: TelegramBot = this.bot,
predicate: (TrackedMessage) -> Boolean = { true },
): Int
suspend fun forget(predicate: (TrackedMessage) -> Boolean = { true }): Int
suspend fun close()
}-
trackrecords a single message; the second overload accepts aProcessedUpdatedirectly. -
messages()returns an immutable snapshot. -
clear()deletes matching messages from Telegram (in batches of 100 — thedeleteMessagesAPI limit) and removes them from storage. Storage is wiped regardless of per-batch outcome, so transient API errors don't leak entries forever. -
forget()drops entries from storage only — Telegram is not touched. -
close()unsubscribes the key from auto-tracking and clears its storage. The instance remains usable; callingbot.sessions.get(...)again re-subscribes.
Pass a qualifier to address an independent session for the same chat / user:
val wizard = bot.sessions.get(chat.id, user.id, qualifier = "wizard")
val support = bot.sessions.get(chat.id, user.id, qualifier = "support")In handler functions the ktnip code generator wires qualifiers up automatically via the @SessionQualifier annotation:
@CommandHandler(["/help"])
suspend fun help(
@SessionQualifier("wizard") wizard: Session,
@SessionQualifier("support") support: Session,
bot: TelegramBot,
) {
// wizard and support are isolated sessions for the same chat/user.
}Omit the annotation for the default (unqualified) session.
There are two equivalent ways to send a message into a session — pick whichever reads better at the call site:
// 1. Pass the session explicitly:
message { "Confirm with yes/no" }.send(user, bot, session)
photo { "FILE_ID" }.send(chat, bot, session)
// 2. Or open a context block and drop the parameter:
with(session) {
message { "Confirm with yes/no" }.send(bot) // targets session.chatId
photo { "FILE_ID" }.send(to = user, via = bot)
}Both routes auto-track the returned Message as Direction.Outgoing (see Action.sendTracked / sendReturningTracked). Because the session is passed explicitly (not via a thread- or coroutine-local) tracking can never be lost when handlers launch child coroutines.
sendReturning(...) works the same way: any returned Message (or list of messages) is recorded into the session before the caller's Deferred completes, so you can keep using the response normally.
SessionStorage is a small interface:
interface SessionStorage {
suspend fun add(key: SessionKey, entry: TrackedMessage)
suspend fun list(key: SessionKey): List<TrackedMessage>
suspend fun remove(key: SessionKey, predicate: (TrackedMessage) -> Boolean): Int
suspend fun clear(key: SessionKey)
}The default is InMemorySessionStorage (ConcurrentHashMap-backed). Implement your own for Redis, JDBC, etc. and plug it in via the configuration block.
val bot = TelegramBot("BOT_TOKEN") {
sessions {
keyStrategy = SessionKeyStrategy.Auto
storage = InMemorySessionStorage()
// managerFactory = SessionManagerFactory { bot, cfg -> CustomSessionManager(bot, cfg) }
}
}All three properties have sensible defaults — the sessions { } block is only required when you actually want to override something.
SessionManager.isIdle() is true until you open the first session. The SessionTrackingInterceptor checks it on every update and short-circuits when idle, so bots that never call bot.sessions.get(...) pay only a single map check per update.
Subscriptions are predicate-based: opening a session for a key registers a predicate that the interceptor matches against subsequent updates. session.close() removes the predicate.
@CommandHandler(["/order"])
suspend fun startOrder(
@SessionQualifier("order") order: Session,
user: User,
bot: TelegramBot,
) {
with(order) {
message { "What would you like to order?" }.send(user, bot) // auto-tracked
}
}
@CommandHandler(["/done"])
suspend fun finishOrder(
@SessionQualifier("order") order: Session,
user: User,
bot: TelegramBot,
) {
// This farewell is sent without the session so it survives clear().
message { "Order received — wiping our chat history." }.send(user, bot)
val removed = order.clear() // delete every message in this slice
order.close() // stop tracking until next /order
println("Cleared $removed messages for ${user.id}")
}The @SessionQualifier("order") parameter keeps this flow isolated from any other concurrent session (a wizard, a support thread, …) the same user might run. Every user reply between /order and /done is auto-recorded by the interceptor; every bot send made inside with(order) { … } is auto-recorded by the session-aware overload.
Telegram bot Wiki © KtGram