bot.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. """A small, opinionated framework for building bots on Chatto.
  2. Chatto 0.5.0 introduced *bot accounts*: user identities flagged as bots that
  3. authenticate with a **key** (e.g. ``cht_BK_...``) rather than a
  4. username/password. The key is used **directly as a bearer token** — there is
  5. no ``/auth/login`` round-trip — and it resolves to a ``User`` with a
  6. capability grant set.
  7. This module turns :class:`chattolib.client.ChattoClient` into the standard
  8. library for bots by adding three things on top of the raw client:
  9. * **Key-based login** — :meth:`Bot.login` builds an authenticated client from
  10. a bot key in one call.
  11. * **An event dispatcher** — :meth:`Bot.run` opens the realtime stream and
  12. routes incoming events (messages, reactions, mentions, presence, typing,
  13. room changes) to the async handlers you register.
  14. * **Flattened verbs** — :meth:`Bot.say`, :meth:`Bot.reply`,
  15. :meth:`Bot.react`, :method:`Bot.set_status`, :meth:`Bot.join_room`,
  16. :meth:`Bot.create_room`, and friends, so a bot's "brain" reads like
  17. natural language instead of protobuf plumbing.
  18. In the protocol-4 realtime channel, live events are thin, caller-scoped hints
  19. (identifiers rather than full resources). Where a handler needs the full
  20. resource (e.g. the body of a posted message), the bot hydrates it on demand
  21. through the corresponding ConnectRPC.
  22. A minimal bot::
  23. import asyncio
  24. from chattolib.bot import Bot
  25. async def on_message(event):
  26. if event.body.startswith("!hello"):
  27. await event.bot.reply(event, "hi!")
  28. async def main():
  29. async with await Bot.login("cht_BK_...") as bot:
  30. bot.on("message", on_message)
  31. await bot.run()
  32. asyncio.run(main())
  33. Requires the ``chattolib[realtime]`` extra for the live stream.
  34. """
  35. from __future__ import annotations
  36. import asyncio
  37. import contextlib
  38. from collections.abc import Awaitable, Callable
  39. from dataclasses import dataclass
  40. from typing import Any
  41. from chattolib import _pb # noqa: F401 — installs the generated pb import path
  42. from chattolib._pb.chatto.api.v1 import presence_pb2
  43. from chattolib.client import ChattoClient
  44. from chattolib.exceptions import ChattoError
  45. from chattolib.realtime import (
  46. ChattoRealtimeCloseError,
  47. ChattoRealtimeError,
  48. RealtimeConnection,
  49. RealtimeEvent,
  50. stream_events,
  51. )
  52. from chattolib.types import (
  53. Message,
  54. PresencePreference,
  55. PresenceStatus,
  56. Room,
  57. RoomGroup,
  58. RoomWithViewerState,
  59. User,
  60. )
  61. def _presence(value: Any) -> PresenceStatus:
  62. """Coerce a protobuf presence value (int or enum) to a PresenceStatus."""
  63. name = value.name if hasattr(value, "name") else presence_pb2.PresenceStatus.Name(int(value))
  64. # The protobuf name is the *value* of our StrEnum (e.g. "PRESENCE_STATUS_ONLINE"),
  65. # so look it up by value, not by member name.
  66. for member in PresenceStatus:
  67. if member.value == name:
  68. return member
  69. return PresenceStatus.UNSPECIFIED
  70. __all__ = [
  71. "Bot",
  72. "BotError",
  73. "BotEvent",
  74. "BotMessageEvent",
  75. "BotPresenceEvent",
  76. "BotPresencePreferenceEvent",
  77. "BotReactionEvent",
  78. "BotRoomEvent",
  79. "BotRoomInvitationEvent",
  80. "BotTypingEvent",
  81. "BotUserEvent",
  82. ]
  83. # Oneof ``event`` cases that map onto a :class:`BotRoomEvent`.
  84. _ROOM_EVENT_KINDS = frozenset(
  85. {
  86. "room_created",
  87. "room_updated",
  88. "room_deleted",
  89. "room_archived",
  90. "room_unarchived",
  91. "room_universal_changed",
  92. "room_slow_mode_changed",
  93. "room_threading_mode_changed",
  94. "user_joined_room",
  95. "user_left_room",
  96. }
  97. )
  98. # Oneof ``event`` cases that map onto a :class:`BotUserEvent`.
  99. _USER_EVENT_KINDS = frozenset(
  100. {"user_account_created", "user_profile_changed", "user_account_deleted"}
  101. )
  102. # Oneof ``event`` cases that map onto a :class:`BotRoomInvitationEvent`
  103. # (invitation lifecycle, since 0.5.0b20; delivered only to sender+recipient).
  104. _INVITATION_EVENT_KINDS = frozenset(
  105. {"room_invitation_created", "room_invitation_accepted", "room_invitation_declined"}
  106. )
  107. class BotError(ChattoError):
  108. """Raised for bot-framework-level errors (bad handlers, bad keys, ...)."""
  109. # ---------------------------------------------------------------------------
  110. # Event types
  111. # ---------------------------------------------------------------------------
  112. @dataclass
  113. class BotEvent:
  114. """Base class for every event the dispatcher delivers to a handler.
  115. ``bot`` is the owning :class:`Bot`, so a handler can act on the event
  116. (e.g. ``await event.bot.reply(event, "...")``) without closing over
  117. globals.
  118. """
  119. bot: Bot
  120. kind: str
  121. async def _noop(self) -> None: # pragma: no cover - interface
  122. ...
  123. @dataclass
  124. class BotMessageEvent(BotEvent):
  125. """A new message in a room the bot can see.
  126. ``message`` is the full :class:`Message`, hydrated from the server when
  127. this event was dispatched. ``actor`` is the author's :class:`User` when
  128. it could be resolved; otherwise ``None``.
  129. """
  130. message: Message
  131. actor: User | None = None
  132. @property
  133. def room_id(self) -> str:
  134. return self.message.room_id
  135. @property
  136. def body(self) -> str | None:
  137. return self.message.body
  138. @property
  139. def is_mention(self) -> bool:
  140. """True when this message mentions the bot (best-effort)."""
  141. me = self.bot.user
  142. if me is None or not self.message.body:
  143. return False
  144. return f"@{me.login}" in self.message.body or me.display_name in self.message.body
  145. @dataclass
  146. class BotReactionEvent(BotEvent):
  147. """A reaction was added to (or removed from) a message."""
  148. room_id: str
  149. message_event_id: str
  150. emoji: str
  151. user_id: str
  152. added: bool = True
  153. @dataclass
  154. class BotPresenceEvent(BotEvent):
  155. """A user's presence status changed."""
  156. user_id: str
  157. status: PresenceStatus
  158. @dataclass
  159. class BotPresencePreferenceEvent(BotEvent):
  160. """The bot's own private availability *preference* changed (0.5.0b6).
  161. Delivered only to the account whose own choice changed. The wire payload is
  162. an empty hint: to read the new choice, hydrate it from the bot via
  163. ``await event.bot.get_presence_preference()`` (the current
  164. :class:`~chattolib.types.PresencePreference`).
  165. """
  166. @dataclass
  167. class BotTypingEvent(BotEvent):
  168. """A user started (or stopped) typing in a room/thread."""
  169. room_id: str
  170. thread_root_event_id: str | None = None
  171. @dataclass
  172. class BotRoomEvent(BotEvent):
  173. """A room lifecycle change (created, updated, archived, member join/...)."""
  174. room: Room | None = None
  175. detail: str = "" # e.g. "created", "updated", "archived", "user_joined_room"
  176. @dataclass
  177. class BotUserEvent(BotEvent):
  178. """A user account was created, changed, or deleted."""
  179. user: User | None = None
  180. removed: bool = False
  181. @dataclass
  182. class BotRoomInvitationEvent(BotEvent):
  183. """A room invitation was created, accepted, or declined (since 0.5.0b20).
  184. Delivered only to the invitation's sender and recipient. ``detail`` is
  185. ``"created"``, ``"accepted"``, or ``"declined"``. The payload is a thin
  186. hint (IDs only); hydrate the full invitation via
  187. ``await event.bot.client.get_room_invitation(event.invitation_id)`` when
  188. the recipient is the bot itself.
  189. """
  190. room_id: str = ""
  191. invitation_id: str = ""
  192. detail: str = ""
  193. # ---------------------------------------------------------------------------
  194. # The Bot
  195. # ---------------------------------------------------------------------------
  196. Handler = Callable[[BotEvent], Awaitable[None]]
  197. class Bot:
  198. """A Chatto bot: an authenticated client plus an event dispatcher and a
  199. set of convenience verbs.
  200. Create one with :meth:`login` (from a bot key) or :meth:`from_client`
  201. (wrapping an already-authenticated :class:`ChattoClient`).
  202. """
  203. def __init__(
  204. self,
  205. client: ChattoClient,
  206. *,
  207. base_url: str | None = None,
  208. ) -> None:
  209. self._client = client
  210. self._base_url = base_url or client.base_url
  211. self._user: User | None = None
  212. self._handlers: dict[str, list[Handler]] = {}
  213. self._connection: RealtimeConnection | None = None
  214. self._running = False
  215. # -- construction ------------------------------------------------------
  216. @classmethod
  217. async def login(
  218. cls,
  219. key: str,
  220. *,
  221. base_url: str | None = None,
  222. ) -> Bot:
  223. """Authenticate a bot with its **key**.
  224. The key (e.g. ``cht_BK_...``) is used directly as the bearer token —
  225. no username/password. ``base_url`` defaults to the public Chatto
  226. server; pass it to target a self-hosted or preview deployment.
  227. """
  228. if base_url is None:
  229. base_url = ChattoClient.DEFAULT_BASE_URL
  230. client = ChattoClient(token=key, base_url=base_url)
  231. bot = cls(client, base_url=base_url)
  232. await bot._probe_identity()
  233. return bot
  234. @classmethod
  235. def from_client(cls, client: ChattoClient) -> Bot:
  236. """Wrap an already-authenticated client (e.g. a human login)."""
  237. return cls(client)
  238. async def _probe_identity(self) -> None:
  239. """Resolve the bot's own identity and warn (not fail) if not a bot."""
  240. try:
  241. self._user = await self._client.me()
  242. except ChattoError:
  243. self._user = None
  244. return
  245. if self._user is not None and not self._user.is_bot:
  246. # Not fatal — a human client can drive the same verbs — but surface
  247. # it so a misconfigured key is obvious.
  248. import warnings
  249. warnings.warn(
  250. f"Bot key authenticated as {self._user.login!r}, which is not "
  251. "flagged is_bot. It will still work, but this is usually a "
  252. "misconfigured key.",
  253. stacklevel=2,
  254. )
  255. # -- identity ----------------------------------------------------------
  256. @property
  257. def client(self) -> ChattoClient:
  258. """The underlying :class:`ChattoClient` for any RPC not wrapped here."""
  259. return self._client
  260. @property
  261. def base_url(self) -> str:
  262. return self._base_url
  263. @property
  264. def user(self) -> User | None:
  265. """The bot's own :class:`User` profile, once known."""
  266. return self._user
  267. @property
  268. def login_name(self) -> str | None:
  269. return self._user.login if self._user else None
  270. @property
  271. def display_name(self) -> str | None:
  272. return self._user.display_name if self._user else None
  273. # -- lifecycle ---------------------------------------------------------
  274. async def __aenter__(self) -> Bot:
  275. return self
  276. async def __aexit__(self, *exc: Any) -> None:
  277. await self.close()
  278. async def close(self) -> None:
  279. if self._connection is not None:
  280. with contextlib.suppress(Exception):
  281. await self._connection.close()
  282. self._connection = None
  283. self._running = False
  284. await self._client.close()
  285. # -- event registration ------------------------------------------------
  286. def on(self, kind: str, handler: Handler) -> Handler:
  287. """Register ``handler`` for events of ``kind``.
  288. ``kind`` is one of: ``message``, ``reaction``, ``presence``,
  289. ``presence_preference``, ``typing``, ``room``, ``user``, ``*`` (all
  290. events). ``handler`` is an
  291. ``async def`` taking a single :class:`BotEvent`. Returns the handler
  292. so ``on`` can be used as a decorator.
  293. """
  294. self._handlers.setdefault(kind, []).append(handler)
  295. return handler
  296. def off(self, kind: str, handler: Handler) -> None:
  297. with contextlib.suppress(ValueError):
  298. self._handlers[kind].remove(handler)
  299. async def _dispatch(self, event: BotEvent) -> None:
  300. handlers = list(self._handlers.get(event.kind, [])) + list(self._handlers.get("*", []))
  301. for handler in handlers:
  302. try:
  303. await handler(event)
  304. except Exception: # noqa: BLE001 - one bad handler must not kill the loop
  305. import logging
  306. logging.exception("bot handler for %r raised", event.kind)
  307. # -- verbs: messaging --------------------------------------------------
  308. async def say(self, room_id: str, body: str = "", *, join_if_needed: bool = True) -> Message:
  309. """Post a message to a room. Returns the created :class:`Message`.
  310. With ``join_if_needed`` (the default), the bot joins the room first if
  311. it isn't already a member — so a bot that wants to be present
  312. everywhere can simply ``await bot.say(room_id, ...)`` without a
  313. separate join step. Pass ``join_if_needed=False`` to instead surface
  314. the server's ``permission_denied`` if the bot can't post.
  315. """
  316. try:
  317. return await self._client.post_message(room_id, body)
  318. except ChattoError as exc:
  319. if not join_if_needed or "not a member" not in str(exc):
  320. raise
  321. await self._client.join_room(room_id)
  322. return await self._client.post_message(room_id, body)
  323. async def reply(
  324. self,
  325. target: BotMessageEvent | Message,
  326. body: str,
  327. *,
  328. also_send_to_channel: bool = False,
  329. ) -> Message:
  330. """Reply to a message (or a :class:`BotMessageEvent`) in its room/thread."""
  331. if isinstance(target, BotMessageEvent):
  332. target = target.message
  333. return await self._client.post_message(
  334. target.room_id,
  335. body,
  336. thread_root_event_id=target.thread_root_event_id,
  337. in_reply_to=target.id,
  338. also_send_to_channel=also_send_to_channel,
  339. )
  340. async def react(self, room_id: str, message_event_id: str, emoji: str) -> bool:
  341. """Add an emoji reaction to a message."""
  342. return await self._client.add_reaction(room_id, message_event_id, emoji)
  343. async def unreact(self, room_id: str, message_event_id: str, emoji: str) -> bool:
  344. """Remove one of the bot's emoji reactions from a message."""
  345. return await self._client.remove_reaction(room_id, message_event_id, emoji)
  346. # -- verbs: presence & status -----------------------------------------
  347. async def set_presence(self, status: PresenceStatus) -> PresenceStatus:
  348. """Set the bot's presence (``ONLINE`` / ``AWAY`` / ``DO_NOT_DISTURB``)."""
  349. return await self._client.set_presence(status)
  350. async def get_presence_preference(self) -> PresencePreference | None:
  351. """Read the bot's saved availability choice (or ``None`` if unset)."""
  352. return await self._client.get_presence_preference()
  353. async def set_presence_preference(
  354. self, status: PresenceStatus, *, expected_revision: str = ""
  355. ) -> PresencePreference:
  356. """Save the bot's availability choice for every device on the server.
  357. Unlike :meth:`set_presence` this persists across disconnects and
  358. accepts ``OFFLINE`` (meaning "Appear Offline"). Echo a prior
  359. ``revision`` back as ``expected_revision`` to guard against a
  360. concurrent change.
  361. """
  362. return await self._client.set_presence_preference(
  363. status, expected_revision=expected_revision
  364. )
  365. async def refresh_presence(self) -> PresencePreference | None:
  366. """Refresh the bot's liveness (call every ~30 s to stay "live")."""
  367. return await self._client.refresh_presence()
  368. async def set_status(self, emoji: str, text: str) -> dict[str, Any]:
  369. """Set the bot's custom status (e.g. a "working on X" note)."""
  370. return await self._client.set_custom_status(emoji, text)
  371. async def clear_status(self) -> dict[str, Any]:
  372. """Clear the bot's custom status."""
  373. return await self._client.delete_custom_status()
  374. # -- verbs: rooms ------------------------------------------------------
  375. async def join_room(self, room_id: str) -> Room:
  376. """Join a room. Returns the joined :class:`Room`."""
  377. return await self._client.join_room(room_id)
  378. async def join_room_group(self, group_id: str) -> list[str]:
  379. """Join **all** the rooms in a room group in one call.
  380. Mirrors the Chatto UI's one-click "join group" action. Returns the
  381. list of room IDs the bot is now a member of as a result.
  382. """
  383. return await self._client.join_room_group(group_id)
  384. async def list_room_groups(self) -> list[RoomGroup]:
  385. """List the room groups (and the rooms each contains) the bot can see."""
  386. return await self._client.list_room_groups()
  387. async def join_all_rooms(self) -> list[str]:
  388. """Join every room the bot can see, grouped the way the UI does.
  389. Joins each room group in one call (so a bot becomes a member of all
  390. the rooms in a group at once), then joins any ungrouped rooms
  391. individually. Returns the room IDs the bot is now a member of.
  392. """
  393. joined: list[str] = []
  394. groups = await self.list_room_groups()
  395. grouped_room_ids: set[str] = set()
  396. for group in groups:
  397. for rws in group.rooms:
  398. if rws.room is not None:
  399. grouped_room_ids.add(rws.room.id)
  400. try:
  401. joined.extend(await self.join_room_group(group.id))
  402. except ChattoError:
  403. # A group the bot can't join is skipped, not fatal.
  404. continue
  405. # Rooms that are not part of any group.
  406. for rws in await self.list_rooms():
  407. if rws.room is None or rws.room.id in grouped_room_ids:
  408. continue
  409. if rws.viewer_state.is_member:
  410. continue
  411. try:
  412. joined.append((await self.join_room(rws.room.id)).id)
  413. except ChattoError:
  414. continue
  415. return joined
  416. async def leave_room(self, room_id: str) -> None:
  417. """Leave a room."""
  418. await self._client.leave_room(room_id)
  419. async def create_room(
  420. self,
  421. name: str,
  422. group_id: str,
  423. *,
  424. description: str = "",
  425. universal: bool = False,
  426. ) -> Room:
  427. """Create a room in a room group."""
  428. return await self._client.create_room(
  429. name, group_id, description=description, universal=universal
  430. )
  431. async def list_rooms(self) -> list[RoomWithViewerState]:
  432. """List the rooms the bot is a member of."""
  433. return await self._client.list_rooms()
  434. async def mark_read(self, room_id: str) -> None:
  435. """Mark a room as read (clears its unread state for the bot)."""
  436. await self._client.mark_room_as_read(room_id)
  437. # -- the run loop ------------------------------------------------------
  438. async def run(
  439. self,
  440. *,
  441. resume_cursor: str | None = None,
  442. until: asyncio.Event | None = None,
  443. ) -> None:
  444. """Connect the realtime stream and dispatch events until it closes.
  445. Reconnects automatically (resuming from the last received
  446. ``resume_cursor``) when the server sends a reconnectable close frame.
  447. When the server terminates the session (or sends a non-reconnectable
  448. close), the loop stops. Pass ``until`` to stop on an external signal.
  449. """
  450. self._running = True
  451. cursor = resume_cursor
  452. while self._running:
  453. if until is not None and until.is_set():
  454. break
  455. try:
  456. async for frame in stream_events(self._client, resume_cursor=cursor):
  457. if until is not None and until.is_set():
  458. break
  459. if isinstance(frame, RealtimeEvent):
  460. if frame.cursor:
  461. cursor = frame.cursor
  462. await self._handle_live(frame)
  463. except ChattoRealtimeCloseError as close:
  464. if not close.reconnect:
  465. # Session terminated or otherwise unrecoverable: stop.
  466. self._running = False
  467. break
  468. # Reconnectable close: loop again, resuming from the cursor.
  469. continue
  470. except ChattoRealtimeError:
  471. raise
  472. self._running = False
  473. # -- live-event dispatch -----------------------------------------------
  474. async def _handle_live(self, frame: RealtimeEvent) -> None:
  475. kind = frame.kind
  476. if kind == "presence_changed":
  477. await self._dispatch(
  478. BotPresenceEvent(
  479. bot=self,
  480. kind="presence",
  481. user_id=frame.actor_id or "",
  482. status=_presence(frame.payload.status),
  483. )
  484. )
  485. elif kind == "viewer_presence_preference_changed":
  486. await self._dispatch(BotPresencePreferenceEvent(bot=self, kind="presence_preference"))
  487. elif kind == "user_typing":
  488. payload = frame.payload
  489. await self._dispatch(
  490. BotTypingEvent(
  491. bot=self,
  492. kind="typing",
  493. room_id=payload.room_id,
  494. thread_root_event_id=payload.thread_root_event_id or None,
  495. )
  496. )
  497. elif kind in ("reaction_added", "reaction_removed"):
  498. payload = frame.payload
  499. await self._dispatch(
  500. BotReactionEvent(
  501. bot=self,
  502. kind="reaction",
  503. room_id=payload.room_id,
  504. message_event_id=payload.message_event_id,
  505. emoji=payload.emoji,
  506. user_id=frame.actor_id or "",
  507. added=kind == "reaction_added",
  508. )
  509. )
  510. elif kind == "message_posted":
  511. await self._on_message_posted(frame)
  512. elif kind in _ROOM_EVENT_KINDS:
  513. await self._on_room_event(frame)
  514. elif kind in _USER_EVENT_KINDS:
  515. await self._on_user_event(frame)
  516. elif kind in _INVITATION_EVENT_KINDS:
  517. payload = frame.payload
  518. await self._dispatch(
  519. BotRoomInvitationEvent(
  520. bot=self,
  521. kind="room_invitation",
  522. room_id=getattr(payload, "room_id", ""),
  523. invitation_id=getattr(payload, "invitation_id", ""),
  524. detail=frame.kind.removeprefix("room_invitation_"),
  525. )
  526. )
  527. async def _on_message_posted(self, frame: RealtimeEvent) -> None:
  528. # Protocol-4 events are thin hints; hydrate the full message so the
  529. # handler gets a usable :class:`Message` (including the body).
  530. payload = frame.payload
  531. msg: Message | None = None
  532. with contextlib.suppress(ChattoError):
  533. msg = await self._client.get_message(payload.room_id, frame.id)
  534. if msg is None:
  535. return
  536. await self._dispatch(BotMessageEvent(bot=self, kind="message", message=msg, actor=None))
  537. async def _on_room_event(self, frame: RealtimeEvent) -> None:
  538. payload = frame.payload
  539. room_id = getattr(payload, "room_id", "")
  540. if not room_id:
  541. return
  542. extra: dict[str, Any] = {}
  543. if frame.kind == "room_created":
  544. name = getattr(payload, "name", "")
  545. if name:
  546. extra["name"] = name
  547. room = Room.parse({"id": room_id, **extra})
  548. await self._dispatch(BotRoomEvent(bot=self, kind="room", room=room, detail=frame.kind))
  549. async def _on_user_event(self, frame: RealtimeEvent) -> None:
  550. user_id = getattr(frame.payload, "user_id", "") or frame.actor_id or ""
  551. removed = frame.kind == "user_account_deleted"
  552. await self._dispatch(
  553. BotUserEvent(
  554. bot=self,
  555. kind="user",
  556. user=User.parse({"id": user_id}) if user_id else None,
  557. removed=removed,
  558. )
  559. )