refactor(gateway): rename persistence models to external chat

This commit is contained in:
Anish Sarkar 2026-05-28 04:37:27 +05:30
parent f2d82234d4
commit a57b741d5e
8 changed files with 274 additions and 323 deletions

View file

@ -1,20 +1,21 @@
"""add gateway tables for Telegram messaging gateway """add external chat surface tables
Revision ID: 144 Revision ID: 144
Revises: 143 Revises: 143
Create Date: 2026-05-27 Create Date: 2026-05-27
Adds the lean v6 gateway schema: Adds the lean external chat surface schema:
* gateway_platform_accounts * external_chat_accounts
* gateway_conversation_bindings * external_chat_bindings
* gateway_inbound_events * external_chat_inbound_events
The gateway stores Telegram-originated conversations in the existing chat External chat surfaces store Telegram-originated conversations in the existing
tables but keeps them out of UI replication. This migration adds ``source`` to chat tables. This migration adds ``source`` to ``new_chat_threads`` and
``new_chat_messages`` as a denormalized Zero publication boundary and publishes ``new_chat_messages`` as UI metadata while publishing all chat-message sources
only ``source = 'web'`` rows. Gateway control-plane tables are served through through Zero so a future SurfSense UI layer can render external chats. External
REST in v1, so they are intentionally not added to ``zero_publication``. chat adapter tables are served through REST in v1, so they are intentionally not
added to ``zero_publication``.
""" """
from __future__ import annotations from __future__ import annotations
@ -146,7 +147,7 @@ def _build_set_table_ddl(
f"documents ({_cols(doc_cols)}), " f"documents ({_cols(doc_cols)}), "
f"folders, " f"folders, "
f"search_source_connectors, " f"search_source_connectors, "
f"new_chat_messages WHERE (source = 'web'), " f"new_chat_messages, "
f"chat_comments, " f"chat_comments, "
f"chat_session_state, " f"chat_session_state, "
f'"user" ({_cols(user_cols)})' f'"user" ({_cols(user_cols)})'
@ -161,53 +162,41 @@ def _create_enum(name: str, values: tuple[str, ...]) -> postgresql.ENUM:
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind() conn = op.get_bind()
gateway_platform_enum = _create_enum( external_chat_platform_enum = _create_enum(
"gateway_platform", ("telegram", "whatsapp", "signal") "external_chat_platform", ("telegram", "whatsapp", "signal")
) )
gateway_account_mode_enum = _create_enum( external_chat_account_mode_enum = _create_enum(
"gateway_account_mode", ("cloud_shared", "self_host_byo") "external_chat_account_mode", ("cloud_shared", "self_host_byo")
) )
gateway_health_status_enum = _create_enum( external_chat_health_status_enum = _create_enum(
"gateway_health_status", ("unknown", "ok", "failing") "external_chat_health_status", ("unknown", "ok", "failing")
) )
gateway_binding_state_enum = _create_enum( external_chat_binding_state_enum = _create_enum(
"gateway_binding_state", ("pending", "bound", "revoked", "suspended") "external_chat_binding_state", ("pending", "bound", "revoked", "suspended")
) )
gateway_peer_kind_enum = _create_enum( external_chat_peer_kind_enum = _create_enum(
"gateway_peer_kind", ("direct", "group", "channel", "unknown") "external_chat_peer_kind", ("direct", "group", "channel", "unknown")
) )
gateway_session_scope_enum = _create_enum( external_chat_event_kind_enum = _create_enum(
"gateway_session_scope", "external_chat_event_kind", ("message", "edited_message", "callback_query", "other")
("per_binding", "per_user_search_space", "ephemeral"),
) )
gateway_dm_policy_enum = _create_enum("gateway_dm_policy", ("enabled", "disabled")) external_chat_event_status_enum = _create_enum(
gateway_group_policy_enum = _create_enum( "external_chat_event_status",
"gateway_group_policy", ("disabled", "allowlist", "mention_required")
)
gateway_event_kind_enum = _create_enum(
"gateway_event_kind", ("message", "edited_message", "callback_query", "other")
)
gateway_event_status_enum = _create_enum(
"gateway_event_status",
("received", "processing", "processed", "ignored", "failed"), ("received", "processing", "processed", "ignored", "failed"),
) )
if not _table_exists(conn, "gateway_platform_accounts"): if not _table_exists(conn, "external_chat_accounts"):
op.create_table( op.create_table(
"gateway_platform_accounts", "external_chat_accounts",
sa.Column("id", sa.BigInteger(), primary_key=True), sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("platform", gateway_platform_enum, nullable=False), sa.Column("platform", external_chat_platform_enum, nullable=False),
sa.Column("mode", gateway_account_mode_enum, nullable=False), sa.Column("mode", external_chat_account_mode_enum, nullable=False),
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("owner_search_space_id", sa.Integer(), nullable=True), sa.Column("owner_search_space_id", sa.Integer(), nullable=True),
sa.Column("is_system_account", sa.Boolean(), nullable=False, server_default="false"), sa.Column("is_system_account", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("encrypted_credentials", sa.Text(), nullable=True), sa.Column("encrypted_credentials", sa.Text(), nullable=True),
sa.Column( sa.Column("bot_username", sa.String(255), nullable=True),
"account_metadata", sa.Column("webhook_secret", sa.String(64), nullable=True),
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column( sa.Column(
"cursor_state", "cursor_state",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
@ -216,7 +205,7 @@ def upgrade() -> None:
), ),
sa.Column( sa.Column(
"health_status", "health_status",
gateway_health_status_enum, external_chat_health_status_enum,
nullable=False, nullable=False,
server_default="unknown", server_default="unknown",
), ),
@ -238,7 +227,7 @@ def upgrade() -> None:
sa.CheckConstraint( sa.CheckConstraint(
"(is_system_account = true AND owner_user_id IS NULL) OR " "(is_system_account = true AND owner_user_id IS NULL) OR "
"(is_system_account = false AND owner_user_id IS NOT NULL)", "(is_system_account = false AND owner_user_id IS NOT NULL)",
name="ck_gateway_accounts_owner_shape", name="ck_external_chat_accounts_owner_shape",
), ),
sa.ForeignKeyConstraint(["owner_user_id"], ["user.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["owner_user_id"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
@ -246,32 +235,40 @@ def upgrade() -> None:
), ),
) )
op.create_index( op.create_index(
"uq_gateway_accounts_owner_platform", "uq_external_chat_accounts_owner_platform",
"gateway_platform_accounts", "external_chat_accounts",
["owner_user_id", "platform"], ["owner_user_id", "platform"],
unique=True, unique=True,
postgresql_where=sa.text("is_system_account = false"), postgresql_where=sa.text("is_system_account = false"),
if_not_exists=True, if_not_exists=True,
) )
op.create_index( op.create_index(
"uq_gateway_accounts_system_platform", "uq_external_chat_accounts_system_platform",
"gateway_platform_accounts", "external_chat_accounts",
["platform"], ["platform"],
unique=True, unique=True,
postgresql_where=sa.text("is_system_account = true"), postgresql_where=sa.text("is_system_account = true"),
if_not_exists=True, if_not_exists=True,
) )
op.create_index(
"uq_external_chat_accounts_webhook_secret",
"external_chat_accounts",
["webhook_secret"],
unique=True,
postgresql_where=sa.text("webhook_secret IS NOT NULL"),
if_not_exists=True,
)
if not _table_exists(conn, "gateway_conversation_bindings"): if not _table_exists(conn, "external_chat_bindings"):
op.create_table( op.create_table(
"gateway_conversation_bindings", "external_chat_bindings",
sa.Column("id", sa.BigInteger(), primary_key=True), sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("account_id", sa.BigInteger(), nullable=False), sa.Column("account_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("search_space_id", sa.Integer(), nullable=False), sa.Column("search_space_id", sa.Integer(), nullable=False),
sa.Column( sa.Column(
"state", "state",
gateway_binding_state_enum, external_chat_binding_state_enum,
nullable=False, nullable=False,
server_default="pending", server_default="pending",
), ),
@ -280,39 +277,25 @@ def upgrade() -> None:
sa.Column("external_peer_id", sa.Text(), nullable=True), sa.Column("external_peer_id", sa.Text(), nullable=True),
sa.Column( sa.Column(
"external_peer_kind", "external_peer_kind",
gateway_peer_kind_enum, external_chat_peer_kind_enum,
nullable=False, nullable=False,
server_default="unknown", server_default="unknown",
), ),
sa.Column("external_thread_id", sa.Text(), nullable=True), sa.Column(
"external_thread_id",
sa.Text(),
nullable=True,
comment="Reserved for Telegram message_thread_id when group/forum support lands.",
),
sa.Column("external_display_name", sa.Text(), nullable=True), sa.Column("external_display_name", sa.Text(), nullable=True),
sa.Column("external_username", sa.Text(), nullable=True), sa.Column("external_username", sa.Text(), nullable=True),
sa.Column("external_pii_hashes", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column( sa.Column(
"external_metadata", "external_metadata",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
nullable=False, nullable=False,
server_default=sa.text("'{}'::jsonb"), server_default=sa.text("'{}'::jsonb"),
), ),
sa.Column("active_thread_id", sa.Integer(), nullable=True), sa.Column("new_chat_thread_id", sa.Integer(), nullable=True),
sa.Column(
"session_scope",
gateway_session_scope_enum,
nullable=False,
server_default="per_binding",
),
sa.Column(
"dm_policy",
gateway_dm_policy_enum,
nullable=False,
server_default="enabled",
),
sa.Column(
"group_policy",
gateway_group_policy_enum,
nullable=False,
server_default="disabled",
),
sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("suspended_at", sa.TIMESTAMP(timezone=True), nullable=True), sa.Column("suspended_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("suspended_reason", sa.Text(), nullable=True), sa.Column("suspended_reason", sa.Text(), nullable=True),
@ -329,17 +312,17 @@ def upgrade() -> None:
server_default=sa.text("(now() AT TIME ZONE 'utc')"), server_default=sa.text("(now() AT TIME ZONE 'utc')"),
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["account_id"], ["gateway_platform_accounts.id"], ondelete="CASCADE" ["account_id"], ["external_chat_accounts.id"], ondelete="CASCADE"
), ),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["active_thread_id"], ["new_chat_threads.id"], ondelete="SET NULL" ["new_chat_thread_id"], ["new_chat_threads.id"], ondelete="SET NULL"
), ),
) )
op.create_index( op.create_index(
"uq_gateway_bindings_account_peer_active", "uq_external_chat_bindings_account_peer_active",
"gateway_conversation_bindings", "external_chat_bindings",
["account_id", "external_peer_id"], ["account_id", "external_peer_id"],
unique=True, unique=True,
postgresql_where=sa.text( postgresql_where=sa.text(
@ -348,51 +331,46 @@ def upgrade() -> None:
if_not_exists=True, if_not_exists=True,
) )
op.create_index( op.create_index(
"uq_gateway_bindings_pairing_code_pending", "uq_external_chat_bindings_pairing_code_pending",
"gateway_conversation_bindings", "external_chat_bindings",
["pairing_code"], ["pairing_code"],
unique=True, unique=True,
postgresql_where=sa.text("state = 'pending'"), postgresql_where=sa.text("state = 'pending'"),
if_not_exists=True, if_not_exists=True,
) )
op.create_index( op.create_index(
"ix_gateway_bindings_user_state", "ix_external_chat_bindings_user_state",
"gateway_conversation_bindings", "external_chat_bindings",
["user_id", "state"], ["user_id", "state"],
if_not_exists=True, if_not_exists=True,
) )
op.create_index( op.create_index(
"ix_gateway_bindings_search_space_state", "ix_external_chat_bindings_search_space_state",
"gateway_conversation_bindings", "external_chat_bindings",
["search_space_id", "state"], ["search_space_id", "state"],
if_not_exists=True, if_not_exists=True,
) )
if not _table_exists(conn, "gateway_inbound_events"): if not _table_exists(conn, "external_chat_inbound_events"):
op.create_table( op.create_table(
"gateway_inbound_events", "external_chat_inbound_events",
sa.Column("id", sa.BigInteger(), primary_key=True), sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("account_id", sa.BigInteger(), nullable=False), sa.Column("account_id", sa.BigInteger(), nullable=False),
sa.Column("binding_id", sa.BigInteger(), nullable=True), sa.Column("external_chat_binding_id", sa.BigInteger(), nullable=True),
sa.Column("platform", gateway_platform_enum, nullable=False), sa.Column("platform", external_chat_platform_enum, nullable=False),
sa.Column("event_dedupe_key", sa.Text(), nullable=False), sa.Column("event_dedupe_key", sa.Text(), nullable=False),
sa.Column("external_event_id", sa.Text(), nullable=True), sa.Column("external_event_id", sa.Text(), nullable=True),
sa.Column("external_message_id", sa.Text(), nullable=True), sa.Column("external_message_id", sa.Text(), nullable=True),
sa.Column("event_kind", gateway_event_kind_enum, nullable=False), sa.Column("event_kind", external_chat_event_kind_enum, nullable=False),
sa.Column( sa.Column(
"raw_payload", "raw_payload",
postgresql.JSONB(astext_type=sa.Text()), postgresql.JSONB(astext_type=sa.Text()),
nullable=True, nullable=True,
), ),
sa.Column( sa.Column("request_id", sa.String(64), nullable=True),
"processing_metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column( sa.Column(
"status", "status",
gateway_event_status_enum, external_chat_event_status_enum,
nullable=False, nullable=False,
server_default="received", server_default="received",
), ),
@ -412,27 +390,34 @@ def upgrade() -> None:
server_default=sa.text("(now() AT TIME ZONE 'utc')"), server_default=sa.text("(now() AT TIME ZONE 'utc')"),
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["account_id"], ["gateway_platform_accounts.id"], ondelete="CASCADE" ["account_id"], ["external_chat_accounts.id"], ondelete="CASCADE"
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["binding_id"], ["gateway_conversation_bindings.id"], ondelete="SET NULL" ["external_chat_binding_id"], ["external_chat_bindings.id"], ondelete="SET NULL"
), ),
sa.UniqueConstraint( sa.UniqueConstraint(
"account_id", "account_id",
"event_dedupe_key", "event_dedupe_key",
name="uq_gateway_inbound_account_dedupe_key", name="uq_external_chat_inbound_account_dedupe_key",
), ),
) )
op.create_index( op.create_index(
"ix_gateway_inbound_status_received_at", "ix_external_chat_inbound_status_received_at",
"gateway_inbound_events", "external_chat_inbound_events",
["status", "received_at"], ["status", "received_at"],
if_not_exists=True, if_not_exists=True,
) )
op.create_index( op.create_index(
"ix_gateway_inbound_binding_received_at", "ix_external_chat_inbound_binding_received_at",
"gateway_inbound_events", "external_chat_inbound_events",
["binding_id", "received_at"], ["external_chat_binding_id", "received_at"],
if_not_exists=True,
)
op.create_index(
"ix_external_chat_inbound_request_id",
"external_chat_inbound_events",
["request_id"],
postgresql_where=sa.text("request_id IS NOT NULL"),
if_not_exists=True, if_not_exists=True,
) )
@ -442,27 +427,27 @@ def upgrade() -> None:
sa.Column("source", sa.Text(), nullable=False, server_default="web"), sa.Column("source", sa.Text(), nullable=False, server_default="web"),
) )
op.alter_column("new_chat_threads", "source", type_=sa.Text()) op.alter_column("new_chat_threads", "source", type_=sa.Text())
if not _column_exists(conn, "new_chat_threads", "binding_id"): if not _column_exists(conn, "new_chat_threads", "external_chat_binding_id"):
op.add_column( op.add_column(
"new_chat_threads", "new_chat_threads",
sa.Column("binding_id", sa.BigInteger(), nullable=True), sa.Column("external_chat_binding_id", sa.BigInteger(), nullable=True),
) )
if not _constraint_exists( if not _constraint_exists(
conn, "new_chat_threads", "fk_new_chat_threads_gateway_binding_id" conn, "new_chat_threads", "fk_new_chat_threads_external_chat_external_chat_binding_id"
): ):
op.create_foreign_key( op.create_foreign_key(
"fk_new_chat_threads_gateway_binding_id", "fk_new_chat_threads_external_chat_external_chat_binding_id",
"new_chat_threads", "new_chat_threads",
"gateway_conversation_bindings", "external_chat_bindings",
["binding_id"], ["external_chat_binding_id"],
["id"], ["id"],
ondelete="SET NULL", ondelete="SET NULL",
) )
op.create_index("ix_new_chat_threads_source", "new_chat_threads", ["source"], if_not_exists=True) op.create_index("ix_new_chat_threads_source", "new_chat_threads", ["source"], if_not_exists=True)
op.create_index( op.create_index(
"ix_new_chat_threads_binding_id", "ix_new_chat_threads_external_chat_binding_id",
"new_chat_threads", "new_chat_threads",
["binding_id"], ["external_chat_binding_id"],
if_not_exists=True, if_not_exists=True,
) )
@ -510,7 +495,9 @@ def upgrade() -> None:
tx = conn.begin_nested() if conn.in_transaction() else conn.begin() tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
with tx: with tx:
conn.execute( conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-144-gateway'") sa.text(
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-144-external-chat'"
)
) )
conn.execute( conn.execute(
sa.text( sa.text(
@ -521,7 +508,9 @@ def upgrade() -> None:
) )
) )
conn.execute( conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-144-gateway'") sa.text(
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-144-external-chat'"
)
) )
@ -565,59 +554,58 @@ def downgrade() -> None:
_drop_column_if_exists("new_chat_messages", "platform_metadata") _drop_column_if_exists("new_chat_messages", "platform_metadata")
_drop_column_if_exists("new_chat_messages", "source") _drop_column_if_exists("new_chat_messages", "source")
_drop_index_if_exists("ix_new_chat_threads_binding_id", "new_chat_threads") _drop_index_if_exists("ix_new_chat_threads_external_chat_binding_id", "new_chat_threads")
_drop_index_if_exists("ix_new_chat_threads_source", "new_chat_threads") _drop_index_if_exists("ix_new_chat_threads_source", "new_chat_threads")
if _constraint_exists( if _constraint_exists(
conn, "new_chat_threads", "fk_new_chat_threads_gateway_binding_id" conn, "new_chat_threads", "fk_new_chat_threads_external_chat_external_chat_binding_id"
): ):
op.drop_constraint( op.drop_constraint(
"fk_new_chat_threads_gateway_binding_id", "fk_new_chat_threads_external_chat_external_chat_binding_id",
"new_chat_threads", "new_chat_threads",
type_="foreignkey", type_="foreignkey",
) )
_drop_column_if_exists("new_chat_threads", "binding_id") _drop_column_if_exists("new_chat_threads", "external_chat_binding_id")
_drop_column_if_exists("new_chat_threads", "source") _drop_column_if_exists("new_chat_threads", "source")
_drop_index_if_exists( _drop_index_if_exists(
"ix_gateway_inbound_binding_received_at", "gateway_inbound_events" "ix_external_chat_inbound_binding_received_at", "external_chat_inbound_events"
) )
_drop_index_if_exists("ix_gateway_inbound_status_received_at", "gateway_inbound_events") _drop_index_if_exists("ix_external_chat_inbound_request_id", "external_chat_inbound_events")
if _table_exists(conn, "gateway_inbound_events"): _drop_index_if_exists("ix_external_chat_inbound_status_received_at", "external_chat_inbound_events")
op.drop_table("gateway_inbound_events") if _table_exists(conn, "external_chat_inbound_events"):
op.drop_table("external_chat_inbound_events")
_drop_index_if_exists( _drop_index_if_exists(
"ix_gateway_bindings_search_space_state", "ix_external_chat_bindings_search_space_state",
"gateway_conversation_bindings", "external_chat_bindings",
) )
_drop_index_if_exists( _drop_index_if_exists(
"ix_gateway_bindings_user_state", "gateway_conversation_bindings" "ix_external_chat_bindings_user_state", "external_chat_bindings"
) )
_drop_index_if_exists( _drop_index_if_exists(
"uq_gateway_bindings_pairing_code_pending", "uq_external_chat_bindings_pairing_code_pending",
"gateway_conversation_bindings", "external_chat_bindings",
) )
_drop_index_if_exists( _drop_index_if_exists(
"uq_gateway_bindings_account_peer_active", "uq_external_chat_bindings_account_peer_active",
"gateway_conversation_bindings", "external_chat_bindings",
) )
if _table_exists(conn, "gateway_conversation_bindings"): if _table_exists(conn, "external_chat_bindings"):
op.drop_table("gateway_conversation_bindings") op.drop_table("external_chat_bindings")
_drop_index_if_exists("uq_gateway_accounts_system_platform", "gateway_platform_accounts") _drop_index_if_exists("uq_external_chat_accounts_system_platform", "external_chat_accounts")
_drop_index_if_exists("uq_gateway_accounts_owner_platform", "gateway_platform_accounts") _drop_index_if_exists("uq_external_chat_accounts_owner_platform", "external_chat_accounts")
if _table_exists(conn, "gateway_platform_accounts"): _drop_index_if_exists("uq_external_chat_accounts_webhook_secret", "external_chat_accounts")
op.drop_table("gateway_platform_accounts") if _table_exists(conn, "external_chat_accounts"):
op.drop_table("external_chat_accounts")
for enum_name in ( for enum_name in (
"gateway_event_status", "external_chat_event_status",
"gateway_event_kind", "external_chat_event_kind",
"gateway_group_policy", "external_chat_peer_kind",
"gateway_dm_policy", "external_chat_binding_state",
"gateway_session_scope", "external_chat_health_status",
"gateway_peer_kind", "external_chat_account_mode",
"gateway_binding_state", "external_chat_platform",
"gateway_health_status",
"gateway_account_mode",
"gateway_platform",
): ):
postgresql.ENUM(name=enum_name).drop(conn, checkfirst=True) postgresql.ENUM(name=enum_name).drop(conn, checkfirst=True)

View file

@ -574,62 +574,45 @@ class ChatVisibility(StrEnum):
# PUBLIC = "PUBLIC" # Reserved for future implementation # PUBLIC = "PUBLIC" # Reserved for future implementation
class GatewayPlatform(StrEnum): class ExternalChatPlatform(StrEnum):
TELEGRAM = "telegram" TELEGRAM = "telegram"
WHATSAPP = "whatsapp" WHATSAPP = "whatsapp"
SIGNAL = "signal" SIGNAL = "signal"
class GatewayAccountMode(StrEnum): class ExternalChatAccountMode(StrEnum):
CLOUD_SHARED = "cloud_shared" CLOUD_SHARED = "cloud_shared"
SELF_HOST_BYO = "self_host_byo" SELF_HOST_BYO = "self_host_byo"
class GatewayHealthStatus(StrEnum): class ExternalChatHealthStatus(StrEnum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
OK = "ok" OK = "ok"
FAILING = "failing" FAILING = "failing"
class GatewayBindingState(StrEnum): class ExternalChatBindingState(StrEnum):
PENDING = "pending" PENDING = "pending"
BOUND = "bound" BOUND = "bound"
REVOKED = "revoked" REVOKED = "revoked"
SUSPENDED = "suspended" SUSPENDED = "suspended"
class GatewayPeerKind(StrEnum): class ExternalChatPeerKind(StrEnum):
DIRECT = "direct" DIRECT = "direct"
GROUP = "group" GROUP = "group"
CHANNEL = "channel" CHANNEL = "channel"
UNKNOWN = "unknown" UNKNOWN = "unknown"
class GatewaySessionScope(StrEnum): class ExternalChatEventKind(StrEnum):
PER_BINDING = "per_binding"
PER_USER_SEARCH_SPACE = "per_user_search_space"
EPHEMERAL = "ephemeral"
class GatewayDmPolicy(StrEnum):
ENABLED = "enabled"
DISABLED = "disabled"
class GatewayGroupPolicy(StrEnum):
DISABLED = "disabled"
ALLOWLIST = "allowlist"
MENTION_REQUIRED = "mention_required"
class GatewayEventKind(StrEnum):
MESSAGE = "message" MESSAGE = "message"
EDITED_MESSAGE = "edited_message" EDITED_MESSAGE = "edited_message"
CALLBACK_QUERY = "callback_query" CALLBACK_QUERY = "callback_query"
OTHER = "other" OTHER = "other"
class GatewayEventStatus(StrEnum): class ExternalChatEventStatus(StrEnum):
RECEIVED = "received" RECEIVED = "received"
PROCESSING = "processing" PROCESSING = "processing"
PROCESSED = "processed" PROCESSED = "processed"
@ -713,12 +696,12 @@ class NewChatThread(BaseModel, TimestampMixin):
# agent_llm_id changes). Unindexed: all reads are by primary key. # agent_llm_id changes). Unindexed: all reads are by primary key.
pinned_llm_config_id = Column(Integer, nullable=True) pinned_llm_config_id = Column(Integer, nullable=True)
# Gateway-originated threads are persisted for the agent, but the UI Zero # Surface metadata for web and external chat threads. Zero publishes all
# publication only exposes ``source='web'`` rows. # chat-message sources; the UI can decide which surfaces to render.
source = Column(Text, nullable=False, default="web", server_default="web") source = Column(Text, nullable=False, default="web", server_default="web")
binding_id = Column( external_chat_binding_id = Column(
BigInteger, BigInteger,
ForeignKey("gateway_conversation_bindings.id", ondelete="SET NULL"), ForeignKey("external_chat_bindings.id", ondelete="SET NULL"),
nullable=True, nullable=True,
index=True, index=True,
) )
@ -743,9 +726,9 @@ class NewChatThread(BaseModel, TimestampMixin):
back_populates="thread", back_populates="thread",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
gateway_binding = relationship( external_chat_binding = relationship(
"GatewayConversationBinding", "ExternalChatBinding",
foreign_keys=[binding_id], foreign_keys=[external_chat_binding_id],
back_populates="threads", back_populates="threads",
) )
@ -822,23 +805,23 @@ class NewChatMessage(BaseModel, TimestampMixin):
) )
class GatewayPlatformAccount(Base, TimestampMixin): class ExternalChatAccount(Base, TimestampMixin):
__tablename__ = "gateway_platform_accounts" __tablename__ = "external_chat_accounts"
__allow_unmapped__ = True __allow_unmapped__ = True
id = Column(BigInteger, primary_key=True, index=True) id = Column(BigInteger, primary_key=True, index=True)
platform = Column( platform = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayPlatform, ExternalChatPlatform,
name="gateway_platform", name="external_chat_platform",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
) )
mode = Column( mode = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayAccountMode, ExternalChatAccountMode,
name="gateway_account_mode", name="external_chat_account_mode",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
@ -851,17 +834,18 @@ class GatewayPlatformAccount(Base, TimestampMixin):
) )
is_system_account = Column(Boolean, nullable=False, default=False, server_default="false") is_system_account = Column(Boolean, nullable=False, default=False, server_default="false")
encrypted_credentials = Column(Text, nullable=True) encrypted_credentials = Column(Text, nullable=True)
account_metadata = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")) bot_username = Column(String(255), nullable=True)
webhook_secret = Column(String(64), nullable=True)
cursor_state = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")) cursor_state = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
health_status = Column( health_status = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayHealthStatus, ExternalChatHealthStatus,
name="gateway_health_status", name="external_chat_health_status",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
default=GatewayHealthStatus.UNKNOWN, default=ExternalChatHealthStatus.UNKNOWN,
server_default=GatewayHealthStatus.UNKNOWN.value, server_default=ExternalChatHealthStatus.UNKNOWN.value,
) )
last_health_check_at = Column(TIMESTAMP(timezone=True), nullable=True) last_health_check_at = Column(TIMESTAMP(timezone=True), nullable=True)
suspended_at = Column(TIMESTAMP(timezone=True), nullable=True) suspended_at = Column(TIMESTAMP(timezone=True), nullable=True)
@ -877,12 +861,12 @@ class GatewayPlatformAccount(Base, TimestampMixin):
owner = relationship("User", foreign_keys=[owner_user_id]) owner = relationship("User", foreign_keys=[owner_user_id])
owner_search_space = relationship("SearchSpace", foreign_keys=[owner_search_space_id]) owner_search_space = relationship("SearchSpace", foreign_keys=[owner_search_space_id])
bindings = relationship( bindings = relationship(
"GatewayConversationBinding", "ExternalChatBinding",
back_populates="account", back_populates="account",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
inbound_events = relationship( inbound_events = relationship(
"GatewayInboundEvent", "ExternalChatInboundEvent",
back_populates="account", back_populates="account",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
@ -891,32 +875,38 @@ class GatewayPlatformAccount(Base, TimestampMixin):
CheckConstraint( CheckConstraint(
"(is_system_account = true AND owner_user_id IS NULL) OR " "(is_system_account = true AND owner_user_id IS NULL) OR "
"(is_system_account = false AND owner_user_id IS NOT NULL)", "(is_system_account = false AND owner_user_id IS NOT NULL)",
name="ck_gateway_accounts_owner_shape", name="ck_external_chat_accounts_owner_shape",
), ),
Index( Index(
"uq_gateway_accounts_owner_platform", "uq_external_chat_accounts_owner_platform",
"owner_user_id", "owner_user_id",
"platform", "platform",
unique=True, unique=True,
postgresql_where=text("is_system_account = false"), postgresql_where=text("is_system_account = false"),
), ),
Index( Index(
"uq_gateway_accounts_system_platform", "uq_external_chat_accounts_system_platform",
"platform", "platform",
unique=True, unique=True,
postgresql_where=text("is_system_account = true"), postgresql_where=text("is_system_account = true"),
), ),
Index(
"uq_external_chat_accounts_webhook_secret",
"webhook_secret",
unique=True,
postgresql_where=text("webhook_secret IS NOT NULL"),
),
) )
class GatewayConversationBinding(Base, TimestampMixin): class ExternalChatBinding(Base, TimestampMixin):
__tablename__ = "gateway_conversation_bindings" __tablename__ = "external_chat_bindings"
__allow_unmapped__ = True __allow_unmapped__ = True
id = Column(BigInteger, primary_key=True, index=True) id = Column(BigInteger, primary_key=True, index=True)
account_id = Column( account_id = Column(
BigInteger, BigInteger,
ForeignKey("gateway_platform_accounts.id", ondelete="CASCADE"), ForeignKey("external_chat_accounts.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
) )
@ -928,68 +918,37 @@ class GatewayConversationBinding(Base, TimestampMixin):
) )
state = Column( state = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayBindingState, ExternalChatBindingState,
name="gateway_binding_state", name="external_chat_binding_state",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
default=GatewayBindingState.PENDING, default=ExternalChatBindingState.PENDING,
server_default=GatewayBindingState.PENDING.value, server_default=ExternalChatBindingState.PENDING.value,
) )
pairing_code = Column(Text, nullable=True) pairing_code = Column(Text, nullable=True)
pairing_code_expires_at = Column(TIMESTAMP(timezone=True), nullable=True) pairing_code_expires_at = Column(TIMESTAMP(timezone=True), nullable=True)
external_peer_id = Column(Text, nullable=True) external_peer_id = Column(Text, nullable=True)
external_peer_kind = Column( external_peer_kind = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayPeerKind, ExternalChatPeerKind,
name="gateway_peer_kind", name="external_chat_peer_kind",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
default=GatewayPeerKind.UNKNOWN, default=ExternalChatPeerKind.UNKNOWN,
server_default=GatewayPeerKind.UNKNOWN.value, server_default=ExternalChatPeerKind.UNKNOWN.value,
) )
external_thread_id = Column(Text, nullable=True) external_thread_id = Column(Text, nullable=True)
external_display_name = Column(Text, nullable=True) external_display_name = Column(Text, nullable=True)
external_username = Column(Text, nullable=True) external_username = Column(Text, nullable=True)
external_pii_hashes = Column(JSONB, nullable=True)
external_metadata = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")) external_metadata = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
active_thread_id = Column( new_chat_thread_id = Column(
Integer, Integer,
ForeignKey("new_chat_threads.id", ondelete="SET NULL"), ForeignKey("new_chat_threads.id", ondelete="SET NULL"),
nullable=True, nullable=True,
index=True, index=True,
) )
session_scope = Column(
SQLAlchemyEnum(
GatewaySessionScope,
name="gateway_session_scope",
values_callable=_enum_values,
),
nullable=False,
default=GatewaySessionScope.PER_BINDING,
server_default=GatewaySessionScope.PER_BINDING.value,
)
dm_policy = Column(
SQLAlchemyEnum(
GatewayDmPolicy,
name="gateway_dm_policy",
values_callable=_enum_values,
),
nullable=False,
default=GatewayDmPolicy.ENABLED,
server_default=GatewayDmPolicy.ENABLED.value,
)
group_policy = Column(
SQLAlchemyEnum(
GatewayGroupPolicy,
name="gateway_group_policy",
values_callable=_enum_values,
),
nullable=False,
default=GatewayGroupPolicy.DISABLED,
server_default=GatewayGroupPolicy.DISABLED.value,
)
revoked_at = Column(TIMESTAMP(timezone=True), nullable=True) revoked_at = Column(TIMESTAMP(timezone=True), nullable=True)
suspended_at = Column(TIMESTAMP(timezone=True), nullable=True) suspended_at = Column(TIMESTAMP(timezone=True), nullable=True)
suspended_reason = Column(Text, nullable=True) suspended_reason = Column(Text, nullable=True)
@ -1001,24 +960,24 @@ class GatewayConversationBinding(Base, TimestampMixin):
server_default=text("(now() AT TIME ZONE 'utc')"), server_default=text("(now() AT TIME ZONE 'utc')"),
) )
account = relationship("GatewayPlatformAccount", back_populates="bindings") account = relationship("ExternalChatAccount", back_populates="bindings")
user = relationship("User", foreign_keys=[user_id]) user = relationship("User", foreign_keys=[user_id])
search_space = relationship("SearchSpace", foreign_keys=[search_space_id]) search_space = relationship("SearchSpace", foreign_keys=[search_space_id])
active_thread = relationship("NewChatThread", foreign_keys=[active_thread_id]) new_chat_thread = relationship("NewChatThread", foreign_keys=[new_chat_thread_id])
threads = relationship( threads = relationship(
"NewChatThread", "NewChatThread",
back_populates="gateway_binding", back_populates="external_chat_binding",
foreign_keys="NewChatThread.binding_id", foreign_keys="NewChatThread.external_chat_binding_id",
) )
inbound_events = relationship( inbound_events = relationship(
"GatewayInboundEvent", "ExternalChatInboundEvent",
back_populates="binding", back_populates="binding",
foreign_keys="GatewayInboundEvent.binding_id", foreign_keys="ExternalChatInboundEvent.external_chat_binding_id",
) )
__table_args__ = ( __table_args__ = (
Index( Index(
"uq_gateway_bindings_account_peer_active", "uq_external_chat_bindings_account_peer_active",
"account_id", "account_id",
"external_peer_id", "external_peer_id",
unique=True, unique=True,
@ -1027,37 +986,37 @@ class GatewayConversationBinding(Base, TimestampMixin):
), ),
), ),
Index( Index(
"uq_gateway_bindings_pairing_code_pending", "uq_external_chat_bindings_pairing_code_pending",
"pairing_code", "pairing_code",
unique=True, unique=True,
postgresql_where=text("state = 'pending'"), postgresql_where=text("state = 'pending'"),
), ),
Index("ix_gateway_bindings_user_state", "user_id", "state"), Index("ix_external_chat_bindings_user_state", "user_id", "state"),
Index("ix_gateway_bindings_search_space_state", "search_space_id", "state"), Index("ix_external_chat_bindings_search_space_state", "search_space_id", "state"),
) )
class GatewayInboundEvent(Base, TimestampMixin): class ExternalChatInboundEvent(Base, TimestampMixin):
__tablename__ = "gateway_inbound_events" __tablename__ = "external_chat_inbound_events"
__allow_unmapped__ = True __allow_unmapped__ = True
id = Column(BigInteger, primary_key=True, index=True) id = Column(BigInteger, primary_key=True, index=True)
account_id = Column( account_id = Column(
BigInteger, BigInteger,
ForeignKey("gateway_platform_accounts.id", ondelete="CASCADE"), ForeignKey("external_chat_accounts.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
) )
binding_id = Column( external_chat_binding_id = Column(
BigInteger, BigInteger,
ForeignKey("gateway_conversation_bindings.id", ondelete="SET NULL"), ForeignKey("external_chat_bindings.id", ondelete="SET NULL"),
nullable=True, nullable=True,
index=True, index=True,
) )
platform = Column( platform = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayPlatform, ExternalChatPlatform,
name="gateway_platform", name="external_chat_platform",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
@ -1067,28 +1026,23 @@ class GatewayInboundEvent(Base, TimestampMixin):
external_message_id = Column(Text, nullable=True) external_message_id = Column(Text, nullable=True)
event_kind = Column( event_kind = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayEventKind, ExternalChatEventKind,
name="gateway_event_kind", name="external_chat_event_kind",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
) )
raw_payload = Column(JSONB, nullable=True) raw_payload = Column(JSONB, nullable=True)
processing_metadata = Column( request_id = Column(String(64), nullable=True)
JSONB,
nullable=False,
default=dict,
server_default=text("'{}'::jsonb"),
)
status = Column( status = Column(
SQLAlchemyEnum( SQLAlchemyEnum(
GatewayEventStatus, ExternalChatEventStatus,
name="gateway_event_status", name="external_chat_event_status",
values_callable=_enum_values, values_callable=_enum_values,
), ),
nullable=False, nullable=False,
default=GatewayEventStatus.RECEIVED, default=ExternalChatEventStatus.RECEIVED,
server_default=GatewayEventStatus.RECEIVED.value, server_default=ExternalChatEventStatus.RECEIVED.value,
) )
attempt_count = Column(Integer, nullable=False, default=0, server_default="0") attempt_count = Column(Integer, nullable=False, default=0, server_default="0")
last_error = Column(Text, nullable=True) last_error = Column(Text, nullable=True)
@ -1100,17 +1054,26 @@ class GatewayInboundEvent(Base, TimestampMixin):
) )
processed_at = Column(TIMESTAMP(timezone=True), nullable=True) processed_at = Column(TIMESTAMP(timezone=True), nullable=True)
account = relationship("GatewayPlatformAccount", back_populates="inbound_events") account = relationship("ExternalChatAccount", back_populates="inbound_events")
binding = relationship("GatewayConversationBinding", back_populates="inbound_events") binding = relationship("ExternalChatBinding", back_populates="inbound_events")
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
"account_id", "account_id",
"event_dedupe_key", "event_dedupe_key",
name="uq_gateway_inbound_account_dedupe_key", name="uq_external_chat_inbound_account_dedupe_key",
),
Index("ix_external_chat_inbound_status_received_at", "status", "received_at"),
Index(
"ix_external_chat_inbound_binding_received_at",
"external_chat_binding_id",
"received_at",
),
Index(
"ix_external_chat_inbound_request_id",
"request_id",
postgresql_where=text("request_id IS NOT NULL"),
), ),
Index("ix_gateway_inbound_status_received_at", "status", "received_at"),
Index("ix_gateway_inbound_binding_received_at", "binding_id", "received_at"),
) )

