Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated documentation for chat-members plugin, adding new sections about filters #1181

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
349 changes: 336 additions & 13 deletions site/docs/plugins/chat-members.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,340 @@ next: false

# Chat Members Plugin (`chat-members`)

Automatically store information about users in a chat and retrieve it easily.
Track group and channel members, and list them.
Telegram doesn't offer a method in the Bot API to retrieve the members of a chat, you have to keep track of them yourself.
This plugin makes it easy to work with `ChatMember` objects, by offering a convenient way to listen for changes in the form of custom filters, and by storing and updating the objects.

## Introduction

In many situations, it is necessary for a bot to have information about all the users of a given chat.
Currently, though, the Telegram Bot API exposes no method that allows us to retrieve this information.
Working with `ChatMember` objects from the Telegram Bot API can sometimes be cumbersome.
There are several different statuses that are often interchangeable in most applications.
In addition, the restricted status is ambiguous because it can represent both members of the group and restricted users that are not in the group.

This plugin comes to the rescue: automatically listening to `chat_member` events and storing all `ChatMember` objects.
This plugin simplifies dealing with chat members by offering strongly-typed filters for chat member updates.

## Usage

### Chat Member Filters

You can listen for two kinds of updates regarding chat members using a Telegram bot: `chat_member` and `my_chat_member`.
Both of them specify the old and new status of the user.

- `my_chat_member` updates are always received by your bot to inform you about the status of the bot being updated in any chat, as well as when users block the bot.
- `chat_member` updates are only received if you explicitly include them in the list of allowed updates, they notify about any status changes for users in chats in which the bot is **admin**.

Instead of manually filtering the old and the new statuses, chat member filters do this automatically for you, allowing you to act on any type of transition you're interested in.
Within the handler, the types of `old_chat_member` and `new_chat_member` are narrowed down accordingly.

::: code-group

```ts [TypeScript]
import { API_CONSTANTS, Bot } from "grammy";
import { chatMemberFilter, myChatMemberFilter } from "@grammyjs/chat-members";

const bot = new Bot("");
const groups = bot.chatType(["group", "supergroup"]);

// WITHOUT this plugin, to react whenever a user joins a group, you have to
// manually filter by status, resulting in error-prone, difficult to read code.
groups.on("chat_member").filter(
(ctx) => {
const { old_chat_member: oldMember, new_chat_member: newMember } =
ctx.chatMember;
return (
(["kicked", "left"].includes(oldMember.status) ||
(oldMember.status === "restricted" && !oldMember.is_member)) &&
(["administrator", "creator", "member"].includes(newMember.status) ||
(newMember.status === "restricted" && newMember.is_member))
);
},
(ctx) => {
const user = ctx.chatMember.new_chat_member.user;
await ctx.reply(`Welcome ${user.first_name} to the group!`);
},
);

// WITH this plugin, the code is greatly simplified and has a lower risk of errors.
// The code below listens to the same events but is much simpler.
groups.filter(chatMemberFilter("out", "in"), async (ctx) => {
const user = ctx.chatMember.new_chat_member.user;
await ctx.reply(`Welcome ${user.first_name} to the group!`);
});

// Listen for updates where the bot is added to a group as a regular user.
groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => {
await ctx.reply("Hello, thank you for adding me to the group!");
});

// Listen for updates where the bot is added to a group as an admin.
groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => {
await ctx.reply("Hello, thank you for adding me to the group as admin!");
});

// Listen for updates where the bot is promoted to admin.
groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => {
await ctx.reply("I was promoted to admin!");
});

// Listen for updates where the bot is demoted to a regular user.
groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => {
await ctx.reply("I am no longer admin");
});

bot.start({
// Make sure to include the "chat_member" update type for the above handlers to work.
allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"],
});
```

