ForcePilot/backend/package/yuxi/channel/extensions/slack/outbound.py
Kris bfc7755137 feat(channel): 添加 Slack 渠道扩展
新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- monitor: 渠道状态监控
- status: 会话状态管理
- actions: 交互动作处理
- interactive: 交互式消息
- commands: 斜杠指令
- threading: 线程管理
- mentions: @提及
- constants: 常量定义
- types: 类型定义
2026-05-21 11:43:23 +08:00

732 lines
27 KiB
Python

import asyncio
import logging
import random
from yuxi.channel.extensions.slack.constants import (
SLACK_TEXT_LIMIT,
SLACK_RETRY_MAX_ATTEMPTS,
SLACK_RETRY_BASE_DELAY,
SLACK_RETRY_MAX_DELAY,
)
from yuxi.channel.extensions.slack.errors import (
SlackError,
SlackErrorCode,
is_retryable_error,
)
from yuxi.channel.extensions.slack.config import SlackConfigAdapter
from yuxi.channel.extensions.slack.format import chunk_text
logger = logging.getLogger(__name__)
class SlackOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit = SLACK_TEXT_LIMIT
poll_max_options = None
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = True
presentation_capabilities = None
delivery_capabilities = None
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
return chunk_text(text, limit)
async def _get_client(self, account_id: str | None, config: dict | None = None):
try:
from slack_sdk.web.async_client import AsyncWebClient
except ImportError:
raise SlackError(SlackErrorCode.CONFIG_ERROR, "slack_sdk is not installed")
adapter = SlackConfigAdapter()
account = await adapter.resolve_account(account_id or "default", config)
client = AsyncWebClient(token=account["bot_token"])
return client, account
async def _retry_with_backoff(self, operation, max_attempts=None, account=None):
try:
from slack_sdk.errors import SlackApiError
except ImportError:
return await operation()
max_attempts = max_attempts or SLACK_RETRY_MAX_ATTEMPTS
base_delay = SLACK_RETRY_BASE_DELAY
max_delay = SLACK_RETRY_MAX_DELAY
for attempt in range(max_attempts):
try:
return await operation()
except SlackApiError as e:
error_code = e.response.get("error", "unknown")
if error_code == "ratelimited":
retry_after = float(e.response.get("headers", {}).get("Retry-After", base_delay))
if attempt < max_attempts - 1:
logger.warning(
"Slack rate limited, waiting %.1fs (attempt %d/%d)",
retry_after,
attempt + 1,
max_attempts,
)
await asyncio.sleep(retry_after)
continue
raise SlackError(
SlackErrorCode.RATE_LIMITED,
f"Rate limit exceeded after {max_attempts} attempts",
) from e
if not is_retryable_error(error_code):
try:
code = SlackErrorCode(error_code)
except ValueError:
code = SlackErrorCode.UNKNOWN
raise SlackError(code, str(e)) from e
if attempt < max_attempts - 1:
delay = min(
base_delay * (2**attempt) + random.uniform(0, 1),
max_delay,
)
logger.warning(
"Slack API error '%s', retrying in %.1fs (attempt %d/%d)",
error_code,
delay,
attempt + 1,
max_attempts,
)
await asyncio.sleep(delay)
else:
raise SlackError(
SlackErrorCode.SEND_FAILED,
f"Max retries ({max_attempts}) exceeded: {error_code}",
) from e
except (ConnectionError, TimeoutError) as e:
if attempt < max_attempts - 1:
delay = min(
base_delay * (2**attempt) + random.uniform(0, 1),
max_delay,
)
logger.warning(
"Network error, retrying in %.1fs (attempt %d/%d)",
delay,
attempt + 1,
max_attempts,
)
await asyncio.sleep(delay)
else:
raise SlackError(
SlackErrorCode.NETWORK_ERROR,
f"Network error after {max_attempts} attempts: {e}",
) from e
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
reply_broadcast: bool = False,
) -> None:
client, account = await self._get_client(account_id)
thread_ts = thread_id or reply_to_id
async def do_send():
kwargs: dict = {
"channel": target_id,
"text": content,
"mrkdwn": True,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
if reply_broadcast and thread_ts:
kwargs["reply_broadcast"] = True
await client.chat_postMessage(**kwargs)
await self._retry_with_backoff(do_send)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
import aiohttp
client, account = await self._get_client(account_id)
async with aiohttp.ClientSession() as session:
async with session.get(media_url) as resp:
data = await resp.read()
content_type = resp.content_type or "application/octet-stream"
filename = media_url.rsplit("/", 1)[-1].split("?")[0] or "media"
file_size = len(data)
thread_ts = thread_id or reply_to_id
async def do_upload():
upload_result = await client.files_getUploadURLExternal(
filename=filename,
length=file_size,
)
upload_url = upload_result["upload_url"]
file_id = upload_result["file_id"]
async with aiohttp.ClientSession() as session:
async with session.put(
upload_url,
data=data,
headers={"Content-Type": content_type},
) as resp:
resp.raise_for_status()
complete_kwargs: dict = {
"files": [{"id": file_id, "title": filename}],
"channel_id": target_id,
}
if thread_ts:
complete_kwargs["thread_ts"] = thread_ts
await client.files_completeUploadExternal(**complete_kwargs)
await self._retry_with_backoff(do_upload)
async def edit_message(
self,
target_id: str,
message_id: str,
content: str,
*,
thread_id: str | None = None,
account_id: str | None = None,
) -> str | None:
client, account = await self._get_client(account_id)
async def do_edit():
result = await client.chat_update(
channel=target_id,
ts=message_id,
text=content,
mrkdwn=True,
)
return result.get("ts")
return await self._retry_with_backoff(do_edit)
async def delete_message(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
async def do_delete():
await client.chat_delete(
channel=target_id,
ts=message_id,
)
await self._retry_with_backoff(do_delete)
async def send_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
async def do_react():
await client.reactions_add(
channel=target_id,
name=emoji.strip(":"),
timestamp=message_id,
)
await self._retry_with_backoff(do_react)
async def remove_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
async def do_remove():
await client.reactions_remove(
channel=target_id,
name=emoji.strip(":"),
timestamp=message_id,
)
await self._retry_with_backoff(do_remove)
async def pin_message(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
async def do_pin():
await client.pins_add(
channel=target_id,
timestamp=message_id,
)
await self._retry_with_backoff(do_pin)
async def unpin_message(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
) -> None:
client, account = await self._get_client(account_id)
async def do_unpin():
await client.pins_remove(
channel=target_id,
timestamp=message_id,
)
await self._retry_with_backoff(do_unpin)
async def send_payload(self, ctx: object) -> object:
payload = getattr(ctx, "payload", None) or {}
target_id = getattr(ctx, "target_id", "")
blocks = payload.get("blocks", [])
text = payload.get("text", "")
thread_ts = getattr(ctx, "thread_ts", None)
account_id = getattr(ctx, "account_id", None)
if not target_id or not blocks:
return None
client, account = await self._get_client(account_id)
async def do_send():
kwargs: dict = {"channel": target_id, "blocks": blocks}
if text:
kwargs["text"] = text
if thread_ts:
kwargs["thread_ts"] = thread_ts
result = await client.chat_postMessage(**kwargs)
return result.get("ts")
return await self._retry_with_backoff(do_send)
async def get_channel_history(
self,
channel_id: str,
*,
limit: int = 10,
latest: str | None = None,
oldest: str | None = None,
account_id: str | None = None,
) -> list[dict]:
client, account = await self._get_client(account_id)
async def do_fetch():
kwargs: dict = {"channel": channel_id, "limit": min(limit, 1000)}
if latest:
kwargs["latest"] = latest
if oldest:
kwargs["oldest"] = oldest
result = await client.conversations_history(**kwargs)
return result.get("messages", [])
return await self._retry_with_backoff(do_fetch)
async def get_thread_replies(
self,
channel_id: str,
thread_ts: str,
*,
limit: int = 10,
account_id: str | None = None,
) -> list[dict]:
client, account = await self._get_client(account_id)
async def do_fetch():
result = await client.conversations_replies(
channel=channel_id,
ts=thread_ts,
limit=min(limit, 1000),
)
return result.get("messages", [])
return await self._retry_with_backoff(do_fetch)
async def list_channels(
self,
*,
types: str = "public_channel,private_channel",
limit: int = 100,
cursor: str | None = None,
account_id: str | None = None,
) -> dict:
client, account = await self._get_client(account_id)
async def do_fetch():
kwargs: dict = {"types": types, "limit": min(limit, 1000)}
if cursor:
kwargs["cursor"] = cursor
result = await client.conversations_list(**kwargs)
return {
"channels": result.get("channels", []),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
}
return await self._retry_with_backoff(do_fetch)
async def get_channel_info(self, channel_id: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
async def do_fetch():
result = await client.conversations_info(channel=channel_id)
return result.get("channel", {})
return await self._retry_with_backoff(do_fetch)
async def get_channel_members(
self,
channel_id: str,
*,
limit: int = 100,
cursor: str | None = None,
account_id: str | None = None,
) -> dict:
client, account = await self._get_client(account_id)
async def do_fetch():
kwargs: dict = {"channel": channel_id, "limit": min(limit, 1000)}
if cursor:
kwargs["cursor"] = cursor
result = await client.conversations_members(**kwargs)
return {
"members": result.get("members", []),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
}
return await self._retry_with_backoff(do_fetch)
async def get_permalink(
self,
channel_id: str,
message_ts: str,
*,
account_id: str | None = None,
) -> str | None:
client, account = await self._get_client(account_id)
async def do_fetch():
result = await client.chat_getPermalink(channel=channel_id, message_ts=message_ts)
return result.get("permalink")
return await self._retry_with_backoff(do_fetch)
async def schedule_message(
self,
target_id: str,
content: str,
post_at: int,
*,
thread_ts: str | None = None,
account_id: str | None = None,
) -> str:
client, account = await self._get_client(account_id)
async def do_schedule():
kwargs: dict = {
"channel": target_id,
"text": content,
"post_at": post_at,
"mrkdwn": True,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
result = await client.chat_scheduleMessage(**kwargs)
return result.get("scheduled_message_id")
return await self._retry_with_backoff(do_schedule)
async def get_file_info(self, file_id: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.files_info(file=file_id)
return result.get("file", {})
async def list_files(
self,
*,
channel_id: str | None = None,
count: int = 100,
page: int = 1,
account_id: str | None = None,
) -> list[dict]:
client, account = await self._get_client(account_id)
kwargs: dict = {"count": min(count, 1000)}
if channel_id:
kwargs["channel"] = channel_id
kwargs["page"] = min(page, 100)
result = await client.files_list(**kwargs)
return result.get("files", [])
async def delete_file(self, file_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.files_delete(file=file_id)
async def get_reactions(self, channel_id: str, message_ts: str, *, account_id: str | None = None) -> list[dict]:
client, account = await self._get_client(account_id)
result = await client.reactions_get(channel=channel_id, timestamp=message_ts, full=True)
return result.get("message", {}).get("reactions", [])
async def list_pins(self, channel_id: str, *, account_id: str | None = None) -> list[dict]:
client, account = await self._get_client(account_id)
result = await client.pins_list(channel=channel_id)
return result.get("items", [])
async def list_users(self, *, cursor: str | None = None, limit: int = 100, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
async def do_fetch():
kwargs: dict = {"limit": min(limit, 1000)}
if cursor:
kwargs["cursor"] = cursor
result = await client.users_list(**kwargs)
return {
"members": result.get("members", []),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
}
return await self._retry_with_backoff(do_fetch)
async def lookup_user_by_email(self, email: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.users_lookupByEmail(email=email)
return result.get("user", {})
async def get_user_presence(self, user_id: str, *, account_id: str | None = None) -> str:
client, account = await self._get_client(account_id)
result = await client.users_getPresence(user=user_id)
return result.get("presence", "unknown")
async def join_channel(self, channel_id: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_join(channel=channel_id)
return result.get("channel", {})
async def leave_channel(self, channel_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.conversations_leave(channel=channel_id)
async def invite_users(self, channel_id: str, users: list[str], *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_invite(channel=channel_id, users=",".join(users))
return result.get("channel", {})
async def set_channel_topic(self, channel_id: str, topic: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_setTopic(channel=channel_id, topic=topic)
return result.get("channel", {})
async def archive_channel(self, channel_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.conversations_archive(channel=channel_id)
async def unarchive_channel(self, channel_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.conversations_unarchive(channel=channel_id)
async def create_channel(self, name: str, *, is_private: bool = False, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_create(name=name, is_private=is_private)
return result.get("channel", {})
async def delete_scheduled_message(
self, channel_id: str, scheduled_message_id: str, *, account_id: str | None = None
) -> None:
client, account = await self._get_client(account_id)
await client.chat_deleteScheduledMessage(channel=channel_id, scheduled_message_id=scheduled_message_id)
async def list_scheduled_messages(self, channel_id: str, *, account_id: str | None = None) -> list[dict]:
client, account = await self._get_client(account_id)
result = await client.chat_scheduledMessages_list(channel=channel_id)
return result.get("scheduled_messages", [])
async def unfurl_links(
self, channel_id: str, message_ts: str, unfurls: dict, *, account_id: str | None = None
) -> None:
client, account = await self._get_client(account_id)
await client.chat_unfurl(channel=channel_id, ts=message_ts, unfurls=unfurls)
async def send_me_message(self, channel_id: str, text: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.chat_meMessage(channel=channel_id, text=text)
async def get_user_profile(
self, user_id: str, *, include_labels: bool = False, account_id: str | None = None
) -> dict:
client, account = await self._get_client(account_id)
result = await client.users_profile_get(user=user_id, include_labels=include_labels)
return result.get("profile", {})
async def set_user_profile(self, user_id: str, profile: dict, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.users_profile_set(user=user_id, profile=profile)
return result.get("profile", {})
async def get_user_identity(self, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.users_identity()
return {"user": result.get("user", {}), "team": result.get("team", {})}
async def list_user_conversations(
self,
*,
user_id: str | None = None,
types: str = "public_channel,private_channel,im,mpim",
limit: int = 100,
account_id: str | None = None,
) -> dict:
client, account = await self._get_client(account_id)
kwargs: dict = {"types": types, "limit": min(limit, 1000)}
if user_id:
kwargs["user"] = user_id
result = await client.users_conversations(**kwargs)
return {
"channels": result.get("channels", []),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
}
async def set_user_presence(self, presence: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.users_setPresence(presence=presence)
async def get_dnd_info(self, user_id: str | None = None, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
kwargs: dict = {}
if user_id:
kwargs["user"] = user_id
result = await client.dnd_info(**kwargs)
return result.data
async def search_messages(
self, query: str, *, count: int = 20, page: int = 1, account_id: str | None = None
) -> dict:
client, account = await self._get_client(account_id)
result = await client.search_messages(query=query, count=min(count, 100), page=min(page, 100))
return result.data
async def add_reminder(self, text: str, time: int | str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.reminders_add(text=text, time=time)
return result.get("reminder", {})
async def list_reminders(self, *, account_id: str | None = None) -> list[dict]:
client, account = await self._get_client(account_id)
result = await client.reminders_list()
return result.get("reminders", [])
async def complete_reminder(self, reminder_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.reminders_complete(reminder=reminder_id)
async def delete_reminder(self, reminder_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.reminders_delete(reminder=reminder_id)
async def add_star(self, channel_id: str, message_ts: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.stars_add(channel=channel_id, timestamp=message_ts)
async def remove_star(self, channel_id: str, message_ts: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.stars_remove(channel=channel_id, timestamp=message_ts)
async def list_stars(self, *, count: int = 100, page: int = 1, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.stars_list(count=min(count, 1000), page=min(page, 100))
return result.data
async def list_emoji(self, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.emoji_list()
return result.get("emoji", {})
async def kick_user(self, channel_id: str, user_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.conversations_kick(channel=channel_id, user=user_id)
async def set_channel_purpose(self, channel_id: str, purpose: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_setPurpose(channel=channel_id, purpose=purpose)
return result.get("channel", {})
async def rename_channel(self, channel_id: str, name: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_rename(channel=channel_id, name=name)
return result.get("channel", {})
async def open_dm(self, user_id: str, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.conversations_open(users=[user_id])
return result.get("channel", {})
async def close_conversation(self, channel_id: str, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.conversations_close(channel=channel_id)
async def open_modal(self, trigger_id: str, view: dict, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.views_open(trigger_id=trigger_id, view=view)
return result.get("view", {})
async def push_modal(self, trigger_id: str, view: dict, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.views_push(trigger_id=trigger_id, view=view)
return result.get("view", {})
async def update_modal(self, view_id: str, view: dict, *, account_id: str | None = None) -> dict:
client, account = await self._get_client(account_id)
result = await client.views_update(view_id=view_id, view=view)
return result.get("view", {})
async def publish_app_home(self, user_id: str, view: dict, *, account_id: str | None = None) -> None:
client, account = await self._get_client(account_id)
await client.views_publish(user_id=user_id, view=view)
async def send_poll(self, ctx: object) -> object:
return None
def sanitize_text(self, text: str, payload: object) -> str:
return text
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return False
def normalize_payload(self, payload, config, account_id=None):
return payload
def resolve_effective_text_chunk_limit(self, config, account_id=None, fallback_limit=None):
return fallback_limit or SLACK_TEXT_LIMIT