Skip to content
48 changes: 37 additions & 11 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { logger } from "@/logger"
import { modules } from "@/modules"
import type { TelemetryContextFlavor } from "@/modules/telemetry"
import { redis } from "@/redis"
import { RedisSet } from "@/redis/set"
import { fmt } from "@/utils/format"
import { ephemeral } from "@/utils/messages"
import type { Context, Role } from "@/utils/types"
Expand All @@ -19,6 +20,19 @@ import { pin } from "./pin"
import { report } from "./report"
import { search } from "./search"

const userSet = new RedisSet({
redis,
prefix: "managed-commands:cached-users",
ttl: 60 * 60 * 24, // 24h, we can afford some staleness here and it helps reduce the number of Redis calls significantly
})

const userRolesCache = new RedisFallbackAdapter<Role[]>({
redis,
prefix: "managed-commands:user-roles",
ttl: 60 * 5,
logger,
})

const adapter = new RedisFallbackAdapter<VersionedState<ConversationData>>({
redis,
prefix: "conv",
Expand All @@ -34,6 +48,14 @@ export const commands = new ManagedCommands<Role, Context, TelemetryContextFlavo
},
],
hooks: {
cachedUserSetCommands: async (userId, chatId) => {
const key = `${userId}:${chatId}`
if (await userSet.has(key)) {
return true
}
await userSet.add(key)
return false
},
wrongScope: async ({ context, command }) => {
await context.deleteMessage().catch(() => {})
logger.info(
Expand Down Expand Up @@ -104,19 +126,15 @@ export const commands = new ManagedCommands<Role, Context, TelemetryContextFlavo
},
},
getUserRoles: async (userId) => {
// TODO: cache this to avoid hitting the db on every command
const { roles } = await api.tg.permissions.getRoles.query({ userId })
return roles || []
const cached = await userRolesCache.read(String(userId))
if (cached) return cached

const res = await api.tg.permissions.getRoles.query({ userId })
const roles = res.roles ?? []
await userRolesCache.write(String(userId), roles)
return roles
},
})
.createCommand({
trigger: "ping",
scope: "private",
description: "Replies with pong",
handler: async ({ context }) => {
await context.reply("pong")
},
})
.createCommand({
trigger: "start",
scope: "private",
Expand All @@ -138,4 +156,12 @@ export const commands = new ManagedCommands<Role, Context, TelemetryContextFlavo
)
},
})
.createCommand({
trigger: "ping",
scope: "private",
description: "Replies with pong",
handler: async ({ context }) => {
await context.reply("pong")
},
})
.withCollection(linkAdminDashboard, report, search, management, moderation, pin, invite)
52 changes: 51 additions & 1 deletion src/lib/managed-commands/command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Conversation } from "@grammyjs/conversations"
import type { Context } from "grammy"
import type { Message } from "grammy/types"
import type { BotCommand, Message } from "grammy/types"
import type { z } from "zod"
import type { MaybeArray } from "@/utils/types"
import type { ConversationContext } from "./context"
Expand Down Expand Up @@ -168,6 +168,14 @@ export type AnyCommand<TRole extends string = string, C extends Context = Contex
C
>

export type AnyGroupCommand<TRole extends string = string, C extends Context = Context> = Command<
CommandArgs,
CommandReplyTo,
"group" | "both",
TRole,
C
>

/**
* Type guard to check if a command is allowed in groups.
* @param cmd The command to check
Expand Down Expand Up @@ -221,3 +229,45 @@ export function isAllowedInPrivateOnly<
>(cmd: Command<A, R, CommandScope, TRole, C>): cmd is Command<A, R, "private", TRole, C> {
return cmd.scope === "private"
}

export function isAllowedInPrivate<
A extends CommandArgs,
R extends CommandReplyTo,
TRole extends string = string,
C extends Context = Context,
>(cmd: Command<A, R, CommandScope, TRole, C>): cmd is Command<A, R, "private" | "both", TRole, C> {
return cmd.scope !== "group"
}

export function isAllowedEverywhere<
A extends CommandArgs,
R extends CommandReplyTo,
TRole extends string = string,
C extends Context = Context,
>(cmd: Command<A, R, CommandScope, TRole, C>): cmd is Command<A, R, "both", TRole, C> {
return cmd.scope === "both" || cmd.scope === undefined
}

export function toBotCommands(command: AnyCommand): BotCommand[] {
const triggers = Array.isArray(command.trigger) ? command.trigger : [command.trigger]
return triggers.map((trigger) => ({
command: trigger,
description: command.description ?? "No description",
}))
}

export function isForThisScope(cmd: AnyCommand, chatType: "private" | "group" | "supergroup" | "channel"): boolean {
if (chatType === "channel") return false
if (cmd.scope === "private") return chatType === "private"
if (cmd.scope === "group") return chatType === "group" || chatType === "supergroup"
return true
}

export function switchOnScope<S extends CommandScope, T>(
cmd: Command<CommandArgs, CommandReplyTo, S>,
handlers: { private: T; group: T; both: T }
) {
if (cmd.scope === "private") return handlers.private
if (cmd.scope === "group") return handlers.group
return handlers.both
}
Loading
Loading