telegraf
Version:
Modern Telegram Bot Framework
205 lines (181 loc) • 7.46 kB
text/typescript
import { Context } from './context'
import { ExclusiveKeys, MaybePromise } from './core/helpers/util'
import { MiddlewareFn } from './middleware'
import d from 'debug'
const debug = d('telegraf:session')
export interface SyncSessionStore<T> {
get: (name: string) => T | undefined
set: (name: string, value: T) => void
delete: (name: string) => void
}
export interface AsyncSessionStore<T> {
get: (name: string) => Promise<T | undefined>
set: (name: string, value: T) => Promise<unknown>
delete: (name: string) => Promise<unknown>
}
export type SessionStore<T> = SyncSessionStore<T> | AsyncSessionStore<T>
interface SessionOptions<S, C extends Context, P extends string> {
/** Customise the session prop. Defaults to "session" and is available as ctx.session. */
property?: P
getSessionKey?: (ctx: C) => MaybePromise<string | undefined>
store?: SessionStore<S>
defaultSession?: (ctx: C) => S
}
/** @deprecated session can use custom properties now. Construct this type directly. */
export interface SessionContext<S extends object> extends Context {
session?: S
}
/**
* Returns middleware that adds `ctx.session` for storing arbitrary state per session key.
*
* The default `getSessionKey` is `${ctx.from.id}:${ctx.chat.id}`.
* If either `ctx.from` or `ctx.chat` is `undefined`, default session key and thus `ctx.session` are also `undefined`.
*
* > ⚠️ Session data is kept only in memory by default, which means that all data will be lost when the process is terminated.
* >
* > If you want to persist data across process restarts, or share it among multiple instances, you should use
* [@telegraf/session](https://www.npmjs.com/package/@telegraf/session), or pass custom `storage`.
*
* @see {@link https://github.com/feathers-studio/telegraf-docs/blob/b694bcc36b4f71fb1cd650a345c2009ab4d2a2a5/guide/session.md Telegraf Docs | Session}
* @see {@link https://github.com/feathers-studio/telegraf-docs/blob/master/examples/session-bot.ts Example}
*/
export function session<
S extends NonNullable<C[P]>,
C extends Context & { [key in P]?: C[P] },
P extends (ExclusiveKeys<C, Context> & string) | 'session' = 'session',
// ^ Only allow prop names that aren't keys in base Context.
// At type level, this is cosmetic. To not get cluttered with all Context keys.
>(options?: SessionOptions<S, C, P>): MiddlewareFn<C> {
const prop = options?.property ?? ('session' as P)
const getSessionKey = options?.getSessionKey ?? defaultGetSessionKey
const store = options?.store ?? new MemorySessionStore()
// caches value from store in-memory while simultaneous updates share it
// when counter reaches 0, the cached ref will be freed from memory
const cache = new Map<string, { ref?: S; counter: number }>()
// temporarily stores concurrent requests
const concurrents = new Map<string, MaybePromise<S | undefined>>()
// this function must be handled with care
// read full description on the original PR: https://github.com/telegraf/telegraf/pull/1713
// make sure to update the tests in test/session.js if you make any changes or fix bugs here
return async (ctx, next) => {
const updId = ctx.update.update_id
let released = false
function releaseChecks() {
if (released && process.env.EXPERIMENTAL_SESSION_CHECKS)
throw new Error(
"Session was accessed or assigned to after the middleware chain exhausted. This is a bug in your code. You're probably accessing session asynchronously and missing awaits."
)
}
// because this is async, requests may still race here, but it will get autocorrected at (1)
// v5 getSessionKey should probably be synchronous to avoid that
const key = await getSessionKey(ctx)
if (!key) {
// Leaving this here could be useful to check for `prop in ctx` in future middleware
ctx[prop] = undefined as unknown as S
return await next()
}
let cached = cache.get(key)
if (cached) {
debug(`(${updId}) found cached session, reusing from cache`)
++cached.counter
} else {
debug(`(${updId}) did not find cached session`)
// if another concurrent request has already sent a store request, fetch that instead
let promise = concurrents.get(key)
if (promise)
debug(`(${updId}) found a concurrent request, reusing promise`)
else {
debug(`(${updId}) fetching from upstream store`)
promise = store.get(key)
}
// synchronously store promise so concurrent requests can share response
concurrents.set(key, promise)
const upstream = await promise
// all concurrent awaits will have promise in their closure, safe to remove now
concurrents.delete(key)
debug(`(${updId}) updating cache`)
// another request may have beaten us to the punch
const c = cache.get(key)
if (c) {
// another request did beat us to the punch
c.counter++
// (1) preserve cached reference; in-memory reference is always newer than from store
cached = c
} else {
// we're the first, so we must cache the reference
cached = { ref: upstream ?? options?.defaultSession?.(ctx), counter: 1 }
cache.set(key, cached)
}
}
// TS already knows cached is always defined by this point, but does not guard cached.
// It will, however, guard `c` here.
const c = cached
let touched = false
Object.defineProperty(ctx, prop, {
get() {
releaseChecks()
touched = true
return c.ref
},
set(value: S) {
releaseChecks()
touched = true
c.ref = value
},
})
try {
await next()
released = true
} finally {
if (--c.counter === 0) {
// decrement to avoid memory leak
debug(`(${updId}) refcounter reached 0, removing cached`)
cache.delete(key)
}
debug(`(${updId}) middlewares completed, checking session`)
// only update store if ctx.session was touched
if (touched)
if (c.ref == null) {
debug(`(${updId}) ctx.${prop} missing, removing from store`)
await store.delete(key)
} else {
debug(`(${updId}) ctx.${prop} found, updating store`)
await store.set(key, c.ref)
}
}
}
}
function defaultGetSessionKey(ctx: Context): string | undefined {
const fromId = ctx.from?.id
const chatId = ctx.chat?.id
if (fromId == null || chatId == null) return undefined
return `${fromId}:${chatId}`
}
/** @deprecated Use `Map` */
export class MemorySessionStore<T> implements SyncSessionStore<T> {
private readonly store = new Map<string, { session: T; expires: number }>()
constructor(private readonly ttl = Infinity) {}
get(name: string): T | undefined {
const entry = this.store.get(name)
if (entry == null) {
return undefined
} else if (entry.expires < Date.now()) {
this.delete(name)
return undefined
}
return entry.session
}
set(name: string, value: T): void {
const now = Date.now()
this.store.set(name, { session: value, expires: now + this.ttl })
}
delete(name: string): void {
this.store.delete(name)
}
}
/** @deprecated session can use custom properties now. Directly use `'session' in ctx` instead */
export function isSessionContext<S extends object>(
ctx: Context
): ctx is SessionContext<S> {
return 'session' in ctx
}