From 635850191519016c78c74297a7053df29bdf543d Mon Sep 17 00:00:00 2001 From: WideLee Date: Wed, 15 Apr 2026 23:46:50 +0800 Subject: [PATCH] refactor(qqbot): split qqbot.py into package & add QR scan-to-configure onboard flow - Refactor gateway/platforms/qqbot.py into gateway/platforms/qqbot/ package: - adapter.py: core QQAdapter (unchanged logic, constants from shared module) - constants.py: shared constants (API URLs, timeouts, message types) - crypto.py: AES-256-GCM key generation and secret decryption - onboard.py: QR-code scan-to-configure API (create_bind_task, poll_bind_result) - utils.py: User-Agent builder, HTTP headers, config helpers - __init__.py: re-exports all public symbols for backward compatibility - Add interactive QR-code setup flow in hermes_cli/gateway.py: - Terminal QR rendering via qrcode package (graceful fallback to URL) - Auto-refresh on QR expiry (up to 3 times) - AES-256-GCM encrypted credential exchange - DM security policy selection (pairing/allowlist/open) - Update hermes_cli/setup.py to delegate to gateway's _setup_qqbot() - Add qrcode>=7.4 dependency to pyproject.toml and requirements.txt --- gateway/config.py | 4 +- gateway/platforms/qqbot/__init__.py | 55 +++++ .../platforms/{qqbot.py => qqbot/adapter.py} | 103 ++++++---- gateway/platforms/qqbot/constants.py | 74 +++++++ gateway/platforms/qqbot/crypto.py | 45 ++++ gateway/platforms/qqbot/onboard.py | 124 +++++++++++ gateway/platforms/qqbot/utils.py | 71 +++++++ hermes_cli/config.py | 6 +- hermes_cli/gateway.py | 193 +++++++++++++++++- hermes_cli/setup.py | 57 +----- hermes_cli/status.py | 2 +- pyproject.toml | 2 + requirements.txt | 1 + uv.lock | 41 +++- .../docs/reference/environment-variables.md | 2 +- website/docs/user-guide/messaging/qqbot.md | 6 +- 16 files changed, 670 insertions(+), 116 deletions(-) create mode 100644 gateway/platforms/qqbot/__init__.py rename gateway/platforms/{qqbot.py => qqbot/adapter.py} (97%) create mode 100644 gateway/platforms/qqbot/constants.py create mode 100644 gateway/platforms/qqbot/crypto.py create mode 100644 gateway/platforms/qqbot/onboard.py create mode 100644 gateway/platforms/qqbot/utils.py diff --git a/gateway/config.py b/gateway/config.py index 799b151b7..d6a196e60 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1229,12 +1229,12 @@ def _apply_env_overrides(config: GatewayConfig) -> None: qq_group_allowed = os.getenv("QQ_GROUP_ALLOWED_USERS", "").strip() if qq_group_allowed: extra["group_allow_from"] = qq_group_allowed - qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip() + qq_home = os.getenv("QQBOT_HOME_CHANNEL", "").strip() if qq_home: config.platforms[Platform.QQBOT].home_channel = HomeChannel( platform=Platform.QQBOT, chat_id=qq_home, - name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"), + name=os.getenv("QQBOT_HOME_CHANNEL_NAME", "Home"), ) # Session settings diff --git a/gateway/platforms/qqbot/__init__.py b/gateway/platforms/qqbot/__init__.py new file mode 100644 index 000000000..4877baa53 --- /dev/null +++ b/gateway/platforms/qqbot/__init__.py @@ -0,0 +1,55 @@ +""" +QQBot platform package. + +Re-exports the main adapter symbols from ``adapter.py`` (the original +``qqbot.py``) so that **all existing import paths remain unchanged**:: + + from gateway.platforms.qqbot import QQAdapter # works + from gateway.platforms.qqbot import check_qq_requirements # works + +New modules: + - ``constants`` — shared constants (API URLs, timeouts, message types) + - ``utils`` — User-Agent builder, config helpers + - ``crypto`` — AES-256-GCM key generation and decryption + - ``onboard`` — QR-code scan-to-configure flow +""" + +# -- Adapter (original qqbot.py) ------------------------------------------ +from .adapter import ( # noqa: F401 + QQAdapter, + QQCloseError, + check_qq_requirements, + _coerce_list, +) + +# -- Onboard (QR-code scan-to-configure) ----------------------------------- +from .onboard import ( # noqa: F401 + BindStatus, + create_bind_task, + poll_bind_result, + build_connect_url, +) +from .crypto import decrypt_secret, generate_bind_key # noqa: F401 + +# -- Utils ----------------------------------------------------------------- +from .utils import build_user_agent, get_api_headers, coerce_list # noqa: F401 + +__all__ = [ + # adapter + "QQAdapter", + "QQCloseError", + "check_qq_requirements", + "_coerce_list", + # onboard + "BindStatus", + "create_bind_task", + "poll_bind_result", + "build_connect_url", + # crypto + "decrypt_secret", + "generate_bind_key", + # utils + "build_user_agent", + "get_api_headers", + "coerce_list", +] diff --git a/gateway/platforms/qqbot.py b/gateway/platforms/qqbot/adapter.py similarity index 97% rename from gateway/platforms/qqbot.py rename to gateway/platforms/qqbot/adapter.py index 32252be12..d41b9a34e 100644 --- a/gateway/platforms/qqbot.py +++ b/gateway/platforms/qqbot/adapter.py @@ -84,38 +84,34 @@ class QQCloseError(Exception): self.reason = str(reason) if reason else "" super().__init__(f"WebSocket closed (code={self.code}, reason={self.reason})") # --------------------------------------------------------------------------- -# Constants +# Constants — imported from the shared constants module. # --------------------------------------------------------------------------- -API_BASE = "https://api.sgroup.qq.com" -TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" -GATEWAY_URL_PATH = "/gateway" - -DEFAULT_API_TIMEOUT = 30.0 -FILE_UPLOAD_TIMEOUT = 120.0 -CONNECT_TIMEOUT_SECONDS = 20.0 - -RECONNECT_BACKOFF = [2, 5, 10, 30, 60] -MAX_RECONNECT_ATTEMPTS = 100 -RATE_LIMIT_DELAY = 60 # seconds -QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds -MAX_QUICK_DISCONNECT_COUNT = 3 - -MAX_MESSAGE_LENGTH = 4000 -DEDUP_WINDOW_SECONDS = 300 -DEDUP_MAX_SIZE = 1000 - -# QQ Bot message types -MSG_TYPE_TEXT = 0 -MSG_TYPE_MARKDOWN = 2 -MSG_TYPE_MEDIA = 7 -MSG_TYPE_INPUT_NOTIFY = 6 - -# QQ Bot file media types -MEDIA_TYPE_IMAGE = 1 -MEDIA_TYPE_VIDEO = 2 -MEDIA_TYPE_VOICE = 3 -MEDIA_TYPE_FILE = 4 +from gateway.platforms.qqbot.constants import ( + API_BASE, + TOKEN_URL, + GATEWAY_URL_PATH, + DEFAULT_API_TIMEOUT, + FILE_UPLOAD_TIMEOUT, + CONNECT_TIMEOUT_SECONDS, + RECONNECT_BACKOFF, + MAX_RECONNECT_ATTEMPTS, + RATE_LIMIT_DELAY, + QUICK_DISCONNECT_THRESHOLD, + MAX_QUICK_DISCONNECT_COUNT, + MAX_MESSAGE_LENGTH, + DEDUP_WINDOW_SECONDS, + DEDUP_MAX_SIZE, + MSG_TYPE_TEXT, + MSG_TYPE_MARKDOWN, + MSG_TYPE_MEDIA, + MSG_TYPE_INPUT_NOTIFY, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, + MEDIA_TYPE_VOICE, + MEDIA_TYPE_FILE, +) +from gateway.platforms.qqbot.utils import coerce_list as _coerce_list_impl, build_user_agent def check_qq_requirements() -> bool: @@ -125,13 +121,7 @@ def check_qq_requirements() -> bool: def _coerce_list(value: Any) -> List[str]: """Coerce config values into a trimmed string list.""" - if value is None: - return [] - if isinstance(value, str): - return [item.strip() for item in value.split(",") if item.strip()] - if isinstance(value, (list, tuple, set)): - return [str(item).strip() for item in value if str(item).strip()] - return [str(value).strip()] if str(value).strip() else [] + return _coerce_list_impl(value) # --------------------------------------------------------------------------- @@ -143,6 +133,9 @@ class QQAdapter(BasePlatformAdapter): # QQ Bot API does not support editing sent messages. SUPPORTS_MESSAGE_EDITING = False + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + _TYPING_INPUT_SECONDS = 60 # input_notify duration reported to QQ + _TYPING_DEBOUNCE_SECONDS = 50 # refresh before it expires def _fail_pending(self, reason: str) -> None: """Fail all pending response futures.""" @@ -151,7 +144,6 @@ class QQAdapter(BasePlatformAdapter): fut.set_exception(RuntimeError(reason)) self._pending_responses.clear() - MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH def __init__(self, config: PlatformConfig): super().__init__(config, Platform.QQBOT) @@ -182,6 +174,11 @@ class QQAdapter(BasePlatformAdapter): self._pending_responses: Dict[str, asyncio.Future] = {} self._seen_messages: Dict[str, float] = {} + # Last inbound message ID per chat — used by send_typing + self._last_msg_id: Dict[str, str] = {} + # Typing debounce: chat_id → last send_typing timestamp + self._typing_sent_at: Dict[str, float] = {} + # Token cache self._access_token: Optional[str] = None self._token_expires_at: float = 0.0 @@ -687,6 +684,12 @@ class QQAdapter(BasePlatformAdapter): # Inbound message handling # ------------------------------------------------------------------ + async def handle_message(self, event: MessageEvent) -> None: + """Cache the last message ID per chat, then delegate to base.""" + if event.message_id and event.source.chat_id: + self._last_msg_id[event.source.chat_id] = event.message_id + await super().handle_message(event) + async def _on_message(self, event_type: str, d: Any) -> None: """Process an inbound QQ Bot message event.""" if not isinstance(d, dict): @@ -909,7 +912,6 @@ class QQAdapter(BasePlatformAdapter): # Attachment processing # ------------------------------------------------------------------ - @staticmethod def _detect_message_type(media_urls: list, media_types: list): """Determine MessageType from attachment content types.""" @@ -1476,6 +1478,7 @@ class QQAdapter(BasePlatformAdapter): headers = { "Authorization": f"QQBot {token}", "Content-Type": "application/json", + "User-Agent": build_user_agent(), } try: @@ -1875,25 +1878,39 @@ class QQAdapter(BasePlatformAdapter): # ------------------------------------------------------------------ async def send_typing(self, chat_id: str, metadata=None) -> None: - """Send an input notify to a C2C user (only supported for C2C).""" - del metadata + """Send an input notify to a C2C user (only supported for C2C). + Debounced to one request per ~50s (the API sets a 60s indicator). + The QQ API requires the originating message ID — retrieved from + ``_last_msg_id`` which is populated by ``_on_message``. + """ if not self.is_connected: return - # Only C2C supports input notify chat_type = self._guess_chat_type(chat_id) if chat_type != "c2c": return + msg_id = self._last_msg_id.get(chat_id) + if not msg_id: + return + + # Debounce — skip if we sent recently + now = time.time() + last_sent = self._typing_sent_at.get(chat_id, 0.0) + if now - last_sent < self._TYPING_DEBOUNCE_SECONDS: + return + try: msg_seq = self._next_msg_seq(chat_id) body = { "msg_type": MSG_TYPE_INPUT_NOTIFY, - "input_notify": {"input_type": 1, "input_second": 60}, + "msg_id": msg_id, + "input_notify": {"input_type": 1, "input_second": self._TYPING_INPUT_SECONDS}, "msg_seq": msg_seq, } await self._api_request("POST", f"/v2/users/{chat_id}/messages", body) + self._typing_sent_at[chat_id] = now except Exception as exc: logger.debug("[%s] send_typing failed: %s", self.name, exc) diff --git a/gateway/platforms/qqbot/constants.py b/gateway/platforms/qqbot/constants.py new file mode 100644 index 000000000..ddae3c133 --- /dev/null +++ b/gateway/platforms/qqbot/constants.py @@ -0,0 +1,74 @@ +"""QQBot package-level constants shared across adapter, onboard, and other modules.""" + +from __future__ import annotations + +import os + +# --------------------------------------------------------------------------- +# QQBot adapter version — bump on functional changes to the adapter package. +# --------------------------------------------------------------------------- + +QQBOT_VERSION = "1.1.0" + +# --------------------------------------------------------------------------- +# API endpoints +# --------------------------------------------------------------------------- + +# The portal domain is configurable via QQ_API_HOST for corporate proxies +# or test environments. Default: q.qq.com (production). +PORTAL_HOST = os.getenv("QQ_PORTAL_HOST", "q.qq.com") + +API_BASE = "https://api.sgroup.qq.com" +TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" +GATEWAY_URL_PATH = "/gateway" + +# QR-code onboard endpoints (on the portal host) +ONBOARD_CREATE_PATH = "/lite/create_bind_task" +ONBOARD_POLL_PATH = "/lite/poll_bind_result" +QR_URL_TEMPLATE = ( + "https://q.qq.com/qqbot/openclaw/connect.html" + "?task_id={task_id}&_wv=2&source=hermes" +) + +# --------------------------------------------------------------------------- +# Timeouts & retry +# --------------------------------------------------------------------------- + +DEFAULT_API_TIMEOUT = 30.0 +FILE_UPLOAD_TIMEOUT = 120.0 +CONNECT_TIMEOUT_SECONDS = 20.0 + +RECONNECT_BACKOFF = [2, 5, 10, 30, 60] +MAX_RECONNECT_ATTEMPTS = 100 +RATE_LIMIT_DELAY = 60 # seconds +QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds +MAX_QUICK_DISCONNECT_COUNT = 3 + +ONBOARD_POLL_INTERVAL = 2.0 # seconds between poll_bind_result calls +ONBOARD_API_TIMEOUT = 10.0 + +# --------------------------------------------------------------------------- +# Message limits +# --------------------------------------------------------------------------- + +MAX_MESSAGE_LENGTH = 4000 +DEDUP_WINDOW_SECONDS = 300 +DEDUP_MAX_SIZE = 1000 + +# --------------------------------------------------------------------------- +# QQ Bot message types +# --------------------------------------------------------------------------- + +MSG_TYPE_TEXT = 0 +MSG_TYPE_MARKDOWN = 2 +MSG_TYPE_MEDIA = 7 +MSG_TYPE_INPUT_NOTIFY = 6 + +# --------------------------------------------------------------------------- +# QQ Bot file media types +# --------------------------------------------------------------------------- + +MEDIA_TYPE_IMAGE = 1 +MEDIA_TYPE_VIDEO = 2 +MEDIA_TYPE_VOICE = 3 +MEDIA_TYPE_FILE = 4 diff --git a/gateway/platforms/qqbot/crypto.py b/gateway/platforms/qqbot/crypto.py new file mode 100644 index 000000000..426bd29de --- /dev/null +++ b/gateway/platforms/qqbot/crypto.py @@ -0,0 +1,45 @@ +"""AES-256-GCM utilities for QQBot scan-to-configure credential decryption.""" + +from __future__ import annotations + +import base64 +import os + + +def generate_bind_key() -> str: + """Generate a 256-bit random AES key and return it as base64. + + The key is passed to ``create_bind_task`` so the server can encrypt + the bot's *client_secret* before returning it. Only this CLI holds + the key, ensuring the secret never travels in plaintext. + """ + return base64.b64encode(os.urandom(32)).decode() + + +def decrypt_secret(encrypted_base64: str, key_base64: str) -> str: + """Decrypt a base64-encoded AES-256-GCM ciphertext. + + Ciphertext layout (after base64-decoding):: + + IV (12 bytes) ‖ ciphertext (N bytes) ‖ AuthTag (16 bytes) + + Args: + encrypted_base64: The ``bot_encrypt_secret`` value from + ``poll_bind_result``. + key_base64: The base64 AES key generated by + :func:`generate_bind_key`. + + Returns: + The decrypted *client_secret* as a UTF-8 string. + """ + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + key = base64.b64decode(key_base64) + raw = base64.b64decode(encrypted_base64) + + iv = raw[:12] + ciphertext_with_tag = raw[12:] # AESGCM expects ciphertext + tag concatenated + + aesgcm = AESGCM(key) + plaintext = aesgcm.decrypt(iv, ciphertext_with_tag, None) + return plaintext.decode("utf-8") diff --git a/gateway/platforms/qqbot/onboard.py b/gateway/platforms/qqbot/onboard.py new file mode 100644 index 000000000..65750b3f1 --- /dev/null +++ b/gateway/platforms/qqbot/onboard.py @@ -0,0 +1,124 @@ +""" +QQBot scan-to-configure (QR code onboard) module. + +Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to +generate a QR-code URL and poll for scan completion. On success the caller +receives the bot's *app_id*, *client_secret* (decrypted locally), and the +scanner's *user_openid* — enough to fully configure the QQBot gateway. + +Reference: https://bot.q.qq.com/wiki/develop/api-v2/ +""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import Tuple +from urllib.parse import quote + +from .constants import ( + ONBOARD_API_TIMEOUT, + ONBOARD_CREATE_PATH, + ONBOARD_POLL_PATH, + PORTAL_HOST, + QR_URL_TEMPLATE, +) +from .crypto import generate_bind_key +from .utils import get_api_headers + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Bind status +# --------------------------------------------------------------------------- + + +class BindStatus(IntEnum): + """Status codes returned by ``poll_bind_result``.""" + + NONE = 0 + PENDING = 1 + COMPLETED = 2 + EXPIRED = 3 + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def create_bind_task( + timeout: float = ONBOARD_API_TIMEOUT, +) -> Tuple[str, str]: + """Create a bind task and return *(task_id, aes_key_base64)*. + + The AES key is generated locally and sent to the server so it can + encrypt the bot credentials before returning them. + + Raises: + RuntimeError: If the API returns a non-zero ``retcode``. + """ + import httpx + + url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}" + key = generate_bind_key() + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.post(url, json={"key": key}, headers=get_api_headers()) + resp.raise_for_status() + data = resp.json() + + if data.get("retcode") != 0: + raise RuntimeError(data.get("msg", "create_bind_task failed")) + + task_id = data.get("data", {}).get("task_id") + if not task_id: + raise RuntimeError("create_bind_task: missing task_id in response") + + logger.debug("create_bind_task ok: task_id=%s", task_id) + return task_id, key + + +async def poll_bind_result( + task_id: str, + timeout: float = ONBOARD_API_TIMEOUT, +) -> Tuple[BindStatus, str, str, str]: + """Poll the bind result for *task_id*. + + Returns: + A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``. + + * ``bot_encrypt_secret`` is AES-256-GCM encrypted — decrypt it with + :func:`~gateway.platforms.qqbot.crypto.decrypt_secret` using the + key from :func:`create_bind_task`. + * ``user_openid`` is the OpenID of the person who scanned the code + (available when ``status == COMPLETED``). + + Raises: + RuntimeError: If the API returns a non-zero ``retcode``. + """ + import httpx + + url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}" + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.post(url, json={"task_id": task_id}, headers=get_api_headers()) + resp.raise_for_status() + data = resp.json() + + if data.get("retcode") != 0: + raise RuntimeError(data.get("msg", "poll_bind_result failed")) + + d = data.get("data", {}) + return ( + BindStatus(d.get("status", 0)), + str(d.get("bot_appid", "")), + d.get("bot_encrypt_secret", ""), + d.get("user_openid", ""), + ) + + +def build_connect_url(task_id: str) -> str: + """Build the QR-code target URL for a given *task_id*.""" + return QR_URL_TEMPLATE.format(task_id=quote(task_id)) diff --git a/gateway/platforms/qqbot/utils.py b/gateway/platforms/qqbot/utils.py new file mode 100644 index 000000000..873e58d2a --- /dev/null +++ b/gateway/platforms/qqbot/utils.py @@ -0,0 +1,71 @@ +"""QQBot shared utilities — User-Agent, HTTP helpers, config coercion.""" + +from __future__ import annotations + +import platform +import sys +from typing import Any, Dict, List + +from .constants import QQBOT_VERSION + + +# --------------------------------------------------------------------------- +# User-Agent +# --------------------------------------------------------------------------- + +def _get_hermes_version() -> str: + """Return the hermes-agent package version, or 'dev' if unavailable.""" + try: + from importlib.metadata import version + return version("hermes-agent") + except Exception: + return "dev" + + +def build_user_agent() -> str: + """Build a descriptive User-Agent string. + + Format:: + + QQBotAdapter/ (Python/; ; Hermes/) + + Example:: + + QQBotAdapter/1.0.0 (Python/3.11.15; darwin; Hermes/0.9.0) + """ + py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + os_name = platform.system().lower() + hermes_version = _get_hermes_version() + return f"QQBotAdapter/{QQBOT_VERSION} (Python/{py_version}; {os_name}; Hermes/{hermes_version})" + + +def get_api_headers() -> Dict[str, str]: + """Return standard HTTP headers for QQBot API requests. + + Includes ``Content-Type``, ``Accept``, and a dynamic ``User-Agent``. + ``q.qq.com`` requires ``Accept: application/json`` — without it, + the server returns a JavaScript anti-bot challenge page. + """ + return { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": build_user_agent(), + } + + +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + +def coerce_list(value: Any) -> List[str]: + """Coerce config values into a trimmed string list. + + Accepts comma-separated strings, lists, tuples, sets, or single values. + """ + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] if str(value).strip() else [] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f08e29266..156e99f2d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -44,7 +44,7 @@ _EXTRA_ENV_KEYS = frozenset({ "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", - "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", + "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME", "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT", "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", @@ -1534,12 +1534,12 @@ OPTIONAL_ENV_VARS = { "prompt": "Allow All QQ Users", "category": "messaging", }, - "QQ_HOME_CHANNEL": { + "QQBOT_HOME_CHANNEL": { "description": "Default QQ channel/group for cron delivery and notifications", "prompt": "QQ Home Channel", "category": "messaging", }, - "QQ_HOME_CHANNEL_NAME": { + "QQBOT_HOME_CHANNEL_NAME": { "description": "Display name for the QQ home channel", "prompt": "QQ Home Channel Name", "category": "messaging", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 585bbe446..2ba1ca337 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1998,7 +1998,7 @@ _PLATFORMS = [ {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False, "is_allowlist": True, "help": "Optional — restrict DM access to specific user OpenIDs."}, - {"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False, + {"name": "QQBOT_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False, "help": "OpenID to deliver cron results and notifications to."}, ], }, @@ -2625,6 +2625,195 @@ def _setup_feishu(): print_info(f" Bot: {bot_name}") +def _setup_qqbot(): + """Interactive setup for QQ Bot — scan-to-configure or manual credentials.""" + print() + print(color(" ─── 🐧 QQ Bot Setup ───", Colors.CYAN)) + + existing_app_id = get_env_value("QQ_APP_ID") + existing_secret = get_env_value("QQ_CLIENT_SECRET") + if existing_app_id and existing_secret: + print() + print_success("QQ Bot is already configured.") + if not prompt_yes_no(" Reconfigure QQ Bot?", False): + return + + # ── QR scan or manual ── + credentials = None + used_qr = False + + print() + if prompt_yes_no(" Scan QR code to add bot automatically?", True): + try: + credentials = _qqbot_qr_flow() + except KeyboardInterrupt: + print() + print_warning(" QQ Bot setup cancelled.") + return + if credentials: + used_qr = True + if not credentials: + print_info(" QR setup did not complete. Continuing with manual input.") + + # ── Manual credential input ── + if not credentials: + print() + print_info(" Go to https://q.qq.com to register a QQ Bot application.") + print_info(" Note your App ID and App Secret from the application page.") + print() + app_id = prompt(" App ID", password=False) + if not app_id: + print_warning(" Skipped — QQ Bot won't work without an App ID.") + return + app_secret = prompt(" App Secret", password=True) + if not app_secret: + print_warning(" Skipped — QQ Bot won't work without an App Secret.") + return + credentials = {"app_id": app_id.strip(), "client_secret": app_secret.strip(), "user_openid": ""} + + # ── Save core credentials ── + save_env_value("QQ_APP_ID", credentials["app_id"]) + save_env_value("QQ_CLIENT_SECRET", credentials["client_secret"]) + + user_openid = credentials.get("user_openid", "") + + # ── DM security policy ── + print() + access_choices = [ + "Use DM pairing approval (recommended)", + "Allow all direct messages", + "Only allow listed user OpenIDs", + ] + access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + if access_idx == 0: + save_env_value("QQ_ALLOW_ALL_USERS", "false") + save_env_value("QQ_ALLOWED_USERS", "") + print_success(" DM pairing enabled.") + print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + elif access_idx == 1: + save_env_value("QQ_ALLOW_ALL_USERS", "true") + save_env_value("QQ_ALLOWED_USERS", "") + print_warning(" Open DM access enabled for QQ Bot.") + else: + default_allow = user_openid or "" + allowlist = prompt(" Allowed user OpenIDs (comma-separated)", default_allow, password=False).replace(" ", "") + save_env_value("QQ_ALLOW_ALL_USERS", "false") + save_env_value("QQ_ALLOWED_USERS", allowlist) + print_success(" Allowlist saved.") + + # ── Home channel ── + print() + home_default = user_openid or "" + home_channel = prompt(" Home channel OpenID (for cron/notifications, or empty)", home_default, password=False) + if home_channel: + save_env_value("QQBOT_HOME_CHANNEL", home_channel.strip()) + print_success(f" Home channel set to {home_channel.strip()}") + + print() + print_success("🐧 QQ Bot configured!") + print_info(f" App ID: {credentials['app_id']}") + + +def _qqbot_render_qr(url: str) -> bool: + """Try to render a QR code in the terminal. Returns True if successful.""" + try: + import qrcode as _qr + qr = _qr.QRCode() + qr.add_data(url) + qr.make(fit=True) + qr.print_ascii(invert=True) + return True + except Exception: + return False + + +def _qqbot_qr_flow(): + """Run the QR-code scan-to-configure flow. + + Returns a dict with app_id, client_secret, user_openid on success, + or None on failure/cancel. + """ + try: + from gateway.platforms.qqbot import ( + create_bind_task, poll_bind_result, build_connect_url, + decrypt_secret, BindStatus, + ) + from gateway.platforms.qqbot.constants import ONBOARD_POLL_INTERVAL + except Exception as exc: + print_error(f" QQBot onboard import failed: {exc}") + return None + + import asyncio + import time + + MAX_REFRESHES = 3 + refresh_count = 0 + + while refresh_count <= MAX_REFRESHES: + loop = asyncio.new_event_loop() + + # ── Create bind task ── + try: + task_id, aes_key = loop.run_until_complete(create_bind_task()) + except Exception as e: + print_warning(f" Failed to create bind task: {e}") + loop.close() + return None + + url = build_connect_url(task_id) + + # ── Display QR code + URL ── + print() + if _qqbot_render_qr(url): + print(f" Scan the QR code above, or open this URL directly:\n {url}") + else: + print(f" Open this URL in QQ on your phone:\n {url}") + print_info(" Tip: pip install qrcode to show a scannable QR code here") + + # ── Poll loop (silent — keep QR visible at bottom) ── + try: + while True: + try: + status, app_id, encrypted_secret, user_openid = loop.run_until_complete( + poll_bind_result(task_id) + ) + except Exception: + time.sleep(ONBOARD_POLL_INTERVAL) + continue + + if status == BindStatus.COMPLETED: + client_secret = decrypt_secret(encrypted_secret, aes_key) + print() + print_success(f" QR scan complete! (App ID: {app_id})") + if user_openid: + print_info(f" Scanner's OpenID: {user_openid}") + return { + "app_id": app_id, + "client_secret": client_secret, + "user_openid": user_openid, + } + + if status == BindStatus.EXPIRED: + refresh_count += 1 + if refresh_count > MAX_REFRESHES: + print() + print_warning(f" QR code expired {MAX_REFRESHES} times — giving up.") + return None + print() + print_warning(f" QR code expired, refreshing... ({refresh_count}/{MAX_REFRESHES})") + loop.close() + break # outer while creates a new task + + time.sleep(ONBOARD_POLL_INTERVAL) + except KeyboardInterrupt: + loop.close() + raise + finally: + loop.close() + + return None + + def _setup_signal(): """Interactive setup for Signal messenger.""" import shutil @@ -2806,6 +2995,8 @@ def gateway_setup(): _setup_dingtalk() elif platform["key"] == "feishu": _setup_feishu() + elif platform["key"] == "qqbot": + _setup_qqbot() else: _setup_standard_platform(platform) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b5efb52a8..9c0ee0bff 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2005,52 +2005,6 @@ def _setup_wecom_callback(): _gw_setup() -def _setup_qqbot(): - """Configure QQ Bot gateway.""" - print_header("QQ Bot") - existing = get_env_value("QQ_APP_ID") - if existing: - print_info("QQ Bot: already configured") - if not prompt_yes_no("Reconfigure QQ Bot?", False): - return - - print_info("Connects Hermes to QQ via the Official QQ Bot API (v2).") - print_info(" Requires a QQ Bot application at q.qq.com") - print_info(" Reference: https://bot.q.qq.com/wiki/develop/api-v2/") - print() - - app_id = prompt("QQ Bot App ID") - if not app_id: - print_warning("App ID is required — skipping QQ Bot setup") - return - save_env_value("QQ_APP_ID", app_id.strip()) - - client_secret = prompt("QQ Bot App Secret", password=True) - if not client_secret: - print_warning("App Secret is required — skipping QQ Bot setup") - return - save_env_value("QQ_CLIENT_SECRET", client_secret) - print_success("QQ Bot credentials saved") - - print() - print_info("🔒 Security: Restrict who can DM your bot") - print_info(" Use QQ user OpenIDs (found in event payloads)") - print() - allowed_users = prompt("Allowed user OpenIDs (comma-separated, leave empty for open access)") - if allowed_users: - save_env_value("QQ_ALLOWED_USERS", allowed_users.replace(" ", "")) - print_success("QQ Bot allowlist configured") - else: - print_info("⚠️ No allowlist set — anyone can DM the bot!") - - print() - print_info("📬 Home Channel: OpenID for cron job delivery and notifications.") - home_channel = prompt("Home channel OpenID (leave empty to set later)") - if home_channel: - save_env_value("QQ_HOME_CHANNEL", home_channel) - - print() - print_success("QQ Bot configured!") def _setup_bluebubbles(): @@ -2119,12 +2073,9 @@ def _setup_bluebubbles(): def _setup_qqbot(): - """Configure QQ Bot (Official API v2) via standard platform setup.""" - from hermes_cli.gateway import _PLATFORMS - qq_platform = next((p for p in _PLATFORMS if p["key"] == "qqbot"), None) - if qq_platform: - from hermes_cli.gateway import _setup_standard_platform - _setup_standard_platform(qq_platform) + """Configure QQ Bot (Official API v2) via gateway setup.""" + from hermes_cli.gateway import _setup_qqbot as _gateway_setup_qqbot + _gateway_setup_qqbot() def _setup_webhooks(): @@ -2264,7 +2215,7 @@ def setup_gateway(config: dict): missing_home.append("Slack") if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"): missing_home.append("BlueBubbles") - if get_env_value("QQ_APP_ID") and not get_env_value("QQ_HOME_CHANNEL"): + if get_env_value("QQ_APP_ID") and not get_env_value("QQBOT_HOME_CHANNEL"): missing_home.append("QQBot") if missing_home: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 2e34ae9c3..8fafbc2f4 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -317,7 +317,7 @@ def show_status(args): "WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None), "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"), "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), - "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"), + "QQBot": ("QQ_APP_ID", "QQBOT_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/pyproject.toml b/pyproject.toml index 0cac0b6b7..d97c10810 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "edge-tts>=7.2.7,<8", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + # QR code rendering for scan-to-configure flows + "qrcode>=7.4,<9", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 96f48e77f..74f42d6c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,4 @@ croniter python-telegram-bot[webhooks]>=22.6 discord.py>=2.0 aiohttp>=3.9.0 +qrcode diff --git a/uv.lock b/uv.lock index 45efc2d93..fa6785aa5 100644 --- a/uv.lock +++ b/uv.lock @@ -300,7 +300,7 @@ wheels = [ [[package]] name = "atroposlib" version = "0.4.0" -source = { git = "https://github.com/NousResearch/atropos.git#c421582b6f7ce8a32f751aab3117d3824ac8f709" } +source = { git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30#c20c85256e5a45ad31edf8b7276e9c5ee1995a30" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -1699,7 +1699,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.8.0" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -1717,6 +1717,7 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "qrcode" }, { name = "requests" }, { name = "rich" }, { name = "tenacity" }, @@ -1737,6 +1738,7 @@ all = [ { name = "dingtalk-stream" }, { name = "discord-py", extra = ["voice"] }, { name = "elevenlabs" }, + { name = "fastapi" }, { name = "faster-whisper" }, { name = "honcho-ai" }, { name = "lark-oapi" }, @@ -1756,6 +1758,7 @@ all = [ { name = "slack-bolt" }, { name = "slack-sdk" }, { name = "sounddevice" }, + { name = "uvicorn", extra = ["standard"] }, ] cli = [ { name = "simple-term-menu" }, @@ -1842,6 +1845,10 @@ voice = [ { name = "numpy" }, { name = "sounddevice" }, ] +web = [ + { name = "fastapi" }, + { name = "uvicorn", extra = ["standard"] }, +] yc-bench = [ { name = "yc-bench", marker = "python_full_version >= '3.12'" }, ] @@ -1855,7 +1862,7 @@ requires-dist = [ { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" }, { name = "anthropic", specifier = ">=0.39.0,<1" }, { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" }, - { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" }, + { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" }, { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" }, @@ -1866,6 +1873,7 @@ requires-dist = [ { name = "exa-py", specifier = ">=2.9.0,<3" }, { name = "fal-client", specifier = ">=0.13.1,<1" }, { name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" }, { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" }, { name = "fire", specifier = ">=0.7.1,<1" }, { name = "firecrawl-py", specifier = ">=4.16.0,<5" }, @@ -1894,6 +1902,7 @@ requires-dist = [ { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, { name = "jinja2", specifier = ">=3.1.5,<4" }, @@ -1918,6 +1927,7 @@ requires-dist = [ { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = ">=22.6,<23" }, { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = ">=2.0.0,<3" }, { name = "pyyaml", specifier = ">=6.0.2,<7" }, + { name = "qrcode", specifier = ">=7.4,<9" }, { name = "requests", specifier = ">=2.33.0,<3" }, { name = "rich", specifier = ">=14.3.3,<15" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = ">=1.0,<2" }, @@ -1927,12 +1937,13 @@ requires-dist = [ { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" }, { name = "tenacity", specifier = ">=9.1.4,<10" }, - { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" }, + { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" }, { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, - { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" }, + { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, ] -provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "rl", "yc-bench", "all"] +provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -4160,6 +4171,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -4776,8 +4799,8 @@ wheels = [ [[package]] name = "tinker" -version = "0.16.1" -source = { git = "https://github.com/thinking-machines-lab/tinker.git#07bd3c2dd3cd4398ac1c26f0ec0deccbf3c1f913" } +version = "0.18.0" +source = { git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b#30517b667f18a3dfb7ef33fb56cf686d5820ba2b" } dependencies = [ { name = "anyio" }, { name = "click" }, @@ -5490,7 +5513,7 @@ wheels = [ [[package]] name = "yc-bench" version = "0.1.0" -source = { git = "https://github.com/collinear-ai/yc-bench.git#0c53c98f01a431db2e391482bc46013045854ab2" } +source = { git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c#bfb0c88062450f46341bd9a5298903fc2e952a5c" } dependencies = [ { name = "litellm", marker = "python_full_version >= '3.12'" }, { name = "matplotlib", marker = "python_full_version >= '3.12'" }, diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 1e8ad8135..b6cfabb3d 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -290,7 +290,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `QQ_ALLOWED_USERS` | Comma-separated QQ user openIDs allowed to message the bot | | `QQ_GROUP_ALLOWED_USERS` | Comma-separated QQ group IDs for group @-message access | | `QQ_ALLOW_ALL_USERS` | Allow all users (`true`/`false`, overrides `QQ_ALLOWED_USERS`) | -| `QQ_HOME_CHANNEL` | QQ user/group openID for cron delivery and notifications | +| `QQBOT_HOME_CHANNEL` | QQ user/group openID for cron delivery and notifications | | `MATTERMOST_URL` | Mattermost server URL (e.g. `https://mm.example.com`) | | `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost | | `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot | diff --git a/website/docs/user-guide/messaging/qqbot.md b/website/docs/user-guide/messaging/qqbot.md index 686fd862e..d9da90d58 100644 --- a/website/docs/user-guide/messaging/qqbot.md +++ b/website/docs/user-guide/messaging/qqbot.md @@ -48,8 +48,8 @@ QQ_CLIENT_SECRET=your-app-secret |---|---|---| | `QQ_APP_ID` | QQ Bot App ID (required) | — | | `QQ_CLIENT_SECRET` | QQ Bot App Secret (required) | — | -| `QQ_HOME_CHANNEL` | OpenID for cron/notification delivery | — | -| `QQ_HOME_CHANNEL_NAME` | Display name for home channel | `Home` | +| `QQBOT_HOME_CHANNEL` | OpenID for cron/notification delivery | — | +| `QQBOT_HOME_CHANNEL_NAME` | Display name for home channel | `Home` | | `QQ_ALLOWED_USERS` | Comma-separated user OpenIDs for DM access | open (all users) | | `QQ_ALLOW_ALL_USERS` | Set to `true` to allow all DMs | `false` | | `QQ_MARKDOWN_SUPPORT` | Enable QQ markdown (msg_type 2) | `true` | @@ -113,7 +113,7 @@ This usually means: - Verify the bot's **intents** are enabled at q.qq.com - Check `QQ_ALLOWED_USERS` if DM access is restricted - For group messages, ensure the bot is **@mentioned** (group policy may require allowlisting) -- Check `QQ_HOME_CHANNEL` for cron/notification delivery +- Check `QQBOT_HOME_CHANNEL` for cron/notification delivery ### Connection errors