Skip to content

Instantly share code, notes, and snippets.

@d2weber
Created December 27, 2025 23:30
Show Gist options
  • Select an option

  • Save d2weber/2f02afe3a5f5d47c0bca44bc831c2845 to your computer and use it in GitHub Desktop.

Select an option

Save d2weber/2f02afe3a5f5d47c0bca44bc831c2845 to your computer and use it in GitHub Desktop.
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"id": "a50a69d3",
"metadata": {
"editable": true,
"slideshow": {
"slide_type": ""
},
"tags": []
},
"source": [
"# Confluence Chat - Project Summary\n",
"\n",
"## Overview\n",
"A real-time chat application built as a webxdc app for Delta Chat. The architecture uses a bot as an API backend that relays messages between clients in a 1-on-1 chat pattern.\n",
"\n",
"## Architecture Pattern\n",
"- **Bot as API Gateway**: Each user has a 1-on-1 chat with the bot\n",
"- **Webxdc as Client**: Slim client that fetches only needed content\n",
"- **Realtime Communication**: Uses webxdc realtime channels for instant messaging\n",
"- **Request/Response Pattern**: Client sends requests, bot processes and broadcasts\n",
"\n",
"## Usage Instructions\n",
"\n",
"1. Setup a DeltaChat account for the bot\n",
"2. Run all the code cells below in order\n",
"3. Setup the inital admin: Connect to the bot with another DeltaChat profile (via QR code or link) and make that profile an admin: `users_tbl.upsert(User(username=\"<address-of-your-client>\", role=\"admin\"))`\n",
"5. Send `/con` to the bot to receive the Confluence.xdc app\n",
"6. Connect to the bot with other profiles\n",
"7. Approve them in the admin view of the webxdc app\n",
"8. Start chatting!"
]
},
{
"cell_type": "markdown",
"id": "bbd4c7e9-5120-4b00-8c4b-9f2a71b812e2",
"metadata": {
"editable": true,
"slideshow": {
"slide_type": ""
},
"tags": []
},
"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": "f25fe932",
"metadata": {
"editable": true,
"slideshow": {
"slide_type": ""
},
"tags": []
},
"source": [
"## Complete Code Setup\n",
"\n",
"The following code cells contain the complete working implementation. Run them in order to set up the Confluence Chat bot."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3d4d10a9",
"metadata": {
"time_run": "2025-12-26T20:53:05.712994+00:00"
},
"outputs": [],
"source": [
"# # Install dependencies (if needed)\n",
"# %pip install -U deltachat-rpc-client deltachat-rpc-server pydantic fastlite python-fasthtml fastcore httpx qrcode[pil]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fecfdb76",
"metadata": {
"time_run": "2025-12-26T20:53:05.783465+00:00"
},
"outputs": [],
"source": [
"from deltachat_rpc_client import Rpc, DeltaChat, Bot\n",
"from deltachat_rpc_client.events import NewMessage, RawEvent\n",
"from deltachat_rpc_client import EventType\n",
"from pydantic import BaseModel, Field, TypeAdapter\n",
"from pydantic.dataclasses import dataclass\n",
"from datetime import datetime\n",
"from typing import Dict, List, Literal, Annotated, Union\n",
"from pathlib import Path\n",
"import threading\n",
"import zipfile\n",
"import httpx\n",
"import json\n",
"from fasthtml.common import *\n",
"from fastcore import *\n",
"from fastlite import database\n",
"\n",
"import asyncio\n",
"\n",
"_loop = asyncio.get_event_loop()\n",
"\n",
"def block_on(f):\n",
" return asyncio.run_coroutine_threadsafe(f, _loop).result()\n",
"\n",
"class InteractiveBot:\n",
" def __init__(self, account):\n",
" self.account = account\n",
" self.bot = Bot(account)\n",
" self.hooks = {}\n",
" self._thread = None\n",
" \n",
" def start(self):\n",
" self._thread = threading.Thread(target=self.bot.run_forever, daemon=True)\n",
" self._thread.start()\n",
" \n",
" def on(self, event_type=NewMessage):\n",
" def decorator(func):\n",
" name = func.__name__\n",
" if name in self.hooks:\n",
" old_func, old_type = self.hooks[name]\n",
" self.bot.remove_hook(old_func, old_type)\n",
" self.hooks[name] = (func, event_type)\n",
" self.bot.add_hook(func, event_type)\n",
" return func\n",
" return decorator\n",
"\n",
"def get_or_create_bot(account_index=0):\n",
" global rpc, dc\n",
" if 'rpc' not in globals():\n",
" rpc = Rpc()\n",
" rpc.start()\n",
" dc = DeltaChat(rpc)\n",
" accounts = dc.get_all_accounts()\n",
" assert accounts, \"Please setup the bots account first\"\n",
" return accounts[account_index]\n",
"\n",
"account = get_or_create_bot(0)\n",
"ibot = InteractiveBot(account)\n",
"ibot.start()"
]
},
{
"cell_type": "markdown",
"id": "05525b57",
"metadata": {},
"source": [
"### Initial required account setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "faf6243f",
"metadata": {
"time_run": "2025-12-26T20:53:05.751057+00:00"
},
"outputs": [],
"source": [
"# account = dc.add_account()\n",
"# account.set_config(\"displayname\", \"Confluence\")\n",
"# account.set_config(\"bot\", \"1\")\n",
"# account.set_config_from_qr(\"DCACCOUNT:https://nine.testrun.org/new\")\n",
"# account.configure()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "11b90e86",
"metadata": {
"time_run": "2025-12-26T20:53:06.821431+00:00"
},
"outputs": [],
"source": [
"import qrcode\n",
"\n",
"qr = qrcode.QRCode()\n",
"qr.add_data(account.get_qr_code())\n",
"qr.print_ascii()\n",
"print(account.get_qr_code())"
]
},
{
"cell_type": "markdown",
"id": "03ca3398",
"metadata": {},
"source": [
"### Models and database"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "41240b49",
"metadata": {
"time_run": "2025-12-26T20:53:06.824217+00:00"
},
"outputs": [],
"source": [
"@dataclass\n",
"class Msg:\n",
" username: str\n",
" content: str\n",
" channel: str\n",
" timestamp: datetime\n",
" \n",
" def __post_init__(self):\n",
" # use this conversion after beeing read from the sqlite database as text\n",
" if isinstance(self.timestamp, str): self.timestamp = datetime.fromisoformat(self.timestamp)\n",
"\n",
"# Client → Bot requests\n",
"class GetAllMessages(BaseModel):\n",
" type: Literal[\"get_all_messages\"] = \"get_all_messages\"\n",
"\n",
"class PostMessage(BaseModel):\n",
" type: Literal[\"post\"] = \"post\"\n",
" channel: str\n",
" content: str\n",
"\n",
"class CreateChannel(BaseModel):\n",
" type: Literal[\"create_channel\"] = \"create_channel\"\n",
" channel: str\n",
" welcome_msg: str = \"Welcome!\"\n",
"\n",
"class DeleteChannel(BaseModel):\n",
" type: Literal[\"delete_channel\"] = \"delete_channel\"\n",
" channel: str\n",
"\n",
"class DeleteMessage(BaseModel):\n",
" type: Literal[\"delete_message\"] = \"delete_message\"\n",
" timestamp: str\n",
"\n",
"class UpdateUser(BaseModel):\n",
" type: Literal[\"update_user\"] = \"update_user\"\n",
" username: str\n",
" role: str\n",
"\n",
"# Bot → Client responses\n",
"class Messages(BaseModel):\n",
" type: Literal[\"messages\"] = \"messages\"\n",
" messages: List[Msg] = []\n",
"\n",
"class ChannelDeleted(BaseModel):\n",
" type: Literal[\"channel_deleted\"] = \"channel_deleted\"\n",
" channel: str\n",
"\n",
"class MessageDeleted(BaseModel):\n",
" type: Literal[\"message_deleted\"] = \"message_deleted\"\n",
" timestamp: str\n",
"\n",
"@dataclass\n",
"class User:\n",
" username: str\n",
" role: str = \"pending\"\n",
"\n",
"class Users(BaseModel):\n",
" type: Literal[\"users\"] = \"users\"\n",
" users: Dict[str, User] = {}\n",
"\n",
"class SelfUser(BaseModel):\n",
" type: Literal[\"self_user\"] = \"self_user\"\n",
" username: str\n",
"\n",
"connected_clients = {}\n",
"ws_clients = set()\n",
"\n",
"\n",
"ClientRequest = Annotated[Union[PostMessage, GetAllMessages, CreateChannel, DeleteChannel, DeleteMessage, UpdateUser], Field(discriminator='type')]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "653e6a9b",
"metadata": {
"time_run": "2025-12-26T20:53:06.863464+00:00"
},
"outputs": [],
"source": [
"db = database(\"confluence.db\")\n",
"msgs_tbl = db.create(Msg, pk='timestamp')\n",
"\n",
"def init_channel(channel, welcome_msg):\n",
" msg = Msg(username=\"system\", content=welcome_msg, channel=channel, timestamp=datetime.now())\n",
" if not msgs_tbl(where='channel = ?', where_args=[channel]): msgs_tbl.insert(msg)\n",
" return msg\n",
"\n",
"init_channel(\"general\", \"Welcome to general!\")\n",
"init_channel(\"random\", \"Welcome to random!\")\n",
"\n",
"\n",
"users_tbl = db.create(User, pk='username')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f638aa7b-4ec4-4c5f-898d-9653838edcb6",
"metadata": {},
"outputs": [],
"source": [
"# # Initial setup: add first admin\n",
"# users_tbl.upsert(User(username=\"<your-address>\", role=\"admin\"))"
]
},
{
"cell_type": "markdown",
"id": "02dba321",
"metadata": {
"time_run": "2025-12-27T22:01:24.048068+00:00"
},
"source": [
"### Bot implementation"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "18afe838",
"metadata": {
"time_run": "2025-12-27T21:09:46.832235+00:00"
},
"outputs": [],
"source": [
"async def broadcast(data):\n",
" data = TypeAdapter(type(data)).dump_json(data)\n",
" for client_msg in connected_clients.values(): client_msg.send_webxdc_realtime_data(data)\n",
" for ws in ws_clients: await ws.send_text(json.dumps(list(data)))\n",
"\n",
"async def get_user(chat):\n",
" contacts = chat.get_contacts()\n",
" if len(contacts) != 1:\n",
" if chat.can_send(): chat.send_text(\"Only 1-on-1 chats are supported.\")\n",
" chat.leave()\n",
" return None\n",
" contact = contacts[0]\n",
" if not contact.get_snapshot()['is_verified']:\n",
" if chat.can_send(): chat.send_text(f\"Please verify your identity first:\\n\\n{account.get_qr_code()}\")\n",
" return None\n",
" addr = contact.get_snapshot()[\"address\"]\n",
" user = users_tbl.get(addr, default=None)\n",
" if not user:\n",
" user = users_tbl.upsert(User(username=addr))\n",
" if chat.can_send(): chat.send_text(\"Access request submitted. Please wait for approval.\")\n",
" await broadcast(Users(users={u.username: u for u in users_tbl()}))\n",
" for user in users_tbl(where='role = \"admin\"'):\n",
" if contact:=account.get_contact_by_addr(user.username):\n",
" if chat:=account.get_chat_by_contact(contact):\n",
" if chat.can_send():\n",
" n_pending = users_tbl.count_where('role = \"pending\"')\n",
" chat.send_text(f\"[system] There are {n_pending} pending requests\")\n",
" return None\n",
" if user.role == \"pending\":\n",
" if chat.can_send(): chat.send_text(\"Your access is pending approval.\")\n",
" return None\n",
" return user\n",
"\n",
"async def handle_request(data, user, send_response):\n",
" req = TypeAdapter(ClientRequest).validate_json(data)\n",
" if isinstance(req, GetAllMessages):\n",
" await send_response(SelfUser(username=user.username))\n",
" await send_response(Users(users={u.username: u for u in users_tbl()}))\n",
" await send_response(Messages(messages=msgs_tbl(order_by='timestamp')))\n",
" elif isinstance(req, PostMessage):\n",
" msg = Msg(username=user.username, channel=req.channel, content=req.content, timestamp=datetime.now())\n",
" msgs_tbl.insert(msg)\n",
" await broadcast(Messages(messages=[msg]))\n",
" text = f\"#{msg.channel} {msg.username} {msg.content}\"\n",
" for user in users_tbl(where='role != \"pending\" AND username != ?', where_args=[user.username]):\n",
" if contact:=account.get_contact_by_addr(user.username):\n",
" if chat:=account.get_chat_by_contact(contact):\n",
" if chat.can_send(): chat.send_text(text)\n",
" elif isinstance(req, CreateChannel):\n",
" if user.role != 'admin': return\n",
" if not msgs_tbl(where='channel = ?', where_args=[req.channel]):\n",
" msg = init_channel(req.channel, req.welcome_msg)\n",
" await broadcast(Messages(messages=[msg]))\n",
" elif isinstance(req, DeleteChannel):\n",
" if user.role != 'admin': return\n",
" msgs_tbl.delete_where('channel = ?', [req.channel])\n",
" await broadcast(ChannelDeleted(channel=req.channel))\n",
" elif isinstance(req, DeleteMessage):\n",
" if user.role != 'admin': return\n",
" msgs_tbl.delete_where('timestamp = ?', [req.timestamp])\n",
" await broadcast(MessageDeleted(timestamp=req.timestamp))\n",
" elif isinstance(req, UpdateUser):\n",
" if user.role != 'admin': return\n",
" if req.role == 'block':\n",
" if contact:=account.get_contact_by_addr(req.username):\n",
" contact.block()\n",
" users_tbl.delete_where('username = ?', [req.username])\n",
" await broadcast(Users(users={u.username: u for u in users_tbl()}))\n",
" return\n",
" if old_user:=users_tbl.get(req.username, default=None):\n",
" if old_user.role == 'pending' and req.role != 'pending':\n",
" if contact:=account.get_contact_by_addr(req.username):\n",
" account.get_chat_by_contact(contact).send_message(\n",
" text=\"Your Confluence access has been approved. Send a message containing /leave to leave. Send /con to resend this webxdc app.\",\n",
" file=str(confluence_xdc_path), filename=\"Confluence.xdc\"\n",
" )\n",
" users_tbl.upsert(User(username=req.username, role=req.role))\n",
" await broadcast(Users(users={u.username: u for u in users_tbl()}))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6e42ff6f",
"metadata": {
"time_run": "2025-12-26T20:53:06.980671+00:00"
},
"outputs": [],
"source": [
"@ibot.on(RawEvent)\n",
"def handle_securejoin(event):\n",
" if event['kind'] == EventType.SECUREJOIN_INVITER_PROGRESS and event.get('progress') == 1000:\n",
" chat = account.get_chat_by_id(event['chat_id'])\n",
" block_on(get_user(chat))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fca05d64",
"metadata": {
"time_run": "2025-12-27T21:51:10.567439+00:00"
},
"outputs": [],
"source": [
"# [(c.id, c.name) for c in account.get_chatlist(snapshot=True)]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "68862444",
"metadata": {
"time_run": "2025-12-27T21:51:14.222791+00:00"
},
"outputs": [],
"source": [
"# [(m.id, m.get_snapshot().chat_id) for m in connected_clients.values()]"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5f4cd3bd",
"metadata": {
"time_run": "2025-12-26T20:53:07.032445+00:00"
},
"outputs": [],
"source": [
"@ibot.on(RawEvent)\n",
"def realtime_advertisement_received(event):\n",
" if event.kind == EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED:\n",
" msg = event.account.get_message_by_id(event.msg_id)\n",
" chat = event.account.get_chat_by_id(msg.get_snapshot().chat_id)\n",
" if not block_on(get_user(chat)): return\n",
" msg.send_webxdc_realtime_advertisement()\n",
" connected_clients[msg.id] = msg\n",
"\n",
"@ibot.on(RawEvent)\n",
"def handle_realtime_data(event):\n",
" if event.kind == EventType.WEBXDC_REALTIME_DATA:\n",
" msg_obj = event.account.get_message_by_id(event.msg_id)\n",
" chat = event.account.get_chat_by_id(msg_obj.get_snapshot().chat_id)\n",
" user = block_on(get_user(chat))\n",
" if not user: return\n",
" async def send_response(resp): msg_obj.send_webxdc_realtime_data(resp.model_dump_json().encode())\n",
" block_on(handle_request(bytes(event.data), user, send_response))\n",
"\n",
"@ibot.on()\n",
"def webxdc_package_request(event):\n",
" msg = event.message_snapshot\n",
" chat = msg.chat\n",
" if chat.can_send() and msg.text.strip() == \"/con\" and block_on(get_user(chat)):\n",
" chat.send_message(text=\"Confluence Chat\", file=str(confluence_xdc_path), filename=\"Confluence.xdc\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "813cee09",
"metadata": {
"time_run": "2025-12-27T21:02:46.482511+00:00"
},
"outputs": [],
"source": [
"@ibot.on()\n",
"def handle_leave(event):\n",
" msg = event.message_snapshot\n",
" chat = msg.chat\n",
" if chat.can_send() and msg.text.strip() == \"/leave\":\n",
" user = block_on(get_user(chat))\n",
" if user:\n",
" for m in msgs_tbl(where='username = ?', where_args=[user.username]): msgs_tbl.update(dict(username='[deleted]', timestamp=m.timestamp))\n",
" users_tbl.delete_where('username = ?', [user.username])\n",
" chat.send_text(\"You have left Confluence. Send /con to rejoin.\")\n",
" block_on(broadcast(Users(users={u.username: u for u in users_tbl()})))\n",
" block_on(broadcast(Messages(messages=msgs_tbl(order_by='timestamp'))))"
]
},
{
"cell_type": "markdown",
"id": "ffdff35f",
"metadata": {},
"source": [
"### Build webxdc app"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6a567fc3",
"metadata": {
"time_run": "2025-12-26T20:53:07.039554+00:00"
},
"outputs": [],
"source": [
"webxdc_dir = Path(\"./my_webxdc\")\n",
"webxdc_dir.delete()\n",
"webxdc_dir.mkdir()\n",
"\n",
"simple_css = httpx.get(\"https://cdn.simplecss.org/simple.min.css\").text\n",
"(webxdc_dir / \"simple.css\").write_text(simple_css)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "06154517",
"metadata": {
"hide_input": true,
"time_run": "2025-12-26T20:53:07.382824+00:00"
},
"outputs": [],
"source": [
"(webxdc_dir/\"webxdc.js\").write_text(\"\"\"\n",
"window.webxdc = (() => {\n",
" const params = new URLSearchParams(window.location.hash.substr(1));\n",
" const selfAddr = params.get(\"addr\") || \"device0@local.host\";\n",
" const selfName = params.get(\"name\") || \"device0\";\n",
"\n",
" class RealtimeListener {\n",
" constructor() {\n",
" this.listener = null;\n",
" this.trashed = false;\n",
" this.ready = false;\n",
" this.queue = [];\n",
" this.ws = new WebSocket(`ws://${window.location.host}/ws`);\n",
" \n",
" this.ws.onopen = () => {\n",
" this.ready = true;\n",
" this.queue.forEach(data => this.ws.send(data));\n",
" this.queue = [];\n",
" };\n",
" \n",
" this.ws.onmessage = (event) => {\n",
" const data = new Uint8Array(JSON.parse(event.data));\n",
" if (this.listener && !this.trashed) this.listener(data);\n",
" };\n",
" }\n",
"\n",
" setListener(listener) { this.listener = listener; }\n",
"\n",
" send(data) {\n",
" if (!(data instanceof Uint8Array)) throw new Error(\"data must be Uint8Array\");\n",
" const msg = JSON.stringify(Array.from(data));\n",
" if (this.ready) this.ws.send(msg);\n",
" else this.queue.push(msg);\n",
" }\n",
"\n",
" leave() {\n",
" this.trashed = true;\n",
" this.ws.close();\n",
" }\n",
" }\n",
"\n",
" return { selfAddr, selfName, joinRealtimeChannel: () => new RealtimeListener() };\n",
"})();\n",
"\"\"\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9a03719c",
"metadata": {
"hide_input": true,
"time_run": "2025-12-26T20:53:07.393659+00:00"
},
"outputs": [],
"source": [
"(webxdc_dir / \"style.css\").write_text(\"\"\"\n",
":root {\n",
" --bg-primary: white;\n",
" --bg-secondary: #f5f5f5;\n",
" --bg-hover: #e0e0e0;\n",
" --text-primary: black;\n",
" --text-secondary: #666;\n",
" --text-muted: #999;\n",
" --border-color: #ccc;\n",
" --accent-color: #007bff;\n",
"}\n",
"\n",
"@media (prefers-color-scheme: dark) {\n",
" :root {\n",
" --bg-primary: #2a2a2a;\n",
" --bg-secondary: #1a1a1a;\n",
" --bg-hover: #333;\n",
" --text-primary: white;\n",
" --text-secondary: #aaa;\n",
" --text-muted: #666;\n",
" --border-color: #444;\n",
" --accent-color: #0066cc;\n",
" }\n",
"}\n",
"\n",
"body { margin: 0; padding: 0; display: flex; height: 100vh; font-family: system-ui; background: var(--bg-primary); color: var(--text-primary); }\n",
"\n",
"#sidebar { width: 100%; background: var(--bg-secondary); padding: 1rem; overflow-y: auto; position: fixed; left: -100%; top: 0; bottom: 0; transition: left 0.3s; z-index: 100; }\n",
"#sidebar.show { left: 0; }\n",
"\n",
"#main { flex: 1; display: flex; flex-direction: column; width: 100%; }\n",
"\n",
"#header { padding: 1rem; border-bottom: 1px solid var(--border-color); background: var(--bg-primary); }\n",
"#toggle-sidebar { cursor: pointer; font-size: 1.5rem; }\n",
"#messages { flex: 1; overflow-y: auto; padding: 1rem; }\n",
"#input-area { padding: 1rem; border-top: 1px solid var(--border-color); display: flex; gap: 0.5rem; }\n",
"#messageInput { flex: 1; }\n",
"\n",
".channel { cursor: pointer; padding: 0.5rem; border-radius: 4px; margin-bottom: 0.25rem; display: flex; justify-content: space-between; align-items: center; }\n",
".channel:hover { background: var(--bg-hover); }\n",
".channel.active { background: var(--accent-color); color: white; }\n",
".channel-name { flex: 1; }\n",
".delete-btn { background: none; border: none; color: inherit; font-size: 1.5rem; cursor: pointer; padding: 0 0.25rem; line-height: 1; }\n",
".delete-btn:hover { color: #ff0000; }\n",
"#create-channel-btn { width: 100%; margin-top: 0.5rem; display: none; }\n",
"\n",
".message { margin-bottom: 1rem; position: relative; }\n",
".message-user { font-weight: bold; margin-right: 0.5rem; }\n",
".message-time { font-size: 0.8em; color: var(--text-secondary); margin-right: 0.5rem; }\n",
".msg-delete-btn { background: none; border: none; color: var(--text-muted); font-size: 1.2rem; cursor: pointer; padding: 0 0.25rem; line-height: 1; }\n",
".msg-delete-btn:hover { color: #ff0000; }\n",
"\n",
".user-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-radius: 4px; margin-bottom: 0.25rem; position: relative; gap: 0.5rem; }\n",
".user-item:hover { background: var(--bg-hover); }\n",
".user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }\n",
".user-name { font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n",
".user-name[title] { cursor: help; }\n",
".user-role { font-size: 0.8em; color: var(--text-secondary); }\n",
".user-actions { display: flex; gap: 0.25rem; flex-shrink: 0; margin-left: auto; }\n",
".user-actions button { padding: 0.25rem 0.5rem; font-size: 0.8em; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); border-radius: 3px; cursor: pointer; }\n",
".user-actions button:hover { background: var(--bg-hover); }\n",
".user-menu-btn { background: none; border: none; color: var(--text-primary); font-size: 1.2rem; cursor: pointer; padding: 0.25rem; flex-shrink: 0; margin-left: auto; }\n",
".user-menu { position: absolute; right: 0; top: 100%; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); z-index: 10; display: none; min-width: 100px; }\n",
".user-menu.show { display: block; }\n",
".user-menu button { display: block; width: 100%; text-align: left; padding: 0.5rem; border: none; background: none; color: var(--text-primary); cursor: pointer; }\n",
".user-menu button:hover { background: var(--bg-hover); }\n",
"\n",
"#modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; display: flex; align-items: center; justify-content: center; }\n",
"#modal-content { background: var(--bg-primary); color: var(--text-primary); padding: 1rem; border-radius: 4px; min-width: 300px; }\n",
"\n",
"@media (min-width: 768px) {\n",
" #sidebar { width: 280px; left: 0; position: static; }\n",
" #toggle-sidebar { display: none; }\n",
"}\n",
"\"\"\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f4d6a65a",
"metadata": {
"hide_input": true,
"time_run": "2025-12-26T20:53:07.403027+00:00"
},
"outputs": [],
"source": [
"(webxdc_dir/\"app.js\").write_text(\"\"\"\n",
"let state = {channels: {}, currentChannel: null, justCreatedChannel: null, createdTimer: null, users: {}, currentUser: null};\n",
"let rt, retryTimer;\n",
"\n",
"const $ = id => document.getElementById(id);\n",
"\n",
"function connect() {\n",
" rt = window.webxdc.joinRealtimeChannel();\n",
" rt.setListener(data => handleMessage(JSON.parse(new TextDecoder().decode(data))));\n",
" requestAllMessages();\n",
"}\n",
"\n",
"function requestAllMessages() {\n",
" if (Object.keys(state.channels).length === 0) {\n",
" send({type: \"get_all_messages\"});\n",
" retryTimer = setTimeout(requestAllMessages, 400);\n",
" }\n",
"}\n",
"\n",
"function send(msg) { rt.send(new TextEncoder().encode(JSON.stringify(msg))); }\n",
"\n",
"function handleMessage(msg) {\n",
" if (msg.type === \"self_user\") {\n",
" clearTimeout(retryTimer);\n",
" state.currentUser = msg.username;\n",
" } else if (msg.type === \"users\") {\n",
" state.users = msg.users;\n",
" renderUsers();\n",
" } else if (msg.type === \"messages\") {\n",
" for (let m of msg.messages) {\n",
" let ch = state.channels[m.channel] = state.channels[m.channel] || {messages: {}, loaded: true};\n",
" ch.messages[m.timestamp] = m;\n",
" if (state.justCreatedChannel === m.channel) { \n",
" clearTimeout(state.createdTimer);\n",
" switchChannel(m.channel); \n",
" state.justCreatedChannel = null; \n",
" }\n",
" }\n",
" $('messageInput').disabled = false;\n",
" $('sendButton').disabled = false;\n",
" renderChannels();\n",
" if (!state.currentChannel && msg.messages.length > 0) switchChannel(msg.messages[0].channel);\n",
" else if (state.currentChannel) renderMessages();\n",
" } else if (msg.type === \"channel_deleted\") {\n",
" delete state.channels[msg.channel];\n",
" if (state.currentChannel === msg.channel) {\n",
" let remaining = Object.keys(state.channels);\n",
" state.currentChannel = remaining.length > 0 ? remaining[0] : null;\n",
" $('current-channel').textContent = state.currentChannel ? ' #' + state.currentChannel : '';\n",
" }\n",
" renderChannels();\n",
" if (state.currentChannel) renderMessages();\n",
" else $('messages').innerHTML = '';\n",
" } else if (msg.type === \"message_deleted\") {\n",
" for (let ch of Object.values(state.channels)) delete ch.messages[msg.timestamp];\n",
" if (state.currentChannel) renderMessages();\n",
" }\n",
"}\n",
"\n",
"function switchChannel(name) {\n",
" state.currentChannel = name;\n",
" $('sidebar').classList.remove('show');\n",
" $('current-channel').textContent = ' #' + name;\n",
" renderChannels();\n",
" renderMessages();\n",
"}\n",
"\n",
"function renderChannels() {\n",
" $('channels').innerHTML = '';\n",
" for (let ch of Object.keys(state.channels)) {\n",
" let div = document.createElement('div');\n",
" div.className = `channel ${ch === state.currentChannel ? 'active' : ''}`;\n",
" let isAdmin = state.users[state.currentUser]?.role === 'admin';\n",
" div.innerHTML = '<span class=\"channel-name\"></span>' + (isAdmin ? '<button class=\"delete-btn\">×</button>' : '');\n",
" div.children[0].textContent = '# ' + ch;\n",
" div.children[0].onclick = () => switchChannel(ch);\n",
" if (isAdmin) div.children[1].onclick = (e) => { e.stopPropagation(); deleteChannel(ch); };\n",
" $('channels').appendChild(div);\n",
" }\n",
"}\n",
"\n",
"function renderUsers() {\n",
" let isAdmin = state.users[state.currentUser]?.role === 'admin';\n",
" $('create-channel-btn').style.display = isAdmin ? 'block' : 'none';\n",
" \n",
" let sorted = Object.values(state.users).sort((a, b) => {\n",
" if (a.role === 'pending' && b.role !== 'pending') return -1;\n",
" if (a.role !== 'pending' && b.role === 'pending') return 1;\n",
" return 0;\n",
" });\n",
" \n",
" $('users').innerHTML = '<h4>Users</h4>';\n",
" for (let user of sorted) {\n",
" let div = document.createElement('div');\n",
" div.className = 'user-item';\n",
" if (isAdmin) {\n",
" if (user.role === 'pending') {\n",
" div.innerHTML = '<div class=\"user-info\"><span class=\"user-name\"></span></div><div class=\"user-actions\"><button class=\"accept-btn\">✓</button><button class=\"block-btn\">✗</button></div>';\n",
" div.children[0].children[0].textContent = user.username;\n",
" div.children[0].children[0].title = user.username;\n",
" div.children[1].children[0].onclick = () => send({type: \"update_user\", username: user.username, role: \"user\"});\n",
" div.children[1].children[1].onclick = () => send({type: \"update_user\", username: user.username, role: \"block\"});\n",
" } else {\n",
" div.innerHTML = '<div class=\"user-info\"><span class=\"user-name\"></span><span class=\"user-role\"></span></div><button class=\"user-menu-btn\">⋮</button><div class=\"user-menu\"><button data-role=\"user\">User</button><button data-role=\"admin\">Admin</button><button data-role=\"pending\">Pending</button></div>';\n",
" div.children[0].children[0].textContent = user.username;\n",
" div.children[0].children[0].title = user.username;\n",
" div.children[0].children[1].textContent = user.role;\n",
" div.children[1].onclick = (e) => { e.stopPropagation(); div.children[2].classList.toggle('show'); };\n",
" for (let btn of div.children[2].children) btn.onclick = () => { send({type: \"update_user\", username: user.username, role: btn.dataset.role}); div.children[2].classList.remove('show'); };\n",
" }\n",
" } else {\n",
" div.innerHTML = '<div class=\"user-info\"><span class=\"user-name\"></span><span class=\"user-role\"></span></div>';\n",
" div.children[0].children[0].textContent = user.username;\n",
" div.children[0].children[0].title = user.username;\n",
" div.children[0].children[1].textContent = user.role === 'admin' ? 'admin' : '';\n",
" }\n",
" $('users').appendChild(div);\n",
" }\n",
"}\n",
"\n",
"function renderMessages() {\n",
" let ch = state.channels[state.currentChannel];\n",
" let out = $('messages');\n",
" if (!ch?.loaded) { out.innerHTML = '<div class=\"loading\">Loading...</div>'; return; }\n",
" out.innerHTML = '';\n",
" let isAdmin = state.users[state.currentUser]?.role === 'admin';\n",
" for (let m of Object.values(ch.messages)) {\n",
" let div = document.createElement('div');\n",
" div.className = 'message';\n",
" div.innerHTML = `<span class=\"message-user\"></span><span class=\"message-time\"></span><button class=\"msg-delete-btn\">×</button><div></div>`;\n",
" div.children[0].textContent = m.username;\n",
" div.children[1].textContent = new Date(m.timestamp).toLocaleTimeString();\n",
" div.children[2].style.display = isAdmin ? 'inline' : 'none';\n",
" div.children[2].onclick = () => deleteMessage(m.timestamp);\n",
" div.children[3].textContent = m.content;\n",
" out.appendChild(div);\n",
" }\n",
" out.scrollTop = out.scrollHeight;\n",
"}\n",
"\n",
"function sendMessage() {\n",
" let content = $('messageInput').value.trim();\n",
" if (content && state.currentChannel) { \n",
" send({type: \"post\", channel: state.currentChannel, content}); \n",
" $('messageInput').value = ''; \n",
" }\n",
"}\n",
"\n",
"function showCreateChannel() { $('modal').style.display = 'flex'; $('channelName').focus(); }\n",
"\n",
"function hideCreateChannel() { $('modal').style.display = 'none'; $('channelName').value = ''; }\n",
"\n",
"function createChannel() {\n",
" let name = $('channelName').value.trim();\n",
" state.justCreatedChannel = name;\n",
" state.createdTimer = setTimeout(() => state.justCreatedChannel = null, 1000);\n",
" send({type: \"create_channel\", channel: name});\n",
" hideCreateChannel();\n",
"}\n",
"\n",
"function deleteChannel(name) {\n",
" if (confirm(`Delete channel #${name}?`)) send({type: \"delete_channel\", channel: name});\n",
"}\n",
"\n",
"function deleteMessage(timestamp) {\n",
" if (confirm('Delete this message?')) send({type: \"delete_message\", timestamp});\n",
"}\n",
"\n",
"function toggleSidebar() { $('sidebar').classList.toggle('show'); }\n",
"\n",
"document.addEventListener('click', (e) => {\n",
" if (!e.target.closest('.user-item')) {\n",
" for (let menu of document.querySelectorAll('.user-menu.show')) menu.classList.remove('show');\n",
" }\n",
"});\n",
"\n",
"window.addEventListener('load', () => connect());\n",
"\"\"\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "39b0341c",
"metadata": {
"hide_input": true,
"time_run": "2025-12-26T20:53:07.427327+00:00"
},
"outputs": [],
"source": [
"(webxdc_dir / \"index.html\").write_text(to_xml(Html(\n",
" Head(\n",
" Meta(charset=\"utf-8\"),\n",
" Meta(name=\"viewport\", content=\"width=device-width\"),\n",
" Title(\"Confluence\"),\n",
" Link(rel=\"stylesheet\", href=\"simple.css\"),\n",
" Link(rel=\"stylesheet\", href=\"style.css\"),\n",
" Script(src=\"webxdc.js\")\n",
" ),\n",
" Body(\n",
" Div(id=\"sidebar\")(\n",
" H3(\"Channels\"),\n",
" Div(id=\"channels\"),\n",
" Button(id=\"create-channel-btn\", onclick=\"showCreateChannel()\")(\"+ New Channel\"),\n",
" Div(id=\"users\")\n",
" ),\n",
" Div(id=\"main\")(\n",
" Div(id=\"header\")(\n",
" Span(id=\"toggle-sidebar\", onclick=\"toggleSidebar()\")(\"☰\"),\n",
" Span(id=\"current-channel\")(\"\")\n",
" ),\n",
" Div(id=\"messages\"),\n",
" Form(id=\"input-area\", onsubmit=\"sendMessage(); return false;\")(\n",
" Input(id=\"messageInput\", type=\"text\", placeholder=\"Type a message...\", autocomplete=\"off\", disabled=True),\n",
" Button(id=\"sendButton\", type=\"submit\", disabled=True)(\"Send\")\n",
" )\n",
" ),\n",
" Div(id=\"modal\", style=\"display:none\")(\n",
" Div(id=\"modal-content\")(\n",
" H3(\"Create Channel\"),\n",
" Form(id=\"create-form\", onsubmit=\"createChannel(); return false;\")(\n",
" Input(id=\"channelName\", type=\"text\", placeholder=\"Channel name\", autocomplete=\"off\", required=True),\n",
" Div(style=\"display:flex;gap:0.5rem;margin-top:1rem\")(\n",
" Button(type=\"submit\")(\"Create\"),\n",
" Button(type=\"button\", onclick=\"hideCreateChannel()\")(\"Cancel\")\n",
" )\n",
" )\n",
" )\n",
" ),\n",
" Script(src=\"app.js\")\n",
" )\n",
")))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2bd2dfcf",
"metadata": {
"time_run": "2025-12-26T20:53:07.463807+00:00"
},
"outputs": [],
"source": [
"confluence_xdc_path = Path(\"./confluence.xdc\")\n",
"\n",
"def package_webxdc():\n",
" with zipfile.ZipFile(confluence_xdc_path, 'w') as xdc:\n",
" xdc.write(webxdc_dir / \"index.html\", \"index.html\")\n",
" xdc.write(webxdc_dir / \"webxdc.js\", \"webxdc.js\")\n",
" xdc.write(webxdc_dir / \"app.js\", \"app.js\")\n",
" xdc.write(webxdc_dir / \"style.css\", \"style.css\")\n",
" xdc.write(webxdc_dir / \"simple.css\", \"simple.css\")\n",
"\n",
"package_webxdc()"
]
},
{
"cell_type": "markdown",
"id": "555f8e8f",
"metadata": {},
"source": [
"## Web Preview (Optional)\n",
"\n",
"The following cells set up a local web server for testing the webxdc app in a browser. This is useful for development but not required for the Delta Chat deployment."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "683c91f2",
"metadata": {
"time_run": "2025-12-26T20:53:07.521307+00:00"
},
"outputs": [],
"source": [
"from fasthtml.common import *\n",
"from fasthtml.jupyter import *\n",
"\n",
"def restart_srv():\n",
" global app, rt, srv, preview\n",
" app = FastHTML(exts='ws')\n",
" rt = app.route\n",
"\n",
" if \"srv\" in globals():\n",
" srv.stop()\n",
" srv = JupyUvi(app)\n",
" @rt(\"/webxdc/{fname:path}\")\n",
" def webxdc(fname:str):\n",
" file_path = webxdc_dir / (fname or \"index.html\")\n",
" if file_path.exists():\n",
" return FileResponse(file_path)\n",
" return \"File not found\", 404\n",
" def get_preview(app): return partial(HTMX, path=\"webxdc/index.html#name=device1&addr=device1@local.host\", app=app, host=None, port=None)\n",
" preview = get_preview(app)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "32f3b7d1",
"metadata": {
"time_run": "2025-12-26T20:53:07.571724+00:00"
},
"outputs": [],
"source": [
"# restart_srv()\n",
"# \n",
"# async def ws_endpoint(websocket: WebSocket):\n",
"# await websocket.accept()\n",
"# ws_clients.add(websocket)\n",
"# try:\n",
"# while True:\n",
"# data = await websocket.receive_text()\n",
"# user = users_tbl.get(\"browser-dev\", default=None)\n",
"# if not user: return\n",
"# async def send_response(resp): await websocket.send_text(json.dumps(list(resp.model_dump_json().encode())))\n",
"# await handle_request(bytes(json.loads(data)), user, send_response)\n",
"# except WebSocketDisconnect: pass\n",
"# finally: ws_clients.discard(websocket)\n",
"# \n",
"# app.add_websocket_route('/ws', ws_endpoint)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment