Initial commit: Matrix Bridge Daemon

Persistent Python daemon connecting Matrix DM room to AiAgent API.

- Config-driven (JSON config file)
- Extensible command system (/new_session, /help)
- Typing indicators while agent processes
- Session auto-naming for identification
- Persistent state across restarts
- Token refresh, retry logic, error handling
- Python stdlib only — no external dependencies
This commit is contained in:
2026-05-15 13:45:24 +02:00
commit 751584c99d
10 changed files with 1024 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
*.egg
dist/
build/
# Editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project-specific
bridge-state.json
*.log

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# Matrix Bridge — Lucy's Messaging Bridge
A persistent Python daemon that connects a Matrix DM room to the local AI agent framework (`AiAgent`). Listens for incoming messages, forwards them to the agent, and sends responses back — all using only Python stdlib.
## Architecture
```
Matrix ──sync──> MatrixClient ──events──> Bridge ──message──> AgentClient ──> AiAgent API
^ │ │
└─────────── response ──────────────────┘ │
typing indicator, session management, command dispatch polling loop
```
## Files
| File | Purpose |
|------|---------|
| `main.py` | Entry point, CLI argument parsing |
| `config.py` | Configuration loader (JSON → dataclass) |
| `bridge.py` | Main loop, event processing, command dispatch |
| `matrix_client.py` | Matrix API client (sync, send, typing, auth) |
| `agent_client.py` | Agent API client (sessions, messages, polling) |
| `state.py` | Persistent state (session ID, batch token, dedup) |
| `config.json` | Configuration file |
## Configuration
Edit `config.json` to set:
- **Matrix** — server, user ID, room ID, credentials file path
- **Agent** — API base URL
- **Bridge** — poll interval, timeouts, limits
## Commands
| Command | Description |
|---------|-------------|
| `/new_session` | Create a fresh agent session (clears conversation) |
| `/help` | Show available commands |
Adding new commands: add an entry to `Bridge.COMMANDS` and a `_cmd_<name>` method.
## Deployment
```bash
# Install
sudo cp -r . /opt/MatrixBridge
sudo cp matrix-bridge.service /etc/systemd/system/
# Start
sudo systemctl enable --now matrix-bridge
```
## Requirements
Python 3.10+ with stdlib only — no external packages needed.

179
agent_client.py Normal file
View File

