python-library-lagrange-adapter 0.1.0__py3-none-any.whl
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.
- lagrange_adapter/__init__.py +4 -0
- lagrange_adapter/adapter.py +128 -0
- lagrange_adapter/bot.py +215 -0
- lagrange_adapter/listener.py +97 -0
- lagrange_adapter/models.py +165 -0
- lagrange_adapter/protocol_adapt.py +194 -0
- python_library_lagrange_adapter-0.1.0.dist-info/METADATA +8 -0
- python_library_lagrange_adapter-0.1.0.dist-info/RECORD +9 -0
- python_library_lagrange_adapter-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
from onebot_protocol import MessagePayload
|
|
4
|
+
|
|
5
|
+
from lagrange_adapter.bot import Bot
|
|
6
|
+
from lagrange_adapter.listener import (
|
|
7
|
+
Listener,
|
|
8
|
+
emit_disconnect,
|
|
9
|
+
emit_error,
|
|
10
|
+
emit_message,
|
|
11
|
+
emit_void,
|
|
12
|
+
)
|
|
13
|
+
from lagrange_adapter.models import BotMessage
|
|
14
|
+
from lagrange_adapter.protocol_adapt import bot_to_onebot, onebot_to_bot
|
|
15
|
+
|
|
16
|
+
MessageCallback = Callable[[MessagePayload], Awaitable[None]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Adapter:
|
|
20
|
+
"""Lagrange 反向 WebSocket 对外门面:入站转统一载荷,出站从载荷发回 CQ 段。
|
|
21
|
+
|
|
22
|
+
群聊默认仅在 @ 机器人时上报;过滤规则在包内完成。
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
port: int = 6199,
|
|
28
|
+
*,
|
|
29
|
+
listeners: list[Listener] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""创建适配器并绑定内部反向 WS 服务。
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
port: 本机监听端口,供 Lagrange 连接
|
|
35
|
+
listeners: 构造期注册的多组事件监听器
|
|
36
|
+
"""
|
|
37
|
+
self._callbacks: list[MessageCallback] = []
|
|
38
|
+
self._listeners: list[Listener] = list(listeners or [])
|
|
39
|
+
self._bot = Bot(port=port)
|
|
40
|
+
self._bot.on_message(self._on_bot_message)
|
|
41
|
+
self._bot.on_connect(self._on_connect)
|
|
42
|
+
self._bot.on_ready(self._on_ready)
|
|
43
|
+
self._bot.on_disconnect(self._on_disconnect)
|
|
44
|
+
self._bot.on_error(self._on_error)
|
|
45
|
+
|
|
46
|
+
def register(self, callback: MessageCallback) -> None:
|
|
47
|
+
"""登记一条消息回调,与监听器中的消息槽位一并触发。
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
callback: 异步处理入站统一消息载荷
|
|
51
|
+
"""
|
|
52
|
+
self._callbacks.append(callback)
|
|
53
|
+
|
|
54
|
+
def on_message(
|
|
55
|
+
self, callback: MessageCallback | None = None
|
|
56
|
+
) -> MessageCallback | Callable[[MessageCallback], MessageCallback]:
|
|
57
|
+
"""登记消息回调;无参调用时返回装饰器。
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
callback: 直接传入的异步处理函数;省略时用于装饰器写法
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
装饰器模式下返回原函数;直接传入时返回同一回调
|
|
64
|
+
"""
|
|
65
|
+
if callback is not None:
|
|
66
|
+
self.register(callback)
|
|
67
|
+
return callback
|
|
68
|
+
|
|
69
|
+
def decorator(fn: MessageCallback) -> MessageCallback:
|
|
70
|
+
self.register(fn)
|
|
71
|
+
return fn
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
async def send(self, payload: MessagePayload) -> None:
|
|
76
|
+
"""按统一载荷向当前会话发送消息。
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
payload: 目标会话与消息段列表
|
|
80
|
+
"""
|
|
81
|
+
await self._bot.send(onebot_to_bot(payload))
|
|
82
|
+
|
|
83
|
+
async def _on_bot_message(self, msg: BotMessage) -> None:
|
|
84
|
+
payload = bot_to_onebot(msg)
|
|
85
|
+
if payload is None:
|
|
86
|
+
return
|
|
87
|
+
await emit_message(self._listeners, payload)
|
|
88
|
+
for callback in self._callbacks:
|
|
89
|
+
try:
|
|
90
|
+
await callback(payload)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
async def _on_connect(self) -> None:
|
|
95
|
+
await emit_void(self._listeners, "on_connect")
|
|
96
|
+
|
|
97
|
+
async def _on_ready(self) -> None:
|
|
98
|
+
await emit_void(self._listeners, "on_ready")
|
|
99
|
+
|
|
100
|
+
async def _on_disconnect(self, reason: str) -> None:
|
|
101
|
+
await emit_disconnect(self._listeners, reason)
|
|
102
|
+
|
|
103
|
+
async def _on_error(self, exc: BaseException) -> None:
|
|
104
|
+
await emit_error(self._listeners, exc)
|
|
105
|
+
|
|
106
|
+
async def start(self) -> None:
|
|
107
|
+
"""在后台启动反向 WebSocket 服务,不阻塞当前协程。
|
|
108
|
+
|
|
109
|
+
与 stop 配对;长期运行请用 run。
|
|
110
|
+
"""
|
|
111
|
+
await emit_void(self._listeners, "on_start")
|
|
112
|
+
await self._bot.start()
|
|
113
|
+
|
|
114
|
+
async def stop(self) -> None:
|
|
115
|
+
"""停止服务并结束事件循环;会触发监听器的 on_stop。"""
|
|
116
|
+
await self._bot.stop()
|
|
117
|
+
await emit_void(self._listeners, "on_stop")
|
|
118
|
+
|
|
119
|
+
async def run(self) -> None:
|
|
120
|
+
"""启动并阻塞直到停止;适合 asyncio.run(adapter.run())。
|
|
121
|
+
|
|
122
|
+
Ctrl+C 或任务取消时会断开连接并触发 on_stop。
|
|
123
|
+
"""
|
|
124
|
+
await emit_void(self._listeners, "on_start")
|
|
125
|
+
try:
|
|
126
|
+
await self._bot.run()
|
|
127
|
+
finally:
|
|
128
|
+
await emit_void(self._listeners, "on_stop")
|
lagrange_adapter/bot.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from aiocqhttp import CQHttp, Event
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from lagrange_adapter.models import BotMessage, MessageType
|
|
9
|
+
|
|
10
|
+
VoidCallback = Callable[[], Awaitable[None]]
|
|
11
|
+
DisconnectCallback = Callable[[str], Awaitable[None]]
|
|
12
|
+
ErrorCallback = Callable[[BaseException], Awaitable[None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Bot(BaseModel):
|
|
16
|
+
"""Lagrange 反向 WebSocket 服务:接收 CQ 事件并回发消息。
|
|
17
|
+
|
|
18
|
+
构造入参见各字段的 description;包外请优先使用 Adapter。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
port: int = Field(default=6199, description="本机监听端口,供 Lagrange 连接")
|
|
22
|
+
|
|
23
|
+
def model_post_init(self, ctx: object) -> None:
|
|
24
|
+
"""初始化运行期状态与事件槽位。"""
|
|
25
|
+
self._stop_event = asyncio.Event()
|
|
26
|
+
|
|
27
|
+
self._bot = None
|
|
28
|
+
self._login_info = None
|
|
29
|
+
self._bot_name: str = None
|
|
30
|
+
self._bot_id: str = None
|
|
31
|
+
self._running = False
|
|
32
|
+
self._on_message: Callable[[BotMessage], Awaitable[None]] | None = None
|
|
33
|
+
self._on_connect: VoidCallback | None = None
|
|
34
|
+
self._on_ready: VoidCallback | None = None
|
|
35
|
+
self._on_disconnect: DisconnectCallback | None = None
|
|
36
|
+
self._on_error: ErrorCallback | None = None
|
|
37
|
+
self._ws_task: asyncio.Task[None] | None = None
|
|
38
|
+
|
|
39
|
+
def on_message(self, callback: Callable[[BotMessage], Awaitable[None]]) -> None:
|
|
40
|
+
"""登记入站聊天事件回调。
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
callback: 异步处理包内机器人消息
|
|
44
|
+
"""
|
|
45
|
+
self._on_message = callback
|
|
46
|
+
|
|
47
|
+
def on_connect(self, callback: VoidCallback) -> None:
|
|
48
|
+
"""登记 WebSocket 已连接回调。"""
|
|
49
|
+
self._on_connect = callback
|
|
50
|
+
|
|
51
|
+
def on_ready(self, callback: VoidCallback) -> None:
|
|
52
|
+
"""登记登录信息就绪回调。"""
|
|
53
|
+
self._on_ready = callback
|
|
54
|
+
|
|
55
|
+
def on_disconnect(self, callback: DisconnectCallback) -> None:
|
|
56
|
+
"""登记连接断开回调。
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
callback: 异步处理断开原因短句
|
|
60
|
+
"""
|
|
61
|
+
self._on_disconnect = callback
|
|
62
|
+
|
|
63
|
+
def on_error(self, callback: ErrorCallback) -> None:
|
|
64
|
+
"""登记运行期错误回调。
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
callback: 异步处理异常对象
|
|
68
|
+
"""
|
|
69
|
+
self._on_error = callback
|
|
70
|
+
|
|
71
|
+
async def _emit_connect(self) -> None:
|
|
72
|
+
if self._on_connect is None:
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
await self._on_connect()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
async def _emit_ready(self) -> None:
|
|
80
|
+
if self._on_ready is None:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
await self._on_ready()
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
async def _emit_disconnect(self, reason: str) -> None:
|
|
88
|
+
if self._on_disconnect is None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
await self._on_disconnect(reason)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
async def _emit_error(self, exc: BaseException) -> None:
|
|
96
|
+
if self._on_error is None:
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
await self._on_error(exc)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
async def _get_login_info(self):
|
|
104
|
+
try:
|
|
105
|
+
self._login_info = await self._bot.get_login_info()
|
|
106
|
+
self._bot_name = self._login_info["nickname"]
|
|
107
|
+
self._bot_id = str(self._login_info["user_id"])
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
async def _handle_ws(self):
|
|
112
|
+
self._bot = CQHttp(api_root='')
|
|
113
|
+
|
|
114
|
+
@self._bot.on_websocket_connection
|
|
115
|
+
async def _(_):
|
|
116
|
+
await self._emit_connect()
|
|
117
|
+
await self._get_login_info()
|
|
118
|
+
await self._emit_ready()
|
|
119
|
+
|
|
120
|
+
@self._bot.on_message
|
|
121
|
+
async def _(event: Event, **kwargs):
|
|
122
|
+
if self._login_info is None:
|
|
123
|
+
await self._get_login_info()
|
|
124
|
+
|
|
125
|
+
user_name = str(event.user_id)
|
|
126
|
+
session_id = str(event.group_id) if event.message_type == 'group' else user_name
|
|
127
|
+
message_type = MessageType.GROUP if event.message_type == 'group' else MessageType.PRIVATE
|
|
128
|
+
data_list = event.message
|
|
129
|
+
message_id = str(event.message_id)
|
|
130
|
+
|
|
131
|
+
message = BotMessage(
|
|
132
|
+
session_id=session_id,
|
|
133
|
+
data_list=data_list,
|
|
134
|
+
bot_id=self._bot_id,
|
|
135
|
+
message_type=message_type,
|
|
136
|
+
user_name=user_name,
|
|
137
|
+
message_id=message_id,
|
|
138
|
+
bot_name=self._bot_name,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if self._on_message is not None:
|
|
142
|
+
try:
|
|
143
|
+
await self._on_message(message)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
@self._bot.on('error')
|
|
148
|
+
async def _(event: Event):
|
|
149
|
+
await self._emit_error(RuntimeError(str(event)))
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
await self._bot.run_task(
|
|
153
|
+
host='0.0.0.0',
|
|
154
|
+
port=self.port,
|
|
155
|
+
shutdown_trigger=self._stop_event.wait,
|
|
156
|
+
)
|
|
157
|
+
finally:
|
|
158
|
+
await self._emit_disconnect("stopped")
|
|
159
|
+
|
|
160
|
+
async def start(self) -> None:
|
|
161
|
+
"""在后台启动反向 WebSocket 服务,不阻塞当前协程。"""
|
|
162
|
+
if self._running:
|
|
163
|
+
return
|
|
164
|
+
self._running = True
|
|
165
|
+
self._stop_event.clear()
|
|
166
|
+
self._ws_task = asyncio.create_task(self._handle_ws())
|
|
167
|
+
|
|
168
|
+
async def send(self, message: BotMessage) -> None:
|
|
169
|
+
"""向群或好友发送 CQ 段列表。
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
message: 含会话类型与 data_list 的包内消息
|
|
173
|
+
"""
|
|
174
|
+
data_list = [
|
|
175
|
+
{"type": segment["type"], "data": segment.get("data", {})}
|
|
176
|
+
for segment in message.data_list
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
sid = int(message.session_id)
|
|
181
|
+
if message.message_type == MessageType.GROUP:
|
|
182
|
+
await self._bot.send_msg(
|
|
183
|
+
message_type="group",
|
|
184
|
+
group_id=sid,
|
|
185
|
+
message=data_list,
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
await self._bot.send_msg(
|
|
189
|
+
message_type="private",
|
|
190
|
+
user_id=sid,
|
|
191
|
+
message=data_list,
|
|
192
|
+
)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
async def stop(self) -> None:
|
|
197
|
+
"""触发关闭并结束常驻运行。"""
|
|
198
|
+
if not self._running:
|
|
199
|
+
self._stop_event.set()
|
|
200
|
+
return
|
|
201
|
+
self._running = False
|
|
202
|
+
self._stop_event.set()
|
|
203
|
+
if self._ws_task is not None and self._ws_task is not asyncio.current_task():
|
|
204
|
+
self._ws_task.cancel()
|
|
205
|
+
with suppress(asyncio.CancelledError):
|
|
206
|
+
await self._ws_task
|
|
207
|
+
self._ws_task = None
|
|
208
|
+
|
|
209
|
+
async def run(self) -> None:
|
|
210
|
+
"""启动服务并阻塞;Ctrl+C 或任务取消时收尾退出。"""
|
|
211
|
+
await self.start()
|
|
212
|
+
try:
|
|
213
|
+
await self._stop_event.wait()
|
|
214
|
+
finally:
|
|
215
|
+
await self.stop()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from onebot_protocol import MessagePayload
|
|
5
|
+
|
|
6
|
+
MessageCallback = Callable[[MessagePayload], Awaitable[None]]
|
|
7
|
+
VoidCallback = Callable[[], Awaitable[None]]
|
|
8
|
+
DisconnectCallback = Callable[[str], Awaitable[None]]
|
|
9
|
+
ErrorCallback = Callable[[BaseException], Awaitable[None]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Listener:
|
|
14
|
+
"""按事件名挂接异步回调;未赋值的槽位不参与派发。
|
|
15
|
+
|
|
16
|
+
构造适配器时传入列表,与运行期登记的消息回调可同时存在。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
on_message: MessageCallback | None = None
|
|
20
|
+
"""收到已转为统一消息载荷的入站聊天消息。"""
|
|
21
|
+
on_start: VoidCallback | None = None
|
|
22
|
+
"""适配器开始监听或进入常驻运行。"""
|
|
23
|
+
on_stop: VoidCallback | None = None
|
|
24
|
+
"""适配器停止监听或常驻运行结束。"""
|
|
25
|
+
on_ready: VoidCallback | None = None
|
|
26
|
+
"""平台侧会话就绪,可稳定收发。"""
|
|
27
|
+
on_connect: VoidCallback | None = None
|
|
28
|
+
"""底层传输已连通,未必已完成鉴权。"""
|
|
29
|
+
on_disconnect: DisconnectCallback | None = None
|
|
30
|
+
"""传输断开;参数为平台给出的原因短句。"""
|
|
31
|
+
on_error: ErrorCallback | None = None
|
|
32
|
+
"""连接或运行期未捕获的异常。"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def emit_void(listeners: list[Listener], name: str) -> None:
|
|
36
|
+
"""对监听器列表派发无参生命周期类回调。
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
listeners: 适配器持有的监听器实例
|
|
40
|
+
name: 监听器上对应的无参槽位名
|
|
41
|
+
"""
|
|
42
|
+
for listener in listeners:
|
|
43
|
+
cb = getattr(listener, name, None)
|
|
44
|
+
if cb is None:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
await cb()
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def emit_disconnect(listeners: list[Listener], reason: str) -> None:
|
|
53
|
+
"""对监听器列表派发断开回调。
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
listeners: 适配器持有的监听器实例
|
|
57
|
+
reason: 断开原因,供日志或重连策略使用
|
|
58
|
+
"""
|
|
59
|
+
for listener in listeners:
|
|
60
|
+
if listener.on_disconnect is None:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
await listener.on_disconnect(reason)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def emit_error(listeners: list[Listener], exc: BaseException) -> None:
|
|
69
|
+
"""对监听器列表派发错误回调。
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
listeners: 适配器持有的监听器实例
|
|
73
|
+
exc: 本次错误对象
|
|
74
|
+
"""
|
|
75
|
+
for listener in listeners:
|
|
76
|
+
if listener.on_error is None:
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
await listener.on_error(exc)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def emit_message(listeners: list[Listener], payload: MessagePayload) -> None:
|
|
85
|
+
"""对监听器列表派发消息回调。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
listeners: 适配器持有的监听器实例
|
|
89
|
+
payload: 已转换为对外统一格式的入站消息
|
|
90
|
+
"""
|
|
91
|
+
for listener in listeners:
|
|
92
|
+
if listener.on_message is None:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
await listener.on_message(payload)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Annotated, Literal, Union
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MessageType(StrEnum):
|
|
8
|
+
"""会话类型:私聊或群聊。"""
|
|
9
|
+
|
|
10
|
+
PRIVATE = "private"
|
|
11
|
+
GROUP = "group"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseSegment(BaseModel):
|
|
15
|
+
"""CQ 消息段通用壳:类型名与原始数据字典。"""
|
|
16
|
+
|
|
17
|
+
type: str = Field(description="段类型,如 text、at、image")
|
|
18
|
+
data: dict = Field(description="协议原始字段字典")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AtSegment(BaseSegment):
|
|
22
|
+
"""@ 成员段。"""
|
|
23
|
+
|
|
24
|
+
type: Literal["at"] = "at"
|
|
25
|
+
qq: str | None = None
|
|
26
|
+
name: str | None = None
|
|
27
|
+
|
|
28
|
+
def model_post_init(self, ctx) -> None:
|
|
29
|
+
self.qq = self.data["qq"]
|
|
30
|
+
self.name = self.data["name"].lstrip("@").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FaceSegment(BaseSegment):
|
|
34
|
+
"""QQ 表情段。"""
|
|
35
|
+
|
|
36
|
+
type: Literal["face"] = "face"
|
|
37
|
+
id: str | None = None
|
|
38
|
+
large: str | None = None
|
|
39
|
+
|
|
40
|
+
def model_post_init(self, ctx) -> None:
|
|
41
|
+
self.id = self.data["id"]
|
|
42
|
+
self.large = self.data["large"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TextSegment(BaseSegment):
|
|
46
|
+
"""纯文本段。"""
|
|
47
|
+
|
|
48
|
+
type: Literal["text"] = "text"
|
|
49
|
+
text: str | None = None
|
|
50
|
+
|
|
51
|
+
def model_post_init(self, ctx) -> None:
|
|
52
|
+
self.text = self.data["text"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReplySegment(BaseSegment):
|
|
56
|
+
"""引用回复段。"""
|
|
57
|
+
|
|
58
|
+
type: Literal["reply"] = "reply"
|
|
59
|
+
id: str | None = None
|
|
60
|
+
|
|
61
|
+
def model_post_init(self, ctx) -> None:
|
|
62
|
+
self.id = self.data["id"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MfaceSegment(BaseSegment):
|
|
66
|
+
"""商城表情段。"""
|
|
67
|
+
|
|
68
|
+
type: Literal["mface"] = "mface"
|
|
69
|
+
url: str | None = None
|
|
70
|
+
emoji_package_id: str | None = None
|
|
71
|
+
emoji_id: str | None = None
|
|
72
|
+
key: str | None = None
|
|
73
|
+
summary: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LocationSegment(BaseSegment):
|
|
77
|
+
"""位置段。"""
|
|
78
|
+
|
|
79
|
+
type: Literal["location"] = "location"
|
|
80
|
+
lat: float | None = None
|
|
81
|
+
lon: float | None = None
|
|
82
|
+
title: str | None = None
|
|
83
|
+
content: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class JsonSegment(BaseSegment):
|
|
87
|
+
"""JSON 卡片段。"""
|
|
88
|
+
|
|
89
|
+
type: Literal["json"] = "json"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ImageSegment(BaseSegment):
|
|
93
|
+
"""图片段。"""
|
|
94
|
+
|
|
95
|
+
type: Literal["image"] = "image"
|
|
96
|
+
file: str | None = None
|
|
97
|
+
filename: str | None = None
|
|
98
|
+
url: str | None = None
|
|
99
|
+
summary: str | None = None
|
|
100
|
+
subType: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ForwardSegment(BaseSegment):
|
|
104
|
+
"""合并转发段。"""
|
|
105
|
+
|
|
106
|
+
type: Literal["forward"] = "forward"
|
|
107
|
+
id: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class VideoSegment(BaseSegment):
|
|
111
|
+
"""视频段。"""
|
|
112
|
+
|
|
113
|
+
type: Literal["video"] = "video"
|
|
114
|
+
file: str | None = None
|
|
115
|
+
url: str | None = None
|
|
116
|
+
|
|
117
|
+
def model_post_init(self, ctx) -> None:
|
|
118
|
+
self.file = self.data["file"]
|
|
119
|
+
self.url = self.data["url"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
Segment = Annotated[
|
|
123
|
+
Union[
|
|
124
|
+
TextSegment,
|
|
125
|
+
ImageSegment,
|
|
126
|
+
FaceSegment,
|
|
127
|
+
AtSegment,
|
|
128
|
+
ForwardSegment,
|
|
129
|
+
ReplySegment,
|
|
130
|
+
JsonSegment,
|
|
131
|
+
VideoSegment,
|
|
132
|
+
MfaceSegment,
|
|
133
|
+
LocationSegment,
|
|
134
|
+
],
|
|
135
|
+
Field(discriminator="type"),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class BotMessage(BaseModel):
|
|
140
|
+
"""包内入站/出站消息:CQ 段列表与会话上下文。"""
|
|
141
|
+
|
|
142
|
+
message_id: str = Field(description="平台分配的消息编号")
|
|
143
|
+
data_list: list[dict] = Field(description="CQ 协议段列表,每项含 type 与 data")
|
|
144
|
+
message_type: MessageType = Field(description="私聊或群聊")
|
|
145
|
+
bot_id: str = Field(description="当前机器人 QQ 号")
|
|
146
|
+
bot_name: str | None = Field(default=None, description="机器人昵称,用于解析 @")
|
|
147
|
+
session_id: str = Field(description="群号或好友号,用作发送目标")
|
|
148
|
+
user_name: str = Field(description="发送方 QQ 号")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = [
|
|
152
|
+
"BotMessage",
|
|
153
|
+
"BaseSegment",
|
|
154
|
+
"TextSegment",
|
|
155
|
+
"ImageSegment",
|
|
156
|
+
"FaceSegment",
|
|
157
|
+
"AtSegment",
|
|
158
|
+
"ForwardSegment",
|
|
159
|
+
"ReplySegment",
|
|
160
|
+
"JsonSegment",
|
|
161
|
+
"VideoSegment",
|
|
162
|
+
"MfaceSegment",
|
|
163
|
+
"LocationSegment",
|
|
164
|
+
"Segment",
|
|
165
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from typing import List, Union, Optional
|
|
2
|
+
from lagrange_adapter.models import (
|
|
3
|
+
BaseSegment,
|
|
4
|
+
TextSegment,
|
|
5
|
+
ImageSegment,
|
|
6
|
+
FaceSegment,
|
|
7
|
+
AtSegment,
|
|
8
|
+
ForwardSegment,
|
|
9
|
+
ReplySegment,
|
|
10
|
+
JsonSegment,
|
|
11
|
+
VideoSegment,
|
|
12
|
+
MfaceSegment,
|
|
13
|
+
LocationSegment,
|
|
14
|
+
BotMessage,
|
|
15
|
+
MessageType,
|
|
16
|
+
)
|
|
17
|
+
import onebot_protocol
|
|
18
|
+
from onebot_protocol import MessagePayload
|
|
19
|
+
|
|
20
|
+
SEGMENT_MAP = {
|
|
21
|
+
"text": TextSegment,
|
|
22
|
+
"image": ImageSegment,
|
|
23
|
+
"face": FaceSegment,
|
|
24
|
+
"at": AtSegment,
|
|
25
|
+
"forward": ForwardSegment,
|
|
26
|
+
"reply": ReplySegment,
|
|
27
|
+
"json": JsonSegment,
|
|
28
|
+
"video": VideoSegment,
|
|
29
|
+
"mface": MfaceSegment,
|
|
30
|
+
"location": LocationSegment,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
USER_MAP: dict[str, str] = {}
|
|
34
|
+
|
|
35
|
+
MENTION_ALL_NAME = "全体成员"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def data_to_segments(data_list: list[dict], bot_name: str, bot_id: str) -> list[BaseSegment]:
|
|
39
|
+
"""把原始段字典列表转为强类型段,并拆分正文里的 @ 机器人。
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
data_list: 事件中的 CQ 段列表
|
|
43
|
+
bot_name: 机器人昵称,用于匹配 @
|
|
44
|
+
bot_id: 机器人 QQ 号
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
过滤无效项并展开 @ 后的段列表
|
|
48
|
+
"""
|
|
49
|
+
segments = [_cast_segment(x) for x in data_list]
|
|
50
|
+
segments = [x for x in segments if x]
|
|
51
|
+
|
|
52
|
+
segments = [
|
|
53
|
+
(_extract_mention_robot(x, bot_name, bot_id) if isinstance(x, TextSegment) else [x])
|
|
54
|
+
for x in segments
|
|
55
|
+
]
|
|
56
|
+
segments = [x for data in segments for x in data]
|
|
57
|
+
|
|
58
|
+
return segments
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def onebot_to_bot(payload: MessagePayload) -> BotMessage:
|
|
62
|
+
"""把对外统一载荷转成 CQ 段列表供 aiocqhttp 发送。
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
payload: 会话、消息段与机器人标识
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
含 data_list 的包内机器人消息
|
|
69
|
+
"""
|
|
70
|
+
data_list = []
|
|
71
|
+
for message in payload.messages:
|
|
72
|
+
if isinstance(message, onebot_protocol.TextMessageSegment):
|
|
73
|
+
message = TextSegment(data={"text": message.data.text})
|
|
74
|
+
elif isinstance(message, onebot_protocol.MentionMessageSegment):
|
|
75
|
+
name = USER_MAP.get(message.data.user_id)
|
|
76
|
+
if not name:
|
|
77
|
+
continue
|
|
78
|
+
message = AtSegment(data={"qq": message.data.user_id, "name": name})
|
|
79
|
+
data_list.append(message.model_dump())
|
|
80
|
+
|
|
81
|
+
msg = BotMessage(
|
|
82
|
+
message_id=payload.message_id,
|
|
83
|
+
data_list=data_list,
|
|
84
|
+
message_type=MessageType(payload.source_type),
|
|
85
|
+
bot_id=payload.bot_id,
|
|
86
|
+
session_id=payload.session_id,
|
|
87
|
+
user_name=payload.user_id or "",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if msg.message_type == MessageType.GROUP:
|
|
91
|
+
if msg.user_name:
|
|
92
|
+
msg.data_list = [
|
|
93
|
+
AtSegment(data={"qq": msg.user_name, "name": USER_MAP.get(msg.user_name, "")}).model_dump(),
|
|
94
|
+
TextSegment(data={"text": " "}).model_dump(),
|
|
95
|
+
] + msg.data_list
|
|
96
|
+
|
|
97
|
+
return msg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def bot_to_onebot(msg: BotMessage) -> Optional[MessagePayload]:
|
|
101
|
+
"""把入站 CQ 消息转为对外统一载荷;群聊未 @ 机器人时返回空。
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
msg: 事件解析后的包内消息
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
可上报的统一载荷;无需上报时为 None
|
|
108
|
+
"""
|
|
109
|
+
global USER_MAP
|
|
110
|
+
|
|
111
|
+
segments = data_to_segments(msg.data_list, msg.bot_name, msg.bot_id)
|
|
112
|
+
|
|
113
|
+
if not _should_broadcast(msg, segments):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
for segment in segments:
|
|
117
|
+
if isinstance(segment, AtSegment) and segment.qq == msg.bot_id:
|
|
118
|
+
segments.remove(segment)
|
|
119
|
+
|
|
120
|
+
messages = []
|
|
121
|
+
for segment in segments:
|
|
122
|
+
if isinstance(segment, TextSegment):
|
|
123
|
+
text = segment.text.strip()
|
|
124
|
+
if not text:
|
|
125
|
+
continue
|
|
126
|
+
message = onebot_protocol.TextMessageSegment(data={"text": text})
|
|
127
|
+
elif isinstance(segment, AtSegment):
|
|
128
|
+
if segment.name == MENTION_ALL_NAME:
|
|
129
|
+
message = onebot_protocol.MentionAllMessageSegment()
|
|
130
|
+
else:
|
|
131
|
+
message = onebot_protocol.MentionMessageSegment(data={"user_id": segment.qq})
|
|
132
|
+
USER_MAP[segment.qq] = segment.name
|
|
133
|
+
else:
|
|
134
|
+
continue
|
|
135
|
+
messages.append(message)
|
|
136
|
+
|
|
137
|
+
if not messages:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
return MessagePayload(
|
|
141
|
+
message_id=msg.message_id,
|
|
142
|
+
source_type=msg.message_type.value,
|
|
143
|
+
bot_id=msg.bot_id,
|
|
144
|
+
session_id=msg.session_id,
|
|
145
|
+
user_id=msg.user_name,
|
|
146
|
+
messages=messages,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _should_broadcast(msg: BotMessage, segments: list[BaseSegment]) -> bool:
|
|
151
|
+
"""群聊仅在 @ 到机器人时向上层上报。"""
|
|
152
|
+
if msg.message_type == MessageType.GROUP:
|
|
153
|
+
for segment in segments:
|
|
154
|
+
if isinstance(segment, AtSegment) and segment.qq == msg.bot_id:
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cast_segment(data: dict) -> Optional[BaseSegment]:
|
|
161
|
+
"""按 type 字段实例化对应段模型;未知或校验失败返回 None。"""
|
|
162
|
+
cls = SEGMENT_MAP.get(data["type"])
|
|
163
|
+
if not cls:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return cls(**data)
|
|
167
|
+
except Exception:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_mention_robot(
|
|
172
|
+
text: TextSegment, bot_name: str, bot_id: str
|
|
173
|
+
) -> List[Union[TextSegment, AtSegment]]:
|
|
174
|
+
"""把正文中「@昵称 」拆成文本段与 @ 段。"""
|
|
175
|
+
|
|
176
|
+
def split(text: TextSegment) -> List[Union[TextSegment, AtSegment]]:
|
|
177
|
+
content = text.text
|
|
178
|
+
|
|
179
|
+
keyword = f"@{bot_name} "
|
|
180
|
+
if keyword in content:
|
|
181
|
+
index = content.find(keyword)
|
|
182
|
+
before_text = content[:index]
|
|
183
|
+
after_text = content[index + len(keyword):]
|
|
184
|
+
return (
|
|
185
|
+
split(TextSegment(data={"text": before_text}))
|
|
186
|
+
+ [AtSegment(data={"name": bot_name, "qq": bot_id})]
|
|
187
|
+
+ split(TextSegment(data={"text": after_text}))
|
|
188
|
+
)
|
|
189
|
+
return [text]
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
return split(text)
|
|
193
|
+
except Exception:
|
|
194
|
+
return [text]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-library-lagrange-adapter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lagrange / aiocqhttp CQ 消息段与 OneBot 载荷互转及接入
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: aiocqhttp
|
|
7
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
8
|
+
Requires-Dist: python-library-onebot-protocol
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
lagrange_adapter/__init__.py,sha256=iYOT58Voz7NrxvrUpv3iv4I-HYxsNd91iUdpooUzuLU,131
|
|
2
|
+
lagrange_adapter/adapter.py,sha256=dlcdTD19CvGz5CZd82_1juOPtcOYu_TKpFh1WWCGL4w,4285
|
|
3
|
+
lagrange_adapter/bot.py,sha256=llMdDPvcL8_jIDBZkuUtBWl21tWKRgFNFwel23fs0Vo,7100
|
|
4
|
+
lagrange_adapter/listener.py,sha256=BjcfQs-M1_2m9tjUJOkfUilizpRftEiD9HfGoyv4KDA,3150
|
|
5
|
+
lagrange_adapter/models.py,sha256=64Hc6jLWAM5RtxyhYpggdOxU_yMDn7F2zLAvXy_jzbs,4020
|
|
6
|
+
lagrange_adapter/protocol_adapt.py,sha256=8Esf5elCjCH964x_hTNLKxumiox9qbwM5ZLo3Vg9y6E,5983
|
|
7
|
+
python_library_lagrange_adapter-0.1.0.dist-info/METADATA,sha256=jbGHLW5wzIB3_zRPYQYgXWcZoARTvsVx7TKckJicBkQ,277
|
|
8
|
+
python_library_lagrange_adapter-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
python_library_lagrange_adapter-0.1.0.dist-info/RECORD,,
|