# Research: matrix-nio Event Handling

Based on: Matrix Client-Server Spec, matrix-nio 0.24+.

## Реакции (m.reaction)

Реакции используют `rel_type: "m.annotation"` для связи с исходным сообщением.

### Слушание и обработка реакций

```python
from nio import AsyncClient, ReactionEvent

class ReactionHandler:
    def __init__(self, client: AsyncClient):
        self.client = client
        # Регистрируем callback — явно, не через декоратор
        self.client.add_event_callback(self.on_reaction, ReactionEvent)

    async def on_reaction(self, room, event: ReactionEvent) -> None:
        # Игнорируем свои реакции
        if event.sender == self.client.user_id:
            return

        # Извлекаем ключ и target event ID
        # Способ 1 (новые версии nio)
        reaction_key = getattr(event, "key", None)
        target_event_id = getattr(event, "reacts_to", None)

        # Способ 2 (fallback — работает во всех версиях)
        if reaction_key is None or target_event_id is None:
            relates_to = event.content.get("m.relates_to", {})
            reaction_key = relates_to.get("key")
            target_event_id = relates_to.get("event_id")

        if not reaction_key or not target_event_id:
            return

        if reaction_key == "👍":
            await self.handle_confirm(room, target_event_id)
        elif reaction_key == "❌":
            await self.handle_cancel(room, target_event_id)

    async def handle_confirm(self, room, target_event_id: str) -> None:
        await self.client.room_send(
            room_id=room.room_id,
            message_type="m.room.message",
            content={"msgtype": "m.text", "body": "✅ Подтверждено"},
        )

    async def handle_cancel(self, room, target_event_id: str) -> None:
        await self.client.room_send(
            room_id=room.room_id,
            message_type="m.room.message",
            content={"msgtype": "m.text", "body": "❌ Отменено"},
        )
```

### Подводный камень #1: атрибуты ReactionEvent отличаются по версиям nio

В разных версиях matrix-nio атрибуты могут называться по-разному. Всегда используй fallback через `event.content`.

### Подводный камень #2: callback нужно регистрировать явно

```python
# НЕПРАВИЛЬНО — просто определить функцию недостаточно
async def on_reaction(room, event):
    pass

# ПРАВИЛЬНО
client.add_event_callback(on_reaction, ReactionEvent)
```

---

## Треды (m.thread)

### Создание треда и отправка сообщений

```python
async def create_thread_root(client: AsyncClient, room_id: str, text: str) -> str:
    """
    Создаёт корневое сообщение для треда.
    Первое сообщение НЕ должно содержать m.relates_to.
    Возвращает event_id корня.
    """
    resp = await client.room_send(
        room_id=room_id,
        message_type="m.room.message",
        content={
            "msgtype": "m.text",
            "body": text,
            # Нет m.relates_to! Это корень.
        },
    )
    return resp.event_id


async def send_to_thread(
    client: AsyncClient,
    room_id: str,
    thread_root_id: str,
    text: str,
) -> str:
    """
    Отправляет сообщение в существующий тред.
    """
    resp = await client.room_send(
        room_id=room_id,
        message_type="m.room.message",
        content={
            "msgtype": "m.text",
            "body": text,
            "m.relates_to": {
                "rel_type": "m.thread",
                "event_id": thread_root_id,  # ID корневого сообщения
            },
        },
    )
    return resp.event_id


# Использование для долгой задачи:
async def run_long_task(client, room_id, task_text):
    root_id = await create_thread_root(client, room_id, f"📋 {task_text}")

    await send_to_thread(client, room_id, root_id, "⏳ Обрабатываю... (1/3)")
    # ... работа ...
    await send_to_thread(client, room_id, root_id, "⏳ Анализирую... (2/3)")
    # ... работа ...
    await send_to_thread(client, room_id, root_id, "✅ Готово!")
```

### Подводный камень #3: корневое сообщение без m.relates_to

```python
# НЕПРАВИЛЬНО — первое сообщение треда не должно ссылаться на что-либо
content = {
    "msgtype": "m.text",
    "body": "Начинаю задачу",
    "m.relates_to": {"rel_type": "m.thread", "event_id": "..."},  # ОШИБКА
}

# ПРАВИЛЬНО — просто обычное сообщение
content = {
    "msgtype": "m.text",
    "body": "Начинаю задачу",
}
```

---

## Typing indicator

```python
async def with_typing(client: AsyncClient, room_id: str, coro):
    """
    Запускает корутину с индикатором печати.
    Автоматически убирает индикатор в finally.
    """
    async def renew_typing():
        """Возобновляет typing каждые 5 секунд (timeout = 10 сек)."""
        import asyncio
        while True:
            await client.room_typing(room_id, typing_state=True, timeout=10000)
            await asyncio.sleep(5)

    import asyncio
    renewal_task = asyncio.create_task(renew_typing())
    try:
        return await coro
    finally:
        renewal_task.cancel()
        await client.room_typing(room_id, typing_state=False, timeout=0)


# Использование:
async def on_message(client, room, event):
    result = await with_typing(client, room.room_id, process_message(event.body))
    await client.room_send(room.room_id, "m.room.message", {"msgtype": "m.text", "body": result})
```

