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.
- MatrixAdapter/Converter.py +284 -0
- MatrixAdapter/Core.py +574 -0
- MatrixAdapter/__init__.py +1 -0
- erispulse_matrixadapter-1.0.0.dist-info/METADATA +209 -0
- erispulse_matrixadapter-1.0.0.dist-info/RECORD +8 -0
- erispulse_matrixadapter-1.0.0.dist-info/WHEEL +5 -0
- erispulse_matrixadapter-1.0.0.dist-info/entry_points.txt +2 -0
- erispulse_matrixadapter-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
MatrixAdapter
|