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.
@@ -0,0 +1,4 @@
1
+ from lagrange_adapter.adapter import Adapter
2
+ from lagrange_adapter.listener import Listener
3
+
4
+ __all__ = ["Adapter", "Listener"]
@@ -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")
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any