chore: linting

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-06-09 00:42:26 -07:00
parent 0a012dbc79
commit ce952d2ad1
127 changed files with 821 additions and 517 deletions

View file

@ -165,9 +165,7 @@ def downgrade() -> 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( sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-148-downgrade'")
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-148-downgrade'"
)
) )
conn.execute(sa.text(ddl)) conn.execute(sa.text(ddl))
conn.execute( conn.execute(

View file

@ -65,6 +65,7 @@ AUTOMATION_RUN_COLS = [
"created_at", "created_at",
] ]
def _has_zero_version(conn, table: str) -> bool: def _has_zero_version(conn, table: str) -> bool:
return ( return (
conn.execute( conn.execute(
@ -190,7 +191,8 @@ def upgrade() -> None:
"external_chat_peer_kind", ("direct", "group", "channel", "unknown") "external_chat_peer_kind", ("direct", "group", "channel", "unknown")
) )
external_chat_event_kind_enum = _create_enum( external_chat_event_kind_enum = _create_enum(
"external_chat_event_kind", ("message", "edited_message", "callback_query", "other") "external_chat_event_kind",
("message", "edited_message", "callback_query", "other"),
) )
external_chat_event_status_enum = _create_enum( external_chat_event_status_enum = _create_enum(
"external_chat_event_status", "external_chat_event_status",
@ -205,7 +207,12 @@ def upgrade() -> None:
sa.Column("mode", external_chat_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("bot_username", sa.String(255), nullable=True), sa.Column("bot_username", sa.String(255), nullable=True),
sa.Column("webhook_secret", sa.String(64), nullable=True), sa.Column("webhook_secret", sa.String(64), nullable=True),
@ -221,7 +228,9 @@ def upgrade() -> None:
nullable=False, nullable=False,
server_default="unknown", server_default="unknown",
), ),
sa.Column("last_health_check_at", sa.TIMESTAMP(timezone=True), nullable=True), sa.Column(
"last_health_check_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),
sa.Column( sa.Column(
@ -285,7 +294,9 @@ def upgrade() -> None:
server_default="pending", server_default="pending",
), ),
sa.Column("pairing_code", sa.Text(), nullable=True), sa.Column("pairing_code", sa.Text(), nullable=True),
sa.Column("pairing_code_expires_at", sa.TIMESTAMP(timezone=True), nullable=True), sa.Column(
"pairing_code_expires_at", sa.TIMESTAMP(timezone=True), nullable=True
),
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",
@ -327,7 +338,9 @@ def upgrade() -> None:
["account_id"], ["external_chat_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(
["new_chat_thread_id"], ["new_chat_threads.id"], ondelete="SET NULL" ["new_chat_thread_id"], ["new_chat_threads.id"], ondelete="SET NULL"
), ),
@ -386,7 +399,9 @@ def upgrade() -> None:
nullable=False, nullable=False,
server_default="received", server_default="received",
), ),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"), sa.Column(
"attempt_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column("last_error", sa.Text(), nullable=True), sa.Column("last_error", sa.Text(), nullable=True),
sa.Column( sa.Column(
"received_at", "received_at",
@ -405,7 +420,9 @@ def upgrade() -> None:
["account_id"], ["external_chat_accounts.id"], ondelete="CASCADE" ["account_id"], ["external_chat_accounts.id"], ondelete="CASCADE"
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["external_chat_binding_id"], ["external_chat_bindings.id"], ondelete="SET NULL" ["external_chat_binding_id"],
["external_chat_bindings.id"],
ondelete="SET NULL",
), ),
sa.UniqueConstraint( sa.UniqueConstraint(
"account_id", "account_id",
@ -445,7 +462,9 @@ def upgrade() -> None:
sa.Column("external_chat_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_external_chat_external_chat_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_external_chat_external_chat_binding_id", "fk_new_chat_threads_external_chat_external_chat_binding_id",
@ -455,7 +474,9 @@ def upgrade() -> None:
["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_external_chat_binding_id", "ix_new_chat_threads_external_chat_binding_id",
"new_chat_threads", "new_chat_threads",
@ -472,7 +493,11 @@ def upgrade() -> None:
if not _column_exists(conn, "new_chat_messages", "platform_metadata"): if not _column_exists(conn, "new_chat_messages", "platform_metadata"):
op.add_column( op.add_column(
"new_chat_messages", "new_chat_messages",
sa.Column("platform_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column(
"platform_metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
) )
op.create_index( op.create_index(
"ix_new_chat_messages_source", "ix_new_chat_messages_source",
@ -553,11 +578,15 @@ def downgrade() -> 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-downgrade'") sa.text(
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-144-downgrade'"
)
) )
conn.execute(sa.text(ddl)) conn.execute(sa.text(ddl))
conn.execute( conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-144-downgrade'") sa.text(
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-144-downgrade'"
)
) )
if _column_exists(conn, "new_chat_messages", "source"): if _column_exists(conn, "new_chat_messages", "source"):
@ -567,10 +596,14 @@ 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_external_chat_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_external_chat_external_chat_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_external_chat_external_chat_binding_id", "fk_new_chat_threads_external_chat_external_chat_binding_id",
@ -583,8 +616,12 @@ def downgrade() -> None:
_drop_index_if_exists( _drop_index_if_exists(
"ix_external_chat_inbound_binding_received_at", "external_chat_inbound_events" "ix_external_chat_inbound_binding_received_at", "external_chat_inbound_events"
) )
_drop_index_if_exists("ix_external_chat_inbound_request_id", "external_chat_inbound_events") _drop_index_if_exists(
_drop_index_if_exists("ix_external_chat_inbound_status_received_at", "external_chat_inbound_events") "ix_external_chat_inbound_request_id", "external_chat_inbound_events"
)
_drop_index_if_exists(
"ix_external_chat_inbound_status_received_at", "external_chat_inbound_events"
)
if _table_exists(conn, "external_chat_inbound_events"): if _table_exists(conn, "external_chat_inbound_events"):
op.drop_table("external_chat_inbound_events") op.drop_table("external_chat_inbound_events")
@ -606,9 +643,15 @@ def downgrade() -> None:
if _table_exists(conn, "external_chat_bindings"): if _table_exists(conn, "external_chat_bindings"):
op.drop_table("external_chat_bindings") op.drop_table("external_chat_bindings")
_drop_index_if_exists("uq_external_chat_accounts_system_platform", "external_chat_accounts") _drop_index_if_exists(
_drop_index_if_exists("uq_external_chat_accounts_owner_platform", "external_chat_accounts") "uq_external_chat_accounts_system_platform", "external_chat_accounts"
_drop_index_if_exists("uq_external_chat_accounts_webhook_secret", "external_chat_accounts") )
_drop_index_if_exists(
"uq_external_chat_accounts_owner_platform", "external_chat_accounts"
)
_drop_index_if_exists(
"uq_external_chat_accounts_webhook_secret", "external_chat_accounts"
)
if _table_exists(conn, "external_chat_accounts"): if _table_exists(conn, "external_chat_accounts"):
op.drop_table("external_chat_accounts") op.drop_table("external_chat_accounts")

View file

@ -63,8 +63,7 @@ def upgrade() -> None:
"ON document_files(search_space_id);" "ON document_files(search_space_id);"
) )
op.execute( op.execute(
"CREATE INDEX IF NOT EXISTS ix_document_files_kind " "CREATE INDEX IF NOT EXISTS ix_document_files_kind ON document_files(kind);"
"ON document_files(kind);"
) )
op.execute( op.execute(
"CREATE INDEX IF NOT EXISTS ix_document_files_created_by_id " "CREATE INDEX IF NOT EXISTS ix_document_files_created_by_id "

View file

@ -68,8 +68,12 @@ def _has_zero_version(conn, table: str) -> bool:
def _set_table_ddl(*, with_automation_runs: bool, conn) -> str: def _set_table_ddl(*, with_automation_runs: bool, conn) -> str:
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if _has_zero_version(conn, "documents") else []) doc_cols = DOCUMENT_COLS + (
user_cols = USER_COLS + (['"_0_version"'] if _has_zero_version(conn, "user") else []) ['"_0_version"'] if _has_zero_version(conn, "documents") else []
)
user_cols = USER_COLS + (
['"_0_version"'] if _has_zero_version(conn, "user") else []
)
tables = [ tables = [
"notifications", "notifications",
f"documents ({', '.join(doc_cols)})", f"documents ({', '.join(doc_cols)})",
@ -96,9 +100,17 @@ def _resync(*, with_automation_runs: bool, tag: str) -> 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(sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-{tag}'")) conn.execute(
conn.execute(sa.text(_set_table_ddl(with_automation_runs=with_automation_runs, conn=conn))) sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-{tag}'")
conn.execute(sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-{tag}'")) )
conn.execute(
sa.text(
_set_table_ddl(with_automation_runs=with_automation_runs, conn=conn)
)
)
conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-{tag}'")
)
def upgrade() -> None: def upgrade() -> None:

View file

@ -67,8 +67,12 @@ def _has_zero_version(conn, table: str) -> bool:
def _set_table_ddl(conn) -> str: def _set_table_ddl(conn) -> str:
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if _has_zero_version(conn, "documents") else []) doc_cols = DOCUMENT_COLS + (
user_cols = USER_COLS + (['"_0_version"'] if _has_zero_version(conn, "user") else []) ['"_0_version"'] if _has_zero_version(conn, "documents") else []
)
user_cols = USER_COLS + (
['"_0_version"'] if _has_zero_version(conn, "user") else []
)
tables = [ tables = [
"notifications", "notifications",
f"documents ({', '.join(doc_cols)})", f"documents ({', '.join(doc_cols)})",
@ -94,9 +98,13 @@ def _resync_zero_publication(tag: str) -> 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(sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-{tag}'")) conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-{tag}'")
)
conn.execute(sa.text(_set_table_ddl(conn))) conn.execute(sa.text(_set_table_ddl(conn)))
conn.execute(sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-{tag}'")) conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-{tag}'")
)
def upgrade() -> None: def upgrade() -> None:
@ -117,7 +125,12 @@ def downgrade() -> None:
if not _column_exists(conn, "searchspaces", "document_summary_llm_id"): if not _column_exists(conn, "searchspaces", "document_summary_llm_id"):
op.add_column( op.add_column(
"searchspaces", "searchspaces",
sa.Column("document_summary_llm_id", sa.Integer(), nullable=True, server_default="0"), sa.Column(
"document_summary_llm_id",
sa.Integer(),
nullable=True,
server_default="0",
),
) )
if not _column_exists(conn, "search_source_connectors", "enable_summary"): if not _column_exists(conn, "search_source_connectors", "enable_summary"):

View file

@ -40,44 +40,149 @@ class ToolMetadata:
# up in the UI tool picker. This list carries metadata only — wire the actual # up in the UI tool picker. This list carries metadata only — wire the actual
# implementation in the relevant builder/registry module. # implementation in the relevant builder/registry module.
TOOL_CATALOG: list[ToolMetadata] = [ TOOL_CATALOG: list[ToolMetadata] = [
ToolMetadata(name="generate_podcast", description="Generate an audio podcast from provided content"), ToolMetadata(
ToolMetadata(name="generate_video_presentation", description="Generate a video presentation with slides and narration from provided content"), name="generate_podcast",
ToolMetadata(name="generate_report", description="Generate a structured report from provided content and export it"), description="Generate an audio podcast from provided content",
ToolMetadata(name="generate_resume", description="Generate a professional resume as a Typst document"), ),
ToolMetadata(name="generate_image", description="Generate images from text descriptions using AI image models"), ToolMetadata(
ToolMetadata(name="scrape_webpage", description="Scrape and extract the main content from a webpage"), name="generate_video_presentation",
ToolMetadata(name="web_search", description="Search the web for real-time information using configured search engines"), description="Generate a video presentation with slides and narration from provided content",
ToolMetadata(name="create_automation", description="Draft an automation from an NL intent; user approves the card; tool saves"), ),
ToolMetadata(name="update_memory", description="Save important long-term facts, preferences, and instructions to the (personal or team) memory"), ToolMetadata(
ToolMetadata(name="create_notion_page", description="Create a new page in the user's Notion workspace"), name="generate_report",
ToolMetadata(name="update_notion_page", description="Append new content to an existing Notion page"), description="Generate a structured report from provided content and export it",
ToolMetadata(name="delete_notion_page", description="Delete an existing Notion page"), ),
ToolMetadata(name="create_google_drive_file", description="Create a new Google Doc or Google Sheet in Google Drive"), ToolMetadata(
ToolMetadata(name="delete_google_drive_file", description="Move an indexed Google Drive file to trash"), name="generate_resume",
ToolMetadata(name="create_dropbox_file", description="Create a new file in Dropbox"), description="Generate a professional resume as a Typst document",
),
ToolMetadata(
name="generate_image",
description="Generate images from text descriptions using AI image models",
),
ToolMetadata(
name="scrape_webpage",
description="Scrape and extract the main content from a webpage",
),
ToolMetadata(
name="web_search",
description="Search the web for real-time information using configured search engines",
),
ToolMetadata(
name="create_automation",
description="Draft an automation from an NL intent; user approves the card; tool saves",
),
ToolMetadata(
name="update_memory",
description="Save important long-term facts, preferences, and instructions to the (personal or team) memory",
),
ToolMetadata(
name="create_notion_page",
description="Create a new page in the user's Notion workspace",
),
ToolMetadata(
name="update_notion_page",
description="Append new content to an existing Notion page",
),
ToolMetadata(
name="delete_notion_page", description="Delete an existing Notion page"
),
ToolMetadata(
name="create_google_drive_file",
description="Create a new Google Doc or Google Sheet in Google Drive",
),
ToolMetadata(
name="delete_google_drive_file",
description="Move an indexed Google Drive file to trash",
),
ToolMetadata(
name="create_dropbox_file", description="Create a new file in Dropbox"
),
ToolMetadata(name="delete_dropbox_file", description="Delete a file from Dropbox"), ToolMetadata(name="delete_dropbox_file", description="Delete a file from Dropbox"),
ToolMetadata(name="create_onedrive_file", description="Create a new file in Microsoft OneDrive"), ToolMetadata(
ToolMetadata(name="delete_onedrive_file", description="Move a OneDrive file to the recycle bin"), name="create_onedrive_file",
ToolMetadata(name="search_calendar_events", description="Search Google Calendar events within a date range"), description="Create a new file in Microsoft OneDrive",
ToolMetadata(name="create_calendar_event", description="Create a new event on Google Calendar"), ),
ToolMetadata(name="update_calendar_event", description="Update an existing indexed Google Calendar event"), ToolMetadata(
ToolMetadata(name="delete_calendar_event", description="Delete an existing indexed Google Calendar event"), name="delete_onedrive_file",
ToolMetadata(name="search_gmail", description="Search emails in Gmail using Gmail search syntax"), description="Move a OneDrive file to the recycle bin",
ToolMetadata(name="read_gmail_email", description="Read the full content of a specific Gmail email"), ),
ToolMetadata(name="create_gmail_draft", description="Create a draft email in Gmail"), ToolMetadata(
name="search_calendar_events",
description="Search Google Calendar events within a date range",
),
ToolMetadata(
name="create_calendar_event",
description="Create a new event on Google Calendar",
),
ToolMetadata(
name="update_calendar_event",
description="Update an existing indexed Google Calendar event",
),
ToolMetadata(
name="delete_calendar_event",
description="Delete an existing indexed Google Calendar event",
),
ToolMetadata(
name="search_gmail",
description="Search emails in Gmail using Gmail search syntax",
),
ToolMetadata(
name="read_gmail_email",
description="Read the full content of a specific Gmail email",
),
ToolMetadata(
name="create_gmail_draft", description="Create a draft email in Gmail"
),
ToolMetadata(name="send_gmail_email", description="Send an email via Gmail"), ToolMetadata(name="send_gmail_email", description="Send an email via Gmail"),
ToolMetadata(name="trash_gmail_email", description="Move an indexed email to trash in Gmail"), ToolMetadata(
ToolMetadata(name="update_gmail_draft", description="Update an existing Gmail draft"), name="trash_gmail_email", description="Move an indexed email to trash in Gmail"
ToolMetadata(name="create_confluence_page", description="Create a new page in the user's Confluence space"), ),
ToolMetadata(name="update_confluence_page", description="Update an existing indexed Confluence page"), ToolMetadata(
ToolMetadata(name="delete_confluence_page", description="Delete an existing indexed Confluence page"), name="update_gmail_draft", description="Update an existing Gmail draft"
ToolMetadata(name="list_discord_channels", description="List text channels in the connected Discord server"), ),
ToolMetadata(name="read_discord_messages", description="Read recent messages from a Discord text channel"), ToolMetadata(
ToolMetadata(name="send_discord_message", description="Send a message to a Discord text channel"), name="create_confluence_page",
ToolMetadata(name="list_teams_channels", description="List Microsoft Teams and their channels"), description="Create a new page in the user's Confluence space",
ToolMetadata(name="read_teams_messages", description="Read recent messages from a Microsoft Teams channel"), ),
ToolMetadata(name="send_teams_message", description="Send a message to a Microsoft Teams channel"), ToolMetadata(
ToolMetadata(name="list_luma_events", description="List upcoming and recent Luma events"), name="update_confluence_page",
ToolMetadata(name="read_luma_event", description="Read detailed information about a specific Luma event"), description="Update an existing indexed Confluence page",
),
ToolMetadata(
name="delete_confluence_page",
description="Delete an existing indexed Confluence page",
),
ToolMetadata(
name="list_discord_channels",
description="List text channels in the connected Discord server",
),
ToolMetadata(
name="read_discord_messages",
description="Read recent messages from a Discord text channel",
),
ToolMetadata(
name="send_discord_message",
description="Send a message to a Discord text channel",
),
ToolMetadata(
name="list_teams_channels",
description="List Microsoft Teams and their channels",
),
ToolMetadata(
name="read_teams_messages",
description="Read recent messages from a Microsoft Teams channel",
),
ToolMetadata(
name="send_teams_message",
description="Send a message to a Microsoft Teams channel",
),
ToolMetadata(
name="list_luma_events", description="List upcoming and recent Luma events"
),
ToolMetadata(
name="read_luma_event",
description="Read detailed information about a specific Luma event",
),
ToolMetadata(name="create_luma_event", description="Create a new event on Luma"), ToolMetadata(name="create_luma_event", description="Create a new event on Luma"),
] ]

View file

@ -308,12 +308,8 @@ def load_openrouter_integration_settings() -> dict | None:
"anonymous_enabled_free instead. Both new flags have been " "anonymous_enabled_free instead. Both new flags have been "
"seeded from the legacy value for back-compat." "seeded from the legacy value for back-compat."
) )
settings.setdefault( settings.setdefault("anonymous_enabled_paid", settings["anonymous_enabled"])
"anonymous_enabled_paid", settings["anonymous_enabled"] settings.setdefault("anonymous_enabled_free", settings["anonymous_enabled"])
)
settings.setdefault(
"anonymous_enabled_free", settings["anonymous_enabled"]
)
# Image generation + vision LLM emission are opt-in (issue L). # Image generation + vision LLM emission are opt-in (issue L).
# OpenRouter's catalogue contains hundreds of image / vision # OpenRouter's catalogue contains hundreds of image / vision
@ -622,7 +618,9 @@ class Config:
WHATSAPP_GRAPH_API_VERSION = os.getenv("WHATSAPP_GRAPH_API_VERSION", "v25.0") WHATSAPP_GRAPH_API_VERSION = os.getenv("WHATSAPP_GRAPH_API_VERSION", "v25.0")
WHATSAPP_WEBHOOK_VERIFY_TOKEN = os.getenv("WHATSAPP_WEBHOOK_VERIFY_TOKEN") WHATSAPP_WEBHOOK_VERIFY_TOKEN = os.getenv("WHATSAPP_WEBHOOK_VERIFY_TOKEN")
WHATSAPP_WEBHOOK_APP_SECRET = os.getenv("WHATSAPP_WEBHOOK_APP_SECRET") WHATSAPP_WEBHOOK_APP_SECRET = os.getenv("WHATSAPP_WEBHOOK_APP_SECRET")
WHATSAPP_BRIDGE_URL = os.getenv("WHATSAPP_BRIDGE_URL", "http://whatsapp-bridge:9929") WHATSAPP_BRIDGE_URL = os.getenv(
"WHATSAPP_BRIDGE_URL", "http://whatsapp-bridge:9929"
)
GATEWAY_WHATSAPP_INTAKE_MODE = os.getenv( GATEWAY_WHATSAPP_INTAKE_MODE = os.getenv(
"GATEWAY_WHATSAPP_INTAKE_MODE", "disabled" "GATEWAY_WHATSAPP_INTAKE_MODE", "disabled"
).lower() ).lower()
@ -632,7 +630,9 @@ class Config:
) )
GATEWAY_SLACK_CLIENT_ID = os.getenv("SLACK_CLIENT_ID") GATEWAY_SLACK_CLIENT_ID = os.getenv("SLACK_CLIENT_ID")
GATEWAY_SLACK_CLIENT_SECRET = os.getenv("SLACK_CLIENT_SECRET") GATEWAY_SLACK_CLIENT_SECRET = os.getenv("SLACK_CLIENT_SECRET")
GATEWAY_SLACK_ENABLED = os.getenv("GATEWAY_SLACK_ENABLED", "FALSE").upper() == "TRUE" GATEWAY_SLACK_ENABLED = (
os.getenv("GATEWAY_SLACK_ENABLED", "FALSE").upper() == "TRUE"
)
GATEWAY_SLACK_SIGNING_SECRET = os.getenv("GATEWAY_SLACK_SIGNING_SECRET") GATEWAY_SLACK_SIGNING_SECRET = os.getenv("GATEWAY_SLACK_SIGNING_SECRET")
GATEWAY_SLACK_REDIRECT_URI = os.getenv("GATEWAY_SLACK_REDIRECT_URI") GATEWAY_SLACK_REDIRECT_URI = os.getenv("GATEWAY_SLACK_REDIRECT_URI")
GATEWAY_DISCORD_ENABLED = ( GATEWAY_DISCORD_ENABLED = (

View file

@ -105,14 +105,18 @@ class WebCrawlerConnector:
logger.info(f"[webcrawler] Using Scrapling AsyncFetcher for: {url}") logger.info(f"[webcrawler] Using Scrapling AsyncFetcher for: {url}")
result = await self._crawl_with_async_fetcher(url) result = await self._crawl_with_async_fetcher(url)
if result: if result:
self._log_tier_outcome("scrapling-static", url, tier_start, "success") self._log_tier_outcome(
"scrapling-static", url, tier_start, "success"
)
self._log_total(url, "scrapling-static", total_start) self._log_total(url, "scrapling-static", total_start)
return result, None return result, None
errors.append("Scrapling static: empty extraction") errors.append("Scrapling static: empty extraction")
self._log_tier_outcome("scrapling-static", url, tier_start, "empty") self._log_tier_outcome("scrapling-static", url, tier_start, "empty")
except Exception as exc: except Exception as exc:
errors.append(f"Scrapling static: {exc!s}") errors.append(f"Scrapling static: {exc!s}")
self._log_tier_outcome("scrapling-static", url, tier_start, "error", exc) self._log_tier_outcome(
"scrapling-static", url, tier_start, "error", exc
)
# --- 3. Scrapling DynamicFetcher (full browser) --- # --- 3. Scrapling DynamicFetcher (full browser) ---
tier_start = time.perf_counter() tier_start = time.perf_counter()
@ -120,7 +124,9 @@ class WebCrawlerConnector:
logger.info(f"[webcrawler] Using Scrapling DynamicFetcher for: {url}") logger.info(f"[webcrawler] Using Scrapling DynamicFetcher for: {url}")
result = await self._crawl_with_dynamic(url) result = await self._crawl_with_dynamic(url)
if result: if result:
self._log_tier_outcome("scrapling-dynamic", url, tier_start, "success") self._log_tier_outcome(
"scrapling-dynamic", url, tier_start, "success"
)
self._log_total(url, "scrapling-dynamic", total_start) self._log_total(url, "scrapling-dynamic", total_start)
return result, None return result, None
errors.append("Scrapling dynamic: empty extraction") errors.append("Scrapling dynamic: empty extraction")
@ -135,7 +141,9 @@ class WebCrawlerConnector:
) )
except Exception as exc: except Exception as exc:
errors.append(f"Scrapling dynamic: {exc!s}") errors.append(f"Scrapling dynamic: {exc!s}")
self._log_tier_outcome("scrapling-dynamic", url, tier_start, "error", exc) self._log_tier_outcome(
"scrapling-dynamic", url, tier_start, "error", exc
)
# --- 4. Scrapling StealthyFetcher (anti-bot, last resort) --- # --- 4. Scrapling StealthyFetcher (anti-bot, last resort) ---
tier_start = time.perf_counter() tier_start = time.perf_counter()
@ -143,7 +151,9 @@ class WebCrawlerConnector:
logger.info(f"[webcrawler] Using Scrapling StealthyFetcher for: {url}") logger.info(f"[webcrawler] Using Scrapling StealthyFetcher for: {url}")
result = await self._crawl_with_stealthy(url) result = await self._crawl_with_stealthy(url)
if result: if result:
self._log_tier_outcome("scrapling-stealthy", url, tier_start, "success") self._log_tier_outcome(
"scrapling-stealthy", url, tier_start, "success"
)
self._log_total(url, "scrapling-stealthy", total_start) self._log_total(url, "scrapling-stealthy", total_start)
return result, None return result, None
errors.append("Scrapling stealthy: empty extraction") errors.append("Scrapling stealthy: empty extraction")

View file

@ -714,7 +714,9 @@ class NewChatThread(BaseModel, TimestampMixin):
# Surface metadata for first-party SurfSense and external chat threads. # Surface metadata for first-party SurfSense and external chat threads.
# Zero publishes all chat-message sources; the UI can decide which surfaces to render. # Zero publishes all chat-message sources; the UI can decide which surfaces to render.
source = Column(Text, nullable=False, default="surfsense", server_default="surfsense") source = Column(
Text, nullable=False, default="surfsense", server_default="surfsense"
)
external_chat_binding_id = Column( external_chat_binding_id = Column(
BigInteger, BigInteger,
ForeignKey("external_chat_bindings.id", ondelete="SET NULL"), ForeignKey("external_chat_bindings.id", ondelete="SET NULL"),
@ -802,7 +804,9 @@ class NewChatMessage(BaseModel, TimestampMixin):
# Mirrors the parent thread source for publication-level filtering. # Mirrors the parent thread source for publication-level filtering.
# This denormalization avoids join-dependent logical replication rules. # This denormalization avoids join-dependent logical replication rules.
source = Column(Text, nullable=False, default="surfsense", server_default="surfsense") source = Column(
Text, nullable=False, default="surfsense", server_default="surfsense"
)
platform_metadata = Column(JSONB, nullable=True) platform_metadata = Column(JSONB, nullable=True)
# Relationships # Relationships
@ -848,11 +852,15 @@ class ExternalChatAccount(Base, TimestampMixin):
owner_search_space_id = Column( owner_search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True
) )
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)
bot_username = Column(String(255), nullable=True) bot_username = Column(String(255), nullable=True)
webhook_secret = Column(String(64), 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(
ExternalChatHealthStatus, ExternalChatHealthStatus,
@ -875,7 +883,9 @@ class ExternalChatAccount(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(
"ExternalChatBinding", "ExternalChatBinding",
back_populates="account", back_populates="account",
@ -980,7 +990,9 @@ class ExternalChatBinding(Base, TimestampMixin):
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_metadata = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")) external_metadata = Column(
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
)
new_chat_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"),
@ -1030,7 +1042,9 @@ class ExternalChatBinding(Base, TimestampMixin):
postgresql_where=text("state = 'pending'"), postgresql_where=text("state = 'pending'"),
), ),
Index("ix_external_chat_bindings_user_state", "user_id", "state"), Index("ix_external_chat_bindings_user_state", "user_id", "state"),
Index("ix_external_chat_bindings_search_space_state", "search_space_id", "state"), Index(
"ix_external_chat_bindings_search_space_state", "search_space_id", "state"
),
) )

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from app.file_storage.backends.base import StorageBackend from app.file_storage.backends.base import StorageBackend
@ -43,10 +44,8 @@ class AzureBlobBackend(StorageBackend):
async with self._service() as service: async with self._service() as service:
blob = service.get_blob_client(self._container, key) blob = service.get_blob_client(self._container, key)
try: with contextlib.suppress(ResourceNotFoundError):
await blob.delete_blob() await blob.delete_blob()
except ResourceNotFoundError:
pass
async def exists(self, key: str) -> bool: async def exists(self, key: str) -> bool:
async with self._service() as service: async with self._service() as service:

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
@ -53,10 +54,8 @@ class LocalFileBackend(StorageBackend):
path = self._path_for(key) path = self._path_for(key)
def _unlink() -> None: def _unlink() -> None:
try: with contextlib.suppress(FileNotFoundError):
path.unlink() path.unlink()
except FileNotFoundError:
pass
await asyncio.to_thread(_unlink) await asyncio.to_thread(_unlink)

View file

@ -31,7 +31,9 @@ def slack_account_credentials(account: ExternalChatAccount) -> dict:
"""Decrypt Slack gateway credentials stored as encrypted JSON.""" """Decrypt Slack gateway credentials stored as encrypted JSON."""
if not account.encrypted_credentials: if not account.encrypted_credentials:
return {} return {}
raw = TokenEncryption(config.SECRET_KEY or "").decrypt_token(account.encrypted_credentials) raw = TokenEncryption(config.SECRET_KEY or "").decrypt_token(
account.encrypted_credentials
)
try: try:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
@ -44,7 +46,9 @@ def discord_account_credentials(account: ExternalChatAccount) -> dict:
"""Decrypt Discord gateway credentials stored as encrypted JSON.""" """Decrypt Discord gateway credentials stored as encrypted JSON."""
if not account.encrypted_credentials: if not account.encrypted_credentials:
return {} return {}
raw = TokenEncryption(config.SECRET_KEY or "").decrypt_token(account.encrypted_credentials) raw = TokenEncryption(config.SECRET_KEY or "").decrypt_token(
account.encrypted_credentials
)
try: try:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
@ -135,4 +139,3 @@ async def get_discord_account_by_guild(
) )
) )
return result.scalars().first() return result.scalars().first()

