Files
oh-my-pi-telegram/lib/config.ts
evandrodevbr 057281a3bb feat: human error states, operator toasts and a queue/origin view in the omp UI
- classify transport and API failures into a short state with the next step
- count undelivered messages and show them next to the sent counter
- toast on owner paired, connection recovered and reply not delivered (throttled)
- second status line previews the next queued turns
- host spinner names the Telegram prompt while its turn runs
- sanitize every Telegram-controlled string that reaches the widget
2026-10-01 17:33:37 -03:00

845 lines
26 KiB
TypeScript

/**
* Telegram bridge config and pairing helpers
* Zones: telegram config, pairing, filesystem
* Owns persisted bot/session pairing state, local config storage, live config controls, authorization policy, and first-user pairing side effects
*/
import { randomUUID } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
writeFileSync,
} from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import { resolveAgentDir, resolveTelegramConfigPath } from "./paths.ts";
import type { TelegramInboundHandlerConfig } from "./inbound.ts";
import type { CommandTemplateObjectConfig } from "./command-templates.ts";
import { withTelegramFileTransaction } from "./locks.ts";
const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
function getConfigPath(): string {
return resolveTelegramConfigPath();
}
export type TelegramOutboundCommandTemplateConfig =
string | CommandTemplateObjectConfig;
export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
type?: string;
match?: string | string[];
output?: string;
timeout?: number | string;
}
export type TelegramTimeMode = "hidden" | "always" | "interval";
export interface TelegramTimeConfig {
injectionMode?: TelegramTimeMode;
interval?: number;
}
export interface ResolvedTelegramTimeConfig {
injectionMode: TelegramTimeMode;
interval: number;
timezone: string;
}
export type TelegramAssistantRenderingMode = "rich" | "html";
export interface TelegramConfig {
botToken?: string;
botUsername?: string;
botId?: number;
allowedUserId?: number;
lastUpdateId?: number;
inboundHandlers?: TelegramInboundHandlerConfig[];
attachmentHandlers?: TelegramInboundHandlerConfig[];
outboundHandlers?: TelegramOutboundHandlerConfig[];
proactivePush?: boolean;
assistant?: {
draftPreviews?: boolean;
rendering?: TelegramAssistantRenderingMode;
};
/** @deprecated use assistant.draftPreviews */
draftPreviews?: boolean;
/** @deprecated use assistant.draftPreviews */
richDraftPreviews?: boolean;
/** @deprecated use assistant.rendering */
assistantRendering?: TelegramAssistantRenderingMode;
voice?: {
replyMode?: "manual" | "mirror" | "always";
/** Whether to attach the provider's transcriptText as caption on voice messages */
sendTranscript?: boolean;
};
time?: TelegramTimeConfig;
/** Named bot/session profiles (e.g. "work", "omp"). */
profiles?: Record<string, TelegramBotProfile>;
}
/**
* Per-profile bot/session identity fields.
* Stored under `profiles.<name>` in telegram.json.
* Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
* assistant, proactivePush) stay at the top level.
*/
export interface TelegramBotProfile {
botToken: string;
botUsername?: string;
botId?: number;
allowedUserId?: number;
lastUpdateId?: number;
}
/** Profile names must contain only lowercase ASCII letters and digits; max 32 chars. */
const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9]{1,32}$/;
const TELEGRAM_RESERVED_PROFILE_NAMES: ReadonlySet<string> = new Set([
"default",
"main",
"active",
]);
export function isValidTelegramProfileName(name: string): boolean {
return (
TELEGRAM_PROFILE_NAME_PATTERN.test(name) &&
!TELEGRAM_RESERVED_PROFILE_NAMES.has(name)
);
}
/**
* Resolve the effective config for a named (or default) profile.
* Returns bot/session fields from the named profile, falling back to
* top-level fields for the default profile. Shared bridge settings
* always come from the top level.
*/
export function resolveTelegramActiveProfile(
config: TelegramConfig,
profileName?: string,
): {
botToken?: string;
botUsername?: string;
botId?: number;
allowedUserId?: number;
lastUpdateId?: number;
} {
if (!profileName || !config.profiles?.[profileName]) {
return {
botToken: config.botToken,
botUsername: config.botUsername,
botId: config.botId,
allowedUserId: config.allowedUserId,
lastUpdateId: config.lastUpdateId,
};
}
const profile = config.profiles[profileName];
return {
botToken: profile.botToken,
botUsername: profile.botUsername,
botId: profile.botId,
allowedUserId: profile.allowedUserId,
lastUpdateId: profile.lastUpdateId,
};
}
/** List defined profile names. */
export function getTelegramProfileNames(config: TelegramConfig): string[] {
return Object.keys(config.profiles ?? {}).sort();
}
export interface TelegramConfigStore {
get: () => TelegramConfig;
getStoredConfig: () => TelegramConfig;
set: (config: TelegramConfig) => void;
update: (mutate: (config: TelegramConfig) => void) => void;
activateProfile: (profileName: string | undefined) => boolean;
getActiveProfileName: () => string | undefined;
getBotToken: () => string | undefined;
hasBotToken: () => boolean;
getAllowedUserId: () => number | undefined;
getInboundHandlers: () => TelegramInboundHandlerConfig[] | undefined;
getAttachmentHandlers: () => TelegramInboundHandlerConfig[] | undefined;
getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
setAllowedUserId: (userId: number) => void;
load: () => Promise<void>;
persist: (config?: TelegramConfig) => Promise<void>;
}
export interface TelegramConfigStoreOptions {
initialConfig?: TelegramConfig;
agentDir?: string;
configPath?: string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export interface TelegramInvalidConfigRecovery {
configPath: string;
recoveryPath: string;
error: unknown;
}
export interface TelegramConfigRuntime {
updateVoiceConfig: (voice: NonNullable<TelegramConfig["voice"]>) => void;
}
export function setGlobalTelegramConfigRuntime(
runtime: TelegramConfigRuntime | undefined,
): void {
const globals = globalThis as Record<string, unknown>;
if (runtime) globals[CONFIG_RUNTIME_KEY] = runtime;
else delete globals[CONFIG_RUNTIME_KEY];
}
export function updateTelegramVoiceConfig(
voice: NonNullable<TelegramConfig["voice"]>,
): boolean {
const runtime = (globalThis as Record<string, unknown>)[
CONFIG_RUNTIME_KEY
] as TelegramConfigRuntime | undefined;
if (!runtime || typeof runtime.updateVoiceConfig !== "function") return false;
runtime.updateVoiceConfig(voice);
return true;
}
type TelegramMutableConfigStore = Pick<
TelegramConfigStore,
"get" | "set" | "persist"
> & {
load?: () => Promise<void>;
};
function isEmptyTelegramConfig(config: TelegramConfig): boolean {
return Object.keys(config).length === 0;
}
async function loadLatestTelegramConfig(
configStore: TelegramMutableConfigStore,
): Promise<void> {
if (!configStore.load) return;
const before = configStore.get();
await configStore.load();
if (
!isEmptyTelegramConfig(before) &&
isEmptyTelegramConfig(configStore.get())
) {
configStore.set(before);
}
}
export function bindGlobalTelegramConfigRuntime(
configStore: TelegramMutableConfigStore,
): void {
setGlobalTelegramConfigRuntime({
updateVoiceConfig(voice) {
const current = configStore.get();
const next = {
...current,
voice: { ...(current.voice ?? {}), ...voice },
};
configStore.set(next);
void configStore.persist(next);
},
});
}
function getInvalidTelegramConfigRecoveryPath(configPath: string): string {
return `${configPath}.invalid-${process.pid}-${Date.now()}`;
}
export async function readTelegramConfig(
configPath: string,
options: {
onInvalidConfig?: (recovery: TelegramInvalidConfigRecovery) => void;
} = {},
): Promise<TelegramConfig> {
return withTelegramFileTransaction(`${configPath}.transaction`, () => {
if (!existsSync(configPath)) return {};
const identity = statSync(configPath);
const content = readFileSync(configPath, "utf8");
try {
return JSON.parse(content) as TelegramConfig;
} catch (error) {
const currentIdentity = statSync(configPath);
if (
currentIdentity.dev !== identity.dev ||
currentIdentity.ino !== identity.ino ||
currentIdentity.size !== identity.size ||
currentIdentity.mtimeMs !== identity.mtimeMs
) {
throw new Error(
`Telegram config changed while validating invalid content: ${configPath}`,
{ cause: error },
);
}
const recoveryPath = getInvalidTelegramConfigRecoveryPath(configPath);
renameSync(configPath, recoveryPath);
options.onInvalidConfig?.({ configPath, recoveryPath, error });
return {};
}
});
}
export async function writeTelegramConfig(
agentDir: string,
configPath: string,
config: TelegramConfig,
): Promise<void> {
await mkdir(agentDir, { recursive: true });
const tempConfigPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempConfigPath, JSON.stringify(config, null, "\t") + "\n", {
encoding: "utf8",
mode: 0o600,
});
await chmod(tempConfigPath, 0o600);
await rename(tempConfigPath, configPath);
await chmod(configPath, 0o600);
}
function isPlainConfigRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function cloneTelegramConfig<T>(value: T): T {
return structuredClone(value);
}
function configValuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function mergeTelegramConfigDelta(
base: Record<string, unknown>,
desired: Record<string, unknown>,
latest: Record<string, unknown>,
): Record<string, unknown> {
const merged = cloneTelegramConfig(latest);
for (const key of new Set([...Object.keys(base), ...Object.keys(desired)])) {
const baseHas = Object.hasOwn(base, key);
const desiredHas = Object.hasOwn(desired, key);
const baseValue = base[key];
const desiredValue = desired[key];
if (baseHas === desiredHas && configValuesEqual(baseValue, desiredValue)) {
continue;
}
if (!desiredHas) {
if (key !== "lastUpdateId") delete merged[key];
continue;
}
if (
key === "lastUpdateId" &&
typeof desiredValue === "number" &&
typeof merged[key] === "number"
) {
merged[key] = Math.max(desiredValue, merged[key] as number);
continue;
}
if (
isPlainConfigRecord(desiredValue) &&
(!baseHas || isPlainConfigRecord(baseValue))
) {
merged[key] = mergeTelegramConfigDelta(
isPlainConfigRecord(baseValue) ? baseValue : {},
desiredValue,
isPlainConfigRecord(merged[key]) ? merged[key] : {},
);
continue;
}
merged[key] = cloneTelegramConfig(desiredValue);
}
return merged;
}
function readTelegramConfigForTransaction(configPath: string): TelegramConfig {
if (!existsSync(configPath)) return {};
const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8"));
if (!isPlainConfigRecord(parsed)) {
throw new Error(`Invalid Telegram config object: ${configPath}`);
}
return parsed as TelegramConfig;
}
function writeTelegramConfigInTransaction(
agentDir: string,
configPath: string,
config: TelegramConfig,
): void {
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
const tempConfigPath = `${configPath}.tmp-${process.pid}-${randomUUID()}`;
writeFileSync(tempConfigPath, `${JSON.stringify(config, null, "\t")}\n`, {
encoding: "utf8",
mode: 0o600,
});
chmodSync(tempConfigPath, 0o600);
renameSync(tempConfigPath, configPath);
chmodSync(configPath, 0o600);
}
export function getTelegramProfileFields(
config: TelegramConfig,
): TelegramBotProfile | undefined {
const token = config.botToken?.trim();
if (!token) return undefined;
return {
botToken: token,
botUsername: config.botUsername,
botId: config.botId,
allowedUserId: config.allowedUserId,
lastUpdateId: config.lastUpdateId,
};
}
function applyTelegramProfile(
config: TelegramConfig,
profileName: string | undefined,
): TelegramConfig {
if (!profileName) return config;
const profile = config.profiles?.[profileName];
if (!profile) return config;
return {
...config,
botToken: profile.botToken,
botUsername: profile.botUsername,
botId: profile.botId,
allowedUserId: profile.allowedUserId,
lastUpdateId: profile.lastUpdateId,
};
}
function storeTelegramEffectiveConfig(
baseConfig: TelegramConfig,
nextConfig: TelegramConfig,
profileName: string | undefined,
): TelegramConfig {
if (!profileName) return nextConfig;
const profile = getTelegramProfileFields(nextConfig);
const profiles = { ...(baseConfig.profiles ?? {}) };
if (profile) profiles[profileName] = profile;
else delete profiles[profileName];
return {
...nextConfig,
botToken: baseConfig.botToken,
botUsername: baseConfig.botUsername,
botId: baseConfig.botId,
allowedUserId: baseConfig.allowedUserId,
lastUpdateId: baseConfig.lastUpdateId,
profiles: Object.keys(profiles).length > 0 ? profiles : undefined,
};
}
export function createTelegramConfigStore(
options: TelegramConfigStoreOptions = {},
): TelegramConfigStore {
let config: TelegramConfig = cloneTelegramConfig(options.initialConfig ?? {});
let persistedConfig: TelegramConfig = {};
let mutationVersion = 0;
let persistQueue: Promise<void> = Promise.resolve();
let activeProfileName: string | undefined;
const agentDir = options.agentDir ?? resolveAgentDir();
const configPath = options.configPath ?? getConfigPath();
const getEffectiveConfig = () =>
applyTelegramProfile(config, activeProfileName);
const setEffectiveConfig = (nextConfig: TelegramConfig) => {
config = storeTelegramEffectiveConfig(
config,
nextConfig,
activeProfileName,
);
mutationVersion += 1;
};
return {
get: getEffectiveConfig,
getStoredConfig: () => config,
set: setEffectiveConfig,
update: (mutate) => {
const nextConfig = getEffectiveConfig();
mutate(nextConfig);
setEffectiveConfig(nextConfig);
},
activateProfile: (profileName) => {
if (profileName && !config.profiles?.[profileName]) return false;
activeProfileName = profileName;
return true;
},
getActiveProfileName: () => activeProfileName,
getBotToken: () => getEffectiveConfig().botToken,
hasBotToken: () => !!getEffectiveConfig().botToken,
getAllowedUserId: () => getEffectiveConfig().allowedUserId,
getInboundHandlers: () => [
...(config.inboundHandlers ?? []),
...(config.attachmentHandlers ?? []),
],
getAttachmentHandlers: () => config.attachmentHandlers,
getOutboundHandlers: () => config.outboundHandlers,
setAllowedUserId: (userId) => {
const nextConfig = getEffectiveConfig();
nextConfig.allowedUserId = userId;
setEffectiveConfig(nextConfig);
},
load: async () => {
config = await readTelegramConfig(configPath, {
onInvalidConfig: (recovery) => {
options.recordRuntimeEvent?.("config", recovery.error, {
phase: "load",
configPath: recovery.configPath,
recoveryPath: recovery.recoveryPath,
});
},
});
persistedConfig = cloneTelegramConfig(config);
mutationVersion += 1;
},
persist: (nextConfig = getEffectiveConfig()) => {
const profileName = activeProfileName;
const desiredConfig = storeTelegramEffectiveConfig(
config,
cloneTelegramConfig(nextConfig),
profileName,
);
const baseConfig = cloneTelegramConfig(persistedConfig);
const capturedMutationVersion = mutationVersion;
const persist = persistQueue.then(() => {
const mergedConfig = withTelegramFileTransaction(
`${configPath}.transaction`,
() => {
const latestConfig = readTelegramConfigForTransaction(configPath);
const merged = mergeTelegramConfigDelta(
baseConfig as Record<string, unknown>,
desiredConfig as Record<string, unknown>,
latestConfig as Record<string, unknown>,
) as TelegramConfig;
writeTelegramConfigInTransaction(agentDir, configPath, merged);
return merged;
},
);
persistedConfig = cloneTelegramConfig(mergedConfig);
if (mutationVersion === capturedMutationVersion) {
config = cloneTelegramConfig(mergedConfig);
} else {
config = mergeTelegramConfigDelta(
baseConfig as Record<string, unknown>,
config as Record<string, unknown>,
mergedConfig as Record<string, unknown>,
) as TelegramConfig;
}
});
persistQueue = persist.catch(() => undefined);
return persist;
},
};
}
export function createTelegramProactivePushChecker(
configStore: Pick<TelegramConfigStore, "get">,
): () => boolean {
return () => configStore.get().proactivePush ?? false;
}
export function createTelegramProactivePushSetter(
configStore: TelegramMutableConfigStore,
): (enabled: boolean) => Promise<void> {
return async (enabled) => {
await loadLatestTelegramConfig(configStore);
const config = { ...configStore.get(), proactivePush: enabled };
configStore.set(config);
await configStore.persist(config);
};
}
export function createTelegramDraftPreviewsChecker(
configStore: Pick<TelegramConfigStore, "get">,
): () => boolean {
return () => {
const config = configStore.get();
return (
config.assistant?.draftPreviews ??
config.draftPreviews ??
config.richDraftPreviews ??
false
);
};
}
export function createTelegramDraftPreviewsSetter(
configStore: TelegramMutableConfigStore,
): (enabled: boolean) => Promise<void> {
return async (enabled) => {
await loadLatestTelegramConfig(configStore);
const {
draftPreviews: _legacyDraftPreviews,
richDraftPreviews: _legacyRichDraftPreviews,
...current
} = configStore.get();
const config = {
...current,
assistant: { ...current.assistant, draftPreviews: enabled },
};
configStore.set(config);
await configStore.persist(config);
};
}
export function createTelegramAssistantRenderingModeGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => TelegramAssistantRenderingMode {
return () => {
const config = configStore.get();
const mode = config.assistant?.rendering ?? config.assistantRendering;
return mode === "html" ? "html" : "rich";
};
}
export function createTelegramAssistantRenderingModeSetter(
configStore: TelegramMutableConfigStore,
): (mode: TelegramAssistantRenderingMode) => Promise<void> {
return async (mode) => {
await loadLatestTelegramConfig(configStore);
const { assistantRendering: _legacyAssistantRendering, ...current } =
configStore.get();
const config = {
...current,
assistant: { ...current.assistant, rendering: mode },
};
configStore.set(config);
await configStore.persist(config);
};
}
export function createTelegramVoiceReplyModeGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => "manual" | "mirror" | "always" {
return () => {
const mode = configStore.get().voice?.replyMode;
return mode === "mirror" || mode === "always" || mode === "manual"
? mode
: "manual";
};
}
export function createTelegramVoiceReplyModeConfiguredChecker(
configStore: Pick<TelegramConfigStore, "get">,
): () => boolean {
return () => {
const mode = configStore.get().voice?.replyMode;
return mode === "mirror" || mode === "always" || mode === "manual";
};
}
export function createTelegramVoiceReplyModeSetter(
configStore: TelegramMutableConfigStore,
): (replyMode: "manual" | "mirror" | "always" | undefined) => Promise<void> {
return async (replyMode) => {
await loadLatestTelegramConfig(configStore);
const current = configStore.get();
if (replyMode === undefined) {
const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
const next = { ...current };
if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
else delete next.voice;
configStore.set(next);
await configStore.persist(next);
return;
}
const next = { ...current, voice: { ...(current.voice ?? {}), replyMode } };
configStore.set(next);
await configStore.persist(next);
};
}
function getSystemTimezone(): string {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz && tz.length > 0 ? tz : "UTC";
} catch {
return "UTC";
}
}
export function resolveTelegramTimeConfig(
raw: TelegramTimeConfig | undefined,
): ResolvedTelegramTimeConfig {
const injectionMode: TelegramTimeMode =
raw?.injectionMode === "always" || raw?.injectionMode === "interval"
? raw.injectionMode
: "hidden";
const interval =
typeof raw?.interval === "number" && Number.isFinite(raw.interval) && raw.interval > 0
? raw.interval
: 60 * 60 * 1000;
const timezone = getSystemTimezone();
return { injectionMode, interval, timezone };
}
export function createTelegramTimeConfigGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => ResolvedTelegramTimeConfig {
return () => resolveTelegramTimeConfig(configStore.get().time);
}
export function createTelegramTimeInjectionModeGetter(
configStore: Pick<TelegramConfigStore, "get">,
): () => TelegramTimeMode {
return () => resolveTelegramTimeConfig(configStore.get().time).injectionMode;
}
export function createTelegramTimeInjectionModeSetter(
configStore: TelegramMutableConfigStore,
): (injectionMode: TelegramTimeMode) => Promise<void> {
return async (injectionMode) => {
await loadLatestTelegramConfig(configStore);
const current = configStore.get();
if (injectionMode === "hidden") {
const { injectionMode: _injectionMode, ...remainingTime } =
current.time ?? {};
const next = { ...current };
if (Object.keys(remainingTime).length > 0) next.time = remainingTime;
else delete next.time;
configStore.set(next);
await configStore.persist(next);
return;
}
const next = {
...current,
time: { ...(current.time ?? {}), injectionMode },
};
configStore.set(next);
await configStore.persist(next);
};
}
export interface TelegramProactivePushTarget {
chatId: number;
threadId?: number;
}
export function createTelegramProactivePushChatIdGetter(deps: {
getActiveTurnChatId: () => number | undefined;
getAllowedUserId: () => number | undefined;
}): () => number | undefined {
return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
}
export function createTelegramProactivePushTargetGetter(deps: {
getActiveTurnTarget: () => TelegramProactivePushTarget | undefined;
getAssignedTarget: () => TelegramProactivePushTarget | undefined;
getAllowedUserId: () => number | undefined;
}): () => TelegramProactivePushTarget | undefined {
return () => {
const activeTarget = deps.getActiveTurnTarget();
if (activeTarget) return activeTarget;
const assignedTarget = deps.getAssignedTarget();
if (assignedTarget) return assignedTarget;
const chatId = deps.getAllowedUserId();
return typeof chatId === "number" ? { chatId } : undefined;
};
}
export function createTelegramConfigControls(
configStore: TelegramMutableConfigStore,
) {
return {
isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
areDraftPreviewsEnabled: createTelegramDraftPreviewsChecker(configStore),
setDraftPreviewsEnabled: createTelegramDraftPreviewsSetter(configStore),
getAssistantRenderingMode:
createTelegramAssistantRenderingModeGetter(configStore),
setAssistantRenderingMode:
createTelegramAssistantRenderingModeSetter(configStore),
getVoiceReplyMode: createTelegramVoiceReplyModeGetter(configStore),
isVoiceReplyModeConfigured:
createTelegramVoiceReplyModeConfiguredChecker(configStore),
setVoiceReplyMode: createTelegramVoiceReplyModeSetter(configStore),
getTimeInjectionMode: createTelegramTimeInjectionModeGetter(configStore),
setTimeInjectionMode: createTelegramTimeInjectionModeSetter(configStore),
};
}
export type TelegramAuthorizationState =
{ kind: "pair"; userId: number } | { kind: "allow" } | { kind: "deny" };
export interface TelegramUserPairingDeps<TContext> {
allowedUserId?: number;
ctx: TContext;
setAllowedUserId: (userId: number) => void;
persistConfig: () => Promise<void>;
updateStatus: (ctx: TContext) => void;
}
export interface TelegramUserPairingRuntimeDeps<TContext> {
getAllowedUserId: () => number | undefined;
setAllowedUserId: (userId: number) => void;
persistConfig: () => Promise<void>;
updateStatus: (ctx: TContext) => void;
/** Called once the user was accepted as owner and the config persisted. */
onPaired?: (userId: number, ctx: TContext) => void;
}
export interface TelegramUserPairingRuntime<TContext> {
pairIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
}
export function getTelegramAuthorizationState(
userId: number,
allowedUserId?: number,
): TelegramAuthorizationState {
if (allowedUserId === undefined) {
return { kind: "pair", userId };
}
if (userId === allowedUserId) {
return { kind: "allow" };
}
return { kind: "deny" };
}
function isTelegramStaleContextError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("stale after session") ||
error.message.includes("stale ctx"))
);
}
export async function pairTelegramUserIfNeeded<TContext>(
userId: number,
deps: TelegramUserPairingDeps<TContext>,
): Promise<boolean> {
const authorization = getTelegramAuthorizationState(
userId,
deps.allowedUserId,
);
if (authorization.kind !== "pair") return false;
deps.setAllowedUserId(authorization.userId);
await deps.persistConfig();
try {
deps.updateStatus(deps.ctx);
} catch (error) {
if (!isTelegramStaleContextError(error)) throw error;
}
return true;
}
export function createTelegramUserPairingRuntime<TContext>(
deps: TelegramUserPairingRuntimeDeps<TContext>,
): TelegramUserPairingRuntime<TContext> {
return {
pairIfNeeded: async (userId, ctx) => {
const paired = await pairTelegramUserIfNeeded(userId, {
allowedUserId: deps.getAllowedUserId(),
ctx,
setAllowedUserId: deps.setAllowedUserId,
persistConfig: deps.persistConfig,
updateStatus: deps.updateStatus,
});
if (paired) deps.onPaired?.(userId, ctx);
return paired;
},
};
}