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>
77 lines
3.6 KiB
Python
77 lines
3.6 KiB
Python
"""bots and webhooks
|
|
|
|
Revision ID: 3350c67553ad
|
|
Revises: 6ee71c8d5e07
|
|
Create Date: 2026-08-14 07:47:25.761745
|
|
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = '3350c67553ad'
|
|
down_revision: Union[str, Sequence[str], None] = '6ee71c8d5e07'
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
"""Upgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.create_table('api_tokens',
|
|
sa.Column('id', sa.Uuid(), nullable=False),
|
|
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
|
sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
|
sa.PrimaryKeyConstraint('id')
|
|
)
|
|
op.create_index(op.f('ix_api_tokens_owner_id'), 'api_tokens', ['owner_id'], unique=False)
|
|
op.create_index(op.f('ix_api_tokens_token_hash'), 'api_tokens', ['token_hash'], unique=True)
|
|
op.create_table('event_subscriptions',
|
|
sa.Column('id', sa.Uuid(), nullable=False),
|
|
sa.Column('room_id', sa.Uuid(), nullable=True),
|
|
sa.Column('event_types', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column('target_url', sa.String(length=2048), nullable=False),
|
|
sa.Column('signing_secret', sa.String(length=64), nullable=False),
|
|
sa.Column('created_by', sa.Uuid(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
|
sa.PrimaryKeyConstraint('id')
|
|
)
|
|
op.create_index(op.f('ix_event_subscriptions_room_id'), 'event_subscriptions', ['room_id'], unique=False)
|
|
op.create_table('webhooks_incoming',
|
|
sa.Column('id', sa.Uuid(), nullable=False),
|
|
sa.Column('room_id', sa.Uuid(), nullable=False),
|
|
sa.Column('token', sa.String(length=64), nullable=False),
|
|
sa.Column('created_by', sa.Uuid(), nullable=False),
|
|
sa.Column('description', sa.String(length=500), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
|
sa.PrimaryKeyConstraint('id')
|
|
)
|
|
op.create_index(op.f('ix_webhooks_incoming_room_id'), 'webhooks_incoming', ['room_id'], unique=False)
|
|
op.create_index(op.f('ix_webhooks_incoming_token'), 'webhooks_incoming', ['token'], unique=True)
|
|
# ### end Alembic commands ###
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""Downgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.drop_index(op.f('ix_webhooks_incoming_token'), table_name='webhooks_incoming')
|
|
op.drop_index(op.f('ix_webhooks_incoming_room_id'), table_name='webhooks_incoming')
|
|
op.drop_table('webhooks_incoming')
|
|
op.drop_index(op.f('ix_event_subscriptions_room_id'), table_name='event_subscriptions')
|
|
op.drop_table('event_subscriptions')
|
|
op.drop_index(op.f('ix_api_tokens_token_hash'), table_name='api_tokens')
|
|
op.drop_index(op.f('ix_api_tokens_owner_id'), table_name='api_tokens')
|
|
op.drop_table('api_tokens')
|
|
# ### end Alembic commands ###
|