python-library-napcat-adapter 0.1.0__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,21 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .env.*
10
+ !.env.example
11
+ # packages 示例本地密钥(*.example 为模板,可提交)
12
+ packages/**/examples/**/.env
13
+ !packages/**/examples/**/.env.example
14
+ packages/**/examples/**/mcp.json
15
+ !packages/**/examples/**/mcp.json.example
16
+ packages/**/examples/**/.sandbox/
17
+ .pytest_cache/
18
+ config.yaml
19
+ logs/
20
+ .cursor/
21
+ uv.lock
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-napcat-adapter
3
+ Version: 0.1.0
4
+ Summary: NapCat CQ 消息段与 OneBot 载荷互转及接入
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: napcat-sdk
7
+ Requires-Dist: pydantic<3,>=2.0
8
+ Requires-Dist: python-library-onebot-protocol
@@ -0,0 +1,4 @@
1
+ from napcat_adapter.adapter import Adapter
2
+ from napcat_adapter.listener import Listener
3
+
4
+ __all__ = ["Adapter", "Listener"]
@@ -0,0 +1,136 @@
1
+ from collections.abc import Awaitable, Callable
2
+
3
+ from onebot_protocol import MessagePayload
4
+
5
+ from napcat_adapter.bot import Bot
6
+ from napcat_adapter.listener import (
7
+ Listener,
8
+ emit_disconnect,
9
+ emit_error,
10
+ emit_message,
11
+ emit_void,
12
+ )
13
+ from napcat_adapter.models import BotMessage
14
+ from napcat_adapter.protocol_adapt import bot_to_onebot, onebot_to_bot
15
+
16
+ MessageCallback = Callable[[MessagePayload], Awaitable[None]]
17
+
18
+
19
+ class Adapter:
20
+ """NapCat 对外门面:入站转统一消息载荷,出站从载荷经 CQ 段发回。
21
+
22
+ 群聊默认仅在 @ 机器人时上报;过滤规则在包内完成。
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ ws_url: str,
28
+ *,
29
+ token: str | None = None,
30
+ reconnect_interval_seconds: float = 5.0,
31
+ listeners: list[Listener] | None = None,
32
+ ) -> None:
33
+ """创建适配器并绑定内部 NapCat 客户端。
34
+
35
+ Args:
36
+ ws_url: NapCat 正向 WebSocket 地址
37
+ token: 可选访问令牌
38
+ reconnect_interval_seconds: 断线后重连等待秒数
39
+ listeners: 构造期注册的多组事件监听器
40
+ """
41
+ self._callbacks: list[MessageCallback] = []
42
+ self._listeners: list[Listener] = list(listeners or [])
43
+ self._bot = Bot(
44
+ ws_url=ws_url,
45
+ token=token,
46
+ reconnect_interval_seconds=reconnect_interval_seconds,
47
+ )
48
+ self._bot.on_message(self._on_bot_message)
49
+ self._bot.on_connect(self._on_connect)
50
+ self._bot.on_ready(self._on_ready)
51
+ self._bot.on_disconnect(self._on_disconnect)
52
+ self._bot.on_error(self._on_error)
53
+
54
+ def register(self, callback: MessageCallback) -> None:
55
+ """登记一条消息回调,与监听器中的消息槽位一并触发。
56
+
57
+ Args:
58
+ callback: 异步处理入站统一消息载荷
59
+ """
60
+ self._callbacks.append(callback)
61
+
62
+ def on_message(
63
+ self, callback: MessageCallback | None = None
64
+ ) -> MessageCallback | Callable[[MessageCallback], MessageCallback]:
65
+ """登记消息回调;无参调用时返回装饰器。
66
+
67
+ Args:
68
+ callback: 直接传入的异步处理函数;省略时用于装饰器写法
69
+
70
+ Returns:
71
+ 装饰器模式下返回原函数;直接传入时返回同一回调
72
+ """
73
+ if callback is not None:
74
+ self.register(callback)
75
+ return callback
76
+
77
+ def decorator(fn: MessageCallback) -> MessageCallback:
78
+ self.register(fn)
79
+ return fn
80
+
81
+ return decorator
82
+
83
+ async def send(self, payload: MessagePayload) -> None:
84
+ """按统一载荷向当前会话发送消息。
85
+
86
+ Args:
87
+ payload: 目标会话与消息段列表
88
+ """
89
+ await self._bot.send(onebot_to_bot(payload))
90
+
91
+ async def _on_bot_message(self, msg: BotMessage) -> None:
92
+ payload = bot_to_onebot(msg)
93
+ if payload is None:
94
+ return
95
+ await emit_message(self._listeners, payload)
96
+ for callback in self._callbacks:
97
+ try:
98
+ await callback(payload)
99
+ except Exception:
100
+ pass
101
+
102
+ async def _on_connect(self) -> None:
103
+ await emit_void(self._listeners, "on_connect")
104
+
105
+ async def _on_ready(self) -> None:
106
+ await emit_void(self._listeners, "on_ready")
107
+
108
+ async def _on_disconnect(self, reason: str) -> None:
109
+ await emit_disconnect(self._listeners, reason)
110
+
111
+ async def _on_error(self, exc: BaseException) -> None:
112
+ await emit_error(self._listeners, exc)
113
+
114
+ async def start(self) -> None:
115
+ """在后台启动 WebSocket 事件循环,不阻塞当前协程。
116
+
117
+ 与 stop 配对;长期运行请用 run。
118
+ """
119
+ await emit_void(self._listeners, "on_start")
120
+ await self._bot.start()
121
+
122
+ async def stop(self) -> None:
123
+ """停止客户端并结束事件循环;会触发监听器的 on_stop。"""
124
+ await self._bot.stop()
125
+ await emit_void(self._listeners, "on_stop")
126
+
127
+ async def run(self) -> None:
128
+ """启动并阻塞直到停止;适合 asyncio.run(adapter.run())。
129
+
130
+ Ctrl+C 或任务取消时会断开连接并触发 on_stop。
131
+ """
132
+ await emit_void(self._listeners, "on_start")
133
+ try:
134
+ await self._bot.run()
135
+ finally:
136
+ await emit_void(self._listeners, "on_stop")
@@ -0,0 +1,265 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from contextlib import suppress
4
+ from typing import Any
5
+
6
+ from napcat import GroupMessageEvent, NapCatClient, PrivateMessageEvent
7
+ from napcat.types.messages import Message, MessageSegment, UnknownMessageSegment
8
+ from pydantic import BaseModel, Field, PrivateAttr
9
+
10
+ from napcat_adapter.models import BotMessage, MessageType
11
+
12
+ VoidCallback = Callable[[], Awaitable[None]]
13
+ DisconnectCallback = Callable[[str], Awaitable[None]]
14
+ ErrorCallback = Callable[[BaseException], Awaitable[None]]
15
+
16
+
17
+ class Bot(BaseModel):
18
+ """NapCat 正向 WebSocket 客户端:收事件、发 CQ 消息。
19
+
20
+ 构造入参见各字段的 description;包外请优先使用 Adapter。
21
+ """
22
+
23
+ ws_url: str = Field(description="NapCat 正向 WebSocket 地址")
24
+ token: str | None = Field(default=None, description="NapCat 访问令牌")
25
+ reconnect_interval_seconds: float = Field(
26
+ default=5.0,
27
+ ge=0.5,
28
+ le=3600.0,
29
+ description="断线或连接失败后再次尝试前的等待秒数",
30
+ )
31
+
32
+ _stop_event: asyncio.Event = PrivateAttr()
33
+ _task: asyncio.Task[None] | None = PrivateAttr(default=None)
34
+ _client: NapCatClient | None = PrivateAttr(default=None)
35
+ _login_info: dict[str, Any] | None = PrivateAttr(default=None)
36
+ _bot_name: str = PrivateAttr(default="")
37
+ _bot_id: str = PrivateAttr(default="")
38
+ _running: bool = PrivateAttr(default=False)
39
+ _on_message: Callable[[BotMessage], Awaitable[None]] | None = PrivateAttr(default=None)
40
+ _on_connect: VoidCallback | None = PrivateAttr(default=None)
41
+ _on_ready: VoidCallback | None = PrivateAttr(default=None)
42
+ _on_disconnect: DisconnectCallback | None = PrivateAttr(default=None)
43
+ _on_error: ErrorCallback | None = PrivateAttr(default=None)
44
+
45
+ def model_post_init(self, ctx: Any) -> None:
46
+ """初始化运行期状态与事件槽位。"""
47
+ self._stop_event = asyncio.Event()
48
+
49
+ def on_message(self, callback: Callable[[BotMessage], Awaitable[None]]) -> None:
50
+ """登记入站聊天事件回调。
51
+
52
+ Args:
53
+ callback: 异步处理包内机器人消息
54
+ """
55
+ self._on_message = callback
56
+
57
+ def on_connect(self, callback: VoidCallback) -> None:
58
+ """登记 WebSocket 已连接回调。"""
59
+ self._on_connect = callback
60
+
61
+ def on_ready(self, callback: VoidCallback) -> None:
62
+ """登记登录信息就绪回调。"""
63
+ self._on_ready = callback
64
+
65
+ def on_disconnect(self, callback: DisconnectCallback) -> None:
66
+ """登记连接断开回调。
67
+
68
+ Args:
69
+ callback: 异步处理断开原因短句
70
+ """
71
+ self._on_disconnect = callback
72
+
73
+ def on_error(self, callback: ErrorCallback) -> None:
74
+ """登记运行期错误回调。
75
+
76
+ Args:
77
+ callback: 异步处理异常对象
78
+ """
79
+ self._on_error = callback
80
+
81
+ async def _emit_connect(self) -> None:
82
+ if self._on_connect is None:
83
+ return
84
+ try:
85
+ await self._on_connect()
86
+ except Exception:
87
+ pass
88
+
89
+ async def _emit_ready(self) -> None:
90
+ if self._on_ready is None:
91
+ return
92
+ try:
93
+ await self._on_ready()
94
+ except Exception:
95
+ pass
96
+
97
+ async def _emit_disconnect(self, reason: str) -> None:
98
+ if self._on_disconnect is None:
99
+ return
100
+ try:
101
+ await self._on_disconnect(reason)
102
+ except Exception:
103
+ pass
104
+
105
+ async def _emit_error(self, exc: BaseException) -> None:
106
+ if self._on_error is None:
107
+ return
108
+ try:
109
+ await self._on_error(exc)
110
+ except Exception:
111
+ pass
112
+
113
+ async def _refresh_login_info(self) -> None:
114
+ if self._client is None:
115
+ return
116
+ try:
117
+ login_info = await self._client.get_login_info()
118
+ user_id = int(login_info["user_id"])
119
+ self._client.self_id = user_id
120
+ self._login_info = dict(login_info)
121
+ self._bot_id = str(user_id)
122
+ self._bot_name = str(login_info["nickname"])
123
+ except Exception:
124
+ pass
125
+
126
+ async def _handle_events(self) -> None:
127
+ try:
128
+ while not self._stop_event.is_set():
129
+ self._client = NapCatClient(ws_url=self.ws_url, token=self.token)
130
+
131
+ try:
132
+ async with self._client:
133
+ await self._emit_connect()
134
+ await self._refresh_login_info()
135
+ await self._emit_ready()
136
+ async for event in self._client:
137
+ if self._stop_event.is_set():
138
+ break
139
+ if isinstance(event, (GroupMessageEvent, PrivateMessageEvent)):
140
+ await self._handle_message(event)
141
+ except asyncio.CancelledError:
142
+ raise
143
+ except ConnectionRefusedError as exc:
144
+ await self._emit_error(exc)
145
+ await self._emit_disconnect("connection_refused")
146
+ except Exception as exc:
147
+ await self._emit_error(exc)
148
+ await self._emit_disconnect("error")
149
+ else:
150
+ if self._stop_event.is_set():
151
+ await self._emit_disconnect("stopped")
152
+ else:
153
+ await self._emit_disconnect("closed")
154
+ finally:
155
+ self._client = None
156
+ self._login_info = None
157
+ self._bot_id = ""
158
+ self._bot_name = ""
159
+
160
+ if self._stop_event.is_set():
161
+ break
162
+
163
+ try:
164
+ await asyncio.wait_for(
165
+ self._stop_event.wait(),
166
+ timeout=self.reconnect_interval_seconds,
167
+ )
168
+ break
169
+ except TimeoutError:
170
+ continue
171
+ finally:
172
+ self._running = False
173
+
174
+ async def _handle_message(self, event: GroupMessageEvent | PrivateMessageEvent) -> None:
175
+ if self._login_info is None:
176
+ await self._refresh_login_info()
177
+
178
+ user_name = str(event.user_id)
179
+ session_id = str(event.group_id) if isinstance(event, GroupMessageEvent) else user_name
180
+ message_type = MessageType.GROUP if isinstance(event, GroupMessageEvent) else MessageType.PRIVATE
181
+ data_list = [dict(segment) for segment in event.message]
182
+
183
+ message = BotMessage(
184
+ session_id=session_id,
185
+ data_list=data_list,
186
+ bot_id=self._bot_id,
187
+ message_type=message_type,
188
+ user_name=user_name,
189
+ message_id=str(event.message_id),
190
+ bot_name=self._bot_name,
191
+ )
192
+
193
+ if self._on_message is None:
194
+ return
195
+ try:
196
+ await self._on_message(message)
197
+ except Exception:
198
+ pass
199
+
200
+ async def start(self) -> None:
201
+ """在后台启动 WebSocket 事件循环,不阻塞当前协程。"""
202
+ if self._running:
203
+ return
204
+ self._running = True
205
+ self._stop_event.clear()
206
+ self._task = asyncio.create_task(self._handle_events())
207
+
208
+ async def send(self, message: BotMessage) -> None:
209
+ """向群或好友发送 CQ 段列表。
210
+
211
+ Args:
212
+ message: 含会话类型与 data_list 的包内消息
213
+ """
214
+ if self._client is None or not self._client.is_running:
215
+ return
216
+
217
+ data_list = [
218
+ {"type": segment["type"], "data": segment.get("data", {})}
219
+ for segment in message.data_list
220
+ ]
221
+ messages = _to_napcat_messages(data_list)
222
+ if not messages:
223
+ return
224
+
225
+ try:
226
+ if message.message_type == MessageType.GROUP:
227
+ await self._client.send_group_msg(
228
+ group_id=message.session_id, message=messages
229
+ )
230
+ else:
231
+ await self._client.send_private_msg(
232
+ user_id=message.session_id, message=messages
233
+ )
234
+ except Exception:
235
+ pass
236
+
237
+ async def stop(self) -> None:
238
+ """停止客户端并结束事件循环。"""
239
+ if not self._running:
240
+ return
241
+ self._stop_event.set()
242
+ if self._task is not None and self._task is not asyncio.current_task():
243
+ self._task.cancel()
244
+ with suppress(asyncio.CancelledError):
245
+ await self._task
246
+ self._running = False
247
+
248
+ async def run(self) -> None:
249
+ """启动连接并阻塞;Ctrl+C 或任务取消时收尾退出。"""
250
+ await self.start()
251
+ try:
252
+ await self._stop_event.wait()
253
+ finally:
254
+ await self.stop()
255
+
256
+
257
+ def _to_napcat_messages(data_list: list[dict[str, Any]]) -> list[Message]:
258
+ """把 CQ 字典段列表转为 NapCat SDK 消息对象,跳过未知段。"""
259
+ messages: list[Message] = []
260
+ for data in data_list:
261
+ segment = MessageSegment.from_dict(data)
262
+ if isinstance(segment, UnknownMessageSegment):
263
+ continue
264
+ messages.append(segment)
265
+ return messages
@@ -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,171 @@
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
+ raw_name = self.data.get("name") or ""
31
+ self.name = raw_name.lstrip("@").strip()
32
+
33
+
34
+ class FaceSegment(BaseSegment):
35
+ """QQ 表情段。"""
36
+
37
+ type: Literal["face"] = "face"
38
+ id: str | None = None
39
+ large: str | None = None
40
+ result_id: str | None = None
41
+ chain_count: int | None = None
42
+
43
+ def model_post_init(self, ctx) -> None:
44
+ self.id = str(self.data["id"])
45
+ self.large = self.data.get("large")
46
+ self.result_id = self.data.get("resultId")
47
+ self.chain_count = self.data.get("chainCount")
48
+
49
+
50
+ class TextSegment(BaseSegment):
51
+ """纯文本段。"""
52
+
53
+ type: Literal["text"] = "text"
54
+ text: str | None = None
55
+
56
+ def model_post_init(self, ctx) -> None:
57
+ self.text = self.data["text"]
58
+
59
+
60
+ class ReplySegment(BaseSegment):
61
+ """引用回复段。"""
62
+
63
+ type: Literal["reply"] = "reply"
64
+ id: str | None = None
65
+
66
+ def model_post_init(self, ctx) -> None:
67
+ self.id = self.data["id"]
68
+
69
+
70
+ class MfaceSegment(BaseSegment):
71
+ """商城表情段。"""
72
+
73
+ type: Literal["mface"] = "mface"
74
+ url: str | None = None
75
+ emoji_package_id: str | None = None
76
+ emoji_id: str | None = None
77
+ key: str | None = None
78
+ summary: str | None = None
79
+
80
+
81
+ class LocationSegment(BaseSegment):
82
+ """位置段。"""
83
+
84
+ type: Literal["location"] = "location"
85
+ lat: float | None = None
86
+ lon: float | None = None
87
+ title: str | None = None
88
+ content: str | None = None
89
+
90
+
91
+ class JsonSegment(BaseSegment):
92
+ """JSON 卡片段。"""
93
+
94
+ type: Literal["json"] = "json"
95
+
96
+
97
+ class ImageSegment(BaseSegment):
98
+ """图片段。"""
99
+
100
+ type: Literal["image"] = "image"
101
+ file: str | None = None
102
+ filename: str | None = None
103
+ url: str | None = None
104
+ summary: str | None = None
105
+ subType: str | None = None
106
+
107
+
108
+ class ForwardSegment(BaseSegment):
109
+ """合并转发段。"""
110
+
111
+ type: Literal["forward"] = "forward"
112
+ id: str | None = None
113
+
114
+
115
+ class VideoSegment(BaseSegment):
116
+ """视频段。"""
117
+
118
+ type: Literal["video"] = "video"
119
+ file: str | None = None
120
+ url: str | None = None
121
+
122
+ def model_post_init(self, ctx) -> None:
123
+ self.file = self.data["file"]
124
+ self.url = self.data["url"]
125
+
126
+
127
+ Segment = Annotated[
128
+ Union[
129
+ TextSegment,
130
+ ImageSegment,
131
+ FaceSegment,
132
+ AtSegment,
133
+ ForwardSegment,
134
+ ReplySegment,
135
+ JsonSegment,
136
+ VideoSegment,
137
+ MfaceSegment,
138
+ LocationSegment,
139
+ ],
140
+ Field(discriminator="type"),
141
+ ]
142
+
143
+
144
+ class BotMessage(BaseModel):
145
+ """包内入站/出站消息:CQ 段列表与会话上下文。"""
146
+
147
+ message_id: str = Field(description="平台分配的消息编号")
148
+ data_list: list[dict] = Field(description="CQ 协议段列表,每项含 type 与 data")
149
+ message_type: MessageType = Field(description="私聊或群聊")
150
+ bot_id: str = Field(description="当前机器人 QQ 号")
151
+ bot_name: str | None = Field(default=None, description="机器人昵称,用于解析 @")
152
+ session_id: str = Field(description="群号或好友号,用作发送目标")
153
+ user_name: str = Field(description="发送方 QQ 号")
154
+
155
+
156
+ __all__ = [
157
+ "BotMessage",
158
+ "BaseSegment",
159
+ "TextSegment",
160
+ "ImageSegment",
161
+ "FaceSegment",
162
+ "AtSegment",
163
+ "ForwardSegment",
164
+ "ReplySegment",
165
+ "JsonSegment",
166
+ "VideoSegment",
167
+ "MfaceSegment",
168
+ "LocationSegment",
169
+ "MessageType",
170
+ "Segment",
171
+ ]
@@ -0,0 +1,194 @@
1
+ from typing import List, Union, Optional
2
+ from napcat_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
+ """把对外统一载荷转成 NapCat 可发送的 CQ 段列表。
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
+ """把 NapCat 入站消息转为对外统一载荷;群聊未 @ 机器人时返回空。
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,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "python-library-napcat-adapter"
7
+ version = "0.1.0"
8
+ description = "NapCat CQ 消息段与 OneBot 载荷互转及接入"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "pydantic>=2.0,<3",
12
+ "napcat-sdk",
13
+ "python-library-onebot-protocol",
14
+ ]
15
+
16
+ [tool.hatch.metadata]
17
+ allow-direct-references = true
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["napcat_adapter"]