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.
+
\ 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