feat(channels): add Moltchat websocket channel with polling fallback
This commit is contained in:
parent
625fc60282
commit
20b8a2fc58
49
README.md
49
README.md
@ -164,7 +164,7 @@ nanobot agent -m "Hello from my local LLM!"
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime, anywhere.
|
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat — anytime, anywhere.
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
@ -172,6 +172,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime,
|
|||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **WhatsApp** | Medium (scan QR) |
|
| **WhatsApp** | Medium (scan QR) |
|
||||||
| **Feishu** | Medium (app credentials) |
|
| **Feishu** | Medium (app credentials) |
|
||||||
|
| **Moltchat** | Medium (claw token + websocket) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
@ -205,6 +206,48 @@ nanobot gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Moltchat (Claw IM)</b></summary>
|
||||||
|
|
||||||
|
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
||||||
|
|
||||||
|
**1. Prepare credentials**
|
||||||
|
- `clawToken`: Claw API token
|
||||||
|
- `agentUserId`: your bot user id
|
||||||
|
- Optional: `sessions`/`panels` with `["*"]` for auto-discovery
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"moltchat": {
|
||||||
|
"enabled": true,
|
||||||
|
"baseUrl": "https://mochat.io",
|
||||||
|
"socketUrl": "https://mochat.io",
|
||||||
|
"socketPath": "/socket.io",
|
||||||
|
"clawToken": "claw_xxx",
|
||||||
|
"agentUserId": "69820107a785110aea8b1069",
|
||||||
|
"sessions": ["*"],
|
||||||
|
"panels": ["*"],
|
||||||
|
"replyDelayMode": "non-mention",
|
||||||
|
"replyDelayMs": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Moltchat API endpoint.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Discord</b></summary>
|
<summary><b>Discord</b></summary>
|
||||||
|
|
||||||
@ -413,7 +456,7 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard
|
|||||||
# Edit config on host to add API keys
|
# Edit config on host to add API keys
|
||||||
vim ~/.nanobot/config.json
|
vim ~/.nanobot/config.json
|
||||||
|
|
||||||
# Run gateway (connects to Telegram/WhatsApp)
|
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Moltchat)
|
||||||
docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway
|
docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway
|
||||||
|
|
||||||
# Or run a single command
|
# Or run a single command
|
||||||
@ -433,7 +476,7 @@ nanobot/
|
|||||||
│ ├── subagent.py # Background task execution
|
│ ├── subagent.py # Background task execution
|
||||||
│ └── tools/ # Built-in tools (incl. spawn)
|
│ └── tools/ # Built-in tools (incl. spawn)
|
||||||
├── skills/ # 🎯 Bundled skills (github, weather, tmux...)
|
├── skills/ # 🎯 Bundled skills (github, weather, tmux...)
|
||||||
├── channels/ # 📱 WhatsApp integration
|
├── channels/ # 📱 Chat channel integrations
|
||||||
├── bus/ # 🚌 Message routing
|
├── bus/ # 🚌 Message routing
|
||||||
├── cron/ # ⏰ Scheduled tasks
|
├── cron/ # ⏰ Scheduled tasks
|
||||||
├── heartbeat/ # 💓 Proactive wake-up
|
├── heartbeat/ # 💓 Proactive wake-up
|
||||||
|
|||||||
@ -2,5 +2,6 @@
|
|||||||
|
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
|
from nanobot.channels.moltchat import MoltchatChannel
|
||||||
|
|
||||||
__all__ = ["BaseChannel", "ChannelManager"]
|
__all__ = ["BaseChannel", "ChannelManager", "MoltchatChannel"]
|
||||||
|
|||||||
@ -77,6 +77,18 @@ class ChannelManager:
|
|||||||
logger.info("Feishu channel enabled")
|
logger.info("Feishu channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Feishu channel not available: {e}")
|
logger.warning(f"Feishu channel not available: {e}")
|
||||||
|
|
||||||
|
# Moltchat channel
|
||||||
|
if self.config.channels.moltchat.enabled:
|
||||||
|
try:
|
||||||
|
from nanobot.channels.moltchat import MoltchatChannel
|
||||||
|
|
||||||
|
self.channels["moltchat"] = MoltchatChannel(
|
||||||
|
self.config.channels.moltchat, self.bus
|
||||||
|
)
|
||||||
|
logger.info("Moltchat channel enabled")
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning(f"Moltchat channel not available: {e}")
|
||||||
|
|
||||||
async def start_all(self) -> None:
|
async def start_all(self) -> None:
|
||||||
"""Start WhatsApp channel and the outbound dispatcher."""
|
"""Start WhatsApp channel and the outbound dispatcher."""
|
||||||
|
|||||||
1227
nanobot/channels/moltchat.py
Normal file
1227
nanobot/channels/moltchat.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -366,6 +366,24 @@ def channels_status():
|
|||||||
"✓" if dc.enabled else "✗",
|
"✓" if dc.enabled else "✗",
|
||||||
dc.gateway_url
|
dc.gateway_url
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Feishu
|
||||||
|
fs = config.channels.feishu
|
||||||
|
fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]"
|
||||||
|
table.add_row(
|
||||||
|
"Feishu",
|
||||||
|
"✓" if fs.enabled else "✗",
|
||||||
|
fs_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# Moltchat
|
||||||
|
mc = config.channels.moltchat
|
||||||
|
mc_base = mc.base_url or "[dim]not configured[/dim]"
|
||||||
|
table.add_row(
|
||||||
|
"Moltchat",
|
||||||
|
"✓" if mc.enabled else "✗",
|
||||||
|
mc_base
|
||||||
|
)
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
tg = config.channels.telegram
|
tg = config.channels.telegram
|
||||||
|
|||||||
@ -39,12 +39,49 @@ class DiscordConfig(BaseModel):
|
|||||||
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
class MoltchatMentionConfig(BaseModel):
|
||||||
|
"""Moltchat mention behavior configuration."""
|
||||||
|
require_in_groups: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MoltchatGroupRule(BaseModel):
|
||||||
|
"""Moltchat per-group mention requirement."""
|
||||||
|
require_mention: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MoltchatConfig(BaseModel):
|
||||||
|
"""Moltchat channel configuration."""
|
||||||
|
enabled: bool = False
|
||||||
|
base_url: str = "http://localhost:11000"
|
||||||
|
socket_url: str = ""
|
||||||
|
socket_path: str = "/socket.io"
|
||||||
|
socket_disable_msgpack: bool = False
|
||||||
|
socket_reconnect_delay_ms: int = 1000
|
||||||
|
socket_max_reconnect_delay_ms: int = 10000
|
||||||
|
socket_connect_timeout_ms: int = 10000
|
||||||
|
refresh_interval_ms: int = 30000
|
||||||
|
watch_timeout_ms: int = 25000
|
||||||
|
watch_limit: int = 100
|
||||||
|
retry_delay_ms: int = 500
|
||||||
|
max_retry_attempts: int = 0 # 0 means unlimited retries
|
||||||
|
claw_token: str = ""
|
||||||
|
agent_user_id: str = ""
|
||||||
|
sessions: list[str] = Field(default_factory=list)
|
||||||
|
panels: list[str] = Field(default_factory=list)
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
mention: MoltchatMentionConfig = Field(default_factory=MoltchatMentionConfig)
|
||||||
|
groups: dict[str, MoltchatGroupRule] = Field(default_factory=dict)
|
||||||
|
reply_delay_mode: str = "non-mention" # off | non-mention
|
||||||
|
reply_delay_ms: int = 120000
|
||||||
|
|
||||||
|
|
||||||
class ChannelsConfig(BaseModel):
|
class ChannelsConfig(BaseModel):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels."""
|
||||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||||
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
||||||
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
||||||
|
moltchat: MoltchatConfig = Field(default_factory=MoltchatConfig)
|
||||||
|
|
||||||
|
|
||||||
class AgentDefaults(BaseModel):
|
class AgentDefaults(BaseModel):
|
||||||
|
|||||||
@ -30,6 +30,8 @@ dependencies = [
|
|||||||
"croniter>=2.0.0",
|
"croniter>=2.0.0",
|
||||||
"python-telegram-bot>=21.0",
|
"python-telegram-bot>=21.0",
|
||||||
"lark-oapi>=1.0.0",
|
"lark-oapi>=1.0.0",
|
||||||
|
"python-socketio>=5.11.0",
|
||||||
|
"msgpack>=1.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
115
tests/test_moltchat_channel.py
Normal file
115
tests/test_moltchat_channel.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.channels.moltchat import (
|
||||||
|
MoltchatBufferedEntry,
|
||||||
|
MoltchatChannel,
|
||||||
|
build_buffered_body,
|
||||||
|
resolve_moltchat_target,
|
||||||
|
resolve_require_mention,
|
||||||
|
resolve_was_mentioned,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import MoltchatConfig, MoltchatGroupRule, MoltchatMentionConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_moltchat_target_prefixes() -> None:
|
||||||
|
t = resolve_moltchat_target("panel:abc")
|
||||||
|
assert t.id == "abc"
|
||||||
|
assert t.is_panel is True
|
||||||
|
|
||||||
|
t = resolve_moltchat_target("session_123")
|
||||||
|
assert t.id == "session_123"
|
||||||
|
assert t.is_panel is False
|
||||||
|
|
||||||
|
t = resolve_moltchat_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 = MoltchatConfig(
|
||||||
|
groups={
|
||||||
|
"*": MoltchatGroupRule(require_mention=False),
|
||||||
|
"group-a": MoltchatGroupRule(require_mention=True),
|
||||||
|
},
|
||||||
|
mention=MoltchatMentionConfig(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 = MoltchatConfig(
|
||||||
|
enabled=True,
|
||||||
|
claw_token="token",
|
||||||
|
agent_user_id="bot",
|
||||||
|
reply_delay_mode="non-mention",
|
||||||
|
reply_delay_ms=60_000,
|
||||||
|
)
|
||||||
|
channel = MoltchatChannel(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 == "moltchat"
|
||||||
|
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=[
|
||||||
|
MoltchatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"),
|
||||||
|
MoltchatBufferedEntry(raw_body="b", author="u2", sender_username="bot"),
|
||||||
|
],
|
||||||
|
is_group=True,
|
||||||
|
)
|
||||||
|
assert "Alice: a" in body
|
||||||
|
assert "bot: b" in body
|
||||||
Loading…
x
Reference in New Issue
Block a user