@@ -0,0 +1,179 @@
"""Agent API client — communicates with the local AI agent framework.
Provides: session management, message sending, state polling, response fetching.
Uses only Python stdlib — no external dependencies.
"""
from __future__ import annotations
import json
import logging
import time
import urllib.error
import urllib.request
from typing import Optional
from config import Config
log = logging.getLogger("matrix-bridge.agent")
class AgentClient:
"""Client for the local agent HTTP API."""
def __init__(self, config: Config):
self.base_url = config.agent.base_url
self._timeout = config.bridge.agent_timeout_seconds
self._retries = config.bridge.agent_retries
# ── Raw Request ─────────────────────────────────────────────────────
def _request(
self, method: str, path: str, body: Optional[dict] = None
) -> Optional[dict]:
"""Make a request to the agent API with retry logic.
Retries on transient connection errors (connection reset, timeout).
HTTP errors (4xx, 5xx) are not retried.
"""
url = f"{self.base_url}{path}"
last_error = None
for attempt in range(self._retries):
data = json.dumps(body).encode("utf-8") if body else None
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json"} if data else {},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace")
log.error(f"Agent API {method} {path} -> {e.code}: {err_body[:200]}")
return None
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
last_error = e
if attempt < self._retries - 1:
log.warning(
f"Agent API {method} {path} transient "
f"(attempt {attempt + 1}/{self._retries}): {e}"
)
time.sleep(1)
else:
log.error(
f"Agent API {method} {path} failed "
f"after {self._retries} attempts: {e}"
)
except Exception as e:
log.error(f"Agent API {method} {path} unexpected error: {e}")
return None
return None
# ── Session Management ──────────────────────────────────────────────
def create_session(self) -> Optional[str]:
"""Create a new agent session. Returns session ID or None."""
result = self._request("POST", "/api/session/new")
if result and "id" in result:
log.info(f"Created session: {result['id']}")
return result["id"]
log.error("Failed to create agent session")
return None
def rename_session(self, session_id: str, new_name: str) -> bool:
"""Rename a session to identify its purpose."""
result = self._request(
"PATCH", f"/api/session/{session_id}", {"name": new_name}
)
if result is not None and result.get("success") is not False:
log.info(f"Renamed {session_id} -> '{new_name}'")
return True
log.warning(f"Failed to rename session {session_id}")
return False
# ── Messaging ───────────────────────────────────────────────────────
def send_message(self, session_id: str, content: str) -> bool:
"""Send a message to the agent. Returns True if accepted."""
result = self._request(
"POST",
f"/api/chat/{session_id}/message",
{"message": content},
)
if result and result.get("ok"):
log.info(f"Forwarded to session {session_id}")
return True
log.error(f"Agent rejected message: {result}")
return False
# ── State & Response Polling ────────────────────────────────────────
def get_session_state(self, session_id: str) -> Optional[dict]:
"""Get the current processing state of a session."""
return self._request("GET", f"/api/session/{session_id}/state")
def get_session_messages(self, session_id: str) -> Optional[list]:
"""Get the element list for a session."""
result = self._request("GET", f"/api/session/{session_id}/messages")
return result if isinstance(result, list) else None
def get_message(self, message_id: str) -> Optional[dict]:
"""Get the full content of a message."""
return self._request("GET", f"/api/message/{message_id}")
def wait_for_response(
self, session_id: str, timeout: int = 300
) -> Optional[str]:
"""Poll until the agent finishes, then return the last assistant message.
**Phase 1:** Wait for ``{"streaming": true}`` to appear (agent started).
**Phase 2:** Wait for streaming to end (agent finished).
**Phase 3:** Fetch and return the last assistant message content.
Intermediate tool calls are invisible — only the final response is returned.
"""
log.info(f"Waiting for agent (session {session_id})...")
start = time.time()
# Phase 1: Wait for agent to start streaming
while time.time() - start < timeout:
state = self.get_session_state(session_id)
if state is None:
log.warning("Session disappeared")
return None
if state.get("streaming"):
log.info("Agent is now streaming")
break
if time.time() - start >= 3:
# Could be a very fast response (no streaming phase)
break
time.sleep(0.3)
# Phase 2: Wait for streaming to end
while time.time() - start < timeout:
state = self.get_session_state(session_id)
if state is None:
log.warning("Session disappeared during processing")
return None
if not state.get("streaming"):
break
time.sleep(0.5)
# Phase 3: Fetch the last assistant message
messages = self.get_session_messages(session_id)
if not messages:
log.warning("No messages in session after agent finished")
return None
for elem in reversed(messages):
if elem.get("type") == "message":
msg = self.get_message(elem["id"])
if msg and msg.get("role") == "assistant":
return msg.get("content", "")
log.warning("No assistant message found")
return None

329
bridge.py Normal file
View File

