From e8bc14110081aa598e7b3882aaf7229760afa756 Mon Sep 17 00:00:00 2001 From: rayz1065 Date: Wed, 22 Jan 2025 17:37:05 +0100 Subject: [PATCH 1/7] Moved existing code to separate file, added chat member filters --- src/deps.deno.ts | 16 ++- src/deps.node.ts | 16 ++- src/filters.test.ts | 300 ++++++++++++++++++++++++++++++++++++++++++++ src/filters.ts | 244 +++++++++++++++++++++++++++++++++++ src/mod.ts | 171 +------------------------ src/storage.ts | 169 +++++++++++++++++++++++++ 6 files changed, 743 insertions(+), 173 deletions(-) create mode 100644 src/filters.test.ts create mode 100644 src/filters.ts create mode 100644 src/storage.ts diff --git a/src/deps.deno.ts b/src/deps.deno.ts index 70ebb4c..5939b6d 100644 --- a/src/deps.deno.ts +++ b/src/deps.deno.ts @@ -1,2 +1,14 @@ -export { Composer, Context, type StorageAdapter } from "https://lib.deno.dev/x/grammy@v1/mod.ts"; -export type { Chat, ChatMember, ChatMemberUpdated, User } from "https://lib.deno.dev/x/grammy@v1/types.ts"; +export { Api, Composer, Context, type Filter, type StorageAdapter } from "https://lib.deno.dev/x/grammy@v1/mod.ts"; +export type { + Chat, + ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, + ChatMemberUpdated, + User, + UserFromGetMe, +} from "https://lib.deno.dev/x/grammy@v1/types.ts"; diff --git a/src/deps.node.ts b/src/deps.node.ts index 86cd7d7..886e07f 100644 --- a/src/deps.node.ts +++ b/src/deps.node.ts @@ -1,2 +1,14 @@ -export { Composer, Context, type StorageAdapter } from "grammy"; -export type { Chat, ChatMember, ChatMemberUpdated, User } from "grammy/types"; +export { Api, Composer, Context, type Filter, type StorageAdapter } from "grammy"; +export type { + Chat, + ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, + ChatMemberUpdated, + User, + UserFromGetMe, +} from "grammy/types"; diff --git a/src/filters.test.ts b/src/filters.test.ts new file mode 100644 index 0000000..c849c5e --- /dev/null +++ b/src/filters.test.ts @@ -0,0 +1,300 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { + Api, + type ChatMember, + type ChatMemberAdministrator, + type ChatMemberBanned, + type ChatMemberLeft, + type ChatMemberMember, + type ChatMemberOwner, + type ChatMemberRestricted, + type ChatMemberUpdated, + Context, + type UserFromGetMe, +} from "./deps.deno.ts"; +import { + type ChatMemberAdmin, + chatMemberFilter, + type ChatMemberFree, + type ChatMemberIn, + chatMemberIs, + type ChatMemberOut, + ChatMemberQuery, + type ChatMemberRegular, + type ChatMemberRestrictedIn, + type ChatMemberRestrictedOut, + type FilteredChatMember, + myChatMemberFilter, +} from "./filters.ts"; + +type ChatMemberStatusBase = + | Exclude + | "restricted_in" + | "restricted_out"; + +Deno.test("filter queries should produce the correct type", () => { + type Expect = T; + type Equal = (() => T extends X ? 1 : 2) extends < + T, + >() => T extends Y ? 1 : 2 ? true + : false; + + type AdminTest = Expect< + Equal, ChatMemberAdmin> + >; + type AdministratorTest = Expect< + Equal< + FilteredChatMember, + ChatMemberAdministrator + > + >; + type CreatorTest = Expect< + Equal, ChatMemberOwner> + >; + type FreeTest = Expect< + Equal, ChatMemberFree> + >; + type InTest = Expect< + Equal, ChatMemberIn> + >; + type OutTest = Expect< + Equal, ChatMemberOut> + >; + type Foo = FilteredChatMember; + type RegularTest = Expect< + Equal, ChatMemberRegular> + >; + type KickedTest = Expect< + Equal, ChatMemberBanned> + >; + type LeftTest = Expect< + Equal, ChatMemberLeft> + >; + type MemberTest = Expect< + Equal, ChatMemberMember> + >; + type RestrictedTest = Expect< + Equal, ChatMemberRestricted> + >; + type RestrictedInTest = Expect< + Equal< + FilteredChatMember, + ChatMemberRestrictedIn + > + >; + type RestrictedOutTest = Expect< + Equal< + FilteredChatMember, + ChatMemberRestrictedOut + > + >; +}); + +Deno.test("should apply query to chat member", () => { + const results: Record< + ChatMemberStatusBase, + Record< + Exclude, + boolean + > + > = { + administrator: { + in: true, + out: false, + free: true, + admin: true, + regular: false, + restricted_in: false, + restricted_out: false, + }, + creator: { + in: true, + out: false, + free: true, + admin: true, + regular: false, + restricted_in: false, + restricted_out: false, + }, + member: { + in: true, + out: false, + free: true, + admin: false, + regular: true, + restricted_in: false, + restricted_out: false, + }, + restricted_in: { + in: true, + out: false, + free: false, + admin: false, + regular: true, + restricted_in: true, + restricted_out: false, + }, + restricted_out: { + in: false, + out: true, + free: false, + admin: false, + regular: false, + restricted_in: false, + restricted_out: true, + }, + left: { + in: false, + out: true, + free: false, + admin: false, + regular: false, + restricted_in: false, + restricted_out: false, + }, + kicked: { + in: false, + out: true, + free: false, + admin: false, + regular: false, + restricted_in: false, + restricted_out: false, + }, + } as const; + + const statuses: ChatMember["status"][] = [ + "administrator", + "creator", + "kicked", + "left", + "member", + "restricted", + ]; + const baseStatuses = Object.keys(results) as ChatMemberStatusBase[]; + baseStatuses.forEach((status) => { + const chatMember = (status === "restricted_in" + ? { status: "restricted", is_member: true } + : status === "restricted_out" + ? { status: "restricted", is_member: false } + : { status }) as ChatMember; + const statusResults = results[status]; + + const queries = Object.keys( + results[status], + ) as (keyof typeof statusResults)[]; + queries.forEach((query) => { + assertEquals(chatMemberIs(chatMember, query), statusResults[query]); + }); + + statuses.forEach((query) => { + assertEquals( + chatMemberIs(chatMember, query), + chatMember.status === query, + ); + }); + }); +}); + +Deno.test("should filter myChatMember", () => { + const administratorKickedCtx = new Context( + { + update_id: 123, + my_chat_member: { + old_chat_member: { status: "administrator" }, + new_chat_member: { status: "kicked" }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + const administratorKickedFilters = [ + ["administrator", "kicked", true], + ["administrator", "out", true], + ["admin", "kicked", true], + ["admin", "out", true], + ["in", "out", true], + ["regular", "kicked", false], + ["member", "out", false], + ["administrator", "member", false], + ["admin", "in", false], + ["out", "in", false], + ] as const; + + administratorKickedFilters.forEach(([oldStatus, newStatus, expected]) => { + const filter = myChatMemberFilter(oldStatus, newStatus); + assertEquals(filter(administratorKickedCtx), expected); + }); +}); + +Deno.test("should filter chatMember", () => { + const leftRestrictedInCtx = new Context( + { + update_id: 123, + chat_member: { + old_chat_member: { status: "left" }, + new_chat_member: { status: "restricted", is_member: true }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + const administratorKickedFilters = [ + ["left", "restricted", true], + ["restricted", "left", false], + ["out", "in", true], + ["in", "out", false], + ["out", "admin", false], + ["kicked", "restricted", false], + ["out", "free", false], + ["kicked", "member", false], + ["member", "out", false], + ] as const; + + administratorKickedFilters.forEach(([oldStatus, newStatus, expected]) => { + const filter = chatMemberFilter(oldStatus, newStatus); + assertEquals(filter(leftRestrictedInCtx), expected); + }); +}); + +Deno.test("should filter out other types of updates", () => { + const administratorAdministratorCtx = new Context( + { + update_id: 123, + chat_member: { + old_chat_member: { status: "administrator" }, + new_chat_member: { status: "administrator" }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + assertEquals( + myChatMemberFilter("admin", "admin")(administratorAdministratorCtx), + false, + ); + assertEquals( + chatMemberFilter("admin", "admin")(administratorAdministratorCtx), + true, + ); + + const memberRestrictedCtx = new Context( + { + update_id: 123, + my_chat_member: { + old_chat_member: { status: "member" }, + new_chat_member: { status: "restricted", is_member: true }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + assertEquals( + myChatMemberFilter("free", "restricted")(memberRestrictedCtx), + true, + ); + assertEquals( + chatMemberFilter("free", "restricted")(memberRestrictedCtx), + false, + ); +}); diff --git a/src/filters.ts b/src/filters.ts new file mode 100644 index 0000000..79e08fe --- /dev/null +++ b/src/filters.ts @@ -0,0 +1,244 @@ +import type { + ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, + Context, + Filter, +} from "./deps.deno.ts"; + +/** + * The 'restricted' status is ambiguous, since it can refer both to a member in + * the group and a member out of the group. + * To avoid ambiguity we split the restricted role into "restricted_in" and + * "restricted_out". + */ + +/** + * A member of the chat, with restrictions applied. + */ +export type ChatMemberRestrictedIn = ChatMemberRestricted & { is_member: true }; +/** + * Not a member of the chat, with restrictions applied. + */ +export type ChatMemberRestrictedOut = ChatMemberRestricted & { + is_member: false; +}; +/** + * A member of the chat, with any role, possibly restricted. + */ +export type ChatMemberIn = + | ChatMemberAdministrator + | ChatMemberOwner + | ChatMemberRestrictedIn + | ChatMemberMember; +/** + * Not a member of the chat + */ +export type ChatMemberOut = + | ChatMemberBanned + | ChatMemberLeft + | ChatMemberRestrictedOut; +/** + * A member of the chat, with any role, not restricted. + */ +export type ChatMemberFree = + | ChatMemberAdministrator + | ChatMemberOwner + | ChatMemberMember; +/** + * An admin of the chat, either administrator or owner. + */ +export type ChatMemberAdmin = ChatMemberAdministrator | ChatMemberOwner; +/** + * A regular (non-admin) user of the chat, possibly restricted. + */ +export type ChatMemberRegular = ChatMemberRestrictedIn | ChatMemberMember; +/** + * Query type for chat member status. + */ +export type ChatMemberQuery = + | "in" + | "out" + | "free" + | "admin" + | "regular" + | "restricted_in" + | "restricted_out" + | ChatMember["status"]; + +/** + * Used to normalize queries to the simplest components. + */ +const chatMemberQueries = { + admin: ["administrator", "creator"], + administrator: ["administrator"], + creator: ["creator"], + free: ["administrator", "creator", "member"], + in: ["administrator", "creator", "member", "restricted_in"], + out: ["kicked", "left", "restricted_out"], + regular: ["member", "restricted_in"], + kicked: ["kicked"], + left: ["left"], + member: ["member"], + restricted: ["restricted"], + restricted_in: ["restricted_in"], + restricted_out: ["restricted_out"], +} as const satisfies Record< + ChatMemberQuery, + (ChatMember["status"] | "restricted_in" | "restricted_out")[] +>; + +/** + * Maps from the query to the corresponding type. + */ +type ChatMemberQueriesMap = { + admin: ChatMemberAdmin; + administrator: ChatMemberAdministrator; + creator: ChatMemberOwner; + free: ChatMemberFree; + in: ChatMemberIn; + out: ChatMemberOut; + regular: ChatMemberRegular; + kicked: ChatMemberBanned; + left: ChatMemberLeft; + member: ChatMemberMember; + restricted: ChatMemberRestricted; + restricted_in: ChatMemberRestrictedIn; + restricted_out: ChatMemberRestrictedOut; +}; + +type NormalizeChatMemberQueryCore = (typeof chatMemberQueries)[Q][number]; + +type MaybeArray = T | T[]; +type NormalizeChatMemberQuery< + Q extends ChatMemberQuery, +> = Q extends ChatMemberQuery ? NormalizeChatMemberQueryCore + : (Q extends ChatMemberQuery[] ? NormalizeChatMemberQuery + : never); +export type FilteredChatMember< + C extends ChatMember, + Q extends ChatMemberQuery, +> = C & ChatMemberQueriesMap[NormalizeChatMemberQuery]; + +/** + * Normalizes the query, returning the corresponding list of chat member + * statuses. + */ +function normalizeChatMemberQuery( + query: MaybeArray, +): NormalizeChatMemberQuery[] { + if (Array.isArray(query)) { + const res = new Set( + query.flatMap(normalizeChatMemberQuery), + ); + return [...res] as NormalizeChatMemberQuery[]; + } + + return [ + ...chatMemberQueries[query], + ] as NormalizeChatMemberQuery[]; +} + +export function chatMemberIs< + C extends ChatMember, + Q extends ChatMemberQuery, +>( + chatMember: C, + query: MaybeArray, +): chatMember is FilteredChatMember { + const roles = normalizeChatMemberQuery(query); + + if (chatMember.status === "restricted") { + if (roles.includes("restricted" as (typeof roles)[number])) { + return true; + } else if (chatMember.is_member) { + return roles.includes("restricted_in" as (typeof roles)[number]); + } else { + return roles.includes("restricted_out" as (typeof roles)[number]); + } + } + + return roles.includes(chatMember.status as (typeof roles)[number]); +} + +/** + * Filter context to only find updates of type 'my_chat_member' where the status + * transitions from oldStatus to newStatus. + * + * Example: + * ```typescript + * // listen for updates where the bot enters a group/supergroup + * bot.chatType(['group', 'supergroup']).filter( + * myChatMemberFilter('out', 'in'), + * (ctx) => { + * const { old_chat_member: oldChatMember, new_chat_member: newChatMember } = + * ctx.myChatMember; + * // ... + * }, + * ); + */ +export function myChatMemberFilter< + C extends Context, + Q1 extends ChatMemberQuery, + Q2 extends ChatMemberQuery, +>(oldStatus: MaybeArray, newStatus: MaybeArray) { + return ( + ctx: C, + ): ctx is Filter & { + myChatMember: { + old_chat_member: FilteredChatMember; + new_chat_member: FilteredChatMember; + }; + } => { + return ( + ctx.has("my_chat_member") && + chatMemberIs(ctx.myChatMember.old_chat_member, oldStatus) && + chatMemberIs(ctx.myChatMember.new_chat_member, newStatus) + ); + }; +} + +/** + * Filter context to only find updates of type 'chat_member' where the status + * transitions from oldStatus to newStatus. + * + * Example: + * ```typescript + * // listen for updates where a user leaves a channel + * bot.chatType('channel').filter( + * chatMemberFilter('in', 'out'), + * (ctx) => { + * const { old_chat_member: oldChatMember, new_chat_member: newChatMember } = + * ctx.chatMember; + * // ... + * }, + * ); + * ``` + * + * **Note**: To receive these updates the bot must be admin in the chat **and** + * you must add 'chat_member' to the list of allowed updates. + */ +export function chatMemberFilter< + C extends Context, + Q1 extends ChatMemberQuery, + Q2 extends ChatMemberQuery, +>(oldStatus: MaybeArray, newStatus: MaybeArray) { + return ( + ctx: C, + ): ctx is Filter & { + chatMember: { + old_chat_member: FilteredChatMember; + new_chat_member: FilteredChatMember; + }; + } => { + return ( + ctx.has("chat_member") && + chatMemberIs(ctx.chatMember.old_chat_member, oldStatus) && + chatMemberIs(ctx.chatMember.new_chat_member, newStatus) + ); + }; +} diff --git a/src/mod.ts b/src/mod.ts index eb66ea5..7cb7ff2 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,169 +1,2 @@ -import { Chat, ChatMember, Composer, Context, StorageAdapter, User } from "./deps.deno.ts"; - -export type ChatMembersFlavor = { - /** - * Namespace of the `chat-members` plugin - */ - chatMembers: { - /** - * Tries to obtain information about a member of a chat. If that information is already known, - * no API calls are made. - * - * If the information is not yet known, calls `ctx.api.getChatMember`, saves the result to storage and returns it. - * - * @param chatId Chat in which to look for the user - * @param userId Id of the user to get information about - * @returns Information about the status of the user on the given chat - */ - getChatMember: (chatId?: string | number, userId?: number) => Promise; - }; -}; - -type ChatMembersContext = Context & ChatMembersFlavor; - -export type ChatMembersOptions = { - /** - * Prevents deletion of members when - * bot receives a LeftChatMember update - */ - keepLeftChatMembers: boolean; - /** - * This option will install middleware to cache chat members without depending on the - * `chat_member` event. For every update, the middleware checks if `ctx.chat` and `ctx.from` exist. If they both do, it - * then proceeds to call `ctx.chatMembers.getChatMember` to add the chat member information to the storage in case it - * doesn't exist. - * - * Enabling this automatically enables caching. - * - * Please note that, if you manually disable caching, the storage will be called for **every update**, which may be a lot, depending on how many - * updates your bot receives. This also has the potential to impact the performance of your bot drastically. Only use this - * if you _really_ know what you're doing and are ok with the risks and consequences. - */ - enableAggressiveStorage: boolean; - /** - * Enables caching of chat members. This can be useful to avoid unnecessary API calls - * when the same user is queried multiple times in a short period of time. - * - * Enabled by default when using aggressive storage. - */ - enableCaching: boolean; - /** - * Function used to determine the key fo a given user and chat - * The default implementation uses a combination of - * chat and user ids in the format `chatId_userId`, - * which will store a user as much times as they join chats. - * - * If you wish to store users only once, regardless of chat, - * you can use a function that considers only the user id, like so: - * - * ```typescript - * bot.use(chatMembers(adapter, { getKey: update => update.new_chat_member.user.id }})); - * ``` - * - * Keep in mind that, if you do that but don't set `keepLeftChatMembers` to `true`, - * a user will be deleted from storage when they leave any chat, even if they're still a member of - * another chat where the bot is present. - */ - getKey: (chatId: string | number, userId: number) => string; -}; - -function defaultKeyStrategy(chatId: string | number, userId: number) { - return `${chatId}_${userId}`; -} - -/** - * Creates a middleware that keeps track of chat member updates - * - * **NOTE**: You need to manually enable `chat_members` update type for this to work - * - * Example usage: - * - * ```typescript - * const bot = new Bot(""); - * const adapter = new MemorySessionStorage(); - * - * bot.use(chatMembers(adapter)); - * - * bot.start({ allowed_updates: ["chat_member"] }); - * ``` - * @param adapter Storage adapter responsible for saving members information - * @param options Configuration options for the middleware - * @returns A middleware that keeps track of chat member updates - */ -export function chatMembers( - adapter: StorageAdapter, - options: Partial = {}, -): Composer { - const { - keepLeftChatMembers = false, - enableAggressiveStorage = false, - getKey = defaultKeyStrategy, - enableCaching = enableAggressiveStorage, - } = options; - - const cache = new Map(); - const composer = new Composer(); - - composer.use((ctx, next) => { - ctx.chatMembers = { - getChatMember: async (chatId = ctx.chat?.id ?? undefined, userId = ctx.from?.id ?? undefined) => { - if (!userId) throw new Error("ctx.from is undefined and no userId was provided"); - if (!chatId) throw new Error("ctx.chat is undefined and no chatId was provided"); - - const key = getKey(chatId, userId); - - const cachedChatMember = enableCaching ? cache.get(key) : undefined; - if (cachedChatMember) return cachedChatMember.value; - - const dbChatMember = await adapter.read(key); - - if (dbChatMember) { - if (enableCaching) cache.set(key, { timestamp: Date.now(), value: dbChatMember }); - return dbChatMember; - } - - const chatMember = await ctx.api.getChatMember(chatId, userId); - - if (enableCaching) cache.set(key, { timestamp: Date.now(), value: chatMember }); - await adapter.write(key, chatMember); - - return chatMember; - }, - }; - - return next(); - }); - - composer.on("chat_member", async (ctx, next) => { - const key = getKey(ctx.chatMember.chat.id, ctx.chatMember.new_chat_member.user.id); - const status = ctx.chatMember.new_chat_member.status; - - const DELETE_STATUS = ["left", "kicked"]; - - if (DELETE_STATUS.includes(status) && !keepLeftChatMembers) { - if (enableCaching) cache.delete(key); - if (await adapter.read(key)) await adapter.delete(key); - - return next(); - } - - if (enableCaching) { - cache.set(key, { timestamp: Date.now(), value: ctx.chatMember.new_chat_member }); - } - await adapter.write(key, ctx.chatMember.new_chat_member); - return next(); - }); - - composer - .filter(() => enableAggressiveStorage) - .filter((ctx): ctx is ChatMembersContext & { chat: Chat; from: User } => Boolean(ctx.chat) && Boolean(ctx.from)) - .use(async (ctx, next) => { - await ctx.chatMembers.getChatMember(); - - return next(); - }); - - return composer; -} - -export default { chatMembers }; +export * from "./filters.ts"; +export * from "./storage.ts"; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..eb66ea5 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,169 @@ +import { Chat, ChatMember, Composer, Context, StorageAdapter, User } from "./deps.deno.ts"; + +export type ChatMembersFlavor = { + /** + * Namespace of the `chat-members` plugin + */ + chatMembers: { + /** + * Tries to obtain information about a member of a chat. If that information is already known, + * no API calls are made. + * + * If the information is not yet known, calls `ctx.api.getChatMember`, saves the result to storage and returns it. + * + * @param chatId Chat in which to look for the user + * @param userId Id of the user to get information about + * @returns Information about the status of the user on the given chat + */ + getChatMember: (chatId?: string | number, userId?: number) => Promise; + }; +}; + +type ChatMembersContext = Context & ChatMembersFlavor; + +export type ChatMembersOptions = { + /** + * Prevents deletion of members when + * bot receives a LeftChatMember update + */ + keepLeftChatMembers: boolean; + /** + * This option will install middleware to cache chat members without depending on the + * `chat_member` event. For every update, the middleware checks if `ctx.chat` and `ctx.from` exist. If they both do, it + * then proceeds to call `ctx.chatMembers.getChatMember` to add the chat member information to the storage in case it + * doesn't exist. + * + * Enabling this automatically enables caching. + * + * Please note that, if you manually disable caching, the storage will be called for **every update**, which may be a lot, depending on how many + * updates your bot receives. This also has the potential to impact the performance of your bot drastically. Only use this + * if you _really_ know what you're doing and are ok with the risks and consequences. + */ + enableAggressiveStorage: boolean; + /** + * Enables caching of chat members. This can be useful to avoid unnecessary API calls + * when the same user is queried multiple times in a short period of time. + * + * Enabled by default when using aggressive storage. + */ + enableCaching: boolean; + /** + * Function used to determine the key fo a given user and chat + * The default implementation uses a combination of + * chat and user ids in the format `chatId_userId`, + * which will store a user as much times as they join chats. + * + * If you wish to store users only once, regardless of chat, + * you can use a function that considers only the user id, like so: + * + * ```typescript + * bot.use(chatMembers(adapter, { getKey: update => update.new_chat_member.user.id }})); + * ``` + * + * Keep in mind that, if you do that but don't set `keepLeftChatMembers` to `true`, + * a user will be deleted from storage when they leave any chat, even if they're still a member of + * another chat where the bot is present. + */ + getKey: (chatId: string | number, userId: number) => string; +}; + +function defaultKeyStrategy(chatId: string | number, userId: number) { + return `${chatId}_${userId}`; +} + +/** + * Creates a middleware that keeps track of chat member updates + * + * **NOTE**: You need to manually enable `chat_members` update type for this to work + * + * Example usage: + * + * ```typescript + * const bot = new Bot(""); + * const adapter = new MemorySessionStorage(); + * + * bot.use(chatMembers(adapter)); + * + * bot.start({ allowed_updates: ["chat_member"] }); + * ``` + * @param adapter Storage adapter responsible for saving members information + * @param options Configuration options for the middleware + * @returns A middleware that keeps track of chat member updates + */ +export function chatMembers( + adapter: StorageAdapter, + options: Partial = {}, +): Composer { + const { + keepLeftChatMembers = false, + enableAggressiveStorage = false, + getKey = defaultKeyStrategy, + enableCaching = enableAggressiveStorage, + } = options; + + const cache = new Map(); + const composer = new Composer(); + + composer.use((ctx, next) => { + ctx.chatMembers = { + getChatMember: async (chatId = ctx.chat?.id ?? undefined, userId = ctx.from?.id ?? undefined) => { + if (!userId) throw new Error("ctx.from is undefined and no userId was provided"); + if (!chatId) throw new Error("ctx.chat is undefined and no chatId was provided"); + + const key = getKey(chatId, userId); + + const cachedChatMember = enableCaching ? cache.get(key) : undefined; + if (cachedChatMember) return cachedChatMember.value; + + const dbChatMember = await adapter.read(key); + + if (dbChatMember) { + if (enableCaching) cache.set(key, { timestamp: Date.now(), value: dbChatMember }); + return dbChatMember; + } + + const chatMember = await ctx.api.getChatMember(chatId, userId); + + if (enableCaching) cache.set(key, { timestamp: Date.now(), value: chatMember }); + await adapter.write(key, chatMember); + + return chatMember; + }, + }; + + return next(); + }); + + composer.on("chat_member", async (ctx, next) => { + const key = getKey(ctx.chatMember.chat.id, ctx.chatMember.new_chat_member.user.id); + const status = ctx.chatMember.new_chat_member.status; + + const DELETE_STATUS = ["left", "kicked"]; + + if (DELETE_STATUS.includes(status) && !keepLeftChatMembers) { + if (enableCaching) cache.delete(key); + if (await adapter.read(key)) await adapter.delete(key); + + return next(); + } + + if (enableCaching) { + cache.set(key, { timestamp: Date.now(), value: ctx.chatMember.new_chat_member }); + } + await adapter.write(key, ctx.chatMember.new_chat_member); + return next(); + }); + + composer + .filter(() => enableAggressiveStorage) + .filter((ctx): ctx is ChatMembersContext & { chat: Chat; from: User } => Boolean(ctx.chat) && Boolean(ctx.from)) + .use(async (ctx, next) => { + await ctx.chatMembers.getChatMember(); + + return next(); + }); + + return composer; +} + +export default { chatMembers }; From 7ab53b1ae02b5d6e34b16c8d1a629ffc95b1443e Mon Sep 17 00:00:00 2001 From: rayz1065 Date: Wed, 22 Jan 2025 18:31:45 +0100 Subject: [PATCH 2/7] Updated README, including info about chat member filters --- src/README.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 6 deletions(-) diff --git a/src/README.md b/src/README.md index ea96972..1781323 100644 --- a/src/README.md +++ b/src/README.md @@ -1,10 +1,132 @@ # Chat members plugin for grammY -This plugin watches for `chat_member` updates and stores a list of users, their statuses and permissions for each chat -in which they and the bot are a member. +This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the +form of custom filters, and by storing and updating the objects. ## Usage +### Chat member filters + +You can listen for two kinds of updates regarding chat members using a telegram bot: `chat_member` and `my_chat_member`, +both of them specify the old and new status of the user. + +- `my_chat_member` updates are received by your bot by default and they inform you about the status of the bot being + updated in any chat, as well as users blocking the bot; +- `chat_member` updates are only received if you specifically include them in the list of allowed updates, they notify + about any status changes for users in chats **where your bot is admin**. + +Filters specify the status before and after the change, allowing you to react to every type of transition you're +interested in. Within the handler, types of `old_chat_member` and `new_chat_member` are updated accordingly. + +```typescript +const bot = new Bot(process.env.BOT_TOKEN!); +const groups = bot.chatType(["group", "supergroup"]); + +groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => { + await ctx.reply("Hello, thank you for adding me to the group!"); +}); + +groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => { + await ctx.reply("Hello, thank you for adding me to the group as admin!"); +}); + +groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => { + await ctx.reply("I was promoted to admin!"); +}); + +groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => { + await ctx.reply("I am no longer admin"); +}); + +groups.filter(chatMemberFilter("out", "in"), async (ctx) => { + const user = ctx.chatMember.new_chat_member.user; + await ctx.reply( + `Welcome ${escapeHtml(user.first_name)} to the group!`, + { parse_mode: "HTML" }, + ); +}); + +bot.start({ + allowed_updates: [...DEFAULT_UPDATE_TYPES, "chat_member"], + onStart: (me) => console.log("Listening to updates as", me.username), +}); +``` + +Filters include the regular Telegram statuses (owner, administrator, member, restricted, left, kicked) and some +additional ones for convenience: + +- restricted_in: a member of the chat with restrictions; +- restricted_out: not a member of the chat, has restrictions; +- in: a member of the chat (administrator, creator, member, restricted_in); +- out: not a member of the chat (left, kicked, restricted_out); +- free: a member of the chat that isn't restricted (administrator, creator, member); +- admin: an admin of the chat (administrator, creator); +- regular: a non-admin member of the chat (member, restricted_in). + +You can create your custom groupings of chat member types by passing an array instead of a string: + +```typescript +groups.filter( + chatMemberFilter(["restricted", "kicked"], ["free", "left"]), + async (ctx) => { + const from = ctx.from; + const { status: oldStatus, user } = ctx.chatMember.old_chat_member; + await ctx.reply( + `${escapeHtml(from.first_name)} lifted ` + + `${oldStatus === "kicked" ? "ban" : "restrictions"} ` + + `from ${escapeHtml(user.first_name)}`, + { parse_mode: "HTML" }, + ); + }, +); +``` + +#### Example usage + +The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then +make a table of the transitions between them: + +| ↱ | Out | Regular | Admin | +| ----------- | ----------- | -------------------- | ------------------- | +| **Out** | ban-changed | join | join-and-promoted | +| **Regular** | exit | restrictions-changed | promoted | +| **Admin** | exit | demoted | permissions-changed | + +Assign a listener to all the transitions that are relevant to your use-case. + +Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat. Add a middleware +to listen to all updates as a way to perform common operations (like updating your database) before handing off control +to a specific handler. + +```typescript +const groups = bot.chatType(["group", "supergroup"]); + +groups.on("chat_member", (ctx, next) => { + // ran on all updates of type chat_member + const { + old_chat_member: { status: oldStatus }, + new_chat_member: { user, status }, + from, + chat, + } = ctx.chatMember; + console.log( + `In group ${chat.id} user ${from.id} changed status of ${user.id}:`, + `${oldStatus} -> ${status}`, + ); + + // update database data here + + return next(); +}); + +// specific handlers + +groups.filter(chatMemberFilter("out", "in"), async (ctx, next) => { + const { new_chat_member: { user } } = ctx.chatMember; + await ctx.reply(`Welcome ${user.first_name}!`); +}); +``` + ### Storing chat members You can use a valid grammY [storage adapter](https://grammy.dev/plugins/session.html#known-storage-adapters) or an @@ -25,8 +147,8 @@ const bot = new Bot(""); bot.use(chatMembers(adapter)); bot.start({ - allowed_updates: ["chat_member", "message"], - onStart: ({ username }) => console.log(`Listening as ${username}`), + allowed_updates: ["chat_member", "message"], + onStart: ({ username }) => console.log(`Listening as ${username}`), }); ``` @@ -41,9 +163,9 @@ Here's an example: ```typescript bot.on("message", async (ctx) => { - const chatMember = await ctx.chatMembers.getChatMember(); + const chatMember = await ctx.chatMembers.getChatMember(); - return ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); + return ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); }); ``` From 8eb7b309af0e650eeb7fc17c217ec189b9ceb3e4 Mon Sep 17 00:00:00 2001 From: rayz1065 Date: Wed, 22 Jan 2025 22:50:42 +0100 Subject: [PATCH 3/7] Sync changes in README --- README.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cda6053..5f44fe1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,132 @@ # Chat members plugin for grammY -This plugin watches for `chat_member` updates and stores a list of users, their statuses and permissions for each chat -in which they and the bot are a member. +This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the +form of custom filters, and by storing and updating the objects. ## Usage +### Chat member filters + +You can listen for two kinds of updates regarding chat members using a telegram bot: `chat_member` and `my_chat_member`, +both of them specify the old and new status of the user. + +- `my_chat_member` updates are received by your bot by default and they inform you about the status of the bot being + updated in any chat, as well as users blocking the bot; +- `chat_member` updates are only received if you specifically include them in the list of allowed updates, they notify + about any status changes for users in chats **where your bot is admin**. + +Filters specify the status before and after the change, allowing you to react to every type of transition you're +interested in. Within the handler, types of `old_chat_member` and `new_chat_member` are updated accordingly. + +```typescript +const bot = new Bot(process.env.BOT_TOKEN!); +const groups = bot.chatType(["group", "supergroup"]); + +groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => { + await ctx.reply("Hello, thank you for adding me to the group!"); +}); + +groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => { + await ctx.reply("Hello, thank you for adding me to the group as admin!"); +}); + +groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => { + await ctx.reply("I was promoted to admin!"); +}); + +groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => { + await ctx.reply("I am no longer admin"); +}); + +groups.filter(chatMemberFilter("out", "in"), async (ctx) => { + const user = ctx.chatMember.new_chat_member.user; + await ctx.reply( + `Welcome ${escapeHtml(user.first_name)} to the group!`, + { parse_mode: "HTML" }, + ); +}); + +bot.start({ + allowed_updates: [...DEFAULT_UPDATE_TYPES, "chat_member"], + onStart: (me) => console.log("Listening to updates as", me.username), +}); +``` + +Filters include the regular Telegram statuses (owner, administrator, member, restricted, left, kicked) and some +additional ones for convenience: + +- restricted_in: a member of the chat with restrictions; +- restricted_out: not a member of the chat, has restrictions; +- in: a member of the chat (administrator, creator, member, restricted_in); +- out: not a member of the chat (left, kicked, restricted_out); +- free: a member of the chat that isn't restricted (administrator, creator, member); +- admin: an admin of the chat (administrator, creator); +- regular: a non-admin member of the chat (member, restricted_in). + +You can create your custom groupings of chat member types by passing an array instead of a string: + +```typescript +groups.filter( + chatMemberFilter(["restricted", "kicked"], ["free", "left"]), + async (ctx) => { + const from = ctx.from; + const { status: oldStatus, user } = ctx.chatMember.old_chat_member; + await ctx.reply( + `${escapeHtml(from.first_name)} lifted ` + + `${oldStatus === "kicked" ? "ban" : "restrictions"} ` + + `from ${escapeHtml(user.first_name)}`, + { parse_mode: "HTML" }, + ); + }, +); +``` + +#### Example usage + +The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then +make a table of the transitions between them: + +| ↱ | Out | Regular | Admin | +| ----------- | ----------- | -------------------- | ------------------- | +| **Out** | ban-changed | join | join-and-promoted | +| **Regular** | exit | restrictions-changed | promoted | +| **Admin** | exit | demoted | permissions-changed | + +Assign a listener to all the transitions that are relevant to your use-case. + +Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat. Add a middleware +to listen to all updates as a way to perform common operations (like updating your database) before handing off control +to a specific handler. + +```typescript +const groups = bot.chatType(["group", "supergroup"]); + +groups.on("chat_member", (ctx, next) => { + // ran on all updates of type chat_member + const { + old_chat_member: { status: oldStatus }, + new_chat_member: { user, status }, + from, + chat, + } = ctx.chatMember; + console.log( + `In group ${chat.id} user ${from.id} changed status of ${user.id}:`, + `${oldStatus} -> ${status}`, + ); + + // update database data here + + return next(); +}); + +// specific handlers + +groups.filter(chatMemberFilter("out", "in"), async (ctx, next) => { + const { new_chat_member: { user } } = ctx.chatMember; + await ctx.reply(`Welcome ${user.first_name}!`); +}); +``` + ### Storing chat members You can use a valid grammY [storage adapter](https://grammy.dev/plugins/session.html#known-storage-adapters) or an @@ -25,8 +147,8 @@ const bot = new Bot(""); bot.use(chatMembers(adapter)); bot.start({ - allowed_updates: ["chat_member", "message"], - onStart: ({ username }) => console.log(`Listening as ${username}`), + allowed_updates: ["chat_member", "message"], + onStart: ({ username }) => console.log(`Listening as ${username}`), }); ``` @@ -41,9 +163,9 @@ Here's an example: ```typescript bot.on("message", async (ctx) => { - const chatMember = await ctx.chatMembers.getChatMember(); + const chatMember = await ctx.chatMembers.getChatMember(); - return ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); + return ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); }); ``` From deb0db05840efdba77b5efd78314e70848b8c7c7 Mon Sep 17 00:00:00 2001 From: rayz <37779815+rayz1065@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:49:35 +0000 Subject: [PATCH 4/7] Apply suggestions from code review Co-authored-by: KnorpelSenf --- README.md | 10 ++++------ src/README.md | 2 +- src/filters.ts | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5f44fe1..54d8b03 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,7 @@ bot.start({ }); ``` -Filters include the regular Telegram statuses (owner, administrator, member, restricted, left, kicked) and some -additional ones for convenience: +Filters include the regular Telegram statuses (owner, administrator, member, restricted, left, kicked) and some additional ones for convenience: - restricted_in: a member of the chat with restrictions; - restricted_out: not a member of the chat, has restrictions; @@ -81,10 +80,9 @@ groups.filter( ); ``` -#### Example usage +#### Example Usage -The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then -make a table of the transitions between them: +The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then make a table of the transitions between them: | ↱ | Out | Regular | Admin | | ----------- | ----------- | -------------------- | ------------------- | @@ -116,7 +114,7 @@ groups.on("chat_member", (ctx, next) => { // update database data here - return next(); + await next(); }); // specific handlers diff --git a/src/README.md b/src/README.md index 1781323..d382b78 100644 --- a/src/README.md +++ b/src/README.md @@ -165,7 +165,7 @@ Here's an example: bot.on("message", async (ctx) => { const chatMember = await ctx.chatMembers.getChatMember(); - return ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); + await ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); }); ``` diff --git a/src/filters.ts b/src/filters.ts index 79e08fe..00977d5 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -10,7 +10,7 @@ import type { Filter, } from "./deps.deno.ts"; -/** +/* * The 'restricted' status is ambiguous, since it can refer both to a member in * the group and a member out of the group. * To avoid ambiguity we split the restricted role into "restricted_in" and From 495f42f51ddb2c4f2bf0aa520c8533f2097a6a98 Mon Sep 17 00:00:00 2001 From: rayz1065 Date: Wed, 29 Jan 2025 19:08:57 +0100 Subject: [PATCH 5/7] Stylistic changes in README, updated deno fmt proseWrap --- README.md | 144 ++++++++++++++++++++++------------------------- deno.json | 3 +- src/README.md | 152 +++++++++++++++++++++++--------------------------- 3 files changed, 139 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 54d8b03..229e8b5 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,50 @@ -# Chat members plugin for grammY +# Chat Members Plugin For grammY -This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the -form of custom filters, and by storing and updating the objects. +This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the form of custom filters, and by storing and updating the objects. ## Usage -### Chat member filters +### Chat Member Filters -You can listen for two kinds of updates regarding chat members using a telegram bot: `chat_member` and `my_chat_member`, -both of them specify the old and new status of the user. +You can listen for two kinds of updates regarding chat members using a telegram bot: `chat_member` and `my_chat_member`, both of them specify the old and new status of the user. -- `my_chat_member` updates are received by your bot by default and they inform you about the status of the bot being - updated in any chat, as well as users blocking the bot; -- `chat_member` updates are only received if you specifically include them in the list of allowed updates, they notify - about any status changes for users in chats **where your bot is admin**. +- `my_chat_member` updates are received by your bot by default and they inform you about the status of the bot being updated in any chat, as well as users blocking the bot; +- `chat_member` updates are only received if you specifically include them in the list of allowed updates, they notify about any status changes for users in chats **where your bot is admin**. -Filters specify the status before and after the change, allowing you to react to every type of transition you're -interested in. Within the handler, types of `old_chat_member` and `new_chat_member` are updated accordingly. +Filters specify the status before and after the change, allowing you to react to every type of transition you're interested in. +Within the handler, types of `old_chat_member` and `new_chat_member` are updated accordingly. ```typescript const bot = new Bot(process.env.BOT_TOKEN!); const groups = bot.chatType(["group", "supergroup"]); groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => { - await ctx.reply("Hello, thank you for adding me to the group!"); + await ctx.reply("Hello, thank you for adding me to the group!"); }); groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => { - await ctx.reply("Hello, thank you for adding me to the group as admin!"); + await ctx.reply("Hello, thank you for adding me to the group as admin!"); }); groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => { - await ctx.reply("I was promoted to admin!"); + await ctx.reply("I was promoted to admin!"); }); groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => { - await ctx.reply("I am no longer admin"); + await ctx.reply("I am no longer admin"); }); groups.filter(chatMemberFilter("out", "in"), async (ctx) => { - const user = ctx.chatMember.new_chat_member.user; - await ctx.reply( - `Welcome ${escapeHtml(user.first_name)} to the group!`, - { parse_mode: "HTML" }, - ); + const user = ctx.chatMember.new_chat_member.user; + await ctx.reply( + `Welcome ${escapeHtml(user.first_name)} to the group!`, + { parse_mode: "HTML" }, + ); }); bot.start({ - allowed_updates: [...DEFAULT_UPDATE_TYPES, "chat_member"], - onStart: (me) => console.log("Listening to updates as", me.username), + allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"], + onStart: (me) => console.log("Listening to updates as", me.username), }); ``` @@ -66,17 +62,17 @@ You can create your custom groupings of chat member types by passing an array in ```typescript groups.filter( - chatMemberFilter(["restricted", "kicked"], ["free", "left"]), - async (ctx) => { - const from = ctx.from; - const { status: oldStatus, user } = ctx.chatMember.old_chat_member; - await ctx.reply( - `${escapeHtml(from.first_name)} lifted ` + - `${oldStatus === "kicked" ? "ban" : "restrictions"} ` + - `from ${escapeHtml(user.first_name)}`, - { parse_mode: "HTML" }, - ); - }, + chatMemberFilter(["restricted", "kicked"], ["free", "left"]), + async (ctx) => { + const from = ctx.from; + const { status: oldStatus, user } = ctx.chatMember.old_chat_member; + await ctx.reply( + `${escapeHtml(from.first_name)} lifted ` + + `${oldStatus === "kicked" ? "ban" : "restrictions"} ` + + `from ${escapeHtml(user.first_name)}`, + { parse_mode: "HTML" }, + ); + }, ); ``` @@ -92,44 +88,41 @@ The best way to use the filters is to pick a set of relevant statuses, for examp Assign a listener to all the transitions that are relevant to your use-case. -Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat. Add a middleware -to listen to all updates as a way to perform common operations (like updating your database) before handing off control -to a specific handler. +Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat. +Add a middleware to listen to all updates as a way to perform common operations (like updating your database) before handing off control to a specific handler. ```typescript const groups = bot.chatType(["group", "supergroup"]); groups.on("chat_member", (ctx, next) => { - // ran on all updates of type chat_member - const { - old_chat_member: { status: oldStatus }, - new_chat_member: { user, status }, - from, - chat, - } = ctx.chatMember; - console.log( - `In group ${chat.id} user ${from.id} changed status of ${user.id}:`, - `${oldStatus} -> ${status}`, - ); - - // update database data here - - await next(); + // ran on all updates of type chat_member + const { + old_chat_member: { status: oldStatus }, + new_chat_member: { user, status }, + from, + chat, + } = ctx.chatMember; + console.log( + `In group ${chat.id} user ${from.id} changed status of ${user.id}:`, + `${oldStatus} -> ${status}`, + ); + + // update database data here + + await next(); }); // specific handlers groups.filter(chatMemberFilter("out", "in"), async (ctx, next) => { - const { new_chat_member: { user } } = ctx.chatMember; - await ctx.reply(`Welcome ${user.first_name}!`); + const { new_chat_member: { user } } = ctx.chatMember; + await ctx.reply(`Welcome ${user.first_name}!`); }); ``` -### Storing chat members +### Storing Chat Members -You can use a valid grammY [storage adapter](https://grammy.dev/plugins/session.html#known-storage-adapters) or an -instance of any class that implements the [`StorageAdapter`](https://deno.land/x/grammy/mod.ts?s=StorageAdapter) -interface. +You can use a valid grammY [storage adapter](https://grammy.dev/plugins/session.html#known-storage-adapters) or an instance of any class that implements the [`StorageAdapter`](https://deno.land/x/grammy/mod.ts?s=StorageAdapter) interface. ```typescript import { Bot, Context, MemorySessionStorage } from "grammy"; @@ -145,39 +138,36 @@ const bot = new Bot(""); bot.use(chatMembers(adapter)); bot.start({ - allowed_updates: ["chat_member", "message"], - onStart: ({ username }) => console.log(`Listening as ${username}`), + allowed_updates: ["chat_member", "message"], + onStart: ({ username }) => console.log(`Listening as ${username}`), }); ``` -### Reading chat member info +### Reading Chat Member Info -This plugin also adds a new `ctx.chatMembers.getChatMember` function that will check the storage for information about a -chat member before querying telegram for it. If the chat member exists in the storage, it will be returned. Otherwise, -`ctx.api.getChatMember` will be called and the result will be saved to the storage, making subsequent calls faster and -removing the need to call telegram again for that user and chat in the future. +This plugin also adds a new `ctx.chatMembers.getChatMember` function that will check the storage for information about a chat member before querying telegram for it. +If the chat member exists in the storage, it will be returned. +Otherwise, `ctx.api.getChatMember` will be called and the result will be saved to the storage, making subsequent calls faster and removing the need to call telegram again for that user and chat in the future. Here's an example: ```typescript bot.on("message", async (ctx) => { - const chatMember = await ctx.chatMembers.getChatMember(); + const chatMember = await ctx.chatMembers.getChatMember(); - return ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); + await ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); }); ``` The second parameter, which is the chat id, is optional; if you don't provide it, `ctx.chat.id` will be used instead. -Please notice that, if you don't provide a chat id and there's no `chat` property inside the context (for example: on -inline query updates), this will throw an error. +Please notice that, if you don't provide a chat id and there's no `chat` property inside the context (for example: on inline query updates), this will throw an error. -## Aggressive storage +## Aggressive Storage -The `enableAggressiveStorage` config option will install middleware to cache chat members without depending on the -`chat_member` event. For every update, the middleware checks if `ctx.chat` and `ctx.from` exist. If they both do, it -then proceeds to call `ctx.chatMembers.getChatMember` to add the chat member information to the storage in case it -doesn't exist. +The `enableAggressiveStorage` config option will install middleware to cache chat members without depending on the `chat_member` event. +For every update, the middleware checks if `ctx.chat` and `ctx.from` exist. +If they both do, it then proceeds to call `ctx.chatMembers.getChatMember` to add the chat member information to the storage in case it doesn't exist. -Please note that this means the storage will be called for **every update**, which may be a lot, depending on how many -updates your bot receives. This also has the potential to impact the performance of your bot drastically. Only use this -if you _really_ know what you're doing and are ok with the risks and consequences. +Please note that this means the storage will be called for **every update**, which may be a lot, depending on how many updates your bot receives. +This also has the potential to impact the performance of your bot drastically. +Only use this if you _really_ know what you're doing and are ok with the risks and consequences. diff --git a/deno.json b/deno.json index 22cab86..9f00019 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,8 @@ "./node_modules/", "./out/", "./package-lock.json" - ] + ], + "proseWrap": "preserve" }, "lint": { "exclude": [ diff --git a/src/README.md b/src/README.md index d382b78..d98c908 100644 --- a/src/README.md +++ b/src/README.md @@ -1,59 +1,54 @@ -# Chat members plugin for grammY +# Chat Members Plugin For grammY -This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the -form of custom filters, and by storing and updating the objects. +This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the form of custom filters, and by storing and updating the objects. ## Usage -### Chat member filters +### Chat Member Filters -You can listen for two kinds of updates regarding chat members using a telegram bot: `chat_member` and `my_chat_member`, -both of them specify the old and new status of the user. +You can listen for two kinds of updates regarding chat members using a telegram bot: `chat_member` and `my_chat_member`, both of them specify the old and new status of the user. -- `my_chat_member` updates are received by your bot by default and they inform you about the status of the bot being - updated in any chat, as well as users blocking the bot; -- `chat_member` updates are only received if you specifically include them in the list of allowed updates, they notify - about any status changes for users in chats **where your bot is admin**. +- `my_chat_member` updates are received by your bot by default and they inform you about the status of the bot being updated in any chat, as well as users blocking the bot; +- `chat_member` updates are only received if you specifically include them in the list of allowed updates, they notify about any status changes for users in chats **where your bot is admin**. -Filters specify the status before and after the change, allowing you to react to every type of transition you're -interested in. Within the handler, types of `old_chat_member` and `new_chat_member` are updated accordingly. +Filters specify the status before and after the change, allowing you to react to every type of transition you're interested in. +Within the handler, types of `old_chat_member` and `new_chat_member` are updated accordingly. ```typescript const bot = new Bot(process.env.BOT_TOKEN!); const groups = bot.chatType(["group", "supergroup"]); groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => { - await ctx.reply("Hello, thank you for adding me to the group!"); + await ctx.reply("Hello, thank you for adding me to the group!"); }); groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => { - await ctx.reply("Hello, thank you for adding me to the group as admin!"); + await ctx.reply("Hello, thank you for adding me to the group as admin!"); }); groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => { - await ctx.reply("I was promoted to admin!"); + await ctx.reply("I was promoted to admin!"); }); groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => { - await ctx.reply("I am no longer admin"); + await ctx.reply("I am no longer admin"); }); groups.filter(chatMemberFilter("out", "in"), async (ctx) => { - const user = ctx.chatMember.new_chat_member.user; - await ctx.reply( - `Welcome ${escapeHtml(user.first_name)} to the group!`, - { parse_mode: "HTML" }, - ); + const user = ctx.chatMember.new_chat_member.user; + await ctx.reply( + `Welcome ${escapeHtml(user.first_name)} to the group!`, + { parse_mode: "HTML" }, + ); }); bot.start({ - allowed_updates: [...DEFAULT_UPDATE_TYPES, "chat_member"], - onStart: (me) => console.log("Listening to updates as", me.username), + allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"], + onStart: (me) => console.log("Listening to updates as", me.username), }); ``` -Filters include the regular Telegram statuses (owner, administrator, member, restricted, left, kicked) and some -additional ones for convenience: +Filters include the regular Telegram statuses (owner, administrator, member, restricted, left, kicked) and some additional ones for convenience: - restricted_in: a member of the chat with restrictions; - restricted_out: not a member of the chat, has restrictions; @@ -67,24 +62,23 @@ You can create your custom groupings of chat member types by passing an array in ```typescript groups.filter( - chatMemberFilter(["restricted", "kicked"], ["free", "left"]), - async (ctx) => { - const from = ctx.from; - const { status: oldStatus, user } = ctx.chatMember.old_chat_member; - await ctx.reply( - `${escapeHtml(from.first_name)} lifted ` + - `${oldStatus === "kicked" ? "ban" : "restrictions"} ` + - `from ${escapeHtml(user.first_name)}`, - { parse_mode: "HTML" }, - ); - }, + chatMemberFilter(["restricted", "kicked"], ["free", "left"]), + async (ctx) => { + const from = ctx.from; + const { status: oldStatus, user } = ctx.chatMember.old_chat_member; + await ctx.reply( + `${escapeHtml(from.first_name)} lifted ` + + `${oldStatus === "kicked" ? "ban" : "restrictions"} ` + + `from ${escapeHtml(user.first_name)}`, + { parse_mode: "HTML" }, + ); + }, ); ``` -#### Example usage +#### Example Usage -The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then -make a table of the transitions between them: +The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then make a table of the transitions between them: | ↱ | Out | Regular | Admin | | ----------- | ----------- | -------------------- | ------------------- | @@ -94,44 +88,41 @@ make a table of the transitions between them: Assign a listener to all the transitions that are relevant to your use-case. -Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat. Add a middleware -to listen to all updates as a way to perform common operations (like updating your database) before handing off control -to a specific handler. +Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat. +Add a middleware to listen to all updates as a way to perform common operations (like updating your database) before handing off control to a specific handler. ```typescript const groups = bot.chatType(["group", "supergroup"]); groups.on("chat_member", (ctx, next) => { - // ran on all updates of type chat_member - const { - old_chat_member: { status: oldStatus }, - new_chat_member: { user, status }, - from, - chat, - } = ctx.chatMember; - console.log( - `In group ${chat.id} user ${from.id} changed status of ${user.id}:`, - `${oldStatus} -> ${status}`, - ); - - // update database data here - - return next(); + // ran on all updates of type chat_member + const { + old_chat_member: { status: oldStatus }, + new_chat_member: { user, status }, + from, + chat, + } = ctx.chatMember; + console.log( + `In group ${chat.id} user ${from.id} changed status of ${user.id}:`, + `${oldStatus} -> ${status}`, + ); + + // update database data here + + await next(); }); // specific handlers groups.filter(chatMemberFilter("out", "in"), async (ctx, next) => { - const { new_chat_member: { user } } = ctx.chatMember; - await ctx.reply(`Welcome ${user.first_name}!`); + const { new_chat_member: { user } } = ctx.chatMember; + await ctx.reply(`Welcome ${user.first_name}!`); }); ``` -### Storing chat members +### Storing Chat Members -You can use a valid grammY [storage adapter](https://grammy.dev/plugins/session.html#known-storage-adapters) or an -instance of any class that implements the [`StorageAdapter`](https://deno.land/x/grammy/mod.ts?s=StorageAdapter) -interface. +You can use a valid grammY [storage adapter](https://grammy.dev/plugins/session.html#known-storage-adapters) or an instance of any class that implements the [`StorageAdapter`](https://deno.land/x/grammy/mod.ts?s=StorageAdapter) interface. ```typescript import { Bot, Context, MemorySessionStorage } from "https://deno.land/x/grammy/mod.ts"; @@ -147,39 +138,36 @@ const bot = new Bot(""); bot.use(chatMembers(adapter)); bot.start({ - allowed_updates: ["chat_member", "message"], - onStart: ({ username }) => console.log(`Listening as ${username}`), + allowed_updates: ["chat_member", "message"], + onStart: ({ username }) => console.log(`Listening as ${username}`), }); ``` -### Reading chat member info +### Reading Chat Member Info -This plugin also adds a new `ctx.chatMembers.getChatMember` function that will check the storage for information about a -chat member before querying telegram for it. If the chat member exists in the storage, it will be returned. Otherwise, -`ctx.api.getChatMember` will be called and the result will be saved to the storage, making subsequent calls faster and -removing the need to call telegram again for that user and chat in the future. +This plugin also adds a new `ctx.chatMembers.getChatMember` function that will check the storage for information about a chat member before querying telegram for it. +If the chat member exists in the storage, it will be returned. +Otherwise, `ctx.api.getChatMember` will be called and the result will be saved to the storage, making subsequent calls faster and removing the need to call telegram again for that user and chat in the future. Here's an example: ```typescript bot.on("message", async (ctx) => { - const chatMember = await ctx.chatMembers.getChatMember(); + const chatMember = await ctx.chatMembers.getChatMember(); - await ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); + await ctx.reply(`Hello, ${chatMember.user.first_name}! I see you are a ${chatMember.status} of this chat!`); }); ``` The second parameter, which is the chat id, is optional; if you don't provide it, `ctx.chat.id` will be used instead. -Please notice that, if you don't provide a chat id and there's no `chat` property inside the context (for example: on -inline query updates), this will throw an error. +Please notice that, if you don't provide a chat id and there's no `chat` property inside the context (for example: on inline query updates), this will throw an error. -## Aggressive storage +## Aggressive Storage -The `enableAggressiveStorage` config option will install middleware to cache chat members without depending on the -`chat_member` event. For every update, the middleware checks if `ctx.chat` and `ctx.from` exist. If they both do, it -then proceeds to call `ctx.chatMembers.getChatMember` to add the chat member information to the storage in case it -doesn't exist. +The `enableAggressiveStorage` config option will install middleware to cache chat members without depending on the `chat_member` event. +For every update, the middleware checks if `ctx.chat` and `ctx.from` exist. +If they both do, it then proceeds to call `ctx.chatMembers.getChatMember` to add the chat member information to the storage in case it doesn't exist. -Please note that this means the storage will be called for **every update**, which may be a lot, depending on how many -updates your bot receives. This also has the potential to impact the performance of your bot drastically. Only use this -if you _really_ know what you're doing and are ok with the risks and consequences. +Please note that this means the storage will be called for **every update**, which may be a lot, depending on how many updates your bot receives. +This also has the potential to impact the performance of your bot drastically. +Only use this if you _really_ know what you're doing and are ok with the risks and consequences. From c3426b64459a27bda686f91d0a01f1e61a33623a Mon Sep 17 00:00:00 2001 From: rayz1065 Date: Thu, 30 Jan 2025 12:53:21 +0100 Subject: [PATCH 6/7] Added TSDoc to chatMemberIs --- src/filters.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/filters.ts b/src/filters.ts index 00977d5..46ba330 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -143,6 +143,15 @@ function normalizeChatMemberQuery( ] as NormalizeChatMemberQuery[]; } +/** + * Utility function to query the status of a chat member. + * + * Pass one of 'restricted_in', 'restricted_out', 'in', 'out', 'free', 'admin', + * 'regular', or one of the default Telegram statuses ('administrator', + * 'creator', 'kicked', 'left', 'member', 'restricted'), or an array of them. + * + * Returns true if the chat member matches the query. + */ export function chatMemberIs< C extends ChatMember, Q extends ChatMemberQuery, From 418f492a86eac0b98aea1f1089fdeb4c1ea7528a Mon Sep 17 00:00:00 2001 From: rayz1065 Date: Thu, 6 Feb 2025 16:05:14 +0100 Subject: [PATCH 7/7] Update editorconfig, run deno fmt --- .editorconfig | 4 +- src/deps.deno.ts | 22 +- src/deps.node.ts | 22 +- src/filters.test.ts | 540 ++++++++++++++++++++++---------------------- src/filters.ts | 234 +++++++++---------- 5 files changed, 411 insertions(+), 411 deletions(-) diff --git a/.editorconfig b/.editorconfig index 88027ef..4dd484b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ end_of_line = lf charset = utf-8 indent_style = space -indent_size = 4 +indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true -max_line_length = 80 \ No newline at end of file +max_line_length = 120 diff --git a/src/deps.deno.ts b/src/deps.deno.ts index 5939b6d..8de82e5 100644 --- a/src/deps.deno.ts +++ b/src/deps.deno.ts @@ -1,14 +1,14 @@ export { Api, Composer, Context, type Filter, type StorageAdapter } from "https://lib.deno.dev/x/grammy@v1/mod.ts"; export type { - Chat, - ChatMember, - ChatMemberAdministrator, - ChatMemberBanned, - ChatMemberLeft, - ChatMemberMember, - ChatMemberOwner, - ChatMemberRestricted, - ChatMemberUpdated, - User, - UserFromGetMe, + Chat, + ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, + ChatMemberUpdated, + User, + UserFromGetMe, } from "https://lib.deno.dev/x/grammy@v1/types.ts"; diff --git a/src/deps.node.ts b/src/deps.node.ts index 886e07f..04ea060 100644 --- a/src/deps.node.ts +++ b/src/deps.node.ts @@ -1,14 +1,14 @@ export { Api, Composer, Context, type Filter, type StorageAdapter } from "grammy"; export type { - Chat, - ChatMember, - ChatMemberAdministrator, - ChatMemberBanned, - ChatMemberLeft, - ChatMemberMember, - ChatMemberOwner, - ChatMemberRestricted, - ChatMemberUpdated, - User, - UserFromGetMe, + Chat, + ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, + ChatMemberUpdated, + User, + UserFromGetMe, } from "grammy/types"; diff --git a/src/filters.test.ts b/src/filters.test.ts index c849c5e..656728f 100644 --- a/src/filters.test.ts +++ b/src/filters.test.ts @@ -1,300 +1,300 @@ import { assertEquals } from "jsr:@std/assert@1"; import { - Api, - type ChatMember, - type ChatMemberAdministrator, - type ChatMemberBanned, - type ChatMemberLeft, - type ChatMemberMember, - type ChatMemberOwner, - type ChatMemberRestricted, - type ChatMemberUpdated, - Context, - type UserFromGetMe, + Api, + type ChatMember, + type ChatMemberAdministrator, + type ChatMemberBanned, + type ChatMemberLeft, + type ChatMemberMember, + type ChatMemberOwner, + type ChatMemberRestricted, + type ChatMemberUpdated, + Context, + type UserFromGetMe, } from "./deps.deno.ts"; import { - type ChatMemberAdmin, - chatMemberFilter, - type ChatMemberFree, - type ChatMemberIn, - chatMemberIs, - type ChatMemberOut, - ChatMemberQuery, - type ChatMemberRegular, - type ChatMemberRestrictedIn, - type ChatMemberRestrictedOut, - type FilteredChatMember, - myChatMemberFilter, + type ChatMemberAdmin, + chatMemberFilter, + type ChatMemberFree, + type ChatMemberIn, + chatMemberIs, + type ChatMemberOut, + ChatMemberQuery, + type ChatMemberRegular, + type ChatMemberRestrictedIn, + type ChatMemberRestrictedOut, + type FilteredChatMember, + myChatMemberFilter, } from "./filters.ts"; type ChatMemberStatusBase = - | Exclude - | "restricted_in" - | "restricted_out"; + | Exclude + | "restricted_in" + | "restricted_out"; Deno.test("filter queries should produce the correct type", () => { - type Expect = T; - type Equal = (() => T extends X ? 1 : 2) extends < - T, - >() => T extends Y ? 1 : 2 ? true - : false; + type Expect = T; + type Equal = (() => T extends X ? 1 : 2) extends < + T, + >() => T extends Y ? 1 : 2 ? true + : false; - type AdminTest = Expect< - Equal, ChatMemberAdmin> - >; - type AdministratorTest = Expect< - Equal< - FilteredChatMember, - ChatMemberAdministrator - > - >; - type CreatorTest = Expect< - Equal, ChatMemberOwner> - >; - type FreeTest = Expect< - Equal, ChatMemberFree> - >; - type InTest = Expect< - Equal, ChatMemberIn> - >; - type OutTest = Expect< - Equal, ChatMemberOut> - >; - type Foo = FilteredChatMember; - type RegularTest = Expect< - Equal, ChatMemberRegular> - >; - type KickedTest = Expect< - Equal, ChatMemberBanned> - >; - type LeftTest = Expect< - Equal, ChatMemberLeft> - >; - type MemberTest = Expect< - Equal, ChatMemberMember> - >; - type RestrictedTest = Expect< - Equal, ChatMemberRestricted> - >; - type RestrictedInTest = Expect< - Equal< - FilteredChatMember, - ChatMemberRestrictedIn - > - >; - type RestrictedOutTest = Expect< - Equal< - FilteredChatMember, - ChatMemberRestrictedOut - > - >; + type AdminTest = Expect< + Equal, ChatMemberAdmin> + >; + type AdministratorTest = Expect< + Equal< + FilteredChatMember, + ChatMemberAdministrator + > + >; + type CreatorTest = Expect< + Equal, ChatMemberOwner> + >; + type FreeTest = Expect< + Equal, ChatMemberFree> + >; + type InTest = Expect< + Equal, ChatMemberIn> + >; + type OutTest = Expect< + Equal, ChatMemberOut> + >; + type Foo = FilteredChatMember; + type RegularTest = Expect< + Equal, ChatMemberRegular> + >; + type KickedTest = Expect< + Equal, ChatMemberBanned> + >; + type LeftTest = Expect< + Equal, ChatMemberLeft> + >; + type MemberTest = Expect< + Equal, ChatMemberMember> + >; + type RestrictedTest = Expect< + Equal, ChatMemberRestricted> + >; + type RestrictedInTest = Expect< + Equal< + FilteredChatMember, + ChatMemberRestrictedIn + > + >; + type RestrictedOutTest = Expect< + Equal< + FilteredChatMember, + ChatMemberRestrictedOut + > + >; }); Deno.test("should apply query to chat member", () => { - const results: Record< - ChatMemberStatusBase, - Record< - Exclude, - boolean - > - > = { - administrator: { - in: true, - out: false, - free: true, - admin: true, - regular: false, - restricted_in: false, - restricted_out: false, - }, - creator: { - in: true, - out: false, - free: true, - admin: true, - regular: false, - restricted_in: false, - restricted_out: false, - }, - member: { - in: true, - out: false, - free: true, - admin: false, - regular: true, - restricted_in: false, - restricted_out: false, - }, - restricted_in: { - in: true, - out: false, - free: false, - admin: false, - regular: true, - restricted_in: true, - restricted_out: false, - }, - restricted_out: { - in: false, - out: true, - free: false, - admin: false, - regular: false, - restricted_in: false, - restricted_out: true, - }, - left: { - in: false, - out: true, - free: false, - admin: false, - regular: false, - restricted_in: false, - restricted_out: false, - }, - kicked: { - in: false, - out: true, - free: false, - admin: false, - regular: false, - restricted_in: false, - restricted_out: false, - }, - } as const; + const results: Record< + ChatMemberStatusBase, + Record< + Exclude, + boolean + > + > = { + administrator: { + in: true, + out: false, + free: true, + admin: true, + regular: false, + restricted_in: false, + restricted_out: false, + }, + creator: { + in: true, + out: false, + free: true, + admin: true, + regular: false, + restricted_in: false, + restricted_out: false, + }, + member: { + in: true, + out: false, + free: true, + admin: false, + regular: true, + restricted_in: false, + restricted_out: false, + }, + restricted_in: { + in: true, + out: false, + free: false, + admin: false, + regular: true, + restricted_in: true, + restricted_out: false, + }, + restricted_out: { + in: false, + out: true, + free: false, + admin: false, + regular: false, + restricted_in: false, + restricted_out: true, + }, + left: { + in: false, + out: true, + free: false, + admin: false, + regular: false, + restricted_in: false, + restricted_out: false, + }, + kicked: { + in: false, + out: true, + free: false, + admin: false, + regular: false, + restricted_in: false, + restricted_out: false, + }, + } as const; - const statuses: ChatMember["status"][] = [ - "administrator", - "creator", - "kicked", - "left", - "member", - "restricted", - ]; - const baseStatuses = Object.keys(results) as ChatMemberStatusBase[]; - baseStatuses.forEach((status) => { - const chatMember = (status === "restricted_in" - ? { status: "restricted", is_member: true } - : status === "restricted_out" - ? { status: "restricted", is_member: false } - : { status }) as ChatMember; - const statusResults = results[status]; + const statuses: ChatMember["status"][] = [ + "administrator", + "creator", + "kicked", + "left", + "member", + "restricted", + ]; + const baseStatuses = Object.keys(results) as ChatMemberStatusBase[]; + baseStatuses.forEach((status) => { + const chatMember = (status === "restricted_in" + ? { status: "restricted", is_member: true } + : status === "restricted_out" + ? { status: "restricted", is_member: false } + : { status }) as ChatMember; + const statusResults = results[status]; - const queries = Object.keys( - results[status], - ) as (keyof typeof statusResults)[]; - queries.forEach((query) => { - assertEquals(chatMemberIs(chatMember, query), statusResults[query]); - }); + const queries = Object.keys( + results[status], + ) as (keyof typeof statusResults)[]; + queries.forEach((query) => { + assertEquals(chatMemberIs(chatMember, query), statusResults[query]); + }); - statuses.forEach((query) => { - assertEquals( - chatMemberIs(chatMember, query), - chatMember.status === query, - ); - }); + statuses.forEach((query) => { + assertEquals( + chatMemberIs(chatMember, query), + chatMember.status === query, + ); }); + }); }); Deno.test("should filter myChatMember", () => { - const administratorKickedCtx = new Context( - { - update_id: 123, - my_chat_member: { - old_chat_member: { status: "administrator" }, - new_chat_member: { status: "kicked" }, - } as ChatMemberUpdated, - }, - new Api(""), - {} as UserFromGetMe, - ); - const administratorKickedFilters = [ - ["administrator", "kicked", true], - ["administrator", "out", true], - ["admin", "kicked", true], - ["admin", "out", true], - ["in", "out", true], - ["regular", "kicked", false], - ["member", "out", false], - ["administrator", "member", false], - ["admin", "in", false], - ["out", "in", false], - ] as const; + const administratorKickedCtx = new Context( + { + update_id: 123, + my_chat_member: { + old_chat_member: { status: "administrator" }, + new_chat_member: { status: "kicked" }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + const administratorKickedFilters = [ + ["administrator", "kicked", true], + ["administrator", "out", true], + ["admin", "kicked", true], + ["admin", "out", true], + ["in", "out", true], + ["regular", "kicked", false], + ["member", "out", false], + ["administrator", "member", false], + ["admin", "in", false], + ["out", "in", false], + ] as const; - administratorKickedFilters.forEach(([oldStatus, newStatus, expected]) => { - const filter = myChatMemberFilter(oldStatus, newStatus); - assertEquals(filter(administratorKickedCtx), expected); - }); + administratorKickedFilters.forEach(([oldStatus, newStatus, expected]) => { + const filter = myChatMemberFilter(oldStatus, newStatus); + assertEquals(filter(administratorKickedCtx), expected); + }); }); Deno.test("should filter chatMember", () => { - const leftRestrictedInCtx = new Context( - { - update_id: 123, - chat_member: { - old_chat_member: { status: "left" }, - new_chat_member: { status: "restricted", is_member: true }, - } as ChatMemberUpdated, - }, - new Api(""), - {} as UserFromGetMe, - ); - const administratorKickedFilters = [ - ["left", "restricted", true], - ["restricted", "left", false], - ["out", "in", true], - ["in", "out", false], - ["out", "admin", false], - ["kicked", "restricted", false], - ["out", "free", false], - ["kicked", "member", false], - ["member", "out", false], - ] as const; + const leftRestrictedInCtx = new Context( + { + update_id: 123, + chat_member: { + old_chat_member: { status: "left" }, + new_chat_member: { status: "restricted", is_member: true }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + const administratorKickedFilters = [ + ["left", "restricted", true], + ["restricted", "left", false], + ["out", "in", true], + ["in", "out", false], + ["out", "admin", false], + ["kicked", "restricted", false], + ["out", "free", false], + ["kicked", "member", false], + ["member", "out", false], + ] as const; - administratorKickedFilters.forEach(([oldStatus, newStatus, expected]) => { - const filter = chatMemberFilter(oldStatus, newStatus); - assertEquals(filter(leftRestrictedInCtx), expected); - }); + administratorKickedFilters.forEach(([oldStatus, newStatus, expected]) => { + const filter = chatMemberFilter(oldStatus, newStatus); + assertEquals(filter(leftRestrictedInCtx), expected); + }); }); Deno.test("should filter out other types of updates", () => { - const administratorAdministratorCtx = new Context( - { - update_id: 123, - chat_member: { - old_chat_member: { status: "administrator" }, - new_chat_member: { status: "administrator" }, - } as ChatMemberUpdated, - }, - new Api(""), - {} as UserFromGetMe, - ); - assertEquals( - myChatMemberFilter("admin", "admin")(administratorAdministratorCtx), - false, - ); - assertEquals( - chatMemberFilter("admin", "admin")(administratorAdministratorCtx), - true, - ); + const administratorAdministratorCtx = new Context( + { + update_id: 123, + chat_member: { + old_chat_member: { status: "administrator" }, + new_chat_member: { status: "administrator" }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + assertEquals( + myChatMemberFilter("admin", "admin")(administratorAdministratorCtx), + false, + ); + assertEquals( + chatMemberFilter("admin", "admin")(administratorAdministratorCtx), + true, + ); - const memberRestrictedCtx = new Context( - { - update_id: 123, - my_chat_member: { - old_chat_member: { status: "member" }, - new_chat_member: { status: "restricted", is_member: true }, - } as ChatMemberUpdated, - }, - new Api(""), - {} as UserFromGetMe, - ); - assertEquals( - myChatMemberFilter("free", "restricted")(memberRestrictedCtx), - true, - ); - assertEquals( - chatMemberFilter("free", "restricted")(memberRestrictedCtx), - false, - ); + const memberRestrictedCtx = new Context( + { + update_id: 123, + my_chat_member: { + old_chat_member: { status: "member" }, + new_chat_member: { status: "restricted", is_member: true }, + } as ChatMemberUpdated, + }, + new Api(""), + {} as UserFromGetMe, + ); + assertEquals( + myChatMemberFilter("free", "restricted")(memberRestrictedCtx), + true, + ); + assertEquals( + chatMemberFilter("free", "restricted")(memberRestrictedCtx), + false, + ); }); diff --git a/src/filters.ts b/src/filters.ts index 46ba330..c0350aa 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -1,13 +1,13 @@ import type { - ChatMember, - ChatMemberAdministrator, - ChatMemberBanned, - ChatMemberLeft, - ChatMemberMember, - ChatMemberOwner, - ChatMemberRestricted, - Context, - Filter, + ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, + Context, + Filter, } from "./deps.deno.ts"; /* @@ -25,30 +25,30 @@ export type ChatMemberRestrictedIn = ChatMemberRestricted & { is_member: true }; * Not a member of the chat, with restrictions applied. */ export type ChatMemberRestrictedOut = ChatMemberRestricted & { - is_member: false; + is_member: false; }; /** * A member of the chat, with any role, possibly restricted. */ export type ChatMemberIn = - | ChatMemberAdministrator - | ChatMemberOwner - | ChatMemberRestrictedIn - | ChatMemberMember; + | ChatMemberAdministrator + | ChatMemberOwner + | ChatMemberRestrictedIn + | ChatMemberMember; /** * Not a member of the chat */ export type ChatMemberOut = - | ChatMemberBanned - | ChatMemberLeft - | ChatMemberRestrictedOut; + | ChatMemberBanned + | ChatMemberLeft + | ChatMemberRestrictedOut; /** * A member of the chat, with any role, not restricted. */ export type ChatMemberFree = - | ChatMemberAdministrator - | ChatMemberOwner - | ChatMemberMember; + | ChatMemberAdministrator + | ChatMemberOwner + | ChatMemberMember; /** * An admin of the chat, either administrator or owner. */ @@ -61,67 +61,67 @@ export type ChatMemberRegular = ChatMemberRestrictedIn | ChatMemberMember; * Query type for chat member status. */ export type ChatMemberQuery = - | "in" - | "out" - | "free" - | "admin" - | "regular" - | "restricted_in" - | "restricted_out" - | ChatMember["status"]; + | "in" + | "out" + | "free" + | "admin" + | "regular" + | "restricted_in" + | "restricted_out" + | ChatMember["status"]; /** * Used to normalize queries to the simplest components. */ const chatMemberQueries = { - admin: ["administrator", "creator"], - administrator: ["administrator"], - creator: ["creator"], - free: ["administrator", "creator", "member"], - in: ["administrator", "creator", "member", "restricted_in"], - out: ["kicked", "left", "restricted_out"], - regular: ["member", "restricted_in"], - kicked: ["kicked"], - left: ["left"], - member: ["member"], - restricted: ["restricted"], - restricted_in: ["restricted_in"], - restricted_out: ["restricted_out"], + admin: ["administrator", "creator"], + administrator: ["administrator"], + creator: ["creator"], + free: ["administrator", "creator", "member"], + in: ["administrator", "creator", "member", "restricted_in"], + out: ["kicked", "left", "restricted_out"], + regular: ["member", "restricted_in"], + kicked: ["kicked"], + left: ["left"], + member: ["member"], + restricted: ["restricted"], + restricted_in: ["restricted_in"], + restricted_out: ["restricted_out"], } as const satisfies Record< - ChatMemberQuery, - (ChatMember["status"] | "restricted_in" | "restricted_out")[] + ChatMemberQuery, + (ChatMember["status"] | "restricted_in" | "restricted_out")[] >; /** * Maps from the query to the corresponding type. */ type ChatMemberQueriesMap = { - admin: ChatMemberAdmin; - administrator: ChatMemberAdministrator; - creator: ChatMemberOwner; - free: ChatMemberFree; - in: ChatMemberIn; - out: ChatMemberOut; - regular: ChatMemberRegular; - kicked: ChatMemberBanned; - left: ChatMemberLeft; - member: ChatMemberMember; - restricted: ChatMemberRestricted; - restricted_in: ChatMemberRestrictedIn; - restricted_out: ChatMemberRestrictedOut; + admin: ChatMemberAdmin; + administrator: ChatMemberAdministrator; + creator: ChatMemberOwner; + free: ChatMemberFree; + in: ChatMemberIn; + out: ChatMemberOut; + regular: ChatMemberRegular; + kicked: ChatMemberBanned; + left: ChatMemberLeft; + member: ChatMemberMember; + restricted: ChatMemberRestricted; + restricted_in: ChatMemberRestrictedIn; + restricted_out: ChatMemberRestrictedOut; }; type NormalizeChatMemberQueryCore = (typeof chatMemberQueries)[Q][number]; type MaybeArray = T | T[]; type NormalizeChatMemberQuery< - Q extends ChatMemberQuery, + Q extends ChatMemberQuery, > = Q extends ChatMemberQuery ? NormalizeChatMemberQueryCore - : (Q extends ChatMemberQuery[] ? NormalizeChatMemberQuery - : never); + : (Q extends ChatMemberQuery[] ? NormalizeChatMemberQuery + : never); export type FilteredChatMember< - C extends ChatMember, - Q extends ChatMemberQuery, + C extends ChatMember, + Q extends ChatMemberQuery, > = C & ChatMemberQueriesMap[NormalizeChatMemberQuery]; /** @@ -129,18 +129,18 @@ export type FilteredChatMember< * statuses. */ function normalizeChatMemberQuery( - query: MaybeArray, + query: MaybeArray, ): NormalizeChatMemberQuery[] { - if (Array.isArray(query)) { - const res = new Set( - query.flatMap(normalizeChatMemberQuery), - ); - return [...res] as NormalizeChatMemberQuery[]; - } + if (Array.isArray(query)) { + const res = new Set( + query.flatMap(normalizeChatMemberQuery), + ); + return [...res] as NormalizeChatMemberQuery[]; + } - return [ - ...chatMemberQueries[query], - ] as NormalizeChatMemberQuery[]; + return [ + ...chatMemberQueries[query], + ] as NormalizeChatMemberQuery[]; } /** @@ -153,25 +153,25 @@ function normalizeChatMemberQuery( * Returns true if the chat member matches the query. */ export function chatMemberIs< - C extends ChatMember, - Q extends ChatMemberQuery, + C extends ChatMember, + Q extends ChatMemberQuery, >( - chatMember: C, - query: MaybeArray, + chatMember: C, + query: MaybeArray, ): chatMember is FilteredChatMember { - const roles = normalizeChatMemberQuery(query); + const roles = normalizeChatMemberQuery(query); - if (chatMember.status === "restricted") { - if (roles.includes("restricted" as (typeof roles)[number])) { - return true; - } else if (chatMember.is_member) { - return roles.includes("restricted_in" as (typeof roles)[number]); - } else { - return roles.includes("restricted_out" as (typeof roles)[number]); - } + if (chatMember.status === "restricted") { + if (roles.includes("restricted" as (typeof roles)[number])) { + return true; + } else if (chatMember.is_member) { + return roles.includes("restricted_in" as (typeof roles)[number]); + } else { + return roles.includes("restricted_out" as (typeof roles)[number]); } + } - return roles.includes(chatMember.status as (typeof roles)[number]); + return roles.includes(chatMember.status as (typeof roles)[number]); } /** @@ -191,24 +191,24 @@ export function chatMemberIs< * ); */ export function myChatMemberFilter< - C extends Context, - Q1 extends ChatMemberQuery, - Q2 extends ChatMemberQuery, + C extends Context, + Q1 extends ChatMemberQuery, + Q2 extends ChatMemberQuery, >(oldStatus: MaybeArray, newStatus: MaybeArray) { - return ( - ctx: C, - ): ctx is Filter & { - myChatMember: { - old_chat_member: FilteredChatMember; - new_chat_member: FilteredChatMember; - }; - } => { - return ( - ctx.has("my_chat_member") && - chatMemberIs(ctx.myChatMember.old_chat_member, oldStatus) && - chatMemberIs(ctx.myChatMember.new_chat_member, newStatus) - ); + return ( + ctx: C, + ): ctx is Filter & { + myChatMember: { + old_chat_member: FilteredChatMember; + new_chat_member: FilteredChatMember; }; + } => { + return ( + ctx.has("my_chat_member") && + chatMemberIs(ctx.myChatMember.old_chat_member, oldStatus) && + chatMemberIs(ctx.myChatMember.new_chat_member, newStatus) + ); + }; } /** @@ -232,22 +232,22 @@ export function myChatMemberFilter< * you must add 'chat_member' to the list of allowed updates. */ export function chatMemberFilter< - C extends Context, - Q1 extends ChatMemberQuery, - Q2 extends ChatMemberQuery, + C extends Context, + Q1 extends ChatMemberQuery, + Q2 extends ChatMemberQuery, >(oldStatus: MaybeArray, newStatus: MaybeArray) { - return ( - ctx: C, - ): ctx is Filter & { - chatMember: { - old_chat_member: FilteredChatMember; - new_chat_member: FilteredChatMember; - }; - } => { - return ( - ctx.has("chat_member") && - chatMemberIs(ctx.chatMember.old_chat_member, oldStatus) && - chatMemberIs(ctx.chatMember.new_chat_member, newStatus) - ); + return ( + ctx: C, + ): ctx is Filter & { + chatMember: { + old_chat_member: FilteredChatMember; + new_chat_member: FilteredChatMember; }; + } => { + return ( + ctx.has("chat_member") && + chatMemberIs(ctx.chatMember.old_chat_member, oldStatus) && + chatMemberIs(ctx.chatMember.new_chat_member, newStatus) + ); + }; }