refactor(slack-adapter): 整理导入顺序并修复格式问题

本次提交包含多个Slack适配器相关的代码优化:
1. 统一多个文件中datetime和UTC的导入顺序
2. 调整collection.abc导入的参数顺序
3. 修复normalizer.py的文件末尾空行问题
4. 重新排序blocks.py中的函数导入
5. 调整directory_config.py中的函数顺序
6. 重构http_handler中的channel_manager调用方式
7. 新增Slack原生流探测逻辑和相关状态管理
8. 扩展消息动作分类和默认配置
9. 新增大量Slack消息块构建工具函数
10. 大幅重构__init__.py的导出内容,整理导入顺序
11. 为adapter新增熔断机制、缓存持久化和更多API方法
12. 新增多种系统事件处理逻辑
This commit is contained in:
Kris 2026-05-13 16:14:38 +08:00
parent b47c6126e6
commit df1a7c7bca
16 changed files with 930 additions and 171 deletions

View File

@ -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",

View File

@ -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", {})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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"],
}

View File

@ -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:

View File

@ -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"

View File

@ -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()
return text.strip()

View File

@ -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

View File

@ -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,