From 5ae1e1addee72754b708c84efbed2940a3821869 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 17:01:42 +0800 Subject: [PATCH] feat(channel): support slack bot --- README.md | 3 +- channel/channel_factory.py | 3 + channel/slack/__init__.py | 1 + channel/slack/slack_channel.py | 506 +++++++++++++++++++++++++++ channel/slack/slack_message.py | 60 ++++ channel/telegram/telegram_channel.py | 45 ++- channel/web/web_channel.py | 17 +- common/const.py | 1 + config.py | 7 +- docs/channels/index.mdx | 2 + docs/channels/slack.mdx | 118 +++++++ docs/docs.json | 9 +- docs/en/channels/index.mdx | 2 + docs/en/channels/slack.mdx | 118 +++++++ docs/ja/README.md | 3 +- docs/ja/channels/index.mdx | 2 + docs/ja/channels/slack.mdx | 118 +++++++ docs/zh/README.md | 5 +- requirements.txt | 2 + 19 files changed, 1012 insertions(+), 10 deletions(-) create mode 100644 channel/slack/__init__.py create mode 100644 channel/slack/slack_channel.py create mode 100644 channel/slack/slack_message.py create mode 100644 docs/channels/slack.mdx create mode 100644 docs/en/channels/slack.mdx create mode 100644 docs/ja/channels/slack.mdx diff --git a/README.md b/README.md index 2d1d61ae..1edf1d3f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major | [Knowledge](https://docs.cowagent.ai/en/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | | [Skills](https://docs.cowagent.ai/en/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | | [Tools](https://docs.cowagent.ai/en/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | -| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, and Telegram | +| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | | Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery | | [Models](https://docs.cowagent.ai/en/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | | [Deploy](https://docs.cowagent.ai/en/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) | @@ -127,6 +127,7 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > See the [Channels overview](https://docs.cowagent.ai/en/channels/index) for setup details. diff --git a/channel/channel_factory.py b/channel/channel_factory.py index fd8cf0e8..f1a14e8f 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -42,6 +42,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == const.TELEGRAM: from channel.telegram.telegram_channel import TelegramChannel ch = TelegramChannel() + elif channel_type == const.SLACK: + from channel.slack.slack_channel import SlackChannel + ch = SlackChannel() elif channel_type in (const.WEIXIN, "wx"): from channel.weixin.weixin_channel import WeixinChannel ch = WeixinChannel() diff --git a/channel/slack/__init__.py b/channel/slack/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/channel/slack/__init__.py @@ -0,0 +1 @@ + diff --git a/channel/slack/slack_channel.py b/channel/slack/slack_channel.py new file mode 100644 index 00000000..8e82fcc5 --- /dev/null +++ b/channel/slack/slack_channel.py @@ -0,0 +1,506 @@ +""" +Slack channel via Bolt for Python (Socket Mode). + +Features: +- Direct message & channel chat (text / image / file) +- Channel trigger: @mention or reply in a thread the bot is in (configurable) +- /cancel fast-path matches Web channel behaviour +- Socket Mode: no public IP / callback URL required, works behind NAT + +Implementation note: + slack_bolt's SocketModeHandler is blocking and runs its own background + threads. We start it in a dedicated thread so the rest of cow (sync) stays + untouched. Inbound events are dispatched onto cow's existing sync + ChatChannel.produce() pipeline; outbound send() calls the Slack Web API + client directly (it is sync-safe). +""" + +import os +import re +import threading + +import requests + +from bridge.context import Context, ContextType +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel, check_prefix +from channel.slack.slack_message import SlackMessage +from common.expired_dict import ExpiredDict +from common.log import logger +from common.singleton import singleton +from config import conf + + +@singleton +class SlackChannel(ChatChannel): + NOT_SUPPORT_REPLYTYPE = [] + + def __init__(self): + super().__init__() + self.bot_token = "" + self.app_token = "" + self.bot_user_id = "" # used to strip @mention and ignore self messages + self._app = None + self._handler = None + self._client = None + self._loop_thread = None + # Idempotent dedup; Slack retries event delivery on slow ack + self._received_msgs = ExpiredDict(60 * 60 * 1) + + # Disable group whitelist / prefix checks (we handle triggering ourselves + # in _should_reply_in_channel), aligned with telegram / feishu channels. + conf()["group_name_white_list"] = ["ALL_GROUP"] + conf()["single_chat_prefix"] = [""] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def startup(self): + self.bot_token = conf().get("slack_bot_token", "") + self.app_token = conf().get("slack_app_token", "") + if not self.bot_token or not self.app_token: + err = "[Slack] slack_bot_token and slack_app_token are both required" + logger.error(err) + self.report_startup_error(err) + return + + # Guard against the common mistake of swapping the two tokens: + # bot token must start with xoxb-, app-level token with xapp-. + if not self.bot_token.startswith("xoxb-") or not self.app_token.startswith("xapp-"): + err = ( + "[Slack] token type mismatch: slack_bot_token must start with 'xoxb-' " + "and slack_app_token must start with 'xapp-' (they look swapped)" + ) + logger.error(err) + self.report_startup_error(err) + return + + try: + from slack_bolt import App + from slack_bolt.adapter.socket_mode import SocketModeHandler + except ImportError: + err = ( + "[Slack] slack_bolt is not installed. " + "Run: pip install slack_bolt" + ) + logger.error(err) + self.report_startup_error(err) + return + + try: + self._app = App(token=self.bot_token) + self._client = self._app.client + + # Resolve our own bot user id (needed for @mention strip / self-ignore) + auth = self._client.auth_test() + self.bot_user_id = auth.get("user_id", "") + self.name = self.bot_user_id # ChatChannel uses self.name to strip @-mention + logger.info(f"[Slack] Bot logged in as user_id={self.bot_user_id}, team={auth.get('team')}") + except Exception as e: + err = f"[Slack] auth_test failed: {e}" + logger.error(err) + self.report_startup_error(err) + return + + self._register_handlers() + + self._handler = SocketModeHandler(self._app, self.app_token) + + def _run(): + try: + logger.info("[Slack] Starting Socket Mode connection...") + self.report_startup_success() + logger.info("[Slack] ✅ Slack bot ready, listening for events") + self._handler.start() + except Exception as e: + logger.error(f"[Slack] socket mode crashed: {e}", exc_info=True) + self.report_startup_error(str(e)) + finally: + logger.info("[Slack] socket mode exited") + + self._loop_thread = threading.Thread(target=_run, daemon=True, name="slack-socket") + self._loop_thread.start() + # Block startup() until the handler thread exits, matching other channels' + # behaviour (startup is a blocking call). + self._loop_thread.join() + + def _register_handlers(self): + app = self._app + + # app_mention: bot is @-mentioned in a channel + @app.event("app_mention") + def _on_app_mention(event, ack): + ack() + self._handle_event(event, is_group=True) + + # message: DMs and channel messages (including thread replies) + @app.event("message") + def _on_message(event, ack): + ack() + self._handle_message_event(event) + + def stop(self): + logger.info("[Slack] stop() called") + try: + if self._handler is not None: + self._handler.close() + except Exception as e: + logger.warning(f"[Slack] handler close error: {e}") + if self._loop_thread and self._loop_thread.is_alive(): + try: + self._loop_thread.join(timeout=10) + except Exception: + pass + logger.info("[Slack] stop() completed") + + # ------------------------------------------------------------------ + # Inbound: slack event -> ChatMessage -> ChatChannel.produce + # ------------------------------------------------------------------ + + def _handle_message_event(self, event: dict): + """Route a raw `message` event: skip bot/system noise, decide grouping.""" + try: + logger.debug( + f"[Slack] message event: channel_type={event.get('channel_type')}, " + f"subtype={event.get('subtype')}, user={event.get('user')}, " + f"ts={event.get('ts')}, thread_ts={event.get('thread_ts')}" + ) + # Ignore bot messages (including our own) and message edits/deletes + if event.get("bot_id") or event.get("subtype") in ("bot_message", "message_changed", "message_deleted"): + return + if event.get("user") == self.bot_user_id: + return + + channel_type = event.get("channel_type", "") + # DM (im) is single chat; channel/group is group chat. app_mention + # already covers channel @-mentions, so for plain channel messages we + # only react when configured / thread-following. + is_group = channel_type in ("channel", "group", "mpim") + if is_group: + # app_mention handler covers explicit @bot; here we only handle + # follow-up replies in threads the bot participates in. + if not self._should_reply_in_channel(event): + return + self._handle_event(event, is_group=is_group) + except Exception as e: + logger.error(f"[Slack] _handle_message_event error: {e}", exc_info=True) + + def _handle_event(self, event: dict, is_group: bool): + """Parse event -> build SlackMessage -> produce().""" + try: + channel_id = event.get("channel", "") + ts = event.get("ts", "") + if not channel_id: + return + + # Idempotent dedup + msg_uid = f"{channel_id}:{ts}" + if self._received_msgs.get(msg_uid): + return + self._received_msgs[msg_uid] = True + + # Parse type + download media if needed. + ctype, content, caption = self._parse_event(event) + if ctype is None: + logger.debug(f"[Slack] unsupported message type, skip. event={event}") + return + + # Strip <@bot_user_id> mention from channel text + if is_group and self.bot_user_id: + if ctype == ContextType.TEXT and content: + content = self._strip_at_mention(content) + if caption: + caption = self._strip_at_mention(caption) + + slack_msg = SlackMessage( + event, + is_group=is_group, + bot_user_id=self.bot_user_id, + ctype=ctype, + content=content, + ) + slack_msg.is_at = is_group # if we reached here in a channel, bot is mentioned/threaded + + from channel.file_cache import get_file_cache + file_cache = get_file_cache() + session_id = self._compute_session_id(event, is_group) + + # Media + caption together: treat as a complete query and bypass the cache + if ctype in (ContextType.IMAGE, ContextType.FILE) and caption: + tag = "image" if ctype == ContextType.IMAGE else "file" + merged_text = f"{caption}\n[{tag}: {content}]" + slack_msg.ctype = ContextType.TEXT + slack_msg.content = merged_text + ctype = ContextType.TEXT + logger.info(f"[Slack] Media+caption merged for session {session_id}") + # fallthrough to the TEXT branch below + + elif ctype == ContextType.IMAGE: + file_cache.add(session_id, content, file_type="image") + logger.info(f"[Slack] Image cached for session {session_id}, waiting for query...") + return + elif ctype == ContextType.FILE: + file_cache.add(session_id, content, file_type="file") + logger.info(f"[Slack] File cached for session {session_id}: {content}") + return + + if ctype == ContextType.TEXT: + # Fast-path: /cancel mirrors Web channel behaviour + if (content or "").strip().lower() in ("/cancel", "cancel"): + self._do_cancel(session_id, channel_id, event) + return + + cached_files = file_cache.get(session_id) + if cached_files: + refs = [] + for fi in cached_files: + ftype = fi["type"] + tag = ftype if ftype in ("image", "video") else "file" + refs.append(f"[{tag}: {fi['path']}]") + slack_msg.content = (slack_msg.content or "") + "\n" + "\n".join(refs) + file_cache.clear(session_id) + logger.info(f"[Slack] Attached {len(cached_files)} cached file(s) to query") + + # Reply in the originating thread when present, else start one on this msg + thread_ts = event.get("thread_ts") or ts + + context = self._compose_context( + slack_msg.ctype, + slack_msg.content, + isgroup=is_group, + msg=slack_msg, + # Replies go back into the thread, no manual @mention needed + no_need_at=True, + ) + if context: + context["session_id"] = session_id + context["receiver"] = channel_id + context["slack_channel"] = channel_id + context["slack_thread_ts"] = thread_ts if is_group else None + self.produce(context) + logger.debug(f"[Slack] received: type={ctype}, content={str(slack_msg.content)[:80]}") + except Exception as e: + logger.error(f"[Slack] _handle_event error: {e}", exc_info=True) + + def _do_cancel(self, session_id: str, channel_id: str, event: dict): + """Fast-path: /cancel calls cancel_session directly without going through agent.""" + try: + from agent.protocol import get_cancel_registry + cancelled = get_cancel_registry().cancel_session(session_id) + text = "Current task cancelled." if cancelled else "No running task to cancel." + thread_ts = event.get("thread_ts") or event.get("ts") + self._client.chat_postMessage(channel=channel_id, text=text, thread_ts=thread_ts) + logger.info(f"[Slack] /cancel session={session_id}, cancelled={cancelled}") + except Exception as e: + logger.error(f"[Slack] /cancel error: {e}", exc_info=True) + + def _parse_event(self, event: dict): + """Parse a slack event and return (ctype, content, caption). + + - content is text for ContextType.TEXT, otherwise the local file path + - caption is the optional text accompanying a file; empty for plain text + """ + text = (event.get("text") or "").strip() + files = event.get("files") or [] + + if files: + # Handle the first attachment; caption is the accompanying message text + f = files[0] + mimetype = (f.get("mimetype") or "").lower() + url = f.get("url_private_download") or f.get("url_private") + name = f.get("name") or f.get("id") or "file" + if not url: + return (None, None, "") + path = self._download_file(url, name) + if not path: + return (None, None, "") + if mimetype.startswith("image/"): + return (ContextType.IMAGE, path, text) + return (ContextType.FILE, path, text) + + if text: + return (ContextType.TEXT, text, "") + + return (None, None, "") + + def _download_file(self, url: str, name: str): + """Download a Slack private file (requires bot token auth) to local tmp dir.""" + try: + headers = {"Authorization": f"Bearer {self.bot_token}"} + resp = requests.get(url, headers=headers, timeout=60, stream=True) + resp.raise_for_status() + tmp_dir = SlackMessage.get_tmp_dir() + # Sanitize the name and keep it unique-ish via the url tail + safe_name = re.sub(r"[^\w.\-]", "_", name) + local_path = os.path.join(tmp_dir, safe_name) + with open(local_path, "wb") as fp: + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + fp.write(chunk) + logger.debug(f"[Slack] downloaded {name} -> {local_path}") + return local_path + except Exception as e: + logger.error(f"[Slack] download_file failed ({name}): {e}") + return None + + # ------------------------------------------------------------------ + # Channel trigger logic + # ------------------------------------------------------------------ + + def _should_reply_in_channel(self, event: dict) -> bool: + """Decide whether to reply to a plain channel message (no @mention). + + app_mention already handles explicit @bot, so here we only deal with + follow-up messages. `all` replies to every message; `mention_or_reply` + replies inside threads the bot already participates in. + """ + mode = conf().get("slack_group_trigger", "mention_or_reply") + if mode == "all": + return True + if mode == "mention_only": + return False + # mention_or_reply: follow up only within an existing thread + return bool(event.get("thread_ts")) + + def _strip_at_mention(self, content: str) -> str: + """Strip <@BOT_USER_ID> from channel text.""" + if not content or not self.bot_user_id: + return content + pattern = re.compile(r"<@" + re.escape(self.bot_user_id) + r">", re.IGNORECASE) + return pattern.sub("", content).strip() + + @staticmethod + def _compute_session_id(event: dict, is_group: bool) -> str: + channel_id = event.get("channel", "") + user_id = event.get("user", "") + if is_group: + if conf().get("group_shared_session", True): + return f"slack_channel_{channel_id}" + return f"slack_channel_{channel_id}_{user_id}" + return f"slack_user_{user_id}" + + # ------------------------------------------------------------------ + # Override _compose_context: skip the parent's group whitelist/at checks + # (already handled via _should_reply_in_channel). Same idea as telegram. + # ------------------------------------------------------------------ + + def _compose_context(self, ctype: ContextType, content, **kwargs): + context = Context(ctype, content) + context.kwargs = kwargs + if "channel_type" not in context: + context["channel_type"] = self.channel_type + if "origin_ctype" not in context: + context["origin_ctype"] = ctype + + cmsg = context["msg"] + if cmsg.is_group: + if conf().get("group_shared_session", True): + context["session_id"] = cmsg.other_user_id + else: + context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}" + else: + context["session_id"] = cmsg.from_user_id + context["receiver"] = cmsg.other_user_id + + if ctype == ContextType.TEXT: + img_match_prefix = check_prefix(content, conf().get("image_create_prefix")) + if img_match_prefix: + content = content.replace(img_match_prefix, "", 1) + context.type = ContextType.IMAGE_CREATE + else: + context.type = ContextType.TEXT + context.content = (content or "").strip() + if "desire_rtype" not in context and conf().get("always_reply_voice"): + context["desire_rtype"] = ReplyType.VOICE + elif ctype == ContextType.VOICE: + if "desire_rtype" not in context and ( + conf().get("voice_reply_voice") or conf().get("always_reply_voice") + ): + context["desire_rtype"] = ReplyType.VOICE + + return context + + # ------------------------------------------------------------------ + # Outbound: ChatChannel.send -> Slack Web API + # ------------------------------------------------------------------ + + def send(self, reply: Reply, context: Context): + """Called from cow's sync main thread; Slack Web client is sync-safe.""" + if self._client is None: + logger.warning("[Slack] client not ready, drop reply") + return + + channel_id = context.get("slack_channel") + thread_ts = context.get("slack_thread_ts") + if not channel_id: + logger.warning("[Slack] no slack_channel in context, drop reply") + return + + try: + self._do_send(reply, channel_id, thread_ts) + logger.info(f"[Slack] sent reply (type={reply.type}, channel={channel_id})") + except Exception as e: + logger.error(f"[Slack] send failed: {e}", exc_info=True) + + def _do_send(self, reply: Reply, channel_id: str, thread_ts): + rtype = reply.type + content = reply.content + + if rtype in (ReplyType.TEXT, ReplyType.INFO, ReplyType.ERROR): + text = str(content) if content is not None else "" + if not text: + return + # Slack caps a message around 40k chars; split conservatively + for chunk in _split_text(text, 3500): + self._client.chat_postMessage(channel=channel_id, text=chunk, thread_ts=thread_ts) + + elif rtype == ReplyType.IMAGE: + # Already a local BytesIO; upload it directly + content.seek(0) + self._client.files_upload_v2( + channel=channel_id, file=content, filename="image.png", thread_ts=thread_ts, + ) + + elif rtype == ReplyType.IMAGE_URL: + url = str(content) + if url.startswith("file://"): + local = url[7:] + self._client.files_upload_v2( + channel=channel_id, file=local, thread_ts=thread_ts, + ) + else: + # Post the URL as text; Slack will unfurl it as an image preview + self._client.chat_postMessage(channel=channel_id, text=url, thread_ts=thread_ts) + + elif rtype in (ReplyType.VOICE, ReplyType.FILE): + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + caption = getattr(reply, "text_content", None) or None + self._client.files_upload_v2( + channel=channel_id, file=local, initial_comment=caption, thread_ts=thread_ts, + ) + + else: + # Fallback: send as plain text + self._client.chat_postMessage(channel=channel_id, text=str(content), thread_ts=thread_ts) + + +def _split_text(text: str, limit: int): + """Split long text preferring line breaks to keep markdown structure intact.""" + if len(text) <= limit: + yield text + return + buf = [] + size = 0 + for line in text.splitlines(keepends=True): + if size + len(line) > limit and buf: + yield "".join(buf) + buf, size = [], 0 + # Hard-split single lines that exceed the limit + while len(line) > limit: + yield line[:limit] + line = line[limit:] + buf.append(line) + size += len(line) + if buf: + yield "".join(buf) diff --git a/channel/slack/slack_message.py b/channel/slack/slack_message.py new file mode 100644 index 00000000..39f215bd --- /dev/null +++ b/channel/slack/slack_message.py @@ -0,0 +1,60 @@ +""" +Slack message adapter. + +Convert a Slack event payload into cow's unified ChatMessage. +File downloads are NOT performed here; the channel layer downloads files +on demand because it needs the bot token for authenticated download URLs. +""" +import os + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.utils import expand_path +from config import conf + + +class SlackMessage(ChatMessage): + """Wrap a Slack event into the unified ChatMessage.""" + + def __init__(self, event: dict, is_group: bool = False, bot_user_id: str = "", + ctype: ContextType = ContextType.TEXT, content: str = ""): + super().__init__(event) + # Basic fields + self.msg_id = event.get("client_msg_id") or event.get("ts") or "" + try: + self.create_time = int(float(event.get("ts", 0))) + except (TypeError, ValueError): + self.create_time = 0 + self.ctype = ctype + self.content = content + + # Sender / chat info + from_user_id = event.get("user", "unknown") + channel_id = event.get("channel", "") + self.from_user_id = from_user_id + self.from_user_nickname = from_user_id + self.to_user_id = bot_user_id or "slack_bot" + self.to_user_nickname = bot_user_id or "slack_bot" + + self.is_group = is_group + if is_group: + # Channel chat: other_user_id = channel_id, actual_user_id = sender id + self.other_user_id = channel_id + self.other_user_nickname = channel_id + self.actual_user_id = from_user_id + self.actual_user_nickname = from_user_id + else: + # DM: use channel_id so replies go back to the same DM channel + self.other_user_id = channel_id or from_user_id + self.other_user_nickname = from_user_id + + # Whether the bot was triggered by @-mention (set by channel layer) + self.is_at = False + + @staticmethod + def get_tmp_dir() -> str: + """Local download directory, aligned with other channels (agent_workspace/tmp).""" + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + tmp_dir = os.path.join(workspace_root, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + return tmp_dir diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py index b3b46589..9e40c59f 100644 --- a/channel/telegram/telegram_channel.py +++ b/channel/telegram/telegram_channel.py @@ -177,10 +177,21 @@ class TelegramChannel(ChatChannel): application.add_handler(MessageHandler(filters.COMMAND, self._on_command_passthrough)) # Start polling. drop_pending_updates avoids replaying backlog after restart. + # Transient "Server disconnected" / RemoteProtocolError during get_updates + # are common over proxies/flaky networks; PTB's network loop auto-retries, + # so we only need to keep the noise down (see _quiet_polling_network_errors). + self._quiet_polling_network_errors() logger.info("[Telegram] Starting long polling...") await application.initialize() await application.start() - await application.updater.start_polling(drop_pending_updates=True) + await application.updater.start_polling( + drop_pending_updates=True, + # Long-poll hold time on the server side; smaller value = reconnect more + # often but each hung connection fails faster. + timeout=30, + # Retry forever on transient get_updates network errors instead of giving up. + bootstrap_retries=-1, + ) self.report_startup_success() logger.info("[Telegram] ✅ Telegram bot ready, polling for updates") @@ -196,6 +207,38 @@ class TelegramChannel(ChatChannel): except Exception as e: logger.warning(f"[Telegram] shutdown error: {e}") + @staticmethod + def _quiet_polling_network_errors(): + """Downgrade PTB's noisy 'Exception happened while polling for updates' logs. + + These transient get_updates errors (RemoteProtocolError / NetworkError / + TimedOut, typically over a proxy) are auto-retried by PTB's network loop, + so logging the full traceback at ERROR is just noise. We attach a filter + that drops these specific records while leaving real errors untouched. + """ + import logging + + class _PollingNoiseFilter(logging.Filter): + _NEEDLES = ( + "Exception happened while polling for updates", + "Server disconnected without sending a response", + ) + + def filter(self, record: logging.LogRecord) -> bool: + try: + msg = record.getMessage() + except Exception: + return True + if any(n in msg for n in self._NEEDLES): + # Keep a single-line breadcrumb at DEBUG, drop the traceback. + logger.debug(f"[Telegram] transient polling network error (auto-retrying): {msg.splitlines()[0]}") + return False + return True + + noise_filter = _PollingNoiseFilter() + for name in ("telegram.ext.Updater", "telegram.ext._updater", "telegram.ext"): + logging.getLogger(name).addFilter(noise_filter) + def stop(self): logger.info("[Telegram] stop() called") self._stop_event.set() diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index c564696e..d230ec4c 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1315,7 +1315,13 @@ class FileServeHandler: file_path = params.path if not file_path or not os.path.isabs(file_path): raise web.notfound() - file_path = os.path.normpath(file_path) + # Resolve symlinks and confine access to the configured root dir, + # so this endpoint can't be abused to read arbitrary files (e.g. /etc/passwd, ~/.ssh). + # Defaults to the user home dir; set web_file_serve_root="/" to allow the whole filesystem. + file_path = os.path.realpath(file_path) + serve_root = os.path.realpath(os.path.expanduser(conf().get("web_file_serve_root", "~") or "~")) + if serve_root != os.sep and os.path.commonpath([file_path, serve_root]) != serve_root: + raise web.notfound() if not os.path.isfile(file_path): raise web.notfound() content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" @@ -2917,6 +2923,15 @@ class ChannelsHandler: {"key": "telegram_token", "label": "Bot Token", "type": "secret"}, ], }), + ("slack", { + "label": {"zh": "Slack", "en": "Slack"}, + "icon": "fa-hashtag", + "color": "purple", + "fields": [ + {"key": "slack_bot_token", "label": "Bot Token (xoxb-)", "type": "secret"}, + {"key": "slack_app_token", "label": "App Token (xapp-)", "type": "secret"}, + ], + }), ]) @staticmethod diff --git a/common/const.py b/common/const.py index 8ec2a2ff..863b6eee 100644 --- a/common/const.py +++ b/common/const.py @@ -245,3 +245,4 @@ WECOM_BOT = "wecom_bot" QQ = "qq" WEIXIN = "weixin" TELEGRAM = "telegram" +SLACK = "slack" diff --git a/config.py b/config.py index cf8f43fe..024ce831 100644 --- a/config.py +++ b/config.py @@ -171,6 +171,10 @@ available_setting = { "telegram_proxy": "", # 可选的 HTTP/SOCKS5 代理,例如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080(留空则走系统环境变量) "telegram_group_trigger": "mention_or_reply", # 群聊触发方式: mention_or_reply(@或回复触发,推荐) | mention_only(仅@) | all(所有消息) "telegram_register_commands": True, # 启动时是否自动向 BotFather 注册命令菜单(与 web 端 slash 命令一致) + # Slack 配置(Socket Mode,无需公网 IP) + "slack_bot_token": "", # Bot User OAuth Token,形如 xoxb-... + "slack_app_token": "", # App-Level Token(开启 Socket Mode 后生成),形如 xapp-... + "slack_group_trigger": "mention_or_reply", # 频道触发方式: mention_or_reply(@或线程内回复,推荐) | mention_only(仅@) | all(所有消息) # 微信配置 "weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录 "weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL @@ -179,7 +183,7 @@ available_setting = { # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 - "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,telegram + "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,telegram,slack "web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用 "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app "debug": False, # 是否开启debug模式,开启后会打印更多日志 @@ -221,6 +225,7 @@ available_setting = { "web_port": 9899, "web_password": "", # Web console password; empty means no authentication required "web_session_expire_days": 30, # Auth session expiry in days + "web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem "agent": True, # 是否开启Agent模式 "agent_workspace": "~/cow", # agent工作空间路径,用于存储skills、memory等 "agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 5d3fffdc..97ba16ab 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -20,6 +20,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 | [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 - **群聊**列指可识别并响应群消息 @@ -39,3 +40,4 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 - [企业微信应用](/channels/wecom) — 企业微信自建应用接入 - [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号) - [Telegram](/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP +- [Slack](/channels/slack) — 团队协作 IM,Socket Mode 接入,无需公网 IP diff --git a/docs/channels/slack.mdx b/docs/channels/slack.mdx new file mode 100644 index 00000000..1103f1c0 --- /dev/null +++ b/docs/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: 将 CowAgent 接入 Slack App +--- + +> 通过 Slack App 的 **Socket Mode** 接入 CowAgent,支持私聊(DM)与频道(@机器人 / 线程内回复触发)。Socket Mode 基于长连接,无需公网 IP 与回调地址,开箱即用。 + +## 一、接入步骤 + +### 步骤一:创建 Slack App + +1. 打开 [Slack API 应用管理页](https://api.slack.com/apps),点击 **Create New App** → **From scratch**。 +2. 填写 **App Name**(如 `CowAgent`),选择要安装的 **Workspace**,点击创建。 + +### 步骤二:开启 Socket Mode 并获取 App Token + +1. 左侧菜单进入 **Settings → Socket Mode**,打开 **Enable Socket Mode**。 +2. 系统会提示生成一个 **App-Level Token**,作用域勾选 `connections:write`,生成后保存这串以 `xapp-` 开头的 Token。 + + + Socket Mode 通过 WebSocket 长连接接收事件,无需在公网暴露回调 URL,适合本地或内网部署。 + + +### 步骤三:配置 Bot 权限并安装 + +1. 进入 **Features → OAuth & Permissions**,在 **Bot Token Scopes** 中点击 **Add an OAuth Scope**,逐项添加以下权限: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` 用于图片、文件的收发;若仅需文本对话可省略。 + + +2. 进入 **Features → Event Subscriptions**,打开 **Enable Events**,在 **Subscribe to bot events** 中点击 **Add Bot User Event** 添加以下事件: + + ``` + app_mention + message.im + message.channels + ``` + + + 如需在私有频道使用,再添加 `message.groups`。 + +3. 进入 **Features → App Home**,在 **Show Tabs** 区域勾选 **Messages Tab**,并勾选下方的 **Allow users to send Slash commands and messages from the messages tab**(允许用户从消息标签页发送消息),否则私聊输入框会被关闭、无法给机器人发消息。 +4. 回到 **OAuth & Permissions**,点击 **Install to Workspace** 完成安装,安装后获取以 `xoxb-` 开头的 **Bot User OAuth Token**。 + + + 若 Slack 客户端仍提示「向此应用发送消息的功能已关闭」,请确认已完成上一步的 App Home 设置,并刷新或重启 Slack 客户端(必要时把 App 从对话列表移除后重新打开)。 + + +### 步骤四:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Slack**,分别填入 Bot Token(`xoxb-`)和 App Token(`xapp-`),点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token,形如 `xoxb-...` | - | + | `slack_app_token` | App-Level Token(开启 Socket Mode 后生成),形如 `xapp-...` | - | + | `slack_group_trigger` | 频道触发方式:`mention_or_reply`(@或线程内回复)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 私聊(DM) | ✅ | +| 频道(@机器人 / 线程内回复) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| 线程回复 | ✅ 回复发送至触发消息所在线程 | + + + Slack 通过线程(Thread)组织对话。机器人会把回复发送到触发消息所在的线程,频道内更整洁。 + + +## 三、使用 + +完成接入后: + +- **私聊(DM)**:在 Slack 左侧 **Apps** 中找到你的 App,直接发消息对话。 +- **频道**:把 App 邀请进频道(`/invite @你的App`),使用 `@你的App 你好` 触发对话;后续在同一线程内直接回复即可继续对话。 + +发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/docs.json b/docs/docs.json index 93c3aa37..0e4e92b2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -197,7 +197,8 @@ "channels/qq", "channels/wecom", "channels/wechatmp", - "channels/telegram" + "channels/telegram", + "channels/slack" ] } ] @@ -401,7 +402,8 @@ "en/channels/qq", "en/channels/wecom", "en/channels/wechatmp", - "en/channels/telegram" + "en/channels/telegram", + "en/channels/slack" ] } ] @@ -605,7 +607,8 @@ "ja/channels/qq", "ja/channels/wecom", "ja/channels/wechatmp", - "ja/channels/telegram" + "ja/channels/telegram", + "ja/channels/slack" ] } ] diff --git a/docs/en/channels/index.mdx b/docs/en/channels/index.mdx index 3533b0d3..8b6a25e9 100644 --- a/docs/en/channels/index.mdx +++ b/docs/en/channels/index.mdx @@ -20,6 +20,7 @@ The table below summarizes the inbound message types, bot reply types, and group | [WeCom App](/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [Official Account](/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details - The **Group Chat** column indicates the ability to recognize and respond to group messages @@ -39,3 +40,4 @@ The table below summarizes the inbound message types, bot reply types, and group - [WeCom App](/en/channels/wecom) — WeCom custom app integration - [Official Account](/en/channels/wechatmp) — WeChat Official Account (subscription / service) - [Telegram](/en/channels/telegram) — global IM, 5-minute setup, no public IP needed +- [Slack](/en/channels/slack) — team collaboration IM, Socket Mode integration, no public IP needed diff --git a/docs/en/channels/slack.mdx b/docs/en/channels/slack.mdx new file mode 100644 index 00000000..f95272ca --- /dev/null +++ b/docs/en/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: Integrate CowAgent with a Slack App +--- + +> Integrate CowAgent into Slack via a Slack App in **Socket Mode**. Supports direct messages (DM) and channels (triggered by @mention or replying within a thread). Socket Mode uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. + +## 1. Setup + +### Step 1: Create a Slack App + +1. Open the [Slack API apps page](https://api.slack.com/apps), click **Create New App** → **From scratch**. +2. Enter an **App Name** (e.g. `CowAgent`), pick the **Workspace** to install into, and create it. + +### Step 2: Enable Socket Mode and get the App Token + +1. In the left sidebar go to **Settings → Socket Mode** and turn on **Enable Socket Mode**. +2. You will be prompted to generate an **App-Level Token** with the `connections:write` scope. Save this token starting with `xapp-`. + + + Socket Mode receives events over a WebSocket connection, so you don't need to expose a public callback URL — ideal for local or intranet deployments. + + +### Step 3: Configure bot scopes and install + +1. Go to **Features → OAuth & Permissions**, click **Add an OAuth Scope** under **Bot Token Scopes**, and add the following scopes one by one: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` are used for sending/receiving images and files; omit them if you only need text conversations. + + +2. Go to **Features → Event Subscriptions**, turn on **Enable Events**, and under **Subscribe to bot events** click **Add Bot User Event** to add: + + ``` + app_mention + message.im + message.channels + ``` + + + Add `message.groups` if you need to use the bot in private channels. + +3. Go to **Features → App Home**, enable **Messages Tab** under **Show Tabs**, and check **Allow users to send Slash commands and messages from the messages tab**. Otherwise the DM input box is disabled and users cannot message the bot. +4. Back in **OAuth & Permissions**, click **Install to Workspace**. After installing, copy the **Bot User OAuth Token** starting with `xoxb-`. + + + If the Slack client still shows "Sending messages to this app has been turned off", make sure you completed the App Home step above, then refresh or restart the Slack client (remove the app from your conversations and reopen it if needed). + + +### Step 4: Connect to CowAgent + + + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Slack**, paste the Bot Token (`xoxb-`) and App Token (`xapp-`), and click connect. + + + Add the following to `config.json` and start Cow: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | Key | Description | Default | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token, like `xoxb-...` | - | + | `slack_app_token` | App-Level Token (generated after enabling Socket Mode), like `xapp-...` | - | + | `slack_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply in thread) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | + + + +The integration is ready when you see logs like: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 2. Capabilities + +| Feature | Support | +| --- | --- | +| Direct message (DM) | ✅ | +| Channel (@bot / reply in thread) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | +| Thread replies | ✅ replies are posted to the thread of the triggering message | + + + Slack organizes conversations into threads. The bot posts replies into the thread of the triggering message, keeping channels tidy. + + +## 3. Usage + +Once connected: + +- **Direct message (DM)**: find your App under **Apps** in the Slack sidebar and message it directly. +- **Channel**: invite the App into a channel (`/invite @your-app`), then trigger it with `@your-app hello`; continue the conversation by replying within the same thread. + +When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/ja/README.md b/docs/ja/README.md index 3db69d8c..0e4327c8 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -30,7 +30,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています | [ナレッジベース](https://docs.cowagent.ai/ja/knowledge/index) | 構造化された知識を Markdown Wiki として自動整理し、進化し続けるナレッジグラフを可視化ブラウジング | | [Skill](https://docs.cowagent.ai/ja/skills/index) | [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub からワンクリックでインストール;対話によるカスタム Skill 作成にも対応 | | [ツール](https://docs.cowagent.ai/ja/tools/index) | ファイル I/O、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10+ の組み込みツール — MCP プロトコルに完全対応 | -| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram を同時にサポート | +| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram、Slack を同時にサポート | | マルチモーダル | テキスト・画像・音声・ファイルをフルサポート — 認識・生成・双方向送受信 | | [モデル](https://docs.cowagent.ai/ja/models/index) | Claude、GPT、Gemini、DeepSeek、GLM、Qwen、Kimi、MiniMax、Doubao など、設定 1 行で切り替え可能 | | [デプロイ](https://docs.cowagent.ai/ja/guide/quick-start) | ワンラインインストーラー、統合された Web コンソール、複数のデプロイモード(ローカル / Docker / サーバー) | @@ -127,6 +127,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [WeCom App](https://docs.cowagent.ai/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](https://docs.cowagent.ai/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > Feishu と WeCom Bot は **Web コンソール内で QR コードをスキャンするだけで接続**できます — パブリック IP は不要です。詳細は [チャネル概要](https://docs.cowagent.ai/ja/channels/index) を参照してください。 diff --git a/docs/ja/channels/index.mdx b/docs/ja/channels/index.mdx index ac6a5acf..05a540d2 100644 --- a/docs/ja/channels/index.mdx +++ b/docs/ja/channels/index.mdx @@ -20,6 +20,7 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください - **グループチャット**列はグループメッセージを認識して応答できることを示します @@ -39,3 +40,4 @@ CowAgent は複数のチャットチャネルへの接続に対応しており - [WeCom アプリ](/ja/channels/wecom) — WeCom 自作アプリ接続 - [WeChat 公式アカウント](/ja/channels/wechatmp) — WeChat 公式アカウント(購読アカウント / サービスアカウント) - [Telegram](/ja/channels/telegram) — グローバル IM、5 分で接続、公開 IP 不要 +- [Slack](/ja/channels/slack) — チームコラボレーション IM、Socket Mode 接続、公開 IP 不要 diff --git a/docs/ja/channels/slack.mdx b/docs/ja/channels/slack.mdx new file mode 100644 index 00000000..d871bd00 --- /dev/null +++ b/docs/ja/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: Slack App 経由で CowAgent を接続 +--- + +> Slack App の **Socket Mode** を通じて CowAgent を接続します。ダイレクトメッセージ(DM)およびチャンネル(@メンションまたはスレッド内の返信で起動)に対応。Socket Mode は WebSocket の常時接続を使うため公開 IP やコールバック URL は不要で、すぐに利用できます。 + +## 1. 接続手順 + +### ステップ 1: Slack App を作成 + +1. [Slack API アプリ管理ページ](https://api.slack.com/apps) を開き、**Create New App** → **From scratch** をクリックします。 +2. **App Name**(例: `CowAgent`)を入力し、インストール先の **Workspace** を選択して作成します。 + +### ステップ 2: Socket Mode を有効化し App Token を取得 + +1. 左メニューの **Settings → Socket Mode** で **Enable Socket Mode** をオンにします。 +2. `connections:write` スコープを持つ **App-Level Token** の生成を求められます。`xapp-` で始まるこの Token を保存してください。 + + + Socket Mode は WebSocket 接続でイベントを受信するため、公開コールバック URL を公開する必要がありません。ローカルやイントラネットでの運用に最適です。 + + +### ステップ 3: Bot 権限を設定してインストール + +1. **Features → OAuth & Permissions** を開き、**Bot Token Scopes** で **Add an OAuth Scope** をクリックして以下を 1 つずつ追加します: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` は画像・ファイルの送受信に使用します。テキスト会話のみであれば省略可能です。 + + +2. **Features → Event Subscriptions** を開き、**Enable Events** をオンにして、**Subscribe to bot events** で **Add Bot User Event** をクリックし、以下を追加します: + + ``` + app_mention + message.im + message.channels + ``` + + + 非公開チャンネルで使用する場合は `message.groups` も追加してください。 + +3. **Features → App Home** を開き、**Show Tabs** 内の **Messages Tab** を有効にして、下の **Allow users to send Slash commands and messages from the messages tab**(メッセージタブからの送信を許可)にチェックを入れます。これを行わないと DM の入力欄が無効化され、ボットにメッセージを送れません。 +4. **OAuth & Permissions** に戻り、**Install to Workspace** をクリックしてインストールします。インストール後、`xoxb-` で始まる **Bot User OAuth Token** を取得します。 + + + Slack クライアントで「このアプリへのメッセージ送信は無効です」と表示される場合は、上記の App Home 設定が完了しているか確認し、Slack クライアントを再読み込み/再起動してください(必要に応じてアプリを会話一覧から削除して再度開きます)。 + + +### ステップ 4: CowAgent に接続 + + + + Web コンソール(既定 `http://127.0.0.1:9899`)を開き、**チャネル** メニュー → **チャネルを追加** → **Slack** を選択し、Bot Token(`xoxb-`)と App Token(`xapp-`)を貼り付けて接続をクリックします。 + + + `config.json` に以下を追加して Cow を起動します: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | パラメータ | 説明 | 既定値 | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token、`xoxb-...` の形式 | - | + | `slack_app_token` | App-Level Token(Socket Mode 有効化後に生成)、`xapp-...` の形式 | - | + | `slack_group_trigger` | チャンネルのトリガー方式: `mention_or_reply`(@ またはスレッド返信)/ `mention_only`(@ のみ)/ `all`(全メッセージ) | `mention_or_reply` | + + + +ログに以下のような出力が表示されれば接続成功です: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 2. 機能 + +| 機能 | 対応状況 | +| --- | --- | +| ダイレクトメッセージ(DM) | ✅ | +| チャンネル(@bot / スレッド返信) | ✅ | +| テキストメッセージ | ✅ 送受信 | +| 画像メッセージ | ✅ 送受信 | +| ファイルメッセージ | ✅ 送受信(PDF / Word / Excel など) | +| スレッド返信 | ✅ 起動メッセージのスレッドに返信を送信 | + + + Slack はスレッドで会話を整理します。Bot は起動メッセージのスレッドに返信を送信するため、チャンネルがすっきりします。 + + +## 3. 使い方 + +接続が完了したら: + +- **ダイレクトメッセージ(DM)**: Slack の左サイドバー **Apps** からアプリを開き、直接メッセージを送ります。 +- **チャンネル**: アプリをチャンネルに招待し(`/invite @your-app`)、`@your-app こんにちは` で起動します。以降は同じスレッド内で返信すれば会話を継続できます。 + +画像やファイルを送るときは、添付の入力欄に **テキスト説明**(説明・質問)を書いて一緒に送信できます。Bot は添付ファイルと説明を合わせて回答します。先に添付を送り、その後に質問を送る形でも、2 つのメッセージは自動でまとめて処理されます。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 377d1beb..bc8000b3 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -9,7 +9,7 @@ **CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆,与你一同成长,是 Agent Harness 工程的最佳实践之一。 -CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、网页等多渠道,7×24 运行于个人电脑或服务器中。 +CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、Slack、网页等多渠道,7×24 运行于个人电脑或服务器中。

🌐 官网  ·  @@ -30,7 +30,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖 | [知识库](https://docs.cowagent.ai/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 | | [技能](https://docs.cowagent.ai/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 | | [工具](https://docs.cowagent.ai/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 | -| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram 等多个渠道 | +| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 | | 多模态 | 文本、图片、语音、文件全消息类型支持,覆盖识别、生成、收发 | | [模型](https://docs.cowagent.ai/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流厂商,配置一行切换 | | [部署](https://docs.cowagent.ai/guide/quick-start) | 一键脚本安装,Web 控制台统一管理;本地、Docker、服务器多种部署方式 | @@ -127,6 +127,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [企业微信应用](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/channels)。 diff --git a/requirements.txt b/requirements.txt index d767e21a..7f0ccc71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,3 +29,5 @@ websocket-client>=1.4.0 pycryptodome # telegram bot python-telegram-bot +# slack bot +slack_bolt