Skip to content
teleproto

Chats & Channels

Dialogs, members, admin actions, joining and leaving — the user-account ops the Bot API can't touch. teleproto exposes them through high-level helpers on TelegramClient and the raw Api.* surface where the helper stops.

Listing dialogs#

A dialog is anything that shows up in your chat list — a DM, a group, a channel, the saved-messages tab. iterDialogs paginates them for you. The full param shape: iterDialogs({ limit?, offsetDate?, offsetId?, offsetPeer?, ignorePinned?, folder? }).

dialogs.ts
for await (const dialog of client.iterDialogs({
  limit: 200,
  ignorePinned: false,
})) {
  console.log(
    `[${dialog.unreadCount.toString().padStart(4)}] ${dialog.title}`,
  );
}

Each dialog exposes .title, .unreadCount, .entity, .message (the last one), and the underlying TL .dialog object if you need raw flags.

Resolving usernames + invite links#

Usernames go through the high-level helper. getEntity resolves and caches against the session so repeat lookups are local.

Invite links don't have a high-level helper because Telegram treats "preview" and "join" as separate requests — drop down to the raw messages.CheckChatInvite call to inspect a link without joining.

resolve.ts
import { Api } from "teleproto";

// Public username → full entity
const channel = await client.getEntity("@some_public_channel");

// Invite link preview (without actually joining)
const preview = await client.invoke(
  new Api.messages.CheckChatInvite({ hash: "AAAAAAAAAAAAAAAAAA" }),
);
// preview is ChatInvite | ChatInviteAlready | ChatInvitePeek
console.log(preview);

Joining#

Public username channels and supergroups use channels.JoinChannel. Pass the result of getInputEntity — joining wants the small InputPeer shape, not the full entity.

join-public.ts
import { Api } from "teleproto";

const channel = await client.getInputEntity("@some_public_channel");
await client.invoke(new Api.channels.JoinChannel({ channel }));

Invite links — the t.me/+abc and t.me/joinchat/abc form — go through messages.ImportChatInvite with just the hash.

join-invite.ts
import { Api } from "teleproto";

// The hash is the part after the t.me/+ or t.me/joinchat/
await client.invoke(
  new Api.messages.ImportChatInvite({ hash: "AAAAAAAAAAAAAAAAAA" }),
);

Leaving#

Channels and supergroups leave through channels.LeaveChannel:

leave-channel.ts
import { Api } from "teleproto";

const channel = await client.getInputEntity("@some_public_channel");
await client.invoke(new Api.channels.LeaveChannel({ channel }));

Legacy basic groups (the pre-supergroup variant) use a different call — messages.DeleteChatUser with your own user id. Set revokeHistory: true if you want the messages you sent in that chat deleted on the way out.

leave-legacy.ts
import { Api } from "teleproto";

// Legacy (non-supergroup) chats use a different request
await client.invoke(
  new Api.messages.DeleteChatUser({
    chatId: legacyChatId,
    userId: await client.getInputEntity("me"),
    revokeHistory: false,
  }),
);

Listing participants#

iterParticipants paginates members of a chat, group, or channel. filter narrows the iteration to a subset — admins, kicked, banned, bots, or a search query.

admins.ts
import { Api } from "teleproto";

for await (const user of client.iterParticipants("@my_channel", {
  filter: new Api.ChannelParticipantsAdmins(),
})) {
  console.log(`@${user.username ?? user.id.toString()} — ${user.firstName ?? ""}`);
}

Other useful filters: Api.ChannelParticipantsKicked, Api.ChannelParticipantsBanned, Api.ChannelParticipantsBots, and Api.ChannelParticipantsSearch({ q }) for a name/username substring search.

participants-search.ts
import { Api } from "teleproto";

for await (const user of client.iterParticipants("@my_group", {
  limit: 50,
  filter: new Api.ChannelParticipantsSearch({ q: "alex" }),
})) {
  console.log(user.id.toString(), user.username);
}

Admin actions#

Promotions go through channels.EditAdmin with a fully-spelled ChatAdminRights object — every flag you don't set is denied. The rank string is the custom title that shows up next to the admin's name (max 16 characters).

edit-admin.ts
import { Api } from "teleproto";

await client.invoke(
  new Api.channels.EditAdmin({
    channel: await client.getInputEntity("@my_channel"),
    userId: await client.getInputEntity("@new_admin"),
    adminRights: new Api.ChatAdminRights({
      changeInfo: true,
      postMessages: true,
      editMessages: true,
      deleteMessages: true,
      banUsers: true,
      inviteUsers: true,
      pinMessages: true,
      addAdmins: false,
      anonymous: false,
      manageCall: false,
      other: false,
    }),
    rank: "editor",
  }),
);

Restrictions, mutes, and bans follow the same pattern but with channels.EditBanned and ChatBannedRights. The flags are negative — setting sendMessages: true actually means "take away the ability to send messages." For a one-shot kick that lets the user rejoin later, use the kickParticipant shortcut.

restrict.ts
import { Api } from "teleproto";

// Restrict (mute / ban). Same pattern as EditAdmin but with ChatBannedRights.
await client.invoke(
  new Api.channels.EditBanned({
    channel: await client.getInputEntity("@my_group"),
    participant: await client.getInputEntity("@offender"),
    bannedRights: new Api.ChatBannedRights({
      untilDate: 0, // 0 = forever
      viewMessages: false,
      sendMessages: true, // mute
      sendMedia: true,
      sendStickers: true,
      sendGifs: true,
      sendGames: true,
      sendInline: true,
      sendPolls: true,
      changeInfo: true,
      inviteUsers: true,
      pinMessages: true,
      embedLinks: true,
    }),
  }),
);

// Shortcut: hard-kick (ban + immediate unban so they can rejoin later)
await client.kickParticipant("@my_group", "@offender");

Reading message history#

History reads go through iterMessages — same pagination story as dialogs and participants. See Messages for the full parameter shape and filter options.

read-history.ts
// Read the last 50 messages without marking them read
for await (const message of client.iterMessages("@noisy_channel", {
  limit: 50,
})) {
  console.log(message.id, message.message?.slice(0, 80));
}

// Mark them read explicitly when you actually want the receipt
await client.markAsRead("@noisy_channel");

Iterating messages does not send a read receipt on its own — the other side won't see the double-tick. Call client.markAsRead(entity) explicitly when you actually want to acknowledge them.