resolve conflicts with main; remove test_mochat_channel.py

This commit is contained in:
Re-bin 2026-02-10 06:52:52 +00:00
commit d1f0615282
11 changed files with 519 additions and 137 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ __pycache__/
poetry.lock
.pytest_cache/
tests/
botpy.log

129
README.md
View File

@ -16,10 +16,12 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
📏 Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime)
📏 Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
@ -166,7 +168,7 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, or Email — anytime, anywhere.
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@ -176,7 +178,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTa
| **Feishu** | Medium (app credentials) |
| **Mochat** | Medium (claw token + websocket) |
| **DingTalk** | Medium (app credentials) |
| **Slack** | Medium (bot + app tokens) |
| **Email** | Medium (IMAP/SMTP credentials) |
| **QQ** | Easy (app credentials) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@ -200,7 +204,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTa
}
```
> Get your user ID from `@userinfobot` on Telegram.
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
> Copy this value **without the `@` symbol** and paste it into the config file.
**3. Run**
@ -378,6 +384,49 @@ nanobot gateway
</details>
<details>
<summary><b>QQ (QQ单聊)</b></summary>
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
**1. Register & create bot**
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
- Create a new bot application
- Go to **开发设置 (Developer Settings)** → copy **AppID** and **AppSecret**
**2. Set up sandbox for testing**
- In the bot management console, find **沙箱配置 (Sandbox Config)**
- Under **在消息列表配置**, click **添加成员** and add your own QQ number
- Once added, scan the bot's QR code with mobile QQ → open the bot profile → tap "发消息" to start chatting
**3. Configure**
> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot.
> - 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
{
"channels": {
"qq": {
"enabled": true,
"appId": "YOUR_APP_ID",
"secret": "YOUR_APP_SECRET",
"allowFrom": []
}
}
}
```
**4. Run**
```bash
nanobot gateway
```
Now send a message to the bot from QQ — it should respond!
</details>
<details>
<summary><b>DingTalk (钉钉)</b></summary>
@ -417,20 +466,67 @@ nanobot gateway
</details>
<details>
<summary><b>Slack</b></summary>
Uses **Socket Mode** — no public URL required.
**1. Create a Slack app**
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
- Pick a name and select your workspace
**2. Configure the app**
- **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`)
- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
- **Event Subscriptions**: Toggle ON → Subscribe to bot events: `message.im`, `message.channels`, `app_mention` → Save Changes
- **App Home**: Scroll to **Show Tabs** → Enable **Messages Tab** → Check **"Allow users to send Slash commands and messages from the messages tab"**
- **Install App**: Click **Install to Workspace** → Authorize → copy the **Bot Token** (`xoxb-...`)
**3. Configure nanobot**
```json
{
"channels": {
"slack": {
"enabled": true,
"botToken": "xoxb-...",
"appToken": "xapp-...",
"groupPolicy": "mention"
}
}
}
```
**4. Run**
```bash
nanobot gateway
```
DM the bot directly or @mention it in a channel — it should respond!
> [!TIP]
> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels).
> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
</details>
<details>
<summary><b>Email</b></summary>
Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data.
Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** — like a personal email assistant.
**1. Get credentials (Gmail example)**
- Enable 2-Step Verification in Google account security
- Create an [App Password](https://myaccount.google.com/apppasswords)
- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`)
- Enable 2-Step Verification → Create an [App Password](https://myaccount.google.com/apppasswords)
- Use this app password for both IMAP and SMTP
**2. Configure**
> [!TIP]
> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate — set `false` to fully disable.
> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders.
> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
```json
{
@ -440,23 +536,19 @@ Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit con
"consentGranted": true,
"imapHost": "imap.gmail.com",
"imapPort": 993,
"imapUsername": "you@gmail.com",
"imapUsername": "my-nanobot@gmail.com",
"imapPassword": "your-app-password",
"imapUseSsl": true,
"smtpHost": "smtp.gmail.com",
"smtpPort": 587,
"smtpUsername": "you@gmail.com",
"smtpUsername": "my-nanobot@gmail.com",
"smtpPassword": "your-app-password",
"smtpUseTls": true,
"fromAddress": "you@gmail.com",
"allowFrom": ["trusted@example.com"]
"fromAddress": "my-nanobot@gmail.com",
"allowFrom": ["your-real-email@gmail.com"]
}
}
}
```
> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely.
> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses.
**3. Run**
@ -537,7 +629,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
### Security
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description |
@ -636,13 +727,13 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
- [ ] **Multi-modal** — See and hear (images, voice, video)
- [ ] **Long-term memory** — Never forget important context
- [ ] **Better reasoning** — Multi-step planning and reflection
- [ ] **More integrations**Slack, calendar, and more
- [ ] **More integrations**Calendar and more
- [ ] **Self-improvement** — Learn from feedback and mistakes
### Contributors
<a href="https://github.com/HKUDS/nanobot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12" />
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
</a>

View File

@ -245,7 +245,8 @@ class AgentLoop:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content
content=final_content,
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:

View File

@ -119,6 +119,29 @@ class ChannelManager:
except ImportError as e:
logger.warning(f"Email channel not available: {e}")
# Slack channel
if self.config.channels.slack.enabled:
try:
from nanobot.channels.slack import SlackChannel
self.channels["slack"] = SlackChannel(
self.config.channels.slack, self.bus
)
logger.info("Slack channel enabled")
except ImportError as e:
logger.warning(f"Slack channel not available: {e}")
# QQ channel
if self.config.channels.qq.enabled:
try:
from nanobot.channels.qq import QQChannel
self.channels["qq"] = QQChannel(
self.config.channels.qq,
self.bus,
)
logger.info("QQ channel enabled")
except ImportError as e:
logger.warning(f"QQ channel not available: {e}")
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
try:

131
nanobot/channels/qq.py Normal file
View File

@ -0,0 +1,131 @@
"""QQ channel implementation using botpy SDK."""
import asyncio
from collections import deque
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import QQConfig
try:
import botpy
from botpy.message import C2CMessage
QQ_AVAILABLE = True
except ImportError:
QQ_AVAILABLE = False
botpy = None
C2CMessage = None
if TYPE_CHECKING:
from botpy.message import C2CMessage
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
"""Create a botpy Client subclass bound to the given channel."""
intents = botpy.Intents(public_messages=True, direct_message=True)
class _Bot(botpy.Client):
def __init__(self):
super().__init__(intents=intents)
async def on_ready(self):
logger.info(f"QQ bot ready: {self.robot.name}")
async def on_c2c_message_create(self, message: "C2CMessage"):
await channel._on_message(message)
async def on_direct_message_create(self, message):
await channel._on_message(message)
return _Bot
class QQChannel(BaseChannel):
"""QQ channel using botpy SDK with WebSocket connection."""
name = "qq"
def __init__(self, config: QQConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: QQConfig = config
self._client: "botpy.Client | None" = None
self._processed_ids: deque = deque(maxlen=1000)
self._bot_task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the QQ bot."""
if not QQ_AVAILABLE:
logger.error("QQ SDK not installed. Run: pip install qq-botpy")
return
if not self.config.app_id or not self.config.secret:
logger.error("QQ app_id and secret not configured")
return
self._running = True
BotClass = _make_bot_class(self)
self._client = BotClass()
self._bot_task = asyncio.create_task(self._run_bot())
logger.info("QQ bot started (C2C private message)")
async def _run_bot(self) -> None:
"""Run the bot connection."""
try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
except Exception as e:
logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
self._running = False
async def stop(self) -> None:
"""Stop the QQ bot."""
self._running = False
if self._bot_task:
self._bot_task.cancel()
try:
await self._bot_task
except asyncio.CancelledError:
pass
logger.info("QQ bot stopped")
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through QQ."""
if not self._client:
logger.warning("QQ client not initialized")
return
try:
await self._client.api.post_c2c_message(
openid=msg.chat_id,
msg_type=0,
content=msg.content,
)
except Exception as e:
logger.error(f"Error sending QQ message: {e}")
async def _on_message(self, data: "C2CMessage") -> None:
"""Handle incoming message from QQ."""
try:
# Dedup by message ID
if data.id in self._processed_ids:
return
self._processed_ids.append(data.id)
author = data.author
user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown'))
content = (data.content or "").strip()
if not content:
return
await self._handle_message(
sender_id=user_id,
chat_id=user_id,
content=content,
metadata={"message_id": data.id},
)
except Exception as e:
logger.error(f"Error handling QQ message: {e}")

205
nanobot/channels/slack.py Normal file
View File

@ -0,0 +1,205 @@
"""Slack channel implementation using Socket Mode."""
import asyncio
import re
from typing import Any
from loguru import logger
from slack_sdk.socket_mode.websockets import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from slack_sdk.web.async_client import AsyncWebClient
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import SlackConfig
class SlackChannel(BaseChannel):
"""Slack channel using Socket Mode."""
name = "slack"
def __init__(self, config: SlackConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: SlackConfig = config
self._web_client: AsyncWebClient | None = None
self._socket_client: SocketModeClient | None = None
self._bot_user_id: str | None = None
async def start(self) -> None:
"""Start the Slack Socket Mode client."""
if not self.config.bot_token or not self.config.app_token:
logger.error("Slack bot/app token not configured")
return
if self.config.mode != "socket":
logger.error(f"Unsupported Slack mode: {self.config.mode}")
return
self._running = True
self._web_client = AsyncWebClient(token=self.config.bot_token)
self._socket_client = SocketModeClient(
app_token=self.config.app_token,
web_client=self._web_client,
)
self._socket_client.socket_mode_request_listeners.append(self._on_socket_request)
# Resolve bot user ID for mention handling
try:
auth = await self._web_client.auth_test()
self._bot_user_id = auth.get("user_id")
logger.info(f"Slack bot connected as {self._bot_user_id}")
except Exception as e:
logger.warning(f"Slack auth_test failed: {e}")
logger.info("Starting Slack Socket Mode client...")
await self._socket_client.connect()
while self._running:
await asyncio.sleep(1)
async def stop(self) -> None:
"""Stop the Slack client."""
self._running = False
if self._socket_client:
try:
await self._socket_client.close()
except Exception as e:
logger.warning(f"Slack socket close failed: {e}")
self._socket_client = None
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Slack."""
if not self._web_client:
logger.warning("Slack client not running")
return
try:
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
thread_ts = slack_meta.get("thread_ts")
channel_type = slack_meta.get("channel_type")
# Only reply in thread for channel/group messages; DMs don't use threads
use_thread = thread_ts and channel_type != "im"
await self._web_client.chat_postMessage(
channel=msg.chat_id,
text=msg.content or "",
thread_ts=thread_ts if use_thread else None,
)
except Exception as e:
logger.error(f"Error sending Slack message: {e}")
async def _on_socket_request(
self,
client: SocketModeClient,
req: SocketModeRequest,
) -> None:
"""Handle incoming Socket Mode requests."""
if req.type != "events_api":
return
# Acknowledge right away
await client.send_socket_mode_response(
SocketModeResponse(envelope_id=req.envelope_id)
)
payload = req.payload or {}
event = payload.get("event") or {}
event_type = event.get("type")
# Handle app mentions or plain messages
if event_type not in ("message", "app_mention"):
return
sender_id = event.get("user")
chat_id = event.get("channel")
# Ignore bot/system messages (any subtype = not a normal user message)
if event.get("subtype"):
return
if self._bot_user_id and sender_id == self._bot_user_id:
return
# Avoid double-processing: Slack sends both `message` and `app_mention`
# for mentions in channels. Prefer `app_mention`.
text = event.get("text") or ""
if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text:
return
# Debug: log basic event shape
logger.debug(
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
event_type,
event.get("subtype"),
sender_id,
chat_id,
event.get("channel_type"),
text[:80],
)
if not sender_id or not chat_id:
return
channel_type = event.get("channel_type") or ""
if not self._is_allowed(sender_id, chat_id, channel_type):
return
if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id):
return
text = self._strip_bot_mention(text)
thread_ts = event.get("thread_ts") or event.get("ts")
# Add :eyes: reaction to the triggering message (best-effort)
try:
if self._web_client and event.get("ts"):
await self._web_client.reactions_add(
channel=chat_id,
name="eyes",
timestamp=event.get("ts"),
)
except Exception as e:
logger.debug(f"Slack reactions_add failed: {e}")
await self._handle_message(
sender_id=sender_id,
chat_id=chat_id,
content=text,
metadata={
"slack": {
"event": event,
"thread_ts": thread_ts,
"channel_type": channel_type,
}
},
)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "im":
if not self.config.dm.enabled:
return False
if self.config.dm.policy == "allowlist":
return sender_id in self.config.dm.allow_from
return True
# Group / channel messages
if self.config.group_policy == "allowlist":
return chat_id in self.config.group_allow_from
return True
def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool:
if self.config.group_policy == "open":
return True
if self.config.group_policy == "mention":
if event_type == "app_mention":
return True
return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text
if self.config.group_policy == "allowlist":
return chat_id in self.config.group_allow_from
return False
def _strip_bot_mention(self, text: str) -> str:
if not text or not self._bot_user_id:
return text
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()

View File

@ -589,6 +589,15 @@ def channels_status():
tg_config
)
# Slack
slack = config.channels.slack
slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]"
table.add_row(
"Slack",
"" if slack.enabled else "",
slack_config
)
console.print(table)

View File

@ -113,6 +113,34 @@ class MochatConfig(BaseModel):
reply_delay_ms: int = 120000
class SlackDMConfig(BaseModel):
"""Slack DM policy configuration."""
enabled: bool = True
policy: str = "open" # "open" or "allowlist"
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
class SlackConfig(BaseModel):
"""Slack channel configuration."""
enabled: bool = False
mode: str = "socket" # "socket" supported
webhook_path: str = "/slack/events"
bot_token: str = "" # xoxb-...
app_token: str = "" # xapp-...
user_token_read_only: bool = True
group_policy: str = "mention" # "mention", "open", "allowlist"
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
class QQConfig(BaseModel):
"""QQ channel configuration using botpy SDK."""
enabled: bool = False
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
class ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
@ -122,6 +150,8 @@ class ChannelsConfig(BaseModel):
mochat: MochatConfig = Field(default_factory=MochatConfig)
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
email: EmailConfig = Field(default_factory=EmailConfig)
slack: SlackConfig = Field(default_factory=SlackConfig)
qq: QQConfig = Field(default_factory=QQConfig)
class AgentDefaults(BaseModel):

View File

@ -132,6 +132,10 @@ class LiteLLMProvider(LLMProvider):
# Apply model-specific overrides (e.g. kimi-k2.5 temperature)
self._apply_model_overrides(model, kwargs)
# Pass api_key directly — more reliable than env vars alone
if self.api_key:
kwargs["api_key"] = self.api_key
# Pass api_base for custom endpoints
if self.api_base:
kwargs["api_base"] = self.api_base

View File

@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
version = "0.1.3.post5"
version = "0.1.3.post6"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
@ -34,6 +34,8 @@ dependencies = [
"socksio>=1.0.0",
"python-socketio>=5.11.0",
"msgpack>=1.0.8",
"slack-sdk>=3.26.0",
"qq-botpy>=1.0.0",
]
[project.optional-dependencies]

View File

@ -1,115 +0,0 @@
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.mochat import (
MochatBufferedEntry,
MochatChannel,
build_buffered_body,
resolve_mochat_target,
resolve_require_mention,
resolve_was_mentioned,
)
from nanobot.config.schema import MochatConfig, MochatGroupRule, MochatMentionConfig
def test_resolve_mochat_target_prefixes() -> None:
t = resolve_mochat_target("panel:abc")
assert t.id == "abc"
assert t.is_panel is True
t = resolve_mochat_target("session_123")
assert t.id == "session_123"
assert t.is_panel is False
t = resolve_mochat_target("mochat:session_456")
assert t.id == "session_456"
assert t.is_panel is False
def test_resolve_was_mentioned_from_meta_and_text() -> None:
payload = {
"content": "hello",
"meta": {
"mentionIds": ["bot-1"],
},
}
assert resolve_was_mentioned(payload, "bot-1") is True
payload = {"content": "ping <@bot-2>", "meta": {}}
assert resolve_was_mentioned(payload, "bot-2") is True
def test_resolve_require_mention_priority() -> None:
cfg = MochatConfig(
groups={
"*": MochatGroupRule(require_mention=False),
"group-a": MochatGroupRule(require_mention=True),
},
mention=MochatMentionConfig(require_in_groups=False),
)
assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-a") is True
assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-b") is False
@pytest.mark.asyncio
async def test_delay_buffer_flushes_on_mention() -> None:
bus = MessageBus()
cfg = MochatConfig(
enabled=True,
claw_token="token",
agent_user_id="bot",
reply_delay_mode="non-mention",
reply_delay_ms=60_000,
)
channel = MochatChannel(cfg, bus)
first = {
"type": "message.add",
"timestamp": "2026-02-07T10:00:00Z",
"payload": {
"messageId": "m1",
"author": "user1",
"content": "first",
"groupId": "group-1",
"meta": {},
},
}
second = {
"type": "message.add",
"timestamp": "2026-02-07T10:00:01Z",
"payload": {
"messageId": "m2",
"author": "user2",
"content": "hello <@bot>",
"groupId": "group-1",
"meta": {},
},
}
await channel._process_inbound_event(target_id="panel-1", event=first, target_kind="panel")
assert bus.inbound_size == 0
await channel._process_inbound_event(target_id="panel-1", event=second, target_kind="panel")
assert bus.inbound_size == 1
msg = await bus.consume_inbound()
assert msg.channel == "mochat"
assert msg.chat_id == "panel-1"
assert "user1: first" in msg.content
assert "user2: hello <@bot>" in msg.content
assert msg.metadata.get("buffered_count") == 2
await channel._cancel_delay_timers()
def test_build_buffered_body_group_labels() -> None:
body = build_buffered_body(
entries=[
MochatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"),
MochatBufferedEntry(raw_body="b", author="u2", sender_username="bot"),
],
is_group=True,
)
assert "Alice: a" in body
assert "bot: b" in body