diff --git a/backend/package/yuxi/channels/adapters/slack/__init__.py b/backend/package/yuxi/channels/adapters/slack/__init__.py index 52bd5327..aa61d275 100644 --- a/backend/package/yuxi/channels/adapters/slack/__init__.py +++ b/backend/package/yuxi/channels/adapters/slack/__init__.py @@ -1,12 +1,32 @@ from __future__ import annotations +from yuxi.channels.adapters.slack.accounts import SlackAccountRegistry +from yuxi.channels.adapters.slack.action_threading import ( + ActionThreadBehavior, + resolve_action_thread_behavior, + resolve_button_action_threading, +) from yuxi.channels.adapters.slack.adapter import SlackAdapter +from yuxi.channels.adapters.slack.agent_prompt import SlackPromptHints +from yuxi.channels.adapters.slack.allowlist import AllowlistManager +from yuxi.channels.adapters.slack.allowlist_name_resolution import ( + resolve_allowlist_names, + resolve_group_names, +) +from yuxi.channels.adapters.slack.approval import ApprovalManager, ApprovalRequest, ApprovalStatus +from yuxi.channels.adapters.slack.approval_native import ( + ExecApprovalConfig, + is_authorized_sender, + normalize_approver_id, + push_approval_to_app_home, + resolve_origin_target, +) from yuxi.channels.adapters.slack.blocks import ( build_actions, build_approval_blocks, build_button, - build_conversations_select, build_context, + build_conversations_select, build_divider, build_error_notice, build_external_select, @@ -21,39 +41,69 @@ from yuxi.channels.adapters.slack.blocks import ( build_users_select, escape_mrkdwn, ) -from yuxi.channels.adapters.slack.security import ( - DmPolicy, - GroupPolicy, - SecurityConfig, - SecurityDecision, +from yuxi.channels.adapters.slack.channel_resolver import resolve_channel_id +from yuxi.channels.adapters.slack.channel_type import ( + OutboundSessionRoute, + format_from_address, + format_to_address, + resolve_channel_type, + resolve_session_route, ) -from yuxi.channels.adapters.slack.allowlist import AllowlistManager -from yuxi.channels.adapters.slack.pairing import PairingManager from yuxi.channels.adapters.slack.chunker import ( ChunkMode, TextChunk, convert_table_to_bullets, resolve_text_chunks, ) -from yuxi.channels.adapters.slack.dm_cache import DmChannelCache -from yuxi.channels.adapters.slack.channel_resolver import resolve_channel_id -from yuxi.channels.adapters.slack.models import ResolvedSlackAccount -from yuxi.channels.adapters.slack.credentials import SlackCredentials -from yuxi.channels.adapters.slack.accounts import SlackAccountRegistry -from yuxi.channels.adapters.slack.reconnect import ReconnectPolicy, ReconnectState -from yuxi.channels.adapters.slack.scopes import ScopesInfo, fetch_scopes, validate_required_scopes -from yuxi.channels.adapters.slack.targets import SlackTarget, parse_slack_target, parse_slack_targets -from yuxi.channels.adapters.slack.sent_cache import SentMessageCache -from yuxi.channels.adapters.slack.security_audit import ( - AuditFinding, - AuditResult, - audit_security_config, - audit_connection_status, - auto_fix_security, +from yuxi.channels.adapters.slack.config_adapter import ( + ConfigAdapterResult, + adapt_config, ) -from yuxi.channels.adapters.slack.reaction_notify import ReactionNotifyConfig, ReactionNotifyMode -from yuxi.channels.adapters.slack.poll_manager import Poll, PollManager, PollOption -from yuxi.channels.adapters.slack.approval import ApprovalManager, ApprovalRequest, ApprovalStatus +from yuxi.channels.adapters.slack.credentials import SlackCredentials +from yuxi.channels.adapters.slack.directory_config import ( + build_directory_config_groups, + build_directory_config_peers, +) +from yuxi.channels.adapters.slack.directory_live import ( + DirectoryGroup, + DirectoryPeer, + list_directory_groups_live, + list_directory_peers_live, +) +from yuxi.channels.adapters.slack.dm_cache import DmChannelCache +from yuxi.channels.adapters.slack.doctor_legacy import ( + DoctorReport, + MigrationResult, + MigrationStatus, + run_config_doctor, + run_legacy_migration, +) +from yuxi.channels.adapters.slack.draft_stream import DraftStream +from yuxi.channels.adapters.slack.group_policy import ( + GroupPolicyRegistry, + PerChannelPolicy, + resolve_group_require_mention, + resolve_group_tool_policy, +) +from yuxi.channels.adapters.slack.interaction_dispatch import ( + InteractionPayload, + dispatch_interaction, + parse_interaction_payload, +) +from yuxi.channels.adapters.slack.interactive_replies import compile_interactive_replies, has_interactive_syntax +from yuxi.channels.adapters.slack.message_actions import ( + MessageAction, + MessageActionsConfig, + compile_message_actions, + gate_message_actions, +) +from yuxi.channels.adapters.slack.message_handler import ( + PreparedSlackMessage, + prepare_message, +) +from yuxi.channels.adapters.slack.message_handler.debounce_key import debounce_key, should_debounce +from yuxi.channels.adapters.slack.message_handler.pipeline_runtime import run_pipeline +from yuxi.channels.adapters.slack.message_handler.preview_finalize import finalize_preview from yuxi.channels.adapters.slack.modals import ( build_approval_modal, build_confirmation_dialog, @@ -61,9 +111,13 @@ from yuxi.channels.adapters.slack.modals import ( build_modal, build_view_error_section, ) -from yuxi.channels.adapters.slack.voice.tts import SlackTTSConfig, synthesize_slack_tts -from yuxi.channels.adapters.slack.vision.vision import SlackVisionConfig, VisionResult, analyze_slack_image - +from yuxi.channels.adapters.slack.models import ResolvedSlackAccount +from yuxi.channels.adapters.slack.monitor_media import resolve_media +from yuxi.channels.adapters.slack.multi_account import ( + MultiAccountRegistry, + SlackAccountInfo, + _init_account_connection, +) from yuxi.channels.adapters.slack.normalizer import ( extract_mentions, normalize_channel_refs, @@ -73,119 +127,62 @@ from yuxi.channels.adapters.slack.normalizer import ( strip_mentions, unescape_html_entities, ) -from yuxi.channels.adapters.slack.agent_prompt import SlackPromptHints -from yuxi.channels.adapters.slack.streaming_compat import StreamingConfig -from yuxi.channels.adapters.slack.streaming import NativeStreamManager, SlackStreamNotDeliveredError, StreamState -from yuxi.channels.adapters.slack.draft_stream import DraftStream -from yuxi.channels.adapters.slack.interactive_replies import compile_interactive_replies, has_interactive_syntax -from yuxi.channels.adapters.slack.message_handler import ( - PreparedSlackMessage, - prepare_message, +from yuxi.channels.adapters.slack.pairing import PairingManager +from yuxi.channels.adapters.slack.pairing_notification import ( + send_pairing_approved_notification, + send_pairing_notification, ) -from yuxi.channels.adapters.slack.message_handler.pipeline_runtime import run_pipeline -from yuxi.channels.adapters.slack.message_handler.debounce_key import debounce_key, should_debounce -from yuxi.channels.adapters.slack.message_handler.preview_finalize import finalize_preview - -from yuxi.channels.adapters.slack.approval_native import ( - ExecApprovalConfig, - normalize_approver_id, - push_approval_to_app_home, - resolve_origin_target, - is_authorized_sender, -) -from yuxi.channels.adapters.slack.directory_live import ( - DirectoryPeer, - DirectoryGroup, - list_directory_peers_live, - list_directory_groups_live, -) -from yuxi.channels.adapters.slack.directory_config import ( - build_directory_config_peers, - build_directory_config_groups, -) -from yuxi.channels.adapters.slack.group_policy import ( - PerChannelPolicy, - GroupPolicyRegistry, - resolve_group_require_mention, - resolve_group_tool_policy, -) -from yuxi.channels.adapters.slack.channel_type import ( - resolve_channel_type, - resolve_session_route, - OutboundSessionRoute, - format_from_address, - format_to_address, -) -from yuxi.channels.adapters.slack.monitor_media import resolve_media +from yuxi.channels.adapters.slack.poll_manager import Poll, PollManager, PollOption +from yuxi.channels.adapters.slack.reaction_notify import ReactionNotifyConfig, ReactionNotifyMode +from yuxi.channels.adapters.slack.reconnect import ReconnectPolicy, ReconnectState from yuxi.channels.adapters.slack.room_context import RoomContext, extract_room_context - -from yuxi.channels.adapters.slack.multi_account import ( - SlackAccountInfo, - MultiAccountRegistry, - _init_account_connection, +from yuxi.channels.adapters.slack.scopes import ScopesInfo, fetch_scopes, validate_required_scopes +from yuxi.channels.adapters.slack.security import ( + DmPolicy, + GroupPolicy, + SecurityConfig, + SecurityDecision, ) -from yuxi.channels.adapters.slack.doctor_legacy import ( - DoctorReport, - MigrationResult, - MigrationStatus, - run_legacy_migration, - run_config_doctor, +from yuxi.channels.adapters.slack.security_audit import ( + AuditFinding, + AuditResult, + audit_connection_status, + audit_security_config, + auto_fix_security, ) from yuxi.channels.adapters.slack.security_doctor import ( SecurityDoctorReport, run_security_doctor, ) -from yuxi.channels.adapters.slack.config_adapter import ( - ConfigAdapterResult, - adapt_config, -) -from yuxi.channels.adapters.slack.thread_tool_context import ( - ThreadToolContext, - extract_thread_tool_context, - inject_thread_tool_context, -) -from yuxi.channels.adapters.slack.action_threading import ( - ActionThreadBehavior, - resolve_action_thread_behavior, - resolve_button_action_threading, -) -from yuxi.channels.adapters.slack.message_actions import ( - MessageAction, - MessageActionsConfig, - gate_message_actions, - compile_message_actions, -) -from yuxi.channels.adapters.slack.setup_wizard import ( - SetupWizardStep, - SetupWizardState, - create_setup_wizard_steps, - setup_wizard_to_config, -) -from yuxi.channels.adapters.slack.setup_allow_from import ( - setup_allow_from, - setup_group_allowlist, - remove_from_allowlist, - add_to_allowlist, - normalize_user_id, -) +from yuxi.channels.adapters.slack.sent_cache import SentMessageCache from yuxi.channels.adapters.slack.setup_adapter import ( SetupAdapterRequest, SetupAdapterResponse, handle_setup_command, ) -from yuxi.channels.adapters.slack.pairing_notification import ( - send_pairing_notification, - send_pairing_approved_notification, +from yuxi.channels.adapters.slack.setup_allow_from import ( + add_to_allowlist, + normalize_user_id, + remove_from_allowlist, + setup_allow_from, + setup_group_allowlist, ) -from yuxi.channels.adapters.slack.allowlist_name_resolution import ( - resolve_allowlist_names, - resolve_group_names, +from yuxi.channels.adapters.slack.setup_wizard import ( + SetupWizardState, + SetupWizardStep, + create_setup_wizard_steps, + setup_wizard_to_config, ) -from yuxi.channels.adapters.slack.interaction_dispatch import ( - InteractionPayload, - dispatch_interaction, - parse_interaction_payload, +from yuxi.channels.adapters.slack.streaming import NativeStreamManager, SlackStreamNotDeliveredError, StreamState +from yuxi.channels.adapters.slack.streaming_compat import StreamingConfig +from yuxi.channels.adapters.slack.targets import SlackTarget, parse_slack_target, parse_slack_targets +from yuxi.channels.adapters.slack.thread_tool_context import ( + ThreadToolContext, + extract_thread_tool_context, + inject_thread_tool_context, ) +from yuxi.channels.adapters.slack.vision.vision import SlackVisionConfig, VisionResult, analyze_slack_image +from yuxi.channels.adapters.slack.voice.tts import SlackTTSConfig, synthesize_slack_tts __all__ = [ "SlackAdapter", diff --git a/backend/package/yuxi/channels/adapters/slack/adapter.py b/backend/package/yuxi/channels/adapters/slack/adapter.py index 16c383f4..629febdd 100644 --- a/backend/package/yuxi/channels/adapters/slack/adapter.py +++ b/backend/package/yuxi/channels/adapters/slack/adapter.py @@ -7,25 +7,51 @@ import io import os import re import time -from collections.abc import AsyncIterator from collections import OrderedDict -from datetime import datetime, UTC +from collections.abc import AsyncIterator +from datetime import UTC, datetime from typing import Any, ClassVar +from slack_sdk.errors import SlackApiError from slack_sdk.http_retry.builtin_async_handlers import AsyncRateLimitErrorRetryHandler from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest -from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient +from yuxi.channels.adapters.slack.agent_prompt import SlackPromptHints +from yuxi.channels.adapters.slack.allowlist import AllowlistManager +from yuxi.channels.adapters.slack.approval import ApprovalManager +from yuxi.channels.adapters.slack.chunker import ChunkMode, resolve_text_chunks +from yuxi.channels.adapters.slack.commands import SlackCommandRegistry +from yuxi.channels.adapters.slack.interactive_replies import compile_interactive_replies, has_interactive_syntax +from yuxi.channels.adapters.slack.normalizer import normalize_slack_text +from yuxi.channels.adapters.slack.pairing import PairingManager +from yuxi.channels.adapters.slack.poll_manager import PollManager +from yuxi.channels.adapters.slack.reaction_notify import ReactionNotifyConfig +from yuxi.channels.adapters.slack.reconnect import Http401BackoffState +from yuxi.channels.adapters.slack.security import ( + DmPolicy, + SecurityConfig, + SecurityDecision, +) +from yuxi.channels.adapters.slack.security_audit import ( + audit_security_config, + auto_fix_security, +) +from yuxi.channels.adapters.slack.sent_cache import SentMessageCache +from yuxi.channels.adapters.slack.session import resolve_chat_id, resolve_chat_type +from yuxi.channels.adapters.slack.streaming_compat import StreamingConfig +from yuxi.channels.adapters.slack.vision.vision import SlackVisionConfig +from yuxi.channels.adapters.slack.voice.tts import SlackTTSConfig, synthesize_slack_tts from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities -from yuxi.channels.meta import ChannelMeta from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelException, ChannelNotConnectedError, ) +from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError +from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( Attachment, ChannelIdentity, @@ -40,31 +66,6 @@ from yuxi.channels.models import ( MessageType, ) from yuxi.channels.registry import register_builtin_adapter -from yuxi.channels.adapters.slack.session import resolve_chat_id, resolve_chat_type -from yuxi.channels.adapters.slack.security import ( - DmPolicy, - SecurityConfig, - SecurityDecision, -) -from yuxi.channels.adapters.slack.security_audit import ( - audit_security_config, - auto_fix_security, -) -from yuxi.channels.adapters.slack.allowlist import AllowlistManager -from yuxi.channels.adapters.slack.pairing import PairingManager -from yuxi.channels.adapters.slack.poll_manager import PollManager -from yuxi.channels.adapters.slack.commands import SlackCommandRegistry -from yuxi.channels.adapters.slack.approval import ApprovalManager -from yuxi.channels.adapters.slack.reaction_notify import ReactionNotifyConfig -from yuxi.channels.adapters.slack.voice.tts import SlackTTSConfig, synthesize_slack_tts -from yuxi.channels.adapters.slack.vision.vision import SlackVisionConfig -from yuxi.channels.adapters.slack.chunker import ChunkMode, resolve_text_chunks -from yuxi.channels.adapters.slack.sent_cache import SentMessageCache -from yuxi.channels.adapters.slack.reconnect import Http401BackoffState -from yuxi.channels.adapters.slack.normalizer import strip_mentions, normalize_slack_text -from yuxi.channels.adapters.slack.interactive_replies import compile_interactive_replies, has_interactive_syntax -from yuxi.channels.adapters.slack.agent_prompt import SlackPromptHints -from yuxi.channels.adapters.slack.streaming_compat import StreamingConfig from yuxi.utils.logging_config import logger @@ -150,6 +151,7 @@ class SlackAdapter(BaseChannelAdapter): self._shutting_down = False self._mode: str = config.get("mode", "socket") if config else "socket" + self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="slack") self._signing_secret = ( config.get("signing_secret", "") or os.getenv("SLACK_SIGNING_SECRET", "") if config @@ -288,6 +290,8 @@ class SlackAdapter(BaseChannelAdapter): self._last_channel_event_at = time.monotonic() self._channel_heartbeat_task = asyncio.create_task(self._channel_heartbeat_loop()) + await self._restore_sent_cache_from_state() + async def disconnect(self) -> None: self._shutting_down = True self._shutdown_event.set() @@ -295,6 +299,8 @@ class SlackAdapter(BaseChannelAdapter): self._streaming_messages.clear() self._connected_event.clear() + await self._persist_sent_cache_to_state() + if self._channel_heartbeat_task and not self._channel_heartbeat_task.done(): self._channel_heartbeat_task.cancel() try: @@ -335,7 +341,7 @@ class SlackAdapter(BaseChannelAdapter): if self._status != ChannelStatus.CONNECTED or not self._client: return DeliveryResult(success=False, error="Slack not connected") - try: + async def _do_send() -> DeliveryResult: payload = self.format_outbound(response) chat_id = response.identity.channel_chat_id thread_ts = response.metadata.get("thread_ts") @@ -361,6 +367,10 @@ class SlackAdapter(BaseChannelAdapter): if payload.get(key): params[key] = payload[key] + for key in ("reply_broadcast", "metadata", "unfurl_links", "unfurl_media", "link_names", "markdown_text"): + if key in payload and payload[key] is not None: + params[key] = payload[key] + if len(text) <= self.text_chunk_limit: params["text"] = text or "(empty message)" params["mrkdwn"] = payload.get("mrkdwn", True) @@ -370,7 +380,7 @@ class SlackAdapter(BaseChannelAdapter): if ts: await self._sent_cache.put(chat_id, ts) return DeliveryResult(success=True, message_id=ts) - return DeliveryResult(success=False, error=result.get("error", "Unknown error")) + raise SlackApiError("chat_postMessage failed", response={"error": result.get("error", "Unknown error")}) chunks = resolve_text_chunks(text, self.text_chunk_limit, mode=ChunkMode.NEWLINE) first_result = None @@ -394,7 +404,8 @@ class SlackAdapter(BaseChannelAdapter): if ts: await self._sent_cache.put(chat_id, ts) elif chunk.index == 0: - return DeliveryResult(success=False, error=result.get("error", "Unknown error")) + error_msg = result.get("error", "Unknown error") + raise SlackApiError("chat_postMessage chunk failed", response={"error": error_msg}) else: failed_chunks.append({"index": chunk.index, "error": result.get("error", "Unknown error")}) @@ -410,6 +421,10 @@ class SlackAdapter(BaseChannelAdapter): return first_result return DeliveryResult(success=False, error="No chunks to send") + try: + return await self._circuit_breaker.call(_do_send) + except CircuitBreakerOpenError: + return DeliveryResult(success=False, error="Circuit breaker open") except SlackApiError as e: err = e.response.get("error", str(e)) return DeliveryResult(success=False, error=err, metadata={"error_type": "slack_api_error"}) @@ -540,6 +555,30 @@ class SlackAdapter(BaseChannelAdapter): if icon_emoji: payload["icon_emoji"] = icon_emoji + reply_broadcast = response.metadata.get("reply_broadcast") + if reply_broadcast is not None: + payload["reply_broadcast"] = bool(reply_broadcast) + + msg_metadata = response.metadata.get("metadata") + if msg_metadata: + payload["metadata"] = msg_metadata + + unfurl_links = response.metadata.get("unfurl_links") + if unfurl_links is not None: + payload["unfurl_links"] = bool(unfurl_links) + + unfurl_media = response.metadata.get("unfurl_media") + if unfurl_media is not None: + payload["unfurl_media"] = bool(unfurl_media) + + link_names = response.metadata.get("link_names") + if link_names: + payload["link_names"] = True + + markdown_text = response.metadata.get("markdown_text") + if markdown_text: + payload["markdown_text"] = markdown_text + return payload async def health_check(self) -> HealthStatus: @@ -984,6 +1023,27 @@ class SlackAdapter(BaseChannelAdapter): logger.warning(f"SLACK_USER_TOKEN does not start with 'xoxp-', got prefix: {token[:5]}...") return token + async def _restore_sent_cache_from_state(self) -> None: + cache_data = await self.state_get("sent_cache", namespace="cache") + if cache_data and isinstance(cache_data, list): + for entry in cache_data: + if isinstance(entry, dict): + chat_id = entry.get("chat_id") + thread_ts = entry.get("thread_ts") + if chat_id and thread_ts: + await self._sent_cache.put(chat_id, thread_ts) + logger.info(f"[Slack] Restored {len(cache_data)} sent cache entries from state_store") + + async def _persist_sent_cache_to_state(self) -> None: + entries: list[dict[str, str]] = [] + async with self._sent_cache._lock: + for chat_id, (thread_ts, stored_at) in self._sent_cache._cache.items(): + if time.monotonic() - stored_at <= self._sent_cache._ttl: + entries.append({"chat_id": chat_id, "thread_ts": thread_ts}) + if entries: + await self.state_set("sent_cache", entries, namespace="cache", ttl_seconds=3600) + logger.debug(f"[Slack] Persisted {len(entries)} sent cache entries to state_store") + async def _connect_http(self, bot_token: str) -> None: if not bot_token: raise ChannelAuthenticationError( @@ -1285,6 +1345,150 @@ class SlackAdapter(BaseChannelAdapter): except SlackApiError as e: return DeliveryResult(success=False, error=str(e)) + async def get_message_permalink(self, channel_id: str, message_ts: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.chat_getPermalink(channel=channel_id, message_ts=message_ts) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + metadata={"permalink": result.get("permalink", "")}, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def schedule_message( + self, + channel_id: str, + *, + text: str | None = None, + blocks: list | None = None, + post_at: int | None = None, + thread_ts: str | None = None, + ) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + params: dict[str, Any] = {"channel": channel_id} + if text: + params["text"] = text + if blocks: + params["blocks"] = blocks + if post_at: + params["post_at"] = post_at + if thread_ts: + params["thread_ts"] = thread_ts + result = await self._client.chat_scheduleMessage(**params) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + message_id=result.get("scheduled_message_id"), + metadata={"channel": result.get("channel"), "post_at": result.get("post_at")}, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def delete_scheduled_message(self, channel_id: str, scheduled_message_id: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.chat_deleteScheduledMessage( + channel=channel_id, scheduled_message_id=scheduled_message_id + ) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def send_me_message(self, channel_id: str, text: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.chat_meMessage(channel=channel_id, text=text) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + message_id=result.get("ts"), + metadata={"channel": result.get("channel")}, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def delete_file(self, file_id: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.files_delete(file=file_id) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def share_file_public(self, file_id: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.files_sharedPublicURL(file=file_id) + ok = result.get("ok", False) + file_info = result.get("file", {}) + return DeliveryResult( + success=ok, + metadata={ + "permalink_public": file_info.get("permalink_public", ""), + "file": file_info, + }, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def get_user_profile(self, user_id: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.users_profile_get(user=user_id) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + metadata={"profile": result.get("profile", {})}, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + + async def lookup_user_by_email(self, email: str) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.users_lookupByEmail(email=email) + ok = result.get("ok", False) + user = result.get("user", {}) + return DeliveryResult( + success=ok, + metadata={"user": user}, + error=result.get("error") if not ok else None, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: if not self._signing_secret: return False @@ -1680,6 +1884,21 @@ class SlackAdapter(BaseChannelAdapter): except SlackApiError as e: return DeliveryResult(success=False, error=str(e)) + async def publish_home_tab(self, user_id: str, view: dict) -> DeliveryResult: + if self._status != ChannelStatus.CONNECTED or not self._client: + return DeliveryResult(success=False, error="Slack not connected") + + try: + result = await self._client.views_publish(user_id=user_id, view=view) + ok = result.get("ok", False) + return DeliveryResult( + success=ok, + error=result.get("error") if not ok else None, + metadata={"view": result.get("view", {}) if ok else None}, + ) + except SlackApiError as e: + return DeliveryResult(success=False, error=str(e)) + def _check_bot_mention(self, text: str) -> bool: if not text or not self._bot_user_id: return False @@ -2153,6 +2372,133 @@ class SlackAdapter(BaseChannelAdapter): except Exception as e: logger.error(f"Slack thread_broadcast error: {e}", exc_info=True) + @handler.on("app_uninstalled") + async def _on_app_uninstalled(client: SocketModeClient, req: SocketModeRequest): + logger.warning(f"Slack app uninstalled by team {req.payload.get('team_id', '')}, cleaning up connections") + try: + self._status = ChannelStatus.DISCONNECTED + self._connected_event.clear() + if self._socket_task and not self._socket_task.done(): + self._socket_task.cancel() + try: + await self._socket_task + except (asyncio.CancelledError, Exception): + pass + except Exception as e: + logger.error(f"Slack app_uninstalled cleanup error: {e}", exc_info=True) + + @handler.on("tokens_revoked") + async def _on_tokens_revoked(client: SocketModeClient, req: SocketModeRequest): + tokens = req.payload.get("tokens", {}) + logger.warning(f"Slack tokens revoked: {list(tokens.keys())}") + try: + self._status = ChannelStatus.DISCONNECTED + self._connected_event.clear() + except Exception as e: + logger.error(f"Slack tokens_revoked cleanup error: {e}", exc_info=True) + + @handler.on("team_join") + async def _on_team_join(client: SocketModeClient, req: SocketModeRequest): + try: + user_data = req.payload.get("user", {}) + user_id = user_data.get("id", "") + msg = self._build_system_event( + req, + event_type=EventType.SYSTEM_EVENT, + content=f"team_join:{user_id}", + channel=user_id, + ts=req.payload.get("event_ts", ""), + user=user_id, + metadata={ + "team_event": "team_join", + "user": user_data, + }, + ) + await self._handle_message_with_security(msg) + except Exception as e: + logger.error(f"Slack team_join error: {e}", exc_info=True) + + @handler.on("emoji_changed") + async def _on_emoji_changed(client: SocketModeClient, req: SocketModeRequest): + try: + subtype = req.payload.get("subtype", "") + msg = self._build_system_event( + req, + event_type=EventType.SYSTEM_EVENT, + content=f"emoji_changed:{subtype}", + ts=req.payload.get("event_ts", ""), + metadata={ + "emoji_event": "emoji_changed", + "subtype": subtype, + "names": req.payload.get("names", req.payload.get("name", [])), + }, + ) + await self._handle_message_with_security(msg) + except Exception as e: + logger.error(f"Slack emoji_changed error: {e}", exc_info=True) + + @handler.on("file_public") + async def _on_file_public(client: SocketModeClient, req: SocketModeRequest): + try: + file_id = req.payload.get("file_id", req.payload.get("file", {}).get("id", "")) + msg = self._build_system_event( + req, + event_type=EventType.SYSTEM_EVENT, + content=f"file_public:{file_id}", + ts=req.payload.get("event_ts", ""), + metadata={ + "file_event": "file_public", + "file_id": file_id, + }, + ) + await self._handle_message_with_security(msg) + except Exception as e: + logger.error(f"Slack file_public error: {e}", exc_info=True) + + @handler.on("file_shared") + async def _on_file_shared(client: SocketModeClient, req: SocketModeRequest): + try: + file_data = req.payload.get("file", {}) + file_id = file_data.get("id", "") + channel_id = req.payload.get("channel_id", "") + msg = self._build_system_event( + req, + event_type=EventType.SYSTEM_EVENT, + content=f"file_shared:{file_id}", + channel=channel_id, + ts=req.payload.get("event_ts", ""), + user=req.payload.get("user_id", ""), + metadata={ + "file_event": "file_shared", + "file": file_data, + "file_id": file_id, + }, + ) + await self._handle_message_with_security(msg) + except Exception as e: + logger.error(f"Slack file_shared error: {e}", exc_info=True) + + @handler.on("user_change") + async def _on_user_change(client: SocketModeClient, req: SocketModeRequest): + try: + user_data = req.payload.get("user", {}) + user_id = user_data.get("id", "") + msg = self._build_system_event( + req, + event_type=EventType.SYSTEM_EVENT, + content=f"user_change:{user_id}", + channel=user_id, + ts=req.payload.get("event_ts", ""), + user=user_id, + metadata={ + "user_event": "user_change", + "user": user_data, + }, + ) + await self._handle_message_with_security(msg) + except Exception as e: + logger.error(f"Slack user_change error: {e}", exc_info=True) + async def _handle_message_changed(self, event: dict, req: SocketModeRequest) -> None: msg_data = event.get("message", {}) previous = event.get("previous_message", {}) diff --git a/backend/package/yuxi/channels/adapters/slack/approval.py b/backend/package/yuxi/channels/adapters/slack/approval.py index 2838936f..edac1b69 100644 --- a/backend/package/yuxi/channels/adapters/slack/approval.py +++ b/backend/package/yuxi/channels/adapters/slack/approval.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from datetime import datetime, UTC, timedelta +from datetime import UTC, datetime, timedelta from enum import StrEnum from typing import Any diff --git a/backend/package/yuxi/channels/adapters/slack/auto_presence.py b/backend/package/yuxi/channels/adapters/slack/auto_presence.py index ae4e599a..6ad0ef60 100644 --- a/backend/package/yuxi/channels/adapters/slack/auto_presence.py +++ b/backend/package/yuxi/channels/adapters/slack/auto_presence.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, UTC +from datetime import UTC, datetime from typing import Any from yuxi.channels.models import DeliveryResult diff --git a/backend/package/yuxi/channels/adapters/slack/blocks.py b/backend/package/yuxi/channels/adapters/slack/blocks.py index a1763dde..b0e78f80 100644 --- a/backend/package/yuxi/channels/adapters/slack/blocks.py +++ b/backend/package/yuxi/channels/adapters/slack/blocks.py @@ -264,3 +264,304 @@ def build_input( if dispatch_action: block["dispatch_action"] = True return block + + +# ========== Input Elements ========== + + +def build_datepicker( + action_id: str, + *, + placeholder: str = "选择日期", + initial_date: str = "", +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "datepicker", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_date: + element["initial_date"] = initial_date + return element + + +def build_checkboxes( + action_id: str, + options: list[dict[str, Any]], + *, + initial_options: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "checkboxes", + "action_id": action_id, + "options": options, + } + if initial_options: + element["initial_options"] = initial_options + return element + + +def build_radio_buttons( + action_id: str, + options: list[dict[str, Any]], + *, + initial_option: dict[str, Any] | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "radio_buttons", + "action_id": action_id, + "options": options, + } + if initial_option: + element["initial_option"] = initial_option + return element + + +def build_overflow_menu( + action_id: str, + options: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "type": "overflow", + "action_id": action_id, + "options": options, + } + + +# ========== Multi-Select Elements ========== + + +def build_multi_static_select( + action_id: str, + options: list[dict[str, Any]], + *, + placeholder: str = "选择选项", + initial_options: list[dict[str, Any]] | None = None, + max_selected_items: int | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "multi_static_select", + "action_id": action_id, + "options": options, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_options: + element["initial_options"] = initial_options + if max_selected_items is not None: + element["max_selected_items"] = max_selected_items + return element + + +def build_multi_users_select( + action_id: str, + *, + placeholder: str = "选择用户", + initial_users: list[str] | None = None, + max_selected_items: int | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "multi_users_select", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_users: + element["initial_users"] = initial_users + if max_selected_items is not None: + element["max_selected_items"] = max_selected_items + return element + + +def build_multi_external_select( + action_id: str, + *, + placeholder: str = "搜索...", + initial_options: list[dict[str, Any]] | None = None, + min_query_length: int = 1, + max_selected_items: int | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "multi_external_select", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + "min_query_length": min_query_length, + } + if initial_options: + element["initial_options"] = initial_options + if max_selected_items is not None: + element["max_selected_items"] = max_selected_items + return element + + +def build_multi_conversations_select( + action_id: str, + *, + placeholder: str = "选择频道", + initial_conversations: list[str] | None = None, + max_selected_items: int | None = None, + filter_include: list[str] | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "multi_conversations_select", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_conversations: + element["initial_conversations"] = initial_conversations + if max_selected_items is not None: + element["max_selected_items"] = max_selected_items + if filter_include: + element["filter"] = {"include": filter_include} + return element + + +# ========== More Input Elements ========== + + +def build_timepicker( + action_id: str, + *, + placeholder: str = "选择时间", + initial_time: str = "", +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "timepicker", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_time: + element["initial_time"] = initial_time + return element + + +def build_plain_text_input( + action_id: str, + *, + placeholder: str = "请输入...", + initial_value: str = "", + multiline: bool = False, + min_length: int | None = None, + max_length: int | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "plain_text_input", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_value: + element["initial_value"] = initial_value + if multiline: + element["multiline"] = True + if min_length is not None: + element["min_length"] = min_length + if max_length is not None: + element["max_length"] = max_length + return element + + +def build_url_text_input( + action_id: str, + *, + placeholder: str = "请输入 URL...", + initial_value: str = "", +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "url_text_input", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_value: + element["initial_value"] = initial_value + return element + + +def build_email_text_input( + action_id: str, + *, + placeholder: str = "请输入邮箱...", + initial_value: str = "", +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "email_text_input", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_value: + element["initial_value"] = initial_value + return element + + +def build_number_input( + action_id: str, + *, + placeholder: str = "请输入数字...", + initial_value: str = "", + min_value: str | None = None, + max_value: str | None = None, + is_decimal_allowed: bool = False, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "number_input", + "action_id": action_id, + "placeholder": {"type": "plain_text", "text": placeholder}, + } + if initial_value: + element["initial_value"] = initial_value + if min_value is not None: + element["min_value"] = min_value + if max_value is not None: + element["max_value"] = max_value + if is_decimal_allowed: + element["is_decimal_allowed"] = True + return element + + +def build_file_input( + action_id: str, + *, + filetypes: list[str] | None = None, + max_files: int | None = None, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "file_input", + "action_id": action_id, + } + if filetypes: + element["filetypes"] = filetypes + if max_files is not None: + element["max_files"] = max_files + return element + + +# ========== Rich Text Block ========== + + +def build_rich_text(elements: list[dict[str, Any]]) -> dict[str, Any]: + return { + "type": "rich_text", + "elements": elements, + } + + +def build_rich_text_section(elements: list[dict[str, Any]]) -> dict[str, Any]: + return { + "type": "rich_text_section", + "elements": elements, + } + + +def build_rich_text_list( + style: str, + elements: list[dict[str, Any]], + *, + indent: int = 0, + border: int = 0, +) -> dict[str, Any]: + element: dict[str, Any] = { + "type": "rich_text_list", + "style": style, + "elements": elements, + "indent": indent, + } + if border: + element["border"] = border + return element diff --git a/backend/package/yuxi/channels/adapters/slack/commands.py b/backend/package/yuxi/channels/adapters/slack/commands.py index 523b90f4..0814c9da 100644 --- a/backend/package/yuxi/channels/adapters/slack/commands.py +++ b/backend/package/yuxi/channels/adapters/slack/commands.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import StrEnum from typing import Any diff --git a/backend/package/yuxi/channels/adapters/slack/directory_config.py b/backend/package/yuxi/channels/adapters/slack/directory_config.py index 9a52ca25..b0951ff6 100644 --- a/backend/package/yuxi/channels/adapters/slack/directory_config.py +++ b/backend/package/yuxi/channels/adapters/slack/directory_config.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Any from yuxi.channels.adapters.slack.directory_live import ( - list_directory_peers_live, list_directory_groups_live, + list_directory_peers_live, ) diff --git a/backend/package/yuxi/channels/adapters/slack/http_handler.py b/backend/package/yuxi/channels/adapters/slack/http_handler.py index c71d10db..07d8ebff 100644 --- a/backend/package/yuxi/channels/adapters/slack/http_handler.py +++ b/backend/package/yuxi/channels/adapters/slack/http_handler.py @@ -6,7 +6,7 @@ from typing import Any from fastapi import APIRouter, Request, status from fastapi.responses import JSONResponse, PlainTextResponse -from yuxi.channels.manager import channel_manager +from yuxi.channels.manager import get_channel_manager from yuxi.utils.logging_config import logger slack_webhook = APIRouter(tags=["slack-webhook"]) @@ -14,7 +14,7 @@ slack_webhook = APIRouter(tags=["slack-webhook"]) @slack_webhook.post("/api/webhook/slack") async def slack_event_receiver(request: Request): - adapter = channel_manager._adapters.get("slack") + adapter = get_channel_manager()._adapters.get("slack") if not adapter: return JSONResponse( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, diff --git a/backend/package/yuxi/channels/adapters/slack/interaction_dispatch.py b/backend/package/yuxi/channels/adapters/slack/interaction_dispatch.py index dcc17948..16c28e03 100644 --- a/backend/package/yuxi/channels/adapters/slack/interaction_dispatch.py +++ b/backend/package/yuxi/channels/adapters/slack/interaction_dispatch.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any diff --git a/backend/package/yuxi/channels/adapters/slack/interactive_replies.py b/backend/package/yuxi/channels/adapters/slack/interactive_replies.py index 0927246d..1a8a596e 100644 --- a/backend/package/yuxi/channels/adapters/slack/interactive_replies.py +++ b/backend/package/yuxi/channels/adapters/slack/interactive_replies.py @@ -4,10 +4,10 @@ import re from typing import Any from yuxi.channels.adapters.slack.blocks import ( - build_button, build_actions, - build_static_select, + build_button, build_option, + build_static_select, ) _MAX_BUTTONS = 5 diff --git a/backend/package/yuxi/channels/adapters/slack/message_actions.py b/backend/package/yuxi/channels/adapters/slack/message_actions.py index 0a87edc0..275917e2 100644 --- a/backend/package/yuxi/channels/adapters/slack/message_actions.py +++ b/backend/package/yuxi/channels/adapters/slack/message_actions.py @@ -11,6 +11,13 @@ class ActionCategory(StrEnum): PINS = "pins" MEMBER_INFO = "memberInfo" EMOJI_LIST = "emojiList" + CALLS = "calls" + WORKFLOWS = "workflows" + SLACK_CONNECT = "slackConnect" + BOOKMARKS = "bookmarks" + CANVASES = "canvases" + ASSISTANT = "assistant" + LISTS = "lists" ACTION_CATEGORY_DEFAULTS: dict[ActionCategory, bool] = { @@ -19,6 +26,13 @@ ACTION_CATEGORY_DEFAULTS: dict[ActionCategory, bool] = { ActionCategory.PINS: True, ActionCategory.MEMBER_INFO: True, ActionCategory.EMOJI_LIST: True, + ActionCategory.CALLS: False, + ActionCategory.WORKFLOWS: False, + ActionCategory.SLACK_CONNECT: False, + ActionCategory.BOOKMARKS: False, + ActionCategory.CANVASES: False, + ActionCategory.ASSISTANT: False, + ActionCategory.LISTS: False, } ACTION_CATEGORY_ACTIONS: dict[ActionCategory, list[str]] = { @@ -27,6 +41,13 @@ ACTION_CATEGORY_ACTIONS: dict[ActionCategory, list[str]] = { ActionCategory.PINS: ["pin", "unpin", "list-pins"], ActionCategory.MEMBER_INFO: ["member-info"], ActionCategory.EMOJI_LIST: ["emoji-list"], + ActionCategory.CALLS: ["calls-add", "calls-end", "calls-update"], + ActionCategory.WORKFLOWS: ["workflow-step-execute", "workflow-step-failed"], + ActionCategory.SLACK_CONNECT: ["connect-invite", "connect-accept"], + ActionCategory.BOOKMARKS: ["bookmark-add", "bookmark-edit", "bookmark-list", "bookmark-remove"], + ActionCategory.CANVASES: ["canvas-create", "canvas-edit", "canvas-delete"], + ActionCategory.ASSISTANT: ["assistant-thread-start", "assistant-thread-message"], + ActionCategory.LISTS: ["list-create", "list-edit", "list-delete", "list-item-add"], } diff --git a/backend/package/yuxi/channels/adapters/slack/message_handler/pipeline_runtime.py b/backend/package/yuxi/channels/adapters/slack/message_handler/pipeline_runtime.py index 0b81b35e..51236b03 100644 --- a/backend/package/yuxi/channels/adapters/slack/message_handler/pipeline_runtime.py +++ b/backend/package/yuxi/channels/adapters/slack/message_handler/pipeline_runtime.py @@ -6,11 +6,10 @@ from yuxi.channels.adapters.slack.message_handler import ( PreparedSlackMessage, prepare_message, ) - +from yuxi.channels.adapters.slack.message_handler.dispatch import dispatch from yuxi.channels.adapters.slack.message_handler.prepare_content import prepare_content from yuxi.channels.adapters.slack.message_handler.prepare_routing import prepare_routing from yuxi.channels.adapters.slack.message_handler.prepare_thread_context import prepare_thread_context -from yuxi.channels.adapters.slack.message_handler.dispatch import dispatch def run_pipeline(raw_event: dict[str, Any], bot_user_id: str) -> PreparedSlackMessage: diff --git a/backend/package/yuxi/channels/adapters/slack/models.py b/backend/package/yuxi/channels/adapters/slack/models.py index 835ac1dd..45ce3d5c 100644 --- a/backend/package/yuxi/channels/adapters/slack/models.py +++ b/backend/package/yuxi/channels/adapters/slack/models.py @@ -35,4 +35,63 @@ class ResolvedSlackAccount(BaseModel): thread_inherit_parent: bool = False thread_require_explicit_mention: bool = False + show_configured: bool = True + quickstart_allow_from: list[str] = [] + force_account_binding: bool = False + prefer_session_lookup: bool = False + model_config = ConfigDict(extra="allow") + + +class SlackChannelMeta(BaseModel): + channel_id: str = "" + channel_name: str = "" + is_im: bool = False + is_channel: bool = False + is_group: bool = False + is_private: bool = False + show_configured: bool = True + quickstart_allow_from: list[str] = [] + force_account_binding: bool = False + prefer_session_lookup: bool = False + + model_config = ConfigDict(extra="allow") + + +class SlackTokenSelector: + WRITE_SCOPES = frozenset( + { + "chat:write", + "chat:write.customize", + "chat:update", + "chat:delete", + "reactions:write", + "files:write", + "users.profile:write", + } + ) + + @staticmethod + def select_for_operation( + account: ResolvedSlackAccount, + operation: str = "write", + ) -> str: + if operation == "write": + return account.bot_token or account.user_token + if account.bot_token: + return account.bot_token + return account.user_token or account.bot_token + + @staticmethod + def get_token_for_operation( + account: ResolvedSlackAccount, + scopes_needed: list[str] | None = None, + ) -> tuple[str, str]: + if scopes_needed and any(s in SlackTokenSelector.WRITE_SCOPES for s in scopes_needed): + if account.bot_token: + return account.bot_token, "bot" + return account.user_token, "user" + + if account.bot_token: + return account.bot_token, "bot" + return account.user_token or account.bot_token, "user" diff --git a/backend/package/yuxi/channels/adapters/slack/normalizer.py b/backend/package/yuxi/channels/adapters/slack/normalizer.py index 3ef22c62..bc2a2a81 100644 --- a/backend/package/yuxi/channels/adapters/slack/normalizer.py +++ b/backend/package/yuxi/channels/adapters/slack/normalizer.py @@ -55,4 +55,4 @@ def normalize_slack_text(text: str) -> str: text = normalize_urls(text) text = normalize_special_mentions(text) text = unescape_html_entities(text) - return text.strip() \ No newline at end of file + return text.strip() diff --git a/backend/package/yuxi/channels/adapters/slack/poll_manager.py b/backend/package/yuxi/channels/adapters/slack/poll_manager.py index 75e2574e..f62ccacb 100644 --- a/backend/package/yuxi/channels/adapters/slack/poll_manager.py +++ b/backend/package/yuxi/channels/adapters/slack/poll_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations import uuid from dataclasses import dataclass, field -from datetime import datetime, UTC, timedelta +from datetime import UTC, datetime, timedelta from typing import Any diff --git a/backend/package/yuxi/channels/adapters/slack/streaming.py b/backend/package/yuxi/channels/adapters/slack/streaming.py index a8fedbfe..ac632e6d 100644 --- a/backend/package/yuxi/channels/adapters/slack/streaming.py +++ b/backend/package/yuxi/channels/adapters/slack/streaming.py @@ -50,6 +50,42 @@ class NativeStreamManager: def __init__(self, client: AsyncWebClient): self._client = client self._sessions: dict[str, NativeStreamSession] = {} + self._probed: bool = False + self._native_available: bool | None = None + + async def probe_native_stream(self) -> bool: + if self._probed: + return self._native_available is True + + self._probed = True + try: + result = await self._client.api_call( + api_method="chat.streamLookup", + http_verb="POST", + params={"limit": 1}, + ) + available = result.get("ok", False) + if not available: + err = result.get("error", "") + if err in ("method_not_supported", "invalid_auth"): + self._native_available = False + logger.info(f"Slack Native Stream API not available: {err}, falling back to edit-in-place") + else: + self._native_available = True + else: + self._native_available = True + except SlackApiError as e: + err = e.response.get("error", str(e)) + logger.info(f"Slack Native Stream API probe failed: {err}, falling back to edit-in-place") + self._native_available = False + except Exception: + self._native_available = False + + return self._native_available is True + + @property + def native_available(self) -> bool | None: + return self._native_available async def start_stream( self,