Metadata-Version: 2.5
Name: securechat-sdk
Version: 4.2.2
Summary: Python SDK for SecureChat bots and user clients (aiogram-like).
Author: Bilbi
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Communications :: Chat
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography>=42
Requires-Dist: pynacl>=1.5
Requires-Dist: websockets>=12
Description-Content-Type: text/markdown

# SecureChat SDK 4.2

Python code that joins SecureChat as a real, **end-to-end encrypted** client. It holds its own keys, connects through
your relay with TLS pinned to the server, and speaks the same post-quantum protocol as the apps.

There are **two ways** to use it, and they are separate things:

|  | 🤖 **Bot** — `Bot` | 👤 **User session** — `Client` |
|---|---|---|
| **What it is** | A separate *bot account* (its name ends in `bot`) | **Your own account**, running as one more of your devices |
| **Signs in with** | A **bot token** from the app (*Menu → My Bots*) | A **session file** you create once with your username + passphrase |
| **People see** | Messages from `@yourbot` | Messages from **you** (they show on your phone and PC too) |
| **Style** | Like **aiogram**: handlers, filters, keyboards, FSM | Like **Telethon**: events, `send_message`, `iter_messages` |
| **Can** | Answer anyone who writes to it, show buttons and menus, guard groups as an admin | Everything you can do: read your history, write to anyone, join groups |
| **Can't** | Read chats it isn't in, or touch anyone's account | Send bot keyboards or buttons (only bots can) |
| **Use it for** | Menus, shops, games, support desks, group guards, notifications | Auto-replies, archiving and searching your chats, personal automation |

> **Rule of thumb:** building something **for other people**: make a **Bot**. Automating **your own account**: use
> **Client**.

---

## Contents

