Skip to content

Instantly share code, notes, and snippets.

@d2weber
Created February 3, 2026 18:36
Show Gist options
  • Select an option

  • Save d2weber/4a7bf5fe3e7aaaa81bc9160387195826 to your computer and use it in GitHub Desktop.

Select an option

Save d2weber/4a7bf5fe3e7aaaa81bc9160387195826 to your computer and use it in GitHub Desktop.
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"id": "ad45b620",
"metadata": {},
"source": [
"# Delta Chat Public Rooms\n",
"\n",
"A relay-bot architecture for moderated public groups in Delta Chat.\n",
"\n",
"**Caveat**: This approach does not scale well for groups larger than a few dozen people, as the bot will run into rate limits when relaying messages to many recipients.\n",
"\n",
"### Design Overview\n",
"\n",
"Each \"room\" is a dedicated bot account. Participants join via SecureJoin QR code and communicate through separate chats with the bot, which relays messages to all other participants.\n",
"\n",
"**Reactions & Threading:** Relaying creates duplicate messages across chats, so replies and reactions need special handling to link back to the correct original. Reply context is maintained by encoding original message IDs into zero-width characters at the start of each relayed message. This is not visible to the use. When someone quotes a reply, the bot decodes this ID and finds the corresponding message in each recipient's chat. Reactions are aggregated across all copies and summarized back to each participant, excluding their own reaction and reactions from people in their chat.\n",
"\n",
"### Why This Architecture?\n",
"\n",
"- **Moderation** — The bot controls all message flow, enabling message removal, or banning without native Delta Chat group admin features.\n",
"\n",
"- **Privacy Options** — Participants don't see each other's email addresses, only display names.\n"
]
},
{
"cell_type": "markdown",
"id": "7738efe9",
"metadata": {},
"source": [
"### Possible future features\n",
"- allow history browsing (& send history on join)\n",
"- list currently online users\n",
"- message editing\n",
"- delete message\n",
"- better moderation (ban, access control with accept/decline)\n",
"- allow new room creation"
]
},
{
"cell_type": "markdown",
"id": "8c9c281e",
"metadata": {},
"source": [
"## License\n",
"\n",
"This project is licensed under the [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/)."
]
},
{
"cell_type": "markdown",
"id": "d80e7820",
"metadata": {},
"source": [
"## One time setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d7732639",
"metadata": {},
"outputs": [],
"source": [
"# %pip install -Uq deltachat_rpc_client deltachat_rpc_server"
]
},
{
"cell_type": "markdown",
"id": "077ffe78",
"metadata": {},
"source": [
"Run the cells needed to get `dc`, then spawn a new room once:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "de5f3792",
"metadata": {},
"outputs": [],
"source": [
"# acc = spawn_room(\"DC Community\")"
]
},
{
"cell_type": "markdown",
"id": "133df53c",
"metadata": {},
"source": [
"## Implementation"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1b46d858",
"metadata": {
"editable": true,
"slideshow": {
"slide_type": ""
},
"tags": [],
"time_run": "2026-02-02T09:09:07.464222+00:00"
},
"outputs": [],
"source": [
"import tempfile\n",
"from deltachat_rpc_client import Rpc, DeltaChat, EventType\n",
"from deltachat_rpc_client.events import NewMessage, RawEvent\n",
"from deltachat_rpc_client.const import SpecialContactId\n",
"from deltachat_rpc_client._utils import AttrDict\n",
"from fastcore.basics import patch\n",
"from fastcore.utils import *\n",
"from itertools import takewhile\n",
"from collections import Counter\n",
"import logging"
]
},
{
"cell_type": "markdown",
"id": "abd395d1",
"metadata": {},
"source": [
"### Hooks system for multi-account relay\n",
"\n",
"The built-in `Bot` class only handles events for a single account. Since our relay architecture uses one bot account per room, we need a custom event loop that processes events across all accounts.\n",
"\n",
"This section sets up:\n",
"- **Hooks registry**: A decorator-based system (`@on`) mapping functions to event types like `NewMessage` or `RawEvent`. Hooks are keyed by function name, so re-running a cell redefines the hook rather than adding a duplicate—ideal for interactive development.\n",
"- **Multi-account event loop**: A patched `events_loop` that receives events from any account, filters out self/device messages, and dispatches to registered hooks\n",
"\n",
"With this pattern, you define handlers once with `@on()` and they apply to messages from all room accounts."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2e83d1bf",
"metadata": {
"time_run": "2026-02-02T09:09:09.681762+00:00"
},
"outputs": [],
"source": [
"hooks = {}\n",
"\n",
"def on(event_type=NewMessage):\n",
" def decorator(func):\n",
" hooks[func.__name__] = (func, event_type)\n",
" return func\n",
" return decorator\n",
"\n",
"def run_hooks(event_type, event):\n",
" for func, et in hooks.values():\n",
" if et == event_type:\n",
" try: func(event)\n",
" except Exception: logging.exception(f\"Error in {func.__name__}\")\n",
"\n",
"@patch\n",
"def events_loop(self: Rpc) -> None:\n",
" try:\n",
" while True:\n",
" if self.closing: return\n",
" raw = self.get_next_event()\n",
" acc = first(a for a in dc.get_all_accounts() if a.id == raw[\"contextId\"])\n",
" if not acc: continue\n",
" event = AttrDict(raw[\"event\"])\n",
" event[\"kind\"], event[\"account\"] = EventType(event.kind), acc\n",
" run_hooks(RawEvent, event)\n",
" if event.kind != EventType.INCOMING_MSG: continue\n",
" for msg in acc.get_next_messages():\n",
" snap = msg.get_snapshot()\n",
" if snap.from_id not in [SpecialContactId.SELF, SpecialContactId.DEVICE]: run_hooks(NewMessage, AttrDict(message_snapshot=snap))\n",
" msg.mark_seen()\n",
" except Exception: logging.exception(\"Exception in the event loop\")"
]
},
{
"cell_type": "markdown",
"id": "9e0b1579",
"metadata": {},
"source": [
"### RPC initialization"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0263ffb3",
"metadata": {
"time_run": "2026-02-02T09:09:12.070113+00:00"
},
"outputs": [],
"source": [
"if 'rpc' not in globals():\n",
" rpc = Rpc()\n",
" rpc.start()\n",
" dc = DeltaChat(rpc)"
]
},
{
"cell_type": "markdown",
"id": "b5518d56",
"metadata": {},
"source": [
"### Message id encoding"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "213c37ef",
"metadata": {
"time_run": "2026-02-02T09:09:14.858973+00:00"
},
"outputs": [],
"source": [
"B0, B1, DELIM = \"\\u200c\", \"\\u200b\", \"\\u200d\"\n",
"ENC, DEC = str.maketrans(\"01\", B0+B1), str.maketrans(B0+B1, \"01\")\n",
"\n",
"def encode_msg_id(i): return f\"{i:b}\".translate(ENC) + DELIM\n",
"def extract_msg_id(text):\n",
" chars = \"\".join(takewhile(lambda c: c in (B0, B1), text))\n",
" return int(chars.translate(DEC), base=2) if chars else None\n",
"\n",
"def determine_original_id(snap):\n",
" if snap.sender.id != SpecialContactId.SELF:\n",
" return snap.id\n",
" return extract_msg_id(snap.text)\n"
]
},
{
"cell_type": "markdown",
"id": "ffce955f",
"metadata": {},
"source": [
"### Relay messages"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1a6b02dc",
"metadata": {
"time_run": "2026-02-02T09:09:17.155547+00:00"
},
"outputs": [],
"source": [
"def find_relayed_msg(chat, original_id):\n",
" \"Find a relayed message in chat by its encoded original ID\"\n",
" m = chat.account.get_message_by_id(original_id)\n",
" if m.exists() and m.get_snapshot().chat_id == chat.id: return m\n",
" results = rpc.search_messages(chat.account.id, encode_msg_id(original_id), chat.id)\n",
" return chat.account.get_message_by_id(results[0]) if results else None\n",
"\n",
"def relay(snap):\n",
" sender_name = snap.sender.get_snapshot().display_name\n",
" original_id = determine_original_id(snap.message.account.get_message_by_id(snap.parent_id).get_snapshot()) if snap.quote else None\n",
" for c in snap.message.account.get_chatlist():\n",
" if c.id == snap.chat.id: continue\n",
" c.send_message(text=encode_msg_id(snap.id) + snap.text,\n",
" override_sender_name=sender_name, \n",
" quoted_msg=find_relayed_msg(c, original_id) if original_id else None,\n",
" file=snap.file, filename=snap.file_name)\n"
]
},
{
"cell_type": "markdown",
"id": "f686c039",
"metadata": {},
"source": [
"### Handlers"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2349f336",
"metadata": {
"time_run": "2026-02-02T09:09:19.674034+00:00"
},
"outputs": [],
"source": [
"HELP = \"Commands: /sc, /scleave, /scqr\"\n",
"WELCOME_MSG = \"Welcome! Your messages are relayed to all participants. \" + HELP\n",
"NEW_ACCOUNT_QR = \"dcaccount:https://nine.testrun.org/new\"\n",
"\n",
"def spawn_room(name, new_acc=None):\n",
" if new_acc is None: new_acc = dc.add_account()\n",
" new_acc.set_config(\"displayname\", name)\n",
" new_acc.set_config(\"bot\", \"1\")\n",
" new_acc.set_config_from_qr(NEW_ACCOUNT_QR)\n",
" new_acc.configure()\n",
" new_acc.self_contact.create_chat()\n",
" new_acc.start_io()\n",
" return new_acc\n",
"\n",
"def cleanup_contacts(account):\n",
" for contact in account.get_contacts():\n",
" if not account.get_chat_by_contact(contact):\n",
" contact.delete()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c4d92af0",
"metadata": {
"time_run": "2026-02-02T09:09:22.779650+00:00"
},
"outputs": [],
"source": [
"\n",
"@on(RawEvent)\n",
"def handle_securejoin(event):\n",
" if event.kind == EventType.SECUREJOIN_INVITER_PROGRESS and event.get('progress') == 1000:\n",
" chat = event.account.get_chat_by_id(event.chatId)\n",
" chat.send_text(WELCOME_MSG)\n",
"\n",
"@on()\n",
"def handle_message(event):\n",
" snap = event.message_snapshot\n",
" if snap.is_info or snap.is_bot: return\n",
" acc, text = snap.chat.account, snap.text.strip()\n",
" match text:\n",
" case \"/sc\":\n",
" contacts = acc.get_contacts(snapshot=True)\n",
" snap.chat.send_text(f\"{HELP}\\nParticipants ({len(contacts)}):\\n\" + \"\\n\".join(sorted(c.display_name for c in contacts)))\n",
" case \"/scqr\":\n",
" qr_text, qr_svg = acc.get_qr_code_svg()\n",
" with tempfile.NamedTemporaryFile(suffix=\".svg\") as f:\n",
" f.write(qr_svg.encode())\n",
" snap.chat.send_message(text=qr_text, file=f.name)\n",
" case \"/scleave\":\n",
" snap.chat.send_text(\"Goodbye! Message again to rejoin.\")\n",
" snap.chat.delete()\n",
" cleanup_contacts(acc)\n",
" # case _ if text.startswith(\"/scnew \"):\n",
" # room_name = text[7:].strip()\n",
" # new_acc = spawn_room(room_name)\n",
" # qr_text, qr_svg = new_acc.get_qr_code_svg()\n",
" # with tempfile.NamedTemporaryFile(suffix=\".svg\") as f:\n",
" # f.write(qr_svg.encode())\n",
" # snap.chat.send_message(text=f\"Click on the link to join `{room_name}`\\n{qr_text}\", file=f.name)\n",
" case _ if text.startswith(\"/\"): snap.chat.send_text(f\"Unknown command. {HELP}\")\n",
" case _: relay(snap)"
]
},
{
"cell_type": "markdown",
"id": "beef2c9a",
"metadata": {},
"source": [
"### Reactions\n",
"\n",
"Aggregate reactions across relayed message copies, summarize back to participants"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "be02a4be",
"metadata": {
"time_run": "2026-02-02T09:09:24.577371+00:00"
},
"outputs": [],
"source": [
"def get_all_corresponding_msgs(acc, snap):\n",
" \"Get all message IDs for an original message across all chats\"\n",
" original_id = determine_original_id(snap)\n",
" msg_ids = rpc.search_messages(acc.id, encode_msg_id(original_id), None) + [original_id]\n",
" return list(rpc.get_messages(acc.id, msg_ids).values())\n",
"\n",
"def aggregate_reactions(rpc_msgs):\n",
" \"Aggregate reactions by contact across all related messages, excluding self\"\n",
" return {cid: emojis for m in rpc_msgs if (rxns := m.get('reactions')) \n",
" for cid, emojis in rxns.get('reactionsByContact', {}).items() \n",
" if cid != str(SpecialContactId.SELF)}\n",
"\n",
"def summarize_reactions(by_contact, exclude_contacts=None):\n",
" \"Create emoji summary string from reactions dict, optionally excluding some contacts\"\n",
" filtered = {cid: e for cid, e in by_contact.items() if not exclude_contacts or cid not in exclude_contacts}\n",
" counts = Counter(e for emojis in filtered.values() for e in emojis)\n",
" sorted_counts = sorted(counts.items(), key=lambda x: (-x[1], x[0]))\n",
" return \" \".join(f\"{e}{'' if c == 1 else c}\" for e,c in sorted_counts) if counts else \"\"\n",
"\n",
"@on(RawEvent)\n",
"def handle_reaction(ev):\n",
" if ev.kind != EventType.REACTIONS_CHANGED or ev.contactId == SpecialContactId.SELF: return\n",
" snap = ev.account.get_message_by_id(ev.msgId).get_snapshot()\n",
" rpc_msgs = get_all_corresponding_msgs(ev.account, snap)\n",
" by_contact = aggregate_reactions(rpc_msgs)\n",
" summary = summarize_reactions(by_contact)\n",
" for rpc_msg in rpc_msgs:\n",
" chat_contacts = set(str(c.id) for c in ev.account.get_chat_by_id(rpc_msg['chatId']).get_contacts())\n",
" reactions = summarize_reactions(by_contact, chat_contacts) if chat_contacts & set(by_contact.keys()) else summary\n",
" ev.account.get_message_by_id(rpc_msg['id']).send_reaction(reactions)"
]
},
{
"cell_type": "markdown",
"id": "4a1c8318",
"metadata": {},
"source": [
"### Start the bot"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8ffba0d6",
"metadata": {
"time_run": "2026-02-02T09:09:26.946281+00:00"
},
"outputs": [],
"source": [
"dc.start_io()"
]
},
{
"cell_type": "markdown",
"id": "0d0892d5",
"metadata": {},
"source": [
"### Use it"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6fb13d14",
"metadata": {},
"outputs": [],
"source": [
"acc = dc.get_all_accounts()[0]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7896d779",
"metadata": {},
"outputs": [],
"source": [
"qr_code, qr_code_svg = acc.get_qr_code_svg()\n",
"from IPython.display import HTML\n",
"HTML(f'<a href=\"{qr_code}\" target=\"_blank\">{qr_code}</a><div style=\"width:200px\">{qr_code_svg}</div>')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "036136f5",
"metadata": {},
"outputs": [],
"source": [
"{acc.id: acc.get_config('displayname') for acc in dc.get_all_accounts()}"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "52f6e5dd",
"metadata": {},
"outputs": [],
"source": [
"{c.id: c.name for c in acc.get_chatlist(snapshot=True)}"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "",
"name": ""
},
"language_info": {
"name": ""
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment