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:
parent
b47c6126e6
commit
df1a7c7bca
@ -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",
|
||||
|
||||
@ -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", {})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user