diff --git a/stacklets/core/bot-runner/accounts.py b/stacklets/core/bot-runner/accounts.py index 4ece26d0..ca4a7e33 100644 --- a/stacklets/core/bot-runner/accounts.py +++ b/stacklets/core/bot-runner/accounts.py @@ -135,10 +135,27 @@ def ensure_rooms(bots, homeserver, server_name, admin_user, admin_password, if not room_id: continue - # Join the bot and all admin-role family members + # Join the bot and all admin-role family members, and make those + # admins of the room: the tech admin created it and is otherwise + # the only account with power there. _join_user(base, token, room_id, f"@{bot_id}:{server_name}") for uid in (admin_user_ids or []): _join_user(base, token, room_id, f"@{uid}:{server_name}") + _ensure_admin_power(base, token, room_id, f"@{uid}:{server_name}") + + +def _ensure_admin_power(base, token, room_id, full_user): + """Give a user power level 100 in a room, writing only when it differs. + The same rule `stack messages setup --room-admins` applies to every family room.""" + path = f"{base}/_matrix/client/v3/rooms/{room_id}/state/m.room.power_levels/" + status, body = _api("GET", path, token=token) + levels: dict = dict(body) if status == 200 else {} + users: dict = dict(levels.get("users") or {}) + if status != 200 or users.get(full_user) == 100: + return + status, resp = _api("PUT", path, {**levels, "users": {**users, full_user: 100}}, token=token) + if status != 200: + logger.warning("Could not make {} an admin of {}: {}", full_user, room_id, resp.get("error", "?")) def _resolve_room(base, token, server_name, alias): diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index b76d3550..5d7bae64 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -76,6 +76,7 @@ import voice from room_context import RoomContext, context_for from stack.ai.client import LLM, LLMError, LLMUnavailableError, Transcriber +from stack.users import TECH_ADMIN_USERNAME # The framework's "I picked this up and I'm working on it" signal. A # bot reacts with 👀 on the source message the moment it starts a @@ -832,15 +833,18 @@ def is_handoff(cls, event) -> bool: @staticmethod def is_bot_user(user_id: str) -> bool: - """Whether a Matrix user is a famstack bot, by convention. + """Whether a Matrix user is one of the stack's own accounts, not a person. Bot accounts have a localpart ending in ``-bot`` (mail-bot, - archivist-bot, scribe-bot, …). The framework owns this one - definition so every surface agrees on it — counting the humans in - a room (scope/visibility), ignoring bot-to-bot chatter, deciding - on-behalf-of attribution. A non-bot string is simply not a bot. + archivist-bot, scribe-bot, …). The tech admin is the other one: it + creates every room and has to stay joined, because Synapse's admin + join only works in rooms it is in, but nobody talks as it. The + framework owns this one definition so every surface agrees on it: + counting the humans in a room (scope/visibility), ignoring + bot-to-bot chatter, deciding on-behalf-of attribution. """ - return (user_id or "").split(":")[0].lstrip("@").endswith("-bot") + localpart = (user_id or "").split(":")[0].lstrip("@") + return localpart.endswith("-bot") or localpart == TECH_ADMIN_USERNAME async def post_source_message( self, diff --git a/stacklets/messages/cli/_admins.py b/stacklets/messages/cli/_admins.py new file mode 100644 index 00000000..527e910a --- /dev/null +++ b/stacklets/messages/cli/_admins.py @@ -0,0 +1,67 @@ +"""The family's admins are admins in every family room. + +Synapse's server-admin flag grants no power inside a room, and the +`private_chat` preset gives power level 100 only to the account that +created the room, which is the tech admin. So every room the stack +creates needs its admin-role users promoted explicitly: the rooms +`setup` creates, the rooms `room create` makes later, and the bot rooms +the bot-runner creates, and `stack messages setup --room-admins` on every +start. Running it again changes nothing. +""" + +from stack.users import user_id + + +def admin_user_ids(users): + """Localparts of the admin-role users in users.toml.""" + return [user_id(u) for u in users if u.get("role") == "admin"] + + +def family_room_ids(client, space_id): + """The family Space itself and every room it lists that the tech + admin is still in. A deleted room can stay listed in the Space, and + nobody manages a room nobody from the stack is in.""" + if not space_id: + return [] + joined = client.joined_rooms() + return [space_id, *(r for r in client.space_children(space_id) if r in joined)] + + +def ensure_admins(client, room_ids, admins, labels=None): + """Give every admin power level 100 in every room. Returns one result + per change or failure; a room already right says nothing.""" + labels = labels or {} + results = [] + for rid in room_ids: + for uid in admins: + full = client._full_user(uid) + outcome = client.ensure_user_power_level(rid, full, 100) + if outcome == "ok": + continue + room = labels.get(rid) or client.room_name(rid) + results.append({ + "ok": outcome == "set", + "user": uid, + "room": room, + "item": f"{full} in {room}", + "action": "promoted to PL 100" if outcome == "set" + else "could not promote (Synapse refused)", + }) + return results + + +def summary(admins, room_count, results): + """One line per admin, also when nothing changed, so every run says + where each admin stands. `ok` is False when a room refused.""" + lines = [] + for uid in admins: + made = [r["room"] for r in results if r["user"] == uid and r["ok"]] + refused = [r["room"] for r in results if r["user"] == uid and not r["ok"]] + if refused: + lines.append({"ok": False, "line": f"{uid} could not be made admin in: {', '.join(refused)}"}) + elif made: + lines.append({"ok": True, "line": f"{uid} is now admin in all {room_count} family rooms " + f"(made admin in: {', '.join(made)})"}) + else: + lines.append({"ok": True, "line": f"{uid} is already admin in all {room_count} family rooms"}) + return lines diff --git a/stacklets/messages/cli/_matrix.py b/stacklets/messages/cli/_matrix.py index 061a3cc3..ab2d0c5b 100644 --- a/stacklets/messages/cli/_matrix.py +++ b/stacklets/messages/cli/_matrix.py @@ -261,6 +261,26 @@ def add_space_child(self, space_id, room_id): token=self.token, ) + def joined_rooms(self): + """Room ids this account is joined to.""" + status, body = _get(self._url("/_matrix/client/v3/joined_rooms"), token=self.token) + return set(body.get("joined_rooms", [])) if status == 200 else set() + + def room_name(self, room_id): + """The room's display name, or its id when it has none.""" + return (self.get_state(room_id, "m.room.name") or {}).get("name") or room_id + + def space_children(self, space_id): + """Room ids a Space lists as children. A removed child keeps its + `m.space.child` event with empty content, so only non-empty ones count.""" + status, body = _get( + self._url(f"/_matrix/client/v3/rooms/{space_id}/state"), token=self.token, + ) + # This endpoint answers with a list of events, not an object. + events: list = body if status == 200 and isinstance(body, list) else [] + return [e["state_key"] for e in events + if isinstance(e, dict) and e.get("type") == "m.space.child" and e.get("content")] + # ── Room state + power levels ──────────────────────────────────────── # # Synapse's "make me an admin" flag controls server-side operations diff --git a/stacklets/messages/cli/room.py b/stacklets/messages/cli/room.py index 98d97293..b183ffff 100644 --- a/stacklets/messages/cli/room.py +++ b/stacklets/messages/cli/room.py @@ -29,6 +29,7 @@ _here = Path(__file__).parent sys.path.insert(0, str(_here)) from _matrix import MatrixClient, _get, _api +from _admins import admin_user_ids, ensure_admins # ── Authentication ────────────────────────────────────────────────────────── @@ -120,7 +121,7 @@ def _cmd_list(client, base_url, argv): print() -def _cmd_create(client, base_url, server_name, argv): +def _cmd_create(client, base_url, server_name, argv, users=()): """Create a room and add it to the family Space. The room is created as a private room with shared history visibility, @@ -151,6 +152,11 @@ def _cmd_create(client, base_url, server_name, argv): client.add_space_child(space_id, room_id) print(" Added to family Space") + # The creating account is the tech admin; the family's admins would + # otherwise have no rights in the new room. + for r in ensure_admins(client, [room_id], admin_user_ids(users), {room_id: f"#{alias}"}): + print(f" {r['item']}: {r['action']}") + print() @@ -226,7 +232,7 @@ def run(args, stacklet, config): if subcmd == "list": return _cmd_list(client, base_url, rest) elif subcmd == "create": - return _cmd_create(client, base_url, server_name, rest) + return _cmd_create(client, base_url, server_name, rest, config.get("users", [])) elif subcmd == "delete": return _cmd_delete(client, base_url, server_name, rest) else: diff --git a/stacklets/messages/cli/setup.py b/stacklets/messages/cli/setup.py index bd1ff4f2..02e9debd 100644 --- a/stacklets/messages/cli/setup.py +++ b/stacklets/messages/cli/setup.py @@ -7,6 +7,12 @@ Idempotent — safe to run again. Existing users, rooms, and spaces are skipped. New users.toml entries get accounts and join existing rooms. + + stack messages setup --room-admins + +only makes the admin-role users admins of the family Space and every room +in it. `stack up messages` runs it on every start, so rooms created after +install and installs from before this rule are brought up to date. """ HELP = "Bootstrap admin, rooms, and family accounts" @@ -20,6 +26,35 @@ from _matrix import MatrixClient sys.path.insert(0, str(_here.parent.parent.parent / "lib")) from stack.users import user_id, get_admin_password, get_user_password, TECH_ADMIN_USERNAME +from _admins import admin_user_ids, ensure_admins, family_room_ids, summary + + +def _login_tech_admin(client, secrets): + """Log the client in as the tech admin. Returns an error dict, or None.""" + admin_pass = get_admin_password(secrets) + if not admin_pass: + return {"error": "Admin password not found in secrets. Run './stack install' or set global__ADMIN_PASSWORD in .stack/secrets.toml."} + + if not client.login(TECH_ADMIN_USERNAME, admin_pass): + return { + "error": "Can't log in to Messages as tech admin.", + "hint": f"docker exec -it stack-messages-synapse register_new_matrix_user -u {TECH_ADMIN_USERNAME} -p -a -c /data/homeserver.yaml http://localhost:8008", + } + return None + + +def _room_admins(client, users, secrets): + """Only the room-admin rule: every admin-role user at power level 100 + in the family Space and every room in it.""" + if failed := _login_tech_admin(client, secrets): + return failed + space_id = client.resolve_room("family") + if not space_id: + return {"error": "No family Space found. Run 'stack messages setup'."} + rooms = family_room_ids(client, space_id) + admins = admin_user_ids(users) + results = ensure_admins(client, rooms, admins) + return {"ok": True, "results": results, "summary": summary(admins, len(rooms), results)} def _setup(client, users, config, secrets=None): @@ -32,15 +67,8 @@ def _setup(client, users, config, secrets=None): # The tech admin (stackadmin) is created by on_install_success via # register_new_matrix_user. Here we just log in to get a session. - admin_pass = get_admin_password(secrets) - if not admin_pass: - return {"error": "Admin password not found in secrets. Run './stack install' or set global__ADMIN_PASSWORD in .stack/secrets.toml."} - - if not client.login(TECH_ADMIN_USERNAME, admin_pass): - return { - "error": "Can't log in to Messages as tech admin.", - "hint": f"docker exec -it stack-messages-synapse register_new_matrix_user -u {TECH_ADMIN_USERNAME} -p -a -c /data/homeserver.yaml http://localhost:8008", - } + if failed := _login_tech_admin(client, secrets): + return failed results.append({"item": f"@{TECH_ADMIN_USERNAME}:{server_name}", "action": "tech admin"}) @@ -161,45 +189,22 @@ def _setup(client, users, config, secrets=None): join_rooms = all_rooms if is_admin_role else everyone_rooms for rid in join_rooms: client.join_user(rid, uid) - - # ── Per-room power level ───────────────────────────────── - # The Synapse "admin" flag passed to createUser is - # server-side only -- it does NOT grant in-room PL. - # Family admins from users.toml need an explicit PL bump - # in the space and every seeded room so they can manage - # settings, ban/kick, and write state events without - # someone having to elevate them manually in Element. - # Idempotent: ensure_user_power_level reads first, only - # writes when the target differs. Non-admin members keep - # the default PL 0 (the private_chat preset). A future - # `role = "moderator"` tier would land as another branch - # here -- not introduced now because the household-policy - # decision about kid PL hasn't been made. - if is_admin_role: - full_uid = client._full_user(uid) - pl_targets = [] - if space_id: - pl_targets.append((space_id, space_name)) - for alias, rid in room_ids.items(): - pl_targets.append((rid, f"#{alias}")) - for room_id_target, label_target in pl_targets: - outcome = client.ensure_user_power_level( - room_id_target, full_uid, 100, - ) - if outcome == "set": - results.append({ - "item": f"{full_uid} in {label_target}", - "action": "promoted to PL 100", - }) - elif outcome == "failed": - results.append({ - "item": f"{full_uid} in {label_target}", - "action": "could not promote (Synapse refused)", - }) - # "ok" stays silent -- already at PL 100, no news. else: results.append({"item": uid, "action": "failed to create"}) + # ── Step 4b: the family's admins are admins in every family room ──── + # For every admin-role user, not only the ones created in this run, + # and in every room of the space, not only the ones created above: + # re-running setup brings an older install up to date. Members keep + # the preset's power level 0. + labels = {rid: f"#{alias}" for alias, rid in room_ids.items()} + if space_id: + labels[space_id] = space_name + results += ensure_admins( + client, family_room_ids(client, space_id) or list(room_ids.values()), + admin_user_ids(users), labels, + ) + # ── Step 5: create stacker-bot and post welcome messages ─────────── BOT_NAME = "stacker-bot" @@ -389,6 +394,16 @@ def run(args, stacklet, config): return {"error": "No users found. Add family members to users.toml."} secrets = config.get("secrets", {}) + if "--room-admins" in (args or []): + result = _room_admins(client, users, secrets) + if result.get("ok"): + print("\n Room admins:\n", file=sys.stderr) + for s in result["summary"]: + print(f" {'✓' if s['ok'] else '✗'} {s['line']}", file=sys.stderr) + print(file=sys.stderr) + else: + print(_pretty(result), file=sys.stderr) + return result result = _setup(client, users, {"space_name": "Family"}, secrets) # Setup is a one-shot operator command; print the result regardless diff --git a/stacklets/messages/hooks/on_start_ready.py b/stacklets/messages/hooks/on_start_ready.py new file mode 100644 index 00000000..b644a927 --- /dev/null +++ b/stacklets/messages/hooks/on_start_ready.py @@ -0,0 +1,14 @@ +"""Every `stack up messages`: the family's admins are admins in every family room. + +Rooms appear after install (a stacklet's bot room, `stack messages room +create`), and an install from before this rule has rooms where only the +tech admin holds power. Reconciling on every start covers both. A failure +is reported and does not stop the stack. +""" + + +def run(ctx): + # `setup --room-admins` prints one line per admin itself. + result = ctx.stack.run_cli_command("messages", "setup", ["--room-admins"]) or {} + if result.get("error"): + ctx.warn(f"Could not check the family admins' room rights: {result['error']}") diff --git a/tests/unit/stacklets/test_archivist_source.py b/tests/unit/stacklets/test_archivist_source.py index 7eb4361d..79d22571 100644 --- a/tests/unit/stacklets/test_archivist_source.py +++ b/tests/unit/stacklets/test_archivist_source.py @@ -136,6 +136,18 @@ def test_human_members_excludes_all_bots(tmp_path): assert bot._count_humans_in_room(room) == 1 # not 2 — bots don't count +def test_a_room_made_by_the_stack_is_personal_with_one_person_in_it(tmp_path): + # `stack messages room create` leaves the tech admin in the room. Lisa + # alone with the archivist is still Lisa's room: her pasted notes are + # filed without a 📌 and under her name. + bot = _bot(tmp_path) + room = _room_with({ + "@lisa:server": 1, "@archivist-bot:server": 1, "@stackadmin:server": 1, + }) + assert bot._human_members(room) == ["@lisa:server"] + assert bot._scope_owner_localpart(room, "@lisa:server", "personal") == "lisa" + + def test_scope_owner_is_sole_human_not_bot_sender(tmp_path): # The bot-sender fix: a personal topic from a bot-posted email nests # under the room's lone human, not under @mail-bot. diff --git a/tests/unit/stacklets/test_messages_admins.py b/tests/unit/stacklets/test_messages_admins.py new file mode 100644 index 00000000..a5191755 --- /dev/null +++ b/tests/unit/stacklets/test_messages_admins.py @@ -0,0 +1,182 @@ +"""The family's admins are admins in every family room. + +Synapse's server-admin flag grants nothing inside a room, and a room's +creator, the tech admin, is its only admin unless someone is promoted. +Every room the stack creates therefore promotes the admin-role users +from users.toml: the installer's rooms, rooms made later with +`stack messages room create`, and the bot rooms the bot-runner creates. +`stack messages setup --room-admins` applies the rule to the whole family Space, on +every `stack up messages`, which is what corrects an existing install. + +Synapse is replaced at its HTTP boundary by pytest-httpserver; the +client code under test is the real one. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) +sys.path.insert(0, str(_REPO_ROOT / "lib")) + +from _admins import admin_user_ids, ensure_admins, family_room_ids # noqa: E402 +from _matrix import MatrixClient # noqa: E402 + +SPACE, DOCS, PICNIC, GONE = "!space:simpson", "!docs:simpson", "!picnic:simpson", "!gone:simpson" +USERS = [ + {"id": "homer", "role": "admin"}, + {"id": "marge", "role": "member"}, + {"id": "lisa", "role": "member"}, +] + + +def _levels(**users): + return {"users": {f"@{k}:simpson": v for k, v in users.items()}, + "users_default": 0, "state_default": 50, "events": {"m.room.name": 50}} + + +class Synapse: + """Serves room power levels and the Space's children, records writes.""" + + def __init__(self, httpserver, rooms): + self.writes: dict[str, dict] = {} + space_state = [ + {"type": "m.space.child", "state_key": DOCS, "content": {"via": ["simpson"]}}, + {"type": "m.space.child", "state_key": PICNIC, "content": {"via": ["simpson"]}}, + # A child removed from the Space keeps its event, emptied. + {"type": "m.space.child", "state_key": GONE, "content": {}}, + {"type": "m.room.name", "state_key": "", "content": {"name": "Family"}}, + ] + httpserver.expect_request(f"/_matrix/client/v3/rooms/{SPACE}/state", method="GET") \ + .respond_with_json(space_state) + # The tech admin left GONE when it was deleted; it is still listed. + httpserver.expect_request("/_matrix/client/v3/joined_rooms") \ + .respond_with_json({"joined_rooms": [SPACE, DOCS, PICNIC]}) + httpserver.expect_request(f"/_matrix/client/v3/rooms/{DOCS}/state/m.room.name/") \ + .respond_with_json({"name": "Documents Room"}) + for rid, levels in rooms.items(): + path = f"/_matrix/client/v3/rooms/{rid}/state/m.room.power_levels/" + httpserver.expect_request(path, method="GET").respond_with_json(levels) + httpserver.expect_request(path.rstrip("/"), method="PUT") \ + .respond_with_handler(self._recorder(rid)) + httpserver.expect_request(path, method="PUT").respond_with_handler(self._recorder(rid)) + + def _recorder(self, rid): + from werkzeug import Response + + def handle(request): + self.writes[rid] = json.loads(request.data) + return Response(json.dumps({"event_id": "$x"}), content_type="application/json") + return handle + + +def _client(httpserver): + client = MatrixClient(httpserver.url_for("").rstrip("/"), "simpson", str(_REPO_ROOT)) + client.token = "t" + return client + + +def test_the_admins_are_the_admin_role_users_from_users_toml(): + assert admin_user_ids(USERS) == ["homer"] + + +def test_family_rooms_are_the_space_and_the_rooms_the_stack_is_still_in(httpserver): + Synapse(httpserver, {}) + assert family_room_ids(_client(httpserver), SPACE) == [SPACE, DOCS, PICNIC] + + +def test_an_admin_missing_from_a_room_is_promoted_and_nothing_else_changes(httpserver): + synapse = Synapse(httpserver, {DOCS: _levels(stackadmin=100, marge=0)}) + + results = ensure_admins(_client(httpserver), [DOCS], ["homer"]) + + written = synapse.writes[DOCS] + assert written["users"] == {"@stackadmin:simpson": 100, "@marge:simpson": 0, "@homer:simpson": 100} + assert written["events"] == {"m.room.name": 50} and written["state_default"] == 50 + assert results == [{"ok": True, "user": "homer", "room": "Documents Room", + "item": "@homer:simpson in Documents Room", "action": "promoted to PL 100"}] + + +def test_a_room_that_is_already_right_is_not_written(httpserver): + synapse = Synapse(httpserver, {PICNIC: _levels(stackadmin=100, homer=100)}) + + assert ensure_admins(_client(httpserver), [PICNIC], ["homer"]) == [] + assert synapse.writes == {} + + +def test_members_are_left_at_their_level(httpserver): + synapse = Synapse(httpserver, {DOCS: _levels(stackadmin=100)}) + + ensure_admins(_client(httpserver), [DOCS], admin_user_ids(USERS)) + + assert "@marge:simpson" not in synapse.writes[DOCS]["users"] + assert "@lisa:simpson" not in synapse.writes[DOCS]["users"] + + +def test_the_bot_runner_makes_the_admins_admins_of_a_bot_room(httpserver): + """The Documents room is created by the bot-runner on `stack up docs`, + before messages runs again, so the bot-runner applies the same rule + every time it starts.""" + import accounts + synapse = Synapse(httpserver, {DOCS: _levels(stackadmin=100)}) + httpserver.expect_request("/_matrix/client/v3/login", method="POST") \ + .respond_with_json({"access_token": "t"}) + httpserver.expect_request("/_matrix/client/v3/directory/room/#family:simpson") \ + .respond_with_json({"room_id": SPACE}) + httpserver.expect_request("/_matrix/client/v3/directory/room/#documents:simpson") \ + .respond_with_json({"room_id": DOCS}) + httpserver.expect_request(f"/_synapse/admin/v1/join/{DOCS}", method="POST") \ + .respond_with_json({"room_id": DOCS}) + + accounts.ensure_rooms( + [{"id": "archivist-bot", "room": "documents"}], + httpserver.url_for("").rstrip("/"), "simpson", "stackadmin", "pw", ["homer"], + ) + + assert synapse.writes[DOCS]["users"] == {"@stackadmin:simpson": 100, "@homer:simpson": 100} + + +def test_setup_room_admins_brings_every_family_room_up_to_date(httpserver, tmp_path): + """What `stack up messages` runs: only the room-admin rule, across the + Space and every room in it, without the rest of setup.""" + import setup + synapse = Synapse(httpserver, { + SPACE: _levels(stackadmin=100, homer=100), + DOCS: _levels(stackadmin=100), + PICNIC: _levels(stackadmin=100), + }) + httpserver.expect_request("/_matrix/client/v3/login", method="POST") \ + .respond_with_json({"access_token": "t", "device_id": "D", "user_id": "@stackadmin:simpson"}) + httpserver.expect_request("/_matrix/client/v3/directory/room/#family:simpson") \ + .respond_with_json({"room_id": SPACE}) + httpserver.expect_request(f"/_matrix/client/v3/rooms/{PICNIC}/state/m.room.name/") \ + .respond_with_json({"name": "Topic: Powerplant Picnic"}) + config = { + "instance_dir": str(tmp_path), # the login saves its session here + "manifest": {"ports": {"synapse": httpserver.port}}, + "stack": {"messages": {"server_name": "simpson"}}, + "secrets": {"global__ADMIN_PASSWORD": "pw"}, + "users": USERS, + } + + result = setup.run(["--room-admins"], {}, config) + + assert result["ok"] + assert result["summary"] == [{"ok": True, "line": "homer is now admin in all 3 family rooms " + "(made admin in: Documents Room, Topic: Powerplant Picnic)"}] + assert set(synapse.writes) == {DOCS, PICNIC} + assert all(w["users"]["@homer:simpson"] == 100 for w in synapse.writes.values()) + + +def test_every_run_says_where_each_admin_stands(): + """Also when nothing changed: the admin reads a line, not silence.""" + from _admins import summary + assert summary(["homer"], 6, []) == [ + {"ok": True, "line": "homer is already admin in all 6 family rooms"}] + refused = [{"ok": False, "user": "homer", "room": "Old Room", "item": "x", "action": "y"}] + assert summary(["homer"], 6, refused) == [ + {"ok": False, "line": "homer could not be made admin in: Old Room"}] diff --git a/tests/unit/stacklets/test_messages_setup.py b/tests/unit/stacklets/test_messages_setup.py index b251109e..a9f2aba7 100644 --- a/tests/unit/stacklets/test_messages_setup.py +++ b/tests/unit/stacklets/test_messages_setup.py @@ -63,6 +63,12 @@ def open_space_to_members(self, space_id): def add_space_child(self, space_id, child_id): return True + def space_children(self, space_id): + return [] + + def joined_rooms(self): + return set() + def send(self, room_alias, plain, html=None): # Same shape as the real client: (ok, detail). Setup reports the # welcome message's outcome, so a fake that returns None here diff --git a/tests/unit/stacklets/test_microbot_source.py b/tests/unit/stacklets/test_microbot_source.py index ffddafd0..62b105a6 100644 --- a/tests/unit/stacklets/test_microbot_source.py +++ b/tests/unit/stacklets/test_microbot_source.py @@ -112,6 +112,12 @@ def test_bot_accounts(self): assert MicroBot.is_bot_user("@archivist-bot:simpson") assert MicroBot.is_bot_user("scribe-bot") # bare localpart + def test_the_tech_admin_is_not_a_person(self): + # It stays joined to every room it created (Synapse's admin join + # needs that), so counting it as a person would make every + # one-person room look shared. + assert MicroBot.is_bot_user("@stackadmin:simpson") + def test_humans(self): assert not MicroBot.is_bot_user("@homer:simpson") assert not MicroBot.is_bot_user("@marge:simpson")