"""Upgrade Feishu event receipts to a durable inbound inbox. Revision ID: 202607270002 Revises: 202607270001 Create Date: 2026-07-27 """ import re from hashlib import sha256 from typing import Any from alembic import op import sqlalchemy as sa revision = "202607270002" down_revision = "202607270001" branch_labels = None depends_on = None TABLE_NAME = "feishu_event_receipts" INDEX_COLUMNS = ( "event_type", "status", "next_attempt_at", "locked_until", "locked_by", "processed_at", "reply_status", "reply_next_attempt_at", "reply_locked_until", "reply_locked_by", "reply_sent_at", ) IDENTIFIER_DIGEST_PATTERN = re.compile(r"sha256-[0-9a-f]{64}\Z") IDENTIFIER_DIGEST_PREFIX = "company-ai-platform:feishu" def _identifier_digest(value: Any, domain: str) -> str | None: text = str(value or "").strip() if not text: return None if IDENTIFIER_DIGEST_PATTERN.fullmatch(text): return text digest = sha256( f"{IDENTIFIER_DIGEST_PREFIX}:{domain}:v1\0{text}".encode("utf-8") ).hexdigest() return f"sha256-{digest}" def _migrate_historical_identifiers() -> None: """Replace legacy raw receipt identifiers without hashing them twice. A strict current-format digest is the only durable marker available in the legacy schema, so matching values are treated as already migrated. A raw identifier that happens to match that format is therefore intentionally indistinguishable and remains unchanged. """ bind = op.get_bind() receipts = sa.table( TABLE_NAME, sa.column("id", sa.Integer()), sa.column("event_key", sa.String()), sa.column("event_id", sa.String()), sa.column("message_id", sa.String()), ) rows = bind.execute( sa.select( receipts.c.id, receipts.c.event_key, receipts.c.event_id, receipts.c.message_id, ).order_by(receipts.c.id) ).mappings() updates: list[tuple[int, dict[str, str | None]]] = [] target_event_keys: dict[str, int] = {} for row in rows: row_id = int(row["id"]) values = { "event_key": _identifier_digest(row["event_key"], "event-key"), "event_id": _identifier_digest(row["event_id"], "event-id"), "message_id": _identifier_digest(row["message_id"], "message-id"), } target_event_key = values["event_key"] if target_event_key is None: raise RuntimeError( f"Cannot migrate blank Feishu receipt event_key at row {row_id}" ) conflicting_row_id = target_event_keys.setdefault(target_event_key, row_id) if conflicting_row_id != row_id: raise RuntimeError( "Cannot migrate colliding Feishu receipt event_key values at " f"rows {conflicting_row_id} and {row_id}" ) if any(values[column] != row[column] for column in values): updates.append((row_id, values)) for row_id, values in updates: bind.execute( sa.update(receipts).where(receipts.c.id == row_id).values(**values) ) def upgrade() -> None: _migrate_historical_identifiers() op.add_column( TABLE_NAME, sa.Column("event_type", sa.String(length=128), nullable=True), ) op.add_column(TABLE_NAME, sa.Column("payload", sa.JSON(), nullable=True)) op.add_column( TABLE_NAME, sa.Column( "auto_reply", sa.Boolean(), server_default=sa.true(), nullable=False, ), ) op.add_column( TABLE_NAME, sa.Column( "status", sa.String(length=32), server_default="pending", nullable=False, ), ) op.add_column( TABLE_NAME, sa.Column( "attempt_count", sa.Integer(), server_default="0", nullable=False, ), ) op.add_column( TABLE_NAME, sa.Column( "max_attempts", sa.Integer(), server_default="4", nullable=False, ), ) op.add_column(TABLE_NAME, sa.Column("last_error", sa.Text(), nullable=True)) op.add_column( TABLE_NAME, sa.Column("next_attempt_at", sa.DateTime(), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("locked_until", sa.DateTime(), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("locked_by", sa.String(length=128), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("processed_at", sa.DateTime(), nullable=True), ) op.add_column(TABLE_NAME, sa.Column("reply_payload", sa.JSON(), nullable=True)) op.add_column( TABLE_NAME, sa.Column("reply_status", sa.String(length=32), nullable=True), ) op.add_column( TABLE_NAME, sa.Column( "reply_attempt_count", sa.Integer(), server_default="0", nullable=False, ), ) op.add_column( TABLE_NAME, sa.Column("reply_last_error", sa.Text(), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("reply_next_attempt_at", sa.DateTime(), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("reply_locked_until", sa.DateTime(), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("reply_locked_by", sa.String(length=128), nullable=True), ) op.add_column( TABLE_NAME, sa.Column("reply_sent_at", sa.DateTime(), nullable=True), ) # Historical receipt-only rows have no replayable payload. Treat them as # completed so deploying the inbox cannot execute old commands. op.execute( sa.text( f""" UPDATE {TABLE_NAME} SET status = 'succeeded', attempt_count = 1, processed_at = received_at """ ) ) for column in INDEX_COLUMNS: op.create_index( op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False, ) def downgrade() -> None: # Identifier digests are intentionally irreversible and remain in place. for column in reversed(INDEX_COLUMNS): op.drop_index( op.f(f"ix_{TABLE_NAME}_{column}"), table_name=TABLE_NAME, ) for column in ( "processed_at", "locked_by", "locked_until", "next_attempt_at", "last_error", "max_attempts", "attempt_count", "status", "auto_reply", "payload", "event_type", "reply_sent_at", "reply_locked_by", "reply_locked_until", "reply_next_attempt_at", "reply_last_error", "reply_attempt_count", "reply_status", "reply_payload", ): op.drop_column(TABLE_NAME, column)