View file

@ -21,7 +21,9 @@ from app.tasks.chat.streaming.flows import stream_new_chat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _events_from_sse(chunks: AsyncIterator[str]) -> AsyncIterator[GatewayStreamEvent]: async def _events_from_sse(
chunks: AsyncIterator[str],
) -> AsyncIterator[GatewayStreamEvent]:
saw_text = False saw_text = False
async for chunk in chunks: async for chunk in chunks:
for raw_line in chunk.splitlines(): for raw_line in chunk.splitlines():
@ -98,4 +100,3 @@ async def call_agent_for_gateway(
record_gateway_turn_latency(0, platform=platform_label) record_gateway_turn_latency(0, platform=platform_label)
finally: finally:
release_thread_lock(thread.id) release_thread_lock(thread.id)

View file

@ -52,4 +52,3 @@ async def assert_authorization_invariant(
await _fail(session, binding, f"rbac_{exc.status_code}") await _fail(session, binding, f"rbac_{exc.status_code}")
return user return user

View file

@ -1,2 +1 @@
"""Base gateway interfaces.""" """Base gateway interfaces."""

View file

@ -62,9 +62,10 @@ class BasePlatformAdapter(ABC):
async def validate_credentials(self) -> dict[str, Any]: async def validate_credentials(self) -> dict[str, Any]:
"""Validate configured credentials and return account metadata.""" """Validate configured credentials and return account metadata."""
async def fetch_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]: async def fetch_updates(
self, *, offset: int | None
) -> AsyncIterator[dict[str, Any]]:
"""Yield provider updates for long-polling adapters.""" """Yield provider updates for long-polling adapters."""
if False: if False:
yield {} # pragma: no cover yield {} # pragma: no cover
raise NotImplementedError("This adapter does not support long-polling") raise NotImplementedError("This adapter does not support long-polling")

View file

@ -16,4 +16,3 @@ def hash_external_id(value: str | int | None) -> str | None:
if not normalized: if not normalized:
return None return None
return hashlib.sha256(normalized.encode("utf-8")).hexdigest() return hashlib.sha256(normalized.encode("utf-8")).hexdigest()

View file

@ -25,4 +25,3 @@ class BaseStreamTranslator(ABC):
@abstractmethod @abstractmethod
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None: async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
"""Consume agent stream events and emit platform messages.""" """Consume agent stream events and emit platform messages."""

View file

@ -64,4 +64,3 @@ def resume_binding(binding: ExternalChatBinding) -> None:
binding.state = ExternalChatBindingState.BOUND binding.state = ExternalChatBindingState.BOUND
binding.suspended_at = None binding.suspended_at = None
binding.suspended_reason = None binding.suspended_reason = None

View file

@ -58,8 +58,10 @@ async def _whatsapp_baileys_supervisor() -> None:
async with async_session_maker() as session: async with async_session_maker() as session:
result = await session.execute( result = await session.execute(
select(ExternalChatAccount).where( select(ExternalChatAccount).where(
ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP, ExternalChatAccount.platform
ExternalChatAccount.mode == ExternalChatAccountMode.SELF_HOST_BYO, == ExternalChatPlatform.WHATSAPP,
ExternalChatAccount.mode
== ExternalChatAccountMode.SELF_HOST_BYO,
ExternalChatAccount.is_system_account.is_(False), ExternalChatAccount.is_system_account.is_(False),
ExternalChatAccount.suspended_at.is_(None), ExternalChatAccount.suspended_at.is_(None),
) )
@ -128,7 +130,9 @@ async def start_byo_long_poll_supervisors() -> None:
) )
_tasks.add(task) _tasks.add(task)
task.add_done_callback(_tasks.discard) task.add_done_callback(_tasks.discard)
logger.info("Started BYO Telegram long-poll supervisor account_id=%s", account.id) logger.info(
"Started BYO Telegram long-poll supervisor account_id=%s", account.id
)
if config.GATEWAY_WHATSAPP_INTAKE_MODE == "baileys": if config.GATEWAY_WHATSAPP_INTAKE_MODE == "baileys":
task = asyncio.create_task( task = asyncio.create_task(
@ -151,9 +155,12 @@ async def stop_byo_long_poll_supervisors() -> None:
task.cancel() task.cancel()
if tasks: if tasks:
try: try:
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=10) await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True), timeout=10
)
except TimeoutError: except TimeoutError:
logger.warning("Timed out waiting for BYO Telegram long-poll supervisors to stop") logger.warning(
"Timed out waiting for BYO Telegram long-poll supervisors to stop"
)
_tasks.clear() _tasks.clear()
_shutdown_event = None _shutdown_event = None

