From 005a0e1bad78a12deac75eab512e18a5df53e72d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 17 Mar 2026 15:43:04 +0800 Subject: [PATCH 1/2] feat: add qq channel --- app.py | 1 + channel/channel_factory.py | 3 + channel/qq/__init__.py | 0 channel/qq/qq_channel.py | 710 +++++++++++++++++++++++++++++++++++++ channel/qq/qq_message.py | 123 +++++++ common/const.py | 1 + docs/channels/wecom.mdx | 8 + docs/en/channels/wecom.mdx | 8 + 8 files changed, 854 insertions(+) create mode 100644 channel/qq/__init__.py create mode 100644 channel/qq/qq_channel.py create mode 100644 channel/qq/qq_message.py diff --git a/app.py b/app.py index b26502f..b09e2f0 100644 --- a/app.py +++ b/app.py @@ -228,6 +228,7 @@ def _clear_singleton_cache(channel_name: str): const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel", const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel", const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel", + const.QQ: "channel.qq.qq_channel.QQChannel", } module_path = cls_map.get(channel_name) if not module_path: diff --git a/channel/channel_factory.py b/channel/channel_factory.py index 3c3a6e8..3ee52e4 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -36,6 +36,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == const.WECOM_BOT: from channel.wecom_bot.wecom_bot_channel import WecomBotChannel ch = WecomBotChannel() + elif channel_type == const.QQ: + from channel.qq.qq_channel import QQChannel + ch = QQChannel() else: raise RuntimeError ch.channel_type = channel_type diff --git a/channel/qq/__init__.py b/channel/qq/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/channel/qq/qq_channel.py b/channel/qq/qq_channel.py new file mode 100644 index 0000000..e88138b --- /dev/null +++ b/channel/qq/qq_channel.py @@ -0,0 +1,710 @@ +""" +QQ Bot channel via WebSocket long connection. + +Supports: +- Group chat (@bot), single chat (C2C), guild channel, guild DM +- Text / image / file message send & receive +- Heartbeat keep-alive and auto-reconnect with session resume +""" + +import base64 +import json +import os +import threading +import time + +import requests +import websocket + +from bridge.context import Context, ContextType +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel, check_prefix +from channel.qq.qq_message import QQMessage +from common.expired_dict import ExpiredDict +from common.log import logger +from common.singleton import singleton +from config import conf + +# Rich media file_type constants +QQ_FILE_TYPE_IMAGE = 1 +QQ_FILE_TYPE_VIDEO = 2 +QQ_FILE_TYPE_VOICE = 3 +QQ_FILE_TYPE_FILE = 4 + +QQ_API_BASE = "https://api.sgroup.qq.com" + +# Intents: GROUP_AND_C2C_EVENT(1<<25) | PUBLIC_GUILD_MESSAGES(1<<30) +DEFAULT_INTENTS = (1 << 25) | (1 << 30) + +# OpCode constants +OP_DISPATCH = 0 +OP_HEARTBEAT = 1 +OP_IDENTIFY = 2 +OP_RESUME = 6 +OP_RECONNECT = 7 +OP_INVALID_SESSION = 9 +OP_HELLO = 10 +OP_HEARTBEAT_ACK = 11 + +# Resumable error codes +RESUMABLE_CLOSE_CODES = {4008, 4009} + + +@singleton +class QQChannel(ChatChannel): + + def __init__(self): + super().__init__() + self.app_id = "" + self.app_secret = "" + + self._access_token = "" + self._token_expires_at = 0 + + self._ws = None + self._ws_thread = None + self._heartbeat_thread = None + self._connected = False + self._stop_event = threading.Event() + self._token_lock = threading.Lock() + + self._session_id = None + self._last_seq = None + self._heartbeat_interval = 45000 + self._can_resume = False + + self.received_msgs = ExpiredDict(60 * 60 * 7.1) + self._msg_seq_counter = {} + + conf()["group_name_white_list"] = ["ALL_GROUP"] + conf()["single_chat_prefix"] = [""] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def startup(self): + self.app_id = conf().get("qq_app_id", "") + self.app_secret = conf().get("qq_app_secret", "") + + if not self.app_id or not self.app_secret: + err = "[QQ] qq_app_id and qq_app_secret are required" + logger.error(err) + self.report_startup_error(err) + return + + self._refresh_access_token() + if not self._access_token: + err = "[QQ] Failed to get initial access_token" + logger.error(err) + self.report_startup_error(err) + return + + self._stop_event.clear() + self._start_ws() + + def stop(self): + logger.info("[QQ] stop() called") + self._stop_event.set() + if self._ws: + try: + self._ws.close() + except Exception: + pass + self._ws = None + self._connected = False + + # ------------------------------------------------------------------ + # Access Token + # ------------------------------------------------------------------ + + def _refresh_access_token(self): + try: + resp = requests.post( + "https://bots.qq.com/app/getAppAccessToken", + json={"appId": self.app_id, "clientSecret": self.app_secret}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + self._access_token = data.get("access_token", "") + expires_in = int(data.get("expires_in", 7200)) + self._token_expires_at = time.time() + expires_in - 60 + logger.info(f"[QQ] Access token refreshed, expires_in={expires_in}s") + except Exception as e: + logger.error(f"[QQ] Failed to refresh access_token: {e}") + + def _get_access_token(self) -> str: + with self._token_lock: + if time.time() >= self._token_expires_at: + self._refresh_access_token() + return self._access_token + + def _get_auth_headers(self) -> dict: + return { + "Authorization": f"QQBot {self._get_access_token()}", + "Content-Type": "application/json", + } + + # ------------------------------------------------------------------ + # WebSocket connection + # ------------------------------------------------------------------ + + def _get_ws_url(self) -> str: + try: + resp = requests.get( + f"{QQ_API_BASE}/gateway", + headers=self._get_auth_headers(), + timeout=10, + ) + resp.raise_for_status() + url = resp.json().get("url", "") + logger.info(f"[QQ] Gateway URL: {url}") + return url + except Exception as e: + logger.error(f"[QQ] Failed to get gateway URL: {e}") + return "" + + def _start_ws(self): + ws_url = self._get_ws_url() + if not ws_url: + logger.error("[QQ] Cannot start WebSocket without gateway URL") + self.report_startup_error("Failed to get gateway URL") + return + + def _on_open(ws): + logger.info("[QQ] WebSocket connected, waiting for Hello...") + + def _on_message(ws, raw): + try: + data = json.loads(raw) + self._handle_ws_message(data) + except Exception as e: + logger.error(f"[QQ] Failed to handle ws message: {e}", exc_info=True) + + def _on_error(ws, error): + logger.error(f"[QQ] WebSocket error: {error}") + + def _on_close(ws, close_status_code, close_msg): + logger.warning(f"[QQ] WebSocket closed: status={close_status_code}, msg={close_msg}") + self._connected = False + if not self._stop_event.is_set(): + if close_status_code in RESUMABLE_CLOSE_CODES and self._session_id: + self._can_resume = True + logger.info("[QQ] Will attempt resume in 3s...") + time.sleep(3) + else: + self._can_resume = False + logger.info("[QQ] Will reconnect in 5s...") + time.sleep(5) + if not self._stop_event.is_set(): + self._start_ws() + + self._ws = websocket.WebSocketApp( + ws_url, + on_open=_on_open, + on_message=_on_message, + on_error=_on_error, + on_close=_on_close, + ) + + def run_forever(): + try: + self._ws.run_forever(ping_interval=0, reconnect=0) + except (SystemExit, KeyboardInterrupt): + logger.info("[QQ] WebSocket thread interrupted") + except Exception as e: + logger.error(f"[QQ] WebSocket run_forever error: {e}") + + self._ws_thread = threading.Thread(target=run_forever, daemon=True) + self._ws_thread.start() + self._ws_thread.join() + + def _ws_send(self, data: dict): + if self._ws: + self._ws.send(json.dumps(data, ensure_ascii=False)) + + # ------------------------------------------------------------------ + # Identify & Resume & Heartbeat + # ------------------------------------------------------------------ + + def _send_identify(self): + self._ws_send({ + "op": OP_IDENTIFY, + "d": { + "token": f"QQBot {self._get_access_token()}", + "intents": DEFAULT_INTENTS, + "shard": [0, 1], + "properties": { + "$os": "linux", + "$browser": "chatgpt-on-wechat", + "$device": "chatgpt-on-wechat", + }, + }, + }) + logger.info(f"[QQ] Identify sent with intents={DEFAULT_INTENTS}") + + def _send_resume(self): + self._ws_send({ + "op": OP_RESUME, + "d": { + "token": f"QQBot {self._get_access_token()}", + "session_id": self._session_id, + "seq": self._last_seq, + }, + }) + logger.info(f"[QQ] Resume sent: session_id={self._session_id}, seq={self._last_seq}") + + def _start_heartbeat(self, interval_ms: int): + if self._heartbeat_thread and self._heartbeat_thread.is_alive(): + return + self._heartbeat_interval = interval_ms + interval_sec = interval_ms / 1000.0 + + def heartbeat_loop(): + while not self._stop_event.is_set() and self._connected: + try: + self._ws_send({ + "op": OP_HEARTBEAT, + "d": self._last_seq, + }) + except Exception as e: + logger.warning(f"[QQ] Heartbeat send failed: {e}") + break + self._stop_event.wait(interval_sec) + + self._heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True) + self._heartbeat_thread.start() + + # ------------------------------------------------------------------ + # Incoming message dispatch + # ------------------------------------------------------------------ + + def _handle_ws_message(self, data: dict): + op = data.get("op") + d = data.get("d") + t = data.get("t") + s = data.get("s") + + if s is not None: + self._last_seq = s + + if op == OP_HELLO: + heartbeat_interval = d.get("heartbeat_interval", 45000) if d else 45000 + logger.info(f"[QQ] Received Hello, heartbeat_interval={heartbeat_interval}ms") + self._heartbeat_interval = heartbeat_interval + if self._can_resume and self._session_id: + self._send_resume() + else: + self._send_identify() + + elif op == OP_HEARTBEAT_ACK: + logger.debug("[QQ] Heartbeat ACK received") + + elif op == OP_HEARTBEAT: + self._ws_send({"op": OP_HEARTBEAT, "d": self._last_seq}) + + elif op == OP_RECONNECT: + logger.warning("[QQ] Server requested reconnect") + self._can_resume = True + if self._ws: + self._ws.close() + + elif op == OP_INVALID_SESSION: + logger.warning("[QQ] Invalid session, re-identifying...") + self._session_id = None + self._can_resume = False + time.sleep(2) + self._send_identify() + + elif op == OP_DISPATCH: + if t == "READY": + self._session_id = d.get("session_id", "") + user = d.get("user", {}) + logger.info(f"[QQ] Ready: session_id={self._session_id}, " + f"bot={user.get('username', '')}") + self._connected = True + self._can_resume = False + self._start_heartbeat(self._heartbeat_interval) + self.report_startup_success() + + elif t == "RESUMED": + logger.info("[QQ] Session resumed successfully") + self._connected = True + self._can_resume = False + self._start_heartbeat(self._heartbeat_interval) + + elif t in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE", + "AT_MESSAGE_CREATE", "DIRECT_MESSAGE_CREATE"): + self._handle_msg_event(d, t) + + elif t in ("GROUP_ADD_ROBOT", "FRIEND_ADD"): + logger.info(f"[QQ] Event: {t}") + + else: + logger.debug(f"[QQ] Dispatch event: {t}") + + # ------------------------------------------------------------------ + # Message event handling + # ------------------------------------------------------------------ + + def _handle_msg_event(self, event_data: dict, event_type: str): + msg_id = event_data.get("id", "") + if self.received_msgs.get(msg_id): + logger.debug(f"[QQ] Duplicate msg filtered: {msg_id}") + return + self.received_msgs[msg_id] = True + + try: + qq_msg = QQMessage(event_data, event_type) + except NotImplementedError as e: + logger.warning(f"[QQ] {e}") + return + except Exception as e: + logger.error(f"[QQ] Failed to parse message: {e}", exc_info=True) + return + + is_group = qq_msg.is_group + + from channel.file_cache import get_file_cache + file_cache = get_file_cache() + + if is_group: + session_id = qq_msg.other_user_id + else: + session_id = qq_msg.from_user_id + + if qq_msg.ctype == ContextType.IMAGE: + if hasattr(qq_msg, "image_path") and qq_msg.image_path: + file_cache.add(session_id, qq_msg.image_path, file_type="image") + logger.info(f"[QQ] Image cached for session {session_id}") + return + + if qq_msg.ctype == ContextType.TEXT: + cached_files = file_cache.get(session_id) + if cached_files: + file_refs = [] + for fi in cached_files: + ftype = fi["type"] + fpath = fi["path"] + if ftype == "image": + file_refs.append(f"[图片: {fpath}]") + elif ftype == "video": + file_refs.append(f"[视频: {fpath}]") + else: + file_refs.append(f"[文件: {fpath}]") + qq_msg.content = qq_msg.content + "\n" + "\n".join(file_refs) + logger.info(f"[QQ] Attached {len(cached_files)} cached file(s)") + file_cache.clear(session_id) + + context = self._compose_context( + qq_msg.ctype, + qq_msg.content, + isgroup=is_group, + msg=qq_msg, + no_need_at=True, + ) + if context: + self.produce(context) + + # ------------------------------------------------------------------ + # _compose_context + # ------------------------------------------------------------------ + + 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: + context["session_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.strip() + + return context + + # ------------------------------------------------------------------ + # Send reply + # ------------------------------------------------------------------ + + def send(self, reply: Reply, context: Context): + msg = context.get("msg") + if not msg: + logger.warning("[QQ] No msg in context, cannot send reply") + return + + event_type = getattr(msg, "event_type", "") + msg_id = getattr(msg, "msg_id", "") + + if reply.type == ReplyType.TEXT: + self._send_text(reply.content, msg, event_type, msg_id) + elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE): + self._send_image(reply.content, msg, event_type, msg_id) + elif reply.type == ReplyType.FILE: + if hasattr(reply, "text_content") and reply.text_content: + self._send_text(reply.text_content, msg, event_type, msg_id) + time.sleep(0.3) + self._send_file(reply.content, msg, event_type, msg_id) + elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL): + self._send_media(reply.content, msg, event_type, msg_id, QQ_FILE_TYPE_VIDEO) + else: + logger.warning(f"[QQ] Unsupported reply type: {reply.type}, falling back to text") + self._send_text(str(reply.content), msg, event_type, msg_id) + + # ------------------------------------------------------------------ + # Send helpers + # ------------------------------------------------------------------ + + def _get_next_msg_seq(self, msg_id: str) -> int: + seq = self._msg_seq_counter.get(msg_id, 1) + self._msg_seq_counter[msg_id] = seq + 1 + return seq + + def _build_msg_url_and_base_body(self, msg: QQMessage, event_type: str, msg_id: str): + """Build the API URL and base body dict for sending a message.""" + if event_type == "GROUP_AT_MESSAGE_CREATE": + group_openid = msg._rawmsg.get("group_openid", "") + url = f"{QQ_API_BASE}/v2/groups/{group_openid}/messages" + body = { + "msg_id": msg_id, + "msg_seq": self._get_next_msg_seq(msg_id), + } + return url, body, "group", group_openid + + elif event_type == "C2C_MESSAGE_CREATE": + user_openid = msg._rawmsg.get("author", {}).get("user_openid", "") or msg.from_user_id + url = f"{QQ_API_BASE}/v2/users/{user_openid}/messages" + body = { + "msg_id": msg_id, + "msg_seq": self._get_next_msg_seq(msg_id), + } + return url, body, "c2c", user_openid + + elif event_type == "AT_MESSAGE_CREATE": + channel_id = msg._rawmsg.get("channel_id", "") + url = f"{QQ_API_BASE}/channels/{channel_id}/messages" + body = {"msg_id": msg_id} + return url, body, "channel", channel_id + + elif event_type == "DIRECT_MESSAGE_CREATE": + guild_id = msg._rawmsg.get("guild_id", "") + url = f"{QQ_API_BASE}/dms/{guild_id}/messages" + body = {"msg_id": msg_id} + return url, body, "dm", guild_id + + return None, None, None, None + + def _post_message(self, url: str, body: dict, event_type: str): + try: + resp = requests.post(url, json=body, headers=self._get_auth_headers(), timeout=10) + if resp.status_code in (200, 201, 202, 204): + logger.info(f"[QQ] Message sent successfully: event_type={event_type}") + else: + logger.error(f"[QQ] Failed to send message: status={resp.status_code}, " + f"body={resp.text}") + except Exception as e: + logger.error(f"[QQ] Send message error: {e}") + + # ------------------------------------------------------------------ + # Send text + # ------------------------------------------------------------------ + + def _send_text(self, content: str, msg: QQMessage, event_type: str, msg_id: str): + url, body, _, _ = self._build_msg_url_and_base_body(msg, event_type, msg_id) + if not url: + logger.warning(f"[QQ] Cannot send reply for event_type: {event_type}") + return + body["content"] = content + body["msg_type"] = 0 + self._post_message(url, body, event_type) + + # ------------------------------------------------------------------ + # Rich media upload & send (image / video / file) + # ------------------------------------------------------------------ + + def _upload_rich_media(self, file_url: str, file_type: int, msg: QQMessage, + event_type: str) -> str: + """ + Upload media via QQ rich media API and return file_info. + For group: POST /v2/groups/{group_openid}/files + For c2c: POST /v2/users/{openid}/files + """ + if event_type == "GROUP_AT_MESSAGE_CREATE": + group_openid = msg._rawmsg.get("group_openid", "") + upload_url = f"{QQ_API_BASE}/v2/groups/{group_openid}/files" + elif event_type == "C2C_MESSAGE_CREATE": + user_openid = (msg._rawmsg.get("author", {}).get("user_openid", "") + or msg.from_user_id) + upload_url = f"{QQ_API_BASE}/v2/users/{user_openid}/files" + else: + logger.warning(f"[QQ] Rich media upload not supported for event_type: {event_type}") + return "" + + upload_body = { + "file_type": file_type, + "url": file_url, + "srv_send_msg": False, + } + + try: + resp = requests.post( + upload_url, json=upload_body, + headers=self._get_auth_headers(), timeout=30, + ) + if resp.status_code in (200, 201): + data = resp.json() + file_info = data.get("file_info", "") + logger.info(f"[QQ] Rich media uploaded: file_type={file_type}, " + f"file_uuid={data.get('file_uuid', '')}") + return file_info + else: + logger.error(f"[QQ] Rich media upload failed: status={resp.status_code}, " + f"body={resp.text}") + return "" + except Exception as e: + logger.error(f"[QQ] Rich media upload error: {e}") + return "" + + def _upload_rich_media_base64(self, file_path: str, file_type: int, msg: QQMessage, + event_type: str) -> str: + """Upload local file via base64 file_data field.""" + if event_type == "GROUP_AT_MESSAGE_CREATE": + group_openid = msg._rawmsg.get("group_openid", "") + upload_url = f"{QQ_API_BASE}/v2/groups/{group_openid}/files" + elif event_type == "C2C_MESSAGE_CREATE": + user_openid = (msg._rawmsg.get("author", {}).get("user_openid", "") + or msg.from_user_id) + upload_url = f"{QQ_API_BASE}/v2/users/{user_openid}/files" + else: + logger.warning(f"[QQ] Rich media upload not supported for event_type: {event_type}") + return "" + + try: + with open(file_path, "rb") as f: + file_data = base64.b64encode(f.read()).decode("utf-8") + except Exception as e: + logger.error(f"[QQ] Failed to read file for upload: {e}") + return "" + + upload_body = { + "file_type": file_type, + "file_data": file_data, + "srv_send_msg": False, + } + + try: + resp = requests.post( + upload_url, json=upload_body, + headers=self._get_auth_headers(), timeout=30, + ) + if resp.status_code in (200, 201): + data = resp.json() + file_info = data.get("file_info", "") + logger.info(f"[QQ] Rich media uploaded (base64): file_type={file_type}, " + f"file_uuid={data.get('file_uuid', '')}") + return file_info + else: + logger.error(f"[QQ] Rich media upload (base64) failed: status={resp.status_code}, " + f"body={resp.text}") + return "" + except Exception as e: + logger.error(f"[QQ] Rich media upload (base64) error: {e}") + return "" + + def _send_media_msg(self, file_info: str, msg: QQMessage, event_type: str, msg_id: str): + """Send a message with msg_type=7 (rich media) using file_info.""" + url, body, _, _ = self._build_msg_url_and_base_body(msg, event_type, msg_id) + if not url: + return + body["msg_type"] = 7 + body["media"] = {"file_info": file_info} + self._post_message(url, body, event_type) + + def _send_image(self, img_path_or_url: str, msg: QQMessage, event_type: str, msg_id: str): + """Send image reply. Supports URL and local file path.""" + if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"): + self._send_text(str(img_path_or_url), msg, event_type, msg_id) + return + + if img_path_or_url.startswith("file://"): + img_path_or_url = img_path_or_url[7:] + + if img_path_or_url.startswith(("http://", "https://")): + file_info = self._upload_rich_media( + img_path_or_url, QQ_FILE_TYPE_IMAGE, msg, event_type) + elif os.path.exists(img_path_or_url): + file_info = self._upload_rich_media_base64( + img_path_or_url, QQ_FILE_TYPE_IMAGE, msg, event_type) + else: + logger.error(f"[QQ] Image not found: {img_path_or_url}") + self._send_text("[Image send failed]", msg, event_type, msg_id) + return + + if file_info: + self._send_media_msg(file_info, msg, event_type, msg_id) + else: + self._send_text("[Image upload failed]", msg, event_type, msg_id) + + def _send_file(self, file_path_or_url: str, msg: QQMessage, event_type: str, msg_id: str): + """Send file reply.""" + if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"): + self._send_text(str(file_path_or_url), msg, event_type, msg_id) + return + + if file_path_or_url.startswith("file://"): + file_path_or_url = file_path_or_url[7:] + + if file_path_or_url.startswith(("http://", "https://")): + file_info = self._upload_rich_media( + file_path_or_url, QQ_FILE_TYPE_FILE, msg, event_type) + elif os.path.exists(file_path_or_url): + file_info = self._upload_rich_media_base64( + file_path_or_url, QQ_FILE_TYPE_FILE, msg, event_type) + else: + logger.error(f"[QQ] File not found: {file_path_or_url}") + self._send_text("[File send failed]", msg, event_type, msg_id) + return + + if file_info: + self._send_media_msg(file_info, msg, event_type, msg_id) + else: + self._send_text("[File upload failed]", msg, event_type, msg_id) + + def _send_media(self, path_or_url: str, msg: QQMessage, event_type: str, + msg_id: str, file_type: int): + """Generic media send for video/voice etc.""" + if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"): + self._send_text(str(path_or_url), msg, event_type, msg_id) + return + + if path_or_url.startswith("file://"): + path_or_url = path_or_url[7:] + + if path_or_url.startswith(("http://", "https://")): + file_info = self._upload_rich_media(path_or_url, file_type, msg, event_type) + elif os.path.exists(path_or_url): + file_info = self._upload_rich_media_base64(path_or_url, file_type, msg, event_type) + else: + logger.error(f"[QQ] Media not found: {path_or_url}") + return + + if file_info: + self._send_media_msg(file_info, msg, event_type, msg_id) + else: + logger.error(f"[QQ] Media upload failed: {path_or_url}") diff --git a/channel/qq/qq_message.py b/channel/qq/qq_message.py new file mode 100644 index 0000000..6e9b49e --- /dev/null +++ b/channel/qq/qq_message.py @@ -0,0 +1,123 @@ +import os +import requests + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.log import logger +from common.utils import expand_path +from config import conf + + +def _get_tmp_dir() -> str: + """Return the workspace tmp directory (absolute path), creating it if needed.""" + ws_root = expand_path(conf().get("agent_workspace", "~/cow")) + tmp_dir = os.path.join(ws_root, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + return tmp_dir + + +class QQMessage(ChatMessage): + """Message wrapper for QQ Bot (websocket long-connection mode).""" + + def __init__(self, event_data: dict, event_type: str): + super().__init__(event_data) + self.msg_id = event_data.get("id", "") + self.create_time = event_data.get("timestamp", "") + self.is_group = event_type in ("GROUP_AT_MESSAGE_CREATE",) + self.event_type = event_type + + author = event_data.get("author", {}) + from_user_id = author.get("member_openid", "") or author.get("id", "") + group_openid = event_data.get("group_openid", "") + + content = event_data.get("content", "").strip() + + attachments = event_data.get("attachments", []) + has_image = any( + a.get("content_type", "").startswith("image/") for a in attachments + ) if attachments else False + + if has_image and not content: + self.ctype = ContextType.IMAGE + img_attachment = next( + a for a in attachments if a.get("content_type", "").startswith("image/") + ) + img_url = img_attachment.get("url", "") + if img_url and not img_url.startswith("http"): + img_url = "https://" + img_url + tmp_dir = _get_tmp_dir() + image_path = os.path.join(tmp_dir, f"qq_{self.msg_id}.png") + try: + resp = requests.get(img_url, timeout=30) + resp.raise_for_status() + with open(image_path, "wb") as f: + f.write(resp.content) + self.content = image_path + self.image_path = image_path + logger.info(f"[QQ] Image downloaded: {image_path}") + except Exception as e: + logger.error(f"[QQ] Failed to download image: {e}") + self.content = "[Image download failed]" + self.image_path = None + elif has_image and content: + self.ctype = ContextType.TEXT + image_paths = [] + tmp_dir = _get_tmp_dir() + for idx, att in enumerate(attachments): + if not att.get("content_type", "").startswith("image/"): + continue + img_url = att.get("url", "") + if img_url and not img_url.startswith("http"): + img_url = "https://" + img_url + img_path = os.path.join(tmp_dir, f"qq_{self.msg_id}_{idx}.png") + try: + resp = requests.get(img_url, timeout=30) + resp.raise_for_status() + with open(img_path, "wb") as f: + f.write(resp.content) + image_paths.append(img_path) + except Exception as e: + logger.error(f"[QQ] Failed to download mixed image: {e}") + content_parts = [content] + for p in image_paths: + content_parts.append(f"[图片: {p}]") + self.content = "\n".join(content_parts) + else: + self.ctype = ContextType.TEXT + self.content = content + + if event_type == "GROUP_AT_MESSAGE_CREATE": + self.from_user_id = from_user_id + self.to_user_id = "" + self.other_user_id = group_openid + self.actual_user_id = from_user_id + self.actual_user_nickname = from_user_id + + elif event_type == "C2C_MESSAGE_CREATE": + user_openid = author.get("user_openid", "") or from_user_id + self.from_user_id = user_openid + self.to_user_id = "" + self.other_user_id = user_openid + self.actual_user_id = user_openid + + elif event_type == "AT_MESSAGE_CREATE": + self.from_user_id = from_user_id + self.to_user_id = "" + channel_id = event_data.get("channel_id", "") + self.other_user_id = channel_id + self.actual_user_id = from_user_id + self.actual_user_nickname = author.get("username", from_user_id) + + elif event_type == "DIRECT_MESSAGE_CREATE": + self.from_user_id = from_user_id + self.to_user_id = "" + guild_id = event_data.get("guild_id", "") + self.other_user_id = f"dm_{guild_id}_{from_user_id}" + self.actual_user_id = from_user_id + self.actual_user_nickname = author.get("username", from_user_id) + + else: + raise NotImplementedError(f"Unsupported QQ event type: {event_type}") + + logger.debug(f"[QQ] Message parsed: type={event_type}, ctype={self.ctype}, " + f"from={self.from_user_id}, content_len={len(self.content)}") diff --git a/common/const.py b/common/const.py index 507b246..02833ce 100644 --- a/common/const.py +++ b/common/const.py @@ -187,3 +187,4 @@ MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST FEISHU = "feishu" DINGTALK = "dingtalk" WECOM_BOT = "wecom_bot" +QQ = "qq" diff --git a/docs/channels/wecom.mdx b/docs/channels/wecom.mdx index 0f57d51..e0ed6fb 100644 --- a/docs/channels/wecom.mdx +++ b/docs/channels/wecom.mdx @@ -88,3 +88,11 @@ description: 将 CowAgent 接入企业微信自建应用 如需让外部个人微信用户使用,可在 **我的企业 → 微信插件** 中分享邀请关注二维码,个人微信扫码关注后即可与应用对话: + +## 常见问题 + +需要确保已安装以下依赖: + +```bash +pip install websocket-client pycryptodome +``` diff --git a/docs/en/channels/wecom.mdx b/docs/en/channels/wecom.mdx index 7b07cd0..e0aca17 100644 --- a/docs/en/channels/wecom.mdx +++ b/docs/en/channels/wecom.mdx @@ -88,3 +88,11 @@ Search for the app name you just created in WeCom to start chatting directly. Yo To allow external personal WeChat users to use the app, go to **My Enterprise → WeChat Plugin**, share the invite QR code. After scanning and following, personal WeChat users can join and chat with the app: + +## FAQ + +Make sure the following dependencies are installed: + +```bash +pip install websocket-client pycryptodome +``` From a4d54f58c8f65d3421a4ee6d7164a959f1ab37f7 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 17 Mar 2026 17:25:36 +0800 Subject: [PATCH 2/2] feat: complete the QQ channel and supplement the docs --- README.md | 26 +++++-- agent/tools/scheduler/integration.py | 2 + channel/qq/qq_channel.py | 43 ++++++++--- channel/web/web_channel.py | 9 +++ common/cloud_client.py | 8 ++ config.py | 2 + docs/agent.md | 2 + docs/channels/qq.mdx | 88 ++++++++++++++++++++++ docs/channels/wecom-bot.mdx | 2 +- docs/docs.json | 2 + docs/en/channels/qq.mdx | 88 ++++++++++++++++++++++ run.sh | 106 +++++++++++++++++++++++++-- 12 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 docs/channels/qq.mdx create mode 100644 docs/en/channels/qq.mdx diff --git a/README.md b/README.md index 0a162ca..e08d6c9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [中文] | [English]

-**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、企业微信应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。 +**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。

🌐 官网  ·  @@ -143,7 +143,7 @@ pip3 install -r requirements-optional.txt ```bash # config.json 文件内容示例 { - "channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,wechatcom_app,wechatmp_service,wechatmp,terminal + "channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal "model": "MiniMax-M2.5", # 模型名称 "minimax_api_key": "", # MiniMax API Key "zhipu_ai_api_key": "", # 智谱GLM API Key @@ -702,7 +702,23 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)

-5. WeCom App - 企业微信应用 +5. QQ - QQ 机器人 + +QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支持 QQ 单聊、群聊和频道消息: + +```json +{ + "channel_type": "qq", + "qq_app_id": "YOUR_APP_ID", + "qq_app_secret": "YOUR_APP_SECRET" +} +``` +详细步骤和参数说明参考 [QQ 机器人接入](https://docs.cowagent.ai/channels/qq) + +
+ +
+6. WeCom App - 企业微信应用 企业微信自建应用接入需在后台创建应用并启用消息回调,配置示例: @@ -722,7 +738,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
-6. WeChat MP - 微信公众号 +7. WeChat MP - 微信公众号 本项目支持订阅号和服务号两种公众号,通过服务号(`wechatmp_service`)体验更佳。 @@ -757,7 +773,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
-7. Terminal - 终端 +8. Terminal - 终端 修改 `config.json` 中的 `channel_type` 字段: diff --git a/agent/tools/scheduler/integration.py b/agent/tools/scheduler/integration.py index 7d43236..949c9ff 100644 --- a/agent/tools/scheduler/integration.py +++ b/agent/tools/scheduler/integration.py @@ -237,6 +237,8 @@ def _execute_send_message(task: dict, agent_bridge): logger.warning(f"[Scheduler] Task {task['id']}: DingTalk single chat message missing sender_staff_id") elif channel_type == "wecom_bot": context["msg"] = None + elif channel_type == "qq": + context["msg"] = None # Create reply reply = Reply(ReplyType.TEXT, content) diff --git a/channel/qq/qq_channel.py b/channel/qq/qq_channel.py index e88138b..d3a1e0f 100644 --- a/channel/qq/qq_channel.py +++ b/channel/qq/qq_channel.py @@ -130,7 +130,7 @@ class QQChannel(ChatChannel): self._access_token = data.get("access_token", "") expires_in = int(data.get("expires_in", 7200)) self._token_expires_at = time.time() + expires_in - 60 - logger.info(f"[QQ] Access token refreshed, expires_in={expires_in}s") + logger.debug(f"[QQ] Access token refreshed, expires_in={expires_in}s") except Exception as e: logger.error(f"[QQ] Failed to refresh access_token: {e}") @@ -159,7 +159,7 @@ class QQChannel(ChatChannel): ) resp.raise_for_status() url = resp.json().get("url", "") - logger.info(f"[QQ] Gateway URL: {url}") + logger.debug(f"[QQ] Gateway URL: {url}") return url except Exception as e: logger.error(f"[QQ] Failed to get gateway URL: {e}") @@ -173,7 +173,7 @@ class QQChannel(ChatChannel): return def _on_open(ws): - logger.info("[QQ] WebSocket connected, waiting for Hello...") + logger.debug("[QQ] WebSocket connected, waiting for Hello...") def _on_message(ws, raw): try: @@ -242,7 +242,7 @@ class QQChannel(ChatChannel): }, }, }) - logger.info(f"[QQ] Identify sent with intents={DEFAULT_INTENTS}") + logger.debug(f"[QQ] Identify sent with intents={DEFAULT_INTENTS}") def _send_resume(self): self._ws_send({ @@ -253,7 +253,7 @@ class QQChannel(ChatChannel): "seq": self._last_seq, }, }) - logger.info(f"[QQ] Resume sent: session_id={self._session_id}, seq={self._last_seq}") + logger.debug(f"[QQ] Resume sent: session_id={self._session_id}, seq={self._last_seq}") def _start_heartbeat(self, interval_ms: int): if self._heartbeat_thread and self._heartbeat_thread.is_alive(): @@ -291,7 +291,7 @@ class QQChannel(ChatChannel): if op == OP_HELLO: heartbeat_interval = d.get("heartbeat_interval", 45000) if d else 45000 - logger.info(f"[QQ] Received Hello, heartbeat_interval={heartbeat_interval}ms") + logger.debug(f"[QQ] Received Hello, heartbeat_interval={heartbeat_interval}ms") self._heartbeat_interval = heartbeat_interval if self._can_resume and self._session_id: self._send_resume() @@ -321,8 +321,8 @@ class QQChannel(ChatChannel): if t == "READY": self._session_id = d.get("session_id", "") user = d.get("user", {}) - logger.info(f"[QQ] Ready: session_id={self._session_id}, " - f"bot={user.get('username', '')}") + bot_name = user.get('username', '') + logger.info(f"[QQ] ✅ Connected successfully (bot={bot_name})") self._connected = True self._can_resume = False self._start_heartbeat(self._heartbeat_interval) @@ -445,8 +445,13 @@ class QQChannel(ChatChannel): def send(self, reply: Reply, context: Context): msg = context.get("msg") + is_group = context.get("isgroup", False) + receiver = context.get("receiver", "") + if not msg: - logger.warning("[QQ] No msg in context, cannot send reply") + # Active send (e.g. scheduled tasks), no original message to reply to + self._active_send_text(reply.content if reply.type == ReplyType.TEXT else str(reply.content), + receiver, is_group) return event_type = getattr(msg, "event_type", "") @@ -521,6 +526,26 @@ class QQChannel(ChatChannel): except Exception as e: logger.error(f"[QQ] Send message error: {e}") + # ------------------------------------------------------------------ + # Active send (no original message, e.g. scheduled tasks) + # ------------------------------------------------------------------ + + def _active_send_text(self, content: str, receiver: str, is_group: bool): + """Send text without an original message (active push). QQ limits active messages to 4/month per user.""" + if not receiver: + logger.warning("[QQ] No receiver for active send") + return + if is_group: + url = f"{QQ_API_BASE}/v2/groups/{receiver}/messages" + else: + url = f"{QQ_API_BASE}/v2/users/{receiver}/messages" + body = { + "content": content, + "msg_type": 0, + } + event_label = "GROUP_ACTIVE" if is_group else "C2C_ACTIVE" + self._post_message(url, body, event_label) + # ------------------------------------------------------------------ # Send text # ------------------------------------------------------------------ diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index d980df9..6327a79 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -615,6 +615,15 @@ class ChannelsHandler: {"key": "wecom_bot_secret", "label": "Secret", "type": "secret"}, ], }), + ("qq", { + "label": {"zh": "QQ 机器人", "en": "QQ Bot"}, + "icon": "fa-comment", + "color": "blue", + "fields": [ + {"key": "qq_app_id", "label": "App ID", "type": "text"}, + {"key": "qq_app_secret", "label": "App Secret", "type": "secret"}, + ], + }), ("wechatcom_app", { "label": {"zh": "企微自建应用", "en": "WeCom App"}, "icon": "fa-building", diff --git a/common/cloud_client.py b/common/cloud_client.py index 7e21228..6ad1bcb 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -26,6 +26,8 @@ CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"} CREDENTIAL_MAP = { "feishu": ("feishu_app_id", "feishu_app_secret"), "dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"), + "wecom_bot": ("wecom_bot_id", "wecom_bot_secret"), + "qq": ("qq_app_id", "qq_app_secret"), "wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"), "wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"), "wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"), @@ -669,6 +671,12 @@ def _build_config(): elif current_channel_type in ("wechatmp", "wechatmp_service"): config["app_id"] = local_conf.get("wechatmp_app_id") config["app_secret"] = local_conf.get("wechatmp_app_secret") + elif current_channel_type == "wecom_bot": + config["app_id"] = local_conf.get("wecom_bot_id") + config["app_secret"] = local_conf.get("wecom_bot_secret") + elif current_channel_type == "qq": + config["app_id"] = local_conf.get("qq_app_id") + config["app_secret"] = local_conf.get("qq_app_secret") elif current_channel_type == "wechatcom_app": config["app_id"] = local_conf.get("wechatcomapp_agent_id") config["app_secret"] = local_conf.get("wechatcomapp_secret") diff --git a/config.py b/config.py index 757d7f5..93e3d6e 100644 --- a/config.py +++ b/config.py @@ -381,6 +381,8 @@ def load_config(): "wechatmp_app_secret": "WECHATMP_APP_SECRET", "wechatcomapp_agent_id": "WECHATCOMAPP_AGENT_ID", "wechatcomapp_secret": "WECHATCOMAPP_SECRET", + "qq_app_id": "QQ_APP_ID", + "qq_app_secret": "QQ_APP_SECRET" } injected = 0 for conf_key, env_key in _CONFIG_TO_ENV.items(): diff --git a/docs/agent.md b/docs/agent.md index 34c889b..9fcdbe4 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -179,5 +179,7 @@ Agent支持在多种渠道中使用,只需修改 `config.json` 中的 `channel - **飞书接入**:[飞书接入文档](https://docs.link-ai.tech/cow/multi-platform/feishu) - **钉钉接入**:[钉钉接入文档](https://docs.link-ai.tech/cow/multi-platform/dingtalk) - **企业微信应用接入**:[企微应用文档](https://docs.link-ai.tech/cow/multi-platform/wechat-com) +- **企微智能机器人**:[企微智能机器人文档](https://docs.link-ai.tech/cow/multi-platform/wecom-bot) +- **QQ机器人**:[QQ机器人文档](https://docs.link-ai.tech/cow/multi-platform/qq) 更多渠道配置参考:[通道说明](../README.md#通道说明) diff --git a/docs/channels/qq.mdx b/docs/channels/qq.mdx new file mode 100644 index 0000000..3b7554a --- /dev/null +++ b/docs/channels/qq.mdx @@ -0,0 +1,88 @@ +--- +title: QQ 机器人 +description: 将 CowAgent 接入 QQ 机器人(WebSocket 长连接模式) +--- + +> 通过 QQ 开放平台的机器人接口接入 CowAgent,支持 QQ 单聊、QQ 群聊(@机器人)、频道消息和频道私信,无需公网 IP,使用 WebSocket 长连接模式。 + + + QQ 机器人通过 QQ 开放平台创建,使用 WebSocket 长连接接收消息,通过 OpenAPI 发送消息,无需公网 IP 和域名。 + + +## 一、创建 QQ 机器人 + +> 进入[QQ 开放平台](https://q.qq.com),QQ扫码登录,如果未注册开放平台账号,请先完成[账号注册](https://q.qq.com/#/register)。 + +1.在 [QQ开放平台-机器人列表页](https://q.qq.com/#/apps),点击创建机器人: + + + +2.填写机器人名称、头像等基本信息,完成创建: + + + +3.点击进入机器人配置页面,选择**开发管理**菜单,完成以下步骤: + + - 复制并记录 **AppID**(机器人ID) + - 生成并记录 **AppSecret**(机器人秘钥) + + + +## 二、配置和运行 + +### 方式一:Web 控制台接入 + +启动 Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **QQ 机器人**,填写上一步保存的 AppID 和 AppSecret,点击接入即可。 + + + +### 方式二:配置文件接入 + +在 `config.json` 中添加以下配置: + +```json +{ + "channel_type": "qq", + "qq_app_id": "YOUR_APP_ID", + "qq_app_secret": "YOUR_APP_SECRET" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `qq_app_id` | QQ 机器人的 AppID,在开放平台开发管理中获取 | +| `qq_app_secret` | QQ 机器人的 AppSecret,在开放平台开发管理中获取 | + +配置完成后启动程序,日志显示 `[QQ] ✅ Connected successfully` 即表示连接成功。 + + +## 三、使用 + +在 QQ开放平台 - 管理 - **使用范围和人员** 菜单中,使用QQ客户端扫描 "添加到群和消息列表" 的二维码,即可开始与QQ机器人的聊天: + + + +对话效果: + + +## 四、功能说明 + +> 注意:若需在群聊及频道中使用QQ机器人,需完成发布上架审核并在使用范围配置权限使用范围。 + +| 功能 | 支持情况 | +| --- | --- | +| QQ 单聊 | ✅ | +| QQ 群聊(@机器人) | ✅ | +| 频道消息(@机器人) | ✅ | +| 频道私信 | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发(群聊和单聊) | +| 文件消息 | ✅ 发送(群聊和单聊) | +| 定时任务 | ✅ 主动推送(每月每用户限 4 条) | + + +## 五、注意事项 + +- **被动消息限制**:QQ 单聊被动消息有效期为 60 分钟,每条消息最多回复 5 次;QQ 群聊被动消息有效期为 5 分钟。 +- **主动消息限制**:单聊和群聊每月主动消息上限为 4 条,在使用定时任务功能时需要注意这个限制 +- **事件权限**:默认订阅 `GROUP_AND_C2C_EVENT`(QQ群/单聊)和 `PUBLIC_GUILD_MESSAGES`(频道公域消息),如需其他事件类型请在开放平台申请权限。 diff --git a/docs/channels/wecom-bot.mdx b/docs/channels/wecom-bot.mdx index 47c46dd..bcdac98 100644 --- a/docs/channels/wecom-bot.mdx +++ b/docs/channels/wecom-bot.mdx @@ -29,7 +29,7 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式) ### 方式一:Web 控制台接入 -启动程序后打开 Web 控制台 (本地连接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **企微智能机器人**,填写上一步保存的 Bot ID 和 Secret,点击接入即可。 +启动Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **企微智能机器人**,填写上一步保存的 Bot ID 和 Secret,点击接入即可。 diff --git a/docs/docs.json b/docs/docs.json index f82f8c7..ebfe877 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -157,6 +157,7 @@ "channels/feishu", "channels/dingtalk", "channels/wecom-bot", + "channels/qq", "channels/wecom", "channels/wechatmp" ] @@ -300,6 +301,7 @@ "en/channels/feishu", "en/channels/dingtalk", "en/channels/wecom-bot", + "en/channels/qq", "en/channels/wecom", "en/channels/wechatmp" ] diff --git a/docs/en/channels/qq.mdx b/docs/en/channels/qq.mdx new file mode 100644 index 0000000..a7f0859 --- /dev/null +++ b/docs/en/channels/qq.mdx @@ -0,0 +1,88 @@ +--- +title: QQ Bot +description: Connect CowAgent to QQ Bot (WebSocket long connection) +--- + +> Connect CowAgent via QQ Open Platform's bot API, supporting QQ direct messages, group chats (@bot), guild channel messages, and guild DMs. No public IP required — uses WebSocket long connection. + + + QQ Bot is created through the QQ Open Platform. It uses WebSocket long connection to receive messages and OpenAPI to send messages. No public IP or domain is required. + + +## 1. Create a QQ Bot + +> Visit the [QQ Open Platform](https://q.qq.com), sign in with QQ. If you haven't registered, please complete [account registration](https://q.qq.com/#/register) first. + +1.Go to the [QQ Open Platform - Bot List](https://q.qq.com/#/apps), and click **Create Bot**: + + + +2.Fill in the bot name, avatar, and other basic information to complete the creation: + + + +3.Enter the bot configuration page, go to **Development Management**, and complete the following steps: + + - Copy and save the **AppID** (Bot ID) + - Generate and save the **AppSecret** (Bot Secret) + + + +## 2. Configuration and Running + +### Option A: Web Console + +Start the program and open the Web console (local access: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **QQ Bot**, fill in the AppID and AppSecret from the previous step, and click Connect. + + + +### Option B: Config File + +Add the following to your `config.json`: + +```json +{ + "channel_type": "qq", + "qq_app_id": "YOUR_APP_ID", + "qq_app_secret": "YOUR_APP_SECRET" +} +``` + +| Parameter | Description | +| --- | --- | +| `qq_app_id` | AppID of the QQ Bot, found in Development Management on the open platform | +| `qq_app_secret` | AppSecret of the QQ Bot, found in Development Management on the open platform | + +After configuration, start the program. The log message `[QQ] ✅ Connected successfully` indicates a successful connection. + + +## 3. Usage + +In the QQ Open Platform, go to **Management → Usage Scope & Members**, scan the "Add to group and message list" QR code with your QQ client to start chatting with the bot: + + + +Chat example: + + +## 4. Supported Features + +> Note: To use the QQ bot in group chats and guild channels, you need to complete the publishing review and configure usage scope permissions. + +| Feature | Status | +| --- | --- | +| QQ Direct Messages | ✅ | +| QQ Group Chat (@bot) | ✅ | +| Guild Channel (@bot) | ✅ | +| Guild DM | ✅ | +| Text Messages | ✅ Send & Receive | +| Image Messages | ✅ Send & Receive (group & direct) | +| File Messages | ✅ Send (group & direct) | +| Scheduled Tasks | ✅ Active push (4 per user per month) | + + +## 5. Notes + +- **Passive message limits**: QQ direct message replies are valid for 60 minutes (max 5 replies per message); group chat replies are valid for 5 minutes. +- **Active message limits**: Both direct and group chats have a monthly limit of 4 active messages. Keep this in mind when using the scheduled tasks feature. +- **Event permissions**: By default, `GROUP_AND_C2C_EVENT` (QQ group/direct) and `PUBLIC_GUILD_MESSAGES` (guild public messages) are subscribed. Apply for additional permissions on the open platform if needed. diff --git a/run.sh b/run.sh index da34abc..34ac2bf 100644 --- a/run.sh +++ b/run.sh @@ -409,19 +409,21 @@ select_channel() { echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${YELLOW}1) Feishu (飞书)${NC}" echo -e "${YELLOW}2) DingTalk (钉钉)${NC}" - echo -e "${YELLOW}3) WeCom (企微应用)${NC}" - echo -e "${YELLOW}4) Web (网页)${NC}" + echo -e "${YELLOW}3) WeCom Bot (企微智能机器人)${NC}" + echo -e "${YELLOW}4) QQ (QQ 机器人)${NC}" + echo -e "${YELLOW}5) WeCom App (企微自建应用)${NC}" + echo -e "${YELLOW}6) Web (网页)${NC}" echo "" while true; do read -p "Enter your choice [press Enter for default: 1 - Feishu]: " channel_choice channel_choice=${channel_choice:-1} case "$channel_choice" in - 1|2|3|4) + 1|2|3|4|5|6) break ;; *) - echo -e "${RED}Invalid choice. Please enter 1-4.${NC}" + echo -e "${RED}Invalid choice. Please enter 1-6.${NC}" ;; esac done @@ -456,9 +458,31 @@ configure_channel() { ACCESS_INFO="DingTalk channel configured" ;; 3) - # WeCom + # WeCom Bot + CHANNEL_TYPE="wecom_bot" + echo -e "${GREEN}Configure WeCom Bot...${NC}" + read -p "Enter WeCom Bot ID: " wecom_bot_id + read -p "Enter WeCom Bot Secret: " wecom_bot_secret + + WECOM_BOT_ID="$wecom_bot_id" + WECOM_BOT_SECRET="$wecom_bot_secret" + ACCESS_INFO="WeCom Bot channel configured" + ;; + 4) + # QQ + CHANNEL_TYPE="qq" + echo -e "${GREEN}Configure QQ Bot...${NC}" + read -p "Enter QQ App ID: " qq_app_id + read -p "Enter QQ App Secret: " qq_app_secret + + QQ_APP_ID="$qq_app_id" + QQ_APP_SECRET="$qq_app_secret" + ACCESS_INFO="QQ Bot channel configured" + ;; + 5) + # WeCom App CHANNEL_TYPE="wechatcom_app" - echo -e "${GREEN}Configure WeCom...${NC}" + echo -e "${GREEN}Configure WeCom App...${NC}" read -p "Enter WeChat Corp ID: " corp_id read -p "Enter WeChat Com App Token: " com_token read -p "Enter WeChat Com App Secret: " com_secret @@ -473,9 +497,9 @@ configure_channel() { WECHATCOM_AGENT_ID="$com_agent_id" WECHATCOM_AES_KEY="$com_aes_key" WECHATCOM_PORT="$com_port" - ACCESS_INFO="WeCom channel configured on port ${com_port}" + ACCESS_INFO="WeCom App channel configured on port ${com_port}" ;; - 4) + 6) # Web CHANNEL_TYPE="web" read -p "Enter web port [press Enter for default: 9899]: " web_port @@ -600,6 +624,72 @@ EOF "agent_max_context_turns": 30, "agent_max_steps": 15 } +EOF + ;; + wecom_bot) + cat > config.json < config.json <