diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3a1397a..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -src/responses.json -responses_test.bot.ts \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index fb05d5a..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "deno.enable": true, - "deno.lint": true, - "deno.unstable": false, - "deno.suggest.imports.hosts": { - "https://deno.land": true, - "https://x.nest.land": true, - "https://crux.land": true, - "https://lib.deno.dev": true - } -} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 47576e6..3e8ddff 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Dunkan +Copyright (c) 2022-2023 Dunkan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 88baa4f..fb93fa3 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,97 @@ -

๐Ÿงช grammY Tests

+> **Warning**: This is the re-write branch and it IS unstable. If you want to use a little-broken-and-old version of this library, switch to the main branch. -**Work in progress!** +# Test framework for grammY -Testing framework for Telegram bots written using [grammY](https://grammy.dev). -Written in [TypeScript](https://typescript.org) and [Deno](https://deno.land/). +A **work-in-progress** framework for testing Telegram bots made using the grammY +Telegram bot framework. grammY is a great framework for developing Telegram bots and it's ecosystem provides everything you need for that. You can read more about grammY here: ****. -Check out the test examples in the [tests](/tests/) folder. The file -[bot.ts](/tests/bot.ts) contains an example logic of the bot we want to write -tests for. [user.test.ts](/tests/user.test.ts) has an example of -[test user instance](#testuser), which sends fake updates and checks whether -your bot replies as expected or not. +However, grammY lacks one important thing. A testing framwork, a good one. And this repository is only an +attempt to make one. I've regretted some choices that I made in the past about the architecture of the library. So, I'm re-writing the whole thing until I get it right. -## Writing tests +#### Installation -Export the -[`Bot`](https://doc.deno.land/https://deno.land/x/grammy/mod.ts/~/Bot) instance -you created. +**Note**: This library is **only available for Deno** at the moment. Node.js support will land when the library is stable and published on . -```ts -import { Bot } from "https://deno.land/x/grammy/mod.ts"; -export const bot = new Bot("BOT_TOKEN"); -// ...Your bot's handlers and logic goes here. -``` - -For now, consider the following simple program as the bot's logic. +You can import from GitHub raw URLs for now, +as this haven't been published on yet. ```ts -bot.command("start", async (ctx) => { - await ctx.reply("Hello there!"); -}); - -bot.hears("Hi", async (ctx) => { - await ctx.reply("Hi!"); -}); +import { Chats } from "https://raw.githubusercontent.com/dcdunkan/tests/refine-2/mod.ts"; ``` -Now the bot we are testing has a start command handler which replies "Hello -there!" and a "Hi" listener which says "Hi!" back. +> The URL above imports from this branch. It is recommended to use a versioned URL than this. -But, to make sure that our bot works as we want it to be, let's add some tests. +## Writing Tests -First, Import the bot object we created first to the test file. +Here is a simple setup showing how you can test your bot. Note that the example is pretty basic at the moment. It'll be extended more as the implementation progresses. -```ts -import { bot } from "./path/to/your/bot/file.ts"; -``` +**`bot.ts`** -Create a chat "manager" instance and create a user. With that user, we can send -fake updates to the bot and make sure that the bot responds as expected. +This file is supposed to export the `Bot` instance. You can have the logic and handlers of the bot in this file. ```ts -import { Chats } from "https://raw.githubusercontent.com/dcdunkan/tests/main/mod.ts"; - -// A testing helper function from Deno standard library. -import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; - -const chats = new Chats(bot); -const user = chats.newUser({ - id: 1234567890, - first_name: "Test user", - username: "test_user", -}); +import { Bot } from "https://deno.land/x/grammy/mod.ts"; +export const bot = new Bot(""); // <-- Put your token inside the quotes. +// Middlewares and logic goes here. For this example, +// we'll just register a /start command handler. +bot.command("start", (ctx) => ctx.reply("How you doin'?")); ``` -- Does the bot send "Hello there!" as expected, for the /start - command? - ```ts - Deno.test("start command", async () => { - await user.command("start"); - assertEquals(user.last.text, "Hello there!"); - }); - ``` - - What's happening here? +> **Warning**: +> Don't start your bot in long polling (`bot.start()`) in the bot.ts file as this framework isn't supposed to be used like that. To start your bot in long polling, create another file (perhaps a main.ts?), import the bot there, start it there and run that file. - user.command("start"); + - is going to send a /start command to the bot. Then we asserts the - text reply of the bot `user.last.text`, to the expected reply, "Hello, - there!". If the logic is right, our test should pass. +**`bot_test.ts`** -- Does the bot reply "Hi!" upon hearing "Hi" from the user? - ```ts - Deno.test("hi", async () => { - await user.sendMessage("Hi"); - assertEquals(user.last.text, "Hi!"); - }); - ``` +```ts +import { Chats } from "..."; +import { bot } from "./bot.ts"; +import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; - What's happening here? +const chats = new Chats(bot); - user.sendMessage("Hi"); +// Create a user to interact with the bot. +const user = chats.newUser({/* details of the user */}); - This sends a text message saying "Hi" to the bot. According to the hears - listener logic, the bot should be replying "Hi!" back. Just like with the - start command, we can compare the expected reply against `user.last.text`, - which points to the text in the last message the bot sent. +// Send a message to the bot. +await user.sendMessage("Hello there!"); -Now let's run the tests using -[Deno's built-in test runner](https://deno.land/manual/testing). +// Looking good. -```bash -deno test # You might pass permission flags like --allow-env if needed. +// Let's actually test something: The start command. +Deno.test("Start command", async () => { + await user.command("start"); + // So the bot replies, and after the bot handles it, + // it's response payload becomes available in the last object. + assertEquals(user.last.text, "How you doin'?"); +}); ``` -If everything's fine, and your tests were successful, which means your bot is -working as expected, you will see the `deno test` command logging a bunch of -green OK-s. It works! ๐ŸŽ‰ - -### Updates and Responses - -user.last contains payload of the last response sent by the -bot. In the first case, it'll look like: +There are methods other than just `sendMessage` and `command`. You can try them out. If you want to see a more up-to-date (not exactly, but yes) example, that is used for testing the implementation while developing this library, checkout the **[example.ts](./example.ts)** file. -```jsonc -{ - "chat_id": 1234567890, - "text": "Hello there!" -} -``` +> **TIP**: +> Like the `user.last` has the payload of the latest response, there is `user.responses`, containing all of the responses and `user.updates` is another array containing all the +> updates that have been sent to the user. -- We can access the text that bot is replying from `user.last.text` -- The actual last full response will be the last element of `user.responses` -- You can also get the updates (requests) the user sent from `user.updates` -- The last sent update is `user.lastUpdate` +That's a simple enough setup. Now you can run the test using `deno test`, and you should see a +bunch of green OKs printing out in the terminal. -## Chat types +## How Does This Work? -### TestUser +First consider reading what the Official grammY Documentation says about testing your bots: . -Defined at: [src/TestUser.ts](/src/TestUser.ts) +This framework handles takes care of what you read there: -Represents a Telegram user (Private chat). They can send, edit, forward, pin -messages; send commands, media; query inline and click buttons. And stop, block, -restart the bot. +- It handles all the outgoing API requests (from the bot) behind the curtain; and the dynamically generated API responses respects the environment the bot is in. So, it should work very well with all of the methods. +- Generating updates for force-testing the bot can be hard and tedious. This framework provides enough methods to cover almost all of your needs. -## TODO +> A much more detailed explanation will be added here later on. -- Add `Group`, `SuperGroup`, `Channel` chat types. -- Return proper API request results. +--- - Example use case: - ```ts - const message = await ctx.reply("Hello!"); - // Currently `message` is just `true`. - console.log(message.message_id); - // => undefined - ``` -- Add more _practical_ test cases and examples. +
---- +Licensed under MIT © 2023 Dunkan -

- - Licensed under MIT - -

