From 30e63ffa839b596ff8ab59a8006b9f9e636e6f61 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Thu, 3 Sep 2026 20:56:10 -0600 Subject: [PATCH] Add an auto-provisioned system account for automated messages (#74) The #72 welcome message was attributed to whichever admin added the member, since there was no system/bot sender concept -- every Message row requires a real user_id. Adds a lazily-created "system" bot account (reusing the existing is_bot infrastructure) and switches the welcome message to post as it instead. Excluded from the People directory and @mention autocomplete for free: the directory already filters is_bot users, and mention suggestions are sourced from room membership, which the system account is never added to. Co-Authored-By: Claude Sonnet 5 --- backend/app/routers/rooms.py | 17 ++++---- backend/app/services/system_user_service.py | 45 +++++++++++++++++++++ backend/tests/test_rooms.py | 8 ++-- backend/tests/test_system_user.py | 13 ++++++ 4 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 backend/app/services/system_user_service.py create mode 100644 backend/tests/test_system_user.py diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 50aa069..c8b79de 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -46,6 +46,7 @@ from app.services.message_service import ( list_recent_messages, list_room_attachments, ) +from app.services.system_user_service import get_or_create_system_user from app.services.upload_settings_service import format_mb, get_upload_settings from app.services.room_service import ( AlreadyMemberError, @@ -662,16 +663,18 @@ async def add_member_endpoint( # welcome message below would otherwise trigger out of order. await broadcast_room_added(request.app.state.broadcaster, data.user_id, room) - # #72: attributed to the admin doing the adding, not a new system/bot - # sender concept -- they're already a real, in-scope user for this - # request, and every Message row requires a real user_id today. + # #74: posted as the auto-provisioned System account, not the admin who + # did the adding -- "Welcome, bob!" reads as coming from the room/app + # itself, not as something the admin personally typed. + system_user = await get_or_create_system_user(db) welcome_name = membership.user.display_name or membership.user.username welcome_message = await create_message( - db, room.id, current_user.id, f"Welcome to #{room.name}, {welcome_name}!" + db, room.id, system_user.id, f"Welcome to #{room.name}, {welcome_name}!" ) # Same "sending implies having seen the room" reasoning as ws/chat.py's - # own live-message path -- without it, the admin's own client would show - # this room as unread from a message they effectively just sent. + # own live-message path -- the admin is the one who caused this message, + # and is presumably already looking at this room's member management, so + # without this their own client would show it as unread regardless. await mark_room_read(db, room.id, current_user.id) await broadcast_new_message( db, @@ -682,7 +685,7 @@ async def add_member_endpoint( str(request.base_url), room.id, welcome_message, - current_user, + system_user, ) online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id]) diff --git a/backend/app/services/system_user_service.py b/backend/app/services/system_user_service.py new file mode 100644 index 0000000..9cc4e6e --- /dev/null +++ b/backend/app/services/system_user_service.py @@ -0,0 +1,45 @@ +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import User +from app.security import generate_token, hash_password + +# #74: one well-known, auto-provisioned bot account the app itself posts as +# for automated first-party messages (the #72 welcome message, and whatever +# comes next) -- distinct from bot_service.py's admin-created integration +# bots, which each need a human actor and audit-log entry for creating them. +# There's no actor here: this account is provisioned lazily, the first time +# something needs to post as it. +SYSTEM_USERNAME = "system" + + +async def get_or_create_system_user(db: AsyncSession) -> User: + result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME)) + user = result.scalar_one_or_none() + if user is not None: + return user + + # Same placeholder-email/discarded-password shape as bot_service.create_bot + # -- this account never logs in, email just satisfies the NOT NULL/unique + # column. + user = User( + username=SYSTEM_USERNAME, + email=f"{SYSTEM_USERNAME}@bots.example.com", + password_hash=hash_password(generate_token()), + is_bot=True, + ) + db.add(user) + try: + await db.flush() + except IntegrityError: + # Two concurrent requests both found no existing row and raced to + # create one -- the loser just reads back the winner's row instead + # of erroring. + await db.rollback() + result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME)) + return result.scalar_one() + + await db.commit() + await db.refresh(user) + return user diff --git a/backend/tests/test_rooms.py b/backend/tests/test_rooms.py index e7fa6c8..f350925 100644 --- a/backend/tests/test_rooms.py +++ b/backend/tests/test_rooms.py @@ -485,10 +485,10 @@ async def test_add_member_directly(client, db_session, monkeypatch): async def test_add_member_posts_welcome_message(client, db_session, monkeypatch): - # #72: attributed to the admin who added them, since there's no - # system/bot sender concept -- mirrors test_add_member_directly's setup. + # #74: posted as the auto-provisioned "system" account, not the admin + # who added them -- mirrors test_add_member_directly's setup. _fake_send_email(monkeypatch) - alice = await register_and_login(client, db_session, username="alice") + await register_and_login(client, db_session, username="alice") room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] await client.post("/api/auth/logout") @@ -500,7 +500,7 @@ async def test_add_member_posts_welcome_message(client, db_session, monkeypatch) assert resp.status_code == 201, resp.text history = (await client.get(f"/api/rooms/{room_id}/messages")).json() - welcome_messages = [m for m in history if m["username"] == "alice"] + welcome_messages = [m for m in history if m["username"] == "system"] assert len(welcome_messages) == 1 assert welcome_messages[0]["content"] == "Welcome to #general, bob!" diff --git a/backend/tests/test_system_user.py b/backend/tests/test_system_user.py new file mode 100644 index 0000000..38155e2 --- /dev/null +++ b/backend/tests/test_system_user.py @@ -0,0 +1,13 @@ +from app.services.system_user_service import SYSTEM_USERNAME, get_or_create_system_user + + +async def test_get_or_create_system_user_creates_bot_account(db_session): + user = await get_or_create_system_user(db_session) + assert user.username == SYSTEM_USERNAME + assert user.is_bot is True + + +async def test_get_or_create_system_user_is_idempotent(db_session): + first = await get_or_create_system_user(db_session) + second = await get_or_create_system_user(db_session) + assert first.id == second.id