View file

@ -1,4 +1,4 @@
"""Gateway account helpers.""" """External chat account helpers."""
from __future__ import annotations from __future__ import annotations
@ -7,16 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config from app.config import config
from app.db import ( from app.db import (
GatewayAccountMode, ExternalChatAccountMode,
GatewayHealthStatus, ExternalChatHealthStatus,
GatewayPlatform, ExternalChatPlatform,
GatewayPlatformAccount, ExternalChatAccount,
) )
from app.utils.oauth_security import TokenEncryption from app.utils.oauth_security import TokenEncryption
def account_token(account: GatewayPlatformAccount) -> str | None: def account_token(account: ExternalChatAccount) -> str | None:
if account.is_system_account and account.platform == GatewayPlatform.TELEGRAM: if account.is_system_account and account.platform == ExternalChatPlatform.TELEGRAM:
return config.TELEGRAM_SHARED_BOT_TOKEN return config.TELEGRAM_SHARED_BOT_TOKEN
if not account.encrypted_credentials: if not account.encrypted_credentials:
return None return None
@ -27,26 +27,24 @@ def account_token(account: GatewayPlatformAccount) -> str | None:
async def get_or_create_system_telegram_account( async def get_or_create_system_telegram_account(
session: AsyncSession, session: AsyncSession,
) -> GatewayPlatformAccount: ) -> ExternalChatAccount:
result = await session.execute( result = await session.execute(
select(GatewayPlatformAccount).where( select(ExternalChatAccount).where(
GatewayPlatformAccount.platform == GatewayPlatform.TELEGRAM, ExternalChatAccount.platform == ExternalChatPlatform.TELEGRAM,
GatewayPlatformAccount.is_system_account.is_(True), ExternalChatAccount.is_system_account.is_(True),
) )
) )
account = result.scalars().first() account = result.scalars().first()
if account is not None: if account is not None:
return account return account
account = GatewayPlatformAccount( account = ExternalChatAccount(
platform=GatewayPlatform.TELEGRAM, platform=ExternalChatPlatform.TELEGRAM,
mode=GatewayAccountMode.CLOUD_SHARED, mode=ExternalChatAccountMode.CLOUD_SHARED,
is_system_account=True, is_system_account=True,
account_metadata={ bot_username=config.TELEGRAM_SHARED_BOT_USERNAME,
"bot_username": config.TELEGRAM_SHARED_BOT_USERNAME, webhook_secret=config.TELEGRAM_WEBHOOK_SECRET,
"webhook_secret": config.TELEGRAM_WEBHOOK_SECRET,
},
cursor_state={}, cursor_state={},
health_status=GatewayHealthStatus.UNKNOWN, health_status=ExternalChatHealthStatus.UNKNOWN,
) )
session.add(account) session.add(account)
await session.flush() await session.flush()

