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。
+
+
🌐 官网 · @@ -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