- [Install](#install)
- [Connecting to the server](#connecting-to-the-server)
- **Bots**
  - [1. Get a token](#1-get-a-token)
  - [2. Your first bot](#2-your-first-bot)
  - [3. Handlers](#3-handlers)
  - [4. Filters](#4-filters)
  - [5. The message object](#5-the-message-object)
  - [6. Sending, editing, deleting](#6-sending-editing-deleting)
  - [7. Formatting](#7-formatting)
  - [8. Keyboards and buttons](#8-keyboards-and-buttons)
  - [9. Callback queries (button presses)](#9-callback-queries-button-presses)
  - [10. Files and stickers](#10-files-and-stickers)
  - [11. Conversations (FSM)](#11-conversations-fsm)
  - [12. Groups and channels](#12-groups-and-channels)
  - [13. Routers, middleware, errors](#13-routers-middleware-errors)
  - [14. The bot's profile and command menu](#14-the-bots-profile-and-command-menu)
  - [15. Running a bot](#15-running-a-bot)
- **User sessions**
  - [1. Create a session file](#1-create-a-session-file)
  - [2. Your first script](#2-your-first-script)
  - [3. Events](#3-events)
  - [4. Client methods](#4-client-methods)
  - [5. Objects](#5-objects)
  - [6. Keeping the session safe](#6-keeping-the-session-safe)
- [Chat ids](#chat-ids)
- [Errors](#errors)
- [Environment variables](#environment-variables)
- [Examples](#examples)
- [Troubleshooting](#troubleshooting)

---

## Install

```
pip install -r securechat_sdk/requirements.txt        # pynacl, websockets, cryptography
```

Python 3.10+. Put the `securechat_sdk` folder next to your script (or on `PYTHONPATH`), then
`from securechat_sdk import Bot` or `from securechat_sdk import Client, events`.

## Connecting to the server

There is nothing to configure. Like the apps, the SDK is **locked to the SecureChat server**: its address is built in
(encoded in `securechat_sdk/server_address.py`: the file holds no readable address, every lock writes different bytes,
and an edited file is refused), and bots and sessions connect only there.
- They go through the relay, with TLS pinned to the server's certificate. An intercepted or tampered connection stops
  with `AuthError` before anything is sent.
- There is no `server=` setting and no environment variable for it: `SC_SERVER` is ignored, and `server=` with another
  address raises an error.

---

---

# 🤖 Bots

## 1. Get a token

In the app: **Menu → My Bots → New bot**, then open the bot and choose **API token → Copy**. A token looks like
`mybot:AbC…`. Anyone who has it *is* the bot, so treat it like a password.

```python
bot = Bot("mybot:AbC…")            # directly (fine for a quick test; don't commit it)
bot = Bot.from_env()               # from the BOT_TOKEN environment variable, or a .bot_token file next to the script
```

**Revoke** in *My Bots* rotates the token. The old one stops working at once: a running bot gets `TokenRevoked` and
stops.

## 2. Your first bot

```python
from securechat_sdk import Bot, F, InlineKeyboardBuilder
from securechat_sdk.formatting import bold

bot = Bot.from_env()

@bot.command("start", description="Say hi")            # also adds /start to the bot's "/" menu
async def start(msg):
    kb = InlineKeyboardBuilder()
    kb.button(text="👍 Yes", callback_data="yes", style="success")
    kb.button(text="Docs", url="https://example.org")
    await msg.answer(f"Hi {bold(msg.from_user.full_name)}! Ready?", reply_markup=kb.as_markup())

@bot.callback_query(F.data == "yes")
async def yes(cb):
    await cb.answer("Saved")                           # a small toast in the app
    await cb.edit_message_text("You said yes ✅")

@bot.message(F.text)
async def echo(msg):
    await msg.reply(msg.text)

bot.run()
```

```
export BOT_TOKEN="mybot:AbC…"            # Windows: set BOT_TOKEN=mybot:AbC…
python my_bot.py
```

## 3. Handlers

A handler is an `async` function with a decorator. For each incoming event, handlers are tried **in the order they were
registered**, and the **first one whose filters match** runs.

| Decorator | Runs for |
|---|---|
| `@bot.message(*filters)` | every incoming message: text, photo, file, sticker, a shared contact… |
| `@bot.command("start", "help", description="…")` | `/start`, `/start args`, `/start@yourbot args`; with `description=`, the command also goes into the "/" menu |
| `@bot.callback_query(*filters)` | a press on one of the bot's inline buttons |
| `@bot.chat_member(*filters)` | every group event: join, leave, add, remove, ban, unban, mute, unmute, promote, demote, pin, unpin, unpin_all, title, photo, slow |
| `@bot.new_chat_members(*filters)` | shortcut: join / add |
| `@bot.left_chat_member(*filters)` | shortcut: leave / remove / ban |
| `@bot.on_start` | after every (re)connect (a good place for `set_commands`) |
| `@bot.on_error` | `async def (event, exc)`: a handler raised |

Several filters in one decorator must all match (AND). Inside a handler, `raise SkipHandler` passes the event on to the
next matching handler.

**What a handler can ask for.** The first argument is the event. Add parameters by name and the SDK fills them in:

```python
async def handler(msg): ...
async def handler(msg, state): ...               # FSMContext for this user (section 11)
async def handler(msg, bot): ...                 # the Bot
async def handler(msg, state, bot): ...
```

## 4. Filters

Each line below is a filter; pass it to a decorator, e.g. `@bot.message(F.text == "hi")`:

```python
from securechat_sdk import F, Command, Text, Regexp

F.text == "hi"                                  # exact text
F.text.startswith("!")                          # also .endswith(), .contains(), .in_([...])
F.photo                                         # truthy = present (F.document, F.sticker, F.contact, F.file …)
F.chat.type == "private"                        # attribute paths work: F.from_user.username == "alice"
Command("help")                                 # what @bot.command("help") uses
Text("Yes", "No")                               # one of these texts
Regexp(r"^order (\d+)$")                        # the match is in msg.regexp_match
(F.text == "x") & (F.chat.type == "group")      # combine with &  |  ~
lambda m: len(m.text) > 100                     # any function (sync or async) works as a filter

@bot.message(F.text.startswith("!"), F.chat.type == "group")     # several filters: all must match
async def group_command(msg): ...
```

`F` comparisons: `==`, `!=`, `<`, `>`, `<=`, `>=`, `.is_()`, `.in_()`, `.startswith()`, `.endswith()`, `.contains()`.

## 5. The message object

`Message` (what handlers receive):

| Attribute | Meaning |
|---|---|
| `message_id` / `id` | the message's id |
| `chat` | `Chat(id, type)`: `id` is the sender's username in a private chat, `"g:<id>"` in a group; `type` is `"private"` or `"group"`; `.is_private`, `.is_group` |
| `from_user` | `User(username, display, is_bot)`: `.full_name`, `.mention` (`@name`), `.id` (= username) |
| `text` | the text (the caption for a file) |
| `ts` | when it was sent (unix ms) |
| `reply_to` | the message it replies to (a dict), or `None` |
| `file`, `photo`, `document` | a `File` (`photo` = an image, `document` = anything else), or `None` |
| `caption` | the text of a file message |
| `sticker` | `{"p": pack, "i": item, "e": emoji, "m": mime}` for stickers / GIFs |
| `command`, `args` | for commands: `CommandObject(command, args, raw)`; `msg.args` is the text after `/start ` (deep-link payloads) |
| `regexp_match` | set by `Regexp(...)` |
| `contact` | `Contact(user_id, first_name)`: the user shared their profile (`request_contact` button) |
| `users_shared` | `UsersShared(request_id, users)`, `.user_ids` (`request_users` button) |
| `chat_shared` | `ChatShared(request_id, chat_id, title, username)` (`request_chat` button) |

| Method | Does |
|---|---|
| `await msg.answer(text, reply_markup=None, parse_mode=…)` | sends to the same chat |
| `await msg.reply(text, reply_markup=None)` | the same, as a reply to this message |
| `await msg.answer_photo(photo, caption="", reply_markup=None)` | sends a picture (bytes or a path) |
| `await msg.edit_text(text, reply_markup=…)` | edits it (only the bot's own messages) |
| `await msg.edit_reply_markup(reply_markup)` | changes or removes its inline buttons |
| `await msg.delete()` | deletes it for everyone |
| `await msg.react("👍")` | a reaction |
| `await msg.typing()` | shows "typing…" for a few seconds |

## 6. Sending, editing, deleting

All are methods of `Bot`; `chat_id` is a username or `"g:<id>"`:

| Method | Notes |
|---|---|
| `send_message(chat_id, text, reply_markup=None, reply_to=None, parse_mode=…)` | returns the sent `Message` |
| `send_file(chat_id, file, caption="", filename=None, reply_markup=None, reply_to=None)` | `file` = bytes or a path; encrypted before upload. `send_photo` / `send_document` are the same method |
| `send_sticker(chat_id, pack, item_id=None, emoji=None, reply_to=None)` | by the pack's link; picks `item_id`, else the first matching `emoji`, else the first sticker |
| `send_chat_action(chat_id, "typing")` | "typing…" |
| `edit_message_text(chat_id, message_id, text, reply_markup=UNSET, parse_mode=UNSET)` | `UNSET` keeps the current keyboard |
| `edit_message_reply_markup(chat_id, message_id, reply_markup=None)` | `None` removes the buttons |
| `delete_message(chat_id, message_id)` | the bot's own, or anyone's as a group admin |
| `react(chat_id, message_id, emoji)` | |

## 7. Formatting

By default (`parse_mode="md"`) text is rendered as SecureChat markup:

| You write | You get |
|---|---|
| `**bold**` | **bold** |
| `__italic__` | *italic* |
| `~~strike~~` | ~~strike~~ |
| `\|\|spoiler\|\|` | a hidden spoiler |
| `` `code` `` and ` ```pre``` ` | monospace |
| `[text](https://…)` | a link |
| `> quote` | a quote |

Helpers in `securechat_sdk.formatting`: `bold`, `italic`, `strike`, `spoiler`, `code`, `pre(text, lang)`, `quote`,
`link(label, url)`, `mention(username)` and **`escape(text)`**. Always `escape()` text that comes from users before you
put it in markup. `Bot(token, parse_mode=None)` (or `parse_mode=None` on one call) sends text exactly as written.

## 8. Keyboards and buttons

**Inline buttons** (under a message):

```python
from securechat_sdk import InlineKeyboardMarkup, InlineKeyboardButton, InlineKeyboardBuilder, WebAppInfo

kb = InlineKeyboardMarkup().row(
    InlineKeyboardButton("Buy", callback_data="buy", style="success", icon="🛒"),
    InlineKeyboardButton("Cancel", callback_data="no", style="danger"))
kb.row(InlineKeyboardButton("Open site", url="https://example.org"),
       InlineKeyboardButton("Copy code", copy_text="ABC-123"))
kb.row(InlineKeyboardButton("Search", switch_inline_query_current_chat="cats"),   # puts "@yourbot cats" in the box
       InlineKeyboardButton("Web app", web_app=WebAppInfo("https://example.org")))

# or the builder:
b = InlineKeyboardBuilder()
for n in range(1, 7):
    b.button(text=str(n), callback_data=f"n:{n}")
b.adjust(3)                                    # rows of 3 (the last size repeats: adjust(2, 3) = 2, then 3, 3…)
await msg.answer("Pick one", reply_markup=b.as_markup())
```

Each button takes **exactly one** action:

| Action | What it does |
|---|---|
| `callback_data=` | sends the bot a callback (≤ 64 chars) |
| `url=` | opens a web link, or jumps to a user / group / channel (`@username`, `sc://username`, `sc://join/CODE`) |
| `copy_text=` | copies text |
| `web_app=` | opens a page after a confirm |
| `switch_inline_query_current_chat=` | puts `@yourbot …` in the input box |

Styles: `primary` (blue), `success` (green), `danger` (red); `blue` / `green` / `red` work too. `icon=` is one emoji.
Limits: 10 rows × 8 buttons, 64 characters of text.

**Reply keyboards** (instead of the phone's keyboard):

```python
from securechat_sdk import (ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove, ForceReply,
                            KeyboardButtonRequestUsers, KeyboardButtonRequestChat)

menu = ReplyKeyboardMarkup([
    [KeyboardButton("📋 Menu", style="primary"), KeyboardButton("Share my profile", request_contact=True)],
    [KeyboardButton("Pick friends", request_users=KeyboardButtonRequestUsers(1, max_quantity=3)),
     KeyboardButton("Pick a group", request_chat=KeyboardButtonRequestChat(2))],
], is_persistent=True, one_time_keyboard=False, input_field_placeholder="Choose…")
await msg.answer("Main menu", reply_markup=menu)

await msg.answer("Bye", reply_markup=ReplyKeyboardRemove())                       # take it away
await msg.answer("Your name?", reply_markup=ForceReply(input_field_placeholder="Name"))  # the app opens a reply

@bot.message(F.contact)
async def got_contact(msg): ...        # msg.contact.user_id / .first_name
@bot.message(F.users_shared)
async def got_users(msg): ...          # msg.users_shared.request_id / .user_ids
@bot.message(F.chat_shared)
async def got_chat(msg): ...           # msg.chat_shared.request_id / .chat_id ("g:…") / .title
```

A reply keyboard stays in the user's app (kept in their encrypted vault) until you send `ReplyKeyboardRemove()`. A
one-time keyboard hides after a press but stays behind the ⌨ button.

## 9. Callback queries (button presses)

```python
@bot.callback_query(F.data.startswith("n:"))
async def picked(cb):
    n = cb.data.split(":")[1]
    await cb.answer(f"You picked {n}")                     # toast; show_alert=True for a dialog
    await cb.edit_message_text(f"Picked **{n}**")          # edit the message the button was on
```

`CallbackQuery`:
- **Attributes:** `id`, `data`, `from_user`, `chat`, `message_id`, `message`.
- **Methods:**
  - `answer(text="", show_alert=False, url="")`: `url=` makes the app open a link or jump to `@username`.
  - `edit_message_text(text, reply_markup=…)`
  - `edit_message_reply_markup(markup)`

A press you don't `answer()` is answered for you (the spinner on the button stops). Presses are end-to-end encrypted to
the bot: the server never learns what users press.

Since SDK 4.2, a press only counts for a message this bot sent buttons on, in the chat it sent them to (others are
dropped before your handler runs). Still check `cb.from_user` yourself when a button is meant for one person.

## 10. Files and stickers

```python
@bot.message(F.photo)
async def photo(msg):
    data = await msg.photo.download()                      # bytes (downloaded and decrypted)
    await msg.answer(f"{msg.photo.name}: {len(data)} bytes")

await bot.send_file(chat, "report.pdf", caption="Monthly report")
await msg.answer_photo(open("cat.jpg", "rb").read(), caption="🐱")

await bot.send_sticker(chat, "cats_by_alice", emoji="😺")
@bot.message(F.sticker)
async def sticker(msg): await msg.answer(f"nice {msg.sticker['e']}")
```

`File`:
- **Attributes:** `file_id`, `name`, `size`, `mime`, `is_image`.
- **Method:** `await file.download()`, which returns the decrypted bytes.
- The server only ever stores ciphertext.

## 11. Conversations (FSM)

Remember where each user is in a multi-step dialog:

```python
from securechat_sdk import SkipHandler, State, StatesGroup

class Order(StatesGroup):
    name = State()
    address = State()

@bot.command("order")
async def order(msg, state):
    await state.set_state(Order.name)
    await msg.answer("Your name?")

@bot.message(lambda m: True)
async def steps(msg, state):
    step = await state.get_state()
    if step == str(Order.name):
        await state.update_data(name=msg.text)
        await state.set_state(Order.address)
        await msg.answer("Address?")
    elif step == str(Order.address):
        data = await state.get_data()
        await state.clear()
        await msg.answer(f"Thanks {data['name']}, shipping to {msg.text}")
    else:
        raise SkipHandler                         # not in a dialog: let other handlers have it
```

`FSMContext` methods:
- `set_state(state)`, `get_state()`
- `update_data(**kw)`, `get_data()`
- `clear()` / `finish()`

`bot.fsm(username)` gets one for any user. The state lives in memory; store anything that must survive a restart
yourself (e.g. in SQLite, as `examples/links_bot.py` does).

## 12. Groups and channels

Add the bot to a group like any member. Then:
- **Private groups:** messages are end-to-end encrypted to the bot too.
- **Public groups / channels:** it reads and posts through the server history like everyone else.

Either way `msg.chat.id` is `"g:<id>"`. For most of the moderation methods below, make the bot an **admin** (group info
→ Members → the bot → Make admin).

| Method | Does |
|---|---|
| `get_chat(chat)` | `ChatInfo`: `title`, `kind` (group / channel), `public`, `handle`, `description`, `owner`, `administrators`, `member_count`, `pinned`, `slow_mode`, `members` (private groups) |
| `get_chat_member_count(chat)` / `get_chat_administrators(chat)` | |
| `get_chat_member(chat, user)` | `ChatMember`: `status` (owner / admin / member / banned / left), `.is_admin`, `.is_muted`, `muted_until` |
| `ban_chat_member(chat, user)` / `unban_chat_member` | banned users can't come back, even with a link |
| `kick_chat_member(chat, user)` | removed, may rejoin |
| `mute_chat_member(chat, user, seconds=None)` / `unmute_chat_member` | `None` = until unmuted |
| `restrict_chat_member(chat, user, until_date=None, can_send_messages=False)` | Telegram-style alias of mute |
| `promote_chat_member(chat, user, is_admin=True)` | owner-only on the server side |
| `add_chat_members(chat, ["bob", "carol"])` | returns those whose privacy settings only allow invite links |
| `delete_message(chat, message_id)` | anyone's message |
| `pin_chat_message` / `unpin_chat_message(chat, mid)` / `unpin_all_chat_messages(chat)` | |
| `set_chat_title` / `set_chat_description` / `set_chat_photo(chat, path_or_bytes)` | |
| `set_slow_mode(chat, seconds)` | 0, 10, 30, 60, 300, 900 or 3600 |
| `export_chat_invite_link(chat, reset=False)` | `"sc:join/…"` |
| `delete_service_message(chat, event_id)` | removes "Alice joined" and similar |
| `leave_chat(chat)` | |

**Group events** come as `ChatMemberUpdated`:
- **Attributes:** `event` (`"join"`, `"leave"`, `"ban"` …), `user` (who it happened to), `actor` (who did it),
  `chat`, `until`, `title`, `event_id`, `.joined`, `.left`.
- **Methods:** `await ev.answer(text)`, and `await ev.delete()` to remove the service message from the chat.

```python
@bot.new_chat_members()
async def welcome(ev):
    await ev.answer(f"Welcome @{ev.user.username}!")

@bot.chat_member()
async def tidy(ev):                               # keep the group clean of "joined / left" lines
    if ev.event in ("join", "add", "leave", "remove"):
        await ev.delete()
```

Group events arrive while the bot is online; they aren't queued. See `examples/guard_bot.py` for a full group guard
(captcha, link filter, flood control, admin commands).

## 13. Routers, middleware, errors

Split a big bot into parts:

```python
from securechat_sdk import Router

shop = Router("shop")

@shop.command("buy")
async def buy(msg): ...

bot.include_router(shop)                          # routers can include routers
```

Middleware wraps every handler of its router (and the routers below it):

```python
@bot.middleware
async def timing(handler, event, data):           # data: {"bot", "event", "state"}; add your own keys
    import time
    t = time.time()
    try:
        return await handler(event, data)
    finally:
        print(f"{type(event).__name__} took {time.time() - t:.3f}s")

@bot.on_error
async def oops(event, exc):
    print("handler failed:", exc)
```

## 14. The bot's profile and command menu

```python
@bot.on_start
async def setup():
    await bot.set_commands({"start": "Open the menu", "help": "How it works"})   # or [("start", "…"), …]
    await bot.set_my_name("Pizza Bot")
    await bot.set_my_description("Order pizza in two taps")
    await bot.set_my_photo("logo.png")
```

Commands registered with `@bot.command(..., description=…)` are published automatically; `set_commands` replaces that
list.

## 15. Running a bot

- **`bot.run()`** blocks and reconnects by itself. Ctrl+C or SIGTERM shut it down cleanly, after the encryption
  state has been saved.
- **In your own asyncio program:** `await bot.run_async()` and `await bot.stop()`.
- **Encryption state:** bots speak protocol v3 (a post-quantum handshake, then a new key for every message). Their
  session keys are kept encrypted in `./securechat_state/<bot>.e2e`. Choose the folder with
  `Bot(token, state_dir="…")` or `SC_STATE_DIR`.
  - **Keep the folder between restarts**, and out of git and shared backups.
  - Deleting it is safe: the bot publishes fresh keys and senders' apps re-send whatever it couldn't read.
- **As a service:** run it under systemd, NSSM or Docker with `BOT_TOKEN` and a persistent `state_dir`.
- **Privacy:** a bot sees the plaintext of the chats it is in. That is what adding a bot means.

---

# 👤 User sessions

`Client` runs as **your own account**, like Telethon or Pyrogram:
- You sign in **once** with your username and passphrase; the SDK saves a **session file**. From then on the script
  runs without the passphrase.
- The session is **one more of your devices**. It appears in the app under *Settings → Devices*, and you can log it out
  there any time.
- It receives every message end-to-end encrypted for itself.
- What it sends shows on your phone and PC too.

## 1. Create a session file

```
python -m securechat_sdk.login me                    # asks: username, passphrase, a password for the file -> me.session
python -m securechat_sdk.login me --no-password      # a file WITHOUT a password (not recommended)
python -m securechat_sdk.login me --recovery         # sign in with your recovery key instead of the passphrase
python -m securechat_sdk.login me --name "Home server"   # how it's named in Settings → Devices
python -m securechat_sdk.login me --logout           # log the session out and delete the files
```

Or let the script do it: `client.start()` asks in the terminal the first time.

**The session file is password-protected by default.** A script then opens it with the same password:
`Client("me", session_password="…")`, or the `SC_SESSION_PASSWORD` environment variable. In a terminal it asks. A
script without a terminal (a service) and without the password stops with `AuthError` instead of guessing.

## 2. Your first script

```python
from securechat_sdk import Client, events

client = Client("me")                                # uses me.session

@client.on(events.NewMessage(incoming=True, pattern=r"(?i)^ping$"))
async def pong(event):
    await event.reply("pong 🏓")

@client.on(events.NewMessage(outgoing=True, pattern=r"^!chats$"))
async def chats(event):                              # type "!chats" in any chat, from your phone or PC
    dialogs = await client.get_dialogs()
    await event.respond("\n".join(f"{d.name} · {d.unread_count} unread" for d in dialogs[:10]))

client.start()
client.run_until_disconnected()
```

Async style:

```python
import asyncio
from securechat_sdk import Client

async def main():
    async with Client("me") as client:
        await client.send_message("alice", "Hello from Python 👋")
        async for m in client.iter_messages("alice", limit=20):
            print(m.date, m.sender_id, m.text)

asyncio.run(main())
```

`TelegramClient` is the same class under another name, for people coming from Telethon.

## 3. Events

Register with `@client.on(EventType(...))` or `client.add_event_handler(fn, EventType(...))`;
`client.remove_event_handler(fn)` removes one. Every event type takes:
- `chats=`: one chat id or a list
- `from_users=`: one username or a list
- `func=`: a function that gets the event and returns True to accept it

| Event | Fires when | The event has |
|---|---|---|
| `events.NewMessage(incoming=, outgoing=, pattern=, …)` | a message arrives (`incoming`) or **you** send one, from here or your other devices (`outgoing`); both by default | `message`, `text`, `chat_id`, `sender_id`, `out`, `is_private`, `is_group`, `id`, `pattern_match`; `reply()`, `respond()`, `delete()`, `mark_read()`, `download_media()`, `get_sender()`, `get_chat()` |
| `events.MessageEdited(...)` | a message was edited (same options) | the same; `text` is the new text |
| `events.MessageDeleted()` | messages were deleted | `chat_id`, `deleted_ids`, `deleted_id`, `sender_id` |
| `events.ChatAction()` | group events: joins, leaves, adds, bans, title / photo changes | `chat_id`, `action`, `user_id`, `by`, `user_joined`, `user_added`, `user_left`, `user_kicked`, `new_title`, `raw` |
| `events.UserUpdate()` | someone is typing, or comes online / goes away | `user_id`, `chat_id`, `typing`, `online` |
| `events.Raw()` | every message from the server, as a dict (advanced) | the dict |

`pattern=` is a regex string, a compiled pattern or a function. Matching uses `re.match`, from the start of the text.
`raise StopPropagation` (also `events.StopPropagation`) in a handler stops the handlers after it.

## 4. Client methods

**Connection**

| Method | |
|---|---|
| `Client(session="securechat", session_password=None, device_name=None)` (`session_password` defaults to `SC_SESSION_PASSWORD`) | `session`: a name (`me` → `me.session`) or a path |
| `start(username=None, passphrase=None, recovery_key=None)` | signs in the first time (asking for what's missing), then connects |
| `sign_in(username, passphrase=None, recovery_key=None)` | writes the session file only |
| `connect()` / `disconnect()` | with an existing session file |
| `run_until_disconnected()` | blocks while events are handled (`run_until_disconnected_async()` in async code) |
| `log_out()` | removes this session from your devices and deletes its files |

**Reading**

| Method | Returns |
|---|---|
| `get_me()` | your `User` |
| `get_entity("alice" \| "@handle" \| "g:<id>")` | a `User` or a `Chat` |
| `get_dialogs(limit=None)` | your chats as `Dialog`s, newest first |
| `iter_messages(entity, limit=100, search=None, from_user=None, reverse=False)` | an async iterator of `Message`s, newest first. History comes from your end-to-end encrypted vault; public groups and channels come from the server's history |
| `get_messages(entity, limit=100, …)` | the same, as a list |
| `get_participants(chat)` | a group's members as `User`s |
| `download_media(message, file=None)` | bytes, or saves into `file` (a path or a folder) and returns the path |

**Writing**

| Method | |
|---|---|
| `send_message(entity, text, reply_to=None, silent=False, parse_mode="md")` | `silent=True` = no notification sound |
| `send_file(entity, file, caption="", reply_to=None, file_name=None, voice_note=False)` | bytes or a path |
| `edit_message(entity, message, text)` | your own messages |
| `delete_messages(entity, message_ids)` | one id / message, or a list |
| `send_reaction(entity, message, emoji)` | |
| `send_read_acknowledge(entity, message=None)` | marks the chat read (the sender sees ✓✓) |
| `action(entity, "typing")` | "typing…" for a few seconds |
| `join_chat("@handle" \| "sc:join/CODE")` | returns the `Chat`; raises if the group needs approval (the request is sent) |
| `leave_chat(chat)` | |

## 5. Objects

**`Message`**
- **Attributes:** `id`, `chat_id`, `sender_id`, `text` (also `raw_text`, `message`), `date` (UTC datetime), `out`
  (sent by you), `reply_to_msg_id`, `file`, `sticker`, `media`, `edit_date`, `silent`, `fwd_from`, `is_private`,
  `is_group`, `is_reply`.
- **Methods:** `reply()`, `respond()`, `edit()`, `delete()`, `react()`, `mark_read()`, `download_media()`,
  `get_sender()`, `get_chat()`.

**`User`:** `id` / `username`, `display` / `first_name`, `handle` (the @username), `bot`.

**`Chat`** (groups and channels): `id` (`"g:<id>"`), `gid`, `title`, `kind` (group / channel), `public`, `handle`,
`participants_count`, `broadcast` (True for channels).

**`Dialog`:** `entity` (a `User` or `Chat`), `id`, `name` / `title`, `message` (the last one), `date`,
`unread_count`, `is_user`, `is_group`, `is_channel`.

## 6. Keeping the session safe

**`me.session` holds your identity key.** Anyone with the file can read and send as you.
- It's written readable by you only. Keep it out of git, chats and cloud folders.
- It's password-protected by default (Argon2id), so a copied file is useless without the password. A file made with
  `--no-password` logs a warning every time it's used.
- End-to-end session keys live next to it in `me.session.e2e`, encrypted with a key derived from your identity.
- Lost the machine? Open the app, go to **Settings → Devices** and log the session out. It stops working at once.
- Secret chats stay on the device that started them, so a session doesn't see them (the same as the apps).
- **Keys are pinned (SDK 4.2).** The first key seen for each person is kept in the encrypted state file. If the server
  later reports a different key, the SDK keeps using the pinned one, logs a warning, and doesn't encrypt to the new key.
  Once you've checked the new key yourself (its safety number in the app), call `client._t.accept_new_key(user, pub)`.
- Someone who has used the advanced (v3) encryption with you can't be quietly switched back to v2: such messages are
  refused and their app is asked to send them again.
- `download_media(msg, folder)` keeps the sender's file name inside `folder` (no paths, never overwrites).

---

## Chat ids

| You mean | Write |
|---|---|
| a person or a bot | their username: `"alice"` (`"@alice"` and `sc://alice` work in the `Client`) |
| a group or channel | `"g:<group id>"`, e.g. `msg.chat.id`, `event.chat_id`, `ChatShared.chat_id` |
| a public group by link (Client) | `get_entity("@handle")`, `join_chat("@handle")` |

## Errors

All errors derive from `SecureChatError`:

| Error | Meaning |
|---|---|
| `AuthError` | the token / session / passphrase was refused, or the server's pin didn't match the address |
| `TokenRevoked` (an `AuthError`) | the bot's owner revoked the token. Make a new one and restart |
| `SecureChatError` | anything else the server refused (no such user / group, not allowed …) |

## Environment variables

| Variable | Used by | Meaning |
|---|---|---|
| `BOT_TOKEN` | `Bot.from_env()` | the token (or put it alone in a `.bot_token` file next to the script) |
| `SC_STATE_DIR` | `Bot` | where the bot keeps its encrypted session keys (default `./securechat_state`) |
| `SC_SESSION_PASSWORD` | `Client`, `login` | the session file's password (for scripts that run unattended) |

## Examples

| File | Shows |
|---|---|
| `examples/echo_bot.py` | the smallest bot: echo, a reply keyboard, photos and documents |
| `examples/menu_bot.py` | inline keyboards: a +/- counter, URL and copy buttons, removing a keyboard |
| `examples/links_bot.py` | categories and links in SQLite, a form with FSM, inline keyboards |
| `examples/guard_bot.py` | a group guard: captcha for newcomers, a link filter, flood control, `/ban /mute /pin /slow` |
| `examples/user_client.py` | a user session: `!ping`, `!chats`, welcoming people who join your groups |

## Troubleshooting

| Problem | Fix |
|---|---|
| `'str' object has no attribute 'on_start'` | You wrote `bot = "mybot:…"`. It must be `bot = Bot("mybot:…")` |
| `AuthError` right away | Wrong or old token (copy it again from *My Bots*), or the connection to the server was intercepted |
| `only connects to the SecureChat server it is locked to` | Remove `server=` from `Bot(...)` / `Client(...)`: the address is built in |
| `TokenRevoked` | The token was revoked in *My Bots*. Copy the new one |
| The bot doesn't see messages in a group | Is it a member? Moderation methods also need it to be an **admin** |
| Moving the bot to another machine | Copy its `state_dir` folder along. Without it the bot still works: it publishes new keys and senders' apps re-send what it couldn't read |
| `me.session is password-protected` | Pass `session_password=` or set `SC_SESSION_PASSWORD` (a service has no terminal to ask in) |
| `no session file me.session` | Run `python -m securechat_sdk.login me` first, or call `client.start()` |
| The session stopped working | It was logged out in *Settings → Devices*. Create a new one |