View file

@ -1,11 +1,11 @@
"""Authorization invariants for gateway-routed turns.""" """Authorization invariants for external-chat-routed turns."""
from __future__ import annotations from __future__ import annotations
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import GatewayConversationBinding, Permission, User from app.db import ExternalChatBinding, Permission, User
from app.gateway.bindings import suspend_binding from app.gateway.bindings import suspend_binding
from app.observability.metrics import record_gateway_auth_invariant_failure from app.observability.metrics import record_gateway_auth_invariant_failure
from app.utils.rbac import check_permission, check_search_space_access from app.utils.rbac import check_permission, check_search_space_access
@ -19,7 +19,7 @@ class GatewaySuspendedError(RuntimeError):
async def _fail( async def _fail(
session: AsyncSession, session: AsyncSession,
binding: GatewayConversationBinding, binding: ExternalChatBinding,
reason: str, reason: str,
) -> None: ) -> None:
suspend_binding(binding, reason) suspend_binding(binding, reason)
@ -30,7 +30,7 @@ async def _fail(
async def assert_authorization_invariant( async def assert_authorization_invariant(
session: AsyncSession, session: AsyncSession,
binding: GatewayConversationBinding, binding: ExternalChatBinding,
) -> User: ) -> User:
if binding.state != "bound": if binding.state != "bound":
await _fail(session, binding, "binding_not_bound") await _fail(session, binding, "binding_not_bound")
@ -46,7 +46,7 @@ async def assert_authorization_invariant(
user, user,
binding.search_space_id, binding.search_space_id,
Permission.CHATS_CREATE.value, Permission.CHATS_CREATE.value,
"Gateway owner no longer has permission to chat in this search space", "External chat owner no longer has permission to chat in this search space",
) )
except HTTPException as exc: except HTTPException as exc:
await _fail(session, binding, f"rbac_{exc.status_code}") await _fail(session, binding, f"rbac_{exc.status_code}")