+
\ No newline at end of file diff --git a/_parse_mode.ts b/_parse_mode.ts new file mode 100644 index 0000000..aadaa6a --- /dev/null +++ b/_parse_mode.ts @@ -0,0 +1,217 @@ +import { Chats } from "./chats.ts"; +import { Context, HTMLParser, HTMLParserHandler, Types } from "./deps.ts"; + +type Attributes = Record; +/* +bold, bold +italic, italic +underline, underline +strikethrough, strikethrough, strikethrough +spoiler, spoiler +bold italic bold italic bold strikethrough italic bold strikethrough spoiler underline italic bold bold +inline URL +inline mention of a user +inline fixed-width code +
pre-formatted fixed-width code block
+
pre-formatted fixed-width code block written in the Python programming language
+*/ + +type TelegramHTMLTags = + | "strong" + | "b" + | "tg-spoiler" + | "span" // span class="tg-spoiler" + | "u" + | "ins" + | "em" + | "i" + | "del" + | "s" + | "strike" + | "pre" + | "code" + | "a"; + +export class HTMLToTelegramHandler + implements HTMLParserHandler { + text: string; + entities: Types.MessageEntity[]; + + #env: Chats; + #buildingEntities: Map; + #openTags: string[]; + #openTagsMeta: (string | undefined)[]; + + constructor(env: Chats) { + this.#env = env; + this.text = ""; + this.entities = []; + this.#buildingEntities = new Map(); + this.#openTags = []; + this.#openTagsMeta = []; + } + + onopentag(name: TelegramHTMLTags, attributes: Attributes) { + this.#openTags.unshift(name); + this.#openTagsMeta.unshift(undefined); + let entityType: Types.MessageEntity["type"] | undefined; + // deno-lint-ignore no-explicit-any + const entityOptions: any = {}; + if (name === "strong" || name === "b") { + entityType = "bold"; + } else if ( + name === "tg-spoiler" || + (name === "span" && attributes.class.split(" ").includes("tg-spoiler")) + ) { + entityType = "spoiler"; + } else if (name === "em" || name === "i") { + entityType = "italic"; + } else if (name === "u" || name === "ins") { + entityType = "underline"; + } else if (name === "del" || name === "s" || name === "strike") { + entityType = "strikethrough"; + } else if (name === "code") { + const pre = this.#buildingEntities.get("pre"); + if (pre && pre.type === "pre" && attributes.class) { + const [_prefix, ...langSegs] = attributes.class.split(" ") + .filter((c) => c.match(/language-(.+)/)?.[1])[0]?.split("-"); + pre.language = langSegs.join("-"); + } else { + entityType = "code"; + } + } else if (name === "pre") { + entityType = "pre"; + } else if (name === "a") { + let url: string | undefined = attributes.href; + if (!url) return; + if (url.startsWith("tg://")) { + const id = Number(url.match(/tg:\/\/user\?id=(\d+)/)?.[1]); + if (isNaN(id)) return; + entityType = "text_mention"; + const chat = this.#env.chats.get(id); + if (!chat) { + entityOptions["user"] = { id, is_bot: false } as Types.User; + } else { + // TODO + } + } else { + entityType = "url"; + entityOptions["url"] = url; + url = undefined; + } + this.#openTagsMeta.shift(); + this.#openTagsMeta.unshift(url); + } + + if (entityType !== undefined && !this.#buildingEntities.has(name)) { + this.#buildingEntities.set(name, { + type: entityType, + offset: this.text.length, + length: 0, + ...entityOptions, + }); + } + } + + ontext(text: string) { + const prevTag = this.#openTags.length > 0 ? this.#openTags[0] : ""; + if (prevTag === "a") { + const url = this.#openTagsMeta[0]; + if (url) text = url; + } + for (const [_, entity] of this.#buildingEntities) { + entity.length += text.length; + } + this.text += text; + } + + onclosetag(tagname: string) { + this.#openTagsMeta.shift(); + this.#openTags.shift(); + const entity = this.#buildingEntities.get(tagname); + if (entity) { + this.#buildingEntities.delete(tagname); + this.entities.push(entity); + } + } + + onattribute() {} + oncdataend() {} + oncdatastart() {} + oncomment() {} + oncommentend() {} + onend() {} + onerror() {} + onopentagname() {} + onparserinit() {} + onprocessinginstruction() {} + onreset() {} +} + +function stripText(text: string, entities: Types.MessageEntity[]) { + if (!entities || !entities.length) return text.trim(); + while (text && text[text.length - 1].trim() === "") { + const entity = entities[entities.length - 1]; + if (entity.offset + entity.length === text.length) { + if (entity.length === 1) { + entities.pop(); + if (!entities.length) return text.trim(); + } else entity.length -= 1; + } + text = text.slice(0, -1); + } + while (text && text[0].trim() === "") { + for (const entity of entities) { + if (entity.offset !== 0) { + entity.offset--; + continue; + } + if (entity.length === 1) { + entities.shift(); + if (!entities.length) return text.trimStart(); + } else entity.length -= 1; + } + text = text.slice(1); + } + return text; +} + +export function HTMLtoEntities(env: Chats, html: string) { + if (!html) return { text: html, entities: [] }; + const handler = new HTMLToTelegramHandler(env); + const parser = new HTMLParser(handler); + parser.write(html); + parser.end(); + const text = stripText(handler.text, handler.entities); + return { text, entities: handler.entities }; +} + +export class ParseMode { + #env: Chats; + #text: Types.Message["text"]; + #entities?: Types.MessageEntity[]; + #parseMode?: Types.ParseMode; + + constructor( + env: Chats, + text: string, + options: { + entities?: Types.MessageEntity[]; + parseMode?: Types.ParseMode; + }, + ) { + this.#env = env; + this.#text = text; + this.#entities = options.entities; + this.#parseMode = options.parseMode; + } + + toEntities(): { text: string; entities: Types.MessageEntity[] } { + if (!this.#text) throw new Error("No valid text"); + if (!this.#parseMode) throw new Error("No parse mode specified"); + if (this.#parseMode.toLowerCase() === "html") { + return HTMLtoEntities(this.#env, this.#text); + } + return { text: this.#text, entities: [] }; + } +} diff --git a/channel.ts b/channel.ts new file mode 100644 index 0000000..dd4855f --- /dev/null +++ b/channel.ts @@ -0,0 +1,176 @@ +import type { Bot, Context, Types } from "./deps.ts"; +import type { Chats } from "./chats.ts"; +import type { MessageId, UserId } from "./types.ts"; +import { defaultChatAdministratorRights } from "./constants.ts"; + +type DetailsExceptObvious = Omit; +type DetailsFromGetChat = Omit< + Types.Chat.ChannelGetChat, + keyof Types.Chat.ChannelChat | "pinned_message" +>; + +export interface ChannelChatDetails extends DetailsExceptObvious { + owner: UserId | Types.ChatMemberOwner; + administrators?: (UserId | Types.ChatMemberAdministrator)[]; + promotedByBot?: boolean; + members?: (UserId | Types.ChatMember)[]; + + pinnedMessages?: Types.Message[]; + additional?: DetailsFromGetChat; + administratorRights?: Types.ChatAdministratorRights; +} + +export class ChannelChat { + readonly type = "channel"; + isBotAMember = false; + + chat_id: number; + chat: Types.Chat.ChannelChat; + + #bot: Bot; + #env: Chats; + + // Channel Related + owner?: UserId; + members = new Map(); + + pinnedMessages = new Set(); + recentPinnedMessage?: MessageId; + messages = new Map(); + + administratorRights: Types.ChatAdministratorRights; + + constructor(env: Chats, public details: ChannelChatDetails) { + this.#env = env; + this.#bot = env.getBot(); + + // Chat Info + this.chat_id = details.id; + this.chat = { + id: details.id, + type: "channel", + title: details.title, + username: details.username, + }; + + // Members + if (typeof details.owner === "number") { + const chat = env.chats.get(details.owner); + if (chat === undefined || chat.type !== "private") { + throw new Error("Cannot create a group without a user owner."); + } + this.owner = details.owner; + this.members.set(this.owner, { + status: "creator", + user: chat.user, + is_anonymous: false, + }); + } else { + this.owner = details.owner.user.id; + this.members.set(this.owner, details.owner); + } + + if (this.owner === this.#bot.botInfo.id) { + throw new Error("You cannot add bot as owner of the group"); + } + + // TODO: WARN: Overwrites existing members. + details.members?.map((member) => { + if (typeof member === "number") { + if (member === this.owner) { + throw new Error( + "DO NOT add creator/owner of the group through members. Use `owner` instead.", + ); + } + const chat = env.chats.get(member); + if (chat === undefined || chat.type !== "private") return; // TODO: throw error? + this.members.set(member, { status: "member", user: chat.user }); + } else { + if (member.status === "creator") { + throw new Error( + "DO NOT add creator/owner of the group through members. Use `owner` instead.", + ); + } + this.members.set(member.user.id, member); + } + }); + + this.administratorRights = details.administratorRights ?? + defaultChatAdministratorRights; + + // TODO: WARN: Overwrites existing member. + details.administrators?.map((member) => { + if (typeof member === "number") { + if (member === this.#bot.botInfo.id) { + this.members.set(member, { + status: "administrator", + user: { + id: this.#bot.botInfo.id, + is_bot: true, + username: this.#bot.botInfo.username, + first_name: this.#bot.botInfo.first_name, + last_name: this.#bot.botInfo.last_name, + }, + can_be_edited: false, + ...env.myDefaultAdministratorRights.groups, + }); + } else { + const chat = env.chats.get(member); + if (chat === undefined || chat.type !== "private") return; // TODO: throw error? + this.members.set(member, { + status: "administrator", + user: chat.user, + can_be_edited: !!details.promotedByBot, + ...this.administratorRights, + }); + } + } else { + this.members.set(member.user.id, member); + } + }); + + if (this.members.has(this.#bot.botInfo.id)) { + this.isBotAMember = true; + } + + // Messages + details.pinnedMessages?.map((message) => { + if (this.pinnedMessages.has(message.message_id)) { + throw new Error("Message was already pinned"); + } + this.messages.set(message.message_id, message); + this.pinnedMessages.add(message.message_id); + }); + + this.recentPinnedMessage = details.pinnedMessages?.at(-1)?.message_id; + } + + getChat(): Types.Chat.ChannelGetChat { + return { + ...this.chat, + ...this.details.additional, + ...(this.recentPinnedMessage && + this.messages.has(this.recentPinnedMessage) + ? { pinned_message: this.messages.get(this.recentPinnedMessage) } + : {}), + }; + } + + getChatMember(userId: UserId): Types.ChatMember | { status: "not-found" } { + const member = this.members.get(userId); + if (member === undefined) return { status: "not-found" }; + if (member.status === "kicked") { + return member.until_date < this.#env.date + ? { status: "left", user: member.user } + : member; + } + if (member.status === "restricted") { + return member.until_date < this.#env.date + ? member.is_member + ? { status: "member", user: member.user } + : { status: "left", user: member.user } + : member; + } + return member; + } +} diff --git a/chats.ts b/chats.ts new file mode 100644 index 0000000..fefac5c --- /dev/null +++ b/chats.ts @@ -0,0 +1,208 @@ +import { Bot, Context, debug, Types } from "./deps.ts"; +import type { + BotCommands, + BotDescriptions, + ChatType, + EnvironmentOptions, + InlineQueryResultCached, + MyDefaultAdministratorRights, +} from "./types.ts"; +import { PrivateChat, PrivateChatDetails } from "./private.ts"; +import { GroupChat, GroupChatDetails } from "./group.ts"; +import { SupergroupChat, SupergroupChatDetails } from "./supergroup.ts"; +import { ChannelChat, ChannelChatDetails } from "./channel.ts"; +import { bakeHandlers } from "./methods/mod.ts"; +import * as CONSTANTS from "./constants.ts"; +import { isChatAdministratorRight, isChatPermission, rand } from "./helpers.ts"; + +/** Emulates a Telegram environment, and everything's in it. */ +export class Chats { + /** Generated random values that are currently in use. */ + randomsInUse: Record> = {}; + /** Responsible for generating unique IDs and strings. */ + unique(generatorFn: () => T) { + const type = generatorFn.name; + let random = generatorFn(); + if (this.randomsInUse[type] === undefined) { + this.randomsInUse[type] = new Set(); + } + while (this.randomsInUse[type].has(random)) random = generatorFn(); + this.randomsInUse[type].add(random); + return random; + } + + private d: ReturnType; + + // Properties existing without other chats. + commands: BotCommands = CONSTANTS.BotCommandsDefault; + descriptions: BotDescriptions = CONSTANTS.BotDescriptionsDefault; + myDefaultAdministratorRights: MyDefaultAdministratorRights; + defaultChatMenuButton: Types.MenuButton; + + // Properties that are related with a chat. + inlineQueries: Map = new Map(); + cachedInlineQueryResults: Map< + Types.InlineQueryResultCachedGif["id"], + InlineQueryResultCached + > = new Map(); + + updates: Map = new Map(); + update_id = 100000000; + + get updateId() { + return this.update_id++; + } + + get date() { + return Math.trunc(Date.now() / 1000); + } + + // Properties of the Telegram environment. + chats: Map> = new Map(); + + constructor(private bot: Bot, options?: EnvironmentOptions) { + this.bot.botInfo = bot.isInited() && bot.botInfo + ? bot.botInfo + : options?.botInfo ?? { + id: this.unique(rand.botId), + first_name: "Test", + last_name: "Bot", + username: "testbot", + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + is_bot: true, + }; + + this.myDefaultAdministratorRights = options?.myDefaultAdministratorRights ?? + CONSTANTS.defaultBotAdministratorRights; + + this.defaultChatMenuButton = options?.defaultChatMenuButton ?? + CONSTANTS.MenuButtonDefault; + + const handlers = bakeHandlers(); + this.bot.api.config.use((prev, method, payload, signal) => { + const handler = handlers[method]; + return handler + // deno-lint-ignore no-explicit-any + ? handler(this, payload) as any // TODO: Fix the type issue. + : prev(method, payload, signal); + }); + + this.d = debug("chats"); + } + + /** Get the bot installed on the environment. */ + getBot() { + return this.bot; + } + + /** Resolve an username registered in the environment */ + resolveUsername(username: string): ChatType | undefined { + for (const chat of this.chats.values()) { + if (chat.chat.type === "group") continue; + if (chat.chat.username === username) return chat; + } + } + + getChatMember( + userId: number, + chatId: number, + ): Types.ChatMember | { status: "not-found" | "chat-not-found" } { + const chat = this.chats.get(chatId); + if (chat === undefined) return { status: "chat-not-found" }; + if (chat.type === "private") { + this.d("No need for checking if user is a member of private chat"); + return { status: "chat-not-found" }; // Yes, thats how Bot API works. + } + return chat.getChatMember(userId); + } + + /** Does the user have the permission to do something in the chat. */ + userCan( + userId: number, + chatId: number, + permission: + | keyof Types.ChatPermissions + | keyof Types.ChatAdministratorRights, + ): boolean { + const member = this.getChatMember(userId, chatId); + + if ( + member.status === "chat-not-found" || + member.status === "not-found" || + member.status === "kicked" || + member.status === "left" + ) return false; + if (member.status === "creator") return true; + if (member.status === "administrator") { + if (isChatAdministratorRight(permission)) { + return !!member[permission]; + } + } + + if (!isChatPermission(permission)) { + throw new Error(`Invalid permission '${permission}'`); + } + + if (member.status === "restricted") return !!member[permission]; + + const chat = this.chats.get(chatId)!; + if (chat.type === "channel") return false; + if (chat.type === "private") return true; // never reached + if (chat.type === "group") return true; + + return !!chat.permissions[permission]; + } + + /** Create and register a new Telegram user in the environment. */ + newUser(details: PrivateChatDetails) { + if (this.chats.has(details.id)) { + throw new Error("Chat with the same ID already exists."); + } + const privateChat = new PrivateChat(this, details); + this.chats.set(privateChat.chat_id, privateChat); + return privateChat; + } + + /** Create and register a new group in the environment. */ + newGroup(details: GroupChatDetails) { + if (this.chats.has(details.id)) { + throw new Error("Chat with the same ID already exists."); + } + const groupChat = new GroupChat(this, details); + this.chats.set(groupChat.chat_id, groupChat); + return groupChat; + } + + /** Create and register a new supergroup in the environment. */ + newSuperGroup(details: SupergroupChatDetails) { + if (this.chats.has(details.id)) { + throw new Error("Chat with the same ID already exists."); + } + const supergroupChat = new SupergroupChat(this, details); + this.chats.set(supergroupChat.chat_id, supergroupChat); + return supergroupChat; + } + + /** Create and register a new channel in the environment. */ + newChannel(details: ChannelChatDetails) { + if (this.chats.has(details.id)) { + throw new Error("Chat with the same ID already exists."); + } + const channelChat = new ChannelChat(this, details); + this.chats.set(channelChat.chat_id, channelChat); + return channelChat; + } + + /** Validate the update before sending it. */ + validateUpdate(update: Omit): Types.Update { + // TODO: the actual validation. + return { ...update, update_id: this.updateId }; + } + + /** Send update to the bot. */ + sendUpdate(update: Types.Update) { + return this.bot.handleUpdate(update); + } +} diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..fd9f238 --- /dev/null +++ b/constants.ts @@ -0,0 +1,84 @@ +import { Types } from "./deps.ts"; +import { BotCommands, BotDescriptions } from "./types.ts"; + +export const defaultBotAdministratorRights: Record< + "groups" | "channels", + Types.ChatAdministratorRights +> = { + groups: { + can_manage_chat: false, + can_change_info: false, + can_post_messages: false, + can_edit_messages: false, + can_delete_messages: false, + can_invite_users: false, + can_restrict_members: false, + can_promote_members: false, + can_manage_video_chats: false, + is_anonymous: false, + can_pin_messages: false, + can_manage_topics: false, + }, + channels: { + can_manage_chat: false, + can_change_info: false, + can_post_messages: false, + can_edit_messages: false, + can_delete_messages: false, + can_invite_users: false, + can_restrict_members: false, + can_promote_members: false, + can_manage_video_chats: false, + is_anonymous: false, + can_pin_messages: false, + can_manage_topics: false, + }, +}; + +export const defaultChatAdministratorRights: Types.ChatAdministratorRights = { + is_anonymous: false, + can_manage_video_chats: true, + can_promote_members: true, + can_invite_users: true, + can_change_info: true, + can_manage_chat: true, + can_pin_messages: true, + can_edit_messages: true, + can_manage_topics: true, + can_post_messages: true, + can_delete_messages: true, + can_restrict_members: true, +}; + +export const defaultChatPermissions: Types.ChatPermissions = { + can_send_messages: true, + can_send_polls: true, + can_invite_users: true, + can_change_info: true, + can_send_audios: true, + can_send_photos: true, + can_send_videos: true, + can_pin_messages: true, + can_manage_topics: false, + can_send_documents: true, + can_send_video_notes: true, + can_send_voice_notes: true, + can_send_other_messages: true, + can_add_web_page_previews: true, +}; + +export const MenuButtonDefault: Types.MenuButton = { type: "default" }; + +export const BotCommandsDefault: BotCommands = { + default: { "": [] }, + all_private_chats: { "": [] }, + all_group_chats: { "": [] }, + all_chat_administrators: { "": [] }, + chat: {}, + chat_member: {}, + chat_administrators: {}, +}; + +export const BotDescriptionsDefault: BotDescriptions = { + "": { description: "", short_description: "" }, +}; diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..09b20b0 --- /dev/null +++ b/deno.json @@ -0,0 +1 @@ +{ "lock": false } diff --git a/deps.ts b/deps.ts index 6f6acba..59c5c80 100644 --- a/deps.ts +++ b/deps.ts @@ -1,28 +1,25 @@ -import { InputFile } from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; -import { InputFileProxy } from "https://lib.deno.dev/x/grammy@1.x/types.ts"; +export * as Types from "https://deno.land/x/grammy@v1.15.1/types.ts"; +export type { + Bot, + Context, + RawApi, +} from "https://deno.land/x/grammy@v1.15.1/mod.ts"; -// All of these types were imported from grammyjs/grammY source. -type GrammyTypes_ = InputFileProxy; -type Telegram = GrammyTypes_["Telegram"]; -type Opts = GrammyTypes_["Opts"][M]; +// export type { Handler as HTMLParserHandler } from "https://deno.land/x/htmlparser@v4.1.1/htmlparser2/Parser.ts"; +// export { Parser as HTMLParser } from "https://deno.land/x/htmlparser@v4.1.1/htmlparser2/Parser.ts"; -export type RawApi = { - [M in keyof Telegram]: Parameters[0] extends undefined - ? (signal?: AbortSignal) => Promise> - : ( - args: Opts, - signal?: AbortSignal, - ) => Promise>; -}; +// a basic debug implementation +import * as c from "https://deno.land/std@0.180.0/fmt/colors.ts"; +const COLORS = [c.red, c.green, c.blue, c.magenta, c.yellow, c.gray]; -export type Methods = string & keyof R; -export type Payload, R extends RawApi> = M extends unknown - ? R[M] extends (signal?: AbortSignal) => unknown // deno-lint-ignore ban-types - ? {} // deno-lint-ignore no-explicit-any - : R[M] extends (args: any, signal?: AbortSignal) => unknown - ? Parameters[0] - : never - : never; +function colorFn(fn: (s: string) => string) { + return Deno.noColor ? (s: string) => s : fn; +} -export * as GrammyTypes from "https://lib.deno.dev/x/grammy@1.x/types.ts"; -export { Bot, Context } from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; +export function debug(ns: string) { + ns = colorFn(COLORS[Math.floor(Math.random() * COLORS.length)])(ns); + return function (...data: unknown[]) { + const now = new Date().toLocaleTimeString("en-US", { hour12: false }); + console.error(c.cyan(now), ns, ...data); + }; +} diff --git a/errors.ts b/errors.ts new file mode 100644 index 0000000..cda6b3e --- /dev/null +++ b/errors.ts @@ -0,0 +1,29 @@ +/** List of API errors in the format: [HTTP status code, Error Message] */ +export const ERRORS = { + not_implemented: [501, "Not Implemented"], + excluded_method: [ + 501, +"This method is not implemented and is excluded from \ +implementing, most likely because its not worth it or does not \ +make sense implementing it because it is related to setting up \ +the bot and has no use in testing.", + ], + chat_not_found: [404, "Chat not found"], + user_not_found: [404, "User not found"], + its_private_chat: [400, "Chat is a private chat"], + its_not_private_chat: [400, "Chat is not a private chat"], + its_group_chat: [400, "Chat is a group chat"], + its_channel_chat: [400, "Chat is a channel chat"], + chat_member_not_found: [404, "Chat member not found"], + reply_to_message_not_found: [404, "Reply to message not found"], + private_chat_member_status: [ + 400, + "chat member status can't be changed in private chats", + ], + not_a_member: [400, "Not a member"], +} as const; + +export const PREFIXES: Record = { + 400: "Bad Request: ", + 404: "Not Found: ", +}; diff --git a/example.ts b/example.ts new file mode 100644 index 0000000..280f943 --- /dev/null +++ b/example.ts @@ -0,0 +1,50 @@ +import { Chats } from "./chats.ts"; +import { Bot, Context } from "https://deno.land/x/grammy@v1.15.1/mod.ts"; + +/// Setup Bot +type MyContext = Context; // Can be extended. + +const bot = new Bot("token"); + +bot.command("start", async (ctx) => { + /* const sent = */ await ctx.reply("Hello."); + // console.log(sent); // Dynamically generated! +}); + +/// Test setup +const chats = new Chats(bot); + +const user = chats.newUser({ + id: 123, + blocked: false, + username: "mak", + first_name: "kek", + last_name: "none", + language_code: "en", +}); + +const group = chats.newGroup({ + id: 234, + title: "Movie Night", + owner: { + status: "creator", + user: { id: 345, first_name: "The Owner", is_bot: false }, + is_anonymous: false, + }, +}); + +user.join(group.chat_id); +user.onEvent("message", () => { + // console.log("User recieved a message from the bot saying", m.text); +}); +// Send a message to the bot. +// await user.sendMessage("Hello"); +await user.command("start"); +// Send a message to the group. +await user.in(group).sendMessage("Hi everyone!"); + +// or first declare a state of the user: +const userInGroup = user.in(group); +await userInGroup.sendMessage("Hi again!"); +// and other properties can be accesses as well: +// userInGroup.sendVideo(...) diff --git a/group.ts b/group.ts new file mode 100644 index 0000000..e3ff4a6 --- /dev/null +++ b/group.ts @@ -0,0 +1,141 @@ +import type { Bot, Context, Types } from "./deps.ts"; +import type { Chats } from "./chats.ts"; +import type { MessageId, UserId } from "./types.ts"; + +type DetailsExceptObvious = Omit; +type DetailsFromGetChat = Omit< + Types.Chat.GroupGetChat, + keyof Types.Chat.GroupChat | "pinned_message" +>; + +export interface GroupChatDetails extends DetailsExceptObvious { + owner: UserId | Types.ChatMemberOwner; + members?: (UserId | Types.ChatMember)[]; + + pinnedMessages?: Types.Message[]; + additional?: DetailsFromGetChat; + chatMenuButton?: Types.MenuButton; +} + +// TODO: Validate if members are added as admins. +export class GroupChat { + readonly type = "group"; + isBotAMember = false; + + chat_id: number; + chat: Types.Chat.GroupChat; + + #bot: Bot; + #env: Chats; + + // Group related + owner?: UserId; + members = new Map(); + + pinnedMessages = new Set(); + recentPinnedMessage?: MessageId; + messages = new Map(); + + chatMenuButton: Types.MenuButton; + + constructor(env: Chats, public details: GroupChatDetails) { + this.#env = env; + this.#bot = env.getBot(); + + // Chat Info + this.chat_id = details.id; + this.chat = { + type: "group", + id: details.id, + title: details.title, + }; + + // Members + if (typeof details.owner === "number") { + const chat = env.chats.get(details.owner); + if (chat === undefined || chat.type !== "private") { + throw new Error("Cannot create a group without a user owner."); + } + this.owner = details.owner; + this.members.set(this.owner, { + status: "creator", + user: chat.user, + is_anonymous: false, + }); + } else { + this.owner = details.owner.user.id; + this.members.set(this.owner, details.owner); + } + + if (this.owner === this.#bot.botInfo.id) { + throw new Error("You cannot add bot as owner of the group"); + } + + details.members?.map((member) => { + if (typeof member === "number") { + if (member === this.owner) { + throw new Error( + "DO NOT add creator/owner of the group through members. Use `owner` instead.", + ); + } + const chat = env.chats.get(member); + if (chat === undefined || chat.type !== "private") return; // TODO: throw error? + this.members.set(member, { status: "member", user: chat.user }); + } else { + if (member.status === "creator") { + throw new Error( + "DO NOT add creator/owner of the group through members. Use `owner` instead.", + ); + } + this.members.set(member.user.id, member); + } + }); + + if (this.members.has(this.#bot.botInfo.id)) { + this.isBotAMember = true; + } + + // Messages + details.pinnedMessages?.map((message) => { + if (this.pinnedMessages.has(message.message_id)) { + throw new Error("Message was already pinned"); + } + this.messages.set(message.message_id, message); + this.pinnedMessages.add(message.message_id); + }); + + this.recentPinnedMessage = details.pinnedMessages?.at(-1)?.message_id; + + // Other + this.chatMenuButton = details.chatMenuButton ?? env.defaultChatMenuButton; + } + + getChat(): Types.Chat.GroupGetChat { + return { + ...this.chat, + ...this.details.additional, + ...(this.recentPinnedMessage && + this.messages.has(this.recentPinnedMessage) + ? { pinned_message: this.messages.get(this.recentPinnedMessage) } + : {}), + }; + } + + getChatMember(userId: UserId): Types.ChatMember | { status: "not-found" } { + const member = this.members.get(userId); + if (member === undefined) return { status: "not-found" }; + if (member.status === "kicked") { + return member.until_date < this.#env.date + ? { status: "left", user: member.user } + : member; + } + if (member.status === "restricted") { + return member.until_date < this.#env.date + ? member.is_member + ? { status: "member", user: member.user } + : { status: "left", user: member.user } + : member; + } + return member; + } +} diff --git a/helpers.ts b/helpers.ts new file mode 100644 index 0000000..6edb9fa --- /dev/null +++ b/helpers.ts @@ -0,0 +1,64 @@ +import { Types } from "./deps.ts"; +import { ERRORS, PREFIXES } from "./errors.ts"; +import { + defaultChatAdministratorRights, + defaultChatPermissions, +} from "./constants.ts"; +import { ChatAdministratorRights, ChatPermissions } from "./types.ts"; + +/** API related properties */ +export const api = { + /** Current Bot API version */ + BOT_API_VERSION: "6.6", + /** Return API result */ + result( // extends Awaited> + result: T, + ): Promise> { + return Promise.resolve({ ok: true, result }); + }, + /** Return API error */ + error( + error: keyof typeof ERRORS, + message = "", + parameters?: Types.ResponseParameters, + ): Promise { + const err = ERRORS[error]; + return Promise.resolve({ + ok: false, + error_code: err[0], + description: `${PREFIXES[err[0]] ? PREFIXES[err[0]] : ""}${err[1]}${ + message ? `: ${message}` : "" + }`, + ...(parameters ? { parameters } : {}), + }); + }, +}; + +/** + * Collection of functions for generating real-looking random + * values for using in Telegram API requests and responses. + */ +export const rand = { + botId: () => randomNumberInBetween(1000000000, 9999999999), +}; + +function randomNumberInBetween(start: number, end: number) { + return Math.floor(start + Math.random() * end); +} + +/** Returns current time in Telegram's format. */ +export function date() { + return Math.trunc(Date.now() / 1000); +} + +export function isChatPermission( + permission: string, +): permission is ChatPermissions { + return Object.keys(defaultChatPermissions).includes(permission); +} + +export function isChatAdministratorRight( + permission: string, +): permission is ChatAdministratorRights { + return Object.keys(defaultChatAdministratorRights).includes(permission); +} diff --git a/methods/bot_settings.ts b/methods/bot_settings.ts new file mode 100644 index 0000000..00133ef --- /dev/null +++ b/methods/bot_settings.ts @@ -0,0 +1,202 @@ +import type { Context, Types } from "../deps.ts"; +import type { + Handler, + Handlers, + LocalizedCommands, + Methods, +} from "../types.ts"; +import { api } from "../helpers.ts"; +import * as CONSTANTS from "../constants.ts"; + +// TODO: Find a proper way to dedupe the logic used in *MyCommands. +export function botSettingsMethods(): Handlers< + C, + Methods<"bot_settings"> +> { + const setMyCommands: Handler = (env, payload) => { + const language = payload.language_code ?? ""; + const scope = payload.scope; + const commands = payload.commands as Types.BotCommand[]; + if (!scope?.type || scope.type === "default") { + env.commands["default"][language] = commands; + } else if ( + scope.type === "chat" || scope.type === "chat_administrators" || + scope.type === "chat_member" + ) { + let chatId = scope.chat_id; + if (typeof chatId === "string") { + const resolvedChat = env.resolveUsername(chatId); + if (!resolvedChat) return api.error("chat_not_found"); + chatId = resolvedChat.chat_id; + } + if (env.commands[scope.type][chatId] === undefined) { + env.commands[scope.type][chatId] = {}; + } + if (scope.type !== "chat_member") { + env.commands[scope.type][chatId][language] = commands; + return api.result(true); + } + // chat_member scope + if (env.commands[scope.type][chatId][scope.user_id] === undefined) { + env.commands[scope.type][chatId][scope.user_id] = {}; + } + env.commands[scope.type][chatId][scope.user_id][language] = commands; + } else { + env.commands[scope.type][language] = commands; + } + return api.result(true); + }; + + const deleteMyCommands: Handler = (env, payload) => { + const language = payload.language_code ?? ""; + const scope = payload.scope; + if (!scope?.type || scope.type === "default") { + env.commands["default"][language] = []; // just reset. + } else if ( + scope.type === "chat" || scope.type === "chat_administrators" || + scope.type === "chat_member" + ) { + let chatId = scope.chat_id; + if (typeof chatId === "string") { + const resolvedChat = env.resolveUsername(chatId); + if (!resolvedChat) return api.error("chat_not_found"); + chatId = resolvedChat.chat_id; + } + if (scope.type !== "chat_member") { + if (env.commands[scope.type][chatId]?.[language]) { + env.commands[scope.type][chatId][language] = []; + } + return api.result(true); + } + if (env.commands[scope.type][chatId]?.[scope.user_id]?.[language]) { + env.commands[scope.type][chatId][scope.user_id][language] = []; + } + } else { + env.commands[scope.type][language] = []; // just reset. + } + return api.result(true); + }; + + const getMyCommands: Handler = (env, payload) => { + const language = payload.language_code ?? ""; + const scope = payload.scope; + let commands: LocalizedCommands | undefined; + if (!scope?.type || scope.type === "default") { + commands = env.commands["default"]; + } else if ( + scope.type === "chat" || scope.type === "chat_administrators" || + scope.type === "chat_member" + ) { + let chatId = scope.chat_id; + if (typeof chatId === "string") { + const resolvedChat = env.resolveUsername(chatId); + if (!resolvedChat) return api.error("chat_not_found"); + chatId = resolvedChat.chat_id; + } + commands = scope.type !== "chat_member" + ? env.commands[scope.type][chatId] + : env.commands[scope.type][chatId]?.[scope.user_id]; + } else { + commands = env.commands[scope.type]; + } + return api.result(commands?.[language] ?? []); + }; + + const setChatMenuButton: Handler = (env, payload) => { + const menuButton = payload.menu_button ?? CONSTANTS.MenuButtonDefault; + if (payload.chat_id === undefined) { + env.defaultChatMenuButton = menuButton; + } else { + const chat = env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + if (chat.type === "channel") return api.error("its_channel_chat"); + chat.chatMenuButton = menuButton; + } + return api.result(true); + }; + + const getChatMenuButton: Handler = (env, payload) => { + if (payload.chat_id === undefined) { + return api.result(env.defaultChatMenuButton); + } + const chat = env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + if (chat.type === "channel") return api.error("its_channel_chat"); + return api.result(chat.chatMenuButton); + }; + + const setMyDescription: Handler = (env, payload) => { + const language = payload.language_code ?? ""; + env.descriptions[language] = { + ...(env.descriptions[language] ?? {}), + description: payload.description ?? "", + }; + return api.result(true); + }; + + const getMyDescription: Handler = (env, payload) => { + const language = payload.language_code ?? ""; + return api.result({ + description: ( + env.descriptions[language] ?? env.descriptions[""] + )?.description ?? "", + }); + }; + + const setMyShortDescription: Handler< + C, + "setMyShortDescription" + > = (env, payload) => { + const language = payload.language_code ?? ""; + env.descriptions[language] = { + ...(env.descriptions[language] ?? {}), + short_description: payload.short_description ?? "", + }; + return api.result(true); + }; + + const getMyShortDescription: Handler< + C, + "getMyShortDescription" + > = (env, payload) => { + const language = payload.language_code ?? ""; + return api.result({ + short_description: ( + env.descriptions[language] ?? env.descriptions[""] + )?.short_description ?? "", + }); + }; + + const setMyDefaultAdministratorRights: Handler< + C, + "setMyDefaultAdministratorRights" + > = (env, payload) => { + const scope = payload.for_channels ? "channels" : "groups"; + if (payload.rights !== undefined) { + env.myDefaultAdministratorRights[scope] = payload.rights; + } + return api.result(true); + }; + + const getMyDefaultAdministratorRights: Handler< + C, + "getMyDefaultAdministratorRights" + > = (env, payload) => { + const scope = payload.for_channels ? "channels" : "groups"; + return api.result(env.myDefaultAdministratorRights[scope]); + }; + + return { + setMyCommands, + deleteMyCommands, + getMyCommands, + setChatMenuButton, + getChatMenuButton, + setMyDescription, + getMyDescription, + setMyShortDescription, + getMyShortDescription, + setMyDefaultAdministratorRights, + getMyDefaultAdministratorRights, + }; +} diff --git a/methods/chat_management.ts b/methods/chat_management.ts new file mode 100644 index 0000000..5c482c0 --- /dev/null +++ b/methods/chat_management.ts @@ -0,0 +1,188 @@ +import type { Context, Types } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function chatMethods(): Handlers< + C, + Methods<"chat_management"> +> { + const kickChatMember: Handler = () => + api.error("not_implemented"); + const banChatMember: Handler = () => + api.error("not_implemented"); + const unbanChatMember: Handler = () => + api.error("not_implemented"); + const restrictChatMember: Handler = () => + api.error("not_implemented"); + const promoteChatMember: Handler = () => + api.error("not_implemented"); + const setChatAdministratorCustomTitle: Handler< + C, + "setChatAdministratorCustomTitle" + > = () => api.error("not_implemented"); + const banChatSenderChat: Handler = () => + api.error("not_implemented"); + const unbanChatSenderChat: Handler = () => + api.error("not_implemented"); + const setChatPermissions: Handler = () => + api.error("not_implemented"); + const exportChatInviteLink: Handler = () => + api.error("not_implemented"); + const createChatInviteLink: Handler = () => + api.error("not_implemented"); + const editChatInviteLink: Handler = () => + api.error("not_implemented"); + const revokeChatInviteLink: Handler = () => + api.error("not_implemented"); + const approveChatJoinRequest: Handler = () => + api.error("not_implemented"); + const declineChatJoinRequest: Handler = () => + api.error("not_implemented"); + const setChatPhoto: Handler = () => + api.error("not_implemented"); + const deleteChatPhoto: Handler = () => + api.error("not_implemented"); + const setChatTitle: Handler = () => + api.error("not_implemented"); + const setChatDescription: Handler = () => + api.error("not_implemented"); + const pinChatMessage: Handler = () => + api.error("not_implemented"); + const unpinChatMessage: Handler = () => + api.error("not_implemented"); + const unpinAllChatMessages: Handler = () => + api.error("not_implemented"); + + const leaveChat: Handler = (env, payload) => { + const chat = typeof payload.chat_id === "string" + ? env.resolveUsername(payload.chat_id) + : env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + if (chat.type === "private") return api.error("private_chat_member_status"); + + const member = chat.getChatMember(env.getBot().botInfo.id); + if (member.status === "not-found") return api.error("chat_not_found"); // not reached + if ( + member.status === "left" || member.status === "kicked" || + (member.status === "restricted" && !member.is_member) + ) return api.error("not_a_member", "So cannot leave"); + + chat.isBotAMember = false; + + if (member.status === "restricted") { + chat.members.set(member.user.id, { ...member, is_member: false }); + } else { + chat.members.set(member.user.id, { status: "left", user: member.user }); + } + return api.result(true); + }; + + const getChat: Handler = (env, payload) => { + const chat = typeof payload.chat_id === "string" + ? env.resolveUsername(payload.chat_id) + : env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + return api.result(chat.getChat()); + }; + + const getChatAdministrators: Handler< + C, + "getChatAdministrators" + > = (env, payload) => { + const chat = typeof payload.chat_id === "string" + ? env.resolveUsername(payload.chat_id) + : env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + if (chat.type === "private") return api.error("its_private_chat"); + if (chat.type === "group") return api.error("its_group_chat"); + + const administrators: + (Types.ChatMemberOwner | Types.ChatMemberAdministrator)[] = []; + for (const member of chat.members.values()) { + if ( + member.status === "administrator" || + member.status === "creator" + ) administrators.push(member); + } + return api.result(administrators); + }; + + const getChatMemberCount: Handler< + C, + "getChatMemberCount" + > = (env, payload) => { + const chat = typeof payload.chat_id === "string" + ? env.resolveUsername(payload.chat_id) + : env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + if (chat.type === "private") return api.error("its_private_chat"); + let memberCount = 0; + for (const member of chat.members.values()) { + if ( + member.status === "creator" || + member.status === "member" || + member.status === "administrator" || + (member.status === "restricted" && member.is_member) + ) memberCount++; + } + return api.result(memberCount); + }; + + // deprecated + const getChatMembersCount: Handler< + C, + "getChatMemberCount" + > = (env, payload) => { + return getChatMemberCount(env, { chat_id: payload.chat_id }); + }; + + const getChatMember: Handler = (env, payload) => { + const chat = typeof payload.chat_id === "string" + ? env.resolveUsername(payload.chat_id) + : env.chats.get(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + // TODO: Does this return the member in private chat? + if (chat.type === "private") return api.error("its_private_chat"); + const member = chat.getChatMember(payload.user_id); + if (member.status === "not-found") return api.error("user_not_found"); + return api.result(member); + }; + + const setChatStickerSet: Handler = () => + api.error("not_implemented"); + const deleteChatStickerSet: Handler = () => + api.error("not_implemented"); + + return { + kickChatMember, + banChatMember, + unbanChatMember, + restrictChatMember, + promoteChatMember, + setChatAdministratorCustomTitle, + banChatSenderChat, + unbanChatSenderChat, + setChatPermissions, + exportChatInviteLink, + createChatInviteLink, + editChatInviteLink, + revokeChatInviteLink, + approveChatJoinRequest, + declineChatJoinRequest, + setChatPhoto, + deleteChatPhoto, + setChatTitle, + setChatDescription, + pinChatMessage, + unpinChatMessage, + unpinAllChatMessages, + leaveChat, + getChat, + getChatAdministrators, + getChatMembersCount, + getChatMemberCount, + getChatMember, + setChatStickerSet, + deleteChatStickerSet, + }; +} diff --git a/methods/forum_management.ts b/methods/forum_management.ts new file mode 100644 index 0000000..0e0da56 --- /dev/null +++ b/methods/forum_management.ts @@ -0,0 +1,48 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function forumManagementMethods(): Handlers< + C, + Methods<"forum_management"> +> { + const getForumTopicIconStickers: Handler = + () => api.error("not_implemented"); + const createForumTopic: Handler = () => + api.error("not_implemented"); + const editForumTopic: Handler = () => + api.error("not_implemented"); + const closeForumTopic: Handler = () => + api.error("not_implemented"); + const reopenForumTopic: Handler = () => + api.error("not_implemented"); + const deleteForumTopic: Handler = () => + api.error("not_implemented"); + const unpinAllForumTopicMessages: Handler = + () => api.error("not_implemented"); + const editGeneralForumTopic: Handler = () => + api.error("not_implemented"); + const closeGeneralForumTopic: Handler = () => + api.error("not_implemented"); + const reopenGeneralForumTopic: Handler = () => + api.error("not_implemented"); + const hideGeneralForumTopic: Handler = () => + api.error("not_implemented"); + const unhideGeneralForumTopic: Handler = () => + api.error("not_implemented"); + + return { + getForumTopicIconStickers, + createForumTopic, + editForumTopic, + closeForumTopic, + reopenForumTopic, + deleteForumTopic, + unpinAllForumTopicMessages, + editGeneralForumTopic, + closeGeneralForumTopic, + reopenGeneralForumTopic, + hideGeneralForumTopic, + unhideGeneralForumTopic, + }; +} diff --git a/methods/games.ts b/methods/games.ts new file mode 100644 index 0000000..80276cf --- /dev/null +++ b/methods/games.ts @@ -0,0 +1,20 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function gamesMethods(): Handlers< + C, + Methods<"games"> +> { + const sendGame: Handler = () => api.error("not_implemented"); + const setGameScore: Handler = () => + api.error("not_implemented"); + const getGameHighScores: Handler = () => + api.error("not_implemented"); + + return { + sendGame, + setGameScore, + getGameHighScores, + }; +} diff --git a/methods/getting_updates.ts b/methods/getting_updates.ts new file mode 100644 index 0000000..62a4139 --- /dev/null +++ b/methods/getting_updates.ts @@ -0,0 +1,24 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function gettingUpdatesMethods(): Handlers< + C, + Methods<"getting_updates"> +> { + const getUpdates: Handler = () => + api.error("not_implemented", "Do not call getUpdates or bot.start()"); + const setWebhook: Handler = () => + api.error("excluded_method"); + const deleteWebhook: Handler = () => + api.error("excluded_method"); + const getWebhookInfo: Handler = () => + api.error("excluded_method"); + + return { + getUpdates, + setWebhook, + deleteWebhook, + getWebhookInfo, + }; +} diff --git a/methods/inline_mode.ts b/methods/inline_mode.ts new file mode 100644 index 0000000..1fc9177 --- /dev/null +++ b/methods/inline_mode.ts @@ -0,0 +1,15 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function inlineModeMethods(): Handlers< + C, + Methods<"inline_mode"> +> { + const answerInlineQuery: Handler = () => + api.error("not_implemented"); + const answerWebAppQuery: Handler = () => + api.error("not_implemented"); + + return { answerInlineQuery, answerWebAppQuery }; +} diff --git a/methods/list.ts b/methods/list.ts new file mode 100644 index 0000000..735fd45 --- /dev/null +++ b/methods/list.ts @@ -0,0 +1,151 @@ +export const METHODS = { + // Getting Updates + getting_updates: [ + "getUpdates", + "setWebhook", + "deleteWebhook", + "getWebhookInfo", + ], + // Setup + setup: [ + "getMe", + "logOut", + "close", + ], + // Messages + messages: [ + "sendMessage", + "forwardMessage", + "copyMessage", + "sendPhoto", + "sendAudio", + "sendDocument", + "sendVideo", + "sendAnimation", + "sendVoice", + "sendVideoNote", + "sendMediaGroup", + "sendLocation", + "editMessageLiveLocation", + "stopMessageLiveLocation", + "sendVenue", + "sendContact", + "sendPoll", + "sendDice", + "sendChatAction", + "getUserProfilePhotos", + "getFile", + "answerCallbackQuery", + ], + // Chat Management + chat_management: [ + "kickChatMember", + "banChatMember", + "unbanChatMember", + "restrictChatMember", + "promoteChatMember", + "setChatAdministratorCustomTitle", + "banChatSenderChat", + "unbanChatSenderChat", + "setChatPermissions", + "exportChatInviteLink", + "createChatInviteLink", + "editChatInviteLink", + "revokeChatInviteLink", + "approveChatJoinRequest", + "declineChatJoinRequest", + "setChatPhoto", + "deleteChatPhoto", + "setChatTitle", + "setChatDescription", + "pinChatMessage", + "unpinChatMessage", + "unpinAllChatMessages", + "leaveChat", + "getChat", + "getChatAdministrators", + "getChatMembersCount", + "getChatMemberCount", + "getChatMember", + "setChatStickerSet", + "deleteChatStickerSet", + ], + /** Forum Management */ + forum_management: [ + "getForumTopicIconStickers", + "createForumTopic", + "editForumTopic", + "closeForumTopic", + "reopenForumTopic", + "deleteForumTopic", + "unpinAllForumTopicMessages", + "editGeneralForumTopic", + "closeGeneralForumTopic", + "reopenGeneralForumTopic", + "hideGeneralForumTopic", + "unhideGeneralForumTopic", + ], + /** Bot Settings */ + bot_settings: [ + "setMyCommands", + "deleteMyCommands", + "getMyCommands", + "setChatMenuButton", + "getChatMenuButton", + "setMyDescription", + "getMyDescription", + "setMyShortDescription", + "getMyShortDescription", + "setMyDefaultAdministratorRights", + "getMyDefaultAdministratorRights", + ], + // Updating Messages + updating_messages: [ + "editMessageText", + "editMessageCaption", + "editMessageMedia", + "editMessageReplyMarkup", + "stopPoll", + "deleteMessage", + ], + // Stickers + stickers: [ + "sendSticker", + "getStickerSet", + "getCustomEmojiStickers", + "uploadStickerFile", + "createNewStickerSet", + "addStickerToSet", + "setStickerPositionInSet", + "deleteStickerFromSet", + "setStickerEmojiList", + "setStickerKeywords", + "setStickerMaskPosition", + "setStickerSetTitle", + "deleteStickerSet", + "setStickerSetThumbnail", + "setCustomEmojiStickerSetThumbnail", + ], + // Inline Mode + inline_mode: [ + "answerInlineQuery", + "answerWebAppQuery", + ], + // Payments + payments: [ + "sendInvoice", + "createInvoiceLink", + "answerShippingQuery", + "answerPreCheckoutQuery", + ], + // Telegram Passport + telegram_passport: [ + "setPassportDataErrors", + ], + // Games + games: [ + "sendGame", + "setGameScore", + "getGameHighScores", + ], +} as const; diff --git a/methods/messages.ts b/methods/messages.ts new file mode 100644 index 0000000..3f08fb6 --- /dev/null +++ b/methods/messages.ts @@ -0,0 +1,111 @@ +import type { Context, Types } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function messagesMethods(): Handlers< + C, + Methods<"messages"> +> { + const sendMessage: Handler = async (env, payload) => { + const chat = typeof payload.chat_id === "number" + ? env.chats.get(payload.chat_id) + : env.resolveUsername(payload.chat_id); + if (chat === undefined) return api.error("chat_not_found"); + // TODO: Parse the text with parse mode to entities. + if (chat.type === "private") { + if ( + typeof payload.reply_to_message_id === "number" && + chat.messages.get(payload.reply_to_message_id) === undefined && + payload.allow_sending_without_reply === undefined + ) { + return api.error("reply_to_message_not_found"); + } + + const message: Types.Message.TextMessage = { + message_id: chat.messageId, + chat: chat.chat, + from: chat.user, + text: payload.text, + date: env.date, + // entities: textToEntities(payload.text, payload.parse_mode), + reply_to_message: payload.reply_to_message_id + ? chat.messages.get( + payload.reply_to_message_id, + ) as Types.Message["reply_to_message"] + : undefined, + has_protected_content: payload.protect_content === true + ? payload.protect_content + : undefined, + message_thread_id: payload.message_thread_id, + }; + + chat.messages.set(chat.messageId, message); + if (!payload.disable_notification) { + await chat.eventHandlers["message"]?.(message); + } + return api.result(message); + } + return api.error("not_implemented"); + }; + + const forwardMessage: Handler = (env, payload) => { + return api.error("not_implemented"); + }; + const copyMessage: Handler = () => + api.error("not_implemented"); + const sendPhoto: Handler = () => api.error("not_implemented"); + const sendAudio: Handler = () => api.error("not_implemented"); + const sendDocument: Handler = () => + api.error("not_implemented"); + const sendVideo: Handler = () => api.error("not_implemented"); + const sendAnimation: Handler = () => + api.error("not_implemented"); + const sendVoice: Handler = () => api.error("not_implemented"); + const sendVideoNote: Handler = () => + api.error("not_implemented"); + const sendMediaGroup: Handler = () => + api.error("not_implemented"); + const sendLocation: Handler = () => + api.error("not_implemented"); + const editMessageLiveLocation: Handler = () => + api.error("not_implemented"); + const stopMessageLiveLocation: Handler = () => + api.error("not_implemented"); + const sendVenue: Handler = () => api.error("not_implemented"); + const sendContact: Handler = () => + api.error("not_implemented"); + const sendPoll: Handler = () => api.error("not_implemented"); + const sendDice: Handler = () => api.error("not_implemented"); + const sendChatAction: Handler = () => + api.error("not_implemented"); + const getUserProfilePhotos: Handler = () => + api.error("not_implemented"); + const getFile: Handler = () => api.error("not_implemented"); + const answerCallbackQuery: Handler = () => + api.error("not_implemented"); + + return { + sendMessage, + forwardMessage, + copyMessage, + sendPhoto, + sendAudio, + sendDocument, + sendVideo, + sendAnimation, + sendVoice, + sendVideoNote, + sendMediaGroup, + sendLocation, + editMessageLiveLocation, + stopMessageLiveLocation, + sendVenue, + sendContact, + sendPoll, + sendDice, + sendChatAction, + getUserProfilePhotos, + getFile, + answerCallbackQuery, + }; +} diff --git a/methods/mod.ts b/methods/mod.ts new file mode 100644 index 0000000..db4df79 --- /dev/null +++ b/methods/mod.ts @@ -0,0 +1,37 @@ +export * from "./list.ts"; + +// TODO: VALIDATION of payloads +import type { Context, RawApi } from "../deps.ts"; +import type { Handlers } from "../types.ts"; + +// Handlers categorized and implemented in several files. +import { gettingUpdatesMethods } from "./getting_updates.ts"; +import { setupMethods } from "./setup.ts"; +import { messagesMethods } from "./messages.ts"; +import { chatMethods } from "./chat_management.ts"; +import { forumManagementMethods } from "./forum_management.ts"; +import { botSettingsMethods } from "./bot_settings.ts"; +import { updatingMessagesMethods } from "./updating_messages.ts"; +import { stickersMethods } from "./stickers.ts"; +import { inlineModeMethods } from "./inline_mode.ts"; +import { paymentsMethods } from "./payments.ts"; +import { telegramPassportMethods } from "./telegram_passport.ts"; +import { gamesMethods } from "./games.ts"; + +// TODO: change the `any` to `AllMethods` when everything's done. +export function bakeHandlers(): Handlers { + return { + ...gettingUpdatesMethods(), + ...setupMethods(), + ...messagesMethods(), + ...chatMethods(), + ...forumManagementMethods(), + ...botSettingsMethods(), + ...updatingMessagesMethods(), + ...stickersMethods(), + ...inlineModeMethods(), + ...paymentsMethods(), + ...telegramPassportMethods(), + ...gamesMethods(), + }; +} diff --git a/methods/payments.ts b/methods/payments.ts new file mode 100644 index 0000000..20c7d7b --- /dev/null +++ b/methods/payments.ts @@ -0,0 +1,24 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function paymentsMethods(): Handlers< + C, + Methods<"payments"> +> { + const sendInvoice: Handler = () => + api.error("not_implemented"); + const createInvoiceLink: Handler = () => + api.error("not_implemented"); + const answerShippingQuery: Handler = () => + api.error("not_implemented"); + const answerPreCheckoutQuery: Handler = () => + api.error("not_implemented"); + + return { + sendInvoice, + createInvoiceLink, + answerShippingQuery, + answerPreCheckoutQuery, + }; +} diff --git a/methods/setup.ts b/methods/setup.ts new file mode 100644 index 0000000..e963977 --- /dev/null +++ b/methods/setup.ts @@ -0,0 +1,14 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function setupMethods(): Handlers< + C, + Methods<"setup"> +> { + const getMe: Handler = (env) => api.result(env.getBot().botInfo); + const logOut: Handler = () => api.error("excluded_method"); + const close: Handler = () => api.error("excluded_method"); + + return { getMe, logOut, close }; +} diff --git a/methods/stickers.ts b/methods/stickers.ts new file mode 100644 index 0000000..d0a10d8 --- /dev/null +++ b/methods/stickers.ts @@ -0,0 +1,59 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function stickersMethods(): Handlers< + C, + Methods<"stickers"> +> { + const sendSticker: Handler = () => + api.error("not_implemented"); + const getStickerSet: Handler = () => + api.error("not_implemented"); + const getCustomEmojiStickers: Handler = () => + api.error("not_implemented"); + const uploadStickerFile: Handler = () => + api.error("not_implemented"); + const createNewStickerSet: Handler = () => + api.error("not_implemented"); + const addStickerToSet: Handler = () => + api.error("not_implemented"); + const setStickerPositionInSet: Handler = () => + api.error("not_implemented"); + const deleteStickerFromSet: Handler = () => + api.error("not_implemented"); + const setStickerEmojiList: Handler = () => + api.error("not_implemented"); + const setStickerKeywords: Handler = () => + api.error("not_implemented"); + const setStickerMaskPosition: Handler = () => + api.error("not_implemented"); + const setStickerSetTitle: Handler = () => + api.error("not_implemented"); + const deleteStickerSet: Handler = () => + api.error("not_implemented"); + const setStickerSetThumbnail: Handler = () => + api.error("not_implemented"); + const setCustomEmojiStickerSetThumbnail: Handler< + C, + "setCustomEmojiStickerSetThumbnail" + > = () => api.error("not_implemented"); + + return { + sendSticker, + getStickerSet, + getCustomEmojiStickers, + uploadStickerFile, + createNewStickerSet, + addStickerToSet, + setStickerPositionInSet, + deleteStickerFromSet, + setStickerEmojiList, + setStickerKeywords, + setStickerMaskPosition, + setStickerSetTitle, + deleteStickerSet, + setStickerSetThumbnail, + setCustomEmojiStickerSetThumbnail, + }; +} diff --git a/methods/telegram_passport.ts b/methods/telegram_passport.ts new file mode 100644 index 0000000..7e47077 --- /dev/null +++ b/methods/telegram_passport.ts @@ -0,0 +1,12 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function telegramPassportMethods(): Handlers< + C, + Methods<"telegram_passport"> +> { + const setPassportDataErrors: Handler = () => + api.error("not_implemented"); + return { setPassportDataErrors }; +} diff --git a/methods/updating_messages.ts b/methods/updating_messages.ts new file mode 100644 index 0000000..0bd096d --- /dev/null +++ b/methods/updating_messages.ts @@ -0,0 +1,29 @@ +import type { Context } from "../deps.ts"; +import type { Handler, Handlers, Methods } from "../types.ts"; +import { api } from "../helpers.ts"; + +export function updatingMessagesMethods(): Handlers< + C, + Methods<"updating_messages"> +> { + const editMessageText: Handler = () => + api.error("not_implemented"); + const editMessageCaption: Handler = () => + api.error("not_implemented"); + const editMessageMedia: Handler = () => + api.error("not_implemented"); + const editMessageReplyMarkup: Handler = () => + api.error("not_implemented"); + const stopPoll: Handler = () => api.error("not_implemented"); + const deleteMessage: Handler = () => + api.error("not_implemented"); + + return { + editMessageText, + editMessageCaption, + editMessageMedia, + editMessageReplyMarkup, + stopPoll, + deleteMessage, + }; +} diff --git a/mod.ts b/mod.ts index d13b059..b9cf94c 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,10 @@ -export * from "./src/TestUser.ts"; -export * from "./src/Chats.ts"; -export * from "./src/types.ts"; +export * from "./chats.ts"; +export * from "./private.ts"; +export * from "./group.ts"; +export * from "./supergroup.ts"; +export * from "./channel.ts"; +export * from "./helpers.ts"; +export * from "./errors.ts"; +export * from "./types.ts"; +export * from "./constants.ts"; +export * from "./methods/mod.ts"; diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..84e3793 --- /dev/null +++ b/notes.txt @@ -0,0 +1,101 @@ + + + โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— +โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ +โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ +โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•”โ•โ•โ•โ–‘โ–‘โ–‘โ•šโ•โ•โ•โ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–‘โ•šโ•โ•โ•โ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ +โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ +โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ•šโ•โ•โ–‘โ–‘โ–‘โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ•โ•โ•โ•โ•โ–‘โ–‘โ–‘โ–‘โ•šโ•โ•โ–‘โ–‘โ–‘โ•šโ•โ•โ•โ•โ•โ•โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + +Some development notes about the tests framework. Notes includes +some answers for frequently asked questions or in another words, +answers for the questions that I may ask myself later, "WHY?". +It also contains some general to-do list. Additional note on it: +Search "TODO" in the source code to find more detailed list. + +==========================|| FAQ ||============================= + +# Why are all the properties public? ------------------------- # + +-> It is a testing library, advanced users should be able + to use every aspect of the environment. +-> Even tho all the method mapping can be stored in a single + file, its easier to split them apart. + +# Any plans to implement the not implemented methods? -------- # + +No. They are excluded because they are more related to setting +up the bot (or bot server) rather than to the functionalities +of the bot. Here is the list of excluded methods if you're +interested: + +- close +- logOut +- setWebhook +- getWebhookInfo +- deleteWebhook +- getUpdates + +# Any ignored payload parameters? ---------------------------- # + +Yes. The following parameters are not implemented: + +-> disable_web_page_preview: Doesn't make sense to have it. + +==========================|| TODO ||============================ + +There are a lot more than this, but I'm only writing down the +ones that are most important, and the ones that I'm most likely +to forget and do something instead stupid. For more TODOs, +search the codebase with the keyword: "TODO". Current list: + +-> Validate types of payload. +-> Ping @Aquathing and @KnorpelSenf for help in validating. +-> Dummy user/chat generator with names. +-> Fix the type error occurred if the 'as any' is removed + from the main transformer middleware. (chats.ts) +-> Implement a proper file ID parser and packer. +-> Implement a parse-mode-d text to entities and vice versa. + +METHODS/ +โ”œ[x] bot_settings +โ”œ[:] chat_management +โ”œ[ ] forum_management +โ”œ[ ] games +โ”œ[x] getting_updates +โ”œ[ ] inline_mode +โ”œ[:] messages +โ”œ[ ] payments +โ”œ[x] setup +โ”œ[ ] stickers +โ”œ[ ] telegram_passport +โ””[ ] updating_messages + +========================|| IMPORTANT ||========================= + +# Bad designs that I regret about -----------------------------# + +-> [DONE] <----------------------------------------------------- + + Change the bad design of the chat members system. It is bad + because its hard to maintain all the individual properties. + It'll be easier if it is simply a single set. + + The current design is split into individual variables: + * owner (creator) + * members (current members, restricted) + * administrators + * banned + + The proposed design: + * members (creator, current, restricted, admin, banned) + * owner (Only the user ID of the owner for easy access) + +========================|| QUESTIONS ||========================= + +-> Does deleted accounts counts in getChatMemberCount? +-> Does the user returned in getChatMember in a private chat? + +================================================================ + +(c) 2023 | Dunkan diff --git a/private.ts b/private.ts new file mode 100644 index 0000000..d21fea3 --- /dev/null +++ b/private.ts @@ -0,0 +1,276 @@ +import { Bot, Context, debug, Types } from "./deps.ts"; +import { Chats } from "./chats.ts"; +import { date } from "./helpers.ts"; +import { + ApiResponse, + ChatId, + InteractableChats, + InteractableChatTypes, + MaybePromise, + MessageId, + NotificationHandler, +} from "./types.ts"; + +type DetailsExceptObvious = Omit; +type DetailsFromGetChat = Omit< + Types.Chat.PrivateGetChat, + keyof Types.Chat.PrivateChat | "pinned_message" +>; +type UserDetails = Omit; + +export interface PrivateChatDetails extends DetailsExceptObvious, UserDetails { + /** do not specify unless you know what you're doing */ + chat?: InteractableChats; + pinnedMessages?: Types.Message[]; + blocked?: boolean; // is the bot blocked by the user + additional?: DetailsFromGetChat; + chatMenuButton?: Types.MenuButton; +} + +type PrivateChatNotificationHandlers = Partial<{ + "message": (message: Types.Message) => MaybePromise; +}>; + +export class PrivateChat { + readonly type = "private"; + isBotMember = true; + + chat_id: ChatId; + #userChat: Types.Chat.PrivateChat; + user: Types.User; + chat: Types.Chat.PrivateChat | InteractableChats; + + ownerOf = new Set(); + + #bot: Bot; + #env: Chats; + + chatMenuButton: Types.MenuButton; + + // Private chat related + pinnedMessages = new Set(); + recentPinnedMessage?: MessageId; + messages = new Map(); + message_id = 1; + + eventHandlers: PrivateChatNotificationHandlers = {}; + + d: ReturnType; + + /** Updates sent by the user to the bot */ + updates: Types.Update[] = []; + responses: ApiResponse[] = []; + + get messageId() { + return this.message_id++; + } + + constructor(env: Chats, public details: PrivateChatDetails) { + this.#env = env; + this.#bot = env.getBot(); + + this.chat_id = details.chat?.id ?? details.id; + const user = { + id: details.id, + username: details.username, + first_name: details.first_name, + last_name: details.last_name, + }; + this.#userChat = { ...user, type: "private" }; + this.chat = details.chat ?? this.#userChat; + this.user = { + ...user, + is_bot: false, + is_premium: details.is_premium, + language_code: details.language_code, + added_to_attachment_menu: details.added_to_attachment_menu, + }; + + this.chatMenuButton = details.chatMenuButton ?? env.defaultChatMenuButton; + + details.pinnedMessages?.map((message) => { + if (this.pinnedMessages.has(message.message_id)) { + throw new Error("Message was already pinned"); + } + this.messages.set(message.message_id, message); + this.pinnedMessages.add(message.message_id); + }); + + this.recentPinnedMessage = details.pinnedMessages?.at(-1)?.message_id; + + // Transformer. + this.#bot.api.config.use((prev, method, payload, signal) => { + if ("chat_id" in payload && payload.chat_id === this.chat_id) { + this.responses.push({ method, payload }); + } + return prev(method, payload, signal); + }); + + this.d = debug(`${user.first_name} (P:${user.id})`); + } + + getChat(): Types.Chat.PrivateGetChat { + return { + ...this.#userChat, + ...this.details.additional, + ...(this.recentPinnedMessage && + this.messages.has(this.recentPinnedMessage) + ? { pinned_message: this.messages.get(this.recentPinnedMessage) } + : {}), + }; + } + + /** + * Use this method for imitating the user as in a chat, i.e., + * as in a group or supergroup or channel. It internally only + * changes the chat object used in the sending updates. The + * further chained methods will be bound to the chat. Returns + * a private chat instance with chat related properties are + * changed to the specified chat properties. + */ + in(chat: InteractableChatTypes) { + return new PrivateChat(this.#env, { + ...this.details, + chat: chat.chat, + chatMenuButton: undefined, + pinnedMessages: undefined, + additional: undefined, + }); + } + + /** + * **Remember, that the event handlers are only called when the bot + * is interacting only with the user.** + * + * Set a event handler using this function. For example, when + * the bot sends a messsage, the user will receive a new message + * event, and those events can be handled by registering a handler. + */ + onEvent( + notification: keyof PrivateChatNotificationHandlers, + handler: NotificationHandler, + ) { + this.eventHandlers[notification] = handler; + } + + /** Make the user a premium subscriber. */ + subscribePremium() { + this.user.is_premium = true; + } + + /** Yes, and suddenly the user hates premium. */ + unsubscribePremium() { + this.user.is_premium = undefined; + } + + // TODO: Chat join request thingy. + /** Join a group or channel. */ + join(chatId: number) { + const member = this.#env.getChatMember(this.user.id, chatId); + if (member.status === "chat-not-found") throw new Error("Chat not found"); + if (member.status === "kicked") { + throw new Error("User is banned from the chat"); + } + + const chat = this.#env.chats.get(chatId)!; + if (chat.type === "private") { + throw new Error(`Makes no sense "joining" a private chat ${chatId}`); + } + + if (member.status === "restricted") { + if (member.is_member) { + this.d(`User is already a member of the chat ${chatId}`); + return true; + } + chat.members.set(this.user.id, { ...member, is_member: true }); + this.d(`Joined the ${chat.type} chat "${chat.chat.title}" (${chatId})`); + return true; + } + + if (member.status !== "left") { + this.d(`User is already a member of the chat ${chatId}`); + return true; + } + + chat.members.set(this.user.id, { status: "member", user: this.user }); + this.d(`Joined the ${chat.type} chat "${chat.chat.title}" (${chatId})`); + return true; + } + + /** Leave a group or channel. */ + leave(chatId: number) { + const member = this.#env.getChatMember(this.user.id, chatId); + if (member.status === "chat-not-found") throw new Error("Chat not found"); + if (member.status === "kicked") throw new Error("User is already banned!"); + if (member.status === "left") { + this.d("User has already left."); + return true; + } + + const chat = this.#env.chats.get(chatId)!; + if (chat.type === "private") { + throw new Error(`Makes no sense "leaving" a private chat ${chatId}`); + } + + if (member.status === "restricted") { + if (member.is_member) { + chat.members.set(this.user.id, { ...member, is_member: false }); + this.d(`Left the ${chat.type} "${chat.chat.title}" (${chatId})`); + return true; + } + this.d("User has already left."); + return true; + } + + if (member.status === "creator") { + this.d("WARNING: user is the owner of the chat"); + this.ownerOf.delete(chatId); + chat.owner = undefined; + } + + chat.members.set(this.user.id, { status: "left", user: this.user }); + this.d(`Left the ${chat.type} "${chat.chat.title}" (${chatId})`); + return true; + } + + /** Send a message in the chat. */ + sendMessage(text: string, options?: { + replyTo?: + | Types.MessageId["message_id"] + | NonNullable; + entities?: Types.MessageEntity[]; + }) { + if (!this.#env.userCan(this.user.id, this.chat.id, "can_send_messages")) { + throw new Error("User have no permission to send message to the chat."); + } + const common = { + message_id: this.messageId, + date: date(), + text, + entities: options?.entities, + reply_to_message: typeof options?.replyTo === "number" + ? this.messages.get(options.replyTo) as NonNullable< + Types.Message["reply_to_message"] + > + : options?.replyTo, + }; + const update: Omit = this.chat.type === "channel" + ? { channel_post: { ...common, chat: this.chat } } + : { message: { ...common, chat: this.chat, from: this.user } }; + const validated = this.#env.validateUpdate(update); + this.updates.push(validated); + return this.#env.sendUpdate(validated); + } + + /** Send a command in the chat, optionally with a match. */ + command(cmd: string, match?: string) { + const text = `/${cmd}${match ? ` ${match}` : ""}`; + return this.sendMessage(text, { + entities: [{ + type: "bot_command", + offset: 0, + length: cmd.length + 1, + }], + }); + } +} diff --git a/scripts/create_method_list.ts b/scripts/create_method_list.ts new file mode 100644 index 0000000..f74e057 --- /dev/null +++ b/scripts/create_method_list.ts @@ -0,0 +1,16 @@ +import { Project } from "https://deno.land/x/ts_morph@17.0.1/mod.ts"; +const project = new Project(); + +const file = await fetch("https://deno.land/x/grammy_types/methods.ts"); +const src = project.createSourceFile("methods.ts", await file.text()); + +const methods = src.getTypeAliasOrThrow("ApiMethods").getType() + .getProperties().map((property) => property.getName()); +await Deno.writeTextFile( + "methods/create_list.ts", + `export const methods = ${JSON.stringify(methods)};`, +); +const cmd = new Deno.Command(Deno.execPath(), { + args: ["fmt", "methods/create_list.ts"], +}); +await cmd.spawn().status; diff --git a/src/Chats.ts b/src/Chats.ts deleted file mode 100644 index 9e5ccb3..0000000 --- a/src/Chats.ts +++ /dev/null @@ -1,32 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { Bot, Context, GrammyTypes } from "../deps.ts"; -import { TestUser } from "./TestUser.ts"; -import type { User } from "./types.ts"; - -export class Chats { - constructor(private bot: Bot, botInfo?: GrammyTypes.UserFromGetMe) { - this.bot.botInfo = botInfo ?? { - id: 42, - first_name: "Test Bot", - is_bot: true, - username: "test_bot", - can_join_groups: true, - can_read_all_group_messages: false, - supports_inline_queries: false, - }; - - this.bot.api.config.use((_, _method, _payload) => { - return { ok: true, result: true } as any; - }); - } - - /** - * Creates a test user and helps you to send mock updates as if they were sent - * from a private chat to the bot. - * @param user Information about the user. - * @returns A `TestUser` instance. - */ - newUser(user: User): TestUser { - return new TestUser(this.bot, user); - } -} diff --git a/src/TestUser.ts b/src/TestUser.ts deleted file mode 100644 index 6ca5fe8..0000000 --- a/src/TestUser.ts +++ /dev/null @@ -1,666 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { Bot, Context, GrammyTypes, Payload } from "../deps.ts"; -import type { Methods, RawApi } from "../deps.ts"; -import type { - ForwardMessageOptions, - MaybeCaptioned, - MaybeReplied, - Misc, - User, -} from "./types.ts"; - -type InOtherChat = GrammyTypes.Chat.GroupChat | GrammyTypes.Chat.SupergroupChat; -type Options = MaybeCaptioned & MaybeReplied & Misc; -type DefaultsOmittedMessage = Omit< - GrammyTypes.Message, - "date" | "chat" | "from" | "message_id" ->; -export type ApiPayload> = Payload; -export type DiceEmoji = - | "๐ŸŽฒ dice" - | "๐ŸŽณ bowling" - | "๐ŸŽฏ dart" - | "๐Ÿ€ basketball" - | "โšฝ football" - | "๐ŸŽฐ slot_machine"; -/** - * A test user for mocking updates sent by a Telegram user or a private chat - */ -export class TestUser { - public readonly user: GrammyTypes.User; - public readonly chat: GrammyTypes.Chat.PrivateChat; - public update_id = 100000; - public message_id = 2; - - /** Outgoing responses from the bot */ - public responses: { - method: Methods; - payload: Record; - // Payload, RawApi>; - }[] = []; - - private callbacks: Record> = {}; - private inlineQueries: Record> = {}; - - /** Incoming requests/updates sent from the user to the bot */ - public updates: GrammyTypes.Update[] = []; - - /** - * @param bot The `Bot` instance to be tested - * @param user Information related to the User - */ - constructor(private bot: Bot, user: User) { - this.user = { ...user, is_bot: false }; - this.chat = { - first_name: user.first_name, - id: user.id, - type: "private", - last_name: user.last_name, - username: user.username, - }; - - this.bot.api.config.use((prev, method, payload, signal) => { - // This is not how actually message ID increments, but this works for now - if (method.startsWith("send")) this.message_id++; - else if (method === "forwardMessage") this.message_id++; - - if ("chat_id" in payload && payload.chat_id === this.chat.id) { - this.responses.push({ method, payload }); - } else if (method === "answerCallbackQuery") { - const cbQueryPayload = payload as ApiPayload<"answerCallbackQuery">; - if (cbQueryPayload.callback_query_id in this.callbacks) { - this.responses.push({ method, payload }); - this.callbacks[cbQueryPayload.callback_query_id] = cbQueryPayload; - } - } else if (method === "answerInlineQuery") { - const inlinePayload = payload as ApiPayload<"answerInlineQuery">; - if (inlinePayload.inline_query_id in this.inlineQueries) { - this.responses.push({ method, payload }); - this.inlineQueries[inlinePayload.inline_query_id] = inlinePayload; - } - } - - // TODO: Return proper API responses - return prev(method, payload, signal); - }); - } - - /** Last sent update by the user */ - get lastUpdate() { - return this.updates[this.updates.length - 1]; - } - - /** Payload of last response from the bot */ - get last() { - return this.responses[this.responses.length - 1].payload; - } - - /** Clears updates (requests) sent by the user */ - clearUpdates(): void { - this.updates = []; - } - - /** Clears responses from the bot */ - clearResponses(): void { - this.responses = []; - } - - /** Clears both updates and responses */ - clear(): void { - this.responses = []; - this.updates = []; - } - - /** - * Use this method to send updates to the bot. - * @param update The Update to send; without the `update_id` property. - */ - async sendUpdate( - update: Omit, - ): Promise { - // TODO: Validate update. - const updateToSend = { ...update, update_id: this.update_id }; - await this.bot.handleUpdate(updateToSend); - this.updates.push(updateToSend); - this.update_id++; - return updateToSend; - } - - /** - * Use this method to send text messages. The sent Update is returned. - * Triggers `bot.hears`, `bot.on(":text")` like listeners. - * - * @param text Text to send. - * @param options Optional parameters for sending the message, such as - * message_id, message entities, bot information, if it was a message sent by - * choosing an inline result. - */ - sendMessage( - text: string, - options?: { - message_id?: number; - entities?: GrammyTypes.MessageEntity[]; - via_bot?: GrammyTypes.User; - }, - chat?: InOtherChat, - ): Promise { - const opts = { - text: text, - message_id: options?.message_id ?? this.message_id++, - ...options, - }; - - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - ...opts, - }, - }); - } - - /** - * Use this method to reply to another message in chat. - * @param replyToMessage The message to reply to. - * @param toReply The content of the reply. - */ - replyTo( - replyToMessage: GrammyTypes.Message, - toReply: string | Omit, - chat?: InOtherChat, - ): Promise { - const other = typeof toReply === "string" ? { text: toReply } : toReply; - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - reply_to_message: { - ...replyToMessage, - reply_to_message: undefined, - }, - ...other, - }, - }); - } - - /** - * Use this method to send a command to the bot. Triggers corresponding - * `bot.command()` listeners. - * @param command Command to send. - * @param match Optionally you can pass in a match/payload, an extra parameter - * which is sent with the command. - */ - command( - command: string, - match?: string, - chat?: InOtherChat, - ): Promise { - return this.sendMessage(`/${command}${match ? ` ${match}` : ""}`, { - entities: [{ - type: "bot_command", - offset: 0, - length: 1 + command.length, - }], - }, chat); - } - - /** - * Use this method to send GIF animations to the bot. - * @param animationOptions Information about the animation and optional parameters. - */ - sendAnimation( - animationOptions: Options & { animation: GrammyTypes.Animation }, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...animationOptions, - }, - }); - } - - /** - * Use this method to send audio messages to the bot. - * @param audioOptions Information about the audio and optional parameters. - */ - sendAudio( - audioOptions: Options & { audio: GrammyTypes.Audio }, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...audioOptions, - }, - }); - } - - /** - * Use this method to send documents and files to the bot. - * @param documentOptions Information about the document and optional parameters. - */ - sendDocument( - documentOptions: Options & { document: GrammyTypes.Document }, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...documentOptions, - }, - }); - } - - /** - * Use this method to send photos to the bot. - * @param photoOptions Information about the photo and optional parameters. - */ - sendPhoto( - photoOptions: Options & { photo: GrammyTypes.PhotoSize[] }, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...photoOptions, - }, - }); - } - - /** - * Use this method to send sticker messages to the bot. - * @param stickerOptions Information about the sticker and optional parameters. - */ - sendSticker( - stickerOptions: { sticker: GrammyTypes.Sticker } & MaybeReplied & Misc, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...stickerOptions, - }, - }); - } - - /** - * Use this method to send videos to the bot. - * @param videoOptions Information about the video and optional parameters. - */ - sendVideo( - videoOptions: Options & { video: GrammyTypes.Video }, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...videoOptions, - }, - }); - } - - /** - * Use this method to send video notes to the bot. - * @param videoNoteOptions Information about the video note and optional parameters. - */ - sendVideoNote( - videoNoteOptions: - & { video_note: GrammyTypes.VideoNote } - & MaybeReplied - & Misc, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...videoNoteOptions, - }, - }); - } - - /** - * Use this method to send voice messages to the bot. - * @param voiceOptions Information about the voice message and optional parameters. - */ - sendVoice( - voiceOptions: Options & { voice: GrammyTypes.Voice }, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - ...voiceOptions, - }, - }); - } - - /** - * Use this method to send a dice animation to the chat. - * @param emoji Emoji on which the dice throw animation is based - * @param value Value of the dice, 1-6 for โ€œ๐ŸŽฒโ€, โ€œ๐ŸŽฏโ€ and โ€œ๐ŸŽณโ€ base emoji, 1-5 - * for โ€œ๐Ÿ€โ€ and โ€œโšฝโ€ base emoji, 1-64 for โ€œ๐ŸŽฐโ€ base emoji - */ - sendDice( - emoji: DiceEmoji, - value: number, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - dice: { - emoji: emoji.split(" ")[0], - value: value, - }, - }, - }); - } - - /** - * Use this method to send a game to the chat. - * @param game Information about the game - */ - sendGame( - game: GrammyTypes.Game, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - game, - }, - }); - } - - /** - * Use this method to send a poll to the chat. - * @param poll Information about the poll - */ - sendPoll( - poll: GrammyTypes.Poll, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - poll, - }, - }); - } - - /** - * Use this method to send a venue to the chat. - * @param venue Information about the venue - */ - sendVenue( - venue: GrammyTypes.Venue, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - venue, - }, - }); - } - - /** - * Use this method to send a location to the chat. - * @param location Information about the location - */ - sendLocation( - location: GrammyTypes.Location, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - location, - }, - }); - } - - /** - * Use this method to edit a message. - * @param message Message to edit. - */ - editMessage( - message: GrammyTypes.Message, - ): Promise { - return this.sendUpdate({ - edited_message: message, - }); - } - - /** - * Use this method to edit a message's text. - * @param message_id ID of the text message to edit. - * @param text New text content of the message. - */ - editMessageText( - message_id: number, - text: string, - chat?: InOtherChat, - ): Promise { - return this.editMessage({ - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - edit_date: Date.now(), - message_id, - text, - }); - } - - /** - * Use this method to forward a message to the bot. - * @param options Information of the forwarding message. - */ - forwardMessage( - options: ForwardMessageOptions, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id++, - forward_date: Date.now(), - ...options, - }, - }); - } - - /** - * Use this method to forward a text message to the bot. - * @param text Text of the forwarding message. - * @param entities Optional message entities array. - */ - forwardTextMessage( - text: string, - entities?: GrammyTypes.MessageEntity[], - chat?: InOtherChat, - ): Promise { - return this.forwardMessage({ text, entities }, chat); - } - - /** - * Use this method to query inline. - * @param query Query string. Defaults to an empty string. - */ - inlineQuery( - query: Omit, - chat?: InOtherChat, - ): Promise { - this.inlineQueries[query.id] = { inline_query_id: query.id, results: [] }; - return this.sendUpdate({ - inline_query: { - ...query, - chat_type: chat?.type ?? "sender", - from: this.user, - }, - }); - } - - // TODO: Implement `chooseInlineResult`. - - /** - * Use this method to click an inline button. - * @param callbackQuery The callback data of the inline button. - */ - click( - callbackQuery: Omit, - chat?: InOtherChat, - ): Promise { - this.callbacks[callbackQuery.id] = { callback_query_id: callbackQuery.id }; - - return this.sendUpdate({ - callback_query: { - ...callbackQuery, - chat_instance: `${chat?.id ?? this.chat.id}071801131325`, // 07 18 01 13 13 25 - from: this.user, - }, - }); - } - - /** - * Use this method to pin a message in chat. - * @param message The message to be pin in chat. - */ - pinMessage( - message: GrammyTypes.ReplyMessage, - chat?: InOtherChat, - ): Promise { - return this.sendUpdate({ - message: { - date: Date.now(), - chat: chat ?? this.chat, - from: this.user, - message_id: this.message_id, - pinned_message: message, - }, - }); - } - - /** - * Short method for sending start command; or, in other words, starting a - * conversation with the bot. - * @param options Optional information for the start command. - */ - startBot( - options?: { - /** /start command payload (match) */ - payload?: string; - /** Whether it is the first ever `/start` command to the bot */ - first_start?: boolean; - }, - chat?: InOtherChat, - ) { - if (!options) return this.command("start"); - this.sendMessage( - `/start${options.payload ? ` ${options.payload}` : ""}`, - { - entities: [{ type: "bot_command", offset: 0, length: 6 }], - message_id: options.first_start ? 1 : this.message_id, - }, - chat, - ); - } - - /** - * Use this method to block or stop the bot. The bot will no longer be able to - * send messages if it is blocked by the user. - */ - stopBot() { - return this.sendUpdate({ - my_chat_member: { - date: Date.now(), - chat: this.chat, - from: this.user, - old_chat_member: { - user: this.user, - status: "member", - }, - new_chat_member: { - user: this.user, - status: "kicked", - until_date: 0, - }, - }, - }); - } - - /** - * Use this method to restart the bot if it has been blocked or - * stopped by the user. - */ - async restartBot({ sendStartCmd = true }: { - /** Whether a start command should be sent after unblocking/restarting the bot */ - sendStartCmd: boolean; - }) { - await this.sendUpdate({ - my_chat_member: { - date: Date.now(), - chat: this.chat, - from: this.user, - old_chat_member: { - user: this.user, - status: "kicked", - until_date: 0, - }, - new_chat_member: { - user: this.user, - status: "member", - }, - }, - }); - - // Clicking "Restart Bot" button, also sends a '/start' command - // in Official Telegram Clients. Set `sendStartCmd` to false, to disable. - if (sendStartCmd) await this.command("start"); - } -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 41ced0e..0000000 --- a/src/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { GrammyTypes } from "../deps.ts"; - -export type User = Omit; - -export interface ForwardMessageOptions { - animation?: GrammyTypes.Animation; - audio?: GrammyTypes.Audio; - author_signature?: string; - caption?: string; - caption_entities?: GrammyTypes.MessageEntity[]; - contact?: GrammyTypes.Contact; - dice?: GrammyTypes.Dice; - document?: GrammyTypes.Document; - edit_date?: number; - entities?: GrammyTypes.MessageEntity[]; - forward_date?: number; - forward_from?: GrammyTypes.User; - forward_from_chat?: GrammyTypes.Chat; - forward_from_message_id?: number; - forward_sender_name?: string; - forward_signature?: string; - game?: GrammyTypes.Game; - has_protected_content?: true; - is_automatic_forward?: true; - location?: GrammyTypes.Location; - media_group_id?: string; - photo?: GrammyTypes.PhotoSize[]; - poll?: GrammyTypes.Poll; - reply_markup?: GrammyTypes.InlineKeyboardMarkup; - reply_to_message?: GrammyTypes.ReplyMessage; - sender_chat?: GrammyTypes.Chat; - sticker?: GrammyTypes.Sticker; - text?: string; - venue?: GrammyTypes.Venue; - via_bot?: GrammyTypes.User; - video?: GrammyTypes.Video; - video_note?: GrammyTypes.VideoNote; - voice?: GrammyTypes.Voice; -} - -export interface MaybeCaptioned { - caption?: string; - caption_entities?: GrammyTypes.MessageEntity[]; -} - -export interface MaybeReplied { - reply_to_message?: GrammyTypes.ReplyMessage; -} - -export interface Misc { - via_bot?: GrammyTypes.User; -} - -export interface GroupAndChannelMisc { - has_protected_content?: true; - author_signature?: string; -} diff --git a/supergroup.ts b/supergroup.ts new file mode 100644 index 0000000..e99cbc4 --- /dev/null +++ b/supergroup.ts @@ -0,0 +1,191 @@ +import type { Bot, Context, Types } from "./deps.ts"; +import type { Chats } from "./chats.ts"; +import { + defaultChatAdministratorRights, + defaultChatPermissions, +} from "./constants.ts"; +import type { MessageId, UserId } from "./types.ts"; + +type DetailsExceptObvious = Omit; +type DetailsFromGetChat = Omit< + Types.Chat.SupergroupGetChat, + keyof Types.Chat.SupergroupChat | "pinned_message" +>; + +export interface SupergroupChatDetails extends DetailsExceptObvious { + owner: UserId | Types.ChatMemberOwner; + administrators?: (UserId | Types.ChatMemberAdministrator)[]; + promotedByBot?: boolean; + members?: (UserId | Types.ChatMember)[]; + + pinnedMessages?: Types.Message[]; + additional?: DetailsFromGetChat; + chatMenuButton?: Types.MenuButton; + administratorRights?: Types.ChatAdministratorRights; +} + +export class SupergroupChat { + readonly type = "supergroup"; + isBotAMember = false; + + chat_id: number; + chat: Types.Chat.SupergroupChat; + + #bot: Bot; + #env: Chats; + + // Supergroup Related + owner?: UserId; + members = new Map(); + + pinnedMessages = new Set(); + recentPinnedMessage?: MessageId; + messages = new Map(); + + permissions: Types.ChatPermissions; + chatMenuButton: Types.MenuButton; + administratorRights: Types.ChatAdministratorRights; + + constructor(env: Chats, public details: SupergroupChatDetails) { + this.#env = env; + this.#bot = env.getBot(); + + this.chat_id = details.id; + this.chat = { + id: details.id, + type: "supergroup", + title: details.title, + is_forum: details.is_forum, + username: details.username, + }; + + // Members + if (typeof details.owner === "number") { + const chat = env.chats.get(details.owner); + if (chat === undefined || chat.type !== "private") { + throw new Error("Cannot create a group without a user owner."); + } + this.owner = details.owner; + this.members.set(this.owner, { + status: "creator", + user: chat.user, + is_anonymous: false, + }); + } else { + this.owner = details.owner.user.id; + this.members.set(this.owner, details.owner); + } + + if (this.owner === this.#bot.botInfo.id) { + throw new Error("You cannot add bot as owner of the group"); + } + + // TODO: WARN: Overwrites existing members. + details.members?.map((member) => { + if (typeof member === "number") { + if (member === this.owner) { + throw new Error( + "DO NOT add creator/owner of the group through members. Use `owner` instead.", + ); + } + const chat = env.chats.get(member); + if (chat === undefined || chat.type !== "private") return; // TODO: throw error? + this.members.set(member, { status: "member", user: chat.user }); + } else { + if (member.status === "creator") { + throw new Error( + "DO NOT add creator/owner of the group through members. Use `owner` instead.", + ); + } + if (member.status !== "left") { + this.members.set(member.user.id, member); + } + } + }); + + this.administratorRights = details.administratorRights ?? + defaultChatAdministratorRights; + + // TODO: WARN: Overwrites existing member. + details.administrators?.map((member) => { + if (typeof member === "number") { + if (member === this.#bot.botInfo.id) { + this.members.set(member, { + status: "administrator", + user: { + id: this.#bot.botInfo.id, + is_bot: true, + username: this.#bot.botInfo.username, + first_name: this.#bot.botInfo.first_name, + last_name: this.#bot.botInfo.last_name, + }, + can_be_edited: false, + ...env.myDefaultAdministratorRights.groups, + }); + } else { + const chat = env.chats.get(member); + if (chat === undefined || chat.type !== "private") return; // TODO: throw error? + this.members.set(member, { + status: "administrator", + user: chat.user, + can_be_edited: !!details.promotedByBot, + ...this.administratorRights, + }); + } + } else { + this.members.set(member.user.id, member); + } + }); + + if (this.members.has(this.#bot.botInfo.id)) { + this.isBotAMember = true; + } + + // Messages + details.pinnedMessages?.map((message) => { + if (this.pinnedMessages.has(message.message_id)) { + throw new Error("Message was already pinned"); + } + this.messages.set(message.message_id, message); + this.pinnedMessages.add(message.message_id); + }); + + this.recentPinnedMessage = details.pinnedMessages?.at(-1)?.message_id; + + // Other + this.chatMenuButton = details.chatMenuButton ?? + env.defaultChatMenuButton; + + this.permissions = details.additional?.permissions ?? + defaultChatPermissions; + } + + getChat(): Types.Chat.SupergroupGetChat { + return { + ...this.chat, + ...this.details.additional, + ...(this.recentPinnedMessage && + this.messages.has(this.recentPinnedMessage) + ? { pinned_message: this.messages.get(this.recentPinnedMessage) } + : {}), + }; + } + + getChatMember(userId: UserId): Types.ChatMember | { status: "not-found" } { + const member = this.members.get(userId); + if (member === undefined) return { status: "not-found" }; + if (member.status === "kicked") { + return member.until_date < this.#env.date + ? { status: "left", user: member.user } + : member; + } + if (member.status === "restricted") { + return member.until_date < this.#env.date + ? member.is_member + ? { status: "member", user: member.user } + : { status: "left", user: member.user } + : member; + } + return member; + } +} diff --git a/tests/bot.ts b/tests/bot.ts deleted file mode 100644 index 759030d..0000000 --- a/tests/bot.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Bot, Context as BaseC, session, SessionFlavor } from "./test_deps.ts"; -export type MyContext = BaseC & SessionFlavor<{ counter: number }>; -export const bot = new Bot("TEST_TOKEN"); - -bot.use(session({ - initial: () => ({ counter: 0 }), -})); - -bot.command("start", async (ctx) => { - await ctx.reply("Hello there!"); -}); - -bot.hears("Hi", async (ctx) => { - await ctx.reply("Hi!"); -}); - -bot.command("reset", async (ctx) => { - const old = ctx.session.counter; - ctx.session.counter = 0; - await ctx.reply("Reset!"); - if (old === 0) { - return await ctx.reply("(It was, already!)"); - } -}); - -bot.on("msg:forward_date", async (ctx) => { - if (ctx.message?.forward_from?.is_bot) { - return await ctx.reply("It's from... a bot?"); - } - if (ctx.message?.forward_from?.id) { - return await ctx.reply(`It's from ${ctx.message.forward_from.id}`); - } - await ctx.reply( - `It says: "${ctx.message?.text}" here.`, - ); -}); - -bot.on("edited_message:photo", async (ctx) => { - await ctx.reply( - `Now, that's an edited picture in ${ctx.editedMessage.message_id}`, - ); -}); - -bot.on("edited_message", async (ctx) => { - await ctx.reply(`You edited: ${ctx.editedMessage.message_id}`); -}); - -bot.on("message:text", async (ctx, next) => { - ctx.session.counter++; - await ctx.reply(ctx.session.counter.toString()); - await next(); -}); - -bot.on("message:animation", async (ctx) => { - await ctx.reply("That's a cool animation!"); -}); - -bot.on("message:audio", async (ctx) => { - await ctx.reply("Is that a new song?"); -}); - -bot.on("message:document", async (ctx) => { - await ctx.reply("What's that? Wait a sec. Let me check it."); -}); - -bot.on("message:photo", async (ctx) => { - if (ctx.message.photo.pop()?.height! > 1920) { - return await ctx.reply("Sorry, but I can't process images that big!"); - } - await ctx.reply("Let me process the photo. Please wait..."); -}); - -bot.on("message:sticker", async (ctx) => { - await ctx.reply("I got another one, here we go!"); - await ctx.replyWithSticker("sticker_id"); -}); - -bot.on("message:video", async (ctx) => { - await ctx.reply("Oh, you! You rickrolled me again!"); -}); - -bot.on("message:video_note", async (ctx) => { - await ctx.replyWithVideoNote("video_note_file_id", { - duration: 15, - length: 360, - }); -}); - -bot.on("message:voice", async (ctx) => { - await ctx.reply("Your voice is amazing!"); -}); - -bot.on("message", async (ctx, next) => { - if (!ctx.message.reply_to_message) return await next(); - await ctx.reply( - `You are replying to ${ctx.message.reply_to_message.message_id}`, - ); -}); - -bot.on("inline_query", async (ctx) => { - await ctx.answerInlineQuery([{ - type: "article", - id: "grammy-website", - title: "grammY", - input_message_content: { - message_text: -"grammY is the best way to create your own Telegram bots. \ -They even have a pretty website! ๐Ÿ‘‡", - parse_mode: "HTML", - }, - reply_markup: { - inline_keyboard: [[{ - text: "grammY website", - url: "https://grammy.dev/", - }]], - }, - url: "https://grammy.dev/", - description: "The Telegram Bot Framework.", - }]); -}); - -// Not commonly used, and you need to enable "Inline Feedback" feature for your -// bot in the settings of BotFather. So, there is no proper test. -// bot.on("chosen_inline_result", async (ctx) => { -// console.log(ctx.chosenInlineResult); -// await ctx.reply(`You chose: ${ctx.chosenInlineResult.result_id}`); -// }); - -bot.callbackQuery("click-me", async (ctx) => { - await ctx.answerCallbackQuery("Nothing here :)"); -}); diff --git a/tests/test_deps.ts b/tests/test_deps.ts deleted file mode 100644 index 503e883..0000000 --- a/tests/test_deps.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - Bot, - Context, - session, - type SessionFlavor, -} from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; diff --git a/tests/user.test.ts b/tests/user.test.ts deleted file mode 100644 index eec5741..0000000 --- a/tests/user.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { bot, MyContext } from "./bot.ts"; -import { /* ApiPayload, */ Chats } from "../mod.ts"; -import { assertEquals } from "https://deno.land/std@0.145.0/testing/asserts.ts"; - -const chats = new Chats(bot); -const user = chats.newUser({ - id: 1234567890, - first_name: "Test", - last_name: "User", - username: "test_usr", - language_code: "en", -}); - -const user2 = chats.newUser({ - id: 2323232323, - first_name: "Test", - last_name: "user 2", - username: "test_usr2", -}); - -Deno.test("Handle Commands and Hi message", async ({ step }) => { - await step("/start Command", async () => { - await user.command("start"); - await user2.command("start"); - // const payload = user.last as ApiPayload<"sendMessage">; - assertEquals(user.last.text, "Hello there!"); - assertEquals(user2.last.text, "Hello there!"); - }); - - await step("Reply 'Hi!' to 'Hi'", async () => { - await user.sendMessage("Hi"); - assertEquals(user.last.text, "Hi!"); - }); - - assertEquals(user.updates.length, 2); - assertEquals(user.responses.length, 2); - assertEquals(user2.updates.length, 1); - assertEquals(user2.responses.length, 1); - - user.clear(); - user2.clear(); -}); - -Deno.test("Message Counter (Session usage)", async ({ step }) => { - await step("Set count to 3", async () => { - await user.sendMessage("Message 1"); - await user.sendMessage("Message 2"); - await user.sendMessage("Message 3"); - - assertEquals(user.updates.length, 3); - assertEquals(user.responses.length, 3); - }); - - await step("Reset count command", async () => { - await user.command("reset"); - assertEquals(user.last.text, "Reset!"); - }); - - await step("Increase count after resetting", async () => { - await user.sendMessage("Message (again)"); - assertEquals(user.last.text, "1"); - - await user.command("reset"); - assertEquals(user.last.text, "Reset!"); - }); - - assertEquals(user.updates.length, 6); - assertEquals(user.responses.length, 6); - - user.clear(); - - // If the reset was already called, the bot sends two messages --> - // 1. "Reset!"; and 2. "(It was, already!)". - await step("Reset again when already reset", async () => { - await user.command("reset"); - - assertEquals(user.responses.at(-2)?.payload.text, "Reset!"); - assertEquals(user.last.text, "(It was, already!)"); - }); - - assertEquals(user.updates.length, 1); - assertEquals(user.responses.length, 2); // 2, because bot sends 2 messages here. - - user.clear(); -}); - -Deno.test("Handle forwarded messages", async ({ step }) => { - await step("Forward: Text message from a user", async () => { - const text = "Forwarding message text content"; - await user.forwardTextMessage(text); - - assertEquals(user.last.text, `It says: "${text}" here.`); - }); - - await step("Forward: Photo from a specific user", async () => { - await user.forwardMessage({ - photo: [{ - file_id: "photo_file_id", - file_unique_id: "photo_file_unique_id", - height: 1920, - width: 1080, - file_size: 123456, - }], - forward_from: { - id: 123, - first_name: "Another user", - is_bot: false, - }, - }); - - assertEquals(user.last.text, "It's from 123"); - }); - - await step("Forward: Text from bot", async () => { - await user.forwardMessage({ - text: "Hey bot!", - forward_from: { - id: 234, - first_name: "Another bot", - username: "another_bot", - is_bot: true, - }, - }); - - assertEquals(user.last.text, "It's from... a bot?"); - }); - - assertEquals(user.responses.length, 3); - user.clear(); -}); - -Deno.test("Reply to message", async () => { - await user.replyTo({ - chat: user.chat, - date: Date.now(), - message_id: 333, - }, { - text: "Reply message text", - }); - - assertEquals(user.last.text, "You are replying to 333"); - user.clear(); -}); - -Deno.test("Handle edited messages", async ({ step }) => { - await step("Edit: Text message", async () => { - await user.editMessageText(2345, "Yes"); - assertEquals(user.last.text, "You edited: 2345"); - }); - - await step("Edit: Photo", async () => { - // Example of getting the update. - const { edited_message } = await user.editMessage({ - date: Date.now() - 2000, - chat: user.chat, - from: user.user, - message_id: 112, - photo: [{ file_id: "id", file_unique_id: "id", height: 33, width: 33 }], - edit_date: Date.now(), - }); - assertEquals( - user.last.text, - `Now, that's an edited picture in ${edited_message?.message_id}`, - ); - }); - - assertEquals(user.responses.length, 2); - assertEquals(user.updates.length, 2); - - user.clear(); -}); - -Deno.test("Handling medias", async ({ step }) => { - await step("Media: Animation", async () => { - await user.sendAnimation({ - animation: { - duration: 10, - file_id: "file_id", - file_unique_id: "file_unique_id", - height: 320, - width: 240, - }, - }); - assertEquals(user.last.text, "That's a cool animation!"); - }); - - await step("Media: Audio", async () => { - await user.sendAudio({ - audio: { - duration: 10, - file_id: "file_id", - file_unique_id: "file_unique_id", - }, - }); - assertEquals(user.last.text, "Is that a new song?"); - }); - - await step("Media: Document", async () => { - await user.sendDocument({ - document: { - file_id: "file_id", - file_unique_id: "file_unique_id", - }, - }); - assertEquals(user.last.text, "What's that? Wait a sec. Let me check it."); - }); - - await step("Media: Photo", async () => { - await user.sendPhoto({ - photo: [{ - file_id: "file_id", - file_unique_id: "file_unique_id", - height: 1920, - width: 1080, - }], - }); - assertEquals(user.last.text, "Let me process the photo. Please wait..."); - }); - - await step("Media: Photo (height > 1920)", async () => { - await user.sendPhoto({ - photo: [{ - file_id: "file_id", - file_unique_id: "file_unique_id", - height: 2000, // max 1920 (NOT an official limit) - width: 1080, - }], - }); - assertEquals(user.last.text, "Sorry, but I can't process images that big!"); - }); - - await step("Media: Sticker", async () => { - await user.sendSticker({ - sticker: { - file_id: "Media: file_id", - file_unique_id: "file_unique_id", - height: 320, - width: 240, - is_animated: true, - is_video: true, - }, - }); - assertEquals( - user.responses[user.responses.length - 2].payload.text, - "I got another one, here we go!", - ); - - assertEquals(user.last.sticker, "sticker_id"); - }); - - await step("Media: Video", async () => { - await user.sendVideo({ - video: { - duration: 90, - file_id: "file_id", - file_unique_id: "file_unique_id", - height: 320, - width: 240, - }, - }); - assertEquals(user.last.text, "Oh, you! You rickrolled me again!"); - }); - - await step("Media: Video note", async () => { - await user.sendVideoNote({ - video_note: { - duration: 90, - file_id: "file_id", - file_unique_id: "file_unique_id", - length: 30, - }, - }); - assertEquals(user.last.video_note, "video_note_file_id"); - assertEquals(user.last.duration, 15); - }); - - await step("Media: Voice", async () => { - await user.sendVoice({ - voice: { - duration: 90, - file_id: "file_id", - file_unique_id: "file_unique_id", - }, - }); - assertEquals(user.last.text, "Your voice is amazing!"); - }); - - assertEquals(user.updates.length, 9); - assertEquals(user.responses.length, 10); - - user.clear(); -}); - -Deno.test("Inline Query", async ({ step }) => { - await step("Query", async () => { - await user.inlineQuery({ - query: "", // Empty query - offset: "", - id: "11", - }); - }); - - assertEquals(user.last.results[0].id, "grammy-website"); - assertEquals(user.responses.length, 1); - - user.clear(); -}); - -Deno.test("Callback Query and buttons", async ({ step }) => { - await step("Clicking 'click-me' button", async () => { - await user.click({ id: "111", data: "click-me" }); - assertEquals(user.last.text, "Nothing here :)"); - }); - - assertEquals(user.updates.length, 1); - assertEquals(user.responses.length, 1); - - user.clear(); -}); diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..5b69f9c --- /dev/null +++ b/types.ts @@ -0,0 +1,110 @@ +import type { Context, RawApi, Types } from "./deps.ts"; +import type { PrivateChat } from "./private.ts"; +import type { GroupChat } from "./group.ts"; +import type { SupergroupChat } from "./supergroup.ts"; +import type { ChannelChat } from "./channel.ts"; +import { METHODS } from "./methods/list.ts"; +import { Chats } from "./chats.ts"; + +export type UserId = Types.User["id"]; +export type MessageId = Types.MessageId["message_id"]; +export type ChatId = Types.Chat["id"]; + +export type MaybePromise = T | Promise; + +export type NotificationHandler = ( + message: Types.Message, +) => MaybePromise; + +export type InteractableChats = + | Types.Chat.GroupChat + | Types.Chat.SupergroupChat + | Types.Chat.ChannelChat; + +export type InteractableChatTypes = + | GroupChat + | SupergroupChat + | ChannelChat; + +export type ApiResponse = { + method: keyof RawApi; + payload?: unknown; +}; + +export type ChatType = + | PrivateChat + | GroupChat + | SupergroupChat + | ChannelChat; + +export type MyDefaultAdministratorRights = Record< + "groups" | "channels", + Types.ChatAdministratorRights +>; + +type Category = keyof typeof METHODS; +export type Methods = typeof METHODS[T][number]; +export type AllMethods = Methods; + +// TODO: Is there no other method for satisfying everyone. +export type Handler = ( + environment: Chats, + payload: Parameters[0], +) => Promise>>>; + +export type Handlers< + C extends Context, + M extends keyof RawApi, +> = Record>; + +// TODO: Re-think the design +type LanguageCode = string; +export type LocalizedCommands = Record< + LanguageCode, + Types.BotCommand[] +>; +type NotChatScopedBotCommands = Record< + Exclude< + Types.BotCommandScope["type"], + "chat" | "chat_member" | "chat_administrators" + >, + LocalizedCommands +>; +interface ChatScopedBotCommands { + chat: { + [chat_id: number]: LocalizedCommands; + }; + chat_administrators: { + [chat_id: number]: LocalizedCommands; + }; + chat_member: { + [chat_id: number]: { + [user_id: number]: LocalizedCommands; + }; + }; +} +export type BotCommands = NotChatScopedBotCommands & ChatScopedBotCommands; + +export interface BotDescriptions { + [lang: string]: Types.BotDescription & Types.BotShortDescription; +} + +export interface EnvironmentOptions { + botInfo?: Types.UserFromGetMe; + myDefaultAdministratorRights?: MyDefaultAdministratorRights; + defaultChatMenuButton?: Types.MenuButton; + defaultCommands?: Types.BotCommand[]; +} + +export type InlineQueryResultCached = + | Types.InlineQueryResultCachedGif + | Types.InlineQueryResultCachedAudio + | Types.InlineQueryResultCachedPhoto + | Types.InlineQueryResultCachedVideo + | Types.InlineQueryResultCachedVoice + | Types.InlineQueryResultCachedSticker + | Types.InlineQueryResultCachedDocument + | Types.InlineQueryResultCachedMpeg4Gif; + +export type ChatPermissions = keyof Types.ChatPermissions; +export type ChatAdministratorRights = keyof Types.ChatAdministratorRights;