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