wanbot 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ .assets
2
+ .env
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ docs/
7
+ *.egg-info/
8
+ *.egg
9
+ *.pyc
10
+ *.pyo
11
+ *.pyd
12
+ *.pyw
13
+ *.pyz
14
+ *.pywz
15
+ *.pyzz
16
+ .venv/
17
+ __pycache__/
18
+ poetry.lock
19
+ .pytest_cache/
20
+ tests/
21
+ botpy.log
22
+ .idea/
23
+ uv.lock
24
+ .idea/
25
+ .ruff_cache/
wanbot-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: wanbot
3
+ Version: 0.0.1
4
+ Summary: A personal AI assistant framework.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: loguru>=0.7.3
7
+ Requires-Dist: pydantic>=2.12.5
8
+ Requires-Dist: typer>=0.24.1
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "wanbot"
3
+ version = "0.0.1"
4
+ description = "A personal AI assistant framework."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "loguru>=0.7.3",
8
+ "pydantic>=2.12.5",
9
+ "typer>=0.24.1",
10
+ ]
11
+
12
+
13
+
14
+
15
+ [project.scripts]
16
+ wanbot = "wanbot.cli.commands:app"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+
23
+ [tool.ruff]
24
+ line-length = 110
25
+ target-version = "py312"
26
+
27
+
28
+ [tool.ruff.lint]
29
+ select = ["E", "F", "I", "N", "W"]
30
+ ignore = ["E501"]
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
34
+ testpaths = ["tests"]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=9.0.2",
39
+ "ruff>=0.15.8",
40
+ ]
File without changes
@@ -0,0 +1,4 @@
1
+ from wanbot.cli.commands import app
2
+
3
+ if __name__ == '__main__':
4
+ app()
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
File without changes
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+
3
+ from wanbot.bus import MessageBus
4
+
5
+
6
+ class AgentLoop:
7
+ """
8
+ Core process engine
9
+ It:
10
+ 1. Receives messages from the bus
11
+ 2. Builds context with history, memory, skills
12
+ 3. Call the LLM
13
+ 4. Execute tool calls
14
+ 4. Sends responses back
15
+ """
16
+
17
+ _TOOL_RESULT_MAX_CHARS = 16_000
18
+
19
+ def __init__(
20
+ self,
21
+ bus: MessageBus,
22
+ provider: LLMProvider,
23
+ workspace: Path,
24
+ model: str | None = None,
25
+ max_iterations: int | None = 50,
26
+ context_window_tokens: int | None = 65536,
27
+ ):
28
+ pass
@@ -0,0 +1,4 @@
1
+ from wanbot.bus.events import InboundMessage, OutboundMessage
2
+ from wanbot.bus.queue import MessageBus
3
+
4
+ __all__ = ["MessageBus","InboundMessage","OutboundMessage"]
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class InboundMessage:
8
+ channel: str # weixin, feishu
9
+ sender_id: str # User identifier
10
+ chat_id: str # Chat/Channel identifier
11
+ content: str # Message text
12
+ timestamp: datetime = field(default_factory=datetime.now)
13
+ media: list[str] = field(default_factory=list) # Media urls
14
+ metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
15
+ session_key_override: str | None = None # Optional override for thread_scoped sessions
16
+
17
+ @property
18
+ def session_key(self) -> str:
19
+ """Unique key for session identification."""
20
+ return self.session_key_override or f"{self.channel}:{self.chat_id}"
21
+
22
+
23
+ @dataclass
24
+ class OutboundMessage:
25
+ channel: str
26
+ chat_id: str
27
+ content: str
28
+ reply_to: str | None = None
29
+ media: list[str] = field(default_factory=list)
30
+ metadata: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,39 @@
1
+ import asyncio
2
+
3
+ from wanbot.bus.events import InboundMessage, OutboundMessage
4
+
5
+
6
+ class MessageBus:
7
+ """
8
+ Async messages bus that decouples chat channels from the agent core.
9
+
10
+ Channel push messages to the inbound queue, and the agent process them and pushes responses to the outbound queue.
11
+ """
12
+
13
+ def __init__(self):
14
+ self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
15
+ self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
16
+
17
+ async def publish_inbound(self, msg: InboundMessage) -> None:
18
+ await self.inbound.put(msg)
19
+
20
+ async def consume_inbound(self) -> InboundMessage:
21
+ """Consume the next inbound message (blocks until available)."""
22
+ return await self.inbound.get()
23
+
24
+ async def publish_outbound(self, msg: OutboundMessage) -> None:
25
+ await self.outbound.put(msg)
26
+
27
+ async def consume_outbound(self) -> OutboundMessage:
28
+ """Consume the next outbound message (blocks until available)."""
29
+ return await self.outbound.get()
30
+
31
+ @property
32
+ def inbound_size(self) -> int:
33
+ """Number of pending inbound messages."""
34
+ return self.inbound.qsize()
35
+
36
+ @property
37
+ def outbound_size(self) -> int:
38
+ """Number of pending outbound messages."""
39
+ return self.outbound.qsize()
@@ -0,0 +1,4 @@
1
+ from wanbot.channels.base import BaseChannel
2
+ from wanbot.channels.manager import ChannelManager
3
+
4
+ __all__ = ["BaseChannel", "ChannelManager"]
@@ -0,0 +1,149 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from wanbot.bus import InboundMessage, MessageBus, OutboundMessage
8
+
9
+
10
+ class BaseChannel(ABC):
11
+ """
12
+ Abstract base class for chat channel implementations.
13
+ Each channel should implement this interface to integrate with wanbot message bus.
14
+ """
15
+
16
+ name: str = "base"
17
+ display_name: str = "Base"
18
+ transcription_api_key: str = ""
19
+
20
+ def __init__(self, config: Any, bus: MessageBus):
21
+ self.config = config
22
+ self.bus = bus
23
+ self._running = False
24
+
25
+ async def transcribe_audio(self, file_path: str | Path) -> str:
26
+ """
27
+ Transcribe an audio file via Groq Whisper.
28
+ Return empty string on failure.
29
+ """
30
+ if not self.transcription_api_key:
31
+ return ""
32
+ try:
33
+ from wanbot.providers.transcription import GroqTranscriptionProvider
34
+
35
+ provider = GroqTranscriptionProvider(api_key=self.transcription_api_key)
36
+ return await provider.transcribe(file_path)
37
+ except Exception as e:
38
+ logger.warning(f"{self.name}: audio transcription failed: {e}")
39
+ return ""
40
+
41
+ async def login(self, force: bool = False) -> bool:
42
+ """
43
+ Perform channel-specific interactive login. (e.g. QR code scan).
44
+
45
+ Returns True if already authenticated or login succeed.
46
+ Override in subclass that support interactive login.
47
+ """
48
+ return True
49
+
50
+ @abstractmethod
51
+ async def start(self) -> None:
52
+ """
53
+ Start the channel and begin listening for messages.
54
+ This should be a long-running async task that:
55
+ 1. Connects to the chat platform.
56
+ 2. Listens for incoming messages.
57
+ 3. Forwards messages to the bus via _handle_message().
58
+ """
59
+ pass
60
+
61
+ @abstractmethod
62
+ async def stop(self) -> None:
63
+ """Stop the channel and clean up resources."""
64
+ pass
65
+
66
+ @abstractmethod
67
+ async def send(self, message: OutboundMessage) -> None:
68
+ """Send message through this channel"""
69
+ pass
70
+
71
+ async def send_delta(
72
+ self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
73
+ ) -> None:
74
+ """Deliver a streaming next chunk.
75
+ Override in subclass to enable streaming.
76
+ Implementations should raise on delivery failure so the channel manager can retry.
77
+
78
+ Streaming contract: `_stream_delta` is chunk, `_stream_end` ends the current segment,and stateful implementations must key buffers by `stream_id` rather than only by `chat_id`.
79
+ """
80
+ pass
81
+
82
+ @property
83
+ def supports_streaming(self) -> bool:
84
+ cfg = self.config
85
+ streaming = (
86
+ cfg.get("streaming", False)
87
+ if isinstance(cfg, dict)
88
+ else getattr(cfg, "streaming", False)
89
+ )
90
+ return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
91
+
92
+ def is_allowed(self, sender_id: str) -> bool:
93
+ """Check if *sender_id* is permitted.
94
+ Empty list -> deny all; ``*`` -> allow all.
95
+ """
96
+ allow_list = getattr(self.config, "allow_from", [])
97
+ if not allow_list:
98
+ logger.warning(f"{self.name}: allow_from is empty - all access denied.")
99
+ return False
100
+ if "*" in allow_list:
101
+ return True
102
+ return str(sender_id) in allow_list
103
+
104
+ async def _handle_message(
105
+ self,
106
+ sender_id: str,
107
+ chat_id: str,
108
+ content: str,
109
+ media: list[str] | None = None,
110
+ metadata: dict[str, Any] | None = None,
111
+ session_key: str | None = None,
112
+ ) -> None:
113
+ """
114
+ Handle an income message from the chat flatform
115
+ This method checks permissions and forwards the bus
116
+ """
117
+
118
+ if not self.is_allowed(sender_id):
119
+ logger.warning(
120
+ f"Access denied for sender {sender_id} on channel {self.name}."
121
+ "Add them to allowFrom list in config to grant access."
122
+ )
123
+ return
124
+
125
+ meta = metadata or {}
126
+
127
+ if self.supports_streaming:
128
+ meta = {**meta, "_want_stream": True}
129
+
130
+ msg = InboundMessage(
131
+ channel=self.name,
132
+ sender_id=sender_id,
133
+ chat_id=chat_id,
134
+ content=content,
135
+ media=media,
136
+ metadata=meta,
137
+ session_key_override=session_key,
138
+ )
139
+
140
+ await self.bus.publish_inbound(msg)
141
+
142
+ @classmethod
143
+ def default_config(cls) -> dict[str, Any]:
144
+ """Return default config for onboard. Override in plugins to auto-populate config.json"""
145
+ return {"enabled": False}
146
+
147
+ @property
148
+ def is_running(self) -> bool:
149
+ return self._running
@@ -0,0 +1,205 @@
1
+ """Channel Manager"""
2
+
3
+ import asyncio
4
+
5
+ from loguru import logger
6
+
7
+ from wanbot.bus import MessageBus, OutboundMessage
8
+ from wanbot.channels import BaseChannel
9
+ from wanbot.config.schema import Config
10
+
11
+
12
+ class ChannelManager:
13
+ """
14
+ Manages chat channels and coordinates message routing.
15
+
16
+ Responsibilities:
17
+ 1. Initialize enabled channels
18
+ 2. Start/Stop channels
19
+ 3. Route outbound messages
20
+ """
21
+
22
+ def __init__(self, config: Config, bus: MessageBus):
23
+ self.config = config
24
+ self.bus = bus
25
+ self.channels: dict[str, BaseChannel] = {}
26
+ self._dispatch_task: asyncio.Task | None = None
27
+
28
+ self._init_channels()
29
+
30
+ def _init_channels(self) -> None:
31
+ from wanbot.channels.registry import discover_all
32
+
33
+ groq_key = self.config.providers.groq.groq_key
34
+
35
+ for name, cls in discover_all().items():
36
+ channel_cfg = getattr(self.config.channels, name, None)
37
+ if channel_cfg is None:
38
+ continue
39
+ enabled = (
40
+ channel_cfg.get("enabled", False)
41
+ if isinstance(channel_cfg, dict)
42
+ else getattr(channel_cfg, "enabled", False)
43
+ )
44
+ if not enabled:
45
+ continue
46
+
47
+ try:
48
+ channel = cls(channel_cfg, self.bus)
49
+ channel.transcription_api_key = groq_key
50
+ self.channels[name] = channel
51
+ logger.info(f"Initialized channel {name}")
52
+ except Exception as e:
53
+ logger.warning(f"{name} channel not available: {e}")
54
+
55
+ async def _start_channel(self, name: str, channel: BaseChannel) -> None:
56
+ try:
57
+ await channel.start()
58
+ except Exception as e:
59
+ logger.error(f"Failed to start channel {name}: {e}")
60
+
61
+ async def start_all(self) -> None:
62
+ if not self.channels:
63
+ logger.warning("No channels configured")
64
+ return
65
+ # Start outbound dispatcher
66
+ self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
67
+
68
+ # Start channels
69
+ tasks = []
70
+ for name, channel in self.channels.items():
71
+ logger.info(f"Starting channel {name}")
72
+ tasks.append(asyncio.create_task(self._start_channel(name, channel)))
73
+ # Wait for all to complete (they should run forever)
74
+ await asyncio.gather(*tasks)
75
+
76
+ async def stop_all(self) -> None:
77
+ """Stop all channels"""
78
+
79
+ # stop dispatcher
80
+
81
+ if self._dispatch_task:
82
+ self._dispatch_task.cancel()
83
+ try:
84
+ await self._dispatch_task
85
+ except asyncio.CancelledError:
86
+ pass
87
+
88
+ # Stop all channels
89
+
90
+ for name, channel in self.channels.items():
91
+ try:
92
+ await channel.stop()
93
+ logger.info(f"Stopped channel {name}")
94
+ except Exception as e:
95
+ logger.error(f"Failed to stop channel {name}: {e}")
96
+
97
+ async def _dispatch_outbound(self) -> None:
98
+ """Dispatch outbound messages to appropriate channel."""
99
+ logger.info("Outbound dispatcher started.")
100
+
101
+ # Buffer
102
+ # 问题背景:为什么会用到Buffer?
103
+ # 当合并连续的流式消息(_stream_delta)时,可能会遇到不属于当前流的消息。这些消息需要暂时保存,以便在合并完成后继续处理。
104
+ # 1. 队列中有消息:[delta1, delta2, other_msg, delta3]
105
+ # 2. delta1 和 delta2 属于同一个流,可以合并
106
+ # 3. other_msg 属于不同的流,不能合并
107
+ # 4. delta3 属于第一个流,应该与 delta1+delta2 合并
108
+ #
109
+ # asyncio.Queue不支持push_front
110
+ # 但是 asyncio.Queue 无法将 other_msg 放回前端,只能放到末尾(put()),这会改变消息顺序。
111
+
112
+ # 1. 初始化
113
+ pending: list[OutboundMessage] = []
114
+
115
+ while True:
116
+ try:
117
+ # 2. 消息获取策略
118
+ # 优先处理pending中缓存的消息
119
+ if pending:
120
+ msg = pending.pop(0)
121
+ else:
122
+ # 等待消息队列,超时1秒检查取消信号
123
+ msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0)
124
+ # 3. 消息过滤
125
+ # 过滤逻辑:
126
+ # 进度提示:LLM
127
+ # 思考过程中的中间状态
128
+ # 工具提示:AI
129
+ # 调用工具时的提示信息
130
+ # 根据配置决定是否发送这些辅助信息
131
+ if msg.metadata.get("_progress"):
132
+ if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
133
+ continue
134
+ if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
135
+ continue
136
+
137
+ # 4. 流式消息合并判断
138
+ # 合并相同(频道,聊天id)的连续流增量消息
139
+ # Coalesce consecutive _stream_delta messages for the same (channel,chat_id)
140
+ # to reduce API calls and improve streaming latency
141
+ if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
142
+ msg, extra_pending = self._coalesce_stream_deltas(msg)
143
+ pending.extend(extra_pending)
144
+
145
+ # 5. 流式消息合并判断
146
+ channel = self.channels.get(msg.channel)
147
+
148
+ if channel:
149
+ await self._send_with_retry(channel, msg)
150
+ else:
151
+ logger.warning(f"Unknown channel: {msg.channel}")
152
+
153
+ except asyncio.TimeoutError:
154
+ continue
155
+ except asyncio.CancelledError:
156
+ break
157
+
158
+ def _coalesce_stream_deltas(
159
+ self, first_msg: OutboundMessage
160
+ ) -> tuple[OutboundMessage, list[OutboundMessage]]:
161
+ """Merge consecutive _steam_delta messages for the same (channel,chat_id)"""
162
+ # 步骤1:初始化合并参数
163
+ target_key = (first_msg.channel, first_msg.chat_id)
164
+ combined_content = first_msg.content
165
+ final_metadata = dict(first_msg.metadata or {})
166
+ non_matching: list[OutboundMessage] = []
167
+
168
+ # Only merge consecutive deltas. As soon as we hit any other message,
169
+ # stop and hand that boundary back to the dispatcher via `pending`.
170
+ # 只合并连续的delta。一旦我们遇到任何其他消息,停止并通过‘ pending ’将该边界交还给调度程序。
171
+ # 步骤2:循环合并后续消息
172
+ while True:
173
+ try:
174
+ next_msg = self.bus.outbound.get_nowait()
175
+ except asyncio.QueueEmpty:
176
+ break
177
+
178
+ # Check if the message belongs to the same stream
179
+ # 步骤3:消息匹配检查
180
+ # 1. 相同通道和聊天 ID 2.是流式 delta 消息 3.当前流未结束(final_metadata.get("_stream_end") 为假)
181
+ is_same_target = (next_msg.channel, next_msg.chat_id) == target_key
182
+ is_delta = next_msg.metadata and next_msg.metadata.get("_stream_delta")
183
+ is_end = next_msg.metadata and next_msg.metadata.get("_stream_end")
184
+
185
+ # 步骤4:合并决策树
186
+ if is_same_target and is_delta and not final_metadata.get("_stream_end"):
187
+ # 条件1:可以合并
188
+ combined_content += next_msg.content # 内容累加
189
+ # If we see _stream_end, remember it and stop coalescing this stream
190
+ if is_end:
191
+ final_metadata["_stream_end"] = True
192
+ # Stream ended - stop coalescing this stream
193
+ break
194
+ else:
195
+ # First non-matching message defines the coalescing boundary.
196
+ non_matching.append(next_msg)
197
+ break
198
+
199
+ merged = OutboundMessage(
200
+ channel=first_msg.channel,
201
+ chat_id=first_msg.chat_id,
202
+ content=combined_content,
203
+ metadata=final_metadata,
204
+ )
205
+ return merged, non_matching
@@ -0,0 +1,75 @@
1
+ """Auto-discovery for built-in channel modules and external plugins"""
2
+
3
+ import importlib
4
+ import pkgutil
5
+ from typing import TYPE_CHECKING
6
+
7
+ from loguru import logger
8
+
9
+ if TYPE_CHECKING:
10
+ from wanbot.channels.base import BaseChannel
11
+
12
+ _INTERNAL = frozenset({"base", "manager", "registry"})
13
+
14
+
15
+ def discover_channel_names() -> list[str]:
16
+ """Return all built-in channel module names by scanning the package (zero imports)."""
17
+ import wanbot.channels as pkg
18
+
19
+ return [
20
+ name
21
+ for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
22
+ if name not in _INTERNAL and not ispkg # 过滤_INTERNAL 和 子包not ispkg
23
+ ]
24
+
25
+
26
+ def load_channel_class(module_name: str) -> type[BaseChannel]:
27
+ """Import *module_name* and return the first BaseChannel subclass found."""
28
+ from wanbot.channels.base import BaseChannel as _Base
29
+
30
+ mod = importlib.import_module(f"wanbot.channels.{module_name}")
31
+ for attr in dir(mod):
32
+ obj = getattr(mod, attr)
33
+ # 1.检查是否为类 2. 检查是否为 BaseChannel 的子类 3. 排除 BaseChannel 自身
34
+ if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base:
35
+ return obj
36
+ raise ImportError(f"No BaseChannel subclass in wanbot.channels.'{module_name}'")
37
+
38
+
39
+ def discover_plugins() -> dict[str, type[BaseChannel]]:
40
+ """
41
+ Discover external channel plugins registered via entry_points.
42
+ 发现通过 entry_points 注册的外部通道插件。
43
+ 1. 用途:这是 Python 标准库(Python 3.8+)提供的模块,用于访问已安装包的元数据。
44
+ 2. 原理:Python 包在安装时(通过 setup.py 或 pyproject.toml),可以声明自己提供了某些“入口点”。主程序可以通过查询这些入口点来找到第三方包提供的函数或类,而无需硬编码 import 语句。
45
+ 3. 分组 (group):代码中指定了 group="wanbot.channels"。这意味着只有那些在配置中明确声明属于 wanbot.channels 组的入口点才会被加载。这是一种命名空间隔离,防止加载无关的插件。
46
+ """
47
+ from importlib.metadata import entry_points
48
+
49
+ plugins: dict[str, type[BaseChannel]] = {}
50
+ for ep in entry_points(group="wanbot.channels"):
51
+ try:
52
+ cls = ep.load()
53
+ plugins[ep.name] = cls
54
+ except Exception as e:
55
+ logger.warning(f"Failed to load channel plugin {ep.name}: {e}")
56
+ return plugins
57
+
58
+
59
+ def discover_all() -> dict[str, type[BaseChannel]]:
60
+ """Return all channels: built-in (pkgutil) merged with external (entry_points).)
61
+ Built-in channels take priority - an external plugin cannot shadow a built-in channel.
62
+ """
63
+ builtin: dict[str, type[BaseChannel]] = {}
64
+ for modname in discover_channel_names():
65
+ try:
66
+ builtin[modname] = load_channel_class(modname)
67
+ except ImportError as exc:
68
+ logger.warning(f"Skipping built-in channel {modname}: {exc}")
69
+
70
+ external = discover_plugins()
71
+ shadowed = set(external) & set(builtin)
72
+ if shadowed:
73
+ logger.warning(f"Plugin(s) shadowed by built-in channels (ignored): {shadowed}")
74
+
75
+ return {**external, **builtin}
File without changes
@@ -0,0 +1,16 @@
1
+ import typer
2
+ from rich.console import Console
3
+
4
+ # =========================================================================
5
+ # CLI APP
6
+ # =========================================================================
7
+
8
+ app = typer.Typer(
9
+ name="wanbot",
10
+ context_settings=dict(help_option_names=["-h", "--help"]),
11
+ help="Wanbot CLI",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+ console = Console()
16
+ EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
File without changes
@@ -0,0 +1,7 @@
1
+ # =========================================================================
2
+ # ROOT CONFIG
3
+ # =========================================================================
4
+
5
+
6
+ class Config:
7
+ pass
File without changes
File without changes