```js [JavaScript]
import { API_CONSTANTS, Bot } from "grammy";
import { chatMemberFilter, myChatMemberFilter } from "@grammyjs/chat-members";

const bot = new Bot("");
const groups = bot.chatType(["group", "supergroup"]);

// WITHOUT this plugin, to react whenever a user joins a group, you have to
// manually filter by status, resulting in error-prone, difficult to read code.
groups.on("chat_member").filter(
(ctx) => {
const { old_chat_member: oldMember, new_chat_member: newMember } =
ctx.chatMember;
return (
(["kicked", "left"].includes(oldMember.status) ||
(oldMember.status === "restricted" && !oldMember.is_member)) &&
(["administrator", "creator", "member"].includes(newMember.status) ||
(newMember.status === "restricted" && newMember.is_member))
);
},
(ctx) => {
const user = ctx.chatMember.new_chat_member.user;
await ctx.reply(`Welcome ${user.first_name} to the group!`);
},
);

// WITH this plugin, the code is greatly simplified and has a lower risk of errors.
// The code below listens to the same events but is much simpler.
groups.filter(chatMemberFilter("out", "in"), async (ctx) => {
const user = ctx.chatMember.new_chat_member.user;
await ctx.reply(`Welcome ${user.first_name} to the group!`);
});

// Listen for updates where the bot is added to a group as a regular user.
groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => {
await ctx.reply("Hello, thank you for adding me to the group!");
});

// Listen for updates where the bot is added to a group as an admin.
groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => {
await ctx.reply("Hello, thank you for adding me to the group as admin!");
});

// Listen for updates where the bot is promoted to admin.
groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => {
await ctx.reply("I was promoted to admin!");
});

// Listen for updates where the bot is demoted to a regular user.
groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => {
await ctx.reply("I am no longer admin");
});

bot.start({
// Make sure to include the "chat_member" update type for the above handlers to work.
allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"],
});
```

```ts [Deno]
import { API_CONSTANTS, Bot } from "https://deno.land/x/grammy/mod.ts";
import {
chatMemberFilter,
myChatMemberFilter,
} from "https://deno.land/x/grammy_chat_members/mod.ts";

const bot = new Bot("");
const groups = bot.chatType(["group", "supergroup"]);

// WITHOUT this plugin, to react whenever a user joins a group, you have to
// manually filter by status, resulting in error-prone, difficult to read code.
groups.on("chat_member").filter(
(ctx) => {
const { old_chat_member: oldMember, new_chat_member: newMember } =
ctx.chatMember;
return (
(["kicked", "left"].includes(oldMember.status) ||
(oldMember.status === "restricted" && !oldMember.is_member)) &&
(["administrator", "creator", "member"].includes(newMember.status) ||
(newMember.status === "restricted" && newMember.is_member))
);
},
(ctx) => {
const user = ctx.chatMember.new_chat_member.user;
await ctx.reply(`Welcome ${user.first_name} to the group!`);
},
);

// WITH this plugin, the code is greatly simplified and has a lower risk of errors.
// The code below listens to the same events but is much simpler.
groups.filter(chatMemberFilter("out", "in"), async (ctx) => {
const user = ctx.chatMember.new_chat_member.user;
await ctx.reply(`Welcome ${user.first_name} to the group!`);
});

// Listen for updates where the bot is added to a group as a regular user.
groups.filter(myChatMemberFilter("out", "regular"), async (ctx) => {
await ctx.reply("Hello, thank you for adding me to the group!");
});

// Listen for updates where the bot is added to a group as an admin.
groups.filter(myChatMemberFilter("out", "admin"), async (ctx) => {
await ctx.reply("Hello, thank you for adding me to the group as admin!");
});

// Listen for updates where the bot is promoted to admin.
groups.filter(myChatMemberFilter("regular", "admin"), async (ctx) => {
await ctx.reply("I was promoted to admin!");
});

// Listen for updates where the bot is demoted to a regular user.
groups.filter(myChatMemberFilter("admin", "regular"), async (ctx) => {
await ctx.reply("I am no longer admin");
});

bot.start({
// Make sure to include the "chat_member" update type for the above handlers to work.
allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"],
});
```

:::

Filters include the regular statuses (owner, administrator, member, restricted, left, kicked) and some additional ones for convenience:

- `restricted_in`: a restricted member of the chat
- `restricted_out`: not a member of the chat, has restrictions
- `in`: a member of the chat (administrator, creator, member, restricted_in)
- `out`: not a member of the chat (left, kicked, restricted_out)
- `free`: a non-restricted member of the chat (administrator, creator, member)
- `admin`: an admin of the chat (administrator, creator)
- `regular`: a non-admin member of the chat (member, restricted_in)

To summarize, here is a diagram showing what each query corresponds to:

![Diagram showing the statuses corresponding to each query.](/images/chat-members-statuses.svg)

You can create your custom groupings of chat member types by passing an array instead of a string:

```typescript
groups.filter(
chatMemberFilter(["restricted", "kicked"], ["free", "left"]),
async (ctx) => {
const from = ctx.from;
const { status: oldStatus, user } = ctx.chatMember.old_chat_member;
await ctx.reply(
`${from.first_name} lifted ` +
`${oldStatus === "kicked" ? "ban" : "restrictions"} ` +
`from ${user.first_name}`,
);
},
);
```

#### Example Usage

The best way to use the filters is to pick a set of relevant statuses, for example 'out', 'regular' and 'admin', then
make a table of the transitions between them:

