ErisPulse-MatrixAdapter 1.0.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,284 @@
1
+ import time
2
+ import uuid
3
+ from typing import Dict, Optional, List
4
+
5
+
6
+ class MatrixConverter:
7
+ def __init__(self, bot_user_id: str = ""):
8
+ self.bot_user_id = bot_user_id
9
+ self._dm_rooms: Dict[str, str] = {}
10
+
11
+ def set_dm_rooms(self, dm_rooms: Dict[str, str]):
12
+ self._dm_rooms = dm_rooms
13
+
14
+ def convert(self, raw_event: Dict, room_id: str = "", is_dm: bool = False) -> Optional[Dict]:
15
+ if not isinstance(raw_event, dict):
16
+ return None
17
+
18
+ event_type = raw_event.get("type", "")
19
+ event_id = raw_event.get("event_id", str(uuid.uuid4()))
20
+
21
+ origin_server_ts = raw_event.get("origin_server_ts", 0)
22
+ if origin_server_ts:
23
+ if origin_server_ts > 10**12:
24
+ event_time = int(origin_server_ts / 1000)
25
+ else:
26
+ event_time = int(origin_server_ts)
27
+ else:
28
+ event_time = int(time.time())
29
+
30
+ sender = raw_event.get("sender", "")
31
+ if sender == self.bot_user_id:
32
+ return None
33
+
34
+ base_event = {
35
+ "id": event_id,
36
+ "time": event_time,
37
+ "type": "",
38
+ "detail_type": "",
39
+ "platform": "matrix",
40
+ "self": {
41
+ "platform": "matrix",
42
+ "user_id": self.bot_user_id,
43
+ },
44
+ "matrix_raw": raw_event,
45
+ "matrix_raw_type": event_type,
46
+ }
47
+
48
+ if is_dm:
49
+ base_event["detail_type"] = "private"
50
+ else:
51
+ base_event["detail_type"] = "group"
52
+
53
+ handler = getattr(self, f"_handle_{event_type.replace('.', '_')}", None)
54
+ if handler:
55
+ return handler(raw_event, base_event, room_id, is_dm)
56
+
57
+ if event_type.startswith("m.room."):
58
+ return self._handle_m_room_generic(raw_event, base_event, room_id, is_dm)
59
+
60
+ return self._create_unknown_event(raw_event, event_id, event_type)
61
+
62
+ def _handle_m_room_message(self, raw_event: Dict, base_event: Dict, room_id: str, is_dm: bool) -> Dict:
63
+ base_event["type"] = "message"
64
+ base_event["user_id"] = raw_event.get("sender", "")
65
+ base_event["user_nickname"] = raw_event.get("sender", "")
66
+
67
+ content = raw_event.get("content", {})
68
+ msgtype = content.get("msgtype", "m.text")
69
+
70
+ message_segments = self._parse_message_content(content, raw_event)
71
+ alt_message = self._generate_alt_message(message_segments)
72
+
73
+ base_event["message"] = message_segments
74
+ base_event["alt_message"] = alt_message
75
+
76
+ if room_id:
77
+ base_event["matrix_room_id"] = room_id
78
+ if is_dm:
79
+ pass
80
+ else:
81
+ base_event["group_id"] = room_id
82
+
83
+ relates_to = content.get("m.relates_to", {})
84
+ if relates_to:
85
+ rel_type = relates_to.get("rel_type", "")
86
+ if rel_type == "m.in_reply_to":
87
+ reply_event_id = relates_to.get("event_id", "")
88
+ if reply_event_id:
89
+ reply_seg = {"type": "reply", "data": {"message_id": reply_event_id}}
90
+ base_event["message"].insert(0, reply_seg)
91
+ elif rel_type == "m.thread":
92
+ base_event["thread_id"] = relates_to.get("event_id", "")
93
+
94
+ new_content = content.get("m.new_content")
95
+ if new_content:
96
+ base_event["matrix_edit"] = True
97
+ base_event["matrix_original_event_id"] = relates_to.get("event_id", "")
98
+
99
+ return base_event
100
+
101
+ def _handle_m_room_member(self, raw_event: Dict, base_event: Dict, room_id: str, is_dm: bool) -> Dict:
102
+ base_event["type"] = "notice"
103
+ base_event["user_id"] = raw_event.get("sender", "")
104
+ base_event["user_nickname"] = raw_event.get("sender", "")
105
+
106
+ content = raw_event.get("content", {})
107
+ prev_content = raw_event.get("unsigned", {}).get("prev_content", {})
108
+ membership = content.get("membership", "")
109
+ prev_membership = prev_content.get("membership", "")
110
+
111
+ state_key = raw_event.get("state_key", "")
112
+ target_user = state_key
113
+
114
+ if membership == "join" and prev_membership != "join":
115
+ base_event["detail_type"] = "group_member_increase"
116
+ base_event["user_id"] = target_user
117
+ displayname = content.get("displayname", target_user)
118
+ base_event["user_nickname"] = displayname
119
+ base_event["operator_id"] = raw_event.get("sender", "")
120
+ base_event["group_id"] = room_id
121
+ base_event["matrix_room_id"] = room_id
122
+ elif membership in ("leave", "ban") and prev_membership == "join":
123
+ base_event["detail_type"] = "group_member_decrease"
124
+ base_event["user_id"] = target_user
125
+ base_event["user_nickname"] = content.get("displayname", target_user)
126
+ base_event["operator_id"] = raw_event.get("sender", "")
127
+ base_event["group_id"] = room_id
128
+ base_event["matrix_room_id"] = room_id
129
+ else:
130
+ base_event["detail_type"] = "matrix_member_update"
131
+ base_event["user_id"] = target_user
132
+ base_event["group_id"] = room_id
133
+ base_event["matrix_room_id"] = room_id
134
+ base_event["matrix_membership"] = membership
135
+
136
+ return base_event
137
+
138
+ def _handle_m_reaction(self, raw_event: Dict, base_event: Dict, room_id: str, is_dm: bool) -> Dict:
139
+ base_event["type"] = "notice"
140
+ base_event["detail_type"] = "matrix_reaction"
141
+ base_event["user_id"] = raw_event.get("sender", "")
142
+ base_event["matrix_room_id"] = room_id
143
+ if not is_dm:
144
+ base_event["group_id"] = room_id
145
+
146
+ content = raw_event.get("content", {})
147
+ relates_to = content.get("m.relates_to", {})
148
+ base_event["matrix_reaction_event_id"] = relates_to.get("event_id", "")
149
+ base_event["matrix_reaction_key"] = relates_to.get("key", "")
150
+
151
+ return base_event
152
+
153
+ def _handle_m_room_redaction(self, raw_event: Dict, base_event: Dict, room_id: str, is_dm: bool) -> Dict:
154
+ base_event["type"] = "notice"
155
+ base_event["detail_type"] = "matrix_redaction"
156
+ base_event["user_id"] = raw_event.get("sender", "")
157
+ base_event["matrix_room_id"] = room_id
158
+ if not is_dm:
159
+ base_event["group_id"] = room_id
160
+
161
+ redacts = raw_event.get("redacts", "")
162
+ base_event["matrix_redacted_event_id"] = redacts
163
+
164
+ return base_event
165
+
166
+ def _handle_m_room_generic(self, raw_event: Dict, base_event: Dict, room_id: str, is_dm: bool) -> Dict:
167
+ event_type = raw_event.get("type", "")
168
+ base_event["type"] = "notice"
169
+ base_event["detail_type"] = f"matrix_{event_type.replace('.', '_').replace('m_room_', '')}"
170
+ base_event["user_id"] = raw_event.get("sender", "")
171
+ base_event["matrix_room_id"] = room_id
172
+ if not is_dm:
173
+ base_event["group_id"] = room_id
174
+
175
+ return base_event
176
+
177
+ def _parse_message_content(self, content: Dict, raw_event: Dict) -> List[Dict]:
178
+ segments = []
179
+ msgtype = content.get("msgtype", "m.text")
180
+ body = content.get("body", "")
181
+ formatted_body = content.get("formatted_body", "")
182
+
183
+ if msgtype in ("m.text", "m.notice", "m.emote"):
184
+ seg_data = {"text": body}
185
+ if formatted_body:
186
+ seg_data["html"] = formatted_body
187
+ segments.append({"type": "text", "data": seg_data})
188
+
189
+ elif msgtype == "m.image":
190
+ url = content.get("url", "")
191
+ segments.append({
192
+ "type": "image",
193
+ "data": {
194
+ "url": url,
195
+ "filename": body,
196
+ "matrix_mxc": url,
197
+ "info": content.get("info", {}),
198
+ },
199
+ })
200
+
201
+ elif msgtype == "m.audio":
202
+ url = content.get("url", "")
203
+ segments.append({
204
+ "type": "voice",
205
+ "data": {
206
+ "url": url,
207
+ "filename": body,
208
+ "matrix_mxc": url,
209
+ "info": content.get("info", {}),
210
+ },
211
+ })
212
+
213
+ elif msgtype == "m.video":
214
+ url = content.get("url", "")
215
+ segments.append({
216
+ "type": "video",
217
+ "data": {
218
+ "url": url,
219
+ "filename": body,
220
+ "matrix_mxc": url,
221
+ "info": content.get("info", {}),
222
+ },
223
+ })
224
+
225
+ elif msgtype == "m.file":
226
+ url = content.get("url", "")
227
+ segments.append({
228
+ "type": "file",
229
+ "data": {
230
+ "url": url,
231
+ "filename": body,
232
+ "matrix_mxc": url,
233
+ "info": content.get("info", {}),
234
+ },
235
+ })
236
+
237
+ elif msgtype == "m.location":
238
+ geo_uri = content.get("geo_uri", "")
239
+ segments.append({
240
+ "type": "location",
241
+ "data": {
242
+ "latitude": 0.0,
243
+ "longitude": 0.0,
244
+ "matrix_geo_uri": geo_uri,
245
+ "text": body,
246
+ },
247
+ })
248
+
249
+ return segments
250
+
251
+ def _create_unknown_event(self, raw_event: Dict, event_id: str, event_type: str) -> Dict:
252
+ return {
253
+ "id": event_id,
254
+ "time": int(time.time()),
255
+ "type": "unknown",
256
+ "detail_type": "unknown",
257
+ "platform": "matrix",
258
+ "self": {"platform": "matrix", "user_id": self.bot_user_id},
259
+ "matrix_raw": raw_event,
260
+ "matrix_raw_type": event_type,
261
+ "warning": f"Unsupported event type: {event_type}",
262
+ "alt_message": "This event type is not supported by this system.",
263
+ }
264
+
265
+ def _generate_alt_message(self, segments: List[Dict]) -> str:
266
+ parts = []
267
+ for seg in segments:
268
+ seg_type = seg["type"]
269
+ data = seg.get("data", {})
270
+ if seg_type == "text":
271
+ parts.append(data.get("text", ""))
272
+ elif seg_type == "image":
273
+ parts.append("[图片]")
274
+ elif seg_type == "voice":
275
+ parts.append("[语音]")
276
+ elif seg_type == "video":
277
+ parts.append("[视频]")
278
+ elif seg_type == "file":
279
+ parts.append(f"[文件:{data.get('filename', '')}]")
280
+ elif seg_type == "location":
281
+ parts.append("[位置]")
282
+ elif seg_type == "reply":
283
+ parts.append("[回复]")
284
+ return " ".join(parts).strip()
MatrixAdapter/Core.py ADDED
@@ -0,0 +1,574 @@
1
+ import asyncio
2
+ import aiohttp
3
+ import json
4
+ import mimetypes
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+ from ErisPulse import sdk
9
+ from .Converter import MatrixConverter
10
+ from ErisPulse.Core.Event import register_event_mixin, unregister_platform_event_methods
11
+
12
+
13
+ class MatrixEventMixin:
14
+ def get_room_id(self) -> str:
15
+ return self.get("matrix_room_id", self.get("matrix_raw", {}).get("room_id", ""))
16
+
17
+ def get_matrix_event_type(self) -> str:
18
+ return self.get("matrix_raw", {}).get("type", "")
19
+
20
+ def get_matrix_sender(self) -> str:
21
+ return self.get("matrix_raw", {}).get("sender", "")
22
+
23
+ def get_reaction_key(self) -> str:
24
+ return self.get("matrix_reaction_key", "")
25
+
26
+ def is_edited(self) -> bool:
27
+ return self.get("matrix_edit", False)
28
+
29
+ def is_notice(self) -> bool:
30
+ raw = self.get("matrix_raw", {})
31
+ content = raw.get("content", {})
32
+ return content.get("msgtype") == "m.notice"
33
+
34
+
35
+ register_event_mixin("matrix", MatrixEventMixin)
36
+
37
+
38
+ class MatrixAdapter(sdk.BaseAdapter):
39
+ class Send(sdk.BaseAdapter.Send):
40
+ def __init__(self, adapter, target_type=None, target_id=None, account_id=None):
41
+ super().__init__(adapter, target_type, target_id, account_id)
42
+ self._reply_event_id = None
43
+ self._mention_users = []
44
+ self._mention_all = False
45
+
46
+ def Text(self, text: str):
47
+ return self.Raw_ob12([{"type": "text", "data": {"text": text}}])
48
+
49
+ def Image(self, file):
50
+ return self.Raw_ob12([{"type": "image", "data": {"file": file}}])
51
+
52
+ def Voice(self, file):
53
+ return self.Raw_ob12([{"type": "voice", "data": {"file": file}}])
54
+
55
+ def Video(self, file):
56
+ return self.Raw_ob12([{"type": "video", "data": {"file": file}}])
57
+
58
+ def File(self, file, filename: str = ""):
59
+ data = {"file": file}
60
+ if filename:
61
+ data["filename"] = filename
62
+ return self.Raw_ob12([{"type": "file", "data": data}])
63
+
64
+ def Notice(self, text: str):
65
+ return self.Raw_ob12([{"type": "notice", "data": {"text": text}}])
66
+
67
+ def Html(self, html: str, fallback: str = ""):
68
+ return self.Raw_ob12([{"type": "html", "data": {"html": html, "fallback": fallback}}])
69
+
70
+ def Reply(self, message_id: str) -> "Send":
71
+ self._reply_event_id = message_id
72
+ return self
73
+
74
+ def At(self, user_id: str) -> "Send":
75
+ self._mention_users.append(user_id)
76
+ return self
77
+
78
+ def AtAll(self) -> "Send":
79
+ self._mention_all = True
80
+ return self
81
+
82
+ def Raw_ob12(self, message: List[Dict], **kwargs):
83
+ return asyncio.create_task(self._do_send_raw_ob12(message, **kwargs))
84
+
85
+ async def _do_send_raw_ob12(self, message: List[Dict], **kwargs):
86
+ text_parts = []
87
+ html_parts = []
88
+ media_file = None
89
+ media_type = None
90
+ media_filename = None
91
+ is_notice = False
92
+ fallback_text = ""
93
+ segment_mentions = []
94
+
95
+ for segment in message:
96
+ seg_type = segment.get("type")
97
+ data = segment.get("data", {})
98
+
99
+ if seg_type == "text":
100
+ text_parts.append(data.get("text", ""))
101
+ elif seg_type == "notice":
102
+ is_notice = True
103
+ text_parts.append(data.get("text", ""))
104
+ elif seg_type == "html":
105
+ html_parts.append(data.get("html", ""))
106
+ fallback_text = data.get("fallback", "")
107
+ elif seg_type == "reply":
108
+ self._reply_event_id = data.get("message_id", "")
109
+ elif seg_type == "mention":
110
+ user_id = data.get("user_id", "")
111
+ if user_id:
112
+ text_parts.append(user_id)
113
+ segment_mentions.append(user_id)
114
+ elif seg_type in ("image", "voice", "video", "file"):
115
+ file = data.get("file", data.get("url", ""))
116
+ if file:
117
+ media_file = file
118
+ media_type = seg_type
119
+ if data.get("filename"):
120
+ media_filename = data["filename"]
121
+
122
+ if self._mention_users:
123
+ text_parts.insert(0, " ".join(self._mention_users) + " ")
124
+ if self._mention_all:
125
+ text_parts.insert(0, "@room ")
126
+
127
+ full_text = "".join(text_parts) or fallback_text or " "
128
+ full_html = "".join(html_parts) if html_parts else None
129
+
130
+ target_room = self._target_id
131
+ content = None
132
+
133
+ if media_file and isinstance(media_file, bytes):
134
+ mxc_uri = await self._adapter._upload_media(media_file, media_type)
135
+ if not mxc_uri:
136
+ return {
137
+ "status": "failed",
138
+ "retcode": 32000,
139
+ "data": None,
140
+ "message_id": "",
141
+ "message": "媒体上传失败",
142
+ "matrix_raw": None,
143
+ }
144
+ content = self._build_media_content(media_type, mxc_uri, media_filename or full_text)
145
+ elif media_file and isinstance(media_file, str) and media_file.startswith("mxc://"):
146
+ content = self._build_media_content(media_type, media_file, media_filename or full_text)
147
+ elif media_file and isinstance(media_file, str) and media_file.startswith(("http://", "https://")):
148
+ result = await self._adapter._download_file(media_file)
149
+ if not result:
150
+ return {
151
+ "status": "failed",
152
+ "retcode": 32000,
153
+ "data": None,
154
+ "message_id": "",
155
+ "message": "媒体下载失败",
156
+ "matrix_raw": None,
157
+ }
158
+ file_bytes, download_ct = result
159
+ upload_ct = download_ct if download_ct and "text/" not in download_ct else None
160
+ mxc_uri = await self._adapter._upload_media(file_bytes, media_type, content_type=upload_ct)
161
+ if not mxc_uri:
162
+ return {
163
+ "status": "failed",
164
+ "retcode": 32000,
165
+ "data": None,
166
+ "message_id": "",
167
+ "message": "媒体上传失败",
168
+ "matrix_raw": None,
169
+ }
170
+ mimetype = upload_ct
171
+ content = self._build_media_content(media_type, mxc_uri, media_filename or full_text, mimetype=mimetype)
172
+ elif media_file:
173
+ path = Path(str(media_file))
174
+ if path.is_file():
175
+ file_bytes = path.read_bytes()
176
+ guessed_type, _ = mimetypes.guess_type(str(path))
177
+ mxc_uri = await self._adapter._upload_media(file_bytes, media_type, content_type=guessed_type)
178
+ if not mxc_uri:
179
+ return {
180
+ "status": "failed",
181
+ "retcode": 32000,
182
+ "data": None,
183
+ "message_id": "",
184
+ "message": "媒体上传失败",
185
+ "matrix_raw": None,
186
+ }
187
+ content = self._build_media_content(media_type, mxc_uri, media_filename or path.name or full_text, mimetype=guessed_type)
188
+
189
+ if content is None:
190
+ content = {
191
+ "msgtype": "m.notice" if is_notice else "m.text",
192
+ "body": full_text,
193
+ }
194
+ if full_html:
195
+ content["format"] = "org.matrix.custom.html"
196
+ content["formatted_body"] = full_html
197
+
198
+ if self._reply_event_id:
199
+ content["m.relates_to"] = {
200
+ "rel_type": "m.in_reply_to",
201
+ "event_id": self._reply_event_id,
202
+ }
203
+ self._reply_event_id = None
204
+
205
+ all_mentioned = list(set(self._mention_users + segment_mentions))
206
+ if all_mentioned or self._mention_all:
207
+ content["m.mentions"] = {}
208
+ if all_mentioned:
209
+ content["m.mentions"]["user_ids"] = all_mentioned
210
+ if self._mention_all:
211
+ content["m.mentions"]["room"] = True
212
+
213
+ txn_id = str(uuid.uuid4())
214
+ endpoint = f"/_matrix/client/v3/rooms/{target_room}/send/m.room.message/{txn_id}"
215
+
216
+ return await self._adapter.call_api(endpoint=endpoint, method="PUT", **content)
217
+
218
+ def _build_media_content(self, media_type: str, mxc_uri: str, body: str, mimetype: str = None) -> Dict:
219
+ msgtype_map = {
220
+ "image": "m.image",
221
+ "voice": "m.audio",
222
+ "video": "m.video",
223
+ "file": "m.file",
224
+ }
225
+ if not mimetype:
226
+ mimetype_map = {
227
+ "image": "image/png",
228
+ "voice": "audio/ogg",
229
+ "video": "video/mp4",
230
+ "file": "application/octet-stream",
231
+ }
232
+ mimetype = mimetype_map.get(media_type, "application/octet-stream")
233
+ return {
234
+ "msgtype": msgtype_map.get(media_type, "m.file"),
235
+ "body": body or media_type,
236
+ "url": mxc_uri,
237
+ "info": {
238
+ "mimetype": mimetype,
239
+ },
240
+ }
241
+
242
+ def __init__(self, sdk):
243
+ super().__init__()
244
+ self.sdk = sdk
245
+ self.logger = sdk.logger
246
+ self.config = self._load_config()
247
+ self.homeserver = self.config.get("homeserver", "https://matrix.org").rstrip("/")
248
+ self.access_token = self.config.get("access_token", "")
249
+ self.bot_id = ""
250
+ self.session: Optional[aiohttp.ClientSession] = None
251
+ self._sync_task: Optional[asyncio.Task] = None
252
+ self._heartbeat_meta_task: Optional[asyncio.Task] = None
253
+ self._next_batch: Optional[str] = None
254
+ self._dm_rooms: Dict[str, str] = {}
255
+ self._running = False
256
+
257
+ self.converter = MatrixConverter()
258
+ self.convert = self.converter.convert
259
+
260
+ if not self.access_token and self.config.get("user_id") and self.config.get("password"):
261
+ pass
262
+
263
+ def _load_config(self):
264
+ config = self.sdk.config.getConfig("Matrix_Adapter")
265
+ if not config:
266
+ default_config = {
267
+ "homeserver": "https://matrix.org",
268
+ "access_token": "YOUR_ACCESS_TOKEN",
269
+ "user_id": "",
270
+ "password": "",
271
+ }
272
+ try:
273
+ self.sdk.config.setConfig("Matrix_Adapter", default_config)
274
+ self.logger.warning("Matrix适配器配置不存在,已自动创建默认配置")
275
+ except Exception as e:
276
+ self.logger.error(f"保存默认配置失败: {e}")
277
+ return default_config
278
+ return config
279
+
280
+ async def _login_if_needed(self):
281
+ if self.access_token:
282
+ try:
283
+ result = await self.call_api(endpoint="/_matrix/client/v3/account/whoami", method="GET")
284
+ if result.get("status") == "ok" and result.get("data"):
285
+ self.bot_id = result["data"].get("user_id", "")
286
+ self.converter.bot_user_id = self.bot_id
287
+ self.logger.info(f"Matrix 已认证: {self.bot_id}")
288
+ return
289
+ except Exception:
290
+ pass
291
+
292
+ user_id = self.config.get("user_id", "")
293
+ password = self.config.get("password", "")
294
+ if user_id and password:
295
+ try:
296
+ login_data = {
297
+ "type": "m.login.password",
298
+ "identifier": {"type": "m.id.user", "user": user_id},
299
+ "password": password,
300
+ }
301
+ result = await self.call_api(endpoint="/_matrix/client/v3/login", method="POST", **login_data)
302
+ if result.get("status") == "ok" and result.get("data"):
303
+ self.access_token = result["data"].get("access_token", "")
304
+ self.bot_id = result["data"].get("user_id", user_id)
305
+ self.converter.bot_user_id = self.bot_id
306
+ self.logger.info(f"Matrix 登录成功: {self.bot_id}")
307
+ else:
308
+ raise Exception(f"登录失败: {result.get('message', 'Unknown error')}")
309
+ except Exception as e:
310
+ self.logger.error(f"Matrix 登录失败: {e}")
311
+ raise
312
+
313
+ async def _sync_loop(self):
314
+ self._running = True
315
+ await self._initial_sync()
316
+ await self._discover_dm_rooms()
317
+
318
+ while self._running:
319
+ try:
320
+ result = await self.call_api(
321
+ endpoint=f"/_matrix/client/v3/sync?since={self._next_batch}&timeout=30000",
322
+ method="GET",
323
+ )
324
+
325
+ if result.get("status") != "ok":
326
+ self.logger.error(f"同步失败: {result.get('message')}")
327
+ await asyncio.sleep(5)
328
+ continue
329
+
330
+ data = result.get("data", {})
331
+ self._next_batch = data.get("next_batch", self._next_batch)
332
+
333
+ await self._process_sync_response(data)
334
+
335
+ except asyncio.CancelledError:
336
+ break
337
+ except Exception as e:
338
+ self.logger.error(f"同步循环异常: {e}")
339
+ await asyncio.sleep(5)
340
+
341
+ async def _initial_sync(self):
342
+ result = await self.call_api(
343
+ endpoint="/_matrix/client/v3/sync?timeout=0",
344
+ method="GET",
345
+ )
346
+ if result.get("status") == "ok" and result.get("data"):
347
+ data = result["data"]
348
+ self._next_batch = data.get("next_batch", "")
349
+ self.logger.info(f"初始同步完成, next_batch: {self._next_batch}")
350
+
351
+ async def _discover_dm_rooms(self):
352
+ result = await self.call_api(
353
+ endpoint="/_matrix/client/v3/user/{user_id}/account_data/m.direct".format(user_id=self.bot_id),
354
+ method="GET",
355
+ )
356
+ if result.get("status") == "ok" and result.get("data"):
357
+ dm_data = result["data"]
358
+ for user_id, room_ids in dm_data.items():
359
+ if isinstance(room_ids, list) and room_ids:
360
+ self._dm_rooms[room_ids[0]] = user_id
361
+ self.converter.set_dm_rooms(self._dm_rooms)
362
+ self.logger.info(f"发现 {len(self._dm_rooms)} 个 DM 房间")
363
+
364
+ async def _process_sync_response(self, data: dict):
365
+ joined_rooms = data.get("rooms", {}).get("join", {})
366
+ for room_id, room_data in joined_rooms.items():
367
+ is_dm = room_id in self._dm_rooms
368
+
369
+ timeline = room_data.get("timeline", {})
370
+ events = timeline.get("events", [])
371
+
372
+ for event in events:
373
+ self.logger.debug(f"处理 Matrix 事件: {event}")
374
+ try:
375
+ onebot_event = self.convert(event, room_id=room_id, is_dm=is_dm)
376
+ if onebot_event:
377
+ await self.sdk.adapter.emit(onebot_event)
378
+ except Exception as e:
379
+ self.logger.error(f"处理事件失败: {e}")
380
+
381
+ invite_rooms = data.get("rooms", {}).get("invite", {})
382
+ for room_id, room_data in invite_rooms.items():
383
+ invite_state = room_data.get("invite_state", {}).get("events", [])
384
+ for event in invite_state:
385
+ if event.get("type") == "m.room.member":
386
+ content = event.get("content", {})
387
+ if content.get("membership") == "invite" and event.get("state_key") == self.bot_id:
388
+ self.logger.info(f"收到房间邀请: {room_id}")
389
+ if self.config.get("auto_accept_invites", True):
390
+ try:
391
+ await self.call_api(
392
+ endpoint=f"/_matrix/client/v3/join/{room_id}",
393
+ method="POST",
394
+ )
395
+ self.logger.info(f"已自动加入房间: {room_id}")
396
+ except Exception as e:
397
+ self.logger.error(f"加入房间失败: {e}")
398
+
399
+ async def _download_file(self, url: str) -> Optional[tuple]:
400
+ try:
401
+ timeout = aiohttp.ClientTimeout(total=300)
402
+ async with self.session.get(url, timeout=timeout) as resp:
403
+ if resp.status != 200:
404
+ self.logger.error(f"下载文件失败: HTTP {resp.status}")
405
+ return None
406
+ content_type = resp.headers.get("Content-Type", "").split(";")[0].strip()
407
+ data = await resp.read()
408
+ return (data, content_type)
409
+ except Exception as e:
410
+ self.logger.error(f"下载文件异常: {e}")
411
+ return None
412
+
413
+ async def _upload_media(self, file: bytes, media_type: str, content_type: str = None) -> Optional[str]:
414
+ if not content_type:
415
+ content_type_map = {
416
+ "image": "image/png",
417
+ "voice": "audio/ogg",
418
+ "video": "video/mp4",
419
+ "file": "application/octet-stream",
420
+ }
421
+ content_type = content_type_map.get(media_type, "application/octet-stream")
422
+
423
+ url = f"{self.homeserver}/_matrix/media/v3/upload"
424
+ headers = {
425
+ "Authorization": f"Bearer {self.access_token}",
426
+ "Content-Type": content_type,
427
+ }
428
+
429
+ try:
430
+ async with self.session.post(url, data=file, headers=headers) as resp:
431
+ data = await resp.json()
432
+ if resp.status == 200:
433
+ return data.get("content_uri", "")
434
+ else:
435
+ self.logger.error(f"上传媒体失败: {data}")
436
+ return None
437
+ except Exception as e:
438
+ self.logger.error(f"上传媒体异常: {e}")
439
+ return None
440
+
441
+ async def call_api(self, endpoint: str, method: str = "POST", **params):
442
+ url = f"{self.homeserver}{endpoint}"
443
+ headers = {"Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json"}
444
+
445
+ try:
446
+ if method.upper() == "GET":
447
+ async with self.session.get(url, headers=headers) as resp:
448
+ raw_response = await resp.json()
449
+ elif method.upper() == "PUT":
450
+ async with self.session.put(url, json=params, headers=headers) as resp:
451
+ raw_response = await resp.json()
452
+ else:
453
+ async with self.session.post(url, json=params, headers=headers) as resp:
454
+ raw_response = await resp.json()
455
+
456
+ success = 200 <= resp.status < 300
457
+
458
+ if not isinstance(raw_response, dict):
459
+ return {
460
+ "status": "ok" if success else "failed",
461
+ "retcode": 0 if success else 34000,
462
+ "data": raw_response,
463
+ "message_id": "",
464
+ "message": "",
465
+ "matrix_raw": raw_response,
466
+ }
467
+
468
+ event_id = raw_response.get("event_id", "")
469
+ message_id = str(event_id) if event_id else ""
470
+ data = dict(raw_response)
471
+ data["message_id"] = message_id
472
+
473
+ return {
474
+ "status": "ok" if success else "failed",
475
+ "retcode": 0 if success else raw_response.get("errcode", 34000),
476
+ "data": data,
477
+ "message_id": message_id,
478
+ "message": "" if success else raw_response.get("error", f"HTTP {resp.status}"),
479
+ "matrix_raw": raw_response,
480
+ }
481
+
482
+ except asyncio.TimeoutError:
483
+ self.logger.error(f"Matrix API 请求超时: {endpoint}")
484
+ return {
485
+ "status": "failed",
486
+ "retcode": 32000,
487
+ "data": None,
488
+ "message_id": "",
489
+ "message": "请求超时",
490
+ "matrix_raw": None,
491
+ }
492
+ except Exception as e:
493
+ self.logger.error(f"调用 Matrix API 失败: {e}")
494
+ return {
495
+ "status": "failed",
496
+ "retcode": 33000,
497
+ "data": None,
498
+ "message_id": "",
499
+ "message": f"API调用失败: {str(e)}",
500
+ "matrix_raw": None,
501
+ }
502
+
503
+ async def _on_connect(self):
504
+ await self.sdk.adapter.emit({
505
+ "type": "meta",
506
+ "detail_type": "connect",
507
+ "platform": "matrix",
508
+ "self": {"platform": "matrix", "user_id": self.bot_id},
509
+ })
510
+ self._heartbeat_meta_task = asyncio.create_task(self._heartbeat_meta_loop())
511
+
512
+ async def _heartbeat_meta_loop(self):
513
+ try:
514
+ while self._running:
515
+ await asyncio.sleep(30)
516
+ await self.sdk.adapter.emit({
517
+ "type": "meta",
518
+ "detail_type": "heartbeat",
519
+ "platform": "matrix",
520
+ "self": {"platform": "matrix", "user_id": self.bot_id},
521
+ })
522
+ except asyncio.CancelledError:
523
+ pass
524
+
525
+ async def start(self):
526
+ self.session = aiohttp.ClientSession()
527
+
528
+ await self._login_if_needed()
529
+
530
+ if not self.bot_id:
531
+ self.logger.error("无法获取 bot user_id,请检查配置")
532
+ if self.session:
533
+ await self.session.close()
534
+ raise Exception("Authentication failed")
535
+
536
+ await self._on_connect()
537
+
538
+ self._sync_task = asyncio.create_task(self._sync_loop())
539
+ self.logger.info("Matrix 适配器已启动")
540
+
541
+ async def shutdown(self):
542
+ self._running = False
543
+
544
+ if self.bot_id:
545
+ await self.sdk.adapter.emit({
546
+ "type": "meta",
547
+ "detail_type": "disconnect",
548
+ "platform": "matrix",
549
+ "self": {"platform": "matrix", "user_id": self.bot_id},
550
+ })
551
+
552
+ if self._heartbeat_meta_task:
553
+ self._heartbeat_meta_task.cancel()
554
+ try:
555
+ await self._heartbeat_meta_task
556
+ except asyncio.CancelledError:
557
+ pass
558
+ self._heartbeat_meta_task = None
559
+
560
+ if self._sync_task:
561
+ self._sync_task.cancel()
562
+ try:
563
+ await self._sync_task
564
+ except asyncio.CancelledError:
565
+ pass
566
+ self._sync_task = None
567
+
568
+ if self.session:
569
+ await self.session.close()
570
+ self.session = None
571
+
572
+ self._dm_rooms.clear()
573
+ unregister_platform_event_methods("matrix")
574
+ self.logger.info("Matrix 适配器已关闭")
@@ -0,0 +1 @@
1
+ from .Core import MatrixAdapter
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: ErisPulse-MatrixAdapter
3
+ Version: 1.0.0
4
+ Summary: ErisPulse的Matrix协议适配模块
5
+ Author-email: wsu2059 <wsu2059@qq.com>
6
+ Project-URL: homepage, https://github.com/ErisPulse/ErisPulse-MatrixAdapter
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: aiohttp>=3.8.0
10
+
11
+ # MatrixAdapter 模块文档
12
+
13
+ ## 简介
14
+ MatrixAdapter 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构的 Matrix 协议适配器,通过 Long Polling Sync API 接收事件,整合了私聊、群组等多种场景的功能模块,提供统一的事件处理和消息操作接口。
15
+
16
+ ## 使用示例
17
+
18
+ ### OneBot12标准事件类型
19
+
20
+ MatrixAdapter 适配器完全兼容 OneBot12 标准事件格式,并提供了一些扩展字段:
21
+
22
+ | 事件类型 | detail_type | 说明 |
23
+ |----------|-------------|------|
24
+ | 消息事件(私聊) | private | DM房间中用户发送的消息 |
25
+ | 消息事件(群组) | group | 群组房间中用户发送的消息 |
26
+ | 群成员增加 | group_member_increase | 用户加入房间 |
27
+ | 群成员减少 | group_member_decrease | 用户离开/被封禁 |
28
+ | 成员信息更新 | matrix_member_update | 房间成员信息变更 |
29
+ | Matrix表情回应 | matrix_reaction | 消息表情回应 |
30
+ | Matrix消息撤回 | matrix_redaction | 消息被撤回/删除 |
31
+ | Matrix房间名称变更 | matrix_name | 房间名称变更 |
32
+ | Matrix房间话题变更 | matrix_topic | 房间话题变更 |
33
+ | Matrix房间头像变更 | matrix_avatar | 房间头像变更 |
34
+ | Matrix权限等级变更 | matrix_power_levels | 房间权限变更 |
35
+
36
+ ---
37
+
38
+ ## 消息发送示例
39
+
40
+ ```python
41
+ from ErisPulse import sdk
42
+ matrix = sdk.adapter.get("matrix")
43
+
44
+ # 发送文本消息
45
+ await matrix.Send.To("group", room_id).Text("Hello World!")
46
+
47
+ # 发送带@的消息
48
+ await matrix.Send.To("group", room_id).At("@user:matrix.org").Text("你好")
49
+
50
+ # 发送带@所有人的消息
51
+ await matrix.Send.To("group", room_id).AtAll().Text("公告通知")
52
+
53
+ # 发送回复消息
54
+ await matrix.Send.To("group", room_id).Reply("$event_id").Text("回复内容")
55
+
56
+ # 发送图片(URL)
57
+ await matrix.Send.To("group", room_id).Image("https://example.com/image.png")
58
+
59
+ # 发送图片(MXC URI)
60
+ await matrix.Send.To("group", room_id).Image("mxc://matrix.org/abc123")
61
+
62
+ # 发送图片(二进制数据)
63
+ with open("image.png", "rb") as f:
64
+ image_data = f.read()
65
+ await matrix.Send.To("group", room_id).Image(image_data)
66
+
67
+ # 发送图片(本地文件路径)
68
+ await matrix.Send.To("group", room_id).Image("/path/to/image.png")
69
+
70
+ # 发送通知消息(m.notice)
71
+ await matrix.Send.To("group", room_id).Notice("系统通知")
72
+
73
+ # 发送HTML格式消息
74
+ await matrix.Send.To("group", room_id).Html("<b>加粗</b> <i>斜体</i>", fallback="加粗 斜体")
75
+
76
+ # 发送文件(带文件名)
77
+ await matrix.Send.To("group", room_id).File("/path/to/file.pdf", filename="文档.pdf")
78
+
79
+ # 组合使用:回复 + @
80
+ await matrix.Send.To("group", room_id).Reply("$event_id").At("@user:matrix.org").Text("复合消息")
81
+
82
+ # 使用 Raw_ob12 发送 OneBot12 格式消息
83
+ message = [
84
+ {"type": "text", "data": {"text": "第一行"}},
85
+ {"type": "image", "data": {"file": "https://example.com/img.jpg"}},
86
+ {"type": "text", "data": {"text": "第二行"}}
87
+ ]
88
+ await matrix.Send.To("group", room_id).Raw_ob12(message)
89
+ ```
90
+
91
+ ---
92
+
93
+ ### 配置说明
94
+
95
+ 首次运行会自动生成默认配置。
96
+
97
+ ```toml
98
+ # config.toml
99
+ [Matrix_Adapter]
100
+ homeserver = "https://matrix.org" # Matrix服务器地址(必填)
101
+ access_token = "YOUR_ACCESS_TOKEN" # 访问令牌(与 user_id+password 二选一)
102
+ user_id = "" # Matrix用户ID(如 @bot:matrix.org)
103
+ password = "" # Matrix用户密码
104
+ auto_accept_invites = true # 是否自动接受房间邀请(可选,默认为true)
105
+ ```
106
+
107
+ **配置项说明:**
108
+ - `homeserver`:Matrix服务器地址(必填),默认为 `https://matrix.org`
109
+ - `access_token`:访问令牌,可从Matrix客户端(如 Element)的设置中获取
110
+ - `user_id`:Matrix用户ID(如 `@bot:matrix.org`),与 `password` 配合使用
111
+ - `password`:Matrix用户密码,用于自动登录获取 access_token
112
+ - `auto_accept_invites`:是否自动接受房间邀请,默认为 `true`
113
+
114
+ **认证方式:**
115
+ - 方式一(推荐):直接提供 `access_token`
116
+ - 方式二:提供 `user_id` 和 `password`,适配器会自动调用登录接口获取 token
117
+
118
+ ---
119
+
120
+ ## Matrix平台特有功能
121
+
122
+ 请参考 [Matrix平台特性文档](platform-features.md) 了解Matrix平台的特有功能,包括去中心化架构、房间概念、Long Polling同步、MXC URI、HTML富文本、表情回应、消息编辑、扩展字段说明等内容。
123
+
124
+ 详细的事件转换对照请参考 [转换对照文档](CoverToOnebot12.md)。
125
+
126
+ ## 事件监听示例
127
+
128
+ ### 使用 Event 模块(推荐)
129
+
130
+ ```python
131
+ from ErisPulse.Core.Event import message, notice
132
+
133
+ @message.on_message()
134
+ async def handle_message(event):
135
+ if event["platform"] == "matrix":
136
+ detail_type = event["detail_type"]
137
+ if detail_type == "private":
138
+ # 处理私聊消息
139
+ pass
140
+ elif detail_type == "group":
141
+ # 处理群组消息
142
+ pass
143
+
144
+ @notice.on_notice()
145
+ async def handle_notice(event):
146
+ if event["platform"] == "matrix":
147
+ detail_type = event["detail_type"]
148
+ if detail_type == "matrix_reaction":
149
+ # 处理表情回应
150
+ reaction_key = event.get("matrix_reaction_key", "")
151
+ elif detail_type == "matrix_redaction":
152
+ # 处理消息撤回
153
+ redacted_id = event.get("matrix_redacted_event_id", "")
154
+ elif detail_type == "group_member_increase":
155
+ # 处理成员加入
156
+ user_id = event.get("user_id", "")
157
+ ```
158
+
159
+ ### 使用 OneBot12 标准事件
160
+
161
+ ```python
162
+ @sdk.adapter.on("message")
163
+ async def handle_message(event):
164
+ if event["platform"] == "matrix":
165
+ bot_id = event["self"]["user_id"]
166
+ print(f"消息来自Bot: {bot_id}")
167
+
168
+ @sdk.adapter.on("notice")
169
+ async def handle_notice(event):
170
+ if event["platform"] == "matrix":
171
+ # 处理Matrix通知事件
172
+ pass
173
+ ```
174
+
175
+ ### 使用 Event Mixin 方法
176
+
177
+ ```python
178
+ @message.on_message()
179
+ async def handle_message(event):
180
+ if event.get("platform") != "matrix":
181
+ return
182
+
183
+ room_id = event.get_room_id() # 获取房间ID
184
+ event_type = event.get_matrix_event_type() # 获取原始Matrix事件类型
185
+ sender = event.get_matrix_sender() # 获取发送者ID
186
+ is_edited = event.is_edited() # 是否为编辑消息
187
+ is_notice = event.is_notice() # 是否为 m.notice 类型
188
+ ```
189
+
190
+ ## 注意事项:
191
+
192
+ 1. 确保在调用 `startup()` 前完成所有处理器的注册
193
+ 2. Matrix 是去中心化协议,用户ID格式为 `@user:server.domain`,房间ID格式为 `!room_id:server.domain`
194
+ 3. Matrix 不区分群聊和私聊,所有会话都是"房间"。适配器通过 DM 账户数据自动识别私聊
195
+ 4. 适配器使用 Long Polling(`/sync` API)获取事件,而非 WebSocket
196
+ 5. 媒体文件通过 `mxc://` URI 引用,适配器支持自动上传和下载
197
+ 6. 程序退出时请调用 `shutdown()` 确保资源释放
198
+ 7. 支持自动接受房间邀请(可通过 `auto_accept_invites` 配置关闭)
199
+ 8. 支持发送 HTML 格式消息,使用 `.Html()` 方法
200
+ 9. 支持消息回复(`.Reply()`)和用户提及(`.At()`、`.AtAll()`)
201
+
202
+ ---
203
+
204
+ ### 参考链接
205
+
206
+ - [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
207
+ - [Matrix 协议规范](https://spec.matrix.org/)
208
+ - [Matrix Client-Server API](https://spec.matrix.org/v1.11/client-server-api/)
209
+ - [模块开发指南](https://www.erisdev.com/#docs/developer-guide/README.md)
@@ -0,0 +1,8 @@
1
+ MatrixAdapter/Converter.py,sha256=qUkDx9TzqWJeET8eZKYgmNnj4yUCO0_eH-mD3U5TBHI,10662
2
+ MatrixAdapter/Core.py,sha256=hFPlqE-1sP8ohWptjME9sY8pD1uz6zJWRIaEYrXr-SI,23319
3
+ MatrixAdapter/__init__.py,sha256=91-9JWoF_G3Nj6ddKoX4rYy7doT2hqNjUaNEgZG_eRM,32
4
+ erispulse_matrixadapter-1.0.0.dist-info/METADATA,sha256=EpeFRKIpmOY0my6zKaltqYnCdYrLGff6ynbj0h4Z7rA,7778
5
+ erispulse_matrixadapter-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ erispulse_matrixadapter-1.0.0.dist-info/entry_points.txt,sha256=Dmpp8-f_kLjGuKW7AAgvOHYz-FTu0J9VEZrcPizSVMU,57
7
+ erispulse_matrixadapter-1.0.0.dist-info/top_level.txt,sha256=rxVrq_fa3bzS5NhrvoY_QyNdsuY_SwumstPZ38NiR6E,14
8
+ erispulse_matrixadapter-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [erispulse.adapter]
2
+ matrix = MatrixAdapter:MatrixAdapter
@@ -0,0 +1 @@
1
+ MatrixAdapter