Private
Public Access
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages, write:messages, manage:rooms) authenticated via Authorization: Bearer on both REST and the WS handshake, live bot WebSocket access on the same /ws/chat endpoint humans use, message editing (WS "edit" envelope -> message_update broadcast, fans out cross-instance for free via the existing broadcaster), incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery, creation-time SSRF validation against private/loopback/link-local targets). Token auth is additive, not a parallel system: a bearer-token-authenticated bot goes through the exact same room-membership/role checks a session- authenticated human does everywhere; only read:messages/write:messages are separately scope-gated (the two message endpoints). manage:rooms scope enforcement, full per-delivery SSRF re-validation, and bot API rate limiting were explicitly scoped out (confirmed with the repo owner) as disproportionate to this phase -- documented as known gaps in backend/README.md rather than silently skipped. Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens, cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/ subscription management, mirroring how invites already work there. The chat UI also gets a minimal "edit your own message" affordance -- not asked for by the issue, but the only practical way to exercise the edit pipeline by hand instead of only via a scripted bot client. Along the way: fixed a real bug caught while writing the incoming-webhook test -- offline-push notification relied on the sender being "connected" to exclude themselves, true for WS-originated messages but not for the new webhook path, which has no WS connection for the attributed sender at all. Now explicitly excluded. Also discovered the REST-only test fixture never triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for it; moved their construction out of the lifespan into create_app() itself (Redis client construction is synchronous/lazy) so both the WS and REST-only paths always have them. New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite now 78/78, stable across repeated runs) plus a scripted end-to-end smoke test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing delivery) and a full browser walkthrough of the new admin/room UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
154 lines
4.8 KiB
Python
154 lines
4.8 KiB
Python
import asyncio
|
|
import uuid
|
|
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.models import EventSubscription, Message, Room, User, WebhookIncoming
|
|
from app.security import generate_token
|
|
from app.services.message_service import create_message
|
|
from app.services.ssrf import validate_target_url
|
|
from app.services.webhook_delivery import deliver_event
|
|
|
|
VALID_EVENT_TYPES = {"message.created", "message.updated"}
|
|
|
|
|
|
class WebhookNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class InvalidEventTypeError(Exception):
|
|
pass
|
|
|
|
|
|
class SubscriptionNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
async def create_incoming_webhook(
|
|
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
|
|
) -> WebhookIncoming:
|
|
webhook = WebhookIncoming(
|
|
room_id=room_id, token=generate_token(), created_by=actor.id, description=description
|
|
)
|
|
db.add(webhook)
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
# A token collision is astronomically unlikely (256 bits of
|
|
# randomness) -- surface it rather than silently masking it.
|
|
await db.rollback()
|
|
raise
|
|
await db.refresh(webhook)
|
|
return webhook
|
|
|
|
|
|
async def list_incoming_webhooks(db: AsyncSession, room_id: uuid.UUID) -> list[WebhookIncoming]:
|
|
result = await db.execute(
|
|
select(WebhookIncoming)
|
|
.where(WebhookIncoming.room_id == room_id)
|
|
.order_by(WebhookIncoming.created_at)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def revoke_incoming_webhook(
|
|
db: AsyncSession, room_id: uuid.UUID, webhook_id: uuid.UUID
|
|
) -> None:
|
|
webhook = await db.get(WebhookIncoming, webhook_id)
|
|
if webhook is None or webhook.room_id != room_id:
|
|
raise WebhookNotFoundError()
|
|
await db.delete(webhook)
|
|
await db.commit()
|
|
|
|
|
|
async def list_all_incoming_webhooks_admin(db: AsyncSession) -> list[WebhookIncoming]:
|
|
result = await db.execute(
|
|
select(WebhookIncoming)
|
|
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
|
.order_by(WebhookIncoming.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[Message, Room, User]:
|
|
result = await db.execute(
|
|
select(WebhookIncoming)
|
|
.where(WebhookIncoming.token == token)
|
|
.options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
|
|
)
|
|
webhook = result.scalar_one_or_none()
|
|
if webhook is None:
|
|
raise WebhookNotFoundError()
|
|
|
|
message = await create_message(db, webhook.room_id, webhook.created_by, content)
|
|
return message, webhook.room, webhook.creator
|
|
|
|
|
|
async def create_event_subscription(
|
|
db: AsyncSession,
|
|
actor: User,
|
|
room_id: uuid.UUID | None,
|
|
event_types: list[str],
|
|
target_url: str,
|
|
) -> tuple[EventSubscription, str]:
|
|
if not set(event_types) <= VALID_EVENT_TYPES:
|
|
raise InvalidEventTypeError()
|
|
validate_target_url(target_url)
|
|
|
|
secret = generate_token()
|
|
subscription = EventSubscription(
|
|
room_id=room_id,
|
|
event_types=event_types,
|
|
target_url=target_url,
|
|
signing_secret=secret,
|
|
created_by=actor.id,
|
|
)
|
|
db.add(subscription)
|
|
await db.commit()
|
|
await db.refresh(subscription)
|
|
return subscription, secret
|
|
|
|
|
|
async def list_event_subscriptions(db: AsyncSession, room_id: uuid.UUID) -> list[EventSubscription]:
|
|
result = await db.execute(
|
|
select(EventSubscription)
|
|
.where(EventSubscription.room_id == room_id)
|
|
.order_by(EventSubscription.created_at)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def revoke_event_subscription(
|
|
db: AsyncSession, room_id: uuid.UUID, subscription_id: uuid.UUID
|
|
) -> None:
|
|
subscription = await db.get(EventSubscription, subscription_id)
|
|
if subscription is None or subscription.room_id != room_id:
|
|
raise SubscriptionNotFoundError()
|
|
await db.delete(subscription)
|
|
await db.commit()
|
|
|
|
|
|
async def list_all_event_subscriptions_admin(db: AsyncSession) -> list[EventSubscription]:
|
|
result = await db.execute(
|
|
select(EventSubscription)
|
|
.options(selectinload(EventSubscription.room), selectinload(EventSubscription.creator))
|
|
.order_by(EventSubscription.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def dispatch_event(
|
|
db: AsyncSession, event_type: str, room_id: uuid.UUID, payload: dict
|
|
) -> None:
|
|
result = await db.execute(
|
|
select(EventSubscription).where(
|
|
or_(EventSubscription.room_id == room_id, EventSubscription.room_id.is_(None))
|
|
)
|
|
)
|
|
for subscription in result.scalars().all():
|
|
if event_type in subscription.event_types:
|
|
asyncio.create_task(deliver_event(subscription, event_type, payload))
|