View file

@ -39,7 +39,9 @@ def _message_reference_payload(message: discord.Message) -> dict[str, Any] | Non
} }
def _serialize_message(message: discord.Message, *, bot_user_id: str | None) -> dict[str, Any]: def _serialize_message(
message: discord.Message, *, bot_user_id: str | None
) -> dict[str, Any]:
guild = message.guild guild = message.guild
channel = message.channel channel = message.channel
thread_id = str(channel.id) if isinstance(channel, discord.Thread) else None thread_id = str(channel.id) if isinstance(channel, discord.Thread) else None
@ -62,8 +64,7 @@ def _serialize_message(message: discord.Message, *, bot_user_id: str | None) ->
"bot": message.author.bot, "bot": message.author.bot,
}, },
"mentions": [ "mentions": [
{"id": str(user.id), "username": user.name} {"id": str(user.id), "username": user.name} for user in message.mentions
for user in message.mentions
], ],
"message_reference": _message_reference_payload(message), "message_reference": _message_reference_payload(message),
"created_at": message.created_at.isoformat() "created_at": message.created_at.isoformat()
@ -73,7 +74,9 @@ def _serialize_message(message: discord.Message, *, bot_user_id: str | None) ->
} }
async def _persist_message(message: discord.Message, *, bot_user_id: str | None) -> None: async def _persist_message(
message: discord.Message, *, bot_user_id: str | None
) -> None:
if message.guild is None: if message.guild is None:
return return
guild_id = str(message.guild.id) guild_id = str(message.guild.id)
@ -82,7 +85,9 @@ async def _persist_message(message: discord.Message, *, bot_user_id: str | None)
async with async_session_maker() as session: async with async_session_maker() as session:
account = await get_discord_account_by_guild(session, guild_id=guild_id) account = await get_discord_account_by_guild(session, guild_id=guild_id)
if account is None: if account is None:
logger.info("Ignoring Discord message for uninstalled guild_id=%s", guild_id) logger.info(
"Ignoring Discord message for uninstalled guild_id=%s", guild_id
)
return return
inbox_id = await persist_inbound_event( inbox_id = await persist_inbound_event(
@ -144,7 +149,9 @@ def _build_client() -> discord.Client:
try: try:
await _persist_message(message, bot_user_id=bot_user_id) await _persist_message(message, bot_user_id=bot_user_id)
except Exception: except Exception:
logger.exception("Discord gateway failed to persist message_id=%s", message.id) logger.exception(
"Discord gateway failed to persist message_id=%s", message.id
)
return client return client

View file

@ -41,7 +41,9 @@ class DiscordStreamTranslator(BaseStreamTranslator):
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None: async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
async for event in events: async for event in events:
if event.type in {"text-delta", "text_delta", "text"}: if event.type in {"text-delta", "text_delta", "text"}:
self._buffer += str(event.data.get("text") or event.data.get("delta") or "") self._buffer += str(
event.data.get("text") or event.data.get("delta") or ""
)
elif event.type in {"data-interrupt-request", "interrupt"}: elif event.type in {"data-interrupt-request", "interrupt"}:
await self._handle_hitl_interrupt() await self._handle_hitl_interrupt()
return return
@ -53,7 +55,9 @@ class DiscordStreamTranslator(BaseStreamTranslator):
async def _flush_final(self) -> None: async def _flush_final(self) -> None:
if not self._buffer: if not self._buffer:
return return
for chunk in split_text_message(self._buffer, max_chars=DISCORD_MAX_MESSAGE_CHARS): for chunk in split_text_message(
self._buffer, max_chars=DISCORD_MAX_MESSAGE_CHARS
):
await self._send_text(chunk) await self._send_text(chunk)
async def _send_text(self, text: str) -> PlatformSendResult: async def _send_text(self, text: str) -> PlatformSendResult:

View file

@ -32,4 +32,3 @@ def filter_hitl_tools(
return None return None
blocked = blocked_names or DEFAULT_HITL_TOOL_NAMES blocked = blocked_names or DEFAULT_HITL_TOOL_NAMES
return [tool for tool in toolkit if (_tool_name(tool) or "") not in blocked] return [tool for tool in toolkit if (_tool_name(tool) or "") not in blocked]

View file

@ -51,4 +51,3 @@ async def persist_inbound_event(
) )
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()

View file

@ -128,7 +128,9 @@ async def process_inbound_event(
event.status = ExternalChatEventStatus.PROCESSED event.status = ExternalChatEventStatus.PROCESSED
event.processed_at = datetime.now(UTC) event.processed_at = datetime.now(UTC)
await session.commit() await session.commit()
record_gateway_inbox_processed(platform=event.platform.value, status="processed") record_gateway_inbox_processed(
platform=event.platform.value, status="processed"
)
async def _mark_failed( async def _mark_failed(
@ -173,7 +175,9 @@ async def _resolve_slack_thread_binding(
parsed, parsed,
) -> ExternalChatBinding | None: ) -> ExternalChatBinding | None:
user_peer_id = parsed.metadata.get("slack_user_peer_id") user_peer_id = parsed.metadata.get("slack_user_peer_id")
thread_peer_id = parsed.metadata.get("slack_thread_peer_id") or parsed.external_peer_id thread_peer_id = (
parsed.metadata.get("slack_thread_peer_id") or parsed.external_peer_id
)
if not user_peer_id or not thread_peer_id: if not user_peer_id or not thread_peer_id:
return None return None
@ -233,7 +237,9 @@ async def _resolve_discord_thread_binding(
parsed, parsed,
) -> ExternalChatBinding | None: ) -> ExternalChatBinding | None:
user_peer_id = parsed.metadata.get("discord_user_peer_id") user_peer_id = parsed.metadata.get("discord_user_peer_id")
thread_peer_id = parsed.metadata.get("discord_thread_peer_id") or parsed.external_peer_id thread_peer_id = (
parsed.metadata.get("discord_thread_peer_id") or parsed.external_peer_id
)
if not user_peer_id or not thread_peer_id: if not user_peer_id or not thread_peer_id:
return None return None
@ -357,7 +363,11 @@ async def _dispatch_inbound_event(
return return
if binding is None: if binding is None:
if bundle.auto_bind_owner and account.owner_user_id and account.owner_search_space_id: if (
bundle.auto_bind_owner
and account.owner_user_id
and account.owner_search_space_id
):
binding = ExternalChatBinding( binding = ExternalChatBinding(
account_id=account.id, account_id=account.id,
user_id=account.owner_user_id, user_id=account.owner_user_id,
@ -385,7 +395,9 @@ async def _dispatch_inbound_event(
event.external_chat_binding_id = binding.id event.external_chat_binding_id = binding.id
if cmd == "/help": if cmd == "/help":
handled = await bundle.commands.handle_help_command(adapter=adapter, event=parsed) handled = await bundle.commands.handle_help_command(
adapter=adapter, event=parsed
)
if handled: if handled:
event.status = ExternalChatEventStatus.PROCESSED event.status = ExternalChatEventStatus.PROCESSED
await session.commit() await session.commit()

View file

@ -55,4 +55,3 @@ async def stop_gateway_inbox_worker() -> None:
with suppress(TimeoutError, asyncio.CancelledError): with suppress(TimeoutError, asyncio.CancelledError):
await asyncio.wait_for(_task, timeout=10) await asyncio.wait_for(_task, timeout=10)
_task = None _task = None

View file

@ -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 ExternalChatBindingState, ExternalChatBinding from app.db import ExternalChatBinding, ExternalChatBindingState
PAIRING_CODE_TTL = timedelta(minutes=10) PAIRING_CODE_TTL = timedelta(minutes=10)
@ -51,4 +51,3 @@ async def redeem_pairing_code(
binding.external_username = external_username binding.external_username = external_username
binding.external_metadata = external_metadata or {} binding.external_metadata = external_metadata or {}
return binding return binding

View file

@ -133,4 +133,3 @@ async def wait_for_token(
if wait_ms > 0: if wait_ms > 0:
await asyncio.sleep(wait_ms / 1000) await asyncio.sleep(wait_ms / 1000)
return wait_ms return wait_ms

View file

@ -186,4 +186,6 @@ def resolve_platform_bundle(account: ExternalChatAccount) -> PlatformBundle:
auto_bind_owner=False, auto_bind_owner=False,
) )
raise RuntimeError(f"unsupported_gateway_platform:{account.platform.value}:{account.mode.value}") raise RuntimeError(
f"unsupported_gateway_platform:{account.platform.value}:{account.mode.value}"
)

View file

@ -8,7 +8,12 @@ import uuid
from sqlalchemy import text from sqlalchemy import text
from app.db import ExternalChatPlatform, ExternalChatAccount, async_session_maker, engine from app.db import (
ExternalChatAccount,
ExternalChatPlatform,
async_session_maker,
engine,
)
from app.gateway.inbox import persist_inbound_event, telegram_event_dedupe_key from app.gateway.inbox import persist_inbound_event, telegram_event_dedupe_key
from app.gateway.telegram.adapter import TelegramAdapter from app.gateway.telegram.adapter import TelegramAdapter
from app.observability.metrics import record_gateway_byo_longpoll_running_delta from app.observability.metrics import record_gateway_byo_longpoll_running_delta
@ -39,7 +44,9 @@ async def _run_telegram_account(account_id: int, token: str) -> None:
account = await session.get(ExternalChatAccount, account_id) account = await session.get(ExternalChatAccount, account_id)
offset = None offset = None
if account is not None: if account is not None:
offset = int((account.cursor_state or {}).get("last_update_id", 0)) + 1 offset = (
int((account.cursor_state or {}).get("last_update_id", 0)) + 1
)
async for update in adapter.fetch_updates(offset=offset): async for update in adapter.fetch_updates(offset=offset):
request_id = f"gateway_{uuid.uuid4().hex[:16]}" request_id = f"gateway_{uuid.uuid4().hex[:16]}"
@ -58,8 +65,11 @@ async def _run_telegram_account(account_id: int, token: str) -> None:
) )
await session.commit() await session.commit()
if inbox_id is not None: if inbox_id is not None:
logger.debug("Persisted Telegram polling update inbox_id=%s", inbox_id) logger.debug(
"Persisted Telegram polling update inbox_id=%s", inbox_id
)
finally: finally:
record_gateway_byo_longpoll_running_delta(-1, account_id=account_id) record_gateway_byo_longpoll_running_delta(-1, account_id=account_id)
await conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key}) await conn.execute(
text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key}
)

View file

@ -38,7 +38,9 @@ class SlackAdapter(BasePlatformAdapter):
slack_user_id = str(event.get("user") or "") slack_user_id = str(event.get("user") or "")
message_ts = str(event.get("ts") or "") message_ts = str(event.get("ts") or "")
thread_ts = str(event.get("thread_ts") or message_ts) thread_ts = str(event.get("thread_ts") or message_ts)
bot_user_id = self.bot_user_id or str(raw_payload.get("authorizations", [{}])[0].get("user_id") or "") bot_user_id = self.bot_user_id or str(
raw_payload.get("authorizations", [{}])[0].get("user_id") or ""
)
if not channel_id or not slack_user_id or not message_ts: if not channel_id or not slack_user_id or not message_ts:
return ParsedInboundEvent( return ParsedInboundEvent(

View file

@ -15,7 +15,9 @@ class SlackGatewayClient:
def __init__(self, bot_token: str) -> None: def __init__(self, bot_token: str) -> None:
self.bot_token = bot_token self.bot_token = bot_token
async def api_call(self, method: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: async def api_call(
self, method: str, payload: dict[str, Any] | None = None
) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=20.0) as client: async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post( response = await client.post(
f"{SLACK_API}/{method}", f"{SLACK_API}/{method}",
@ -55,7 +57,9 @@ class SlackGatewayClient:
ts: str, ts: str,
text: str, text: str,
) -> PlatformSendResult: ) -> PlatformSendResult:
data = await self.api_call("chat.update", {"channel": channel, "ts": ts, "text": text}) data = await self.api_call(
"chat.update", {"channel": channel, "ts": ts, "text": text}
)
return PlatformSendResult( return PlatformSendResult(
external_message_id=str(data.get("ts") or ts), external_message_id=str(data.get("ts") or ts),
raw_response=data, raw_response=data,

View file

@ -41,7 +41,9 @@ class SlackStreamTranslator(BaseStreamTranslator):
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None: async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
async for event in events: async for event in events:
if event.type in {"text-delta", "text_delta", "text"}: if event.type in {"text-delta", "text_delta", "text"}:
self._buffer += str(event.data.get("text") or event.data.get("delta") or "") self._buffer += str(
event.data.get("text") or event.data.get("delta") or ""
)
elif event.type in {"data-interrupt-request", "interrupt"}: elif event.type in {"data-interrupt-request", "interrupt"}:
await self._handle_hitl_interrupt() await self._handle_hitl_interrupt()
return return
@ -53,7 +55,9 @@ class SlackStreamTranslator(BaseStreamTranslator):
async def _flush_final(self) -> None: async def _flush_final(self) -> None:
if not self._buffer: if not self._buffer:
return return
for chunk in split_text_message(self._buffer, max_chars=SLACK_MAX_MESSAGE_CHARS): for chunk in split_text_message(
self._buffer, max_chars=SLACK_MAX_MESSAGE_CHARS
):
await self._send_text(chunk) await self._send_text(chunk)
async def _send_text(self, text: str) -> PlatformSendResult: async def _send_text(self, text: str) -> PlatformSendResult:

View file

@ -1,2 +1 @@
"""Telegram gateway adapter.""" """Telegram gateway adapter."""

View file

@ -51,9 +51,7 @@ class TelegramAdapter(BasePlatformAdapter):
"channel": "channel", "channel": "channel",
}.get(chat_type, "unknown") }.get(chat_type, "unknown")
display_name = chat.get("title") or " ".join( display_name = chat.get("title") or " ".join(
part part for part in (sender.get("first_name"), sender.get("last_name")) if part
for part in (sender.get("first_name"), sender.get("last_name"))
if part
) )
return ParsedInboundEvent( return ParsedInboundEvent(
@ -62,14 +60,21 @@ class TelegramAdapter(BasePlatformAdapter):
external_peer_id=str(chat["id"]) if chat.get("id") is not None else None, external_peer_id=str(chat["id"]) if chat.get("id") is not None else None,
external_peer_kind=peer_kind, external_peer_kind=peer_kind,
external_message_id=( external_message_id=(
str(message["message_id"]) if message.get("message_id") is not None else None str(message["message_id"])
if message.get("message_id") is not None
else None
), ),
external_user_id=str(sender["id"]) if sender.get("id") is not None else None, external_user_id=str(sender["id"])
if sender.get("id") is not None
else None,
text=message.get("text") or message.get("caption"), text=message.get("text") or message.get("caption"),
raw_payload=raw_payload, raw_payload=raw_payload,
display_name=display_name or None, display_name=display_name or None,
username=sender.get("username") or chat.get("username"), username=sender.get("username") or chat.get("username"),
metadata={"chat_type": chat_type, "update_id": raw_payload.get("update_id")}, metadata={
"chat_type": chat_type,
"update_id": raw_payload.get("update_id"),
},
) )
async def send_message( async def send_message(
@ -108,7 +113,8 @@ class TelegramAdapter(BasePlatformAdapter):
async def leave_chat(self, *, external_peer_id: str) -> None: async def leave_chat(self, *, external_peer_id: str) -> None:
await self.client.leave_chat(chat_id=external_peer_id) await self.client.leave_chat(chat_id=external_peer_id)
async def fetch_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]: async def fetch_updates(
self, *, offset: int | None
) -> AsyncIterator[dict[str, Any]]:
async for update in self.client.get_updates(offset=offset): async for update in self.client.get_updates(offset=offset):
yield update yield update

View file

@ -106,4 +106,3 @@ async def retry_plaintext_on_bad_markdown(call, *args, **kwargs) -> PlatformSend
raise raise
kwargs["parse_mode"] = None kwargs["parse_mode"] = None
return await call(*args, **kwargs) return await call(*args, **kwargs)

View file

@ -54,7 +54,9 @@ async def handle_start_command(
return True return True
async def handle_help_command(*, adapter: TelegramAdapter, event: ParsedInboundEvent) -> bool: async def handle_help_command(
*, adapter: TelegramAdapter, event: ParsedInboundEvent
) -> bool:
if not event.external_peer_id: if not event.external_peer_id:
return True return True
await adapter.send_message(external_peer_id=event.external_peer_id, text=HELP_TEXT) await adapter.send_message(external_peer_id=event.external_peer_id, text=HELP_TEXT)

View file

@ -32,9 +32,13 @@ def _split_at_boundary(text: str, max_units: int) -> tuple[str, str]:
end -= 1 end -= 1
candidate = text[:end] candidate = text[:end]
boundary = max(candidate.rfind("\n\n"), candidate.rfind(". "), candidate.rfind("\n")) boundary = max(
candidate.rfind("\n\n"), candidate.rfind(". "), candidate.rfind("\n")
)
if boundary > max(200, end // 2): if boundary > max(200, end // 2):
end = boundary + (2 if candidate[boundary : boundary + 2] in {"\n\n", ". "} else 1) end = boundary + (
2 if candidate[boundary : boundary + 2] in {"\n\n", ". "} else 1
)
return text[:end], text[end:] return text[:end], text[end:]
@ -56,4 +60,3 @@ def chunk_message(
chunks.append(chunk) chunks.append(chunk)
return chunks return chunks
return split_text_message(text, max_chars=max_units) return split_text_message(text, max_chars=max_units)

View file

@ -49,7 +49,9 @@ class TelegramStreamTranslator(BaseStreamTranslator):
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None: async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
async for event in events: async for event in events:
if event.type in {"text-delta", "text_delta", "text"}: if event.type in {"text-delta", "text_delta", "text"}:
self._buffer += str(event.data.get("text") or event.data.get("delta") or "") self._buffer += str(
event.data.get("text") or event.data.get("delta") or ""
)
await self._maybe_flush() await self._maybe_flush()
elif event.type in {"data-interrupt-request", "interrupt"}: elif event.type in {"data-interrupt-request", "interrupt"}:
await self._handle_hitl_interrupt() await self._handle_hitl_interrupt()
@ -159,7 +161,9 @@ class TelegramStreamTranslator(BaseStreamTranslator):
) )
if chat_wait: if chat_wait:
record_gateway_rate_limit_hit(bucket="tg:chat") record_gateway_rate_limit_hit(bucket="tg:chat")
global_wait = await wait_for_token("tg:global", capacity=25, refill_per_sec=25.0) global_wait = await wait_for_token(
"tg:global", capacity=25, refill_per_sec=25.0
)
if global_wait: if global_wait:
record_gateway_rate_limit_hit(bucket="tg:global") record_gateway_rate_limit_hit(bucket="tg:global")
@ -168,4 +172,3 @@ class TelegramStreamTranslator(BaseStreamTranslator):
await self._flush(final=False) await self._flush(final=False)
await self._send_text(HITL_UNSUPPORTED_MESSAGE) await self._send_text(HITL_UNSUPPORTED_MESSAGE)
record_gateway_hitl_aborted(platform="telegram") record_gateway_hitl_aborted(platform="telegram")

View file

@ -36,5 +36,6 @@ def release_thread_lock(thread_id: int) -> None:
try: try:
_redis().delete(_lock_key(thread_id)) _redis().delete(_lock_key(thread_id))
except redis.RedisError as exc: except redis.RedisError as exc:
logger.warning("Failed to release gateway thread lock for %s: %s", thread_id, exc) logger.warning(
"Failed to release gateway thread lock for %s: %s", thread_id, exc
)

View file

@ -36,7 +36,8 @@ class WhatsAppBaileysAdapter(BasePlatformAdapter):
external_user_id=sender_id or None, external_user_id=sender_id or None,
text=str(body) if body is not None else None, text=str(body) if body is not None else None,
raw_payload=raw_payload, raw_payload=raw_payload,
display_name=str(raw_payload.get("chatName") or sender_id or chat_id) or None, display_name=str(raw_payload.get("chatName") or sender_id or chat_id)
or None,
username=None, username=None,
metadata={ metadata={
"sender_id": sender_id, "sender_id": sender_id,
@ -92,7 +93,9 @@ class WhatsAppBaileysAdapter(BasePlatformAdapter):
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def fetch_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]: async def fetch_updates(
self, *, offset: int | None
) -> AsyncIterator[dict[str, Any]]:
async with httpx.AsyncClient(timeout=35) as client: async with httpx.AsyncClient(timeout=35) as client:
response = await client.get(f"{self.bridge_url}/messages") response = await client.get(f"{self.bridge_url}/messages")
response.raise_for_status() response.raise_for_status()

View file

@ -54,7 +54,9 @@ class WhatsAppCloudAdapter(BasePlatformAdapter):
username=None, username=None,
metadata={ metadata={
"phone_number_id": _metadata(raw_payload).get("phone_number_id"), "phone_number_id": _metadata(raw_payload).get("phone_number_id"),
"display_phone_number": _metadata(raw_payload).get("display_phone_number"), "display_phone_number": _metadata(raw_payload).get(
"display_phone_number"
),
"timestamp": message.get("timestamp"), "timestamp": message.get("timestamp"),
"message_type": message.get("type"), "message_type": message.get("type"),
}, },
@ -96,7 +98,9 @@ def _changes(raw_payload: dict[str, Any]) -> list[dict[str, Any]]:
for entry in raw_payload.get("entry") or []: for entry in raw_payload.get("entry") or []:
if isinstance(entry, dict): if isinstance(entry, dict):
changes.extend( changes.extend(
change for change in (entry.get("changes") or []) if isinstance(change, dict) change
for change in (entry.get("changes") or [])
if isinstance(change, dict)
) )
return changes return changes

View file

@ -18,8 +18,7 @@ class WhatsAppCredentials(TypedDict, total=False):
def load_system_whatsapp_credentials() -> WhatsAppCredentials: def load_system_whatsapp_credentials() -> WhatsAppCredentials:
if not ( if not (
config.WHATSAPP_SHARED_BUSINESS_TOKEN config.WHATSAPP_SHARED_BUSINESS_TOKEN and config.WHATSAPP_SHARED_PHONE_NUMBER_ID
and config.WHATSAPP_SHARED_PHONE_NUMBER_ID
): ):
raise RuntimeError("whatsapp_system_credentials_not_configured") raise RuntimeError("whatsapp_system_credentials_not_configured")

View file

@ -41,7 +41,9 @@ class WhatsAppCloudStreamTranslator(BaseStreamTranslator):
if event.type in {"text-delta", "text_delta", "text"}: if event.type in {"text-delta", "text_delta", "text"}:
if not self._typing_sent: if not self._typing_sent:
await self._send_typing_indicator() await self._send_typing_indicator()
self._buffer += str(event.data.get("text") or event.data.get("delta") or "") self._buffer += str(
event.data.get("text") or event.data.get("delta") or ""
)
elif event.type in {"data-interrupt-request", "interrupt"}: elif event.type in {"data-interrupt-request", "interrupt"}:
await self._handle_hitl_interrupt() await self._handle_hitl_interrupt()
return return

View file

@ -42,7 +42,9 @@ class WhatsAppBaileysStreamTranslator(BaseStreamTranslator):
await self._send_typing_indicator() await self._send_typing_indicator()
async for event in events: async for event in events:
if event.type in {"text-delta", "text_delta", "text"}: if event.type in {"text-delta", "text_delta", "text"}:
self._buffer += str(event.data.get("text") or event.data.get("delta") or "") self._buffer += str(
event.data.get("text") or event.data.get("delta") or ""
)
await self._maybe_flush() await self._maybe_flush()
elif event.type in {"data-interrupt-request", "interrupt"}: elif event.type in {"data-interrupt-request", "interrupt"}:
await self._handle_hitl_interrupt() await self._handle_hitl_interrupt()
@ -86,7 +88,9 @@ class WhatsAppBaileysStreamTranslator(BaseStreamTranslator):
if not isinstance(self.adapter, WhatsAppBaileysAdapter): if not isinstance(self.adapter, WhatsAppBaileysAdapter):
return return
try: try:
await self.adapter.send_typing_indicator(external_peer_id=self.external_peer_id) await self.adapter.send_typing_indicator(
external_peer_id=self.external_peer_id
)
record_gateway_outbound(platform="whatsapp", kind="typing", status="sent") record_gateway_outbound(platform="whatsapp", kind="typing", status="sent")
except Exception: except Exception:
logger.debug("WhatsApp Baileys typing indicator failed", exc_info=True) logger.debug("WhatsApp Baileys typing indicator failed", exc_info=True)

View file

@ -202,7 +202,9 @@ class IndexingPipelineService:
await self.session.commit() await self.session.commit()
async def index_batch(self, connector_docs: list[ConnectorDocument]) -> list[Document]: async def index_batch(
self, connector_docs: list[ConnectorDocument]
) -> list[Document]:
"""Convenience method: prepare_for_indexing then index each document. """Convenience method: prepare_for_indexing then index each document.
Indexers that need heartbeat callbacks or custom per-document logic Indexers that need heartbeat callbacks or custom per-document logic
@ -347,7 +349,9 @@ class IndexingPipelineService:
await self.session.rollback() await self.session.rollback()
return [] return []
async def index(self, document: Document, connector_doc: ConnectorDocument) -> Document: async def index(
self, document: Document, connector_doc: ConnectorDocument
) -> Document:
""" """
Run deterministic content storage, embedding, and chunking for a document. Run deterministic content storage, embedding, and chunking for a document.
""" """

View file

@ -9,7 +9,6 @@ from __future__ import annotations
# Initialize app.db first to avoid a partial-init circular import when this # Initialize app.db first to avoid a partial-init circular import when this
# package is the entry point (e.g. Celery loading it before any ORM code). # package is the entry point (e.g. Celery loading it before any ORM code).
import app.db # noqa: F401 import app.db # noqa: F401
from app.notifications.persistence import Notification from app.notifications.persistence import Notification
from app.notifications.service import NotificationService from app.notifications.service import NotificationService

View file

@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.automations.api import router as automations_router from app.automations.api import router as automations_router
from app.gateway import require_gateway_enabled
from app.file_storage.api import router as file_storage_router from app.file_storage.api import router as file_storage_router
from app.gateway import require_gateway_enabled
from app.notifications.api import router as notifications_router
from .agent_action_log_route import router as agent_action_log_router from .agent_action_log_route import router as agent_action_log_router
from .agent_flags_route import router as agent_flags_router from .agent_flags_route import router as agent_flags_router
@ -46,7 +47,6 @@ from .model_list_routes import router as model_list_router
from .new_chat_routes import router as new_chat_router from .new_chat_routes import router as new_chat_router
from .new_llm_config_routes import router as new_llm_config_router from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router from .notes_routes import router as notes_router
from app.notifications.api import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_router from .notion_add_connector_route import router as notion_add_connector_router
from .obsidian_plugin_routes import router as obsidian_plugin_router from .obsidian_plugin_routes import router as obsidian_plugin_router
from .onedrive_add_connector_route import router as onedrive_add_connector_router from .onedrive_add_connector_route import router as onedrive_add_connector_router
@ -76,8 +76,12 @@ router.include_router(documents_router)
router.include_router(folders_router) router.include_router(folders_router)
_gateway_enabled_dep = [Depends(require_gateway_enabled)] _gateway_enabled_dep = [Depends(require_gateway_enabled)]
router.include_router(gateway_router, dependencies=_gateway_enabled_dep) router.include_router(gateway_router, dependencies=_gateway_enabled_dep)
router.include_router(gateway_whatsapp_webhook_router, dependencies=_gateway_enabled_dep) router.include_router(
router.include_router(gateway_whatsapp_baileys_router, dependencies=_gateway_enabled_dep) gateway_whatsapp_webhook_router, dependencies=_gateway_enabled_dep
)
router.include_router(
gateway_whatsapp_baileys_router, dependencies=_gateway_enabled_dep
)
router.include_router(notes_router) router.include_router(notes_router)
router.include_router(new_chat_router) # Chat with assistant-ui persistence router.include_router(new_chat_router) # Chat with assistant-ui persistence
router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_id} router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_id}

View file

@ -119,21 +119,35 @@ def _discord_redirect_uri() -> str:
return f"{base.rstrip('/')}/api/v1/gateway/discord/callback" return f"{base.rstrip('/')}/api/v1/gateway/discord/callback"
def _slack_frontend_redirect(space_id: int, *, success: bool = False, error: str | None = None) -> RedirectResponse: def _slack_frontend_redirect(
qs = "slack_gateway=connected" if success else f"error={error or 'slack_gateway_failed'}" space_id: int, *, success: bool = False, error: str | None = None
) -> RedirectResponse:
qs = (
"slack_gateway=connected"
if success
else f"error={error or 'slack_gateway_failed'}"
)
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/user-settings?{qs}" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/user-settings?{qs}"
) )
def _discord_frontend_redirect(space_id: int, *, success: bool = False, error: str | None = None) -> RedirectResponse: def _discord_frontend_redirect(
qs = "discord_gateway=connected" if success else f"error={error or 'discord_gateway_failed'}" space_id: int, *, success: bool = False, error: str | None = None
) -> RedirectResponse:
qs = (
"discord_gateway=connected"
if success
else f"error={error or 'discord_gateway_failed'}"
)
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/user-settings?{qs}" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/user-settings?{qs}"
) )
def verify_slack_signature(*, signing_secret: str, timestamp: str | None, signature: str | None, body: bytes) -> bool: def verify_slack_signature(
*, signing_secret: str, timestamp: str | None, signature: str | None, body: bytes
) -> bool:
if not signing_secret or not timestamp or not signature: if not signing_secret or not timestamp or not signature:
return False return False
try: try:
@ -239,7 +253,9 @@ async def install_slack_gateway(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]: ) -> dict[str, str]:
if not _slack_gateway_enabled(): if not _slack_gateway_enabled():
raise HTTPException(status_code=500, detail="Slack gateway OAuth is not configured") raise HTTPException(
status_code=500, detail="Slack gateway OAuth is not configured"
)
await check_search_space_access(session, user, search_space_id) await check_search_space_access(session, user, search_space_id)
state = _get_state_manager().generate_secure_state(search_space_id, user.id) state = _get_state_manager().generate_secure_state(search_space_id, user.id)
auth_params = { auth_params = {
@ -269,11 +285,17 @@ async def slack_gateway_callback(
state_data = None state_data = None
if error: if error:
return _slack_frontend_redirect(space_id or 0, error="slack_gateway_oauth_denied") return _slack_frontend_redirect(
space_id or 0, error="slack_gateway_oauth_denied"
)
if not code or state_data is None: if not code or state_data is None:
raise HTTPException(status_code=400, detail="Invalid Slack gateway OAuth callback") raise HTTPException(
status_code=400, detail="Invalid Slack gateway OAuth callback"
)
if not _slack_gateway_enabled(): if not _slack_gateway_enabled():
raise HTTPException(status_code=500, detail="Slack gateway OAuth is not configured") raise HTTPException(
status_code=500, detail="Slack gateway OAuth is not configured"
)
user_id = UUID(state_data["user_id"]) user_id = UUID(state_data["user_id"])
token_payload = { token_payload = {
@ -300,7 +322,9 @@ async def slack_gateway_callback(
team = token_json.get("team") or {} team = token_json.get("team") or {}
team_id = team.get("id") team_id = team.get("id")
if not bot_token or not team_id: if not bot_token or not team_id:
raise HTTPException(status_code=400, detail="Slack gateway OAuth returned incomplete data") raise HTTPException(
status_code=400, detail="Slack gateway OAuth returned incomplete data"
)
bot_user_id = token_json.get("bot_user_id") bot_user_id = token_json.get("bot_user_id")
app_id = token_json.get("app_id") app_id = token_json.get("app_id")
@ -388,7 +412,9 @@ async def install_discord_gateway(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> dict[str, str]: ) -> dict[str, str]:
if not _discord_gateway_enabled(): if not _discord_gateway_enabled():
raise HTTPException(status_code=500, detail="Discord gateway OAuth is not configured") raise HTTPException(
status_code=500, detail="Discord gateway OAuth is not configured"
)
await check_search_space_access(session, user, search_space_id) await check_search_space_access(session, user, search_space_id)
state = _get_state_manager().generate_secure_state(search_space_id, user.id) state = _get_state_manager().generate_secure_state(search_space_id, user.id)
auth_params = { auth_params = {
@ -420,11 +446,17 @@ async def discord_gateway_callback(
state_data = None state_data = None
if error: if error:
return _discord_frontend_redirect(space_id or 0, error="discord_gateway_oauth_denied") return _discord_frontend_redirect(
space_id or 0, error="discord_gateway_oauth_denied"
)
if not code or state_data is None: if not code or state_data is None:
raise HTTPException(status_code=400, detail="Invalid Discord gateway OAuth callback") raise HTTPException(
status_code=400, detail="Invalid Discord gateway OAuth callback"
)
if not _discord_gateway_enabled(): if not _discord_gateway_enabled():
raise HTTPException(status_code=500, detail="Discord gateway OAuth is not configured") raise HTTPException(
status_code=500, detail="Discord gateway OAuth is not configured"
)
user_id = UUID(state_data["user_id"]) user_id = UUID(state_data["user_id"])
token_payload = { token_payload = {
@ -535,7 +567,10 @@ async def discord_gateway_callback(
elif binding.user_id == user_id: elif binding.user_id == user_id:
binding.search_space_id = space_id binding.search_space_id = space_id
binding.external_username = discord_username or binding.external_username binding.external_username = discord_username or binding.external_username
binding.external_metadata = {**(binding.external_metadata or {}), **metadata} binding.external_metadata = {
**(binding.external_metadata or {}),
**metadata,
}
await session.commit() await session.commit()
return _discord_frontend_redirect(space_id, success=True) return _discord_frontend_redirect(space_id, success=True)
@ -614,7 +649,9 @@ async def _resolve_webhook_account(
if account is None or account.platform != ExternalChatPlatform.TELEGRAM: if account is None or account.platform != ExternalChatPlatform.TELEGRAM:
raise HTTPException(status_code=404, detail="Gateway account not found") raise HTTPException(status_code=404, detail="Gateway account not found")
expected_secret = account.webhook_secret or "" expected_secret = account.webhook_secret or ""
if not expected_secret or not hmac.compare_digest(header_secret or "", expected_secret): if not expected_secret or not hmac.compare_digest(
header_secret or "", expected_secret
):
raise HTTPException(status_code=403, detail="Invalid Telegram webhook secret") raise HTTPException(status_code=403, detail="Invalid Telegram webhook secret")
return account return account
@ -654,7 +691,9 @@ async def telegram_webhook(
event_dedupe_key=telegram_event_dedupe_key(update_id), event_dedupe_key=telegram_event_dedupe_key(update_id),
external_event_id=str(update_id), external_event_id=str(update_id),
external_message_id=( external_message_id=(
str(message["message_id"]) if message.get("message_id") is not None else None str(message["message_id"])
if message.get("message_id") is not None
else None
), ),
event_kind=_classify_telegram_event(payload), event_kind=_classify_telegram_event(payload),
raw_payload=payload, raw_payload=payload,
@ -739,7 +778,10 @@ async def list_bindings(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
result = await session.execute( result = await session.execute(
select(ExternalChatBinding, ExternalChatAccount) select(ExternalChatBinding, ExternalChatAccount)
.join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id) .join(
ExternalChatAccount,
ExternalChatBinding.account_id == ExternalChatAccount.id,
)
.where(ExternalChatBinding.user_id == user.id) .where(ExternalChatBinding.user_id == user.id)
) )
return [ return [
@ -777,13 +819,20 @@ async def list_connections(
] ]
if platform is not None: if platform is not None:
filters.append(ExternalChatAccount.platform == platform) filters.append(ExternalChatAccount.platform == platform)
if platform == ExternalChatPlatform.WHATSAPP and active_whatsapp_mode is not None: if (
platform == ExternalChatPlatform.WHATSAPP
and active_whatsapp_mode is not None
):
filters.append(ExternalChatAccount.mode == active_whatsapp_mode) filters.append(ExternalChatAccount.mode == active_whatsapp_mode)
else: else:
if not _telegram_gateway_enabled(): if not _telegram_gateway_enabled():
filters.append(ExternalChatAccount.platform != ExternalChatPlatform.TELEGRAM) filters.append(
ExternalChatAccount.platform != ExternalChatPlatform.TELEGRAM
)
if active_whatsapp_mode is None: if active_whatsapp_mode is None:
filters.append(ExternalChatAccount.platform != ExternalChatPlatform.WHATSAPP) filters.append(
ExternalChatAccount.platform != ExternalChatPlatform.WHATSAPP
)
else: else:
filters.append( filters.append(
or_( or_(
@ -794,7 +843,10 @@ async def list_connections(
result = await session.execute( result = await session.execute(
select(ExternalChatBinding, ExternalChatAccount) select(ExternalChatBinding, ExternalChatAccount)
.join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id) .join(
ExternalChatAccount,
ExternalChatBinding.account_id == ExternalChatAccount.id,
)
.where(*filters) .where(*filters)
) )
@ -828,7 +880,9 @@ async def list_connections(
baileys_account_ids.add(int(account.id)) baileys_account_ids.add(int(account.id))
route_type = "account" route_type = "account"
connection_id = account.id connection_id = account.id
search_space_id = account.owner_search_space_id or binding.search_space_id search_space_id = (
account.owner_search_space_id or binding.search_space_id
)
display_name = "WhatsApp Bridge" display_name = "WhatsApp Bridge"
connections.append( connections.append(
@ -853,9 +907,8 @@ async def list_connections(
} }
) )
if ( if active_whatsapp_mode == ExternalChatAccountMode.SELF_HOST_BYO and (
active_whatsapp_mode == ExternalChatAccountMode.SELF_HOST_BYO platform is None or platform == ExternalChatPlatform.WHATSAPP
and (platform is None or platform == ExternalChatPlatform.WHATSAPP)
): ):
account_result = await session.execute( account_result = await session.execute(
select(ExternalChatAccount).where( select(ExternalChatAccount).where(
@ -940,7 +993,9 @@ async def update_binding_search_space(
ExternalChatBindingState.BOUND, ExternalChatBindingState.BOUND,
ExternalChatBindingState.SUSPENDED, ExternalChatBindingState.SUSPENDED,
}: }:
raise HTTPException(status_code=400, detail="Only active bindings can be routed") raise HTTPException(
status_code=400, detail="Only active bindings can be routed"
)
account = await session.get(ExternalChatAccount, binding.account_id) account = await session.get(ExternalChatAccount, binding.account_id)
if account is None or _is_inactive_whatsapp_account(account): if account is None or _is_inactive_whatsapp_account(account):
raise HTTPException(status_code=404, detail="Binding not found") raise HTTPException(status_code=404, detail="Binding not found")
@ -1062,4 +1117,3 @@ async def resume_external_chat_binding(
binding.updated_at = datetime.now(UTC) binding.updated_at = datetime.now(UTC)
await session.commit() await session.commit()
return {"ok": True} return {"ok": True}

View file

@ -33,7 +33,9 @@ class BaileysPairRequest(BaseModel):
def _ensure_baileys_enabled() -> None: def _ensure_baileys_enabled() -> None:
if config.GATEWAY_WHATSAPP_INTAKE_MODE != "baileys": if config.GATEWAY_WHATSAPP_INTAKE_MODE != "baileys":
raise HTTPException(status_code=404, detail="WhatsApp Baileys gateway is disabled") raise HTTPException(
status_code=404, detail="WhatsApp Baileys gateway is disabled"
)
if config.is_cloud(): if config.is_cloud():
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,

View file

@ -79,7 +79,9 @@ async def whatsapp_webhook(
def _verify_signature(raw_body: bytes, header_signature: str | None) -> None: def _verify_signature(raw_body: bytes, header_signature: str | None) -> None:
if not config.WHATSAPP_WEBHOOK_APP_SECRET: if not config.WHATSAPP_WEBHOOK_APP_SECRET:
raise HTTPException(status_code=500, detail="WhatsApp app secret is not configured") raise HTTPException(
status_code=500, detail="WhatsApp app secret is not configured"
)
received = (header_signature or "").removeprefix("sha256=") received = (header_signature or "").removeprefix("sha256=")
expected = hmac.new( expected = hmac.new(
config.WHATSAPP_WEBHOOK_APP_SECRET.encode(), config.WHATSAPP_WEBHOOK_APP_SECRET.encode(),
@ -87,7 +89,9 @@ def _verify_signature(raw_body: bytes, header_signature: str | None) -> None:
hashlib.sha256, hashlib.sha256,
).hexdigest() ).hexdigest()
if not received or not hmac.compare_digest(received, expected): if not received or not hmac.compare_digest(received, expected):
raise HTTPException(status_code=403, detail="Invalid WhatsApp webhook signature") raise HTTPException(
status_code=403, detail="Invalid WhatsApp webhook signature"
)
async def _process_payload(session: AsyncSession, payload: dict[str, Any]) -> None: async def _process_payload(session: AsyncSession, payload: dict[str, Any]) -> None:
@ -114,7 +118,9 @@ async def _process_messages_change(
change: dict[str, Any], change: dict[str, Any],
value: dict[str, Any], value: dict[str, Any],
) -> None: ) -> None:
statuses = [status for status in value.get("statuses") or [] if isinstance(status, dict)] statuses = [
status for status in value.get("statuses") or [] if isinstance(status, dict)
]
for status in statuses: for status in statuses:
record_gateway_outbound( record_gateway_outbound(
platform="whatsapp", platform="whatsapp",

View file

@ -25,6 +25,7 @@ from app.db import (
User, User,
get_async_session, get_async_session,
) )
from app.notifications.service import NotificationService
from app.schemas.obsidian_plugin import ( from app.schemas.obsidian_plugin import (
ALLOWED_ATTACHMENT_EXTENSIONS, ALLOWED_ATTACHMENT_EXTENSIONS,
ATTACHMENT_MIME_TYPES, ATTACHMENT_MIME_TYPES,
@ -43,7 +44,6 @@ from app.schemas.obsidian_plugin import (
SyncAckItem, SyncAckItem,
SyncBatchRequest, SyncBatchRequest,
) )
from app.notifications.service import NotificationService
from app.services.obsidian_plugin_indexer import ( from app.services.obsidian_plugin_indexer import (
delete_note, delete_note,
get_manifest, get_manifest,

View file

@ -20,6 +20,7 @@ from app.db import (
User, User,
has_permission, has_permission,
) )
from app.notifications.service import NotificationService
from app.schemas.chat_comments import ( from app.schemas.chat_comments import (
AuthorResponse, AuthorResponse,
CommentBatchResponse, CommentBatchResponse,
@ -31,7 +32,6 @@ from app.schemas.chat_comments import (
MentionListResponse, MentionListResponse,
MentionResponse, MentionResponse,
) )
from app.notifications.service import NotificationService
from app.utils.chat_comments import parse_mentions, render_mentions from app.utils.chat_comments import parse_mentions, render_mentions
from app.utils.rbac import check_permission, get_user_permissions from app.utils.rbac import check_permission, get_user_permissions

View file

@ -64,9 +64,6 @@ class ConfluenceKBSyncService:
if dup: if dup:
content_hash = unique_hash content_hash = unique_hash
summary_content = f"Confluence Page: {page_title}\n\n{page_content}" summary_content = f"Confluence Page: {page_title}\n\n{page_content}"
summary_embedding = embed_text(summary_content) summary_embedding = embed_text(summary_content)
@ -166,8 +163,6 @@ class ConfluenceKBSyncService:
space_id = (document.document_metadata or {}).get("space_id", "") space_id = (document.document_metadata or {}).get("space_id", "")
summary_content = f"Confluence Page: {page_title}\n\n{page_content}" summary_content = f"Confluence Page: {page_title}\n\n{page_content}"
summary_embedding = embed_text(summary_content) summary_embedding = embed_text(summary_content)

View file

@ -71,9 +71,6 @@ class DropboxKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = f"Dropbox File: {file_name}\n\n{indexable_content}" summary_content = f"Dropbox File: {file_name}\n\n{indexable_content}"
summary_embedding = embed_text(summary_content) summary_embedding = embed_text(summary_content)

View file

@ -77,9 +77,6 @@ class GmailKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = f"Gmail Message: {subject}\n\n{indexable_content}" summary_content = f"Gmail Message: {subject}\n\n{indexable_content}"
summary_embedding = await asyncio.to_thread(embed_text, summary_content) summary_embedding = await asyncio.to_thread(embed_text, summary_content)

View file

@ -89,9 +89,6 @@ class GoogleCalendarKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = ( summary_content = (
f"Google Calendar Event: {event_summary}\n\n{indexable_content}" f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
) )
@ -252,9 +249,6 @@ class GoogleCalendarKBSyncService:
if not indexable_content: if not indexable_content:
return {"status": "error", "message": "Event produced empty content"} return {"status": "error", "message": "Event produced empty content"}
summary_content = ( summary_content = (
f"Google Calendar Event: {event_summary}\n\n{indexable_content}" f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
) )

View file

@ -73,12 +73,7 @@ class GoogleDriveKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = f"Google Drive File: {file_name}\n\n{indexable_content}"
summary_content = (
f"Google Drive File: {file_name}\n\n{indexable_content}"
)
summary_embedding = embed_text(summary_content) summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content) chunks = await create_document_chunks(indexable_content)

View file

@ -83,9 +83,6 @@ class LinearKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = ( summary_content = (
f"Linear Issue {issue_identifier}: {issue_title}\n\n{issue_content}" f"Linear Issue {issue_identifier}: {issue_title}\n\n{issue_content}"
) )
@ -207,8 +204,6 @@ class LinearKBSyncService:
comment_count = len(formatted_issue.get("comments", [])) comment_count = len(formatted_issue.get("comments", []))
formatted_issue.get("description", "") formatted_issue.get("description", "")
summary_content = ( summary_content = (
f"Linear Issue {issue_identifier}: {issue_title}\n\n{issue_content}" f"Linear Issue {issue_identifier}: {issue_title}\n\n{issue_content}"
) )

View file

@ -72,9 +72,6 @@ class NotionKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = f"Notion Page: {page_title}\n\n{markdown_content}" summary_content = f"Notion Page: {page_title}\n\n{markdown_content}"
summary_embedding = embed_text(summary_content) summary_embedding = embed_text(summary_content)
@ -225,7 +222,6 @@ class NotionKBSyncService:
f"Final content length: {len(full_content)} chars, verified={content_verified}" f"Final content length: {len(full_content)} chars, verified={content_verified}"
) )
logger.debug("Generating summary and embeddings") logger.debug("Generating summary and embeddings")
summary_content = f"Notion Page: {document.document_metadata.get('page_title')}\n\n{full_content}" summary_content = f"Notion Page: {document.document_metadata.get('page_title')}\n\n{full_content}"

View file

@ -72,9 +72,6 @@ class OneDriveKBSyncService:
) )
content_hash = unique_hash content_hash = unique_hash
summary_content = f"OneDrive File: {file_name}\n\n{indexable_content}" summary_content = f"OneDrive File: {file_name}\n\n{indexable_content}"
summary_embedding = await asyncio.to_thread(embed_text, summary_content) summary_embedding = await asyncio.to_thread(embed_text, summary_content)

View file

@ -9,8 +9,8 @@ from uuid import UUID
from app.celery_app import celery_app from app.celery_app import celery_app
from app.config import config from app.config import config
from app.observability import metrics as ot_metrics
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
from app.observability import metrics as ot_metrics
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
from app.tasks.connector_indexers.local_folder_indexer import ( from app.tasks.connector_indexers.local_folder_indexer import (
@ -1335,7 +1335,7 @@ async def _index_local_folder_async(
exclude_patterns=exclude_patterns, exclude_patterns=exclude_patterns,
file_extensions=file_extensions, file_extensions=file_extensions,
root_folder_id=root_folder_id, root_folder_id=root_folder_id,
target_file_paths=target_file_paths, target_file_paths=target_file_paths,
on_heartbeat_callback=_heartbeat_progress on_heartbeat_callback=_heartbeat_progress
if (is_batch or is_full_scan) if (is_batch or is_full_scan)
else None, else None,
@ -1463,7 +1463,7 @@ async def _index_uploaded_folder_files_async(
user_id=user_id, user_id=user_id,
folder_name=folder_name, folder_name=folder_name,
root_folder_id=root_folder_id, root_folder_id=root_folder_id,
file_mappings=file_mappings, file_mappings=file_mappings,
on_heartbeat_callback=_heartbeat_progress, on_heartbeat_callback=_heartbeat_progress,
use_vision_llm=use_vision_llm, use_vision_llm=use_vision_llm,
processing_mode=processing_mode, processing_mode=processing_mode,

View file

@ -46,7 +46,8 @@ def reconcile_inbox_task() -> None:
result = await session.execute( result = await session.execute(
update(ExternalChatInboundEvent) update(ExternalChatInboundEvent)
.where( .where(
ExternalChatInboundEvent.status == ExternalChatEventStatus.PROCESSING, ExternalChatInboundEvent.status
== ExternalChatEventStatus.PROCESSING,
ExternalChatInboundEvent.received_at < stale_threshold, ExternalChatInboundEvent.received_at < stale_threshold,
) )
.values( .values(
@ -163,4 +164,3 @@ async def enqueue_telegram_update(account_id: int, raw_update: dict) -> int | No
) )
await session.commit() await session.commit()
return inbox_id return inbox_id

View file

@ -260,7 +260,7 @@ async def index_confluence_pages(
connector_id=connector_id, connector_id=connector_id,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
with session.no_autoflush: with session.no_autoflush:
duplicate_by_content = await check_duplicate_document_by_hash( duplicate_by_content = await check_duplicate_document_by_hash(

View file

@ -414,7 +414,7 @@ async def index_google_calendar_events(
connector_id=connector_id, connector_id=connector_id,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
with session.no_autoflush: with session.no_autoflush:
duplicate = await check_duplicate_document_by_hash( duplicate = await check_duplicate_document_by_hash(

View file

@ -552,7 +552,7 @@ async def _process_single_file(
connector_id=connector_id, connector_id=connector_id,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
pipeline = IndexingPipelineService(session) pipeline = IndexingPipelineService(session)
documents = await pipeline.prepare_for_indexing([doc]) documents = await pipeline.prepare_for_indexing([doc])

View file

@ -444,7 +444,7 @@ async def index_google_gmail_messages(
connector_id=connector_id, connector_id=connector_id,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
with session.no_autoflush: with session.no_autoflush:
duplicate = await check_duplicate_document_by_hash( duplicate = await check_duplicate_document_by_hash(

View file

@ -268,7 +268,7 @@ async def index_linear_issues(
connector_id=connector_id, connector_id=connector_id,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
with session.no_autoflush: with session.no_autoflush:
duplicate = await check_duplicate_document_by_hash( duplicate = await check_duplicate_document_by_hash(

View file

@ -568,7 +568,7 @@ async def index_local_folder(
folder_path=folder_path, folder_path=folder_path,
folder_name=folder_name, folder_name=folder_name,
target_file_path=target_file_paths[0], target_file_path=target_file_paths[0],
root_folder_id=root_folder_id, root_folder_id=root_folder_id,
task_logger=task_logger, task_logger=task_logger,
log_entry=log_entry, log_entry=log_entry,
) )
@ -580,7 +580,7 @@ async def index_local_folder(
folder_path=folder_path, folder_path=folder_path,
folder_name=folder_name, folder_name=folder_name,
target_file_paths=target_file_paths, target_file_paths=target_file_paths,
root_folder_id=root_folder_id, root_folder_id=root_folder_id,
on_progress_callback=on_heartbeat_callback, on_progress_callback=on_heartbeat_callback,
) )
if err: if err:
@ -766,7 +766,7 @@ async def index_local_folder(
folder_name=folder_name, folder_name=folder_name,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
connector_docs.append(doc) connector_docs.append(doc)
file_meta_map[unique_identifier] = { file_meta_map[unique_identifier] = {
"relative_path": relative_path, "relative_path": relative_path,
@ -983,7 +983,7 @@ async def _index_batch_files(
folder_path=folder_path, folder_path=folder_path,
folder_name=folder_name, folder_name=folder_name,
target_file_path=file_path, target_file_path=file_path,
root_folder_id=root_folder_id, root_folder_id=root_folder_id,
task_logger=task_logger, task_logger=task_logger,
log_entry=log_entry, log_entry=log_entry,
) )
@ -1111,7 +1111,7 @@ async def _index_single_file(
folder_name=folder_name, folder_name=folder_name,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
if root_folder_id: if root_folder_id:
connector_doc.folder_id = await _resolve_folder_for_file( connector_doc.folder_id = await _resolve_folder_for_file(
@ -1396,7 +1396,7 @@ async def index_uploaded_files(
folder_name=folder_name, folder_name=folder_name,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
connector_doc.folder_id = await _resolve_folder_for_file( connector_doc.folder_id = await _resolve_folder_for_file(
session, session,

View file

@ -440,9 +440,7 @@ async def index_luma_events(
summary_content = ( summary_content = (
f"Luma Event: {item['event_name']}\n\n{item['event_markdown']}" f"Luma Event: {item['event_name']}\n\n{item['event_markdown']}"
) )
summary_embedding = await asyncio.to_thread( summary_embedding = await asyncio.to_thread(embed_text, summary_content)
embed_text, summary_content
)
chunks = await create_document_chunks(item["event_markdown"]) chunks = await create_document_chunks(item["event_markdown"])

View file

@ -308,7 +308,7 @@ async def index_notion_pages(
connector_id=connector_id, connector_id=connector_id,
search_space_id=search_space_id, search_space_id=search_space_id,
user_id=user_id, user_id=user_id,
) )
with session.no_autoflush: with session.no_autoflush:
duplicate = await check_duplicate_document_by_hash( duplicate = await check_duplicate_document_by_hash(

View file

@ -318,9 +318,7 @@ async def index_crawled_urls(
continue continue
# Format content as structured document for summary generation # Format content as structured document for summary generation
structured_document = crawler.format_to_structured_document( crawler.format_to_structured_document(crawl_result)
crawl_result
)
# Generate content hash using a version WITHOUT metadata # Generate content hash using a version WITHOUT metadata
structured_document_for_hash = crawler.format_to_structured_document( structured_document_for_hash = crawler.format_to_structured_document(
@ -332,8 +330,8 @@ async def index_crawled_urls(
# Extract useful metadata # Extract useful metadata
title = metadata.get("title", url) title = metadata.get("title", url)
description = metadata.get("description", "") metadata.get("description", "")
language = metadata.get("language", "") metadata.get("language", "")
# Update title immediately for better UX # Update title immediately for better UX
document.title = title document.title = title

View file

@ -34,9 +34,9 @@ class AnonymousProxiesProvider(ProxyProvider):
"l": Config.RESIDENTIAL_PROXY_LOCATION, "l": Config.RESIDENTIAL_PROXY_LOCATION,
"t": Config.RESIDENTIAL_PROXY_TYPE, "t": Config.RESIDENTIAL_PROXY_TYPE,
} }
return base64.b64encode( return base64.b64encode(json.dumps(password_dict).encode("utf-8")).decode(
json.dumps(password_dict).encode("utf-8") "utf-8"
).decode("utf-8") )
def get_proxy_url(self) -> str | None: def get_proxy_url(self) -> str | None:
username = Config.RESIDENTIAL_PROXY_USERNAME username = Config.RESIDENTIAL_PROXY_USERNAME

View file

@ -110,7 +110,9 @@ def _format_table_entry(conn: Connection, table: str) -> str:
def build_set_table_sql(conn: Connection) -> str: def build_set_table_sql(conn: Connection) -> str:
"""Build the canonical plain SET TABLE statement for Zero's event triggers.""" """Build the canonical plain SET TABLE statement for Zero's event triggers."""
table_list = ", ".join(_format_table_entry(conn, table) for table in ZERO_PUBLICATION) table_list = ", ".join(
_format_table_entry(conn, table) for table in ZERO_PUBLICATION
)
return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}" return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}"
@ -175,7 +177,9 @@ def verify_publication(conn: Connection) -> list[str]:
actual_columns = actual[table] actual_columns = actual[table]
actual_key = sorted(actual_columns) if actual_columns is not None else None actual_key = sorted(actual_columns) if actual_columns is not None else None
expected_key = sorted(expected_columns) if expected_columns is not None else None expected_key = (
sorted(expected_columns) if expected_columns is not None else None
)
if actual_key != expected_key: if actual_key != expected_key:
mismatches.append( mismatches.append(
f"{table}: expected columns {expected_columns or 'ALL'}, " f"{table}: expected columns {expected_columns or 'ALL'}, "
@ -196,6 +200,7 @@ async def _verify_cli() -> int:
engine = create_async_engine(database_url) engine = create_async_engine(database_url)
async with engine.connect() as async_conn: async with engine.connect() as async_conn:
def run_verify(sync_conn: Connection) -> list[str]: def run_verify(sync_conn: Connection) -> list[str]:
return verify_publication(sync_conn) return verify_publication(sync_conn)
@ -215,7 +220,9 @@ async def _verify_cli() -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Manage SurfSense's Zero publication") parser = argparse.ArgumentParser(description="Manage SurfSense's Zero publication")
parser.add_argument("--verify", action="store_true", help="verify zero_publication shape") parser.add_argument(
"--verify", action="store_true", help="verify zero_publication shape"
)
args = parser.parse_args() args = parser.parse_args()
if args.verify: if args.verify:

View file

@ -41,7 +41,9 @@ async def main() -> int:
await session.commit() await session.commit()
account_id = int(account.id) account_id = int(account.id)
webhook_url = f"{base_url.rstrip('/')}/api/v1/gateway/webhooks/telegram/{account_id}" webhook_url = (
f"{base_url.rstrip('/')}/api/v1/gateway/webhooks/telegram/{account_id}"
)
bot = Bot(token=token) bot = Bot(token=token)
ok = await bot.set_webhook( ok = await bot.set_webhook(
url=webhook_url, url=webhook_url,
@ -58,4 +60,3 @@ async def main() -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(asyncio.run(main())) raise SystemExit(asyncio.run(main()))

View file

@ -140,7 +140,10 @@ def install(active_patches: list[Any]) -> None:
"app.agents.chat.multi_agent_chat.shared.tools.mcp.tool.streamablehttp_client", "app.agents.chat.multi_agent_chat.shared.tools.mcp.tool.streamablehttp_client",
_fake_streamablehttp_client, _fake_streamablehttp_client,
), ),
("app.agents.chat.multi_agent_chat.shared.tools.mcp.tool.ClientSession", _FakeClientSession), (
"app.agents.chat.multi_agent_chat.shared.tools.mcp.tool.ClientSession",
_FakeClientSession,
),
] ]
for target, replacement in targets: for target, replacement in targets:
p = patch(target, replacement) p = patch(target, replacement)

View file

@ -135,8 +135,6 @@ async def test_agent_checkpoint_round_trips_across_turns(
{"messages": [HumanMessage(content="second turn")]}, config {"messages": [HumanMessage(content="second turn")]}, config
) )
texts = [ texts = [m.content for m in second["messages"] if isinstance(m, HumanMessage)]
m.content for m in second["messages"] if isinstance(m, HumanMessage)
]
assert "remember apple" in texts, "turn 1 history not reloaded from checkpoint" assert "remember apple" in texts, "turn 1 history not reloaded from checkpoint"
assert len(second["messages"]) > len(first["messages"]) assert len(second["messages"]) > len(first["messages"])

View file

@ -45,9 +45,7 @@ def _build_desktop_fs_mw(root: Path):
"""Build the production filesystem middleware bound to a real local folder.""" """Build the production filesystem middleware bound to a real local folder."""
selection = FilesystemSelection( selection = FilesystemSelection(
mode=FilesystemMode.DESKTOP_LOCAL_FOLDER, mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
local_mounts=( local_mounts=(LocalFilesystemMount(mount_id=_MOUNT_ID, root_path=str(root)),),
LocalFilesystemMount(mount_id=_MOUNT_ID, root_path=str(root)),
),
) )
resolver = build_backend_resolver(selection) resolver = build_backend_resolver(selection)
return build_filesystem_mw( return build_filesystem_mw(
@ -157,7 +155,7 @@ async def test_write_then_ls_lists_file(tmp_path: Path):
async def test_edit_file_rewrites_on_disk(tmp_path: Path): async def test_edit_file_rewrites_on_disk(tmp_path: Path):
"""edit_file applies a real string replacement to the on-disk file.""" """edit_file applies a real string replacement to the on-disk file."""
result = await _run( await _run(
tmp_path, tmp_path,
[ [
ScriptedTurn( ScriptedTurn(

View file

@ -7,9 +7,7 @@ from app.indexing_pipeline.adapters.file_upload_adapter import UploadDocumentAda
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_sets_status_ready(db_session, db_search_space, db_user, mocker): async def test_sets_status_ready(db_session, db_search_space, db_user, mocker):
"""Document status is READY after successful indexing.""" """Document status is READY after successful indexing."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -29,9 +27,7 @@ async def test_sets_status_ready(db_session, db_search_space, db_user, mocker):
assert DocumentStatus.is_state(document.status, DocumentStatus.READY) assert DocumentStatus.is_state(document.status, DocumentStatus.READY)
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_content_is_source_markdown(db_session, db_search_space, db_user, mocker): async def test_content_is_source_markdown(db_session, db_search_space, db_user, mocker):
"""Document content is set to the extracted source markdown.""" """Document content is set to the extracted source markdown."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -51,9 +47,7 @@ async def test_content_is_source_markdown(db_session, db_search_space, db_user,
assert document.content == "## Hello\n\nSome content." assert document.content == "## Hello\n\nSome content."
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_chunks_written_to_db(db_session, db_search_space, db_user, mocker): async def test_chunks_written_to_db(db_session, db_search_space, db_user, mocker):
"""Chunks derived from the source markdown are persisted in the DB.""" """Chunks derived from the source markdown are persisted in the DB."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -98,9 +92,7 @@ async def test_raises_on_indexing_failure(db_session, db_search_space, db_user,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_reindex_updates_content(db_session, db_search_space, db_user, mocker): async def test_reindex_updates_content(db_session, db_search_space, db_user, mocker):
"""Document content is updated to the new source markdown after reindexing.""" """Document content is updated to the new source markdown after reindexing."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -126,9 +118,7 @@ async def test_reindex_updates_content(db_session, db_search_space, db_user, moc
assert document.content == "## Edited\n\nNew content after user edit." assert document.content == "## Edited\n\nNew content after user edit."
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_reindex_updates_content_hash( async def test_reindex_updates_content_hash(
db_session, db_search_space, db_user, mocker db_session, db_search_space, db_user, mocker
): ):
@ -157,9 +147,7 @@ async def test_reindex_updates_content_hash(
assert document.content_hash != original_hash assert document.content_hash != original_hash
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, mocker): async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, mocker):
"""Document status is READY after successful reindexing.""" """Document status is READY after successful reindexing."""
adapter = UploadDocumentAdapter(db_session) adapter = UploadDocumentAdapter(db_session)
@ -222,9 +210,7 @@ async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, moc
assert chunks[0].content == "Updated chunk." assert chunks[0].content == "Updated chunk."
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_reindex_clears_reindexing_flag( async def test_reindex_clears_reindexing_flag(
db_session, db_search_space, db_user, mocker db_session, db_search_space, db_user, mocker
): ):

View file

@ -34,9 +34,7 @@ def _cal_doc(
) )
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_calendar_pipeline_creates_ready_document( async def test_calendar_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):
@ -65,9 +63,7 @@ async def test_calendar_pipeline_creates_ready_document(
assert DocumentStatus.is_state(row.status, DocumentStatus.READY) assert DocumentStatus.is_state(row.status, DocumentStatus.READY)
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_calendar_legacy_doc_migrated( async def test_calendar_legacy_doc_migrated(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):

View file

@ -33,9 +33,7 @@ def _drive_doc(
) )
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_drive_pipeline_creates_ready_document( async def test_drive_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):
@ -64,9 +62,7 @@ async def test_drive_pipeline_creates_ready_document(
assert DocumentStatus.is_state(row.status, DocumentStatus.READY) assert DocumentStatus.is_state(row.status, DocumentStatus.READY)
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_drive_legacy_doc_migrated( async def test_drive_legacy_doc_migrated(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):

View file

@ -32,9 +32,7 @@ def _dropbox_doc(
) )
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_dropbox_pipeline_creates_ready_document( async def test_dropbox_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):
@ -63,9 +61,7 @@ async def test_dropbox_pipeline_creates_ready_document(
assert DocumentStatus.is_state(row.status, DocumentStatus.READY) assert DocumentStatus.is_state(row.status, DocumentStatus.READY)
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_dropbox_duplicate_content_skipped( async def test_dropbox_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):

View file

@ -36,9 +36,7 @@ def _gmail_doc(
) )
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_gmail_pipeline_creates_ready_document( async def test_gmail_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):
@ -68,9 +66,7 @@ async def test_gmail_pipeline_creates_ready_document(
assert row.source_markdown == doc.source_markdown assert row.source_markdown == doc.source_markdown
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_gmail_legacy_doc_migrated_then_reused( async def test_gmail_legacy_doc_migrated_then_reused(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):

View file

@ -9,9 +9,7 @@ from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineServ
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_index_batch_creates_ready_documents( async def test_index_batch_creates_ready_documents(
db_session, db_search_space, make_connector_document, mocker db_session, db_search_space, make_connector_document, mocker
): ):
@ -49,9 +47,7 @@ async def test_index_batch_creates_ready_documents(
assert row.embedding is not None assert row.embedding is not None
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_index_batch_empty_returns_empty(db_session, mocker): async def test_index_batch_empty_returns_empty(db_session, mocker):
"""index_batch with empty input returns an empty list.""" """index_batch with empty input returns an empty list."""
service = IndexingPipelineService(session=db_session) service = IndexingPipelineService(session=db_session)

View file

@ -32,9 +32,7 @@ def _onedrive_doc(
) )
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_onedrive_pipeline_creates_ready_document( async def test_onedrive_pipeline_creates_ready_document(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):
@ -63,9 +61,7 @@ async def test_onedrive_pipeline_creates_ready_document(
assert DocumentStatus.is_state(row.status, DocumentStatus.READY) assert DocumentStatus.is_state(row.status, DocumentStatus.READY)
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_onedrive_duplicate_content_skipped( async def test_onedrive_duplicate_content_skipped(
db_session, db_search_space, db_connector, db_user, mocker db_session, db_search_space, db_connector, db_user, mocker
): ):

View file

@ -32,9 +32,7 @@ async def test_new_document_is_persisted_with_pending_status(
assert reloaded.source_markdown == doc.source_markdown assert reloaded.source_markdown == doc.source_markdown
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_unchanged_ready_document_is_skipped( async def test_unchanged_ready_document_is_skipped(
db_session, db_session,
db_search_space, db_search_space,
@ -55,9 +53,7 @@ async def test_unchanged_ready_document_is_skipped(
assert results == [] assert results == []
@pytest.mark.usefixtures( @pytest.mark.usefixtures("patched_embed_texts", "patched_chunk_text")
"patched_embed_texts", "patched_chunk_text"
)
async def test_title_only_change_updates_title_in_db( async def test_title_only_change_updates_title_in_db(
db_session, db_session,
db_search_space, db_search_space,

View file

@ -47,7 +47,9 @@ async def test_comment_reply_truncates_long_preview(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
): ):
"""A long comment preview is truncated in the reply message.""" """A long comment preview is truncated in the reply message."""
notification = await _notify(db_session, db_user, db_search_space, preview="y" * 150) notification = await _notify(
db_session, db_user, db_search_space, preview="y" * 150
)
assert notification.message == "y" * 100 + "..." assert notification.message == "y" * 100 + "..."

View file

@ -12,7 +12,7 @@ from datetime import UTC, datetime, timedelta
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSpace, User from app.db import User
from app.notifications.persistence import Notification from app.notifications.persistence import Notification
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration

View file

@ -47,7 +47,9 @@ async def test_new_mention_truncates_long_preview(
db_session: AsyncSession, db_user: User, db_search_space: SearchSpace db_session: AsyncSession, db_user: User, db_search_space: SearchSpace
): ):
"""A long comment preview is truncated in the mention message.""" """A long comment preview is truncated in the mention message."""
notification = await _notify(db_session, db_user, db_search_space, preview="x" * 150) notification = await _notify(
db_session, db_user, db_search_space, preview="x" * 150
)
assert notification.message == "x" * 100 + "..." assert notification.message == "x" * 100 + "..."

View file

@ -137,7 +137,10 @@ class TestPluginLoaderIsolation:
_FakeEntryPoint("crashing", crashing_factory), _FakeEntryPoint("crashing", crashing_factory),
_FakeEntryPoint("ok", year_substituter_factory), _FakeEntryPoint("ok", year_substituter_factory),
] ]
with patch("app.agents.chat.multi_agent_chat.main_agent.plugins.loader.entry_points", return_value=eps): with patch(
"app.agents.chat.multi_agent_chat.main_agent.plugins.loader.entry_points",
return_value=eps,
):
result = load_plugin_middlewares( result = load_plugin_middlewares(
_ctx(), allowed_plugin_names={"crashing", "ok"} _ctx(), allowed_plugin_names={"crashing", "ok"}
) )

View file

@ -5,8 +5,7 @@ import asyncio
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from app.gateway import byo_long_poll from app.gateway import byo_long_poll, runner
from app.gateway import runner
class ScalarResult: class ScalarResult:
@ -48,7 +47,9 @@ async def test_start_byo_long_poll_noops_when_mode_is_webhook(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatch): async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatch):
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll") monkeypatch.setattr(
byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll"
)
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.execute.return_value = ScalarResult([]) session.execute.return_value = ScalarResult([])
monkeypatch.setattr( monkeypatch.setattr(
@ -63,8 +64,12 @@ async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatc
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_byo_long_poll_spawns_one_supervisor_per_account(mocker, monkeypatch): async def test_start_byo_long_poll_spawns_one_supervisor_per_account(
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll") mocker, monkeypatch
):
monkeypatch.setattr(
byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll"
)
accounts = [mocker.Mock(id=1), mocker.Mock(id=2)] accounts = [mocker.Mock(id=1), mocker.Mock(id=2)]
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.execute.return_value = ScalarResult(accounts) session.execute.return_value = ScalarResult(accounts)
@ -73,7 +78,9 @@ async def test_start_byo_long_poll_spawns_one_supervisor_per_account(mocker, mon
"async_session_maker", "async_session_maker",
lambda: SessionContext(session), lambda: SessionContext(session),
) )
monkeypatch.setattr(byo_long_poll, "account_token", lambda account: f"token-{account.id}") monkeypatch.setattr(
byo_long_poll, "account_token", lambda account: f"token-{account.id}"
)
async def forever(_account_id: int, _token: str) -> None: async def forever(_account_id: int, _token: str) -> None:
await asyncio.Event().wait() await asyncio.Event().wait()
@ -108,7 +115,9 @@ async def test_supervisor_retries_after_run_returns(mocker, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch): async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch):
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll") monkeypatch.setattr(
byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll"
)
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.execute.return_value = ScalarResult([mocker.Mock(id=1)]) session.execute.return_value = ScalarResult([mocker.Mock(id=1)])
monkeypatch.setattr( monkeypatch.setattr(
@ -130,7 +139,9 @@ async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_telegram_account_persists_for_fastapi_inbox_worker(mocker, monkeypatch): async def test_run_telegram_account_persists_for_fastapi_inbox_worker(
mocker, monkeypatch
):
class ConnectionContext: class ConnectionContext:
async def __aenter__(self): async def __aenter__(self):
conn = mocker.AsyncMock() conn = mocker.AsyncMock()
@ -169,4 +180,3 @@ async def test_run_telegram_account_persists_for_fastapi_inbox_worker(mocker, mo
second_session.commit.assert_awaited_once() second_session.commit.assert_awaited_once()
persist.assert_awaited_once() persist.assert_awaited_once()
assert persist.await_args.kwargs["request_id"].startswith("gateway_") assert persist.await_args.kwargs["request_id"].startswith("gateway_")

View file

@ -5,7 +5,9 @@ from app.tasks.celery_tasks import gateway_tasks
def test_enqueue_received_sweep_is_noop_guard(mocker): def test_enqueue_received_sweep_is_noop_guard(mocker):
apply_async = mocker.Mock() apply_async = mocker.Mock()
mocker.patch.object(gateway_tasks.process_inbound_event_task, "apply_async", apply_async) mocker.patch.object(
gateway_tasks.process_inbound_event_task, "apply_async", apply_async
)
info = mocker.patch.object(gateway_tasks.logger, "info") info = mocker.patch.object(gateway_tasks.logger, "info")
replayed = gateway_tasks.enqueue_received_sweep_task.run() replayed = gateway_tasks.enqueue_received_sweep_task.run()
@ -13,4 +15,3 @@ def test_enqueue_received_sweep_is_noop_guard(mocker):
apply_async.assert_not_called() apply_async.assert_not_called()
assert replayed == 0 assert replayed == 0
info.assert_called_once() info.assert_called_once()

View file

@ -15,4 +15,3 @@ def test_chunk_message_preserves_content_and_limits_size():
assert "".join(chunks) == text assert "".join(chunks) == text
assert len(chunks) > 1 assert len(chunks) > 1
assert all(len(chunk.encode("utf-16-le")) // 2 <= 4096 for chunk in chunks) assert all(len(chunk.encode("utf-16-le")) // 2 <= 4096 for chunk in chunks)

View file

@ -12,4 +12,3 @@ def test_filter_hitl_tools_removes_known_approval_tools():
filtered = filter_hitl_tools(tools) filtered = filter_hitl_tools(tools)
assert [getattr(tool, "name", tool) for tool in filtered] == ["search", "summarize"] assert [getattr(tool, "name", tool) for tool in filtered] == ["search", "summarize"]

View file

@ -8,7 +8,9 @@ from app.gateway import inbox_worker
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_inbox_worker_claims_and_processes_in_fastapi_process(mocker, monkeypatch): async def test_inbox_worker_claims_and_processes_in_fastapi_process(
mocker, monkeypatch
):
claim = mocker.AsyncMock(return_value=7) claim = mocker.AsyncMock(return_value=7)
process = mocker.AsyncMock(side_effect=asyncio.CancelledError) process = mocker.AsyncMock(side_effect=asyncio.CancelledError)
monkeypatch.setattr(inbox_worker, "claim_next_inbound_event", claim) monkeypatch.setattr(inbox_worker, "claim_next_inbound_event", claim)
@ -42,4 +44,3 @@ async def test_start_stop_gateway_inbox_worker(mocker, monkeypatch):
assert stopped.is_set() assert stopped.is_set()
assert inbox_worker._task is None assert inbox_worker._task is None

View file

@ -38,4 +38,3 @@ async def test_redeem_pairing_code_binds_pending_row(mocker):
assert binding.state == ExternalChatBindingState.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

View file

@ -9,5 +9,6 @@ def test_process_inbound_event_task_is_noop_guard(mocker):
assert gateway_tasks.process_inbound_event_task.run(123) is None assert gateway_tasks.process_inbound_event_task.run(123) is None
warning.assert_called_once() warning.assert_called_once()
assert "FastAPI owns external chat agent turn processing" in warning.call_args.args[0] assert (
"FastAPI owns external chat agent turn processing" in warning.call_args.args[0]
)

View file

@ -23,7 +23,9 @@ def _enable_gateways(monkeypatch):
monkeypatch.setattr(routes.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "webhook") monkeypatch.setattr(routes.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "webhook")
monkeypatch.setattr(routes.config, "TELEGRAM_SHARED_BOT_TOKEN", "telegram-token") monkeypatch.setattr(routes.config, "TELEGRAM_SHARED_BOT_TOKEN", "telegram-token")
monkeypatch.setattr(routes.config, "TELEGRAM_SHARED_BOT_USERNAME", "surf_bot") monkeypatch.setattr(routes.config, "TELEGRAM_SHARED_BOT_USERNAME", "surf_bot")
monkeypatch.setattr(routes.config, "TELEGRAM_WEBHOOK_SECRET", "telegram-webhook-secret") monkeypatch.setattr(
routes.config, "TELEGRAM_WEBHOOK_SECRET", "telegram-webhook-secret"
)
monkeypatch.setattr(routes.config, "GATEWAY_SLACK_ENABLED", True) monkeypatch.setattr(routes.config, "GATEWAY_SLACK_ENABLED", True)
monkeypatch.setattr(routes.config, "GATEWAY_SLACK_CLIENT_ID", "slack-client") monkeypatch.setattr(routes.config, "GATEWAY_SLACK_CLIENT_ID", "slack-client")
@ -37,7 +39,9 @@ def _enable_gateways(monkeypatch):
class RequestStub: class RequestStub:
def __init__(self, payload=None, *, headers=None, json_exc: Exception | None = None): def __init__(
self, payload=None, *, headers=None, json_exc: Exception | None = None
):
self.headers = headers or {} self.headers = headers or {}
self._payload = payload self._payload = payload
self._json_exc = json_exc self._json_exc = json_exc
@ -70,7 +74,9 @@ def _slack_account() -> ExternalChatAccount:
) )
def _signed_slack_request(payload: dict, *, secret: str = "signing-secret") -> RequestStub: def _signed_slack_request(
payload: dict, *, secret: str = "signing-secret"
) -> RequestStub:
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
timestamp = str(int(time.time())) timestamp = str(int(time.time()))
digest = hmac.new( digest = hmac.new(
@ -195,7 +201,9 @@ async def test_telegram_webhook_persists_for_fastapi_inbox_worker(mocker, monkey
async def test_telegram_webhook_commits_dedup_without_enqueue(mocker, monkeypatch): async def test_telegram_webhook_commits_dedup_without_enqueue(mocker, monkeypatch):
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.get.return_value = _account() session.get.return_value = _account()
monkeypatch.setattr(routes, "persist_inbound_event", mocker.AsyncMock(return_value=None)) monkeypatch.setattr(
routes, "persist_inbound_event", mocker.AsyncMock(return_value=None)
)
request = RequestStub( request = RequestStub(
{"update_id": 10, "message": {"message_id": 7}}, {"update_id": 10, "message": {"message_id": 7}},
@ -250,7 +258,11 @@ async def test_slack_webhook_url_verification(monkeypatch, mocker):
async def test_slack_webhook_persists_event(monkeypatch, mocker): async def test_slack_webhook_persists_event(monkeypatch, mocker):
_enable_slack_gateway(monkeypatch) _enable_slack_gateway(monkeypatch)
session = mocker.AsyncMock() session = mocker.AsyncMock()
monkeypatch.setattr(routes, "get_slack_account_by_team", mocker.AsyncMock(return_value=_slack_account())) monkeypatch.setattr(
routes,
"get_slack_account_by_team",
mocker.AsyncMock(return_value=_slack_account()),
)
persist = mocker.AsyncMock(return_value=100) persist = mocker.AsyncMock(return_value=100)
monkeypatch.setattr(routes, "persist_inbound_event", persist) monkeypatch.setattr(routes, "persist_inbound_event", persist)
payload = { payload = {
@ -280,7 +292,11 @@ async def test_slack_webhook_persists_event(monkeypatch, mocker):
async def test_slack_webhook_ignores_self_event(monkeypatch, mocker): async def test_slack_webhook_ignores_self_event(monkeypatch, mocker):
_enable_slack_gateway(monkeypatch) _enable_slack_gateway(monkeypatch)
session = mocker.AsyncMock() session = mocker.AsyncMock()
monkeypatch.setattr(routes, "get_slack_account_by_team", mocker.AsyncMock(return_value=_slack_account())) monkeypatch.setattr(
routes,
"get_slack_account_by_team",
mocker.AsyncMock(return_value=_slack_account()),
)
persist = mocker.AsyncMock(return_value=100) persist = mocker.AsyncMock(return_value=100)
monkeypatch.setattr(routes, "persist_inbound_event", persist) monkeypatch.setattr(routes, "persist_inbound_event", persist)
request = _signed_slack_request( request = _signed_slack_request(
@ -331,4 +347,3 @@ def test_discord_gateway_callback_does_not_create_search_source_connector():
callback_source = inspect.getsource(routes.discord_gateway_callback) callback_source = inspect.getsource(routes.discord_gateway_callback)
assert "SearchSourceConnector" not in callback_source assert "SearchSourceConnector" not in callback_source

View file

@ -81,7 +81,9 @@ class TestCwdDefaults:
class TestRelativePathResolution: class TestRelativePathResolution:
def test_relative_path_resolves_against_cwd(self): def test_relative_path_resolves_against_cwd(self):
assert ( assert (
resolve_relative(_mw(), "notes.md", _runtime({"cwd": "/documents/projects"})) resolve_relative(
_mw(), "notes.md", _runtime({"cwd": "/documents/projects"})
)
== "/documents/projects/notes.md" == "/documents/projects/notes.md"
) )
@ -281,7 +283,11 @@ class TestNormalizeLocalMountPath:
_desktop_mw(backend), _desktop_mw(backend),
"/brand-new-note.md", "/brand-new-note.md",
_runtime( _runtime(
{"file_operation_contract": {"suggested_path": "/root_b/notes/context.md"}} {
"file_operation_contract": {
"suggested_path": "/root_b/notes/context.md"
}
}
), ),
) )
assert resolved == "/root_b/brand-new-note.md" assert resolved == "/root_b/brand-new-note.md"

View file

@ -15,7 +15,9 @@ from unittest.mock import AsyncMock
import numpy as np import numpy as np
import pytest import pytest
from app.agents.chat.multi_agent_chat.main_agent.middleware.kb_persistence import middleware as kb_persistence from app.agents.chat.multi_agent_chat.main_agent.middleware.kb_persistence import (
middleware as kb_persistence,
)
from app.db import Document from app.db import Document

Some files were not shown because too many files have changed in this diff Show more