Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion stacklets/core/bot-runner/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 10 additions & 6 deletions stacklets/core/bot-runner/microbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions stacklets/messages/cli/_admins.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions stacklets/messages/cli/_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions stacklets/messages/cli/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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:
Expand Down
105 changes: 60 additions & 45 deletions stacklets/messages/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <password> -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):
Expand All @@ -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 <password> -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"})

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions stacklets/messages/hooks/on_start_ready.py
Original file line number Diff line number Diff line change
@@ -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']}")
12 changes: 12 additions & 0 deletions tests/unit/stacklets/test_archivist_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading