Skip to content
Open
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
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