View file

@ -1,4 +1,4 @@
"""Gateway binding helpers.""" """External chat binding helpers."""
from __future__ import annotations from __future__ import annotations
@ -9,19 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import ( from app.db import (
ChatVisibility, ChatVisibility,
GatewayBindingState, ExternalChatBindingState,
GatewayConversationBinding, ExternalChatBinding,
NewChatThread, NewChatThread,
) )
async def get_or_create_thread_for_binding( async def get_or_create_thread_for_binding(
session: AsyncSession, session: AsyncSession,
binding: GatewayConversationBinding, binding: ExternalChatBinding,
) -> NewChatThread: ) -> NewChatThread:
if binding.active_thread_id is not None: if binding.new_chat_thread_id is not None:
result = await session.execute( result = await session.execute(
select(NewChatThread).where(NewChatThread.id == binding.active_thread_id) select(NewChatThread).where(NewChatThread.id == binding.new_chat_thread_id)
) )
thread = result.scalars().first() thread = result.scalars().first()
if thread is not None and not thread.archived: if thread is not None and not thread.archived:
@ -33,30 +33,30 @@ async def get_or_create_thread_for_binding(
created_by_id=binding.user_id, created_by_id=binding.user_id,
visibility=ChatVisibility.PRIVATE, visibility=ChatVisibility.PRIVATE,
source="telegram", source="telegram",
binding_id=binding.id, external_chat_binding_id=binding.id,
) )
session.add(thread) session.add(thread)
await session.flush() await session.flush()
binding.active_thread_id = thread.id binding.new_chat_thread_id = thread.id
return thread return thread
def suspend_binding(binding: GatewayConversationBinding, reason: str) -> None: def suspend_binding(binding: ExternalChatBinding, reason: str) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
binding.state = GatewayBindingState.SUSPENDED binding.state = ExternalChatBindingState.SUSPENDED
binding.suspended_at = now binding.suspended_at = now
binding.suspended_reason = reason binding.suspended_reason = reason
def revoke_binding(binding: GatewayConversationBinding) -> None: def revoke_binding(binding: ExternalChatBinding) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
binding.state = GatewayBindingState.REVOKED binding.state = ExternalChatBindingState.REVOKED
binding.revoked_at = now binding.revoked_at = now
binding.active_thread_id = None binding.new_chat_thread_id = None
def resume_binding(binding: GatewayConversationBinding) -> None: def resume_binding(binding: ExternalChatBinding) -> None:
binding.state = GatewayBindingState.BOUND binding.state = ExternalChatBindingState.BOUND
binding.suspended_at = None binding.suspended_at = None
binding.suspended_reason = None binding.suspended_reason = None

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import GatewayInboundEvent, GatewayPlatform from app.db import ExternalChatInboundEvent, ExternalChatPlatform
def telegram_event_dedupe_key(update_id: int | str) -> str: def telegram_event_dedupe_key(update_id: int | str) -> str:
@ -16,15 +16,16 @@ async def persist_inbound_event(
session: AsyncSession, session: AsyncSession,
*, *,
account_id: int, account_id: int,
platform: GatewayPlatform, platform: ExternalChatPlatform,
event_dedupe_key: str, event_dedupe_key: str,
event_kind: str, event_kind: str,
raw_payload: dict, raw_payload: dict,
external_event_id: str | None = None, external_event_id: str | None = None,
external_message_id: str | None = None, external_message_id: str | None = None,
request_id: str | None = None,
) -> int | None: ) -> int | None:
stmt = ( stmt = (
insert(GatewayInboundEvent) insert(ExternalChatInboundEvent)
.values( .values(
account_id=account_id, account_id=account_id,
platform=platform, platform=platform,
@ -33,11 +34,12 @@ async def persist_inbound_event(
external_message_id=external_message_id, external_message_id=external_message_id,
event_kind=event_kind, event_kind=event_kind,
raw_payload=raw_payload, raw_payload=raw_payload,
request_id=request_id,
) )
.on_conflict_do_nothing( .on_conflict_do_nothing(
index_elements=["account_id", "event_dedupe_key"], index_elements=["account_id", "event_dedupe_key"],
) )
.returning(GatewayInboundEvent.id) .returning(ExternalChatInboundEvent.id)
) )
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()

View file

@ -1,4 +1,4 @@
"""Pairing code lifecycle for gateway bindings.""" """Pairing code lifecycle for external chat bindings."""
from __future__ import annotations from __future__ import annotations
@ -8,7 +8,7 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import GatewayBindingState, GatewayConversationBinding from app.db import ExternalChatBindingState, ExternalChatBinding
PAIRING_CODE_TTL = timedelta(minutes=10) PAIRING_CODE_TTL = timedelta(minutes=10)
@ -30,19 +30,19 @@ async def redeem_pairing_code(
external_display_name: str | None, external_display_name: str | None,
external_username: str | None, external_username: str | None,
external_metadata: dict | None = None, external_metadata: dict | None = None,
) -> GatewayConversationBinding | None: ) -> ExternalChatBinding | None:
result = await session.execute( result = await session.execute(
select(GatewayConversationBinding).where( select(ExternalChatBinding).where(
GatewayConversationBinding.pairing_code == code, ExternalChatBinding.pairing_code == code,
GatewayConversationBinding.state == GatewayBindingState.PENDING, ExternalChatBinding.state == ExternalChatBindingState.PENDING,
GatewayConversationBinding.pairing_code_expires_at > datetime.now(UTC), ExternalChatBinding.pairing_code_expires_at > datetime.now(UTC),
) )
) )
binding = result.scalars().first() binding = result.scalars().first()
if binding is None: if binding is None:
return None return None
binding.state = GatewayBindingState.BOUND binding.state = ExternalChatBindingState.BOUND
binding.pairing_code = None binding.pairing_code = None
binding.pairing_code_expires_at = None binding.pairing_code_expires_at = None
binding.external_peer_id = external_peer_id binding.external_peer_id = external_peer_id