### Подводный камень #4: typing исчезает через 10 секунд

Нужно возобновлять каждые 5 секунд если обработка долгая.

---

## Фильтрация собственных сообщений

```python
from nio import RoomMessageText

class MessageHandler:
    def __init__(self, client: AsyncClient):
        self.client = client
        self.client.add_event_callback(self.on_message, RoomMessageText)

    async def on_message(self, room, event: RoomMessageText) -> None:
        # ОБЯЗАТЕЛЬНО: игнорировать свои сообщения во избежание петли
        if event.sender == self.client.user_id:
            return

        # Обрабатываем только чужие сообщения
        await self.process(room, event)
```

### Подводный камень #5: user_id может быть None до login()

```python
if self.client.user_id is None:
    return  # клиент ещё не залогинен

if event.sender == self.client.user_id:
    return
```

---

## Обработка invite (m.room.member)

```python
from nio import InviteEvent, InviteMemberEvent

class InviteHandler:
    def __init__(self, client: AsyncClient):
        self.client = client
        self.client.add_event_callback(self.on_invite, InviteEvent)

    async def on_invite(self, room, event: InviteMemberEvent) -> None:
        """
        Вызывается когда бот получает приглашение в комнату.
        room.room_id — куда приглашают
        room.inviter — кто приглашает
        """
        inviter = room.inviter
        room_id = room.room_id

        # Принимаем приглашение
        resp = await self.client.join(room_id)
        if hasattr(resp, "room_id"):
            # Успешно вошли — начинаем регистрацию пользователя
            await self.start_onboarding(room_id, inviter)
        else:
            print(f"Failed to join {room_id}: {resp}")

    async def start_onboarding(self, room_id: str, user_id: str) -> None:
        await self.client.room_send(
            room_id=room_id,
            message_type="m.room.message",
            content={"msgtype": "m.text", "body": "Привет! Создаю ваше пространство..."},
        )
```

### Подводный камень #6: InviteRoom vs Room

В момент invite бот ещё не в комнате, поэтому `room.name` может быть `None`.

---

## Sync loop и reconnect

### Полная реализация с reconnect

```python
from nio import AsyncClient, SyncError
import asyncio, logging

logger = logging.getLogger(__name__)

class SyncManager:
    def __init__(self, client: AsyncClient):
        self.client = client
        self.sync_token = None
        self.should_stop = False

    async def run(self) -> None:
        reconnect_delay = 5
        attempts = 0

        while not self.should_stop:
            try:
                await self._sync_once()
                attempts = 0  # сбрасываем счётчик на успешный sync
                reconnect_delay = 5
            except Exception as e:
                attempts += 1
                if attempts > 10:
                    logger.error("Too many reconnect failures, stopping")
                    break
                delay = min(reconnect_delay * (2 ** (attempts - 1)), 300)
                logger.warning(f"Sync error: {e}. Retrying in {delay}s")
                await asyncio.sleep(delay)

    async def _sync_once(self) -> None:
        is_first_sync = self.sync_token is None

        response = await self.client.sync(
            since=self.sync_token,
            full_state=is_first_sync,
            set_presence="online",
            timeout=30000,
        )

        if isinstance(response, SyncError):
            raise Exception(f"Sync failed: {response.message}")

        if is_first_sync:
            # При первом синхе — только сохраняем токен, не обрабатываем старые события
            logger.info("Initial sync complete, skipping old events")
            self.sync_token = response.next_batch
            return

        self.sync_token = response.next_batch
        # Callbacks вызываются автоматически через add_event_callback
```

### Подводный камень #7: при первом синхе — много старых событий

```python
# Если не пропустить первый синх — бот обработает всю историю комнат
if self.sync_token is None:
    self.sync_token = response.next_batch
    return  # пропустить, только взять токен
```

### Подводный камень #8: full_state нужен только для первого синха

```python
# При повторных синхах full_state=True сильно замедляет работу
response = await client.sync(
    since=token,
    full_state=token is None,  # True только если первый раз
)
```

---

## Подводные камни (резюме)

| Проблема | Решение |
|----------|---------|
| Реакции не обрабатываются | `client.add_event_callback(handler, ReactionEvent)` |
| `event.reacts_to` не существует | Fallback: `event.content.get("m.relates_to", {}).get("event_id")` |
| Первое сообщение треда не создаёт тред | Первое сообщение БЕЗ `m.relates_to` |
| Бот зацикливается | `event.sender == client.user_id` → return |
| Старые события при старте | Пропусти первый синх: сохрани токен, не обрабатывай |
| Typing исчезает быстро | Возобновляй каждые 5 секунд |
| InviteRoom.name is None | Нормально, используй `room.room_id` |

---

## Выводы для нашей реализации

1. **Регистрировать callbacks** в `__init__` бота, до первого `sync()`
2. **Всегда проверять** `event.sender != client.user_id`
3. **Для подтверждений** — слать сообщение с инструкцией `👍 / ❌`, слушать `ReactionEvent`
4. **Для долгих задач** — `create_thread_root()`, статусы в тред через `send_to_thread()`
5. **Typing** — использовать `with_typing()` helper с автообновлением
6. **Sync loop** — при первом синхе пропускать старые события
