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.
- wanbot-0.0.1/.gitignore +25 -0
- wanbot-0.0.1/PKG-INFO +8 -0
- wanbot-0.0.1/pyproject.toml +40 -0
- wanbot-0.0.1/wanbot/__init__.py +0 -0
- wanbot-0.0.1/wanbot/__main__.py +4 -0
- wanbot-0.0.1/wanbot/__version__.py +1 -0
- wanbot-0.0.1/wanbot/agent/__init__.py +0 -0
- wanbot-0.0.1/wanbot/agent/loop.py +28 -0
- wanbot-0.0.1/wanbot/bus/__init__.py +4 -0
- wanbot-0.0.1/wanbot/bus/events.py +30 -0
- wanbot-0.0.1/wanbot/bus/queue.py +39 -0
- wanbot-0.0.1/wanbot/channels/__init__.py +4 -0
- wanbot-0.0.1/wanbot/channels/base.py +149 -0
- wanbot-0.0.1/wanbot/channels/manager.py +205 -0
- wanbot-0.0.1/wanbot/channels/registry.py +75 -0
- wanbot-0.0.1/wanbot/cli/__init__.py +0 -0
- wanbot-0.0.1/wanbot/cli/commands.py +16 -0
- wanbot-0.0.1/wanbot/config/__init__.py +0 -0
- wanbot-0.0.1/wanbot/config/schema.py +7 -0
- wanbot-0.0.1/wanbot/providers/__init__.py +0 -0
- wanbot-0.0.1/wanbot/providers/base.py +0 -0
wanbot-0.0.1/.gitignore
ADDED
|
@@ -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,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 @@
|
|
|
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,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,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
|
|
File without changes
|
|
File without changes
|