diff --git a/.gitignore b/.gitignore index 55338f7..36dbfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ docs/ __pycache__/ poetry.lock .pytest_cache/ -tests/ \ No newline at end of file +tests/ +botpy.log diff --git a/README.md b/README.md index 7bf98fd..0c74e17 100644 --- a/README.md +++ b/README.md @@ -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) |
Telegram (Recommended) @@ -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
+
+QQ (QQๅ•่Š) + +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! + +
+
DingTalk (้’‰้’‰) @@ -417,20 +466,67 @@ nanobot gateway
+
+Slack + +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. + +
+
Email -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 - + Contributors diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 72ea86a..b764c3d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index f1d0001..464fa97 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -118,6 +118,29 @@ class ChannelManager: logger.info("Email channel enabled") 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.""" diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py new file mode 100644 index 0000000..5964d30 --- /dev/null +++ b/nanobot/channels/qq.py @@ -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}") diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py new file mode 100644 index 0000000..be95dd2 --- /dev/null +++ b/nanobot/channels/slack.py @@ -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() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d4d56bc..bcadba9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 26abcd7..c9bdb02 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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): diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 9d76c2a..dd50ed9 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 274f971..4c10d49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/test_mochat_channel.py b/tests/test_mochat_channel.py deleted file mode 100644 index 4d73840..0000000 --- a/tests/test_mochat_channel.py +++ /dev/null @@ -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