python-library-napcat-adapter 0.1.0__tar.gz → 0.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-napcat-adapter
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: NapCat CQ 消息段与 OneBot 载荷互转及接入
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: napcat-sdk
@@ -98,11 +98,18 @@ class ImageSegment(BaseSegment):
98
98
  """图片段。"""
99
99
 
100
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
101
+
102
+
103
+ class RecordSegment(BaseSegment):
104
+ """语音段(CQ record)。"""
105
+
106
+ type: Literal["record"] = "record"
107
+
108
+
109
+ class FileSegment(BaseSegment):
110
+ """文件段。"""
111
+
112
+ type: Literal["file"] = "file"
106
113
 
107
114
 
108
115
  class ForwardSegment(BaseSegment):
@@ -116,18 +123,14 @@ class VideoSegment(BaseSegment):
116
123
  """视频段。"""
117
124
 
118
125
  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
 
126
127
 
127
128
  Segment = Annotated[
128
129
  Union[
129
130
  TextSegment,
130
131
  ImageSegment,
132
+ RecordSegment,
133
+ FileSegment,
131
134
  FaceSegment,
132
135
  AtSegment,
133
136
  ForwardSegment,
@@ -158,6 +161,8 @@ __all__ = [
158
161
  "BaseSegment",
159
162
  "TextSegment",
160
163
  "ImageSegment",
164
+ "RecordSegment",
165
+ "FileSegment",
161
166
  "FaceSegment",
162
167
  "AtSegment",
163
168
  "ForwardSegment",
@@ -0,0 +1,318 @@
1
+ from typing import List, Union, Optional
2
+
3
+ import onebot_protocol
4
+ from onebot_protocol import MessagePayload
5
+ from onebot_protocol.models import (
6
+ FileSegmentData,
7
+ ImageSegmentData,
8
+ LocationSegmentData,
9
+ ReplySegmentData,
10
+ VideoSegmentData,
11
+ VoiceSegmentData,
12
+ )
13
+
14
+ from napcat_adapter.models import (
15
+ AtSegment,
16
+ BaseSegment,
17
+ BotMessage,
18
+ FileSegment,
19
+ ImageSegment,
20
+ LocationSegment,
21
+ MessageType,
22
+ RecordSegment,
23
+ ReplySegment,
24
+ TextSegment,
25
+ VideoSegment,
26
+ )
27
+
28
+ SEGMENT_MAP = {
29
+ "text": TextSegment,
30
+ "image": ImageSegment,
31
+ "record": RecordSegment,
32
+ "file": FileSegment,
33
+ "at": AtSegment,
34
+ "reply": ReplySegment,
35
+ "video": VideoSegment,
36
+ "location": LocationSegment,
37
+ }
38
+
39
+ USER_MAP: dict[str, str] = {}
40
+
41
+ MENTION_ALL_NAME = "全体成员"
42
+
43
+
44
+ def data_to_segments(data_list: list[dict], bot_name: str, bot_id: str) -> list[BaseSegment]:
45
+ """把原始段字典列表转为强类型段,并拆分正文里的 @ 机器人。
46
+
47
+ Args:
48
+ data_list: 事件中的 CQ 段列表
49
+ bot_name: 机器人昵称,用于匹配 @
50
+ bot_id: 机器人 QQ 号
51
+
52
+ Returns:
53
+ 过滤无效项并展开 @ 后的段列表
54
+ """
55
+ segments = [_cast_segment(x) for x in data_list]
56
+ segments = [x for x in segments if x]
57
+
58
+ segments = [
59
+ (_extract_mention_robot(x, bot_name, bot_id) if isinstance(x, TextSegment) else [x])
60
+ for x in segments
61
+ ]
62
+ segments = [x for data in segments for x in data]
63
+
64
+ return segments
65
+
66
+
67
+ def onebot_to_bot(payload: MessagePayload) -> BotMessage:
68
+ """把对外统一载荷转成 NapCat 可发送的 CQ 段列表。
69
+
70
+ Args:
71
+ payload: 会话、消息段与机器人标识
72
+
73
+ Returns:
74
+ 含 data_list 的包内机器人消息
75
+ """
76
+ data_list = []
77
+ for message in payload.messages:
78
+ segment = _onebot_to_cq_segment(message)
79
+ if segment is None:
80
+ continue
81
+ data_list.append(segment.model_dump())
82
+
83
+ msg = BotMessage(
84
+ message_id=payload.message_id,
85
+ data_list=data_list,
86
+ message_type=MessageType(payload.source_type),
87
+ bot_id=payload.bot_id,
88
+ session_id=payload.session_id,
89
+ user_name=payload.user_id or "",
90
+ )
91
+
92
+ if msg.message_type == MessageType.GROUP:
93
+ if msg.user_name:
94
+ msg.data_list = [
95
+ AtSegment(data={"qq": msg.user_name, "name": USER_MAP.get(msg.user_name, "")}).model_dump(),
96
+ TextSegment(data={"text": " "}).model_dump(),
97
+ ] + msg.data_list
98
+
99
+ return msg
100
+
101
+
102
+ def bot_to_onebot(msg: BotMessage) -> Optional[MessagePayload]:
103
+ """把 NapCat 入站消息转为对外统一载荷;群聊未 @ 机器人时返回空。
104
+
105
+ Args:
106
+ msg: 事件解析后的包内消息
107
+
108
+ Returns:
109
+ 可上报的统一载荷;无需上报时为 None
110
+ """
111
+ global USER_MAP
112
+
113
+ segments = data_to_segments(msg.data_list, msg.bot_name, msg.bot_id)
114
+
115
+ if not _should_broadcast(msg, segments):
116
+ return None
117
+
118
+ segments = [s for s in segments if not (isinstance(s, AtSegment) and s.qq == msg.bot_id)]
119
+
120
+ messages = []
121
+ for segment in segments:
122
+ if isinstance(segment, AtSegment) and segment.qq:
123
+ USER_MAP[segment.qq] = segment.name or ""
124
+ converted = _segment_to_onebot(segment)
125
+ if converted is not None:
126
+ messages.append(converted)
127
+
128
+ if not messages:
129
+ return None
130
+
131
+ return MessagePayload(
132
+ message_id=msg.message_id,
133
+ source_type=msg.message_type.value,
134
+ bot_id=msg.bot_id,
135
+ session_id=msg.session_id,
136
+ user_id=msg.user_name,
137
+ messages=messages,
138
+ )
139
+
140
+
141
+ def _cq_data_to_file_data(data: dict) -> Optional[FileSegmentData]:
142
+ """从 CQ data 提取 FileData;无可用内容引用时返回 None。"""
143
+ content = data.get("url") or data.get("file") or data.get("path")
144
+ if not content:
145
+ return None
146
+ name = data.get("name") or data.get("filename")
147
+ mime_type = data.get("mime") or data.get("mime_type")
148
+ raw_size = data.get("file_size", data.get("size"))
149
+ size: int | None = None
150
+ if raw_size is not None:
151
+ try:
152
+ size = int(raw_size)
153
+ except (TypeError, ValueError):
154
+ size = None
155
+ return FileSegmentData(name=name, content=str(content), mime_type=mime_type, size=size)
156
+
157
+
158
+ def _file_data_to_cq(data: FileSegmentData) -> dict:
159
+ """把 FileData 编成 NapCat / OneBot 11 可识别的 CQ data。"""
160
+ out: dict = {}
161
+ content = data.content
162
+ if not content:
163
+ return out
164
+ out["file"] = content
165
+ if content.startswith(("http://", "https://")):
166
+ out["url"] = content
167
+ if data.name:
168
+ out["name"] = data.name
169
+ if data.size is not None:
170
+ out["file_size"] = data.size
171
+ return out
172
+
173
+
174
+ def _onebot_to_cq_segment(
175
+ message: onebot_protocol.MessageSegment,
176
+ ) -> BaseSegment | None:
177
+ if isinstance(message, onebot_protocol.TextMessageSegment):
178
+ return TextSegment(data={"text": message.data.text})
179
+ if isinstance(message, onebot_protocol.MentionMessageSegment):
180
+ name = USER_MAP.get(message.data.user_id)
181
+ if not name:
182
+ return None
183
+ return AtSegment(data={"qq": message.data.user_id, "name": name})
184
+ if isinstance(message, onebot_protocol.ImageMessageSegment):
185
+ cq_data = _file_data_to_cq(message.data)
186
+ return ImageSegment(data=cq_data) if cq_data else None
187
+ if isinstance(message, onebot_protocol.VoiceMessageSegment):
188
+ cq_data = _file_data_to_cq(message.data)
189
+ return RecordSegment(data=cq_data) if cq_data else None
190
+ if isinstance(message, onebot_protocol.AudioMessageSegment):
191
+ cq_data = _file_data_to_cq(message.data)
192
+ return FileSegment(data=cq_data) if cq_data else None
193
+ if isinstance(message, onebot_protocol.VideoMessageSegment):
194
+ cq_data = _file_data_to_cq(message.data)
195
+ return VideoSegment(data=cq_data) if cq_data else None
196
+ if isinstance(message, onebot_protocol.FileMessageSegment):
197
+ cq_data = _file_data_to_cq(message.data)
198
+ return FileSegment(data=cq_data) if cq_data else None
199
+ if isinstance(message, onebot_protocol.LocationMessageSegment):
200
+ loc = message.data
201
+ return LocationSegment(
202
+ data={
203
+ "lat": loc.latitude,
204
+ "lon": loc.longitude,
205
+ "title": loc.title,
206
+ "content": loc.content,
207
+ }
208
+ )
209
+ if isinstance(message, onebot_protocol.ReplyMessageSegment):
210
+ reply_id = message.data.message_id
211
+ if not reply_id:
212
+ return None
213
+ return ReplySegment(data={"id": reply_id})
214
+ return None
215
+
216
+
217
+ def _segment_to_onebot(
218
+ segment: BaseSegment,
219
+ ) -> onebot_protocol.MessageSegment | None:
220
+ if isinstance(segment, TextSegment):
221
+ text = (segment.text or "").strip()
222
+ if not text:
223
+ return None
224
+ return onebot_protocol.TextMessageSegment(data={"text": text})
225
+ if isinstance(segment, AtSegment):
226
+ if segment.name == MENTION_ALL_NAME:
227
+ return onebot_protocol.MentionAllMessageSegment()
228
+ return onebot_protocol.MentionMessageSegment(data={"user_id": segment.qq})
229
+ if isinstance(segment, ImageSegment):
230
+ file_data = _cq_data_to_file_data(segment.data)
231
+ if file_data is None:
232
+ return None
233
+ return onebot_protocol.ImageMessageSegment(data=ImageSegmentData(**file_data.model_dump()))
234
+ if isinstance(segment, RecordSegment):
235
+ file_data = _cq_data_to_file_data(segment.data)
236
+ if file_data is None:
237
+ return None
238
+ return onebot_protocol.VoiceMessageSegment(data=VoiceSegmentData(**file_data.model_dump()))
239
+ if isinstance(segment, VideoSegment):
240
+ file_data = _cq_data_to_file_data(segment.data)
241
+ if file_data is None:
242
+ return None
243
+ return onebot_protocol.VideoMessageSegment(data=VideoSegmentData(**file_data.model_dump()))
244
+ if isinstance(segment, FileSegment):
245
+ file_data = _cq_data_to_file_data(segment.data)
246
+ if file_data is None:
247
+ return None
248
+ return onebot_protocol.FileMessageSegment(data=FileSegmentData(**file_data.model_dump()))
249
+ if isinstance(segment, LocationSegment):
250
+ raw = segment.data
251
+ try:
252
+ lat = float(raw.get("lat", 0))
253
+ lon = float(raw.get("lon", 0))
254
+ except (TypeError, ValueError):
255
+ return None
256
+ return onebot_protocol.LocationMessageSegment(
257
+ data=LocationSegmentData(
258
+ latitude=lat,
259
+ longitude=lon,
260
+ title=str(raw.get("title") or ""),
261
+ content=str(raw.get("content") or ""),
262
+ )
263
+ )
264
+ if isinstance(segment, ReplySegment):
265
+ reply_id = segment.data.get("id")
266
+ if not reply_id:
267
+ return None
268
+ return onebot_protocol.ReplyMessageSegment(
269
+ data=ReplySegmentData(message_id=str(reply_id))
270
+ )
271
+ return None
272
+
273
+
274
+ def _should_broadcast(msg: BotMessage, segments: list[BaseSegment]) -> bool:
275
+ """群聊仅在 @ 到机器人时向上层上报。"""
276
+ if msg.message_type == MessageType.GROUP:
277
+ for segment in segments:
278
+ if isinstance(segment, AtSegment) and segment.qq == msg.bot_id:
279
+ return True
280
+ return False
281
+ return True
282
+
283
+
284
+ def _cast_segment(data: dict) -> Optional[BaseSegment]:
285
+ """按 type 字段实例化对应段模型;未知或校验失败返回 None。"""
286
+ cls = SEGMENT_MAP.get(data["type"])
287
+ if not cls:
288
+ return None
289
+ try:
290
+ return cls(**data)
291
+ except Exception:
292
+ return None
293
+
294
+
295
+ def _extract_mention_robot(
296
+ text: TextSegment, bot_name: str, bot_id: str
297
+ ) -> List[Union[TextSegment, AtSegment]]:
298
+ """把正文中「@昵称 」拆成文本段与 @ 段。"""
299
+
300
+ def split(text: TextSegment) -> List[Union[TextSegment, AtSegment]]:
301
+ content = text.text
302
+
303
+ keyword = f"@{bot_name} "
304
+ if keyword in content:
305
+ index = content.find(keyword)
306
+ before_text = content[:index]
307
+ after_text = content[index + len(keyword):]
308
+ return (
309
+ split(TextSegment(data={"text": before_text}))
310
+ + [AtSegment(data={"name": bot_name, "qq": bot_id})]
311
+ + split(TextSegment(data={"text": after_text}))
312
+ )
313
+ return [text]
314
+
315
+ try:
316
+ return split(text)
317
+ except Exception:
318
+ return [text]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-library-napcat-adapter"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "NapCat CQ 消息段与 OneBot 载荷互转及接入"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,194 +0,0 @@
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]