@@ -0,0 +1,329 @@
"""Core bridge logic — connects Matrix messages to the agent API.
Orchestrates the polling loop, message dispatching, command handling,
and response delivery. Designed to be extensible: add new commands by
adding entries to the COMMANDS registry.
"""
from __future__ import annotations
import logging
import signal
import time
from typing import Optional
from config import Config
from state import BridgeState
from matrix_client import MatrixClient
from agent_client import AgentClient
log = logging.getLogger("matrix-bridge.bridge")
# ── User identity ─────────────────────────────────────────────────────
# Messages from this user are forwarded to the agent.
DANIEL_USER_ID = "@daniel:matrix.daedalus706.de"
class Bridge:
"""Main bridge that connects Matrix messages to the agent API."""
def __init__(self, config: Config):
self.config = config
self.matrix = MatrixClient(config)
self.agent = AgentClient(config)
self.state = BridgeState.load(config.bridge.state_file)
self._running = True
# Register signal handlers
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
# ── Lifecycle ───────────────────────────────────────────────────────
def _signal_handler(self, signum, frame) -> None:
"""Handle shutdown signals gracefully."""
log.info(f"Received signal {signum}, shutting down...")
self._running = False
def run(self) -> None:
"""Enter the main polling loop. Blocks until shutdown."""
log.info("=" * 50)
log.info("Matrix Bridge Daemon starting")
log.info("=" * 50)
log.info(f"State: {self.state}")
log.info(f"Room: {self.config.matrix.room_id}")
log.info(f"Agent: {self.config.agent.base_url}")
# Bootstrap the Matrix sync batch token if needed
if not self.state.next_batch:
self._bootstrap_sync()
# Validate saved session (framework may have restarted)
self._validate_session()
log.info(
f"Entering main loop (poll every "
f"{self.config.bridge.poll_interval_seconds}s)"
)
consecutive_errors = 0
while self._running:
try:
nb, events = self.matrix.sync(
since=self.state.next_batch,
timeout=self.config.bridge.matrix_poll_timeout,
)
if nb:
self.state.next_batch = nb
consecutive_errors = 0
# Deduplicate events
known = set(self.state.processed_event_ids)
new_events = [
e for e in events if e.get("event_id") not in known
]
if new_events:
log.debug(
f"Sync: {len(events)} event(s), "
f"{len(new_events)} new"
)
for e in new_events:
log.debug(
f" Event: type={e.get('type')} "
f"sender={e.get('sender')} "
f"id={e.get('event_id')}"
)
if e.get("type") == "m.room.message":
log.debug(
f" Body: {e.get('content', {}).get('body', '')[:80]}"
)
self.state.mark_processed(
[e["event_id"] for e in new_events if e.get("event_id")],
limit=self.config.bridge.processed_ids_limit,
)
self.state.save(self.config.bridge.state_file)
self._process_events(new_events)
else:
log.debug(
f"Sync OK, 0 new events (batch: {nb[:35]}...)"
)
else:
consecutive_errors += 1
if consecutive_errors > 10:
log.warning(
f"{consecutive_errors} consecutive errors, "
f"backing off"
)
time.sleep(10)
consecutive_errors = 0
except Exception as e:
log.error(f"Unexpected error in main loop: {e}", exc_info=True)
consecutive_errors += 1
time.sleep(self.config.bridge.poll_interval_seconds)
log.info("Bridge stopped gracefully")
# ── Initialization Helpers ──────────────────────────────────────────
def _bootstrap_sync(self) -> None:
"""Perform an initial sync to get the first batch token."""
log.info("No batch token — performing initial sync...")
nb, _ = self.matrix.sync(timeout=self.config.bridge.matrix_poll_timeout)
if nb:
self.state.next_batch = nb
self.state.save(self.config.bridge.state_file)
log.info(f"Got initial batch: {nb[:40]}...")
else:
log.warning("Initial sync failed, will retry in main loop")
def _validate_session(self) -> None:
"""Check if the saved session is still valid on the agent."""
sid = self.state.session_id
if sid:
state = self.agent.get_session_state(sid)
if state is None:
log.warning(
f"Saved session {sid} no longer valid, clearing"
)
self.state.session_id = None
self.state.processed_event_ids = []
self.state.save(self.config.bridge.state_file)
# ── Event Processing ────────────────────────────────────────────────
DANIEL_USER_ID = DANIEL_USER_ID
@staticmethod
def _is_from_daniel(event: dict) -> bool:
"""Check if a Matrix event is a text message from Daniel."""
return (
event.get("type") == "m.room.message"
and event.get("content", {}).get("msgtype") == "m.text"
and event.get("sender") == DANIEL_USER_ID
)
def _process_events(self, events: list[dict]) -> None:
"""Process a batch of new Matrix events.
Extracts Daniel's text messages, checks for commands,
bundles them, and forwards to the agent.
"""
daniel_msgs = [
e for e in events if self._is_from_daniel(e)
]
if not daniel_msgs:
return
# Sort chronologically
daniel_msgs.sort(key=lambda e: e.get("origin_server_ts", 0))
texts = [e.get("content", {}).get("body", "") for e in daniel_msgs]
log.info(f"Received {len(texts)} message(s) from Daniel")
# Check for commands first
remaining = self._dispatch_commands(texts)
if not remaining:
return
# Forward remaining texts to the agent
self._forward_to_agent(remaining)
# ── Command System ──────────────────────────────────────────────────
COMMANDS = {
"/new_session": "Create a fresh agent session (clears conversation history)",
"/help": "Show this list of available commands",
}
def _dispatch_commands(self, texts: list[str]) -> list[str]:
"""Check messages for commands and handle them.
Returns the list of texts that were NOT handled as commands,
so they can be forwarded to the agent.
"""
remaining = []
for text in texts:
stripped = text.strip()
if stripped == "/new_session":
self._cmd_new_session()
elif stripped == "/help":
self._cmd_help()
else:
remaining.append(text)
return remaining
def _cmd_new_session(self) -> None:
"""Handle /new_session: create a fresh session and notify the user."""
log.info("Executing /new_session command")
session_id = self.agent.create_session()
if session_id:
self.agent.rename_session(session_id, f"Matrix {session_id}")
self.state.session_id = session_id
self.state.save(self.config.bridge.state_file)
self.matrix.send_message("🔄 New session created!")
else:
self.matrix.send_message(
"❌ Failed to create new session. Check logs."
)
def _cmd_help(self) -> None:
"""Handle /help: list available commands."""
lines = ["**Available commands:**"]
for cmd, desc in self.COMMANDS.items():
lines.append(f"- `{cmd}` — {desc}")
self.matrix.send_message("\n".join(lines))
# ── Agent Forwarding ────────────────────────────────────────────────
def _ensure_session(self) -> Optional[str]:
"""Get or create an agent session. Returns session ID or None."""
session_id = self.state.session_id
if not session_id:
log.info("No active session, creating one")
session_id = self.agent.create_session()
if not session_id:
return None
self.agent.rename_session(session_id, f"Matrix {session_id}")
self.state.session_id = session_id
self.state.save(self.config.bridge.state_file)
return session_id
# Verify the session still exists
if self.agent.get_session_state(session_id) is None:
log.warning(f"Session {session_id} gone, creating new one")
session_id = self.agent.create_session()
if not session_id:
return None
self.agent.rename_session(session_id, f"Matrix {session_id}")
self.state.session_id = session_id
self.state.save(self.config.bridge.state_file)
return session_id
@staticmethod
def _bundle_messages(texts: list[str]) -> str:
"""Combine multiple messages into a single agent prompt."""
if len(texts) == 1:
return texts[0]
parts = [
f"--- Message {i} ---\n{t}"
for i, t in enumerate(texts, 1)
]
return "Your human sent these messages at once:\n\n" + "\n\n".join(parts)
def _forward_to_agent(self, texts: list[str]) -> None:
"""Send texts to the agent and relay the response back to Matrix."""
session_id = self._ensure_session()
if not session_id:
self.matrix.send_message(
"❌ Failed to create agent session. Check logs."
)
return
prompt = self._bundle_messages(texts)
log.info(
f"Sending {len(texts)} message(s) to agent: "
f"{prompt[:120]}..."
)
# Show typing indicator while processing
self.matrix.set_typing(True)
try:
if not self.agent.send_message(session_id, prompt):
self.matrix.send_message(
"❌ Failed to send message to agent."
)
return
response = self.agent.wait_for_response(
session_id,
timeout=self.config.bridge.agent_response_timeout,
)
finally:
self.matrix.set_typing(False)
if response:
self._send_response(response)
else:
log.warning("Agent returned no response — not sending to Matrix")
def _send_response(self, text: str) -> None:
"""Send the agent's response to Matrix, splitting long messages."""
max_chunk = self.config.bridge.max_message_length
if len(text) <= max_chunk:
self.matrix.send_message(text)
else:
for i in range(0, len(text), max_chunk):
chunk = text[i : i + max_chunk]
if i == 0:
self.matrix.send_message(chunk)
else:
self.matrix.send_message(f"(continued)\n{chunk}")

