Scaffold gitea-review-bot: thin matrix-nio PR-review bot (auto-map, threaded review, subagent panel, merge/reject/deploy)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# gitea-review-bot credentials — COPY to .env (gitignored). Never commit real values.
|
||||
# The real .env lives next to the deployment on the Spark.
|
||||
|
||||
MATRIX_HOMESERVER=https://<your-synapse-host>
|
||||
MATRIX_USER=@reviewer:<your-domain>
|
||||
MATRIX_DEVICE_ID=gitea-review-bot
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
|
||||
# Gitea access token with Read+Write on the repos it reviews (needed to merge/close PRs).
|
||||
# Gitea → Settings → Applications → Generate Token, scope: repository = Read and Write.
|
||||
GITEA_TOKEN=
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Secrets & local env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local deployment config + runtime state (per-deployment; never committed)
|
||||
config.toml
|
||||
state/
|
||||
|
||||
# Claude Code — deny by default, allow-list shared wiring.
|
||||
# .claude/ also accumulates worktrees, editor configs, and OS cruft; commit
|
||||
# only the shared parts so new local scratch (or a stray secret) stays out.
|
||||
.claude/*
|
||||
!.claude/rules/
|
||||
!.claude/agents/
|
||||
!.claude/commands/
|
||||
!.claude/skills/
|
||||
!.claude/settings.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# OS cruft
|
||||
.DS_Store
|
||||
@@ -0,0 +1,91 @@
|
||||
# gitea-review-bot: AGENTS.md
|
||||
|
||||
gitea-review-bot is a **thin Matrix bot that runs an AI PR-review-and-merge workflow for your Gitea
|
||||
repos**, one review room per repo. It is a *separate* bot from `matrix-bridge` (which turns Matrix
|
||||
messages into interactive Claude Code sessions): this one's single responsibility is **reviewing
|
||||
pull requests** — so every room it's in is a review room and there is no manual "ignore this room"
|
||||
wiring. Invite it to a room, it auto-joins and maps the room to a repo by name, then for that repo
|
||||
it polls Gitea PRs, posts a headless `claude -p` review as a Matrix thread, and lets you merge/reject
|
||||
from the thread.
|
||||
|
||||
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for items
|
||||
> tagged `(gitea-review-bot)` and surface them before proposing next steps; triage with `/triage`.
|
||||
|
||||
## Stack
|
||||
|
||||
Python + **matrix-nio**, one thin Docker container on the Spark (same shape as `matrix-bridge` and
|
||||
the `ten31-database` intake bot). No framework (Maubot rejected — see Decisions). The heavy work —
|
||||
the `claude -p` review and any deploy — runs **on the Mac over SSH**, reusing matrix-bridge's proven
|
||||
wrappers (`scripts/ask-claude.sh` for the review, `scripts/deploy-site.sh` for publish). State is
|
||||
flat JSON in a writable `state/` mount.
|
||||
|
||||
## Placement
|
||||
|
||||
| Dimension | Call |
|
||||
|---|---|
|
||||
| Host | **Spark**, plain Docker container (NOT Start9/s9pk) |
|
||||
| Runtime | Long-running service: matrix-nio sync + a Gitea poll loop over mapped repos |
|
||||
| Model routing | `claude -p` on the Mac via the Spark→Mac SSH seam (subscription); the review session spawns subagents (reviewer / adjudicator / security-auditor) |
|
||||
| Data layer | Flat JSON in `state/` (room→repo map + enabled agents + reviewed-PR heads + thread roots) |
|
||||
| Interface | Matrix — one review room per repo (+ phone) |
|
||||
| Repo home | Local + Gitea (`ssh://git@immense-voyage.local:59916/grant/gitea-review-bot.git`) |
|
||||
| Sensitivity | Sends PR diffs to `claude -p` (subscription). Fine for code review; flag the boundary before pointing it at a sensitive repo. |
|
||||
|
||||
## Commands
|
||||
|
||||
- **Run (container, on the Spark):** from `~/gitea-review-bot`, `docker compose up -d --build`
|
||||
(host networking, `restart: unless-stopped`; read-only mounts of `.env`/`config.toml`/SSH key,
|
||||
read-write `state/`). Logs: `docker compose logs -f`.
|
||||
- **Deploy:** the Spark's `~/gitea-review-bot` is a Gitea clone tracking `master`; deploy =
|
||||
`git fetch && git reset --hard origin/master && docker compose up -d --build` (or a Spark Control
|
||||
Update tile, once added — captured in the inbox). `config.toml` is gitignored — refresh it on the
|
||||
Spark separately (scp) like matrix-bridge.
|
||||
- **Onboard a repo:** create a Matrix room named like the repo, invite this bot → it auto-joins,
|
||||
maps the room to `<owner>/<roomname>` (+ `~/Projects/<roomname>` on the Mac), and posts an
|
||||
onboarding message. Pick review agents in-chat: `agents +reviewer +security -adjudicator`.
|
||||
- **In a review room:** `merge` / `reject` (inside a PR's thread, or `merge <n>` by number), `yes`/`no`
|
||||
to confirm a merge, `agents …` to toggle the subagent panel.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/bot.py` — the bot: matrix-nio sync; auto-join + in-room auto-map (room→repo by name); a Gitea
|
||||
poll loop per mapped repo; threaded `claude -p` review (subagent panel); merge/reject/deploy
|
||||
in-thread; whole-thread redaction on resolve (server-enumerated, restart-proof).
|
||||
- `config.example.toml` — homeserver, `[mac]` (ssh alias + the reused matrix-bridge wrapper paths),
|
||||
`[gitea]` (api_base/owner/verify_tls), `[defaults]`, optional `[repo.<name>]` deploy overrides.
|
||||
- `.env.example` — `MATRIX_*` + `GITEA_TOKEN` (real `.env` gitignored).
|
||||
- `Dockerfile` · `docker-compose.yml` · `docker-entrypoint.sh` — the Spark container (generic image;
|
||||
secrets/config via read-only mounts; entrypoint writes `~/.ssh/config` for the Mac alias).
|
||||
- `state/` — gitignored runtime JSON (`rooms.json`: room map + agents + heads + thread roots).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Separate bot from matrix-bridge** (single responsibility = PR review). Beat: extending
|
||||
matrix-bridge with review-room special-casing. Reopens if the two bots' logic heavily overlaps.
|
||||
- **Thin matrix-nio container, NOT Maubot.** Reevaluated 2026-06-28: Maubot helps with the Matrix
|
||||
plumbing we've already solved, not the SSH/`claude -p`/poll logic that's the actual weight, and it
|
||||
reintroduces a web-UI/management layer (Spark Control is the dashboard). Reopens at ~6+ bots or a
|
||||
non-developer web-management need; the lighter step first is a shared "bot kit" library.
|
||||
- **Subagent panel (Option B):** the lead `claude -p` session spawns subagents and presents each
|
||||
output + its own overall recommendation. Beat: bot-orchestrated separate `claude -p` runs (more
|
||||
deterministic but 3× the sessions + more bot code). Reopens if headless subagent spawning is flaky.
|
||||
- **Panel composition is per-room, set in chat** (onboarding message + `agents +/-`), not config.
|
||||
- **Reuse matrix-bridge's Mac wrappers + Spark→Mac SSH key** (don't duplicate the seam).
|
||||
- **Auto-map by name:** room `<x>` → Gitea `<owner>/<x>` + `~/Projects/<x>`; mapping persists to
|
||||
`state/` (mirrors matrix-bridge D14). One room per repo.
|
||||
|
||||
## Sovereignty
|
||||
|
||||
Reviews send PR diffs to `claude -p` (the subscription), not a frontier API on payload data; that's
|
||||
acceptable for code review of these repos. Before pointing the bot at a repo with sensitive content,
|
||||
revisit this — local inference via Spark Control would be the path.
|
||||
|
||||
## Current state
|
||||
|
||||
**Scaffolded + initial bot built 2026-06-28.** First milestone: invite the bot to a room named
|
||||
`<repo>` → it auto-joins, maps to `grant/<repo>`, and within ~60s of a new PR posts a threaded
|
||||
`claude -p` review; `merge` + `yes` in that thread merges it on Gitea and redacts the thread. Core
|
||||
flow ported from matrix-bridge (D15–D19), generalized to multi-repo + the subagent panel.
|
||||
**Not yet run** — needs the bot's `.env` (Matrix creds + `GITEA_TOKEN`) + `config.toml` on the
|
||||
Spark, then `docker compose up -d --build`; then onboard the first repo and validate the panel +
|
||||
in-chat agent toggles. A Spark Control tile is captured in the cross-project inbox.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# gitea-review-bot — Spark container. Generic image; secrets/config via read-only mounts at runtime.
|
||||
# Host networking (docker-compose) so it reaches Synapse (TLS), Gitea (LAN), and the Mac (SSH).
|
||||
FROM python:3.12-slim
|
||||
|
||||
# openssh-client: the bot shells out to `ssh mac-bridge ...` to run the review + deploy on the Mac.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src/ ./src/
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# .env and config.toml arrive via read-only mounts at runtime (never baked).
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["python", "-u", "src/bot.py"]
|
||||
@@ -0,0 +1,22 @@
|
||||
# gitea-review-bot
|
||||
|
||||
A thin Matrix bot that reviews and merges your **Gitea** pull requests, one review room per repo.
|
||||
Separate from `matrix-bridge` (which runs interactive Claude Code sessions) — this bot's only job
|
||||
is PR review.
|
||||
|
||||
## How it works
|
||||
1. Create a Matrix room named like a repo and invite the bot. It auto-joins and maps the room to
|
||||
`<owner>/<repo>` (and `~/Projects/<repo>` on the Mac), then posts an onboarding message.
|
||||
2. It polls that repo's open PRs and posts each as a **thread**: a headless `claude -p` review (with
|
||||
an optional subagent panel) run on the Mac over SSH.
|
||||
3. In the thread: `merge` (then `yes`) or `reject`. On a clean merge it can auto-deploy, then
|
||||
redacts the thread so the room shows only open PRs.
|
||||
|
||||
Pick which review agents run per room in chat: `agents +reviewer +security -adjudicator`.
|
||||
|
||||
## Run (on the Spark)
|
||||
1. Copy `config.example.toml` → `config.toml` and `.env.example` → `.env`; fill them in (Matrix
|
||||
creds, `GITEA_TOKEN`, the `[mac]` SSH alias + reused matrix-bridge wrapper paths, `[gitea]`).
|
||||
2. `docker compose up -d --build`. Logs: `docker compose logs -f`.
|
||||
|
||||
See `AGENTS.md` for the full design, placement, and decisions.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# gitea-review-bot — ROADMAP
|
||||
|
||||
Phases beyond the first milestone (threaded single-flow review + merge/reject/deploy; see AGENTS.md
|
||||
`## Current state`).
|
||||
|
||||
## Phase 2 — Subagent panel, proven
|
||||
- The lead `claude -p` reliably spawns the enabled subagents (reviewer / adjudicator /
|
||||
security-auditor) and presents each opinion + its own recommendation. **Exit:** a real PR review
|
||||
shows up to 3 distinct, labeled opinions + the lead's call; flaky headless subagent spawning
|
||||
falsifies it → fall back to bot-orchestrated separate `claude -p` runs (the rejected alternative).
|
||||
- In-chat `agents +/-` toggles change which subagents run on the next review, per room (persisted).
|
||||
|
||||
## Phase 3 — Multi-repo at scale
|
||||
- Onboard ≥3 repos to their own rooms; confirm independent polling, no cross-talk. **Exit:** three
|
||||
repos reviewed from three rooms in one run with no interleaving.
|
||||
- Per-repo deploy (`[repo.<name>].deploy_on_merge`) exercised on a non-static repo (not a Pages site).
|
||||
|
||||
## Phase 4 — Spark Control tile
|
||||
- Status badge + Update/Restart/Stop-Start/Logs, mirroring matrix-bridge's D10 (captured in the
|
||||
cross-project inbox as a spark-control item).
|
||||
|
||||
## Deferred / non-goals
|
||||
- Not a Claude-session bot (that's matrix-bridge); not Maubot (revisit at ~6+ bots or web-UI mgmt);
|
||||
no GitHub/non-Gitea; no Gitea account/permission management; E2EE deferred (private LAN transport).
|
||||
- A shared "bot kit" library (extract the common matrix-nio + SSH plumbing across the three bots)
|
||||
once duplication bites — the lighter step before ever considering Maubot.
|
||||
- Poll → webhook upgrade if 60s latency ever matters.
|
||||
@@ -0,0 +1,30 @@
|
||||
# gitea-review-bot — deployment config (EXAMPLE). Copy to config.toml (gitignored) and fill in.
|
||||
# Credentials live in the gitignored .env, NOT here.
|
||||
|
||||
[homeserver]
|
||||
url = "https://<your-synapse-host>"
|
||||
user = "@reviewer:<your-domain>" # the bot's OWN Matrix account (not matrix-bridge's @agent)
|
||||
|
||||
# How the bot reaches the Mac (reuses matrix-bridge's Spark→Mac seam + wrappers).
|
||||
[mac]
|
||||
ssh_alias = "mac-bridge"
|
||||
# The review runs `claude -p` in the repo — matrix-bridge's ask-claude.sh does exactly that.
|
||||
review_launcher = "/Users/macpro/Projects/matrix-bridge/scripts/ask-claude.sh"
|
||||
# Post-merge publish (per-repo, opt-in) — matrix-bridge's deploy-site.sh (ff-pull master + deploy).
|
||||
deploy_launcher = "/Users/macpro/Projects/matrix-bridge/scripts/deploy-site.sh"
|
||||
hostname = "10.59.211.5" # the Mac over WireGuard (container generates ~/.ssh/config)
|
||||
user = "macpro"
|
||||
|
||||
[gitea]
|
||||
api_base = "https://<gitea-host-or-IP>:<port>/api/v1" # use the LAN IP if only a .local name exists
|
||||
# (a slim container can't resolve mDNS .local)
|
||||
owner = "grant" # repos are <owner>/<roomname>
|
||||
verify_tls = false # StartOS Gitea serves a self-signed cert on the LAN
|
||||
|
||||
[defaults]
|
||||
poll_interval_seconds = 60
|
||||
review_timeout_seconds = 600 # a `claude -p` review (with subagents) can take minutes
|
||||
|
||||
# Optional per-repo overrides (auto-deploy is OFF unless set here). Keyed by repo/room name.
|
||||
# [repo.ten31-site]
|
||||
# deploy_on_merge = true
|
||||
@@ -0,0 +1,18 @@
|
||||
# gitea-review-bot — Spark deployment. `docker compose up -d` runs it detached; restart survives
|
||||
# reboots. Host networking reaches Synapse (TLS), Gitea (LAN), and the Mac (WireGuard SSH alias).
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
image: gitea-review-bot
|
||||
container_name: gitea-review-bot
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./.env:/app/.env:ro
|
||||
- ./config.toml:/app/config.toml:ro
|
||||
# Writable state (room→repo map, enabled agents, reviewed heads, thread roots). Survives
|
||||
# restarts/rebuilds; gitignored.
|
||||
- ./state:/app/state:rw
|
||||
# Reuse the SAME Spark→Mac key matrix-bridge uses (chmod 600 on the host).
|
||||
# Override the host path with GRB_SSH_KEY_HOST if the key lives elsewhere.
|
||||
- ${GRB_SSH_KEY_HOST:-/home/modelo/.ssh/id_ed25519}:/root/.ssh/id_ed25519:ro
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# gitea-review-bot container entrypoint — the container's "environment seam".
|
||||
#
|
||||
# Generates ~/.ssh/config for the Mac alias from config.toml's [mac] section, then execs the bot.
|
||||
# SSH-client wiring lives here, not in bot.py (same split matrix-bridge uses). On the Spark host the
|
||||
# bot would use modelo's existing ~/.ssh/config; in the container we recreate just the one alias.
|
||||
set -e
|
||||
|
||||
SSH_DIR="$HOME/.ssh"
|
||||
mkdir -p "$SSH_DIR"
|
||||
chmod 700 "$SSH_DIR"
|
||||
|
||||
GRB_SSH_KEY="${GRB_SSH_KEY:-$SSH_DIR/id_ed25519}" \
|
||||
SSH_CONFIG="$SSH_DIR/config" \
|
||||
KNOWN_HOSTS="$SSH_DIR/known_hosts" \
|
||||
python - <<'PY'
|
||||
import os, tomllib
|
||||
with open("/app/config.toml", "rb") as f:
|
||||
mac = tomllib.load(f)["mac"]
|
||||
config = f"""Host {mac.get('ssh_alias', 'mac-bridge')}
|
||||
HostName {mac['hostname']}
|
||||
User {mac['user']}
|
||||
IdentityFile {os.environ['GRB_SSH_KEY']}
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking accept-new
|
||||
UserKnownHostsFile {os.environ['KNOWN_HOSTS']}
|
||||
"""
|
||||
with open(os.environ['SSH_CONFIG'], "w") as f:
|
||||
f.write(config)
|
||||
PY
|
||||
chmod 600 "$SSH_DIR/config"
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,2 @@
|
||||
matrix-nio>=0.24
|
||||
tomli>=2.0; python_version < "3.11"
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gitea-review-bot — per-repo Gitea PR review in Matrix.
|
||||
|
||||
A thin matrix-nio bot, separate from matrix-bridge. Invite it to a room → it auto-joins and maps the
|
||||
room to a repo by name (room "foo" → Gitea <owner>/foo + ~/Projects/foo). For each mapped repo it
|
||||
polls open PRs, runs a headless `claude -p` review on the Mac (an optional subagent panel), posts the
|
||||
verdict as a Matrix thread per PR, and lets you merge/reject from the thread; a clean resolve redacts
|
||||
the thread. Runs on the Spark; the review + any deploy run on the Mac over SSH, reusing matrix-bridge's
|
||||
wrappers. Config: ../config.toml Creds: ../.env State: state/rooms.json
|
||||
"""
|
||||
import asyncio
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
import tomllib # py >= 3.11
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # py < 3.11
|
||||
|
||||
from nio import AsyncClient, InviteMemberEvent, MatrixRoom, RoomMessageText
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
REVIEW_TIMEOUT = 600 # seconds for a `claude -p` review (subagent panel can take minutes)
|
||||
DEPLOY_TIMEOUT = 180 # seconds for a post-merge deploy
|
||||
SETUP_TIMEOUT = 30 # seconds for setup SSH probes (ls ~/Projects, test -d)
|
||||
HTTP_TIMEOUT = 30 # seconds per Gitea / Matrix API request
|
||||
GITEA_DIFF_LIMIT = 40000 # max diff chars embedded in the review prompt (crosses SSH as one arg)
|
||||
MAX_MSG_CHARS = 30000 # split long messages under Matrix's ~64KB event cap
|
||||
|
||||
# Subagents the panel can run. Aliases map loose user input to the canonical name.
|
||||
PANEL_AGENTS = ("reviewer", "adjudicator", "security-auditor")
|
||||
AGENT_ALIASES = {"security": "security-auditor", "sec": "security-auditor",
|
||||
"review": "reviewer", "adjudicate": "adjudicator", "adj": "adjudicator"}
|
||||
AGENT_BRIEF = {
|
||||
"reviewer": "an independent code review — does the code make sense? correctness, bugs, quality",
|
||||
"adjudicator": "an adjudication — is this change necessary/relevant to include, or unnecessary?",
|
||||
"security-auditor": "a security review of the diff — injection, secrets, auth, unsafe patterns",
|
||||
}
|
||||
|
||||
|
||||
def load_env(path):
|
||||
env = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v.strip().strip('"')
|
||||
return env
|
||||
|
||||
|
||||
def load_config(path):
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def load_state(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(path, state):
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def gitea_api(method, url, token, verify_tls, payload=None, accept="application/json"):
|
||||
"""Blocking Gitea API request; returns (status|None, body_text). Run via asyncio.to_thread.
|
||||
payload (dict) -> JSON body for POST/PATCH. verify_tls=False for a self-signed LAN Gitea."""
|
||||
headers = {"Authorization": f"token {token}", "Accept": accept}
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
ctx = ssl.create_default_context()
|
||||
if not verify_tls:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
return None, repr(e)
|
||||
|
||||
|
||||
def gitea_error_message(body):
|
||||
try:
|
||||
return json.loads(body).get("message") or body[:200]
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return (body or "")[:200] or "(no detail)"
|
||||
|
||||
|
||||
def thread_root_of(event):
|
||||
relates = (event.source or {}).get("content", {}).get("m.relates_to") or {}
|
||||
if relates.get("rel_type") == "m.thread":
|
||||
return relates.get("event_id")
|
||||
return None
|
||||
|
||||
|
||||
def in_reply_to_id(event):
|
||||
relates = (event.source or {}).get("content", {}).get("m.relates_to") or {}
|
||||
return (relates.get("m.in_reply_to") or {}).get("event_id")
|
||||
|
||||
|
||||
def message_text(event):
|
||||
"""The user's typed text, with any rich-reply / thread quoted fallback stripped (a Matrix reply
|
||||
prepends the quoted original as `>`-lines, so otherwise the first word parses as `>`)."""
|
||||
body = event.body or ""
|
||||
if in_reply_to_id(event):
|
||||
lines = body.split("\n")
|
||||
i = 0
|
||||
while i < len(lines) and lines[i].startswith(">"):
|
||||
i += 1
|
||||
if i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
body = "\n".join(lines[i:])
|
||||
return body.strip()
|
||||
|
||||
|
||||
def split_message(text, limit=MAX_MSG_CHARS):
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks, buf = [], ""
|
||||
for line in text.splitlines(keepends=True):
|
||||
while len(line) > limit:
|
||||
if buf:
|
||||
chunks.append(buf); buf = ""
|
||||
chunks.append(line[:limit]); line = line[limit:]
|
||||
if len(buf) + len(line) > limit:
|
||||
chunks.append(buf); buf = ""
|
||||
buf += line
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
return chunks
|
||||
|
||||
|
||||
def suggest_repo(room_name, candidates):
|
||||
if not room_name or not candidates:
|
||||
return None
|
||||
target = room_name.strip().lstrip("#").lower()
|
||||
exact = {c.lower(): c for c in candidates}
|
||||
if target in exact:
|
||||
return exact[target]
|
||||
subs = [c for c in candidates if target in c.lower()]
|
||||
if len(subs) == 1:
|
||||
return subs[0]
|
||||
close = difflib.get_close_matches(target, candidates, n=1, cutoff=0.6)
|
||||
return close[0] if close else None
|
||||
|
||||
|
||||
def build_review_prompt(label, pr, diff, truncated, agents):
|
||||
"""One-shot `claude -p` prompt: review this PR's diff, return a Matrix-ready verdict. If `agents`
|
||||
is non-empty the lead session also spawns those subagents (Task tool) and presents each verdict
|
||||
plus its own overall recommendation (the subagent panel)."""
|
||||
num = pr.get("number")
|
||||
title = pr.get("title", "")
|
||||
author = (pr.get("user") or {}).get("login", "?")
|
||||
body = (pr.get("body") or "").strip() or "(no description)"
|
||||
head_ref = (pr.get("head") or {}).get("ref", "?")
|
||||
base_ref = (pr.get("base") or {}).get("ref", "?")
|
||||
trunc = (f"\n\n[The diff was truncated to the first {GITEA_DIFF_LIMIT} characters — review what is "
|
||||
"shown and say the review is partial.]" if truncated else "")
|
||||
|
||||
if agents:
|
||||
panel = "\n".join(f" - **{a}** subagent: {AGENT_BRIEF[a]}" for a in agents)
|
||||
panel_block = f"""
|
||||
Then convene a review panel: use the Task tool to spawn each of these subagents independently, give \
|
||||
each the PR metadata and the diff below, and reproduce each one's verdict verbatim under its own \
|
||||
**heading**:
|
||||
{panel}
|
||||
|
||||
Finally, weigh your own read and the panel and give an **OVERALL RECOMMENDATION** on the first line.
|
||||
"""
|
||||
else:
|
||||
panel_block = ""
|
||||
|
||||
return f"""You are the lead reviewer for a proposed pull request to `{label}`. You are running \
|
||||
inside a checkout of the base branch, so READ any file in this repo for context (and its \
|
||||
AGENTS.md/CLAUDE.md for conventions). This is a READ-ONLY review — do not modify anything.
|
||||
|
||||
Pull request #{num}: "{title}"
|
||||
Author: {author} Branch: {head_ref} -> {base_ref}
|
||||
Description: {body}
|
||||
|
||||
Your ENTIRE stdout becomes one Matrix message read on a phone — output only the review, no preamble. \
|
||||
Lead off with:
|
||||
|
||||
RECOMMENDATION: ACCEPT / REQUEST CHANGES / REJECT (first line — should this be merged into \
|
||||
{base_ref} as-is? intent-aware: a work-in-progress / test / "do not merge" PR is REJECT even if clean)
|
||||
|
||||
Then: **What it changes** (2-5 bullets), **Concerns** (bugs, broken markup/links, design/convention \
|
||||
violations, security, content — or "none significant"), **Notes** (optional).
|
||||
{panel_block}{trunc}
|
||||
--- BEGIN UNIFIED DIFF ---
|
||||
{diff}
|
||||
--- END UNIFIED DIFF ---
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
env = load_env(os.path.join(REPO_ROOT, ".env"))
|
||||
cfg = load_config(os.path.join(REPO_ROOT, "config.toml"))
|
||||
|
||||
homeserver = env["MATRIX_HOMESERVER"]
|
||||
user_id = env["MATRIX_USER"]
|
||||
token = env["MATRIX_ACCESS_TOKEN"]
|
||||
device_id = env.get("MATRIX_DEVICE_ID", "gitea-review-bot")
|
||||
gitea_token = env.get("GITEA_TOKEN", "")
|
||||
|
||||
ssh_alias = os.environ.get("GRB_SSH_ALIAS") or cfg["mac"]["ssh_alias"]
|
||||
review_launcher = cfg["mac"]["review_launcher"]
|
||||
deploy_launcher = cfg["mac"].get("deploy_launcher")
|
||||
mac_user = cfg["mac"]["user"]
|
||||
projects_base = f"/Users/{mac_user}/Projects"
|
||||
|
||||
gitea_api_base = cfg["gitea"]["api_base"].rstrip("/")
|
||||
gitea_owner = cfg["gitea"]["owner"]
|
||||
gitea_verify = cfg["gitea"].get("verify_tls", True)
|
||||
|
||||
poll_interval = cfg.get("defaults", {}).get("poll_interval_seconds", 60)
|
||||
review_timeout = cfg.get("defaults", {}).get("review_timeout_seconds", REVIEW_TIMEOUT)
|
||||
repo_overrides = cfg.get("repo", {}) # {name: {deploy_on_merge: bool}}
|
||||
|
||||
state_dir = os.environ.get("GRB_STATE") or os.path.join(REPO_ROOT, "state")
|
||||
rooms_path = os.path.join(state_dir, "rooms.json")
|
||||
rooms = load_state(rooms_path) # room_id -> {repo,repo_dir,label,agents,heads,threads}
|
||||
pending_setup = {} # room_id -> suggested folder (joined, not yet mapped)
|
||||
pending_merge = {} # (room_id, pr_str) -> True : awaiting `yes` in the thread
|
||||
|
||||
client = AsyncClient(homeserver, user_id)
|
||||
client.restore_login(user_id=user_id, device_id=device_id, access_token=token)
|
||||
|
||||
# ---- primitives -------------------------------------------------------------------------
|
||||
async def say(room_id, text, thread_root=None):
|
||||
content = {"msgtype": "m.text", "body": text}
|
||||
if thread_root:
|
||||
content["m.relates_to"] = {
|
||||
"rel_type": "m.thread", "event_id": thread_root,
|
||||
"is_falling_back": True, "m.in_reply_to": {"event_id": thread_root},
|
||||
}
|
||||
resp = await client.room_send(room_id, "m.room.message", content)
|
||||
return getattr(resp, "event_id", None)
|
||||
|
||||
async def ssh_run(remote, timeout):
|
||||
"""Run a command on the Mac over SSH; return (rc, combined_output). rc=None on timeout."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh", ssh_alias, remote,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill(); await proc.wait()
|
||||
return None, f"timed out after {timeout}s"
|
||||
return proc.returncode, out.decode(errors="replace").strip()
|
||||
|
||||
async def run_review(repo_dir, prompt):
|
||||
remote = f"{shlex.quote(review_launcher)} {shlex.quote(repo_dir)} {shlex.quote(prompt)}"
|
||||
return await ssh_run(remote, review_timeout)
|
||||
|
||||
async def run_deploy(repo_dir):
|
||||
remote = f"{shlex.quote(deploy_launcher)} {shlex.quote(repo_dir)}"
|
||||
return await ssh_run(remote, DEPLOY_TIMEOUT)
|
||||
|
||||
async def dir_exists(path):
|
||||
rc, _ = await ssh_run(f"test -d {shlex.quote(path)}", SETUP_TIMEOUT)
|
||||
return rc == 0
|
||||
|
||||
async def list_projects():
|
||||
rc, out = await ssh_run(
|
||||
f"find {shlex.quote(projects_base)} -maxdepth 1 -mindepth 1 -type d", SETUP_TIMEOUT)
|
||||
if rc != 0 or not out:
|
||||
return []
|
||||
return sorted(os.path.basename(p) for p in out.splitlines() if p)
|
||||
|
||||
def matrix_get(path):
|
||||
req = urllib.request.Request(homeserver.rstrip("/") + path,
|
||||
headers={"Authorization": "Bearer " + token})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode(errors="replace")
|
||||
except Exception as e:
|
||||
return None, repr(e)
|
||||
|
||||
def gitea_g(path, accept="application/json"):
|
||||
return gitea_api("GET", f"{gitea_api_base}{path}", gitea_token, gitea_verify, accept=accept)
|
||||
|
||||
def gitea_w(method, path, payload):
|
||||
return gitea_api(method, f"{gitea_api_base}{path}", gitea_token, gitea_verify, payload)
|
||||
|
||||
# ---- auto-join + in-room auto-map (mirrors matrix-bridge D14) ----------------------------
|
||||
async def start_setup(rid, room_name):
|
||||
pending_setup[rid] = None
|
||||
candidates = await list_projects()
|
||||
suggestion = suggest_repo(room_name, candidates)
|
||||
pending_setup[rid] = suggestion
|
||||
if suggestion:
|
||||
await say(rid, f"👋 Joined. This looks like the review room for **{suggestion}** — reply "
|
||||
f"`yes` to watch `{gitea_owner}/{suggestion}`, or send another repo name.")
|
||||
else:
|
||||
hint = f" (e.g. {', '.join(candidates[:6])})" if candidates else ""
|
||||
await say(rid, f"👋 Joined, but I can't tell which repo this room is for. Reply with the "
|
||||
f"repo name{hint}.")
|
||||
|
||||
async def finish_setup(rid, answer):
|
||||
if rid in rooms:
|
||||
return
|
||||
tokens = answer.split()
|
||||
suggestion = pending_setup.get(rid)
|
||||
if tokens and tokens[0].lower() in ("yes", "y") and suggestion:
|
||||
name = suggestion
|
||||
elif tokens:
|
||||
name = tokens[0].lstrip("#")
|
||||
else:
|
||||
await say(rid, "Didn't catch a repo name — reply with the repo this room should review.")
|
||||
return
|
||||
repo_dir = f"{projects_base}/{name}"
|
||||
if not await dir_exists(repo_dir):
|
||||
await say(rid, f"⚠️ No repo checkout at `{repo_dir}` on the Mac. Pick an existing one.")
|
||||
return
|
||||
status, _ = await asyncio.to_thread(lambda: gitea_g(f"/repos/{gitea_owner}/{name}"))
|
||||
if status != 200:
|
||||
await say(rid, f"⚠️ Gitea has no repo `{gitea_owner}/{name}` (HTTP {status}). Check the name.")
|
||||
return
|
||||
rooms[rid] = {"repo": f"{gitea_owner}/{name}", "repo_dir": repo_dir, "label": name,
|
||||
"agents": [], "heads": {}, "threads": {}}
|
||||
pending_setup.pop(rid, None)
|
||||
save_state(rooms_path, rooms)
|
||||
print(f"mapped {rid} -> {gitea_owner}/{name}", flush=True)
|
||||
await say(rid,
|
||||
f"✅ This room now reviews **{gitea_owner}/{name}**. New PRs will appear here as "
|
||||
f"threads — `merge`/`reject` inside a thread (`yes` to confirm a merge).\n\n"
|
||||
f"Review panel (extra opinions from the lead `claude -p` review): "
|
||||
f"{', '.join(PANEL_AGENTS)}. Currently **off** (lead review only). Turn some on with "
|
||||
f"`agents +reviewer +security`, off with `agents -adjudicator`.")
|
||||
|
||||
async def handle_agents(rid, parts):
|
||||
room = rooms[rid]
|
||||
enabled = set(room.get("agents", []))
|
||||
changed = False
|
||||
for tok in parts[1:]:
|
||||
sign = "+"
|
||||
if tok and tok[0] in "+-":
|
||||
sign, tok = tok[0], tok[1:]
|
||||
name = AGENT_ALIASES.get(tok.lower(), tok.lower())
|
||||
if name not in PANEL_AGENTS:
|
||||
await say(rid, f"Unknown agent `{tok}`. Options: {', '.join(PANEL_AGENTS)}.")
|
||||
return
|
||||
if sign == "-":
|
||||
enabled.discard(name); changed = True
|
||||
else:
|
||||
enabled.add(name); changed = True
|
||||
if changed:
|
||||
room["agents"] = [a for a in PANEL_AGENTS if a in enabled] # canonical order
|
||||
save_state(rooms_path, rooms)
|
||||
cur = ", ".join(room["agents"]) or "none (lead review only)"
|
||||
await say(rid, f"🧪 Review panel for **{room['label']}**: {cur}.")
|
||||
|
||||
# ---- review + resolve (per mapped room) -------------------------------------------------
|
||||
async def redact_thread(rid, num):
|
||||
"""Redact a resolved PR's whole thread (root + replies), enumerating members from the server
|
||||
so it works even after a restart. Needs redact power in the room."""
|
||||
room = rooms.get(rid, {})
|
||||
root = room.get("threads", {}).pop(str(num), None)
|
||||
save_state(rooms_path, rooms)
|
||||
if not root:
|
||||
return
|
||||
eids, frm = [root], None
|
||||
rq = urllib.parse.quote(rid, safe="")
|
||||
rootq = urllib.parse.quote(root, safe="")
|
||||
for _ in range(20):
|
||||
q = "?limit=100" + (f"&from={urllib.parse.quote(frm)}" if frm else "")
|
||||
status, data = await asyncio.to_thread(
|
||||
matrix_get, f"/_matrix/client/v1/rooms/{rq}/relations/{rootq}/m.thread{q}")
|
||||
if status != 200 or not isinstance(data, dict):
|
||||
break
|
||||
eids += [ev["event_id"] for ev in data.get("chunk", []) if ev.get("event_id")]
|
||||
frm = data.get("next_batch")
|
||||
if not frm:
|
||||
break
|
||||
for eid in eids:
|
||||
try:
|
||||
await client.room_redact(rid, eid)
|
||||
except Exception as e:
|
||||
print(f"redact {eid} failed: {e!r}", flush=True)
|
||||
print(f"redacted {room.get('label')} PR #{num} thread ({len(eids)} events)", flush=True)
|
||||
|
||||
async def review_pr(rid, pr):
|
||||
room = rooms[rid]
|
||||
num = pr["number"]
|
||||
title = pr.get("title", "")
|
||||
author = (pr.get("user") or {}).get("login", "?")
|
||||
url = pr.get("html_url", "")
|
||||
repo = room["repo"]
|
||||
dstatus, diff = await asyncio.to_thread(
|
||||
lambda: gitea_g(f"/repos/{repo}/pulls/{num}.diff", accept="text/plain"))
|
||||
if dstatus != 200 or not (diff or "").strip():
|
||||
print(f"{room['label']} PR #{num} diff HTTP {dstatus} — retrying", flush=True)
|
||||
return False
|
||||
truncated = len(diff) > GITEA_DIFF_LIMIT
|
||||
if truncated:
|
||||
diff = diff[:GITEA_DIFF_LIMIT]
|
||||
prompt = build_review_prompt(room["label"], pr, diff, truncated, room.get("agents", []))
|
||||
rc, out = await run_review(room["repo_dir"], prompt)
|
||||
if rc != 0:
|
||||
print(f"{room['label']} PR #{num} review FAILED rc={rc}: {(out or '')[:300]}", flush=True)
|
||||
await say(rid, f"⚠️ Review failed for PR #{num} “{title}” (rc={rc}): {(out or 'no output')[:500]}")
|
||||
return True
|
||||
chunks = split_message(f"📋 PR #{num} “{title}” by {author}\n{url}\n\n"
|
||||
+ (out or "(claude returned no output)"))
|
||||
root = await say(rid, chunks[0])
|
||||
if root:
|
||||
room["threads"][str(num)] = root
|
||||
save_state(rooms_path, rooms)
|
||||
for chunk in chunks[1:]:
|
||||
await say(rid, chunk, thread_root=root)
|
||||
else:
|
||||
for chunk in chunks[1:]:
|
||||
await say(rid, chunk)
|
||||
print(f"reviewed {room['label']} PR #{num} ({len(out)} chars)", flush=True)
|
||||
return True
|
||||
|
||||
async def deploy_after_merge(rid, num):
|
||||
room = rooms[rid]
|
||||
override = repo_overrides.get(room["label"], {})
|
||||
if not (override.get("deploy_on_merge") and deploy_launcher):
|
||||
return True
|
||||
root = room.get("threads", {}).get(str(num))
|
||||
await say(rid, "🚀 Publishing the merged change to the live site…", root)
|
||||
rc, out = await run_deploy(room["repo_dir"])
|
||||
if rc == 0:
|
||||
await say(rid, "✅ Live site updated.", root)
|
||||
return True
|
||||
tail = (out or "no output").splitlines()[-1] if out else "no output"
|
||||
print(f"{room['label']} deploy FAILED rc={rc}: {(out or '')[:400]}", flush=True)
|
||||
await say(rid, f"⚠️ Merged, but auto-deploy didn't run (rc={rc}): {tail[:300]} "
|
||||
f"— deploy it manually.", root)
|
||||
return False
|
||||
|
||||
async def do_merge(rid, num):
|
||||
room = rooms[rid]
|
||||
root = room.get("threads", {}).get(str(num))
|
||||
status, body = await asyncio.to_thread(
|
||||
lambda: gitea_w("POST", f"/repos/{room['repo']}/pulls/{num}/merge",
|
||||
{"Do": "merge", "force_merge": True}))
|
||||
if status is not None and 200 <= status < 300:
|
||||
print(f"merged {room['label']} PR #{num}", flush=True)
|
||||
if await deploy_after_merge(rid, num):
|
||||
await redact_thread(rid, num)
|
||||
else:
|
||||
detail = gitea_error_message(body) if status else body
|
||||
await say(rid, f"⚠️ merge PR #{num} failed (HTTP {status}): {detail}", root)
|
||||
|
||||
async def do_reject(rid, num):
|
||||
room = rooms[rid]
|
||||
root = room.get("threads", {}).get(str(num))
|
||||
status, body = await asyncio.to_thread(
|
||||
lambda: gitea_w("PATCH", f"/repos/{room['repo']}/pulls/{num}", {"state": "closed"}))
|
||||
if status is not None and 200 <= status < 300:
|
||||
print(f"closed {room['label']} PR #{num}", flush=True)
|
||||
await redact_thread(rid, num)
|
||||
else:
|
||||
detail = gitea_error_message(body) if status else body
|
||||
await say(rid, f"⚠️ reject PR #{num} failed (HTTP {status}): {detail}", root)
|
||||
|
||||
def pr_for_thread(rid, event):
|
||||
root = thread_root_of(event)
|
||||
if not root:
|
||||
return None
|
||||
for num, r in rooms.get(rid, {}).get("threads", {}).items():
|
||||
if r == root:
|
||||
return num
|
||||
return None
|
||||
|
||||
async def handle_command(rid, event):
|
||||
text = message_text(event)
|
||||
parts = text.split()
|
||||
if not parts:
|
||||
return
|
||||
cmd = parts[0].lower()
|
||||
if cmd == "agents":
|
||||
await handle_agents(rid, parts)
|
||||
return
|
||||
thread_pr = pr_for_thread(rid, event)
|
||||
|
||||
if cmd in ("yes", "y") and thread_pr is not None and (rid, thread_pr) in pending_merge:
|
||||
pending_merge.pop((rid, thread_pr), None)
|
||||
await do_merge(rid, int(thread_pr))
|
||||
return
|
||||
if cmd in ("no", "cancel") and thread_pr is not None and (rid, thread_pr) in pending_merge:
|
||||
pending_merge.pop((rid, thread_pr), None)
|
||||
await say(rid, f"❌ Merge of PR #{thread_pr} cancelled.",
|
||||
rooms[rid]["threads"].get(thread_pr))
|
||||
return
|
||||
|
||||
if cmd not in ("merge", "close", "reject"):
|
||||
return
|
||||
if thread_pr is not None:
|
||||
num = int(thread_pr)
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
num = int(parts[1].lstrip("#"))
|
||||
except ValueError:
|
||||
await say(rid, f"`{parts[1]}` isn't a PR number. Try `{cmd} 1`.")
|
||||
return
|
||||
else:
|
||||
await say(rid, f"Type `{cmd}` inside a PR's thread, or give the number: `{cmd} 1`.")
|
||||
return
|
||||
root = rooms[rid].get("threads", {}).get(str(num))
|
||||
if cmd == "merge":
|
||||
pending_merge[(rid, str(num))] = True
|
||||
await say(rid, f"⚠️ Reply `yes` in this thread to merge PR #{num} (or `no` to cancel).", root)
|
||||
else:
|
||||
await do_reject(rid, num)
|
||||
|
||||
# ---- event callbacks --------------------------------------------------------------------
|
||||
async def on_message(room: MatrixRoom, event: RoomMessageText):
|
||||
if event.sender == user_id:
|
||||
return
|
||||
rid = room.room_id
|
||||
if rid in rooms:
|
||||
await handle_command(rid, event)
|
||||
elif rid in pending_setup:
|
||||
await finish_setup(rid, message_text(event))
|
||||
else:
|
||||
await start_setup(rid, room.display_name)
|
||||
|
||||
async def on_invite(room: MatrixRoom, event: InviteMemberEvent):
|
||||
if event.state_key != user_id:
|
||||
return
|
||||
rid = room.room_id
|
||||
resp = await client.join(rid)
|
||||
if not getattr(resp, "room_id", None):
|
||||
print(f"FAILED to auto-join {rid}: {resp}", flush=True)
|
||||
return
|
||||
print(f"auto-joined {rid} (invited by {event.sender})", flush=True)
|
||||
if rid not in rooms:
|
||||
await start_setup(rid, room.display_name)
|
||||
|
||||
# ---- Gitea poll loop over all mapped repos ----------------------------------------------
|
||||
async def poll_loop():
|
||||
print(f"gitea-review-bot: polling {len(rooms)} mapped repo(s) every {poll_interval}s", flush=True)
|
||||
while True:
|
||||
for rid in list(rooms.keys()):
|
||||
room = rooms.get(rid)
|
||||
if not room:
|
||||
continue
|
||||
try:
|
||||
status, body = await asyncio.to_thread(
|
||||
lambda: gitea_g(f"/repos/{room['repo']}/pulls?state=open&sort=recentupdate&limit=50"))
|
||||
if status != 200:
|
||||
print(f"{room['label']} list PRs HTTP {status}: {str(body)[:200]}", flush=True)
|
||||
continue
|
||||
open_prs = json.loads(body)
|
||||
open_nums = {str(p["number"]) for p in open_prs}
|
||||
for pr in open_prs:
|
||||
num = str(pr["number"])
|
||||
head = (pr.get("head") or {}).get("sha", "")
|
||||
if room["heads"].get(num) == head:
|
||||
continue
|
||||
if await review_pr(rid, pr):
|
||||
room["heads"][num] = head
|
||||
save_state(rooms_path, rooms)
|
||||
stale = [n for n in room["heads"] if n not in open_nums]
|
||||
if stale:
|
||||
for n in stale:
|
||||
room["heads"].pop(n, None)
|
||||
room.get("threads", {}).pop(n, None)
|
||||
save_state(rooms_path, rooms)
|
||||
except Exception as e:
|
||||
print(f"poll error ({room.get('label')}): {e!r}", flush=True)
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
client.add_event_callback(on_invite, InviteMemberEvent)
|
||||
print("priming sync (skipping backlog)...", flush=True)
|
||||
await client.sync(timeout=30000, full_state=False)
|
||||
client.add_event_callback(on_message, RoomMessageText)
|
||||
who = await client.whoami()
|
||||
print(f"listening as {who.user_id}; {len(rooms)} mapped review room(s)", flush=True)
|
||||
poll_task = asyncio.create_task(poll_loop())
|
||||
try:
|
||||
await client.sync_forever(timeout=30000)
|
||||
finally:
|
||||
poll_task.cancel()
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user