| ↱ | Out | Regular | Admin |
| ----------- | ----------- | -------------------- | ------------------- |
| **Out** | ban-changed | join | join-and-promoted |
| **Regular** | exit | restrictions-changed | promoted |
| **Admin** | exit | demoted | permissions-changed |

Assign a listener to all the transitions that are relevant to your use-case.

Combine these filters with `bot.chatType` to only listen for transitions for a specific type of chat.
Add a middleware to listen to all updates as a way to perform common operations (like updating your database) before handing off control to a specific handler.

```typescript
const groups = bot.chatType(["group", "supergroup"]);

groups.on("chat_member", async (ctx, next) => {
// ran on all updates of type chat_member
const {
old_chat_member: { status: oldStatus },
new_chat_member: { user, status },
from,
chat,
} = ctx.chatMember;
console.log(
`In group ${chat.id} user ${from.id} changed status of ${user.id}:`,
`${oldStatus} -> ${status}`,
);

// update database data here

await next();
});

// specific handlers

groups.filter(chatMemberFilter("out", "in"), async (ctx, next) => {
const { new_chat_member: { user } } = ctx.chatMember;
await ctx.reply(`Welcome ${user.first_name}!`);
});
```

### Status Checking Utility

The `chatMemberIs` utility function can be useful whenever you want to use filtering logic within a handler.
It takes as input any of the regular and custom statuses (or an array of them), and narrows the type of the passed variable.

```ts
bot.callbackQuery("foo", async (ctx) => {
const chatMember = await ctx.getChatMember(ctx.from.id);

if (!chatMemberIs(chatMember, "free")) {
chatMember.status; // "restricted" | "left" | "kicked"
await ctx.answerCallbackQuery({
show_alert: true,
text: "You don't have permission to do this!",
});
return;
}

chatMember.status; // "creator" | "administrator" | "member"
await ctx.answerCallbackQuery("bar");
});
```

### Hydrating Chat Member Objects

You can further improve your development experience by using the hydration [API transformer](../advanced/transformers).
This transformer will apply to calls to `getChatMember` and `getChatAdministrators`, adding a convenient `is` method to the returned `ChatMember` objects.

```ts
type MyContext = HydrateChatMemberFlavor<Context>;
type MyApi = HydrateChatMemberApiFlavor<Api>;

const bot = new Bot<MyContext, MyApi>("");

bot.api.config.use(hydrateChatMember());

bot.command("ban", async (ctx) => {
const author = await ctx.getAuthor();

if (!author.is("admin")) {
author.status; // "member" | "restricted" | "left" | "kicked"
await ctx.reply("You don't have permission to do this");
return;
}

author.status; // "creator" | "administrator"
// ...
});
```

### Storing Chat Members

You can use a valid grammY [storage adapter](./session#known-storage-adapters) or an instance of any class that implements the [`StorageAdapter`](/ref/core/storageadapter) interface.
Expand All @@ -27,7 +349,7 @@ This means you also need to specify any other events you'd like to receive.
::: code-group

```ts [TypeScript]
import { Bot, type Context, MemorySessionStorage } from "grammy";
import { API_CONSTANTS, Bot, type Context, MemorySessionStorage } from "grammy";
import { type ChatMember } from "grammy/types";
import { chatMembers, type ChatMembersFlavor } from "@grammyjs/chat-members";

Expand All @@ -40,13 +362,13 @@ const bot = new Bot<MyContext>("");
bot.use(chatMembers(adapter));

bot.start({
// Make sure to specify the desired update types
allowed_updates: ["chat_member", "message"],
// Make sure to specify the desired update types.
allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"],
});
```

```js [JavaScript]
import { Bot, MemorySessionStorage } from "grammy";
import { API_CONSTANTS, Bot, MemorySessionStorage } from "grammy";
import { chatMembers } from "@grammyjs/chat-members";

const adapter = new MemorySessionStorage();
Expand All @@ -56,13 +378,14 @@ const bot = new Bot("");
bot.use(chatMembers(adapter));

bot.start({
// Make sure to specify the desired update types
allowed_updates: ["chat_member", "message"],
// Make sure to specify the desired update types.
allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"],
});
```

```ts [Deno]
import {
API_CONSTANTS,
Bot,
type Context,
MemorySessionStorage,
Expand All @@ -82,8 +405,8 @@ const bot = new Bot<MyContext>("");
bot.use(chatMembers(adapter));

bot.start({
// Make sure to specify the desired update types
allowed_updates: ["chat_member", "message"],
// Make sure to specify the desired update types.
allowed_updates: [...API_CONSTANTS.DEFAULT_UPDATE_TYPES, "chat_member"],
});
```

Expand Down
Loading
Loading