21
config.json Normal file
View File

@@ -0,0 +1,21 @@
{
"matrix": {
"server": "matrix.daedalus706.de",
"user_id": "@lucy:matrix.daedalus706.de",
"room_id": "!8zLJacfaL2WYoYVp:matrix.daedalus706.de",
"credentials_file": "/home/admin/auth/matrix-credentials.txt"
},
"agent": {
"base_url": "http://10.0.1.2:8080"
},
"bridge": {
"poll_interval_seconds": 1,
"state_file": "/home/admin/agent-dir/bridge-state.json",
"matrix_poll_timeout": 0,
"agent_timeout_seconds": 30,
"agent_retries": 3,
"max_message_length": 40000,
"processed_ids_limit": 200,
"agent_response_timeout": 300
}
}

59
config.py Normal file
View File

@@ -0,0 +1,59 @@
"""Configuration loader for the Matrix Bridge.
Loads settings from config.json and provides typed access via dataclasses.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Optional
DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
@dataclass
class MatrixConfig:
server: str
user_id: str
room_id: str
credentials_file: str
@dataclass
class AgentConfig:
base_url: str
@dataclass
class BridgeConfig:
poll_interval_seconds: int = 1
state_file: str = "/home/admin/agent-dir/bridge-state.json"
matrix_poll_timeout: int = 0
agent_timeout_seconds: int = 30
agent_retries: int = 3
max_message_length: int = 40000
processed_ids_limit: int = 200
agent_response_timeout: int = 300
@dataclass
class Config:
matrix: MatrixConfig
agent: AgentConfig
bridge: BridgeConfig = field(default_factory=BridgeConfig)
@classmethod
def load(cls, path: str = DEFAULT_CONFIG_PATH) -> "Config":
"""Load configuration from a JSON file."""
with open(path, "r") as f:
data = json.load(f)
matrix = MatrixConfig(**data.get("matrix", {}))
agent = AgentConfig(**data.get("agent", {}))
bridge_data = data.get("bridge", {})
bridge = BridgeConfig(**bridge_data)
return cls(matrix=matrix, agent=agent, bridge=bridge)

74
main.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Matrix Bridge Daemon — connects Lucy's Matrix DM to the local agent API.
Polling loop that listens for new messages in a Matrix room, forwards them
to the AI agent framework, and sends responses back.
Usage:
python3 main.py # Normal mode
python3 main.py --config path/to.json # Custom config
python3 main.py --debug # Verbose logging
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from config import Config
from bridge import Bridge
def setup_logging(debug: bool = False) -> None:
"""Configure logging format and level."""
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Matrix Bridge Daemon — connects Matrix DMs to the agent API",
)
parser.add_argument(
"--config",
default=os.path.join(os.path.dirname(__file__), "config.json"),
help="Path to configuration file (default: config.json)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable verbose DEBUG-level logging",
)
args = parser.parse_args()
setup_logging(debug=args.debug)
log = logging.getLogger("matrix-bridge")
# Load configuration
try:
config = Config.load(args.config)
except Exception as e:
log.error(f"Failed to load config from {args.config}: {e}")
sys.exit(1)
# Create and run the bridge
bridge = Bridge(config)
# Verify agent API is reachable
if bridge.agent._request("GET", "/api/sessions") is not None:
log.info("Agent API is reachable")
else:
log.warning("Agent API not reachable — will retry in main loop")
bridge.run()
if __name__ == "__main__":
main()

206
matrix_client.py Normal file
View File

@@ -0,0 +1,206 @@
"""Matrix API client — handles all communication with the Matrix server.
Provides: sync, send messages, typing indicators, token refresh.
Uses only Python stdlib — no external dependencies.
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.error
import urllib.request
from typing import Optional
from urllib.parse import urlencode
from config import Config
log = logging.getLogger("matrix-bridge.matrix")
class MatrixClient:
"""Client for communicating with a Matrix homeserver."""
def __init__(self, config: Config):
self.server = config.matrix.server
self.user_id = config.matrix.user_id
self.room_id = config.matrix.room_id
self._creds_file = config.matrix.credentials_file
self._access_token: Optional[str] = None
self._load_credentials()
# ── Credential Management ───────────────────────────────────────────
def _load_credentials(self) -> None:
"""Read access token from the credentials file."""
creds = self._parse_creds_file()
self._access_token = creds.get("Access Token")
if not self._access_token:
raise RuntimeError("No Access Token found in credentials file")
def _parse_creds_file(self) -> dict[str, str]:
"""Parse key: value pairs from the credentials file."""
creds = {}
with open(self._creds_file, "r") as f:
for line in f:
if ":" in line:
key, _, value = line.partition(":")
creds[key.strip()] = value.strip()
return creds
def _refresh_token(self) -> bool:
"""Re-authenticate to Matrix and update the credentials file."""
creds = self._parse_creds_file()
password = creds.get("Password")
username = creds.get("Username")
if not password or not username:
log.error("Cannot refresh token: missing username or password")
return False
url = f"https://{self.server}/_matrix/client/v3/login"
payload = {
"type": "m.login.password",
"user": username,
"password": password,
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
new_token = result["access_token"]
# Persist to credentials file
with open(self._creds_file, "r") as f:
content = f.read()
content = content.replace(
creds.get("Access Token", ""), new_token
)
with open(self._creds_file, "w") as f:
f.write(content)
self._access_token = new_token
log.info("Matrix token refreshed")
return True
except Exception as e:
log.error(f"Matrix re-login failed: {e}")
return False
# ── Raw Request ─────────────────────────────────────────────────────
def _request(
self, method: str, path: str, body: Optional[dict] = None
) -> Optional[dict]:
"""Make an authenticated request to the Matrix API.
Handles token expiry by re-authenticating and retrying once.
Returns parsed JSON on success, None on failure.
"""
url = f"https://{self.server}{path}"
data = json.dumps(body).encode("utf-8") if body else None
req = urllib.request.Request(
url, data=data,
headers={
"Content-Type": "application/json" if data else "",
"Authorization": f"Bearer {self._access_token}",
},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace")
if "M_UNKNOWN_TOKEN" in err_body:
log.info("Token expired, refreshing...")
if self._refresh_token():
return self._request(method, path, body)
log.error(f"Matrix {method} {path} -> HTTP {e.code}: {err_body[:200]}")
return None
except Exception as e:
log.error(f"Matrix {method} {path} failed: {e}")
return None
# ── Sync ────────────────────────────────────────────────────────────
def sync(
self, since: Optional[str] = None, timeout: int = 0
) -> tuple[Optional[str], list[dict]]:
"""Poll the Matrix sync API.
Args:
since: Batch token from the previous sync (None for initial).
timeout: Server-side timeout in ms (0 = return immediately).
Returns:
(next_batch_token, list_of_events) or (None, []) on error.
"""
params = {"timeout": str(timeout)}
if since:
params["since"] = since
result = self._request(
"GET", f"/_matrix/client/v3/sync?{urlencode(params)}"
)
if result is None:
return None, []
next_batch = result.get("next_batch")
# Collect all timeline events from joined rooms
events = []
for room_data in result.get("rooms", {}).get("join", {}).values():
events.extend(
room_data.get("timeline", {}).get("events", [])
)
return next_batch, events
# ── Send Message ────────────────────────────────────────────────────
def send_message(self, text: str) -> bool:
"""Send a text message to the configured room. Returns True on success."""
txn_id = f"lucy-bridge-{int(time.time())}-{os.getpid()}"
path = (
f"/_matrix/client/v3/rooms/"
f"{self.room_id}/send/m.room.message/{txn_id}"
)
payload = {"msgtype": "m.text", "body": text}
result = self._request("PUT", path, payload)
if result and "event_id" in result:
log.info(f"Sent to Matrix, event_id: {result['event_id']}")
return True
return False
# ── Typing Indicator ────────────────────────────────────────────────
def set_typing(self, typing: bool, timeout_ms: int = 20000) -> bool:
"""Show or hide the typing indicator in the configured room.
This is best-effort — failures are logged at DEBUG level since
typing indicators are non-critical.
"""
path = (
f"/_matrix/client/v3/rooms/"
f"{self.room_id}/typing/{self.user_id}"
)
payload: dict = {"typing": typing}
if typing:
payload["timeout"] = timeout_ms
result = self._request("PUT", path, payload)
if result is not None:
log.debug(f"Typing {'on' if typing else 'off'}")
return True
log.debug("Typing indicator failed (non-critical)")
return False

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
# Matrix Bridge — No external dependencies required.
# Uses only Python stdlib (urllib, json, logging, etc.).

74
state.py Normal file
View File

@@ -0,0 +1,74 @@
"""Persistent state management for the bridge.
Saves and loads session ID, Matrix sync batch token, and processed event IDs
so the bridge survives crashes and restarts without losing context.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Optional
log = logging.getLogger("matrix-bridge.state")
class BridgeState:
"""Persistent state that survives bridge restarts."""
def __init__(
self,
session_id: Optional[str] = None,
next_batch: Optional[str] = None,
processed_event_ids: Optional[list[str]] = None,
):
self.session_id = session_id
self.next_batch = next_batch
self.processed_event_ids = processed_event_ids or []
@classmethod
def load(cls, path: str) -> "BridgeState":
"""Load state from disk, or return defaults."""
if os.path.exists(path):
try:
with open(path, "r") as f:
data = json.load(f)
return cls(
session_id=data.get("session_id"),
next_batch=data.get("next_batch"),
processed_event_ids=data.get("processed_event_ids", []),
)
except (json.JSONDecodeError, IOError) as e:
log.warning(f"Failed to load state file: {e}")
return cls()
def save(self, path: str) -> None:
"""Persist state to disk."""
data = {
"session_id": self.session_id,
"next_batch": self.next_batch,
"processed_event_ids": self.processed_event_ids,
}
try:
with open(path, "w") as f:
json.dump(data, f, indent=2)
except IOError as e:
log.error(f"Failed to save state: {e}")
def mark_processed(self, event_ids: list[str], limit: int = 200) -> None:
"""Add event IDs to the processed set and trim to prevent bloat."""
processed = set(self.processed_event_ids)
for eid in event_ids:
processed.add(eid)
if len(processed) > limit:
processed = set(list(processed)[-limit:])
self.processed_event_ids = list(processed)
def __repr__(self) -> str:
return (
f"BridgeState("
f"session={self.session_id}, "
f"batch={'set' if self.next_batch else 'unset'}, "
f"processed={len(self.processed_event_ids)})"
)