Private
Public Access
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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ from app.services.message_service import (
|
|||||||
list_recent_messages,
|
list_recent_messages,
|
||||||
list_room_attachments,
|
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.upload_settings_service import format_mb, get_upload_settings
|
||||||
from app.services.room_service import (
|
from app.services.room_service import (
|
||||||
AlreadyMemberError,
|
AlreadyMemberError,
|
||||||
@@ -662,16 +663,18 @@ async def add_member_endpoint(
|
|||||||
# welcome message below would otherwise trigger out of order.
|
# welcome message below would otherwise trigger out of order.
|
||||||
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
|
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
|
# #74: posted as the auto-provisioned System account, not the admin who
|
||||||
# sender concept -- they're already a real, in-scope user for this
|
# did the adding -- "Welcome, bob!" reads as coming from the room/app
|
||||||
# request, and every Message row requires a real user_id today.
|
# 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_name = membership.user.display_name or membership.user.username
|
||||||
welcome_message = await create_message(
|
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
|
# 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
|
# own live-message path -- the admin is the one who caused this message,
|
||||||
# this room as unread from a message they effectively just sent.
|
# 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 mark_room_read(db, room.id, current_user.id)
|
||||||
await broadcast_new_message(
|
await broadcast_new_message(
|
||||||
db,
|
db,
|
||||||
@@ -682,7 +685,7 @@ async def add_member_endpoint(
|
|||||||
str(request.base_url),
|
str(request.base_url),
|
||||||
room.id,
|
room.id,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
current_user,
|
system_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
async def test_add_member_posts_welcome_message(client, db_session, monkeypatch):
|
||||||
# #72: attributed to the admin who added them, since there's no
|
# #74: posted as the auto-provisioned "system" account, not the admin
|
||||||
# system/bot sender concept -- mirrors test_add_member_directly's setup.
|
# who added them -- mirrors test_add_member_directly's setup.
|
||||||
_fake_send_email(monkeypatch)
|
_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"]
|
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||||
|
|
||||||
await client.post("/api/auth/logout")
|
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
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
history = (await client.get(f"/api/rooms/{room_id}/messages")).json()
|
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 len(welcome_messages) == 1
|
||||||
assert welcome_messages[0]["content"] == "Welcome to #general, bob!"
|
assert welcome_messages[0]["content"] == "Welcome to #general, bob!"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user