fix(qq): add configurable message format and onboard backfill

This commit is contained in:
Xubin Ren
2026-03-14 08:25:44 +00:00
parent 91d95f139e
commit af65145bc8
5 changed files with 110 additions and 12 deletions

View File

@@ -546,6 +546,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
**3. Configure** **3. Configure**
> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access. > - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access.
> - `msgFormat`: Optional. Use `"plain"` (default) for maximum compatibility with legacy QQ clients, or `"markdown"` for richer formatting on newer clients.
> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow. > - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
```json ```json
@@ -555,7 +556,8 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
"enabled": true, "enabled": true,
"appId": "YOUR_APP_ID", "appId": "YOUR_APP_ID",
"secret": "YOUR_APP_SECRET", "secret": "YOUR_APP_SECRET",
"allowFrom": ["YOUR_OPENID"] "allowFrom": ["YOUR_OPENID"],
"msgFormat": "plain"
} }
} }
} }

View File

@@ -2,7 +2,7 @@
import asyncio import asyncio
from collections import deque from collections import deque
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Literal
from loguru import logger from loguru import logger
@@ -58,6 +58,7 @@ class QQConfig(Base):
app_id: str = "" app_id: str = ""
secret: str = "" secret: str = ""
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
msg_format: Literal["plain", "markdown"] = "plain"
class QQChannel(BaseChannel): class QQChannel(BaseChannel):
@@ -126,22 +127,27 @@ class QQChannel(BaseChannel):
try: try:
msg_id = msg.metadata.get("message_id") msg_id = msg.metadata.get("message_id")
self._msg_seq += 1 self._msg_seq += 1
msg_type = self._chat_type_cache.get(msg.chat_id, "c2c") use_markdown = self.config.msg_format == "markdown"
if msg_type == "group": payload: dict[str, Any] = {
"msg_type": 2 if use_markdown else 0,
"msg_id": msg_id,
"msg_seq": self._msg_seq,
}
if use_markdown:
payload["markdown"] = {"content": msg.content}
else:
payload["content"] = msg.content
chat_type = self._chat_type_cache.get(msg.chat_id, "c2c")
if chat_type == "group":
await self._client.api.post_group_message( await self._client.api.post_group_message(
group_openid=msg.chat_id, group_openid=msg.chat_id,
msg_type=0, **payload,
content=msg.content,
msg_id=msg_id,
msg_seq=self._msg_seq,
) )
else: else:
await self._client.api.post_c2c_message( await self._client.api.post_c2c_message(
openid=msg.chat_id, openid=msg.chat_id,
msg_type=0, **payload,
content=msg.content,
msg_id=msg_id,
msg_seq=self._msg_seq,
) )
except Exception as e: except Exception as e:
logger.error("Error sending QQ message: {}", e) logger.error("Error sending QQ message: {}", e)

View File

@@ -6,6 +6,7 @@ import select
import signal import signal
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any
# Force UTF-8 encoding for Windows console # Force UTF-8 encoding for Windows console
if sys.platform == "win32": if sys.platform == "win32":
@@ -259,6 +260,20 @@ def onboard():
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
"""Recursively fill in missing values from defaults without overwriting user config."""
if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing
merged = dict(existing)
for key, value in defaults.items():
if key not in merged:
merged[key] = value
else:
merged[key] = _merge_missing_defaults(merged[key], value)
return merged
def _onboard_plugins(config_path: Path) -> None: def _onboard_plugins(config_path: Path) -> None:
"""Inject default config for all discovered channels (built-in + plugins).""" """Inject default config for all discovered channels (built-in + plugins)."""
import json import json
@@ -276,6 +291,8 @@ def _onboard_plugins(config_path: Path) -> None:
for name, cls in all_channels.items(): for name, cls in all_channels.items():
if name not in channels: if name not in channels:
channels[name] = cls.default_config() channels[name] = cls.default_config()
else:
channels[name] = _merge_missing_defaults(channels[name], cls.default_config())
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)

View File

@@ -1,4 +1,5 @@
import json import json
from types import SimpleNamespace
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -86,3 +87,46 @@ def test_onboard_refresh_rewrites_legacy_config_template(tmp_path, monkeypatch)
assert defaults["maxTokens"] == 3333 assert defaults["maxTokens"] == 3333
assert defaults["contextWindowTokens"] == 65_536 assert defaults["contextWindowTokens"] == 65_536
assert "memoryWindow" not in defaults assert "memoryWindow" not in defaults
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_path.write_text(
json.dumps(
{
"channels": {
"qq": {
"enabled": False,
"appId": "",
"secret": "",
"allowFrom": [],
}
}
}
),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda: workspace)
monkeypatch.setattr(
"nanobot.channels.registry.discover_all",
lambda: {
"qq": SimpleNamespace(
default_config=lambda: {
"enabled": False,
"appId": "",
"secret": "",
"allowFrom": [],
"msgFormat": "plain",
}
)
},
)
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert saved["channels"]["qq"]["msgFormat"] == "plain"

View File

@@ -94,3 +94,32 @@ async def test_send_c2c_message_uses_plain_text_c2c_api_with_msg_seq() -> None:
"msg_seq": 2, "msg_seq": 2,
} }
assert not channel._client.api.group_calls assert not channel._client.api.group_calls
@pytest.mark.asyncio
async def test_send_group_message_uses_markdown_when_configured() -> None:
channel = QQChannel(
QQConfig(app_id="app", secret="secret", allow_from=["*"], msg_format="markdown"),
MessageBus(),
)
channel._client = _FakeClient()
channel._chat_type_cache["group123"] = "group"
await channel.send(
OutboundMessage(
channel="qq",
chat_id="group123",
content="**hello**",
metadata={"message_id": "msg1"},
)
)
assert len(channel._client.api.group_calls) == 1
call = channel._client.api.group_calls[0]
assert call == {
"group_openid": "group123",
"msg_type": 2,
"markdown": {"content": "**hello**"},
"msg_id": "msg1",
"msg_seq": 2,
}