diff --git a/package.json b/package.json index 45eeb0f..c3047d2 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@grammyjs/menu": "^1.3.1", "@grammyjs/parse-mode": "^1.11.1", "@grammyjs/runner": "^2.0.3", - "@polinetwork/backend": "^0.15.3", + "@polinetwork/backend": "^0.15.5", "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", "@types/ssdeep.js": "^0.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9216892..0bb00af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(grammy@1.37.0) '@polinetwork/backend': - specifier: ^0.15.3 - version: 0.15.3 + specifier: ^0.15.5 + version: 0.15.5 '@t3-oss/env-core': specifier: ^0.13.4 version: 0.13.4(arktype@2.1.20)(typescript@5.7.3)(zod@4.1.11) @@ -425,8 +425,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@polinetwork/backend@0.15.3': - resolution: {integrity: sha512-W63S2omBKMQnoEWKtrHBPywJaQf2I39ZubHTBIivseEfPMcC3M3HUDlTOiGcg/TpMIIuGj9lp6dmRGtH4YhFHw==} + '@polinetwork/backend@0.15.5': + resolution: {integrity: sha512-+60RfYsbPUnqpjbCeAMmhjdnBmKuSBQXEGF+YLQOAt7oRWBeW5woN8fA5dj+Ec1fEQjXOxlvDY6dOPiwyzSGMA==} '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} @@ -1784,7 +1784,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@polinetwork/backend@0.15.3': {} + '@polinetwork/backend@0.15.5': {} '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: diff --git a/src/commands/management/groups.ts b/src/commands/management/groups.ts new file mode 100644 index 0000000..4ecb1d7 --- /dev/null +++ b/src/commands/management/groups.ts @@ -0,0 +1,52 @@ +import z from "zod" +import { GroupManagement } from "@/lib/group-management" +import { CommandsCollection } from "@/lib/managed-commands" +import { fmt } from "@/utils/format" +import type { Role } from "@/utils/types" + +export const groups = new CommandsCollection("Groups").createCommand({ + trigger: "updategroup", + scope: "private", + description: "Trigger group info update to the database (eg. title or tag change)", + args: [ + { + key: "chatId", + optional: false, + type: z.coerce.number(), + description: "Chat ID (number, obtained from alternative clients) of the group you want to force update", + }, + ], + permissions: { + allowedRoles: ["owner", "direttivo"], + }, + handler: async ({ context, args }) => { + const group = await context.api.getChat(args.chatId).catch(() => null) + if (!group) + return void context.reply( + fmt( + ({ code, n }) => + n`Group with chatId ${code`${args.chatId}`} does not exists or the bot is not an administrator.` + ) + ) + + if (group.type === "private") + return void context.reply( + fmt(({ code, n }) => n`Chat with chatId ${code`${args.chatId}`} is a private chat, not a group.`) + ) + + const res = await GroupManagement.update(group.id, context.from) + if (res.isErr()) { + return void context.reply( + fmt(({ code, n, b, i }) => [b`There was an ERROR`, n`chatId: ${code`${args.chatId}`}`, i`\n${res.error}`], { + sep: "\n", + }) + ) + } + + await context.reply( + fmt(({ code, n, b }) => [b`✅ Group Updated`, n`chatId: ${code`${args.chatId}`}`], { + sep: "\n", + }) + ) + }, +}) diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index be8f0dd..1a1a64d 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -2,7 +2,8 @@ import { CommandsCollection } from "@/lib/managed-commands" import type { Role } from "@/utils/types" import { audit } from "./audit" import { grants } from "./grants" +import { groups } from "./groups" import { role } from "./role" import { userid } from "./userid" -export const management = new CommandsCollection("Management").withCollection(audit, grants, role, userid) +export const management = new CommandsCollection("Management").withCollection(audit, grants, groups, role, userid) diff --git a/src/lib/group-management/index.ts b/src/lib/group-management/index.ts index a261e2a..a76f655 100644 --- a/src/lib/group-management/index.ts +++ b/src/lib/group-management/index.ts @@ -1,30 +1,161 @@ import type { AppRouter } from "@polinetwork/backend" import type { TRPCClient } from "@trpc/client" -import type { Chat, ChatFullInfo } from "grammy/types" +import type { Chat, ChatFullInfo, User } from "grammy/types" import type { Result } from "neverthrow" import { err, ok } from "neverthrow" import { api } from "@/backend" +import { logger } from "@/logger" +import { modules } from "@/modules" +import { printUsername } from "@/utils/users" + +function stripChatInfo(chat: ChatFullInfo) { + return { + id: chat.id, + title: chat.title, + tag: chat.username, + is_forum: chat.is_forum, + type: chat.type, + invite_link: chat.invite_link, + } +} + +async function errorNoInviteLink(chat: ChatFullInfo, type: "CREATE" | "UPDATE") { + const reason = "Missing invite_link, probably the bot is not admin or does not have permission to invite via link" + logger.error({ chat: stripChatInfo(chat), reason }, `[GroupManagement] Cannot ${type} group`) + await modules.get("tgLogger").groupManagement({ + type: type === "CREATE" ? "CREATE_FAIL" : "UPDATE_FAIL", + chat, + reason, + }) + return err(reason) +} + +async function errorBackend(chat: ChatFullInfo, type: "CREATE" | "UPDATE", fatal: boolean = false) { + if (fatal) logger.fatal("[GroupManagement] HELP! Sent and recieved chatId do not match") + const reason = `${fatal ? "FATAL " : ""}There was an error in the backend` + logger.error({ chat: stripChatInfo(chat), reason }, `[GroupManagement] Cannot ${type} group`) + await modules.get("tgLogger").groupManagement({ + type: type === "CREATE" ? "CREATE_FAIL" : "UPDATE_FAIL", + chat, + inviteLink: chat.invite_link, + reason, + }) + return err(reason) +} type GroupDB = Parameters["tg"]["groups"]["create"]["mutate"]>[0][0] export const GroupManagement = { - async create(chat: ChatFullInfo): Promise> { + async create(chatId: number, addedBy: User): Promise> { + const chat = await modules.shared.api.getChat(chatId).catch(() => null) + if (!chat) { + const reason = "The bot cannot retrieve chat info, probably it is not an administrator" + logger.error({ chatId, reason }, "[GroupManagement] Cannot CREATE group") + await modules.get("tgLogger").exception({ + type: "GENERIC", + error: new Error("Cannot execute GroupManagement.create because the bot cannot fetch the chat from API."), + }) + return err(reason) + } + if (!chat.invite_link) { - return err(`no invite_link, maybe the user does not have permission to "Invite users via link"`) + return errorNoInviteLink(chat, "CREATE") } - const newGroup: GroupDB = { telegramId: chat.id, title: chat.title, link: chat.invite_link } + // chat.username does not start with @ + const newGroup: GroupDB = { telegramId: chat.id, title: chat.title, link: chat.invite_link, tag: chat.username } const res = await api.tg.groups.create.mutate([newGroup]) if (!res.length || res[0] !== chat.id) { - return err(`unknown`) + return errorBackend(chat, "CREATE", res.length >= 1 && res[0] !== chat.id) } + await modules.get("tgLogger").groupManagement({ type: "CREATE", chat, addedBy, inviteLink: chat.invite_link }) + logger.info( + { chat: stripChatInfo(chat), addedBy: printUsername(addedBy) }, + "[GroupManagement] CREATE group success" + ) return ok(newGroup) }, + + async update(chatId: number, requestedBy: User): Promise> { + const chat = await modules.shared.api.getChat(chatId).catch(() => null) + if (!chat) { + const reason = "The bot is not in this group or is not an administrator" + logger.warn({ chatId, reason }, "[GroupManagement] Cannot UPDATE group") + return err(reason) + } + + if (!chat.invite_link) { + return errorNoInviteLink(chat, "UPDATE") + } + + const saved = await api.tg.groups.getById.query({ telegramId: chat.id }).catch(() => null) + if (!saved) { + const reason = "Group with this chatId does not exist in the database." + logger.warn({ chat: stripChatInfo(chat), reason }, "[GroupManagement] Cannot UPDATE group") + return err(reason) + } + + // chat.username does not start with @ + const updatedGroup: GroupDB = { telegramId: chat.id, title: chat.title, link: chat.invite_link, tag: chat.username } + const res = await api.tg.groups.create.mutate([updatedGroup]) + if (!res.length || res[0] !== chat.id) { + return errorBackend(chat, "UPDATE", res.length >= 1 && res[0] !== chat.id) + } + + await modules + .get("tgLogger") + .groupManagement({ type: "UPDATE", chat, addedBy: requestedBy, inviteLink: chat.invite_link }) + logger.info( + { chat: stripChatInfo(chat), requestedBy: printUsername(requestedBy) }, + "[GroupManagement] UPDATE group success" + ) + return ok(updatedGroup) + }, + async delete(chat: Chat): Promise> { const deleted = await api.tg.groups.delete.mutate({ telegramId: chat.id }) - if (!deleted) return err("it probably wasn't there") + if (!deleted) { + const reason = "Group with this chatId does not exist in the database." + logger.warn({ chat, reason }, "[GroupManagement] Cannot DELETE group") + return err(reason) + } + + await modules.get("tgLogger").groupManagement({ type: "DELETE", chat }) + logger.info({ chat }, "[GroupManagement] DELETE group success") return ok() }, + + async checkAdderPermission(chat: Chat, addedBy: User): Promise { + const { allowed } = await api.tg.permissions.canAddBot.query({ userId: addedBy.id }) + if (allowed) { + logger.debug( + { chat, addedBy: printUsername(addedBy), allowed }, + `[GroupManagement] checkAdderPermission result: ALLOWED` + ) + return true + } + + const left = await modules.shared.api.leaveChat(chat.id).catch(() => false) + if (!left) { + await modules.get("tgLogger").groupManagement({ + type: "LEAVE_FAIL", + chat, + addedBy, + }) + logger.error( + { chat, addedBy: printUsername(addedBy), allowed, left }, + `[GroupManagement] checkAdderPermission result: DENIED. Cannot leave unauthorized group` + ) + return false + } + + await modules.get("tgLogger").groupManagement({ type: "LEAVE", chat, addedBy: addedBy }) + logger.warn( + { chat, addedBy: printUsername(addedBy), allowed, left }, + `[GroupManagement] checkAdderPermission result: DENIED. LEFT unauthorized group` + ) + return false + }, } diff --git a/src/middlewares/bot-membership-handler.ts b/src/middlewares/bot-membership-handler.ts index 8230b9f..d2948d0 100644 --- a/src/middlewares/bot-membership-handler.ts +++ b/src/middlewares/bot-membership-handler.ts @@ -1,8 +1,5 @@ -import { Composer, type Filter, InlineKeyboard, type MiddlewareObj } from "grammy" -import { api } from "@/backend" +import { Composer, type Filter, type MiddlewareObj } from "grammy" import { GroupManagement } from "@/lib/group-management" -import { logger } from "@/logger" -import { modules } from "@/modules" import type { Context } from "@/utils/types" type ChatType = "group" | "supergroup" | "private" | "channel" @@ -39,18 +36,18 @@ export class BotMembershipHandler implements MiddlewareObj const newStatus = ctx.myChatMember.new_chat_member.status if (chat.type === "private") return next() - if (this.isJoin(ctx)) { + if (BotMembershipHandler.isJoin(ctx)) { // joined event // go next, if adder has no permission - if (!(await this.checkAdderPermission(ctx))) return next() + if (!(await GroupManagement.checkAdderPermission(ctx.myChatMember.chat, ctx.myChatMember.from))) return next() } if (newStatus === "administrator") { // promoted to admin event - await this.createGroup(ctx) + await GroupManagement.create(ctx.chatId, ctx.myChatMember.from) } else { // not an admin anymore (left, restricted or downgraded) - await this.deleteGroup(ctx) + await GroupManagement.delete(ctx.chat) } await next() @@ -61,75 +58,10 @@ export class BotMembershipHandler implements MiddlewareObj return this.composer.middleware() } - private isJoin(ctx: MemberContext): boolean { + private static isJoin(ctx: MemberContext): boolean { const oldStatusCheck = ["left", "kicked"].includes(ctx.myChatMember.old_chat_member.status) const newStatusCheck = joinEvent[ctx.myChatMember.chat.type].includes(ctx.myChatMember.new_chat_member.status) return oldStatusCheck && newStatusCheck } - - private async checkAdderPermission(ctx: MemberContext): Promise { - const { allowed } = await api.tg.permissions.canAddBot.query({ userId: ctx.myChatMember.from.id }) - if (!allowed) { - const left = await ctx.leaveChat().catch(() => false) - if (left) { - await modules - .get("tgLogger") - .groupManagement({ type: "LEAVE", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from }) - logger.info({ chat: ctx.myChatMember.chat, from: ctx.myChatMember.from }, `[BCE] Left unauthorized group`) - } else { - await modules.get("tgLogger").groupManagement({ - type: "LEAVE_FAIL", - chat: ctx.myChatMember.chat, - addedBy: ctx.myChatMember.from, - }) - logger.error( - { chat: ctx.myChatMember.chat, from: ctx.myChatMember.from }, - `[BCE] Cannot left unauthorized group` - ) - } - } - return allowed - } - - private async deleteGroup(ctx: MemberContext): Promise { - const chat = ctx.myChatMember.chat - const res = await GroupManagement.delete(chat) - await res.match( - async () => { - await modules.get("tgLogger").groupManagement({ type: "DELETE", chat }) - logger.info({ chat }, `[BCE] Deleted a group`) - }, - (e) => { - logger.error({ chat }, `[BCE] Cannot delete group from DB. Reason: ${e}`) - } - ) - } - - private async createGroup(ctx: MemberContext): Promise { - const chat = await ctx.getChat() - const res = await GroupManagement.create(chat) - const logChat = { - id: chat.id, - title: chat.title, - is_forum: chat.is_forum, - type: chat.type, - invite_link: chat.invite_link, - } - - await res.match( - async (g) => { - await modules.get("tgLogger").groupManagement({ type: "CREATE", chat, inviteLink: g.link, addedBy: ctx.from }) - logger.info({ chat: logChat }, `[BCE] Created a new group`) - }, - async (e) => { - const ik = new InlineKeyboard() - if (chat.invite_link) ik.url("Join Group", chat.invite_link) - await modules - .get("tgLogger") - .groupManagement({ type: "CREATE_FAIL", chat, inviteLink: chat.invite_link, reason: e }) - logger.error({ chat: logChat }, `[BCE] Cannot create group into DB. Reason: ${e}`) - } - ) - } } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index ae5a4f1..736e534 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -320,11 +320,12 @@ export class TgLogger extends Module { break case "CREATE": + case "UPDATE": msg = fmt( ({ b, n }) => [ - b`✳ Create`, + props.type === "CREATE" ? b`✳ Create` : b`🔄 Update`, n`${b`Group:`} ${fmtChat(props.chat)}`, - n`${b`Added by:`} ${fmtUser(props.addedBy)}`, + n`${props.type === "CREATE" ? b`Added by:` : b`Requested by:`} ${fmtUser(props.addedBy)}`, ], { sep: "\n", @@ -333,10 +334,11 @@ export class TgLogger extends Module { reply_markup = new InlineKeyboard().url("Join Group", props.inviteLink) break + case "UPDATE_FAIL": case "CREATE_FAIL": msg = fmt( ({ b, n, i }) => [ - b`! Cannot Create`, + b`! Cannot ${props.type === "CREATE_FAIL" ? "Create" : "Update"}`, n`${b`Group:`} ${fmtChat(props.chat)}`, n`${b`Reason`}: ${props.reason}`, i`Check logs for more details`, diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index 55c87d0..f24e26c 100644 --- a/src/modules/tg-logger/types.ts +++ b/src/modules/tg-logger/types.ts @@ -45,12 +45,12 @@ export type GroupManagement = { type: "DELETE" } | { - type: "CREATE" + type: "CREATE" | "UPDATE" addedBy: User inviteLink: string } | { - type: "CREATE_FAIL" + type: "UPDATE_FAIL" | "CREATE_FAIL" reason: string inviteLink?: string }