fix(qq): add configurable message format and onboard backfill
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user