errbot-backend-matrix 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ # Matrix Errbot backend plugin
2
+ from .matrix import MatrixBackend
3
+
4
+ __all__ = ["MatrixBackend"]
@@ -0,0 +1,9 @@
1
+ [Core]
2
+ Name = Matrix
3
+ Module = err_backend_matrix
4
+
5
+ [Documentation]
6
+ Description = A fully functional Errbot backend for Matrix using matrix-nio.
7
+
8
+ [Python]
9
+ Version = 3
@@ -0,0 +1,344 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Sequence
4
+
5
+ from errbot.backends.base import AWAY, DND, OFFLINE, ONLINE, Identifier, Message, Presence
6
+ from errbot.core import ErrBot
7
+ from errbot.rendering import text, xhtml
8
+ from nio import (
9
+ AsyncClient,
10
+ InviteMemberEvent,
11
+ LoginError,
12
+ MegolmEvent,
13
+ RoomCreateResponse,
14
+ RoomEncryptionEvent,
15
+ RoomMemberEvent,
16
+ RoomMessageText,
17
+ RoomPreset,
18
+ RoomResolveAliasResponse,
19
+ RoomSendResponse,
20
+ RoomTopicEvent,
21
+ )
22
+
23
+ from err_backend_matrix.person import MatrixPerson, MatrixRoomOccupant
24
+ from err_backend_matrix.room import MatrixRoom
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ class MatrixBackend(ErrBot):
30
+ """A fully functional Errbot backend for Matrix using matrix-nio."""
31
+
32
+ def __init__(self, config):
33
+ super().__init__(config)
34
+ self.client = None
35
+ self.loop = None
36
+ self._dm_rooms = {}
37
+
38
+ # Markdown renderers
39
+ self.md_text = text()
40
+ self.md_xhtml = xhtml()
41
+
42
+ # Bot identity configuration
43
+ self.homeserver = self.bot_config.BOT_IDENTITY.get("homeserver", "https://matrix.org")
44
+ self.mxid = self.bot_config.BOT_IDENTITY.get("username")
45
+ self.password = self.bot_config.BOT_IDENTITY.get("password")
46
+ self.token = self.bot_config.BOT_IDENTITY.get("token")
47
+ self.device_id = self.bot_config.BOT_IDENTITY.get("device_id")
48
+ self.store_path = self.bot_config.BOT_IDENTITY.get("store_path")
49
+ self.ssl = self.bot_config.BOT_IDENTITY.get("ssl", True)
50
+ self.proxy = self.bot_config.BOT_IDENTITY.get("proxy")
51
+
52
+ def run_async_coroutine(self, coro):
53
+ """Helper to run a coroutine in the backend event loop."""
54
+ if self.loop and self.loop.is_running():
55
+ return asyncio.run_coroutine_threadsafe(coro, self.loop)
56
+ else:
57
+ new_loop = asyncio.new_event_loop()
58
+ try:
59
+ return new_loop.run_until_complete(coro)
60
+ finally:
61
+ new_loop.close()
62
+
63
+ def run_async_coroutine_sync(self, coro):
64
+ """Helper to run a coroutine in the backend loop and block for the result."""
65
+ if self.loop and self.loop.is_running():
66
+ future = asyncio.run_coroutine_threadsafe(coro, self.loop)
67
+ return future.result()
68
+ else:
69
+ new_loop = asyncio.new_event_loop()
70
+ try:
71
+ return new_loop.run_until_complete(coro)
72
+ finally:
73
+ new_loop.close()
74
+
75
+ def serve_once(self) -> bool:
76
+ log.info("Initializing Matrix backend connection...")
77
+ self.loop = asyncio.new_event_loop()
78
+ asyncio.set_event_loop(self.loop)
79
+
80
+ self.client = AsyncClient(
81
+ homeserver=self.homeserver,
82
+ user=self.mxid,
83
+ device_id=self.device_id,
84
+ store_path=self.store_path,
85
+ ssl=self.ssl,
86
+ proxy=self.proxy,
87
+ )
88
+
89
+ try:
90
+ self.loop.run_until_complete(self.async_run())
91
+ except Exception:
92
+ log.exception("Matrix connection crashed:")
93
+ return False
94
+ finally:
95
+ log.info("Closing Matrix client and loop...")
96
+ self.loop.run_until_complete(self.client.close())
97
+ self.loop.close()
98
+ self.loop = None
99
+ return True
100
+
101
+ async def async_run(self):
102
+ log.info("Logging into Matrix...")
103
+ if self.token:
104
+ self.client.access_token = self.token
105
+ self.client.user_id = self.mxid
106
+ # Verify token
107
+ await self.client.whoami()
108
+ elif self.password:
109
+ res = await self.client.login(self.password)
110
+ if isinstance(res, LoginError):
111
+ raise Exception(f"Failed to log in to Matrix: {res.message}")
112
+ else:
113
+ raise Exception("No Matrix password or token configured in BOT_IDENTITY.")
114
+
115
+ log.info("Matrix login successful.")
116
+ self.bot_identifier = MatrixPerson(self.client.user_id)
117
+
118
+ # Register event callbacks
119
+ self.client.add_event_callback(self.on_room_message, RoomMessageText)
120
+ self.client.add_event_callback(self.on_room_member, RoomMemberEvent)
121
+ self.client.add_event_callback(self.on_room_topic, RoomTopicEvent)
122
+ self.client.add_event_callback(self.on_invite, InviteMemberEvent)
123
+ self.client.add_event_callback(self.on_megolm_event, MegolmEvent)
124
+ self.client.add_event_callback(self.on_room_encryption, RoomEncryptionEvent)
125
+
126
+ # Trigger Errbot connect callback
127
+ self.connect_callback()
128
+
129
+ # Initial sync to build rooms lists and fetch state
130
+ log.info("Syncing with homeserver...")
131
+ await self.client.sync(timeout=30000)
132
+ log.info("Initial sync complete. Ready for events.")
133
+
134
+ # Start listening for incoming events forever
135
+ await self.client.sync_forever(timeout=30000)
136
+
137
+ def on_room_message(self, room, event: RoomMessageText):
138
+ # Skip self-sent messages
139
+ if event.sender == self.client.user_id:
140
+ return
141
+
142
+ msg = Message(body=event.body)
143
+ msg.extras["event_id"] = event.event_id
144
+
145
+ # Look up display name from room user cache
146
+ sender_user = room.users.get(event.sender)
147
+ display_name = sender_user.name if sender_user else None
148
+
149
+ # Check if room is DM or MUC
150
+ is_dm = len(room.users) <= 2 and not room.canonical_alias
151
+
152
+ if is_dm:
153
+ msg.frm = MatrixPerson(event.sender, displayname=display_name)
154
+ msg.to = self.bot_identifier
155
+ else:
156
+ room_obj = MatrixRoom(room.room_id, self)
157
+ msg.frm = MatrixRoomOccupant(event.sender, room_obj, displayname=display_name)
158
+ msg.to = room_obj
159
+
160
+ # Extract mentions
161
+ mentioned = []
162
+ for word in event.body.split():
163
+ if word.startswith("@") and ":" in word:
164
+ clean_word = word.rstrip(".,:;!?")
165
+ try:
166
+ mentioned.append(self.build_identifier(clean_word))
167
+ except Exception:
168
+ log.debug(f"Failed to build identifier for clean_word: {clean_word}", exc_info=True)
169
+
170
+ # Trigger message callback
171
+ self.callback_message(msg)
172
+ if mentioned:
173
+ self.callback_mention(msg, mentioned)
174
+
175
+ def on_room_member(self, room, event: RoomMemberEvent):
176
+ if event.state_key == self.client.user_id:
177
+ # Self membership change
178
+ room_obj = MatrixRoom(room.room_id, self)
179
+ if event.membership == "join":
180
+ self.callback_room_joined(room_obj)
181
+ elif event.membership in ("leave", "ban"):
182
+ self.callback_room_left(room_obj)
183
+ else:
184
+ # Other membership changes mapping to presence
185
+ status = ONLINE if event.membership == "join" else OFFLINE
186
+ room_obj = MatrixRoom(room.room_id, self)
187
+ user_obj = MatrixRoomOccupant(event.state_key, room_obj)
188
+ presence = Presence(identifier=user_obj, status=status)
189
+ self.callback_presence(presence)
190
+
191
+ def on_room_topic(self, room, event: RoomTopicEvent):
192
+ room_obj = MatrixRoom(room.room_id, self)
193
+ self.callback_room_topic(room_obj)
194
+
195
+ def on_invite(self, room, event: InviteMemberEvent):
196
+ auto_join = self.bot_config.BOT_IDENTITY.get("auto_join_on_invite", True)
197
+ if auto_join:
198
+ log.info(f"Automatically joining room {room.room_id} after invite from {event.sender}")
199
+ self.run_async_coroutine(self.client.join(room.room_id))
200
+
201
+ def on_megolm_event(self, room, event: MegolmEvent):
202
+ log.warning(
203
+ f"Received encrypted MegolmEvent in room {room.room_id} that could not be decrypted. Session ID: {event.session_id}"
204
+ )
205
+
206
+ def on_room_encryption(self, room, event: RoomEncryptionEvent):
207
+ log.info(f"Room {room.room_id} is now encrypted using {event.algorithm}")
208
+
209
+ def send_message(self, msg: Message) -> None:
210
+ super().send_message(msg)
211
+ self.run_async_coroutine(self._async_send_message(msg))
212
+
213
+ async def _async_send_message(self, msg: Message):
214
+ if not self.client:
215
+ log.error("Matrix client not initialized; cannot send message.")
216
+ return
217
+
218
+ to_id = None
219
+ if msg.is_group:
220
+ room_str = str(msg.to)
221
+ if room_str.startswith("#"):
222
+ try:
223
+ res = await self.client.room_resolve_alias(room_str)
224
+ if isinstance(res, RoomResolveAliasResponse):
225
+ to_id = res.room_id
226
+ else:
227
+ to_id = room_str
228
+ except Exception:
229
+ to_id = room_str
230
+ else:
231
+ to_id = room_str
232
+ else:
233
+ # Resolving DM room
234
+ user_id = msg.to.userid
235
+ to_id = self._dm_rooms.get(user_id)
236
+ if not to_id:
237
+ # Scan rooms list
238
+ for r_id, r in self.client.rooms.items():
239
+ if len(r.users) <= 2 and user_id in r.users:
240
+ to_id = r_id
241
+ self._dm_rooms[user_id] = to_id
242
+ break
243
+
244
+ if not to_id:
245
+ # Create DM
246
+ log.info(f"Creating DM room with {user_id}")
247
+ res = await self.client.room_create(
248
+ is_direct=True, invite=[user_id], preset=RoomPreset.private_chat
249
+ )
250
+ if isinstance(res, RoomCreateResponse):
251
+ to_id = res.room_id
252
+ self._dm_rooms[user_id] = to_id
253
+ log.info(f"Created DM room {to_id} with {user_id}")
254
+ else:
255
+ log.error(f"Failed to create DM room with {user_id}: {res}")
256
+ return
257
+
258
+ if not to_id:
259
+ log.error("Could not resolve recipient ID.")
260
+ return
261
+
262
+ # Markdown body conversion
263
+ body_text = self.md_text.convert(msg.body)
264
+ body_html = self.md_xhtml.convert(msg.body)
265
+
266
+ content = {
267
+ "msgtype": "m.text",
268
+ "body": body_text,
269
+ "format": "org.matrix.custom.html",
270
+ "formatted_body": body_html,
271
+ }
272
+
273
+ # Rich thread/replies relations
274
+ if msg.parent and "event_id" in msg.parent.extras:
275
+ content["m.relates_to"] = {"m.in_reply_to": {"event_id": msg.parent.extras["event_id"]}}
276
+
277
+ try:
278
+ res = await self.client.room_send(room_id=to_id, message_type="m.room.message", content=content)
279
+ if isinstance(res, RoomSendResponse):
280
+ msg.extras["event_id"] = res.event_id
281
+ except Exception:
282
+ log.exception(f"Exception sending message to {to_id}:")
283
+
284
+ def change_presence(self, status: str = ONLINE, message: str = "") -> None:
285
+ if not self.client:
286
+ return
287
+
288
+ matrix_presence = "online"
289
+ if status == OFFLINE:
290
+ matrix_presence = "offline"
291
+ elif status in (AWAY, DND):
292
+ matrix_presence = "unavailable"
293
+
294
+ self.run_async_coroutine(self.client.set_presence(matrix_presence))
295
+
296
+ def build_identifier(self, text_representation: str) -> Identifier:
297
+ if text_representation.startswith("!") or text_representation.startswith("#"):
298
+ return MatrixRoom(text_representation, self)
299
+ else:
300
+ return MatrixPerson(text_representation)
301
+
302
+ def is_from_self(self, msg: Message) -> bool:
303
+ return msg.frm.person == self.client.user_id
304
+
305
+ def build_reply(self, msg: Message, text=None, private: bool = False, threaded: bool = False) -> Message:
306
+ reply = self.build_message(text or "")
307
+
308
+ if private:
309
+ if isinstance(msg.frm, MatrixRoomOccupant):
310
+ reply.to = MatrixPerson(msg.frm.userid)
311
+ else:
312
+ reply.to = msg.frm
313
+ else:
314
+ if msg.is_group:
315
+ reply.to = msg.to
316
+ else:
317
+ reply.to = msg.frm
318
+
319
+ if threaded:
320
+ reply.parent = msg
321
+
322
+ return reply
323
+
324
+ def prefix_groupchat_reply(self, message: Message, identifier: Identifier):
325
+ message.body = f"@{identifier.nick}: {message.body}"
326
+
327
+ def shutdown(self) -> None:
328
+ log.info("Shutting down Matrix backend...")
329
+ if self.client:
330
+ self.client.stop_sync_forever()
331
+ super().shutdown()
332
+
333
+ @property
334
+ def mode(self) -> str:
335
+ return "matrix"
336
+
337
+ @property
338
+ def rooms(self) -> Sequence[MatrixRoom]:
339
+ if not self.client:
340
+ return []
341
+ return [MatrixRoom(room_id, self) for room_id in self.client.rooms]
342
+
343
+ def query_room(self, room: str) -> MatrixRoom:
344
+ return MatrixRoom(room, self)
@@ -0,0 +1,81 @@
1
+ import logging
2
+
3
+ from errbot.backends.base import Person, RoomOccupant
4
+
5
+ log = logging.getLogger(__name__)
6
+
7
+
8
+ class MatrixPerson(Person):
9
+ """This class describes a person on the Matrix network."""
10
+
11
+ def __init__(self, userid: str, displayname: str = None):
12
+ if not userid.startswith("@") or ":" not in userid:
13
+ log.warning(f"Matrix user ID format might be invalid: {userid}")
14
+
15
+ self._userid = userid
16
+ self._displayname = displayname
17
+
18
+ @property
19
+ def userid(self) -> str:
20
+ return self._userid
21
+
22
+ @property
23
+ def username(self) -> str:
24
+ if self._displayname:
25
+ return self._displayname
26
+ if self._userid.startswith("@") and ":" in self._userid:
27
+ return self._userid[1:].split(":")[0]
28
+ return self._userid
29
+
30
+ @property
31
+ def fullname(self) -> str:
32
+ return self._displayname or self.username
33
+
34
+ @property
35
+ def aclattr(self) -> str:
36
+ return self._userid
37
+
38
+ # Errbot Person expectations
39
+ person = aclattr
40
+ nick = username
41
+ client = userid
42
+
43
+ def __str__(self) -> str:
44
+ return self._userid
45
+
46
+ def __unicode__(self) -> str:
47
+ return self._userid
48
+
49
+ def __eq__(self, other) -> bool:
50
+ if not isinstance(other, MatrixPerson):
51
+ return False
52
+ return self._userid == other._userid
53
+
54
+ def __hash__(self) -> int:
55
+ return hash(self._userid)
56
+
57
+
58
+ class MatrixRoomOccupant(MatrixPerson, RoomOccupant):
59
+ """This class represents a person inside a Matrix Room."""
60
+
61
+ def __init__(self, userid: str, room, displayname: str = None):
62
+ super().__init__(userid, displayname=displayname)
63
+ self._room = room
64
+
65
+ @property
66
+ def room(self):
67
+ return self._room
68
+
69
+ def __str__(self) -> str:
70
+ return f"{self._room}/{self.username}"
71
+
72
+ def __unicode__(self) -> str:
73
+ return f"{self._room}/{self.username}"
74
+
75
+ def __eq__(self, other) -> bool:
76
+ if not isinstance(other, MatrixRoomOccupant):
77
+ return False
78
+ return self.room == other.room and self.userid == other.userid
79
+
80
+ def __hash__(self) -> int:
81
+ return hash((self.userid, self.room))
@@ -0,0 +1,147 @@
1
+ import logging
2
+
3
+ from errbot.backends.base import Room, RoomError
4
+
5
+ log = logging.getLogger(__name__)
6
+
7
+
8
+ class MatrixRoom(Room):
9
+ """This class represents a Multi-User Chatroom on Matrix."""
10
+
11
+ def __init__(self, room_id_or_alias: str, backend=None):
12
+ self._id_or_alias = room_id_or_alias
13
+ self._backend = backend
14
+
15
+ def __str__(self) -> str:
16
+ return self._id_or_alias
17
+
18
+ @property
19
+ def id(self) -> str:
20
+ return self._id_or_alias
21
+
22
+ @property
23
+ def aclattr(self) -> str:
24
+ return self._id_or_alias
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ if self._backend is not None and self._backend.client is not None:
29
+ resolved_id = self.resolve_room_id()
30
+ room = self._backend.client.rooms.get(resolved_id)
31
+ if room and room.name:
32
+ return room.name
33
+ return self._id_or_alias
34
+
35
+ @property
36
+ def exists(self) -> bool:
37
+ if self._backend is not None and self._backend.client is not None:
38
+ resolved_id = self.resolve_room_id()
39
+ return resolved_id in self._backend.client.rooms
40
+ return False
41
+
42
+ @property
43
+ def joined(self) -> bool:
44
+ if self._backend is not None and self._backend.client is not None:
45
+ resolved_id = self.resolve_room_id()
46
+ return resolved_id in self._backend.client.rooms
47
+ return False
48
+
49
+ def join(self, username: str = None, password: str = None) -> None:
50
+ if self._backend is None:
51
+ raise RoomError("No backend associated with room")
52
+ self._backend.run_async_coroutine_sync(self._backend.client.join(self._id_or_alias))
53
+
54
+ def leave(self, reason: str = None) -> None:
55
+ if self._backend is None:
56
+ raise RoomError("No backend associated with room")
57
+ room_id = self.resolve_room_id()
58
+ if not room_id:
59
+ raise RoomError("Could not resolve room ID to leave")
60
+ self._backend.run_async_coroutine_sync(self._backend.client.room_leave(room_id))
61
+
62
+ def create(self) -> None:
63
+ if self._backend is None:
64
+ raise RoomError("No backend associated with room")
65
+ alias_localpart = None
66
+ if self._id_or_alias.startswith("#") and ":" in self._id_or_alias:
67
+ alias_localpart = self._id_or_alias[1:].split(":")[0]
68
+
69
+ self._backend.run_async_coroutine_sync(
70
+ self._backend.client.room_create(alias_localpart=alias_localpart, name=alias_localpart)
71
+ )
72
+
73
+ def destroy(self) -> None:
74
+ room_id = self.resolve_room_id()
75
+ if room_id:
76
+ self._backend.run_async_coroutine_sync(self._backend.client.room_leave(room_id))
77
+ self._backend.run_async_coroutine_sync(self._backend.client.room_forget(room_id))
78
+
79
+ @property
80
+ def topic(self) -> str:
81
+ if self._backend is not None and self._backend.client is not None:
82
+ room = self._backend.client.rooms.get(self.resolve_room_id())
83
+ if room:
84
+ return room.topic or ""
85
+ return ""
86
+
87
+ @topic.setter
88
+ def topic(self, topic: str) -> None:
89
+ if self._backend is None:
90
+ raise RoomError("No backend associated with room")
91
+ room_id = self.resolve_room_id()
92
+ if not room_id:
93
+ raise RoomError("Could not resolve room ID to set topic")
94
+ self._backend.run_async_coroutine_sync(
95
+ self._backend.client.room_put_state(room_id=room_id, event_type="m.room.topic", content={"topic": topic})
96
+ )
97
+
98
+ @property
99
+ def occupants(self) -> list:
100
+ if self._backend is None or self._backend.client is None:
101
+ return []
102
+ room_id = self.resolve_room_id()
103
+ if not room_id:
104
+ return []
105
+ room = self._backend.client.rooms.get(room_id)
106
+ if not room:
107
+ return []
108
+
109
+ from .person import MatrixRoomOccupant
110
+
111
+ return [MatrixRoomOccupant(user_id, self, displayname=user.name) for user_id, user in room.users.items()]
112
+
113
+ def invite(self, *args) -> None:
114
+ if self._backend is None:
115
+ raise RoomError("No backend associated with room")
116
+ room_id = self.resolve_room_id()
117
+ if not room_id:
118
+ raise RoomError("Could not resolve room ID to invite users")
119
+ for user in args:
120
+ user_id = str(user)
121
+ self._backend.run_async_coroutine_sync(self._backend.client.room_invite(room_id, user_id))
122
+
123
+ def resolve_room_id(self) -> str:
124
+ if self._id_or_alias.startswith("!"):
125
+ return self._id_or_alias
126
+ if self._backend is not None and self._backend.client is not None:
127
+ # Check standard room list
128
+ for room_id, room in self._backend.client.rooms.items():
129
+ if room.canonical_alias == self._id_or_alias:
130
+ return room_id
131
+ # Fetch resolution via API
132
+ try:
133
+ res = self._backend.run_async_coroutine_sync(self._backend.client.room_resolve_alias(self._id_or_alias))
134
+ # In matrix-nio, RoomResolveAliasResponse has room_id
135
+ if res and hasattr(res, "room_id"):
136
+ return res.room_id
137
+ except Exception:
138
+ log.debug(f"Failed to resolve room alias: {self._id_or_alias}", exc_info=True)
139
+ return self._id_or_alias
140
+
141
+ def __eq__(self, other) -> bool:
142
+ if not isinstance(other, MatrixRoom):
143
+ return False
144
+ return self.resolve_room_id() == other.resolve_room_id()
145
+
146
+ def __hash__(self) -> int:
147
+ return hash(self.resolve_room_id())