Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ import {
// implementations live with the commands.

export { previewMessage } from "./notifications/format.js";
export {
AdminCache,
canControl,
clearAdminCache,
DEFAULT_ADMIN_CACHE_MAX_SIZE,
DEFAULT_ADMIN_CACHE_TTL_MS,
isChatAdmin,
} from "./commands/admin-check.js";
export { helpDetailMessage, COMMAND_DETAILS, type CommandDetail } from "./commands/help-detail.js";
export {
CALLBACK_PAUSE_CANCEL,
Expand Down Expand Up @@ -268,12 +276,12 @@ export function registerCommandHandlers(bot: Bot | CommandRegistrar, deps: BotDe
* the handler. Gating here — instead of inside each handler — means a new
* operator-only or allowlisted command cannot forget its check.
*/
function dispatch(
async function dispatch(
module: CommandModule,
ctx: CommandContext<Context>,
deps: BotDeps,
): Promise<void> {
if (module.operatorOnly === true && !operatorAllowed(ctx, deps.config, module.command)) {
if (module.operatorOnly === true && !(await operatorAllowed(ctx, deps.config, module.command))) {
return Promise.resolve();
}
if (module.chatAllowlist === true && !chatAllowed(ctx, deps.config, module.command)) {
Expand Down
3 changes: 2 additions & 1 deletion src/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { resumeMessage } from "./commands/resume.js";
import { statusMessage } from "./commands/status.js";
import type { CommandDeps } from "./commands/registry.js";
import { TELEGRAM_OPTIONS, isOperator, safeAnswerCallback } from "./commands/runtime.js";
import { canControl } from "./commands/admin-check.js";

// ── Callback payload validation ──────────────────────────────────────────────

Expand Down Expand Up @@ -237,7 +238,7 @@ export async function handleCallbackQuery(

// Enforce operator role authorization
if (action.type === "pause" || action.type === "resume" || action.type === "broadcast") {
if (!isOperator(ctx, config)) {
if (!(await canControl(ctx, config, ctx.api))) {
console.warn(
`[bot] ignored unauthorized callback /${action.type} on update ${ctx.update.update_id}`,
);
Expand Down
275 changes: 275 additions & 0 deletions src/commands/admin-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/**
* Group administrator authorization and caching for operator commands.
*
* `isOperator` in `src/bot.ts` checks `OPERATOR_TELEGRAM_USER_ID` only. In
* group and supergroup chats shared with other administrators, state-changing
* commands (`/pause`, `/resume`, etc.) also honour Telegram chat administrator
* rights via `getChatMember` — including anonymous admins, whose messages have
* no `from.id` and instead set `sender_chat.id === chat.id`.
*
* Private-chat authorization is unchanged: only `OPERATOR_TELEGRAM_USER_ID`
* grants access there.
*/

import type { Api, Context } from "grammy";
import type { BotConfig } from "../config.js";
import { safeErrorMessage } from "../notifications/format.js";

/** Default cache TTL for `getChatMember` results (60 seconds). */
export const DEFAULT_ADMIN_CACHE_TTL_MS = 60_000;

/** Default maximum number of cached `(chatId, userId)` entries. */
export const DEFAULT_ADMIN_CACHE_MAX_SIZE = 256;

export interface ChatMemberApi {
getChatMember(
chatId: number | string,
userId: number,
): Promise<{ status?: string | undefined } | null | undefined>;
}

export interface AdminCheckOptions {
/** Cache entry TTL in milliseconds. Defaults to {@link DEFAULT_ADMIN_CACHE_TTL_MS}. */
ttlMs?: number | undefined;
/** Maximum number of entries in the cache. Defaults to {@link DEFAULT_ADMIN_CACHE_MAX_SIZE}. */
maxSize?: number | undefined;
/** Injectable clock for deterministic tests. Defaults to `Date.now`. */
now?: (() => number) | undefined;
/** Secrets to redact if `getChatMember` fails. */
secrets?: readonly string[] | undefined;
/** Custom cache instance (defaults to the module-level bounded cache). */
cache?: AdminCache | undefined;
}

interface CacheEntry {
isAdmin: boolean;
expiresAt: number;
}

/**
* Bounded TTL cache for `(chatId, userId)` admin status lookups.
* Evicts expired entries first, then oldest-inserted (LRU order) when full.
*/
export class AdminCache {
private readonly entries = new Map<string, CacheEntry>();
private readonly inflight = new Map<string, Promise<boolean>>();
readonly ttlMs: number;
readonly maxSize: number;
private readonly now: () => number;

constructor(options: { ttlMs?: number | undefined; maxSize?: number | undefined; now?: (() => number) | undefined } = {}) {
this.ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_ADMIN_CACHE_TTL_MS);
this.maxSize = Math.max(1, Math.floor(options.maxSize ?? DEFAULT_ADMIN_CACHE_MAX_SIZE));
this.now = options.now ?? Date.now;
}

get size(): number {
return this.entries.size;
}

clear(): void {
this.entries.clear();
this.inflight.clear();
}

get(
key: string,
ttlMs: number = this.ttlMs,
maxSize: number = this.maxSize,
now: () => number = this.now,
): boolean | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (now() >= entry.expiresAt) {
this.entries.delete(key);
return undefined;
}
// Refresh insertion order for LRU eviction.
this.entries.delete(key);
this.entries.set(key, entry);
void ttlMs;
void maxSize;
return entry.isAdmin;
}

set(
key: string,
isAdmin: boolean,
ttlMs: number = this.ttlMs,
maxSize: number = this.maxSize,
now: () => number = this.now,
): void {
if (ttlMs <= 0) return;
const currentNow = now();
this.entries.delete(key);

if (this.entries.size >= maxSize) {
// First sweep expired entries.
for (const [k, v] of this.entries) {
if (currentNow >= v.expiresAt) {
this.entries.delete(k);
}
}
// Evict oldest entries if still at capacity.
while (this.entries.size >= maxSize) {
const oldestKey = this.entries.keys().next().value;
if (oldestKey === undefined) break;
this.entries.delete(oldestKey);
}
}

this.entries.set(key, {
isAdmin,
expiresAt: currentNow + ttlMs,
});
}

getInflight(key: string): Promise<boolean> | undefined {
return this.inflight.get(key);
}

setInflight(key: string, promise: Promise<boolean>): void {
this.inflight.set(key, promise);
}

deleteInflight(key: string): void {
this.inflight.delete(key);
}
}

const defaultCache = new AdminCache();

/** Clear the module-level admin check cache (useful in tests). */
export function clearAdminCache(): void {
defaultCache.clear();
}

function isAdminStatus(status: unknown): boolean {
return status === "administrator" || status === "creator";
}

/**
* Check whether `userId` is an administrator or creator of `chatId` via
* `api.getChatMember`, cached with a short TTL and bounded size.
*
* API errors fail closed (`false`) and are logged with secret redaction.
*/
export async function isChatAdmin(
api: ChatMemberApi | Pick<Api, "getChatMember"> | null | undefined,
chatId: number | string,
userId: number | null | undefined,
options: AdminCheckOptions = {},
): Promise<boolean> {
if (
!api ||
typeof api.getChatMember !== "function" ||
typeof userId !== "number" ||
!Number.isFinite(userId) ||
chatId === null ||
chatId === undefined ||
chatId === ""
) {
return false;
}

const cache = options.cache ?? defaultCache;
const ttlMs = options.ttlMs ?? cache.ttlMs;
const maxSize = options.maxSize ?? cache.maxSize;
const now = options.now ?? Date.now;
const key = `${String(chatId)}:${String(userId)}`;

const cached = cache.get(key, ttlMs, maxSize, now);
if (cached !== undefined) {
return cached;
}

const existing = cache.getInflight(key);
if (existing !== undefined) {
return existing;
}

const request = (async (): Promise<boolean> => {
try {
const member = await api.getChatMember(chatId, userId);
const result = isAdminStatus(member?.status);
cache.set(key, result, ttlMs, maxSize, now);
return result;
} catch (err) {
console.warn(
`[bot] getChatMember failed for chat ${String(chatId)}: ${safeErrorMessage(
err,
options.secrets ? [...options.secrets] : [],
)}`,
);
return false;
} finally {
cache.deleteInflight(key);
}
})();

cache.setInflight(key, request);
return request;
}

function isGroupChat(chat: { id?: number | undefined; type?: string | undefined } | undefined): boolean {
if (!chat) return false;
if (chat.type === "group" || chat.type === "supergroup") return true;
if (chat.type === "private" || chat.type === "channel") return false;
// When chat.type is omitted in minimal test contexts, negative chat IDs
// identify Telegram groups/supergroups while positive IDs are private chats.
return typeof chat.id === "number" && chat.id < 0;
}

/**
* Combined authorization helper for state-changing commands and callbacks.
*
* Rules:
* 1. The configured `OPERATOR_TELEGRAM_USER_ID` is always authorised.
* 2. Private-chat authorization is unchanged (only `OPERATOR_TELEGRAM_USER_ID`).
* 3. In group/supergroup chats:
* - An anonymous admin (`sender_chat.id === chat.id`) is treated as admin.
* - Otherwise `isChatAdmin(api, chat.id, from.id)` checks Telegram admin rights.
*/
export async function canControl(
ctx: Partial<Pick<Context, "from" | "chat" | "senderChat" | "message" | "callbackQuery" | "api">>,
config: Pick<BotConfig, "operatorTelegramUserId"> & Partial<Pick<BotConfig, "botToken">>,
api?: ChatMemberApi | Pick<Api, "getChatMember"> | null | undefined,
options: AdminCheckOptions = {},
): Promise<boolean> {
const operatorId = config?.operatorTelegramUserId ?? null;
if (operatorId !== null && ctx?.from?.id !== undefined && String(ctx.from.id) === operatorId) {
return true;
}

const chat =
ctx?.chat ??
ctx?.message?.chat ??
(ctx?.callbackQuery?.message as { chat?: { id: number; type?: string } } | undefined)?.chat;

if (!chat || typeof chat.id !== "number" || !isGroupChat(chat)) {
return false;
}

const senderChat =
ctx?.senderChat ??
(ctx as { sender_chat?: { id?: number } } | undefined)?.sender_chat ??
ctx?.message?.sender_chat;

if (senderChat && typeof senderChat.id === "number" && senderChat.id === chat.id) {
return true;
}

const userId = ctx?.from?.id ?? ctx?.message?.from?.id ?? ctx?.callbackQuery?.from?.id;
if (typeof userId !== "number") {
return false;
}

const effectiveApi = api ?? ctx?.api;
const secrets =
options.secrets ?? (typeof config?.botToken === "string" && config.botToken ? [config.botToken] : []);

return isChatAdmin(effectiveApi, chat.id, userId, {
...options,
secrets,
});
}
7 changes: 4 additions & 3 deletions src/commands/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { CommandContext, Context } from "grammy";

import type { BotConfig } from "../config.js";
import { safeErrorMessage, splitTelegramMessage } from "../notifications/format.js";
import { canControl } from "./admin-check.js";

/** MarkdownV2 reply options used by every command message. */
export const TELEGRAM_OPTIONS = {
Expand Down Expand Up @@ -81,12 +82,12 @@ export function chatAllowed(
* Gate for operator commands. Non-operators get no reply at all, matching the
* silent-ignore policy above; the log records the ignored attempt.
*/
export function operatorAllowed(
export async function operatorAllowed(
ctx: CommandContext<Context>,
config: BotConfig,
command: string,
): boolean {
if (isOperator(ctx, config)) return true;
): Promise<boolean> {
if (await canControl(ctx, config, ctx.api)) return true;
console.warn(`[bot] ignored unauthorized /${command} on update ${ctx.update.update_id}`);
return false;
}
Expand Down
Loading
Loading