View file

@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta
import pytest import pytest
from app.db import GatewayBindingState from app.db import ExternalChatBindingState
from app.gateway.pairing import generate_pairing_code, redeem_pairing_code from app.gateway.pairing import generate_pairing_code, redeem_pairing_code
@ -16,7 +16,7 @@ def test_generate_pairing_code_is_short_display_token():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redeem_pairing_code_binds_pending_row(mocker): async def test_redeem_pairing_code_binds_pending_row(mocker):
binding = mocker.Mock() binding = mocker.Mock()
binding.state = GatewayBindingState.PENDING binding.state = ExternalChatBindingState.PENDING
binding.pairing_code_expires_at = datetime.now(UTC) + timedelta(minutes=1) binding.pairing_code_expires_at = datetime.now(UTC) + timedelta(minutes=1)
scalars = mocker.Mock() scalars = mocker.Mock()
scalars.first.return_value = binding scalars.first.return_value = binding
@ -35,7 +35,7 @@ async def test_redeem_pairing_code_binds_pending_row(mocker):
) )
assert redeemed is binding assert redeemed is binding
assert binding.state == GatewayBindingState.BOUND assert binding.state == ExternalChatBindingState.BOUND
assert binding.external_peer_id == "telegram:123" assert binding.external_peer_id == "telegram:123"
assert binding.pairing_code is None assert binding.pairing_code is None