diff --git a/README.md b/README.md index 8b9e044e..70b2707f 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, and Official Accounts | +| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, and Telegram | | 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) | @@ -126,6 +126,7 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | [QQ](https://docs.cowagent.ai/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [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) | ✅ | ✅ | ✅ | ✅ | ✅ | > 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 cf6bfea8..fd8cf0e8 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -39,6 +39,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == const.QQ: from channel.qq.qq_channel import QQChannel ch = QQChannel() + elif channel_type == const.TELEGRAM: + from channel.telegram.telegram_channel import TelegramChannel + ch = TelegramChannel() elif channel_type in (const.WEIXIN, "wx"): from channel.weixin.weixin_channel import WeixinChannel ch = WeixinChannel() diff --git a/channel/telegram/__init__.py b/channel/telegram/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py new file mode 100644 index 00000000..b3b46589 --- /dev/null +++ b/channel/telegram/telegram_channel.py @@ -0,0 +1,676 @@ +""" +Telegram channel via Bot API (long polling mode). + +Features: +- Single chat & group chat (text / photo / voice / video / document) +- Group trigger: @mention or reply-to-bot (configurable) +- /cancel fast-path matches Web channel behaviour +- Auto-register bot commands menu on startup (mirrors Web slash menu) +- Optional HTTP/SOCKS5 proxy support for restricted networks + +Implementation note: + python-telegram-bot is async-first. We run the bot inside a dedicated + thread with its own asyncio loop so the rest of cow (which is sync) + stays untouched. Inbound updates are dispatched onto cow's existing + sync ChatChannel.produce() pipeline; outbound send() schedules + coroutines back onto that loop via asyncio.run_coroutine_threadsafe. +""" + +import asyncio +import os +import re +import threading + +from bridge.context import Context, ContextType +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel, check_prefix +from channel.telegram.telegram_message import TelegramMessage +from common.expired_dict import ExpiredDict +from common.log import logger +from common.singleton import singleton +from config import conf + +# Bot command menu, aligned with Web slash commands. +# Top-level commands only; sub-commands are entered with a space (e.g. "/skill list"). +TELEGRAM_BOT_COMMANDS = [ + ("help", "Show command help"), + ("status", "Show running status"), + ("context", "View/clear conversation context (sub: clear)"), + ("skill", "Manage skills (list/search/install/...)"), + ("memory", "Manage memory (sub: dream)"), + ("knowledge", "Manage knowledge base (list/on/off)"), + ("config", "Show current config"), + ("cancel", "Cancel running agent task"), + ("logs", "Show recent logs"), + ("version", "Show version"), +] + + +@singleton +class TelegramChannel(ChatChannel): + NOT_SUPPORT_REPLYTYPE = [] + + def __init__(self): + super().__init__() + self.bot_token = "" + self.bot_username = "" # used for @-mention matching + self._bot = None + self._application = None + self._loop = None + self._loop_thread = None + self._stop_event = threading.Event() + # Idempotent dedup; TG occasionally redelivers the same update on flaky networks + self._received_msgs = ExpiredDict(60 * 60 * 1) + + # Disable group whitelist / prefix checks (we handle triggering ourselves + # in _should_reply_in_group), aligned with feishu / wecom_bot channels. + conf()["group_name_white_list"] = ["ALL_GROUP"] + conf()["single_chat_prefix"] = [""] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def startup(self): + self.bot_token = conf().get("telegram_token", "") + if not self.bot_token: + err = "[Telegram] telegram_token is required" + logger.error(err) + self.report_startup_error(err) + return + + try: + from telegram.ext import ( + Application, + MessageHandler, + CommandHandler, + filters, + ) + except ImportError: + err = ( + "[Telegram] python-telegram-bot is not installed. " + "Run: pip install python-telegram-bot" + ) + logger.error(err) + self.report_startup_error(err) + return + + # Run the asyncio event loop in a dedicated thread so the sync cow body + # is untouched. + self._loop = asyncio.new_event_loop() + + def _run_loop(): + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._async_main(Application, MessageHandler, CommandHandler, filters)) + except Exception as e: + logger.error(f"[Telegram] event loop crashed: {e}", exc_info=True) + self.report_startup_error(str(e)) + finally: + try: + self._loop.close() + except Exception: + pass + logger.info("[Telegram] event loop exited") + + self._loop_thread = threading.Thread(target=_run_loop, daemon=True, name="telegram-loop") + self._loop_thread.start() + # Block startup() until the loop thread exits, matching other channels' + # behaviour (startup is a blocking call). + self._loop_thread.join() + + async def _async_main(self, Application, MessageHandler, CommandHandler, filters): + """Build Application, register handlers, and run polling.""" + builder = Application.builder().token(self.bot_token) + + # Proxy: prefer telegram_proxy config, fall back to HTTPS_PROXY env var + proxy_url = conf().get("telegram_proxy", "") or os.environ.get("HTTPS_PROXY", "") + if proxy_url: + try: + builder = builder.proxy(proxy_url).get_updates_proxy(proxy_url) + logger.info(f"[Telegram] using proxy: {proxy_url}") + except Exception as e: + logger.warning(f"[Telegram] proxy config failed, fallback to direct: {e}") + + # Media uploads (photo/voice/video/document) over a proxy can be slow, + # bump read/write/connect/pool timeouts. + builder = ( + builder + .read_timeout(60) + .write_timeout(120) + .connect_timeout(30) + .pool_timeout(30) + ) + + application = builder.build() + self._application = application + self._bot = application.bot + + # Fetch our own username (needed for @-mention matching in groups) + try: + me = await self._bot.get_me() + self.bot_username = me.username or "" + self.name = self.bot_username # ChatChannel uses self.name to strip @-mention + logger.info(f"[Telegram] Bot logged in as @{self.bot_username} (id={me.id})") + except Exception as e: + err = f"[Telegram] get_me failed: {e}" + logger.error(err) + self.report_startup_error(err) + return + + # Register the command menu (failure is non-fatal) + if conf().get("telegram_register_commands", True): + try: + from telegram import BotCommand + cmds = [BotCommand(name, desc) for name, desc in TELEGRAM_BOT_COMMANDS] + await self._bot.set_my_commands(cmds) + logger.info(f"[Telegram] Registered {len(cmds)} bot commands") + except Exception as e: + logger.warning(f"[Telegram] set_my_commands failed: {e}") + + # Handlers: + # 1) /cancel uses the fast-path + application.add_handler(CommandHandler("cancel", self._on_cancel)) + # 2) Normal messages (text + media) + application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._on_message)) + # 3) Other slash commands are forwarded as plain text for the agent to handle + application.add_handler(MessageHandler(filters.COMMAND, self._on_command_passthrough)) + + # Start polling. drop_pending_updates avoids replaying backlog after restart. + logger.info("[Telegram] Starting long polling...") + await application.initialize() + await application.start() + await application.updater.start_polling(drop_pending_updates=True) + self.report_startup_success() + logger.info("[Telegram] ✅ Telegram bot ready, polling for updates") + + # Block until stop() + try: + while not self._stop_event.is_set(): + await asyncio.sleep(0.5) + finally: + try: + await application.updater.stop() + await application.stop() + await application.shutdown() + except Exception as e: + logger.warning(f"[Telegram] shutdown error: {e}") + + def stop(self): + logger.info("[Telegram] stop() called") + self._stop_event.set() + if self._loop_thread and self._loop_thread.is_alive(): + try: + self._loop_thread.join(timeout=10) + except Exception: + pass + logger.info("[Telegram] stop() completed") + + # ------------------------------------------------------------------ + # Inbound: telegram update -> ChatMessage -> ChatChannel.produce + # ------------------------------------------------------------------ + + async def _on_cancel(self, update, _context): + """Fast-path: /cancel calls cancel_session directly without going through agent.""" + try: + from agent.protocol import get_cancel_registry + session_id = self._compute_session_id(update) + cancelled = get_cancel_registry().cancel_session(session_id) + text = "Current task cancelled." if cancelled else "No running task to cancel." + await update.effective_message.reply_text(text) + logger.info(f"[Telegram] /cancel session={session_id}, cancelled={cancelled}") + except Exception as e: + logger.error(f"[Telegram] /cancel error: {e}", exc_info=True) + try: + await update.effective_message.reply_text(f"⚠️ /cancel failed: {e}") + except Exception: + pass + + async def _on_command_passthrough(self, update, _context): + """All non-/cancel commands fall through to plain message handling.""" + await self._on_message(update, _context) + + async def _on_message(self, update, _context): + """Telegram update entry: parse message -> build ChatMessage -> produce().""" + try: + message = update.effective_message + chat = update.effective_chat + if not message or not chat: + return + + # Idempotent dedup + msg_uid = f"{chat.id}:{message.message_id}" + if self._received_msgs.get(msg_uid): + return + self._received_msgs[msg_uid] = True + + is_group = chat.type in ("group", "supergroup") + + # Debug log: helpful when group messages are silently dropped + if is_group: + logger.debug( + f"[Telegram] group update received: chat_id={chat.id}, " + f"text={(message.text or message.caption or '')[:40]!r}, " + f"reply_to_bot={bool(message.reply_to_message and message.reply_to_message.from_user and message.reply_to_message.from_user.username == self.bot_username)}" + ) + + # Group trigger gate (silently drop if not triggered) + if is_group and not self._should_reply_in_group(update): + logger.debug(f"[Telegram] group message not triggered (need @{self.bot_username} or reply), skip") + return + + # Parse message type + download media if needed. + # Media messages with caption return both the local path and the caption text. + ctype, content, caption = await self._parse_message(message) + if ctype is None: + logger.debug(f"[Telegram] unsupported message type, skip. msg={message}") + return + + # Strip @bot mention for group text/caption + if is_group and self.bot_username: + if ctype == ContextType.TEXT and content: + content = self._strip_at_mention(content) + if caption: + caption = self._strip_at_mention(caption) + + tg_msg = TelegramMessage( + update, + is_group=is_group, + bot_username=self.bot_username, + ctype=ctype, + content=content, + ) + tg_msg.is_at = is_group # If we got here in a group, the bot is mentioned/replied + + # File cache: standalone media goes into cache, the next text query attaches them + from channel.file_cache import get_file_cache + file_cache = get_file_cache() + session_id = self._compute_session_id(update) + + # 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}]" + tg_msg.ctype = ContextType.TEXT + tg_msg.content = merged_text + ctype = ContextType.TEXT + logger.info(f"[Telegram] 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"[Telegram] 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"[Telegram] File cached for session {session_id}: {content}") + return + + if ctype == ContextType.TEXT: + 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']}]") + tg_msg.content = (tg_msg.content or "") + "\n" + "\n".join(refs) + file_cache.clear(session_id) + logger.info(f"[Telegram] Attached {len(cached_files)} cached file(s) to query") + + # Dispatch to cow main pipeline (reuses ChatChannel._compose_context routing) + context = self._compose_context( + tg_msg.ctype, + tg_msg.content, + isgroup=is_group, + msg=tg_msg, + ) + if context: + context["session_id"] = session_id + context["receiver"] = str(chat.id) + context["telegram_chat_id"] = chat.id + context["telegram_reply_to_msg_id"] = message.message_id if is_group else None + self.produce(context) + logger.debug(f"[Telegram] received: type={ctype}, content={str(tg_msg.content)[:80]}") + + except Exception as e: + logger.error(f"[Telegram] _on_message error: {e}", exc_info=True) + + async def _parse_message(self, message): + """Parse a telegram message and return (ctype, content, caption). + + - content is text for ContextType.TEXT, otherwise the local file path + - caption is the optional text accompanying a media message; empty for plain text + """ + caption = (message.caption or "").strip() + + if message.photo: + largest = message.photo[-1] + path = await self._download_file(largest.file_id, suffix=".jpg") + return (ContextType.IMAGE, path, caption) if path else (None, None, "") + + if message.voice or message.audio: + audio_obj = message.voice or message.audio + suffix = ".ogg" if message.voice else ( + "." + (audio_obj.mime_type.split("/")[-1] if getattr(audio_obj, "mime_type", "") else "mp3") + ) + path = await self._download_file(audio_obj.file_id, suffix=suffix) + return (ContextType.VOICE, path, caption) if path else (None, None, "") + + if message.video or message.video_note: + video_obj = message.video or message.video_note + path = await self._download_file(video_obj.file_id, suffix=".mp4") + return (ContextType.FILE, path, caption) if path else (None, None, "") + + if message.document: + doc = message.document + ext = "" + if doc.file_name and "." in doc.file_name: + ext = "." + doc.file_name.rsplit(".", 1)[-1] + path = await self._download_file(doc.file_id, suffix=ext, original_name=doc.file_name) + if not path: + return (None, None, "") + # Image-typed documents (user picked "send as file") are treated as images + mime = (doc.mime_type or "").lower() + if mime.startswith("image/"): + return (ContextType.IMAGE, path, caption) + return (ContextType.FILE, path, caption) + + if message.text: + return (ContextType.TEXT, message.text.strip(), "") + + return (None, None, "") + + async def _download_file(self, file_id: str, suffix: str = "", original_name: str = ""): + """Download via bot.get_file into the local tmp dir; return path or None on failure.""" + try: + f = await self._bot.get_file(file_id) + tmp_dir = TelegramMessage.get_tmp_dir() + base = original_name or f"{file_id}{suffix or ''}" + # Prefix with file_id to avoid name collisions / weird chars + safe_name = f"{file_id}_{base}" if original_name else base + local_path = os.path.join(tmp_dir, safe_name) + await f.download_to_drive(custom_path=local_path) + logger.debug(f"[Telegram] downloaded file_id={file_id} -> {local_path}") + return local_path + except Exception as e: + logger.error(f"[Telegram] download_file failed (file_id={file_id}): {e}") + return None + + # ------------------------------------------------------------------ + # Group trigger logic + # ------------------------------------------------------------------ + + def _should_reply_in_group(self, update) -> bool: + """Decide whether to reply to a group message based on configuration.""" + mode = conf().get("telegram_group_trigger", "mention_or_reply") + if mode == "all": + return True + + message = update.effective_message + if not message: + return False + + # 1) Mentioned + if self.bot_username and self._is_mentioned(message, self.bot_username): + return True + + # 2) Reply to a bot message + if mode == "mention_or_reply": + reply = message.reply_to_message + if reply and reply.from_user and reply.from_user.username == self.bot_username: + return True + + return False + + @staticmethod + def _is_mentioned(message, bot_username: str) -> bool: + """Check whether entities/caption_entities contain a @mention of the bot.""" + bot_at = "@" + bot_username.lower() + text = (message.text or message.caption or "").lower() + if bot_at in text: + return True + # Also check entities strictly to support text_mention (no-username @) + for ent in (message.entities or []) + (message.caption_entities or []): + if ent.type == "mention": + src = message.text or message.caption or "" + if src[ent.offset: ent.offset + ent.length].lower() == bot_at: + return True + return False + + def _strip_at_mention(self, content: str) -> str: + """Strip @bot_username from group text (case-insensitive).""" + if not content or not self.bot_username: + return content + pattern = re.compile(r"@" + re.escape(self.bot_username), re.IGNORECASE) + return pattern.sub("", content).strip() + + @staticmethod + def _compute_session_id(update) -> str: + chat = update.effective_chat + user = update.effective_user + is_group = chat.type in ("group", "supergroup") + if is_group: + if conf().get("group_shared_session", True): + return f"tg_group_{chat.id}" + return f"tg_group_{chat.id}_{user.id}" + return f"tg_user_{user.id}" + + # ------------------------------------------------------------------ + # Override _compose_context: skip the parent's group whitelist/at checks + # (already handled in _on_message via _should_reply_in_group). Same idea + # as the feishu channel. + # ------------------------------------------------------------------ + + 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 -> Telegram API + # ------------------------------------------------------------------ + + def send(self, reply: Reply, context: Context): + """Called from cow's sync main thread; we marshal the coroutine onto the loop thread.""" + if self._loop is None or self._bot is None: + logger.warning("[Telegram] bot not ready, drop reply") + return + + chat_id = context.get("telegram_chat_id") + reply_to = context.get("telegram_reply_to_msg_id") + if chat_id is None: + logger.warning("[Telegram] no telegram_chat_id in context, drop reply") + return + + coro = self._async_send(reply, chat_id, reply_to) + try: + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + # Media uploads through a proxy can be slow; let PTB's own timeouts win + future.result(timeout=180) + except Exception as e: + logger.error(f"[Telegram] send failed: {e}") + + # Number of retries for transient network errors (proxy hiccups etc.) + _SEND_RETRIES = 2 + _SEND_RETRY_BACKOFF = 2.0 # seconds + + async def _send_with_retry(self, send_fn, *, label: str): + """Run a single Telegram API call with retries for transient network errors.""" + from telegram.error import NetworkError, TimedOut + last_err = None + for attempt in range(self._SEND_RETRIES + 1): + try: + return await send_fn() + except (NetworkError, TimedOut) as e: + last_err = e + if attempt >= self._SEND_RETRIES: + break + wait = self._SEND_RETRY_BACKOFF * (attempt + 1) + logger.warning( + f"[Telegram] {label} transient error (attempt {attempt + 1}/" + f"{self._SEND_RETRIES + 1}): {e}; retry in {wait}s" + ) + await asyncio.sleep(wait) + raise last_err + + async def _async_send(self, reply: Reply, chat_id, reply_to_msg_id): + try: + rtype = reply.type + content = reply.content + + if rtype == ReplyType.TEXT or rtype == ReplyType.INFO or rtype == ReplyType.ERROR: + # Telegram caps a single text message at 4096 chars; auto-split + text = str(content) if content is not None else "" + if not text: + return + for chunk in _split_text(text, 4000): + await self._send_with_retry( + lambda c=chunk: self._bot.send_message( + chat_id=chat_id, + text=c, + reply_to_message_id=reply_to_msg_id, + # Avoid failing the whole send if reply_to was deleted + allow_sending_without_reply=True, + ), + label="send_message", + ) + + elif rtype == ReplyType.IMAGE: + # Already a local BytesIO; send it directly + content.seek(0) + await self._send_with_retry( + lambda: self._bot.send_photo( + chat_id=chat_id, + photo=content, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ), + label="send_photo", + ) + + elif rtype == ReplyType.IMAGE_URL: + url = str(content) + if url.startswith("file://"): + local = url[7:] + # Open inside the lambda so each retry gets a fresh stream + async def _send_local_photo(): + with open(local, "rb") as f: + return await self._bot.send_photo( + chat_id=chat_id, photo=f, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + await self._send_with_retry(_send_local_photo, label="send_photo(file)") + else: + await self._send_with_retry( + lambda: self._bot.send_photo( + chat_id=chat_id, photo=url, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ), + label="send_photo(url)", + ) + + elif rtype == ReplyType.VOICE: + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + async def _send_voice(): + with open(local, "rb") as f: + return await self._bot.send_voice( + chat_id=chat_id, voice=f, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + await self._send_with_retry(_send_voice, label="send_voice") + + elif rtype == ReplyType.FILE: + # Videos go through send_video, everything else through send_document + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + # File replies may carry an accompanying text caption + caption = getattr(reply, "text_content", None) or None + is_video = isinstance(local, str) and local.lower().endswith( + (".mp4", ".mov", ".avi", ".mkv", ".webm") + ) + + async def _send_file(): + with open(local, "rb") as f: + if is_video: + return await self._bot.send_video( + chat_id=chat_id, video=f, caption=caption, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + return await self._bot.send_document( + chat_id=chat_id, document=f, caption=caption, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + await self._send_with_retry(_send_file, label="send_video" if is_video else "send_document") + + else: + # Fallback: send as plain text + await self._send_with_retry( + lambda: self._bot.send_message( + chat_id=chat_id, text=str(content), + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ), + label="send_message(fallback)", + ) + + logger.info(f"[Telegram] sent reply (type={rtype}, chat_id={chat_id})") + + except Exception as e: + logger.error(f"[Telegram] _async_send error: {e}", exc_info=True) + + +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/telegram/telegram_message.py b/channel/telegram/telegram_message.py new file mode 100644 index 00000000..c97c6059 --- /dev/null +++ b/channel/telegram/telegram_message.py @@ -0,0 +1,62 @@ +""" +Telegram message adapter. + +Convert a python-telegram-bot Update into cow's unified ChatMessage. +File downloads are NOT performed here; the channel layer triggers +bot.get_file() on demand because it requires the async event loop. +""" +import os + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.utils import expand_path +from config import conf + + +class TelegramMessage(ChatMessage): + """Wrap a Telegram Update into the unified ChatMessage.""" + + def __init__(self, update, is_group: bool = False, bot_username: str = "", + ctype: ContextType = ContextType.TEXT, content: str = ""): + super().__init__(update) + message = update.effective_message + chat = update.effective_chat + user = update.effective_user + + # Basic fields + self.msg_id = str(message.message_id) if message else "" + self.create_time = int(message.date.timestamp()) if message and message.date else 0 + self.ctype = ctype + self.content = content + + # Sender / chat info + from_user_id = str(user.id) if user else "unknown" + from_user_nick = ( + user.full_name if user and user.full_name else (user.username if user else "unknown") + ) + self.from_user_id = from_user_id + self.from_user_nickname = from_user_nick or from_user_id + self.to_user_id = bot_username or "telegram_bot" + self.to_user_nickname = bot_username or "telegram_bot" + + self.is_group = is_group + if is_group: + # Group: other_user_id = group_id, actual_user_id = sender id + self.other_user_id = str(chat.id) + self.other_user_nickname = chat.title or str(chat.id) + self.actual_user_id = from_user_id + self.actual_user_nickname = self.from_user_nickname + else: + self.other_user_id = from_user_id + self.other_user_nickname = self.from_user_nickname + + # Whether the bot was triggered by @-mention or reply (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/web/web_channel.py b/channel/web/web_channel.py index af4e241e..57891855 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2909,6 +2909,14 @@ class ChannelsHandler: {"key": "wechatmp_port", "label": "Port", "type": "number", "default": 8080}, ], }), + ("telegram", { + "label": {"zh": "Telegram", "en": "Telegram"}, + "icon": "fa-paper-plane", + "color": "sky", + "fields": [ + {"key": "telegram_token", "label": "Bot Token", "type": "secret"}, + ], + }), ]) @staticmethod diff --git a/common/const.py b/common/const.py index 7addd6af..4daea457 100644 --- a/common/const.py +++ b/common/const.py @@ -243,3 +243,4 @@ DINGTALK = "dingtalk" WECOM_BOT = "wecom_bot" QQ = "qq" WEIXIN = "weixin" +TELEGRAM = "telegram" diff --git a/config.py b/config.py index 1d44dcc5..cf8f43fe 100644 --- a/config.py +++ b/config.py @@ -166,6 +166,11 @@ available_setting = { # 企微智能机器人配置(长连接模式) "wecom_bot_id": "", # 企微智能机器人BotID "wecom_bot_secret": "", # 企微智能机器人长连接Secret + # Telegram 配置 + "telegram_token": "", # 从 @BotFather 申请的 bot token + "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 命令一致) # 微信配置 "weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录 "weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL @@ -174,7 +179,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 + "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,telegram "web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用 "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app "debug": False, # 是否开启debug模式,开启后会打印更多日志 diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 9049a51d..5d3fffdc 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -19,6 +19,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 | [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | - **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 - **群聊**列指可识别并响应群消息 @@ -37,3 +38,4 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 - [QQ](/channels/qq) — QQ 官方机器人开放平台 - [企业微信应用](/channels/wecom) — 企业微信自建应用接入 - [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号) +- [Telegram](/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP diff --git a/docs/channels/telegram.mdx b/docs/channels/telegram.mdx new file mode 100644 index 00000000..d7ab7a44 --- /dev/null +++ b/docs/channels/telegram.mdx @@ -0,0 +1,112 @@ +--- +title: Telegram +description: 将 CowAgent 接入 Telegram Bot +--- + +> 通过 Telegram Bot API 接入 CowAgent,支持单聊与群聊(@机器人 / 回复机器人触发),使用 Long Polling 模式无需公网 IP,开箱即用。 + + +## 一、接入步骤 + +### 步骤一:通过 BotFather 创建 Bot + +1. 在 Telegram 中搜索并打开官方账号 [@BotFather](https://t.me/BotFather)。 +2. 发送 `/newbot` 命令,按提示输入: + - **Bot 名称**(显示名,可中文,例如 `My CowAgent Bot`) + - **Bot 用户名**(必须以 `bot` 结尾,例如 `my_cowagent_bot`) +3. 创建成功后,BotFather 会返回一段 **HTTP API Token**(形如 `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`),妥善保存。 + + + 这个 Token 等同于 Bot 的密码,请勿泄露。如果意外泄漏可向 `@BotFather` 发送 `/revoke` 重置。 + + +### 步骤二:(群聊使用)关闭 Privacy Mode + +仅使用单聊可跳过此步。Telegram Bot 默认开启 **Privacy Mode**,群聊中只能收到带 `@bot` 的命令(如 `/start@your_bot`)以及对 bot 消息的 reply;**普通的 `@bot 你好` 文字消息收不到**,会导致群聊无响应。 + +向 `@BotFather` 发送: + +1. `/setprivacy` +2. 选择刚才创建的 bot +3. 选择 `Disable` + + + 若设置后群聊仍无响应,可尝试把 Bot 从群里移除并重新拉入。 + + +### 步骤三:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Telegram**,填入 Bot Token,点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `telegram_token` | BotFather 返回的 HTTP API Token | - | + | `telegram_group_trigger` | 群聊触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + | `telegram_register_commands` | 启动时是否自动向 BotFather 注册命令菜单 | `true` | + | `telegram_proxy` | (可选)代理地址,如 `http://127.0.0.1:7890`、`socks5://127.0.0.1:1080`;运行环境无法直连 `api.telegram.org` 时配置,留空则使用环境变量 `HTTPS_PROXY` | `""` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 单聊 | ✅ | +| 群聊(@机器人 / 回复机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 语音消息 | ✅ 收发(接收 OGG/Opus,发送 OGG/Opus) | +| 视频消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| 命令菜单 | ✅ 与 Web 控制台 slash 命令一致 | + +### 命令菜单 + +启动时会自动向 BotFather 注册命令菜单,用户在 Telegram 输入框输入 `/` 会出现下拉提示: + +| 命令 | 说明 | +| --- | --- | +| `/help` | 显示命令帮助 | +| `/status` | 查看运行状态 | +| `/context` | 查看对话上下文(`/context clear` 清除) | +| `/skill` | 技能管理(`/skill list`、`/skill install` 等) | +| `/memory` | 记忆管理(`/memory dream`) | +| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) | +| `/config` | 查看当前配置 | +| `/cancel` | 中止当前正在运行的 Agent 任务 | +| `/logs` | 查看最近日志 | +| `/version` | 查看版本 | + + + Telegram 命令菜单只能展示一级命令,子命令通过空格输入即可,例如 `/skill list`、`/context clear`。 + + +## 三、使用 + +完成接入后: + +- **单聊**:在 Telegram 中搜索你创建的 Bot 用户名(如 `@my_cowagent_bot`),点击 `Start` 即可开始对话。 +- **群聊**:把 Bot 拉进群,使用 `@bot 你好` 或 **回复 Bot 的某条消息** 触发对话。若群聊无响应,请检查 Privacy Mode 是否已按 [步骤二](#步骤二-群聊使用-关闭-privacy-mode) 关闭。 + +发送图片或文件时,可以直接在附件上方的输入框中 **添加 Caption**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/docs.json b/docs/docs.json index e2826887..93c3aa37 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -196,7 +196,8 @@ "channels/wecom-bot", "channels/qq", "channels/wecom", - "channels/wechatmp" + "channels/wechatmp", + "channels/telegram" ] } ] @@ -399,7 +400,8 @@ "en/channels/wecom-bot", "en/channels/qq", "en/channels/wecom", - "en/channels/wechatmp" + "en/channels/wechatmp", + "en/channels/telegram" ] } ] @@ -602,7 +604,8 @@ "ja/channels/wecom-bot", "ja/channels/qq", "ja/channels/wecom", - "ja/channels/wechatmp" + "ja/channels/wechatmp", + "ja/channels/telegram" ] } ] diff --git a/docs/en/channels/index.mdx b/docs/en/channels/index.mdx index 41fc3230..3533b0d3 100644 --- a/docs/en/channels/index.mdx +++ b/docs/en/channels/index.mdx @@ -19,6 +19,7 @@ The table below summarizes the inbound message types, bot reply types, and group | [QQ](/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [Official Account](/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | - 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 @@ -37,3 +38,4 @@ The table below summarizes the inbound message types, bot reply types, and group - [QQ](/en/channels/qq) — QQ Official Bot open platform - [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 diff --git a/docs/en/channels/telegram.mdx b/docs/en/channels/telegram.mdx new file mode 100644 index 00000000..f90da992 --- /dev/null +++ b/docs/en/channels/telegram.mdx @@ -0,0 +1,111 @@ +--- +title: Telegram +description: Integrate CowAgent with Telegram via the Bot API +--- + +> Integrate CowAgent into Telegram via the official Bot API. Supports private chat and group chat (triggered by @mention or replying to the bot). Uses Long Polling — no public IP required, works out of the box. + + +## 1. Setup + +### Step 1: Create a Bot via BotFather + +1. Open the official account [@BotFather](https://t.me/BotFather) in Telegram. +2. Send `/newbot` and follow the prompts: + - **Bot name** (display name, e.g. `My CowAgent Bot`) + - **Bot username** (must end with `bot`, e.g. `my_cowagent_bot`) +3. Once created, BotFather returns an **HTTP API Token** (e.g. `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`). Keep it safe. + + + The token is the password of your bot — never share it. If it leaks, send `/revoke` to `@BotFather` to reset it. + + +### Step 2: (Group chat only) Disable Privacy Mode + +Skip this step if you only use private chat. Telegram bots run in **Privacy Mode** by default — in groups they can only see commands suffixed with `@bot` (e.g. `/start@your_bot`) and replies to bot messages; **plain `@bot hello` text messages are not delivered**, so the bot will appear unresponsive in groups. + +Send the following to `@BotFather`: + +1. `/setprivacy` +2. Pick the bot you just created +3. Choose `Disable` + + + If the bot is still silent in groups after this, try removing it from the group and adding it back. + + +### Step 3: Connect to CowAgent + + + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Telegram**, paste the Bot Token, and click connect. + + + Add the following to `config.json` and start Cow: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | Key | Description | Default | + | --- | --- | --- | + | `telegram_token` | HTTP API Token returned by BotFather | - | + | `telegram_group_trigger` | Group trigger: `mention_or_reply` (@ or reply) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | + | `telegram_register_commands` | Whether to register the command menu with BotFather on startup | `true` | + + + +The integration is ready when you see logs like: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 2. Capabilities + +| Feature | Support | +| --- | --- | +| Private chat | ✅ | +| Group chat (@bot / reply to bot) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| Voice messages | ✅ send / receive (OGG/Opus) | +| Video messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | +| Command menu | ✅ aligned with Web Console slash commands | + +### Command Menu + +On startup, the channel registers a command menu with BotFather. Typing `/` in Telegram shows a dropdown: + +| Command | Description | +| --- | --- | +| `/help` | Show command help | +| `/status` | View runtime status | +| `/context` | View conversation context (`/context clear` to clear) | +| `/skill` | Skill management (`/skill list`, `/skill install`, ...) | +| `/memory` | Memory management (`/memory dream`) | +| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) | +| `/config` | View current config | +| `/cancel` | Cancel the running Agent task | +| `/logs` | View recent logs | +| `/version` | Show version | + + + Telegram's command menu only displays top-level commands; subcommands are entered with a space, e.g. `/skill list`, `/context clear`. + + +## 3. Usage + +Once connected: + +- **Private chat**: search for your bot username (e.g. `@my_cowagent_bot`) in Telegram, click `Start` and chat away. +- **Group chat**: add the bot to a group, then trigger it with `@bot hello` or by **replying to one of the bot's messages**. If the bot doesn't respond in groups, double-check Privacy Mode in [Step 2](#step-2-group-chat-only-disable-privacy-mode). + +When sending an image or file, you can **add a caption** (description / question) directly 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 df71ec74..69beb6ba 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、公式アカウントを同時にサポート | +| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram を同時にサポート | | マルチモーダル | テキスト・画像・音声・ファイルをフルサポート — 認識・生成・双方向送受信 | | [モデル](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 / サーバー) | @@ -126,6 +126,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [QQ](https://docs.cowagent.ai/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [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) | ✅ | ✅ | ✅ | ✅ | ✅ | > 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 93774e73..ac6a5acf 100644 --- a/docs/ja/channels/index.mdx +++ b/docs/ja/channels/index.mdx @@ -19,6 +19,7 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | [QQ](/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | - **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください - **グループチャット**列はグループメッセージを認識して応答できることを示します @@ -37,3 +38,4 @@ CowAgent は複数のチャットチャネルへの接続に対応しており - [QQ](/ja/channels/qq) — QQ 公式ボットオープンプラットフォーム - [WeCom アプリ](/ja/channels/wecom) — WeCom 自作アプリ接続 - [WeChat 公式アカウント](/ja/channels/wechatmp) — WeChat 公式アカウント(購読アカウント / サービスアカウント) +- [Telegram](/ja/channels/telegram) — グローバル IM、5 分で接続、公開 IP 不要 diff --git a/docs/ja/channels/telegram.mdx b/docs/ja/channels/telegram.mdx new file mode 100644 index 00000000..eab1d3b0 --- /dev/null +++ b/docs/ja/channels/telegram.mdx @@ -0,0 +1,111 @@ +--- +title: Telegram +description: Telegram Bot API 経由で CowAgent を接続 +--- + +> 公式の Telegram Bot API を通じて CowAgent を接続します。1 対 1 チャットおよびグループチャット(@メンションまたはボットへの返信で起動)に対応。Long Polling 方式のため公開 IP は不要で、すぐに利用できます。 + + +## 1. 接続手順 + +### ステップ 1: BotFather で Bot を作成 + +1. Telegram で公式アカウント [@BotFather](https://t.me/BotFather) を開きます。 +2. `/newbot` を送り、案内に従って入力します: + - **Bot 名**(表示名、例: `My CowAgent Bot`) + - **Bot ユーザー名**(`bot` で終わる必要があります、例: `my_cowagent_bot`) +3. 作成完了後、BotFather から **HTTP API Token**(例: `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`)が返されます。大切に保管してください。 + + + Token は Bot のパスワードに相当します。漏えいしないよう注意してください。万が一漏れた場合は `@BotFather` に `/revoke` を送って再発行できます。 + + +### ステップ 2:(グループ利用時)Privacy Mode を無効化 + +1 対 1 チャットのみ利用する場合はスキップ可能です。Telegram Bot は既定で **Privacy Mode** が有効で、グループ内では `@bot` 接尾辞付きのコマンド(例: `/start@your_bot`)と、Bot メッセージへの返信のみ受信できます。**通常の `@bot こんにちは` のようなテキストメッセージは届きません**。そのままだとグループで反応しないので、必要に応じて以下を設定してください。 + +`@BotFather` に対して: + +1. `/setprivacy` を送信 +2. 作成した Bot を選択 +3. `Disable` を選択 + + + 設定後もグループで反応しない場合は、Bot を一度グループから外して再度追加してみてください。 + + +### ステップ 3: CowAgent に接続 + + + + Web コンソール(既定 `http://127.0.0.1:9899`)を開き、**チャネル** メニュー → **チャネルを追加** → **Telegram** を選択し、Bot Token を貼り付けて接続をクリックします。 + + + `config.json` に以下を追加して Cow を起動します: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | パラメータ | 説明 | 既定値 | + | --- | --- | --- | + | `telegram_token` | BotFather から発行された HTTP API Token | - | + | `telegram_group_trigger` | グループのトリガー方式: `mention_or_reply`(@ または返信)/ `mention_only`(@ のみ)/ `all`(全メッセージ) | `mention_or_reply` | + | `telegram_register_commands` | 起動時に BotFather にコマンドメニューを登録するかどうか | `true` | + + + +ログに以下のような出力が表示されれば接続成功です: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 2. 機能 + +| 機能 | 対応状況 | +| --- | --- | +| 1 対 1 チャット | ✅ | +| グループチャット(@bot / Bot への返信) | ✅ | +| テキストメッセージ | ✅ 送受信 | +| 画像メッセージ | ✅ 送受信 | +| 音声メッセージ | ✅ 送受信(OGG/Opus) | +| 動画メッセージ | ✅ 送受信 | +| ファイルメッセージ | ✅ 送受信(PDF / Word / Excel など) | +| コマンドメニュー | ✅ Web コンソールの slash コマンドと一致 | + +### コマンドメニュー + +起動時に BotFather へコマンドメニューを自動登録します。Telegram の入力欄で `/` を入力するとサジェストが表示されます: + +| コマンド | 説明 | +| --- | --- | +| `/help` | コマンドヘルプを表示 | +| `/status` | 実行ステータスを確認 | +| `/context` | 対話コンテキストを表示(`/context clear` でクリア) | +| `/skill` | スキル管理(`/skill list`、`/skill install` など) | +| `/memory` | 記憶管理(`/memory dream`) | +| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) | +| `/config` | 現在の設定を表示 | +| `/cancel` | 実行中の Agent タスクを中断 | +| `/logs` | 最近のログを表示 | +| `/version` | バージョンを表示 | + + + Telegram のコマンドメニューはトップレベルのコマンドのみ表示されます。サブコマンドはスペース区切りで入力します(例: `/skill list`、`/context clear`)。 + + +## 3. 使い方 + +接続が完了したら: + +- **1 対 1 チャット**: Telegram で Bot のユーザー名(例: `@my_cowagent_bot`)を検索し、`Start` をタップして会話を開始します。 +- **グループチャット**: Bot をグループに追加し、`@bot こんにちは` または **Bot のメッセージに返信** することで起動します。グループで反応しない場合は [ステップ 2](#ステップ-2-グループ利用時-privacy-mode-を無効化) の Privacy Mode 設定を確認してください。 + +画像やファイルを送るときは、添付欄の上の入力欄に **キャプション**(説明・質問)を直接書いて一緒に送信できます。Bot は添付ファイルとキャプションを合わせて回答します。先に添付を送り、その後に質問を送る形でも、2 つのメッセージは自動でまとめて処理されます。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 095e9194..79bcb769 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -9,7 +9,7 @@ **CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆,与你一同成长,是 Agent Harness 工程的最佳实践之一。 -CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、网页等多渠道,7×24 运行于个人电脑或服务器中。 +CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、网页等多渠道,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、公众号 等多个渠道 | +| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram 等多个渠道 | | 多模态 | 文本、图片、语音、文件全消息类型支持,覆盖识别、生成、收发 | | [模型](https://docs.cowagent.ai/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流厂商,配置一行切换 | | [部署](https://docs.cowagent.ai/guide/quick-start) | 一键脚本安装,Web 控制台统一管理;本地、Docker、服务器多种部署方式 | @@ -126,6 +126,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [企业微信应用](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | > 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/channels)。 diff --git a/requirements.txt b/requirements.txt index 706d9894..b6592d70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,5 @@ dingtalk_stream # wecom bot websocket mode websocket-client>=1.4.0 pycryptodome +# telegram bot (requires Python >= 3.9; skipped on older versions) +python-telegram-bot; python_version >= "3.9"