Skip to content

@puregram/session

keep a bag of state for each user and read/write it from any handler — counters, half-filled forms, last-seen timestamps, whatever the bot needs. by default it lives in memory and vanishes on restart, but you can drop in any redis / sqlite / file-on-disk backend that satisfies KVStorage<V>

every update gets update.session attached as a transparent proxy — assignments persist when the handler returns, no save() calls, no manual flushing

when to use

reach for @puregram/session any time you need state that spans multiple updates from the same user. it's also required by @puregram/scenes and optionally by @puregram/flow for persistent flows

install

sh
yarn add @puregram/session
sh
npm i -S @puregram/session
sh
pnpm add @puregram/session

quick start

ts
import { Telegram } from 'puregram'
import { session } from '@puregram/session'

const telegram = Telegram.fromToken(process.env.TOKEN!)
  .extend(session())

telegram.onMessage(async (message) => {
  const counter = (message.session.counter as number ?? 0) + 1

  message.session.counter = counter

  await message.send(`you sent ${counter} messages!`)
})

await telegram.startPolling()

.extend(session()) does three things at install time:

  • registers a high-priority onUpdate hook that loads the user's data from storage, wraps it in a proxy, and attaches it as update.session
  • flushes any changes back to storage when the handler chain returns
  • exposes the configured backing store directly as tg.session for out-of-handler access

core api

update.session

a transparent proxy over the stored session object. read and write it like a plain object — the plugin handles load and flush transparently

ts
message.session.counter = 1
message.session.user = { name: 'alex' }

delete message.session.draft

nested plain objects and arrays are proxied recursively. class instances, Map, Set, Date, and other built-ins are stored by reference and not proxied — serialize them yourself if you need them to survive

update.session.$forceUpdate()

writes the current session state to storage immediately, mid-handler. useful for long-running tasks or branches that might not return cleanly:

ts
telegram.onMessage(async (message) => {
  message.session.startedAt = Date.now()

  await message.session.$forceUpdate()

  await runLongTask(message)
})

idempotent — safe to call multiple times

tg.session

direct access to the backing KVStorage, bypasses the proxy and ttl layer. use it from cron jobs, webhooks from other services, or admin tooling that runs outside an update context:

ts
await tg.session.set('promo:flag', { active: true })

const flag = await tg.session.get('promo:flag')

await tg.session.delete('promo:flag')

const exists = await tg.session.has('promo:flag')

ttl(value, t?)

mark a session value to expire t ms after it was last set. the proxy checks per-access — no backend support required:

ts
import { ttl } from '@puregram/session'

message.session.user = ttl(user, 300_000)

every subsequent assignment resets the timer:

ts
message.session.user = newUser

clear ttl by re-assigning with t = 0:

ts
message.session.user = ttl(user, 0)

if your backend supports sliding-window expiry (redis, sqlite) it will be detected automatically via isTtlStorage and touch()-d on every read, rolling the backend timer forward without re-wrapping

options

session(options?):

optiontypedefaultdescription
storageKVStorage<unknown>new MemoryStorage()backing store. swap in any KVStorage — including LruMemoryStorage, @puregram/storage-redis, @puregram/storage-sqlite, or a custom adapter. TtlStorage (sliding-window expiry) is auto-detected via isTtlStorage
getStorageKey(update) => string | StorageKeyDescriptor | undefined(u) => ({ chat: u.chat?.id, user: u.from?.id })how to derive the per-update storage key. undefined → skip session for that update
initial(update) => SessionData() => ({})initial value when the storage entry is missing
lazybooleanfalsewhen true, defers storage.get until update.session is accessed inside the handler — saves a get+set pair when the handler doesn't need session. do not enable when @puregram/scenes is loaded

scoping by chat instead of by user

ts
session({
  getStorageKey: (update) => {
    if ('chat' in update && update.chat !== undefined) {
      return `chat:${update.chat.id}`
    }

    return undefined
  }
})

bounded in-memory cache

cap how many sessions live in memory — once the cap is hit, the least-recently-used entry gets evicted:

ts
import { LruMemoryStorage } from '@puregram/session'

session({
  storage: new LruMemoryStorage({ max: 10_000 })
})

persistent backend

ts
import { session, type KVStorage } from '@puregram/session'

class JsonFileStorage<V> implements KVStorage<V> {
  async get (key: string): Promise<V | undefined> { return undefined }
  async set (key: string, value: V): Promise<void> {}
  async delete (key: string): Promise<void> {}
  async has (key: string): Promise<boolean> { return false }
}

const telegram = Telegram.fromToken(TOKEN).extend(session({
  storage: new JsonFileStorage('./sessions.json')
}))

lazy loading

defer the storage.get until update.session is accessed:

ts
session({ lazy: true })

in lazy mode, update.session is a thenable on first access — await it to get the proxy:

ts
telegram.onMessage(async (message) => {
  if (!message.text?.startsWith('/')) {
    return
  }

  const session = await message.session

  session.counter = (session.counter as number ?? 0) + 1
})
scenariostorage.getstorage.set
never accesses update.session00
reads update.session.x only10
writes update.session.x = ...11

lazy + scenes

do not enable lazy: true when @puregram/scenes is also loaded — scenes relies on synchronous update.session.<key> access inside its hook

composite keys

getStorageKey can return a StorageKeyDescriptor instead of a raw string. segments are joined as user:<id>:chat:<id>:thread:<id>:key:<value>, with undefined parts omitted:

ts
session({
  getStorageKey: update => ({
    chat: update.chatId,
    user: update.from?.id,
    thread: update.messageThreadId
  })
})
fieldserializes as
useruser:<id>
chatchat:<id>
threadthread:<id> — forum-topic-scoped sessions
keykey:<value> — free-form trailing segment

the default keyer produces user:<id>:chat:<id> — same user gets independent sessions in different chats. pass a custom getStorageKey to revert to v2-style per-user-regardless-of-chat behavior

typescript

@puregram/session attaches session: SessionContext to every update kind via codegen — no setup needed if Record<string, unknown> is fine:

ts
telegram.onMessage((message) => {
  message.session.anything = 42
})

declare-merge SessionData to get typed session fields:

ts
declare module '@puregram/session' {
  interface SessionData {
    counter: number
    user?: { name: string }
  }
}

message.session is then typed as SessionData & { $forceUpdate: () => Promise<void> } & { [key: string]: unknown }

plugins like @puregram/scenes merge their own fields (e.g. __scene) into SessionData automatically — no conflicts

errors

session itself throws no custom errors. storage adapters may throw on network/io failures — those propagate as-is. if initial throws, the error propagates from the onUpdate hook

exported types

ts
import type {
  AnyUpdate,
  KVStorage,
  LruMemoryStorageOptions,
  SessionContext,
  SessionData,
  SessionExtension,
  SessionOptions,
  StorageKeyDescriptor,
  TtlData,
  TtlStorage,
  TtlWrapped
} from '@puregram/session'

see also