mmrelay 1.2.6__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.
- mmrelay/__init__.py +5 -0
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +2013 -0
- mmrelay/cli_utils.py +746 -0
- mmrelay/config.py +956 -0
- mmrelay/constants/__init__.py +65 -0
- mmrelay/constants/app.py +29 -0
- mmrelay/constants/config.py +78 -0
- mmrelay/constants/database.py +22 -0
- mmrelay/constants/formats.py +20 -0
- mmrelay/constants/messages.py +45 -0
- mmrelay/constants/network.py +45 -0
- mmrelay/constants/plugins.py +42 -0
- mmrelay/constants/queue.py +20 -0
- mmrelay/db_runtime.py +269 -0
- mmrelay/db_utils.py +1017 -0
- mmrelay/e2ee_utils.py +400 -0
- mmrelay/log_utils.py +274 -0
- mmrelay/main.py +439 -0
- mmrelay/matrix_utils.py +3091 -0
- mmrelay/meshtastic_utils.py +1245 -0
- mmrelay/message_queue.py +647 -0
- mmrelay/plugin_loader.py +1933 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +638 -0
- mmrelay/plugins/debug_plugin.py +30 -0
- mmrelay/plugins/drop_plugin.py +127 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +79 -0
- mmrelay/plugins/map_plugin.py +353 -0
- mmrelay/plugins/mesh_relay_plugin.py +222 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +128 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +312 -0
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +828 -0
- mmrelay/tools/__init__.py +27 -0
- mmrelay/tools/mmrelay.service +19 -0
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
- mmrelay/tools/sample-docker-compose.yaml +30 -0
- mmrelay/tools/sample.env +10 -0
- mmrelay/tools/sample_config.yaml +120 -0
- mmrelay/windows_utils.py +346 -0
- mmrelay-1.2.6.dist-info/METADATA +145 -0
- mmrelay-1.2.6.dist-info/RECORD +50 -0
- mmrelay-1.2.6.dist-info/WHEEL +5 -0
- mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
- mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
- mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/matrix_utils.py
ADDED
|
@@ -0,0 +1,3091 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import getpass
|
|
3
|
+
import html
|
|
4
|
+
import importlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import ssl
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
from nio import (
|
|
17
|
+
AsyncClient,
|
|
18
|
+
AsyncClientConfig,
|
|
19
|
+
DiscoveryInfoError,
|
|
20
|
+
DiscoveryInfoResponse,
|
|
21
|
+
MatrixRoom,
|
|
22
|
+
MegolmEvent,
|
|
23
|
+
ReactionEvent,
|
|
24
|
+
RoomMessageEmote,
|
|
25
|
+
RoomMessageNotice,
|
|
26
|
+
RoomMessageText,
|
|
27
|
+
UploadResponse,
|
|
28
|
+
)
|
|
29
|
+
from nio.events.room_events import RoomMemberEvent
|
|
30
|
+
from PIL import Image
|
|
31
|
+
|
|
32
|
+
# Import nio exception types with error handling for test environments
|
|
33
|
+
try:
|
|
34
|
+
from nio.exceptions import LocalProtocolError as NioLocalProtocolError
|
|
35
|
+
from nio.exceptions import LocalTransportError as NioLocalTransportError
|
|
36
|
+
from nio.exceptions import RemoteProtocolError as NioRemoteProtocolError
|
|
37
|
+
from nio.exceptions import RemoteTransportError as NioRemoteTransportError
|
|
38
|
+
from nio.responses import ErrorResponse as NioErrorResponse
|
|
39
|
+
from nio.responses import LoginError as NioLoginError
|
|
40
|
+
from nio.responses import LogoutError as NioLogoutError
|
|
41
|
+
except ImportError:
|
|
42
|
+
# Fallback for test environments where nio imports might fail
|
|
43
|
+
NioLoginError = Exception
|
|
44
|
+
NioLogoutError = Exception
|
|
45
|
+
NioErrorResponse = Exception
|
|
46
|
+
NioLocalProtocolError = Exception
|
|
47
|
+
NioRemoteProtocolError = Exception
|
|
48
|
+
NioLocalTransportError = Exception
|
|
49
|
+
NioRemoteTransportError = Exception
|
|
50
|
+
|
|
51
|
+
from mmrelay.cli_utils import (
|
|
52
|
+
_create_ssl_context,
|
|
53
|
+
msg_require_auth_login,
|
|
54
|
+
msg_retry_auth_login,
|
|
55
|
+
)
|
|
56
|
+
from mmrelay.config import (
|
|
57
|
+
get_base_dir,
|
|
58
|
+
get_e2ee_store_dir,
|
|
59
|
+
get_meshtastic_config_value,
|
|
60
|
+
load_credentials,
|
|
61
|
+
save_credentials,
|
|
62
|
+
)
|
|
63
|
+
from mmrelay.constants.app import WINDOWS_PLATFORM
|
|
64
|
+
from mmrelay.constants.config import (
|
|
65
|
+
CONFIG_SECTION_MATRIX,
|
|
66
|
+
DEFAULT_BROADCAST_ENABLED,
|
|
67
|
+
DEFAULT_DETECTION_SENSOR,
|
|
68
|
+
E2EE_KEY_SHARING_DELAY_SECONDS,
|
|
69
|
+
)
|
|
70
|
+
from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
|
|
71
|
+
from mmrelay.constants.formats import (
|
|
72
|
+
DEFAULT_MATRIX_PREFIX,
|
|
73
|
+
DEFAULT_MESHTASTIC_PREFIX,
|
|
74
|
+
DETECTION_SENSOR_APP,
|
|
75
|
+
)
|
|
76
|
+
from mmrelay.constants.messages import (
|
|
77
|
+
DEFAULT_MESSAGE_TRUNCATE_BYTES,
|
|
78
|
+
DISPLAY_NAME_DEFAULT_LENGTH,
|
|
79
|
+
MAX_TRUNCATION_LENGTH,
|
|
80
|
+
MESHNET_NAME_ABBREVIATION_LENGTH,
|
|
81
|
+
MESSAGE_PREVIEW_LENGTH,
|
|
82
|
+
SHORTNAME_FALLBACK_LENGTH,
|
|
83
|
+
TRUNCATION_LOG_LIMIT,
|
|
84
|
+
)
|
|
85
|
+
from mmrelay.constants.network import (
|
|
86
|
+
MATRIX_EARLY_SYNC_TIMEOUT,
|
|
87
|
+
MATRIX_LOGIN_TIMEOUT,
|
|
88
|
+
MATRIX_ROOM_SEND_TIMEOUT,
|
|
89
|
+
MATRIX_SYNC_OPERATION_TIMEOUT,
|
|
90
|
+
MILLISECONDS_PER_SECOND,
|
|
91
|
+
)
|
|
92
|
+
from mmrelay.db_utils import (
|
|
93
|
+
async_prune_message_map,
|
|
94
|
+
async_store_message_map,
|
|
95
|
+
get_message_map_by_matrix_event_id,
|
|
96
|
+
)
|
|
97
|
+
from mmrelay.log_utils import get_logger
|
|
98
|
+
|
|
99
|
+
# Do not import plugin_loader here to avoid circular imports
|
|
100
|
+
from mmrelay.meshtastic_utils import connect_meshtastic, sendTextReply
|
|
101
|
+
from mmrelay.message_queue import get_message_queue, queue_message
|
|
102
|
+
|
|
103
|
+
logger = get_logger(name="Matrix")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_room_alias(value: Any) -> bool:
|
|
107
|
+
"""Return True when value looks like a Matrix room alias (string starting with '#')."""
|
|
108
|
+
|
|
109
|
+
return isinstance(value, str) and value.startswith("#")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _iter_room_alias_entries(mapping):
|
|
113
|
+
"""
|
|
114
|
+
Yield (alias_or_id, setter) pairs for entries in a Matrix room mapping.
|
|
115
|
+
|
|
116
|
+
Each yielded tuple contains:
|
|
117
|
+
- alias_or_id (str): the room alias or room ID found in the entry (may be an alias starting with '#' or a canonical room ID). If a dict entry has no `"id"` key, an empty string is yielded.
|
|
118
|
+
- setter (callable): a function accepting a single argument `new_id` which updates the underlying mapping in-place to replace the alias with the resolved room ID.
|
|
119
|
+
|
|
120
|
+
Supports two mapping shapes:
|
|
121
|
+
- list: items may be strings (alias/ID) or dicts with an `"id"` key.
|
|
122
|
+
- dict: values may be strings (alias/ID) or dicts with an `"id"` key.
|
|
123
|
+
|
|
124
|
+
The setter updates the original collection (list element or dict value) so callers can resolve aliases and persist resolved IDs back into the provided mapping.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
if isinstance(mapping, list):
|
|
128
|
+
for index, entry in enumerate(mapping):
|
|
129
|
+
if isinstance(entry, dict):
|
|
130
|
+
yield entry.get(
|
|
131
|
+
"id", ""
|
|
132
|
+
), lambda new_id, target=entry: target.__setitem__("id", new_id)
|
|
133
|
+
else:
|
|
134
|
+
yield entry, lambda new_id, idx=index, collection=mapping: collection.__setitem__(
|
|
135
|
+
idx, new_id
|
|
136
|
+
)
|
|
137
|
+
elif isinstance(mapping, dict):
|
|
138
|
+
for key, entry in list(mapping.items()):
|
|
139
|
+
if isinstance(entry, dict):
|
|
140
|
+
yield entry.get(
|
|
141
|
+
"id", ""
|
|
142
|
+
), lambda new_id, target=entry: target.__setitem__("id", new_id)
|
|
143
|
+
else:
|
|
144
|
+
yield entry, lambda new_id, target_key=key, collection=mapping: collection.__setitem__(
|
|
145
|
+
target_key, new_id
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _resolve_aliases_in_mapping(mapping, resolver):
|
|
150
|
+
"""
|
|
151
|
+
Resolve Matrix room alias entries found in a mapping (list or dict) by calling an async resolver and replacing aliases with resolved room IDs in-place.
|
|
152
|
+
|
|
153
|
+
This function iterates entries produced by _iter_room_alias_entries(mapping). For each entry whose key/value looks like a Matrix room alias (a string starting with '#'), it awaits the provided resolver coroutine with the alias; if the resolver returns a truthy room ID, the corresponding entry in the original mapping is updated via the entry's setter. If mapping is not a list or dict, the function logs a warning and returns without modifying anything.
|
|
154
|
+
|
|
155
|
+
Parameters:
|
|
156
|
+
mapping (list|dict): A mapping of Matrix rooms where some entries may be aliases (e.g., "#room:example.org").
|
|
157
|
+
resolver (Callable[[str], Awaitable[Optional[str]]]): Async callable that accepts an alias and returns a resolved room ID (or falsy on failure).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
None
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
if not isinstance(mapping, (list, dict)):
|
|
164
|
+
logger.warning(
|
|
165
|
+
"matrix_rooms is expected to be a list or dict, got %s",
|
|
166
|
+
type(mapping).__name__,
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
for alias, setter in _iter_room_alias_entries(mapping):
|
|
171
|
+
if _is_room_alias(alias):
|
|
172
|
+
resolved_id = await resolver(alias)
|
|
173
|
+
if resolved_id:
|
|
174
|
+
setter(resolved_id)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _update_room_id_in_mapping(mapping, alias, resolved_id) -> bool:
|
|
178
|
+
"""
|
|
179
|
+
Replace a room alias with its resolved room ID in a mapping.
|
|
180
|
+
|
|
181
|
+
Parameters:
|
|
182
|
+
mapping (list|dict): A matrix_rooms mapping represented as a list of aliases or a dict of entries; only list and dict types are supported.
|
|
183
|
+
alias (str): The room alias to replace (e.g., "#room:server").
|
|
184
|
+
resolved_id (str): The canonical room ID to substitute for the alias (e.g., "!abcdef:server").
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
bool: True if the alias was found and replaced with resolved_id; False if the mapping type is unsupported or the alias was not present.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
if not isinstance(mapping, (list, dict)):
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
for existing_alias, setter in _iter_room_alias_entries(mapping):
|
|
194
|
+
if existing_alias == alias:
|
|
195
|
+
setter(resolved_id)
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _display_room_channel_mappings(
|
|
201
|
+
rooms: Dict[str, Any], config: Dict[str, Any], e2ee_status: Dict[str, Any]
|
|
202
|
+
) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Log Matrix rooms grouped by Meshtastic channel, showing mapping counts and E2EE/encryption indicators.
|
|
205
|
+
|
|
206
|
+
Reads the "matrix_rooms" entry from config (accepting either dict or list form), builds a mapping from room ID to the configured "meshtastic_channel", then groups and logs rooms ordered by channel number. For each room logs an emoji/status depending on the room's encryption flag and the provided e2ee_status["overall_status"] (common values: "ready", "unavailable", "disabled").
|
|
207
|
+
|
|
208
|
+
Parameters:
|
|
209
|
+
rooms (dict): Mapping of room_id -> room object (room objects should expose at least `display_name` and `encrypted` attributes or fall back to the room_id).
|
|
210
|
+
config (dict): Configuration dict containing a "matrix_rooms" section; entries should include "id" and "meshtastic_channel" when using dict/list room formats.
|
|
211
|
+
e2ee_status (dict): E2EE status information; function expects an "overall_status" key used to determine messaging/encryption indicators.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
None
|
|
215
|
+
"""
|
|
216
|
+
if not rooms:
|
|
217
|
+
logger.info("Bot is not in any Matrix rooms")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Get matrix_rooms configuration
|
|
221
|
+
matrix_rooms_config = config.get("matrix_rooms", [])
|
|
222
|
+
if not matrix_rooms_config:
|
|
223
|
+
logger.info("No matrix_rooms configuration found")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Normalize matrix_rooms configuration to list format
|
|
227
|
+
if isinstance(matrix_rooms_config, dict):
|
|
228
|
+
# Convert dict format to list format
|
|
229
|
+
matrix_rooms_list = list(matrix_rooms_config.values())
|
|
230
|
+
else:
|
|
231
|
+
# Already in list format
|
|
232
|
+
matrix_rooms_list = matrix_rooms_config
|
|
233
|
+
|
|
234
|
+
# Create mapping of room_id -> channel number
|
|
235
|
+
room_to_channel = {}
|
|
236
|
+
for room_config in matrix_rooms_list:
|
|
237
|
+
if isinstance(room_config, dict):
|
|
238
|
+
room_id = room_config.get("id")
|
|
239
|
+
channel = room_config.get("meshtastic_channel")
|
|
240
|
+
if room_id and channel is not None:
|
|
241
|
+
room_to_channel[room_id] = channel
|
|
242
|
+
|
|
243
|
+
# Group rooms by channel
|
|
244
|
+
channels = {}
|
|
245
|
+
|
|
246
|
+
for room_id, room in rooms.items():
|
|
247
|
+
if room_id in room_to_channel:
|
|
248
|
+
channel = room_to_channel[room_id]
|
|
249
|
+
if channel not in channels:
|
|
250
|
+
channels[channel] = []
|
|
251
|
+
channels[channel].append((room_id, room))
|
|
252
|
+
|
|
253
|
+
# Display header
|
|
254
|
+
mapped_rooms = sum(len(room_list) for room_list in channels.values())
|
|
255
|
+
logger.info(f"Matrix Rooms → Meshtastic Channels ({mapped_rooms} configured):")
|
|
256
|
+
|
|
257
|
+
# Display rooms organized by channel (sorted by channel number)
|
|
258
|
+
for channel in sorted(channels.keys()):
|
|
259
|
+
room_list = channels[channel]
|
|
260
|
+
logger.info(f" Channel {channel}:")
|
|
261
|
+
|
|
262
|
+
for room_id, room in room_list:
|
|
263
|
+
room_name = getattr(room, "display_name", room_id)
|
|
264
|
+
encrypted = getattr(room, "encrypted", False)
|
|
265
|
+
|
|
266
|
+
# Format with encryption status
|
|
267
|
+
if e2ee_status["overall_status"] == "ready":
|
|
268
|
+
if encrypted:
|
|
269
|
+
logger.info(f" 🔒 {room_name}")
|
|
270
|
+
else:
|
|
271
|
+
logger.info(f" ✅ {room_name}")
|
|
272
|
+
else:
|
|
273
|
+
if encrypted:
|
|
274
|
+
if e2ee_status["overall_status"] == "unavailable":
|
|
275
|
+
logger.info(
|
|
276
|
+
f" ⚠️ {room_name} (E2EE not supported - messages blocked)"
|
|
277
|
+
)
|
|
278
|
+
elif e2ee_status["overall_status"] == "disabled":
|
|
279
|
+
logger.info(
|
|
280
|
+
f" ⚠️ {room_name} (E2EE disabled - messages blocked)"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
logger.info(
|
|
284
|
+
f" ⚠️ {room_name} (E2EE incomplete - messages may be blocked)"
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
logger.info(f" ✅ {room_name}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _can_auto_create_credentials(matrix_config: dict) -> bool:
|
|
291
|
+
"""
|
|
292
|
+
Return True if the Matrix config provides non-empty strings for homeserver, a user id (bot_user_id or user_id), and password.
|
|
293
|
+
|
|
294
|
+
Checks that the `matrix_config` contains the required fields to perform an automatic login flow by ensuring each value exists and is a non-blank string.
|
|
295
|
+
|
|
296
|
+
Parameters:
|
|
297
|
+
matrix_config (dict): The `matrix` section from config.yaml.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
bool: True when homeserver, (bot_user_id or user_id), and password are all present and non-empty strings; otherwise False.
|
|
301
|
+
"""
|
|
302
|
+
homeserver = matrix_config.get("homeserver")
|
|
303
|
+
user = matrix_config.get("bot_user_id") or matrix_config.get("user_id")
|
|
304
|
+
password = matrix_config.get("password")
|
|
305
|
+
return all(isinstance(v, str) and v.strip() for v in (homeserver, user, password))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _normalize_bot_user_id(homeserver: str, bot_user_id: str) -> str:
|
|
309
|
+
"""
|
|
310
|
+
Normalize a bot user identifier into a full Matrix MXID.
|
|
311
|
+
|
|
312
|
+
Accepts several common input forms and returns a normalized Matrix ID of the form
|
|
313
|
+
"@localpart:server". Behavior:
|
|
314
|
+
- If bot_user_id is falsy, it is returned unchanged.
|
|
315
|
+
- If bot_user_id already contains a server part (e.g. "@user:server.com" or "user:server.com"),
|
|
316
|
+
the existing server is preserved (any trailing numeric port is removed).
|
|
317
|
+
- If bot_user_id lacks a server part (e.g. "@user" or "user"), the server domain is derived
|
|
318
|
+
from the provided homeserver and appended.
|
|
319
|
+
- The homeserver argument is tolerant of missing URL scheme and will extract the hostname
|
|
320
|
+
portion (handles inputs like "example.com", "https://example.com:8448", or
|
|
321
|
+
"[::1]:8448/path").
|
|
322
|
+
|
|
323
|
+
Parameters:
|
|
324
|
+
homeserver (str): The Matrix homeserver URL or host used to derive a server domain.
|
|
325
|
+
bot_user_id (str): A bot identifier in one of several forms (with or without leading "@"
|
|
326
|
+
and with or without a server part).
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
str: A normalized Matrix user ID in the form "@localpart:server".
|
|
330
|
+
"""
|
|
331
|
+
if not bot_user_id:
|
|
332
|
+
return bot_user_id
|
|
333
|
+
|
|
334
|
+
def _canonical_server(value: str | None) -> str | None:
|
|
335
|
+
if not value:
|
|
336
|
+
return value
|
|
337
|
+
value = value.strip()
|
|
338
|
+
if value.startswith("[") and "]" in value:
|
|
339
|
+
closing_index = value.find("]")
|
|
340
|
+
value = value[1:closing_index]
|
|
341
|
+
if value.count(":") == 1 and re.search(r":\d+$", value):
|
|
342
|
+
value = value.rsplit(":", 1)[0]
|
|
343
|
+
if ":" in value and not value.startswith("["):
|
|
344
|
+
value = f"[{value}]"
|
|
345
|
+
return value
|
|
346
|
+
|
|
347
|
+
# Derive domain from homeserver (tolerate missing scheme; drop brackets/port/paths)
|
|
348
|
+
parsed = urlparse(homeserver)
|
|
349
|
+
domain = parsed.hostname or urlparse(f"//{homeserver}").hostname
|
|
350
|
+
if not domain:
|
|
351
|
+
# Last-ditch fallback for malformed inputs; drop any trailing :port
|
|
352
|
+
host = homeserver.split("://")[-1].split("/", 1)[0]
|
|
353
|
+
domain = re.sub(r":\d+$", "", host)
|
|
354
|
+
|
|
355
|
+
domain = _canonical_server(domain)
|
|
356
|
+
|
|
357
|
+
# Normalize user ID
|
|
358
|
+
localpart, *serverpart = bot_user_id.lstrip("@").split(":", 1)
|
|
359
|
+
if serverpart and serverpart[0]:
|
|
360
|
+
# Already has a server part; drop any brackets/port consistently
|
|
361
|
+
raw_server = serverpart[0]
|
|
362
|
+
server = urlparse(f"//{raw_server}").hostname or re.sub(
|
|
363
|
+
r":\d+$",
|
|
364
|
+
"",
|
|
365
|
+
raw_server,
|
|
366
|
+
)
|
|
367
|
+
server = _canonical_server(server)
|
|
368
|
+
return f"@{localpart}:{server}"
|
|
369
|
+
|
|
370
|
+
# No server part, add the derived domain
|
|
371
|
+
return f"@{localpart.rstrip(':')}:{domain}"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _get_msgs_to_keep_config():
|
|
375
|
+
"""
|
|
376
|
+
Return the configured number of Meshtastic–Matrix message mappings to retain.
|
|
377
|
+
|
|
378
|
+
Reads the global `config` and prefers the new location `database.msg_map.msgs_to_keep`.
|
|
379
|
+
If that section is absent, falls back to the legacy `db.msg_map.msgs_to_keep` and emits a deprecation warning.
|
|
380
|
+
If no configuration is available or `msgs_to_keep` is not set, returns DEFAULT_MSGS_TO_KEEP.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
int: Number of message mappings to keep.
|
|
384
|
+
"""
|
|
385
|
+
global config
|
|
386
|
+
if not config:
|
|
387
|
+
return DEFAULT_MSGS_TO_KEEP
|
|
388
|
+
|
|
389
|
+
msg_map_config = config.get("database", {}).get("msg_map", {})
|
|
390
|
+
|
|
391
|
+
# If not found in database config, check legacy db config
|
|
392
|
+
if not msg_map_config:
|
|
393
|
+
legacy_msg_map_config = config.get("db", {}).get("msg_map", {})
|
|
394
|
+
|
|
395
|
+
if legacy_msg_map_config:
|
|
396
|
+
msg_map_config = legacy_msg_map_config
|
|
397
|
+
logger.warning(
|
|
398
|
+
"Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return msg_map_config.get("msgs_to_keep", DEFAULT_MSGS_TO_KEEP)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _get_detailed_sync_error_message(sync_response) -> str:
|
|
405
|
+
"""
|
|
406
|
+
Return a concise, user-facing explanation for why an initial Matrix sync failed.
|
|
407
|
+
|
|
408
|
+
Given a sync response or error object (commonly a nio ErrorResponse, an HTTP/transport error, raw bytes, or any object exposing `message`, `status_code`, or `transport_response`), extract the most specific human-readable reason available and map common HTTP/transport conditions to short, actionable messages (e.g., authentication failure, forbidden, not found, rate limited, server error). Falls back to a generic network/connectivity message when no specific detail can be reliably extracted.
|
|
409
|
+
|
|
410
|
+
Parameters:
|
|
411
|
+
sync_response: The sync response or error object to summarize. May be bytes/bytearray, a nio ErrorResponse-like object, or any object with `message`, `status_code`, or `transport_response` attributes.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
str: A short, user-focused error description suitable for logs and brief troubleshooting hints.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
# Handle bytes/bytearray types by converting to string
|
|
418
|
+
if isinstance(sync_response, (bytes, bytearray)):
|
|
419
|
+
try:
|
|
420
|
+
sync_response = sync_response.decode("utf-8")
|
|
421
|
+
except UnicodeDecodeError:
|
|
422
|
+
return "Network connectivity issue or server unreachable (binary data)"
|
|
423
|
+
|
|
424
|
+
# Try to extract specific error information
|
|
425
|
+
if hasattr(sync_response, "message") and sync_response.message:
|
|
426
|
+
message = sync_response.message
|
|
427
|
+
# Handle if message is bytes/bytearray
|
|
428
|
+
if isinstance(message, (bytes, bytearray)):
|
|
429
|
+
try:
|
|
430
|
+
message = message.decode("utf-8")
|
|
431
|
+
except UnicodeDecodeError:
|
|
432
|
+
return "Network connectivity issue or server unreachable"
|
|
433
|
+
return message
|
|
434
|
+
elif hasattr(sync_response, "status_code") and sync_response.status_code:
|
|
435
|
+
status_code = sync_response.status_code
|
|
436
|
+
# Handle if status_code is not an int
|
|
437
|
+
try:
|
|
438
|
+
status_code = int(status_code)
|
|
439
|
+
except (ValueError, TypeError):
|
|
440
|
+
return "Network connectivity issue or server unreachable"
|
|
441
|
+
|
|
442
|
+
if status_code == 401:
|
|
443
|
+
return "Authentication failed - invalid or expired credentials"
|
|
444
|
+
elif status_code == 403:
|
|
445
|
+
return "Access forbidden - check user permissions"
|
|
446
|
+
elif status_code == 404:
|
|
447
|
+
return "Server not found - check homeserver URL"
|
|
448
|
+
elif status_code == 429:
|
|
449
|
+
return "Rate limited - too many requests"
|
|
450
|
+
elif status_code >= 500:
|
|
451
|
+
return f"Server error (HTTP {status_code}) - the Matrix server is experiencing issues"
|
|
452
|
+
else:
|
|
453
|
+
return f"HTTP error {status_code}"
|
|
454
|
+
elif hasattr(sync_response, "transport_response"):
|
|
455
|
+
# Check for transport-level errors
|
|
456
|
+
transport = sync_response.transport_response
|
|
457
|
+
if hasattr(transport, "status_code"):
|
|
458
|
+
try:
|
|
459
|
+
status_code = int(transport.status_code)
|
|
460
|
+
return f"Transport error: HTTP {status_code}"
|
|
461
|
+
except (ValueError, TypeError):
|
|
462
|
+
return "Network connectivity issue or server unreachable"
|
|
463
|
+
|
|
464
|
+
# Fallback to string representation with safety checks
|
|
465
|
+
try:
|
|
466
|
+
error_str = str(sync_response)
|
|
467
|
+
except Exception:
|
|
468
|
+
return "Network connectivity issue or server unreachable"
|
|
469
|
+
|
|
470
|
+
# Clean up object repr strings that contain angle brackets
|
|
471
|
+
if error_str and error_str != "None":
|
|
472
|
+
# Remove object repr patterns like <object at 0x...>
|
|
473
|
+
if "<" in error_str and ">" in error_str and " at 0x" in error_str:
|
|
474
|
+
return "Network connectivity issue or server unreachable"
|
|
475
|
+
# Remove HTML/XML-like content
|
|
476
|
+
elif "<" in error_str and ">" in error_str:
|
|
477
|
+
return "Network connectivity issue or server unreachable"
|
|
478
|
+
elif "unknown error" in error_str.lower():
|
|
479
|
+
return "Network connectivity issue or server unreachable"
|
|
480
|
+
else:
|
|
481
|
+
return error_str
|
|
482
|
+
else:
|
|
483
|
+
return "Network connectivity issue or server unreachable"
|
|
484
|
+
|
|
485
|
+
except (AttributeError, ValueError, TypeError) as e:
|
|
486
|
+
logger.debug(
|
|
487
|
+
"Failed to extract sync error details from %r: %s", sync_response, e
|
|
488
|
+
)
|
|
489
|
+
# If we can't extract error details, provide a generic but helpful message
|
|
490
|
+
return (
|
|
491
|
+
"Unable to determine specific error - likely a network connectivity issue"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _create_mapping_info(
|
|
496
|
+
matrix_event_id, room_id, text, meshnet=None, msgs_to_keep=None
|
|
497
|
+
):
|
|
498
|
+
"""
|
|
499
|
+
Create metadata linking a Matrix event to a Meshtastic message for cross-network mapping.
|
|
500
|
+
|
|
501
|
+
Strips quoted lines from `text` and populates a mapping dict containing identifiers and retention settings. If `msgs_to_keep` is None the value is obtained from _get_msgs_to_keep_config(). Returns None when any of `matrix_event_id`, `room_id`, or `text` is missing or falsy.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
dict or None: Mapping with keys:
|
|
505
|
+
- matrix_event_id: original Matrix event id
|
|
506
|
+
- room_id: Matrix room id
|
|
507
|
+
- text: cleaned text with quoted lines removed
|
|
508
|
+
- meshnet: optional meshnet name (may be None)
|
|
509
|
+
- msgs_to_keep: number of message mappings to retain
|
|
510
|
+
"""
|
|
511
|
+
if not matrix_event_id or not room_id or not text:
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
if msgs_to_keep is None:
|
|
515
|
+
msgs_to_keep = _get_msgs_to_keep_config()
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
"matrix_event_id": matrix_event_id,
|
|
519
|
+
"room_id": room_id,
|
|
520
|
+
"text": strip_quoted_lines(text),
|
|
521
|
+
"meshnet": meshnet,
|
|
522
|
+
"msgs_to_keep": msgs_to_keep,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def get_interaction_settings(config):
|
|
527
|
+
"""
|
|
528
|
+
Determine if message reactions and replies are enabled in the configuration.
|
|
529
|
+
|
|
530
|
+
Checks for both the new `message_interactions` structure and the legacy `relay_reactions` flag for backward compatibility. Returns a dictionary with boolean values for `reactions` and `replies`, defaulting to both disabled if not specified.
|
|
531
|
+
"""
|
|
532
|
+
if config is None:
|
|
533
|
+
return {"reactions": False, "replies": False}
|
|
534
|
+
|
|
535
|
+
meshtastic_config = config.get("meshtastic", {})
|
|
536
|
+
|
|
537
|
+
# Check for new structured configuration first
|
|
538
|
+
if "message_interactions" in meshtastic_config:
|
|
539
|
+
interactions = meshtastic_config["message_interactions"]
|
|
540
|
+
return {
|
|
541
|
+
"reactions": interactions.get("reactions", False),
|
|
542
|
+
"replies": interactions.get("replies", False),
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
# Fall back to legacy relay_reactions setting
|
|
546
|
+
if "relay_reactions" in meshtastic_config:
|
|
547
|
+
enabled = meshtastic_config["relay_reactions"]
|
|
548
|
+
logger.warning(
|
|
549
|
+
"Configuration setting 'relay_reactions' is deprecated. "
|
|
550
|
+
"Please use 'message_interactions: {reactions: bool, replies: bool}' instead. "
|
|
551
|
+
"Legacy mode: enabling reactions only."
|
|
552
|
+
)
|
|
553
|
+
return {
|
|
554
|
+
"reactions": enabled,
|
|
555
|
+
"replies": False,
|
|
556
|
+
} # Only reactions for legacy compatibility
|
|
557
|
+
|
|
558
|
+
# Default to privacy-first (both disabled)
|
|
559
|
+
return {"reactions": False, "replies": False}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def message_storage_enabled(interactions):
|
|
563
|
+
"""
|
|
564
|
+
Determine if message storage is needed based on enabled message interactions.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
True if either reactions or replies are enabled in the interactions dictionary; otherwise, False.
|
|
568
|
+
"""
|
|
569
|
+
return interactions["reactions"] or interactions["replies"]
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _add_truncated_vars(format_vars, prefix, text):
|
|
573
|
+
"""Helper function to add variable length truncation variables to format_vars dict."""
|
|
574
|
+
# Always add truncated variables, even for empty text (to prevent KeyError)
|
|
575
|
+
text = text or "" # Convert None to empty string
|
|
576
|
+
logger.debug(f"Adding truncated vars for prefix='{prefix}', text='{text}'")
|
|
577
|
+
for i in range(
|
|
578
|
+
1, MAX_TRUNCATION_LENGTH + 1
|
|
579
|
+
): # Support up to MAX_TRUNCATION_LENGTH chars, always add all variants
|
|
580
|
+
truncated_value = text[:i]
|
|
581
|
+
format_vars[f"{prefix}{i}"] = truncated_value
|
|
582
|
+
if i <= TRUNCATION_LOG_LIMIT: # Only log first few to avoid spam
|
|
583
|
+
logger.debug(f" {prefix}{i} = '{truncated_value}'")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def validate_prefix_format(format_string, available_vars):
|
|
587
|
+
"""Validate prefix format string against available variables.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
format_string (str): The format string to validate.
|
|
591
|
+
available_vars (dict): Dictionary of available variables with test values.
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
tuple: (is_valid: bool, error_message: str or None)
|
|
595
|
+
"""
|
|
596
|
+
try:
|
|
597
|
+
# Test format with dummy data
|
|
598
|
+
format_string.format(**available_vars)
|
|
599
|
+
return True, None
|
|
600
|
+
except (KeyError, ValueError) as e:
|
|
601
|
+
return False, str(e)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def get_meshtastic_prefix(config, display_name, user_id=None):
|
|
605
|
+
"""
|
|
606
|
+
Generate a Meshtastic message prefix using the configured format, supporting variable-length truncation and user-specific variables.
|
|
607
|
+
|
|
608
|
+
If prefix formatting is enabled in the configuration, returns a formatted prefix string for Meshtastic messages using the user's display name and optional Matrix user ID. Supports custom format strings with placeholders for the display name, truncated display name segments (e.g., `{display5}`), and user ID components. Falls back to a default format if the custom format is invalid or missing. Returns an empty string if prefixing is disabled.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
config (dict): The application configuration dictionary.
|
|
612
|
+
display_name (str): The user's display name (room-specific or global).
|
|
613
|
+
user_id (str, optional): The user's Matrix ID (@user:server.com).
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
str: The formatted prefix string if enabled, empty string otherwise.
|
|
617
|
+
|
|
618
|
+
Examples:
|
|
619
|
+
Basic usage:
|
|
620
|
+
get_meshtastic_prefix(config, "Alice Smith")
|
|
621
|
+
# Returns: "Alice[M]: " (with default format)
|
|
622
|
+
|
|
623
|
+
Custom format:
|
|
624
|
+
config = {"meshtastic": {"prefix_format": "{display8}> "}}
|
|
625
|
+
get_meshtastic_prefix(config, "Alice Smith")
|
|
626
|
+
# Returns: "Alice Sm> "
|
|
627
|
+
"""
|
|
628
|
+
meshtastic_config = config.get("meshtastic", {})
|
|
629
|
+
|
|
630
|
+
# Check if prefixes are enabled
|
|
631
|
+
if not meshtastic_config.get("prefix_enabled", True):
|
|
632
|
+
return ""
|
|
633
|
+
|
|
634
|
+
# Get custom format or use default
|
|
635
|
+
prefix_format = meshtastic_config.get("prefix_format", DEFAULT_MESHTASTIC_PREFIX)
|
|
636
|
+
|
|
637
|
+
# Parse username and server from user_id if available
|
|
638
|
+
username = ""
|
|
639
|
+
server = ""
|
|
640
|
+
if user_id:
|
|
641
|
+
# Extract username and server from @username:server.com format
|
|
642
|
+
if user_id.startswith("@") and ":" in user_id:
|
|
643
|
+
parts = user_id[1:].split(":", 1) # Remove @ and split on first :
|
|
644
|
+
username = parts[0]
|
|
645
|
+
server = parts[1] if len(parts) > 1 else ""
|
|
646
|
+
|
|
647
|
+
# Available variables for formatting with variable length support
|
|
648
|
+
format_vars = {
|
|
649
|
+
"display": display_name or "",
|
|
650
|
+
"user": user_id or "",
|
|
651
|
+
"username": username,
|
|
652
|
+
"server": server,
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# Add variable length display name truncation (display1, display2, display3, etc.)
|
|
656
|
+
_add_truncated_vars(format_vars, "display", display_name)
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
return prefix_format.format(**format_vars)
|
|
660
|
+
except (KeyError, ValueError) as e:
|
|
661
|
+
# Fallback to default format if custom format is invalid
|
|
662
|
+
logger.warning(
|
|
663
|
+
f"Invalid prefix_format '{prefix_format}': {e}. Using default format."
|
|
664
|
+
)
|
|
665
|
+
# The default format only uses 'display5', which is safe to format
|
|
666
|
+
return DEFAULT_MESHTASTIC_PREFIX.format(
|
|
667
|
+
display5=display_name[:DISPLAY_NAME_DEFAULT_LENGTH] if display_name else ""
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def get_matrix_prefix(config, longname, shortname, meshnet_name):
|
|
672
|
+
"""
|
|
673
|
+
Generates a formatted prefix string for Meshtastic messages relayed to Matrix, based on configuration settings and sender/mesh network names.
|
|
674
|
+
|
|
675
|
+
The prefix format supports variable-length truncation for the sender and mesh network names using template variables (e.g., `{long4}` for the first 4 characters of the sender name). Returns an empty string if prefixing is disabled in the configuration.
|
|
676
|
+
|
|
677
|
+
Parameters:
|
|
678
|
+
longname (str): Full Meshtastic sender name.
|
|
679
|
+
shortname (str): Short Meshtastic sender name.
|
|
680
|
+
meshnet_name (str): Name of the mesh network.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
str: The formatted prefix string, or an empty string if prefixing is disabled.
|
|
684
|
+
"""
|
|
685
|
+
matrix_config = config.get(CONFIG_SECTION_MATRIX, {})
|
|
686
|
+
|
|
687
|
+
# Enhanced debug logging for configuration troubleshooting
|
|
688
|
+
logger.debug(
|
|
689
|
+
f"get_matrix_prefix called with longname='{longname}', shortname='{shortname}', meshnet_name='{meshnet_name}'"
|
|
690
|
+
)
|
|
691
|
+
logger.debug(f"Matrix config section: {matrix_config}")
|
|
692
|
+
|
|
693
|
+
# Check if prefixes are enabled for Matrix direction
|
|
694
|
+
if not matrix_config.get("prefix_enabled", True):
|
|
695
|
+
logger.debug("Matrix prefixes are disabled, returning empty string")
|
|
696
|
+
return ""
|
|
697
|
+
|
|
698
|
+
# Get custom format or use default
|
|
699
|
+
matrix_prefix_format = matrix_config.get("prefix_format", DEFAULT_MATRIX_PREFIX)
|
|
700
|
+
logger.debug(
|
|
701
|
+
f"Using matrix prefix format: '{matrix_prefix_format}' (default: '{DEFAULT_MATRIX_PREFIX}')"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Available variables for formatting with variable length support
|
|
705
|
+
format_vars = {
|
|
706
|
+
"long": longname,
|
|
707
|
+
"short": shortname,
|
|
708
|
+
"mesh": meshnet_name,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# Add variable length truncation for longname and mesh name
|
|
712
|
+
_add_truncated_vars(format_vars, "long", longname)
|
|
713
|
+
_add_truncated_vars(format_vars, "mesh", meshnet_name)
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
result = matrix_prefix_format.format(**format_vars)
|
|
717
|
+
logger.debug(
|
|
718
|
+
f"Matrix prefix generated: '{result}' using format '{matrix_prefix_format}' with vars {format_vars}"
|
|
719
|
+
)
|
|
720
|
+
# Additional debug to help identify the issue
|
|
721
|
+
if result == f"[{longname}/{meshnet_name}]: ":
|
|
722
|
+
logger.debug(
|
|
723
|
+
"Generated prefix matches default format - check if custom configuration is being loaded correctly"
|
|
724
|
+
)
|
|
725
|
+
return result
|
|
726
|
+
except (KeyError, ValueError) as e:
|
|
727
|
+
# Fallback to default format if custom format is invalid
|
|
728
|
+
logger.warning(
|
|
729
|
+
f"Invalid matrix prefix_format '{matrix_prefix_format}': {e}. Using default format."
|
|
730
|
+
)
|
|
731
|
+
# The default format only uses 'long' and 'mesh', which are safe
|
|
732
|
+
return DEFAULT_MATRIX_PREFIX.format(
|
|
733
|
+
long=longname or "", mesh=meshnet_name or ""
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# Global config variable that will be set from config.py
|
|
738
|
+
config = None
|
|
739
|
+
|
|
740
|
+
# These will be set in connect_matrix()
|
|
741
|
+
matrix_homeserver = None
|
|
742
|
+
matrix_rooms = None
|
|
743
|
+
matrix_access_token = None
|
|
744
|
+
bot_user_id = None
|
|
745
|
+
bot_user_name = None # Detected upon logon
|
|
746
|
+
bot_start_time = int(
|
|
747
|
+
time.time() * MILLISECONDS_PER_SECOND
|
|
748
|
+
) # Timestamp when the bot starts, used to filter out old messages
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
matrix_client = None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def bot_command(command, event):
|
|
755
|
+
"""
|
|
756
|
+
Checks if the given command is directed at the bot,
|
|
757
|
+
accounting for variations in different Matrix clients.
|
|
758
|
+
"""
|
|
759
|
+
full_message = event.body.strip()
|
|
760
|
+
content = event.source.get("content", {})
|
|
761
|
+
formatted_body = content.get("formatted_body", "")
|
|
762
|
+
|
|
763
|
+
# Remove HTML tags and extract the text content
|
|
764
|
+
text_content = re.sub(r"<[^>]+>", "", formatted_body).strip()
|
|
765
|
+
|
|
766
|
+
# Check for simple !command format first
|
|
767
|
+
if full_message.startswith(f"!{command}") or text_content.startswith(f"!{command}"):
|
|
768
|
+
return True
|
|
769
|
+
|
|
770
|
+
# Check if the message starts with bot_user_id or bot_user_name
|
|
771
|
+
if full_message.startswith(bot_user_id) or text_content.startswith(bot_user_id):
|
|
772
|
+
# Construct a regex pattern to match variations of bot mention and command
|
|
773
|
+
pattern = rf"^(?:{re.escape(bot_user_id)}|{re.escape(bot_user_name)}|[#@].+?)[,:;]?\s*!{command}"
|
|
774
|
+
return bool(re.match(pattern, full_message)) or bool(
|
|
775
|
+
re.match(pattern, text_content)
|
|
776
|
+
)
|
|
777
|
+
elif full_message.startswith(bot_user_name) or text_content.startswith(
|
|
778
|
+
bot_user_name
|
|
779
|
+
):
|
|
780
|
+
# Construct a regex pattern to match variations of bot mention and command
|
|
781
|
+
pattern = rf"^(?:{re.escape(bot_user_id)}|{re.escape(bot_user_name)}|[#@].+?)[,:;]?\s*!{command}"
|
|
782
|
+
return bool(re.match(pattern, full_message)) or bool(
|
|
783
|
+
re.match(pattern, text_content)
|
|
784
|
+
)
|
|
785
|
+
else:
|
|
786
|
+
return False
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
async def connect_matrix(passed_config=None):
|
|
790
|
+
"""
|
|
791
|
+
Initialize and return a configured matrix-nio AsyncClient connected to the configured Matrix homeserver.
|
|
792
|
+
|
|
793
|
+
Creates or restores client credentials (prefers credentials.json, falls back to automatic login using username/password from config, then to direct tokens in config), optionally enables End-to-End Encryption when configured and dependencies are available, performs an initial full-state sync to populate rooms, resolves room aliases found in configuration, and sets module-level connection state used by other functions.
|
|
794
|
+
|
|
795
|
+
Parameters:
|
|
796
|
+
passed_config (dict | None): Optional configuration to use for this connection attempt; when provided it overrides the module-level config for this call.
|
|
797
|
+
|
|
798
|
+
Returns:
|
|
799
|
+
AsyncClient | None: A ready-to-use matrix-nio AsyncClient on success, or `None` if connection or credentials are unavailable.
|
|
800
|
+
|
|
801
|
+
Raises:
|
|
802
|
+
ValueError: If the required top-level "matrix_rooms" configuration is missing.
|
|
803
|
+
ConnectionError: If the initial Matrix sync fails or times out.
|
|
804
|
+
"""
|
|
805
|
+
global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
|
|
806
|
+
|
|
807
|
+
# Update the global config if a config is passed
|
|
808
|
+
if passed_config is not None:
|
|
809
|
+
config = passed_config
|
|
810
|
+
|
|
811
|
+
# Check if config is available
|
|
812
|
+
if config is None:
|
|
813
|
+
logger.error("No configuration available. Cannot connect to Matrix.")
|
|
814
|
+
return None
|
|
815
|
+
|
|
816
|
+
# Check if client already exists
|
|
817
|
+
if matrix_client:
|
|
818
|
+
return matrix_client
|
|
819
|
+
|
|
820
|
+
# Check for credentials.json first
|
|
821
|
+
credentials = None
|
|
822
|
+
credentials_path = None
|
|
823
|
+
|
|
824
|
+
# Try to find credentials.json in the config directory
|
|
825
|
+
try:
|
|
826
|
+
from mmrelay.config import get_base_dir
|
|
827
|
+
|
|
828
|
+
config_dir = get_base_dir()
|
|
829
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
830
|
+
|
|
831
|
+
if os.path.exists(credentials_path):
|
|
832
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
833
|
+
credentials = json.load(f)
|
|
834
|
+
except Exception as e:
|
|
835
|
+
logger.warning(f"Error loading credentials: {e}")
|
|
836
|
+
|
|
837
|
+
# If credentials.json exists, use it
|
|
838
|
+
if credentials:
|
|
839
|
+
matrix_homeserver = credentials["homeserver"]
|
|
840
|
+
matrix_access_token = credentials["access_token"]
|
|
841
|
+
bot_user_id = credentials["user_id"]
|
|
842
|
+
e2ee_device_id = credentials.get("device_id")
|
|
843
|
+
|
|
844
|
+
# Log consolidated credentials info
|
|
845
|
+
logger.debug(f"Using Matrix credentials (device: {e2ee_device_id})")
|
|
846
|
+
|
|
847
|
+
# If device_id is missing, warn but proceed; we'll learn and persist it after restore_login().
|
|
848
|
+
if not isinstance(e2ee_device_id, str) or not e2ee_device_id.strip():
|
|
849
|
+
logger.warning(
|
|
850
|
+
"credentials.json has no valid device_id; proceeding to restore session and discover device_id."
|
|
851
|
+
)
|
|
852
|
+
e2ee_device_id = None
|
|
853
|
+
|
|
854
|
+
# If config also has Matrix login info, let the user know we're ignoring it
|
|
855
|
+
if config and "matrix" in config and "access_token" in config["matrix"]:
|
|
856
|
+
logger.info(
|
|
857
|
+
"NOTE: Ignoring Matrix login details in config.yaml in favor of credentials.json"
|
|
858
|
+
)
|
|
859
|
+
# Check if we can automatically create credentials from config.yaml
|
|
860
|
+
elif (
|
|
861
|
+
config and "matrix" in config and _can_auto_create_credentials(config["matrix"])
|
|
862
|
+
):
|
|
863
|
+
logger.info(
|
|
864
|
+
"No credentials.json found, but config.yaml has password field. Attempting automatic login..."
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
matrix_section = config["matrix"]
|
|
868
|
+
homeserver = matrix_section["homeserver"]
|
|
869
|
+
username = matrix_section.get("bot_user_id") or matrix_section.get("user_id")
|
|
870
|
+
# Normalize the username to ensure it's a full MXID
|
|
871
|
+
if username:
|
|
872
|
+
username = _normalize_bot_user_id(homeserver, username)
|
|
873
|
+
password = matrix_section["password"]
|
|
874
|
+
|
|
875
|
+
# Attempt automatic login
|
|
876
|
+
try:
|
|
877
|
+
success = await login_matrix_bot(
|
|
878
|
+
homeserver=homeserver,
|
|
879
|
+
username=username,
|
|
880
|
+
password=password,
|
|
881
|
+
logout_others=False,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
if success:
|
|
885
|
+
logger.info(
|
|
886
|
+
"Automatic login successful! Credentials saved to credentials.json"
|
|
887
|
+
)
|
|
888
|
+
# Load the newly created credentials and set up for credentials flow
|
|
889
|
+
credentials = load_credentials()
|
|
890
|
+
if not credentials:
|
|
891
|
+
logger.error("Failed to load newly created credentials")
|
|
892
|
+
return None
|
|
893
|
+
|
|
894
|
+
# Set up variables for credentials-based connection
|
|
895
|
+
matrix_homeserver = credentials["homeserver"]
|
|
896
|
+
matrix_access_token = credentials["access_token"]
|
|
897
|
+
bot_user_id = credentials["user_id"]
|
|
898
|
+
e2ee_device_id = credentials.get("device_id")
|
|
899
|
+
else:
|
|
900
|
+
logger.error(
|
|
901
|
+
"Automatic login failed. Please check your credentials or use 'mmrelay auth login'"
|
|
902
|
+
)
|
|
903
|
+
return None
|
|
904
|
+
except Exception as e:
|
|
905
|
+
logger.exception(f"Error during automatic login: {type(e).__name__}")
|
|
906
|
+
logger.error("Please use 'mmrelay auth login' for interactive setup")
|
|
907
|
+
return None
|
|
908
|
+
else:
|
|
909
|
+
# Check if config is available
|
|
910
|
+
if config is None:
|
|
911
|
+
logger.error("No configuration available. Cannot connect to Matrix.")
|
|
912
|
+
return None
|
|
913
|
+
|
|
914
|
+
# Check if matrix section exists in config
|
|
915
|
+
if "matrix" not in config:
|
|
916
|
+
logger.error(
|
|
917
|
+
"No Matrix authentication available. Neither credentials.json nor matrix section in config found."
|
|
918
|
+
)
|
|
919
|
+
logger.error(msg_require_auth_login())
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
matrix_section = config["matrix"]
|
|
923
|
+
|
|
924
|
+
# Check for required fields in matrix section
|
|
925
|
+
required_fields = ["homeserver", "access_token", "bot_user_id"]
|
|
926
|
+
missing_fields = [
|
|
927
|
+
field for field in required_fields if field not in matrix_section
|
|
928
|
+
]
|
|
929
|
+
|
|
930
|
+
if missing_fields:
|
|
931
|
+
logger.error(f"Matrix section is missing required fields: {missing_fields}")
|
|
932
|
+
logger.error(msg_require_auth_login())
|
|
933
|
+
return None
|
|
934
|
+
|
|
935
|
+
# Extract Matrix configuration from config
|
|
936
|
+
matrix_homeserver = matrix_section["homeserver"]
|
|
937
|
+
matrix_access_token = matrix_section["access_token"]
|
|
938
|
+
bot_user_id = _normalize_bot_user_id(
|
|
939
|
+
matrix_homeserver, matrix_section["bot_user_id"]
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
# Manual method does not support device_id - use auth system for E2EE
|
|
943
|
+
e2ee_device_id = None
|
|
944
|
+
|
|
945
|
+
# Get matrix rooms from config
|
|
946
|
+
if "matrix_rooms" not in config:
|
|
947
|
+
logger.error("Configuration is missing 'matrix_rooms' section")
|
|
948
|
+
logger.error(
|
|
949
|
+
"Please ensure your config.yaml includes matrix_rooms configuration"
|
|
950
|
+
)
|
|
951
|
+
raise ValueError("Missing required 'matrix_rooms' configuration")
|
|
952
|
+
matrix_rooms = config["matrix_rooms"]
|
|
953
|
+
|
|
954
|
+
# Create SSL context using certifi's certificates with system default fallback
|
|
955
|
+
ssl_context = _create_ssl_context()
|
|
956
|
+
if ssl_context is None:
|
|
957
|
+
logger.warning(
|
|
958
|
+
"Failed to create certifi/system SSL context; proceeding with AsyncClient defaults"
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
# Check if E2EE is enabled
|
|
962
|
+
e2ee_enabled = False
|
|
963
|
+
e2ee_store_path = None
|
|
964
|
+
# Only initialize e2ee_device_id if not already set from credentials
|
|
965
|
+
if "e2ee_device_id" not in locals():
|
|
966
|
+
e2ee_device_id = None
|
|
967
|
+
|
|
968
|
+
try:
|
|
969
|
+
from mmrelay.config import is_e2ee_enabled
|
|
970
|
+
|
|
971
|
+
# Check if E2EE is enabled using the helper function
|
|
972
|
+
e2ee_enabled = is_e2ee_enabled(config)
|
|
973
|
+
|
|
974
|
+
# Debug logging for E2EE detection
|
|
975
|
+
logger.debug(
|
|
976
|
+
f"E2EE detection: matrix config section present: {'matrix' in config}"
|
|
977
|
+
)
|
|
978
|
+
logger.debug(f"E2EE detection: e2ee enabled = {e2ee_enabled}")
|
|
979
|
+
|
|
980
|
+
if e2ee_enabled:
|
|
981
|
+
# Check if running on Windows
|
|
982
|
+
if sys.platform == WINDOWS_PLATFORM:
|
|
983
|
+
logger.error(
|
|
984
|
+
"E2EE is not supported on Windows due to library limitations."
|
|
985
|
+
)
|
|
986
|
+
logger.error(
|
|
987
|
+
"The python-olm library requires native C libraries that are difficult to install on Windows."
|
|
988
|
+
)
|
|
989
|
+
logger.error(
|
|
990
|
+
"Please disable E2EE in your configuration or use a Linux/macOS system for E2EE support."
|
|
991
|
+
)
|
|
992
|
+
e2ee_enabled = False
|
|
993
|
+
else:
|
|
994
|
+
# Check if python-olm is installed
|
|
995
|
+
try:
|
|
996
|
+
importlib.import_module("olm")
|
|
997
|
+
|
|
998
|
+
# Also check for other required E2EE dependencies unless tests skip them
|
|
999
|
+
if os.getenv("MMRELAY_TESTING") != "1":
|
|
1000
|
+
try:
|
|
1001
|
+
nio_crypto = importlib.import_module("nio.crypto")
|
|
1002
|
+
if not hasattr(nio_crypto, "OlmDevice"):
|
|
1003
|
+
raise ImportError("nio.crypto.OlmDevice is unavailable")
|
|
1004
|
+
|
|
1005
|
+
nio_store = importlib.import_module("nio.store")
|
|
1006
|
+
if not hasattr(nio_store, "SqliteStore"):
|
|
1007
|
+
raise ImportError(
|
|
1008
|
+
"nio.store.SqliteStore is unavailable"
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
logger.debug("All E2EE dependencies are available")
|
|
1012
|
+
except ImportError:
|
|
1013
|
+
logger.exception("Missing E2EE dependency")
|
|
1014
|
+
logger.error(
|
|
1015
|
+
"Please reinstall with: pipx install 'mmrelay[e2e]'"
|
|
1016
|
+
)
|
|
1017
|
+
logger.warning("E2EE will be disabled for this session.")
|
|
1018
|
+
e2ee_enabled = False
|
|
1019
|
+
else:
|
|
1020
|
+
# Dependencies are available, keep the config-determined value
|
|
1021
|
+
if e2ee_enabled:
|
|
1022
|
+
logger.info("End-to-End Encryption (E2EE) is enabled")
|
|
1023
|
+
else:
|
|
1024
|
+
logger.debug(
|
|
1025
|
+
"E2EE dependencies available but E2EE is disabled in configuration"
|
|
1026
|
+
)
|
|
1027
|
+
else:
|
|
1028
|
+
logger.debug(
|
|
1029
|
+
"Skipping additional E2EE dependency imports in test mode"
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
if e2ee_enabled:
|
|
1033
|
+
# Ensure nio still receives a store path even when dependency
|
|
1034
|
+
# checks are skipped (e.g. production runs without MMRELAY_TESTING);
|
|
1035
|
+
# without this the client will not load encryption state.
|
|
1036
|
+
# Get store path from config or use default
|
|
1037
|
+
if (
|
|
1038
|
+
"encryption" in config["matrix"]
|
|
1039
|
+
and "store_path" in config["matrix"]["encryption"]
|
|
1040
|
+
):
|
|
1041
|
+
e2ee_store_path = os.path.expanduser(
|
|
1042
|
+
config["matrix"]["encryption"]["store_path"]
|
|
1043
|
+
)
|
|
1044
|
+
elif (
|
|
1045
|
+
"e2ee" in config["matrix"]
|
|
1046
|
+
and "store_path" in config["matrix"]["e2ee"]
|
|
1047
|
+
):
|
|
1048
|
+
e2ee_store_path = os.path.expanduser(
|
|
1049
|
+
config["matrix"]["e2ee"]["store_path"]
|
|
1050
|
+
)
|
|
1051
|
+
else:
|
|
1052
|
+
e2ee_store_path = get_e2ee_store_dir()
|
|
1053
|
+
|
|
1054
|
+
# Create store directory if it doesn't exist
|
|
1055
|
+
os.makedirs(e2ee_store_path, exist_ok=True)
|
|
1056
|
+
|
|
1057
|
+
# Check if store directory contains database files
|
|
1058
|
+
store_files = (
|
|
1059
|
+
os.listdir(e2ee_store_path)
|
|
1060
|
+
if os.path.exists(e2ee_store_path)
|
|
1061
|
+
else []
|
|
1062
|
+
)
|
|
1063
|
+
db_files = [f for f in store_files if f.endswith(".db")]
|
|
1064
|
+
if db_files:
|
|
1065
|
+
logger.debug(
|
|
1066
|
+
f"Found existing E2EE store files: {', '.join(db_files)}"
|
|
1067
|
+
)
|
|
1068
|
+
else:
|
|
1069
|
+
logger.warning(
|
|
1070
|
+
"No existing E2EE store files found. Encryption may not work correctly."
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
logger.debug(f"Using E2EE store path: {e2ee_store_path}")
|
|
1074
|
+
|
|
1075
|
+
# If device_id is not present in credentials, we can attempt to learn it later.
|
|
1076
|
+
if not e2ee_device_id:
|
|
1077
|
+
logger.debug(
|
|
1078
|
+
"No device_id in credentials; will retrieve from store/whoami later if available"
|
|
1079
|
+
)
|
|
1080
|
+
except ImportError:
|
|
1081
|
+
logger.warning(
|
|
1082
|
+
"E2EE is enabled in config but python-olm is not installed."
|
|
1083
|
+
)
|
|
1084
|
+
logger.warning("Install 'mmrelay[e2e]' to use E2EE features.")
|
|
1085
|
+
e2ee_enabled = False
|
|
1086
|
+
except (KeyError, TypeError):
|
|
1087
|
+
# E2EE not configured
|
|
1088
|
+
pass
|
|
1089
|
+
|
|
1090
|
+
# Initialize the Matrix client with custom SSL context
|
|
1091
|
+
# Use the same AsyncClientConfig pattern as working E2EE examples
|
|
1092
|
+
client_config = AsyncClientConfig(
|
|
1093
|
+
max_limit_exceeded=0,
|
|
1094
|
+
max_timeouts=0,
|
|
1095
|
+
store_sync_tokens=True,
|
|
1096
|
+
encryption_enabled=e2ee_enabled,
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
# Log the device ID being used
|
|
1100
|
+
if e2ee_device_id:
|
|
1101
|
+
logger.debug(f"Device ID from credentials: {e2ee_device_id}")
|
|
1102
|
+
|
|
1103
|
+
matrix_client = AsyncClient(
|
|
1104
|
+
homeserver=matrix_homeserver,
|
|
1105
|
+
user=bot_user_id,
|
|
1106
|
+
device_id=e2ee_device_id, # Will be None if not specified in config or credentials
|
|
1107
|
+
store_path=e2ee_store_path if e2ee_enabled else None,
|
|
1108
|
+
config=client_config,
|
|
1109
|
+
ssl=ssl_context,
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
# Set the access_token and user_id using restore_login for better session management
|
|
1113
|
+
if credentials:
|
|
1114
|
+
# Use restore_login method for proper session restoration.
|
|
1115
|
+
# nio will handle loading the store automatically if store_path was provided
|
|
1116
|
+
# to the client constructor.
|
|
1117
|
+
matrix_client.restore_login(
|
|
1118
|
+
user_id=bot_user_id,
|
|
1119
|
+
device_id=e2ee_device_id,
|
|
1120
|
+
access_token=matrix_access_token,
|
|
1121
|
+
)
|
|
1122
|
+
logger.info(
|
|
1123
|
+
f"Restored login session for {bot_user_id} with device {e2ee_device_id}"
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
# If the device_id was not known up-front, capture what nio has after restore.
|
|
1127
|
+
if not e2ee_device_id and getattr(matrix_client, "device_id", None):
|
|
1128
|
+
e2ee_device_id = matrix_client.device_id
|
|
1129
|
+
logger.debug(f"Device ID established after restore_login: {e2ee_device_id}")
|
|
1130
|
+
try:
|
|
1131
|
+
if credentials is not None:
|
|
1132
|
+
credentials["device_id"] = e2ee_device_id
|
|
1133
|
+
save_credentials(credentials)
|
|
1134
|
+
logger.info("Updated credentials.json with discovered device_id")
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
logger.debug(f"Failed to persist discovered device_id: {e}")
|
|
1137
|
+
else:
|
|
1138
|
+
# Fallback to direct assignment for legacy token-based auth
|
|
1139
|
+
matrix_client.access_token = matrix_access_token
|
|
1140
|
+
matrix_client.user_id = bot_user_id
|
|
1141
|
+
|
|
1142
|
+
# If E2EE is enabled, upload keys if necessary.
|
|
1143
|
+
# nio will have loaded the store automatically if store_path was provided.
|
|
1144
|
+
if e2ee_enabled:
|
|
1145
|
+
try:
|
|
1146
|
+
if matrix_client.should_upload_keys:
|
|
1147
|
+
logger.info("Uploading encryption keys...")
|
|
1148
|
+
await matrix_client.keys_upload()
|
|
1149
|
+
logger.info("Encryption keys uploaded successfully")
|
|
1150
|
+
else:
|
|
1151
|
+
logger.debug("No key upload needed - keys already present")
|
|
1152
|
+
except Exception:
|
|
1153
|
+
logger.exception("Failed to upload E2EE keys")
|
|
1154
|
+
# E2EE might still work, so we don't disable it here
|
|
1155
|
+
logger.error("Consider regenerating credentials with: mmrelay auth login")
|
|
1156
|
+
|
|
1157
|
+
# Perform initial sync to populate rooms (needed for message delivery)
|
|
1158
|
+
logger.debug("Performing initial sync to initialize rooms...")
|
|
1159
|
+
try:
|
|
1160
|
+
# A full_state=True sync is required to get room encryption state
|
|
1161
|
+
sync_response = await asyncio.wait_for(
|
|
1162
|
+
matrix_client.sync(timeout=MATRIX_EARLY_SYNC_TIMEOUT, full_state=True),
|
|
1163
|
+
timeout=MATRIX_SYNC_OPERATION_TIMEOUT,
|
|
1164
|
+
)
|
|
1165
|
+
# Check if sync failed by looking for error class name
|
|
1166
|
+
if (
|
|
1167
|
+
hasattr(sync_response, "__class__")
|
|
1168
|
+
and "Error" in sync_response.__class__.__name__
|
|
1169
|
+
):
|
|
1170
|
+
# Provide more detailed error information
|
|
1171
|
+
error_type = sync_response.__class__.__name__
|
|
1172
|
+
error_details = _get_detailed_sync_error_message(sync_response)
|
|
1173
|
+
logger.error(f"Initial sync failed: {error_type}")
|
|
1174
|
+
logger.error(f"Error details: {error_details}")
|
|
1175
|
+
|
|
1176
|
+
# Provide user-friendly troubleshooting guidance
|
|
1177
|
+
if "SyncError" in error_type:
|
|
1178
|
+
logger.error(
|
|
1179
|
+
"This usually indicates a network connectivity issue or server problem."
|
|
1180
|
+
)
|
|
1181
|
+
logger.error("Troubleshooting steps:")
|
|
1182
|
+
logger.error("1. Check your internet connection")
|
|
1183
|
+
logger.error(
|
|
1184
|
+
f"2. Verify the homeserver URL is correct: {matrix_homeserver}"
|
|
1185
|
+
)
|
|
1186
|
+
logger.error("3. Ensure the Matrix server is online and accessible")
|
|
1187
|
+
logger.error("4. Check if your credentials are still valid")
|
|
1188
|
+
|
|
1189
|
+
try:
|
|
1190
|
+
await matrix_client.close()
|
|
1191
|
+
except Exception:
|
|
1192
|
+
logger.debug("Ignoring error while closing client after sync failure")
|
|
1193
|
+
finally:
|
|
1194
|
+
matrix_client = None
|
|
1195
|
+
raise ConnectionError(f"Matrix sync failed: {error_type} - {error_details}")
|
|
1196
|
+
else:
|
|
1197
|
+
logger.info(
|
|
1198
|
+
f"Initial sync completed. Found {len(matrix_client.rooms)} rooms."
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
# List all rooms with unified E2EE status display
|
|
1202
|
+
from mmrelay.config import config_path
|
|
1203
|
+
from mmrelay.e2ee_utils import (
|
|
1204
|
+
get_e2ee_status,
|
|
1205
|
+
get_room_encryption_warnings,
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
# Get comprehensive E2EE status
|
|
1209
|
+
e2ee_status = get_e2ee_status(config, config_path)
|
|
1210
|
+
|
|
1211
|
+
# Resolve room aliases in config (supports list[str|dict] and dict[str->str|dict])
|
|
1212
|
+
async def _resolve_alias(alias: str) -> Optional[str]:
|
|
1213
|
+
"""
|
|
1214
|
+
Resolve a Matrix room alias to its canonical room ID.
|
|
1215
|
+
|
|
1216
|
+
Attempts to resolve the provided room alias using the module's Matrix client. Returns the resolved room ID string on success; returns None if the alias cannot be resolved or if an error/timeout occurs (errors from the underlying nio client are caught and handled internally).
|
|
1217
|
+
"""
|
|
1218
|
+
logger.debug(f"Resolving alias from config: {alias}")
|
|
1219
|
+
try:
|
|
1220
|
+
response = await matrix_client.room_resolve_alias(alias)
|
|
1221
|
+
if hasattr(response, "room_id") and response.room_id:
|
|
1222
|
+
logger.debug(f"Resolved alias {alias} to {response.room_id}")
|
|
1223
|
+
return response.room_id
|
|
1224
|
+
logger.warning(
|
|
1225
|
+
f"Could not resolve alias {alias}: {getattr(response, 'message', response)}"
|
|
1226
|
+
)
|
|
1227
|
+
except (
|
|
1228
|
+
NioErrorResponse,
|
|
1229
|
+
NioLocalProtocolError,
|
|
1230
|
+
NioRemoteProtocolError,
|
|
1231
|
+
NioLocalTransportError,
|
|
1232
|
+
NioRemoteTransportError,
|
|
1233
|
+
asyncio.TimeoutError,
|
|
1234
|
+
):
|
|
1235
|
+
logger.exception(f"Error resolving alias {alias}")
|
|
1236
|
+
return None
|
|
1237
|
+
|
|
1238
|
+
await _resolve_aliases_in_mapping(matrix_rooms, _resolve_alias)
|
|
1239
|
+
|
|
1240
|
+
# Display rooms with channel mappings
|
|
1241
|
+
_display_room_channel_mappings(matrix_client.rooms, config, e2ee_status)
|
|
1242
|
+
|
|
1243
|
+
# Show warnings for encrypted rooms when E2EE is not ready
|
|
1244
|
+
warnings = get_room_encryption_warnings(matrix_client.rooms, e2ee_status)
|
|
1245
|
+
for warning in warnings:
|
|
1246
|
+
logger.warning(warning)
|
|
1247
|
+
|
|
1248
|
+
# Debug information
|
|
1249
|
+
encrypted_count = sum(
|
|
1250
|
+
1
|
|
1251
|
+
for room in matrix_client.rooms.values()
|
|
1252
|
+
if getattr(room, "encrypted", False)
|
|
1253
|
+
)
|
|
1254
|
+
logger.debug(
|
|
1255
|
+
f"Found {encrypted_count} encrypted rooms out of {len(matrix_client.rooms)} total rooms"
|
|
1256
|
+
)
|
|
1257
|
+
logger.debug(f"E2EE status: {e2ee_status['overall_status']}")
|
|
1258
|
+
|
|
1259
|
+
# Additional debugging for E2EE enabled case
|
|
1260
|
+
if e2ee_enabled and encrypted_count == 0 and len(matrix_client.rooms) > 0:
|
|
1261
|
+
logger.debug("No encrypted rooms detected - all rooms are plaintext")
|
|
1262
|
+
except asyncio.TimeoutError:
|
|
1263
|
+
logger.exception(
|
|
1264
|
+
f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
|
|
1265
|
+
)
|
|
1266
|
+
logger.error(
|
|
1267
|
+
"This indicates a network connectivity issue or slow Matrix server."
|
|
1268
|
+
)
|
|
1269
|
+
logger.error("Troubleshooting steps:")
|
|
1270
|
+
logger.error("1. Check your internet connection")
|
|
1271
|
+
logger.error(f"2. Verify the homeserver is accessible: {matrix_homeserver}")
|
|
1272
|
+
logger.error(
|
|
1273
|
+
"3. Try again in a few minutes - the server may be temporarily overloaded"
|
|
1274
|
+
)
|
|
1275
|
+
logger.error(
|
|
1276
|
+
"4. Consider using a different Matrix homeserver if the problem persists"
|
|
1277
|
+
)
|
|
1278
|
+
try:
|
|
1279
|
+
await matrix_client.close()
|
|
1280
|
+
except Exception:
|
|
1281
|
+
logger.debug("Ignoring error while closing client after sync timeout")
|
|
1282
|
+
finally:
|
|
1283
|
+
matrix_client = None
|
|
1284
|
+
raise ConnectionError(
|
|
1285
|
+
f"Matrix sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds - check network connectivity and server status"
|
|
1286
|
+
) from None
|
|
1287
|
+
|
|
1288
|
+
# Add a delay to allow for key sharing to complete
|
|
1289
|
+
# This addresses a race condition where the client attempts to send encrypted messages
|
|
1290
|
+
# before it has received and processed room key sharing messages from other devices.
|
|
1291
|
+
# The initial sync() call triggers key sharing requests, but the actual key exchange
|
|
1292
|
+
# happens asynchronously. Without this delay, outgoing messages may be sent unencrypted
|
|
1293
|
+
# even to encrypted rooms. While not ideal, this timing-based approach is necessary
|
|
1294
|
+
# because matrix-nio doesn't provide event-driven alternatives to detect when key
|
|
1295
|
+
# sharing is complete.
|
|
1296
|
+
if e2ee_enabled:
|
|
1297
|
+
logger.debug(
|
|
1298
|
+
f"Waiting for {E2EE_KEY_SHARING_DELAY_SECONDS} seconds to allow for key sharing..."
|
|
1299
|
+
)
|
|
1300
|
+
await asyncio.sleep(E2EE_KEY_SHARING_DELAY_SECONDS)
|
|
1301
|
+
|
|
1302
|
+
# Fetch the bot's display name
|
|
1303
|
+
response = await matrix_client.get_displayname(bot_user_id)
|
|
1304
|
+
if hasattr(response, "displayname"):
|
|
1305
|
+
bot_user_name = response.displayname
|
|
1306
|
+
else:
|
|
1307
|
+
bot_user_name = bot_user_id # Fallback if display name is not set
|
|
1308
|
+
|
|
1309
|
+
# Set E2EE status on the client for other functions to access
|
|
1310
|
+
matrix_client.e2ee_enabled = e2ee_enabled
|
|
1311
|
+
|
|
1312
|
+
return matrix_client
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
async def login_matrix_bot(
|
|
1316
|
+
homeserver=None, username=None, password=None, logout_others=False
|
|
1317
|
+
):
|
|
1318
|
+
"""
|
|
1319
|
+
Perform an interactive Matrix login for the bot and persist credentials for later use.
|
|
1320
|
+
|
|
1321
|
+
This coroutine attempts server discovery for the provided homeserver, logs in as the given username, optionally initializes an encrypted client store (if E2EE is enabled in configuration), and saves resulting credentials (homeserver, user_id, access_token, device_id) to credentials.json so the relay can restore the session non-interactively. If an existing credentials.json contains a matching user_id, the device_id will be reused when available.
|
|
1322
|
+
|
|
1323
|
+
Parameters:
|
|
1324
|
+
homeserver (str | None): Homeserver URL to use. If None, the user is prompted.
|
|
1325
|
+
username (str | None): Matrix username (without or with leading "@"). If None, the user is prompted.
|
|
1326
|
+
password (str | None): Password for the account. If None, the user is prompted securely.
|
|
1327
|
+
logout_others (bool | None): If True, attempts to log out other sessions after login. If None, the user is prompted. (Note: full "logout others" behavior may be limited.)
|
|
1328
|
+
|
|
1329
|
+
Returns:
|
|
1330
|
+
bool: True on successful login and credentials persisted; False on failure. The function handles errors internally and returns False rather than raising.
|
|
1331
|
+
"""
|
|
1332
|
+
try:
|
|
1333
|
+
# Optionally enable verbose nio/aiohttp debug logging
|
|
1334
|
+
if os.getenv("MMRELAY_DEBUG_NIO") == "1":
|
|
1335
|
+
logging.getLogger("nio").setLevel(logging.DEBUG)
|
|
1336
|
+
logging.getLogger("nio.client").setLevel(logging.DEBUG)
|
|
1337
|
+
logging.getLogger("nio.http_client").setLevel(logging.DEBUG)
|
|
1338
|
+
logging.getLogger("nio.responses").setLevel(logging.DEBUG)
|
|
1339
|
+
logging.getLogger("aiohttp").setLevel(logging.DEBUG)
|
|
1340
|
+
|
|
1341
|
+
# Get homeserver URL
|
|
1342
|
+
if not homeserver:
|
|
1343
|
+
homeserver = input(
|
|
1344
|
+
"Enter Matrix homeserver URL (e.g., https://matrix.org): "
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
# Ensure homeserver URL has the correct format
|
|
1348
|
+
if not (homeserver.startswith("https://") or homeserver.startswith("http://")):
|
|
1349
|
+
homeserver = "https://" + homeserver
|
|
1350
|
+
|
|
1351
|
+
# Step 1: Perform server discovery to get the actual homeserver URL
|
|
1352
|
+
logger.info(f"Performing server discovery for {homeserver}...")
|
|
1353
|
+
|
|
1354
|
+
# Create SSL context using certifi's certificates
|
|
1355
|
+
ssl_context = _create_ssl_context()
|
|
1356
|
+
if ssl_context is None:
|
|
1357
|
+
logger.warning(
|
|
1358
|
+
"Failed to create SSL context for server discovery; falling back to default system SSL"
|
|
1359
|
+
)
|
|
1360
|
+
else:
|
|
1361
|
+
logger.debug(f"SSL context created successfully: {ssl_context}")
|
|
1362
|
+
logger.debug(f"SSL context protocol: {ssl_context.protocol}")
|
|
1363
|
+
logger.debug(f"SSL context verify_mode: {ssl_context.verify_mode}")
|
|
1364
|
+
|
|
1365
|
+
# Create a temporary client for discovery
|
|
1366
|
+
temp_client = AsyncClient(homeserver, "", ssl=ssl_context)
|
|
1367
|
+
try:
|
|
1368
|
+
discovery_response = await asyncio.wait_for(
|
|
1369
|
+
temp_client.discovery_info(), timeout=MATRIX_LOGIN_TIMEOUT
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
try:
|
|
1373
|
+
if isinstance(discovery_response, DiscoveryInfoResponse):
|
|
1374
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1375
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1376
|
+
homeserver = actual_homeserver
|
|
1377
|
+
elif isinstance(discovery_response, DiscoveryInfoError):
|
|
1378
|
+
logger.info(
|
|
1379
|
+
f"Server discovery failed, using original URL: {homeserver}"
|
|
1380
|
+
)
|
|
1381
|
+
# Continue with original homeserver URL
|
|
1382
|
+
else:
|
|
1383
|
+
# Fallback for test environments or unexpected response types
|
|
1384
|
+
if hasattr(discovery_response, "homeserver_url"):
|
|
1385
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1386
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1387
|
+
homeserver = actual_homeserver
|
|
1388
|
+
else:
|
|
1389
|
+
logger.warning(
|
|
1390
|
+
f"Server discovery returned unexpected response type, using original URL: {homeserver}"
|
|
1391
|
+
)
|
|
1392
|
+
except TypeError as e:
|
|
1393
|
+
logger.warning(
|
|
1394
|
+
f"Server discovery error: {e}, using original URL: {homeserver}"
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
except asyncio.TimeoutError:
|
|
1398
|
+
logger.warning(
|
|
1399
|
+
f"Server discovery timed out, using original URL: {homeserver}"
|
|
1400
|
+
)
|
|
1401
|
+
# Continue with original homeserver URL
|
|
1402
|
+
except Exception as e:
|
|
1403
|
+
logger.warning(
|
|
1404
|
+
f"Server discovery error: {e}, using original URL: {homeserver}"
|
|
1405
|
+
)
|
|
1406
|
+
# Continue with original homeserver URL
|
|
1407
|
+
finally:
|
|
1408
|
+
await temp_client.close()
|
|
1409
|
+
|
|
1410
|
+
# Get username
|
|
1411
|
+
if not username:
|
|
1412
|
+
username = input("Enter Matrix username (without @): ")
|
|
1413
|
+
|
|
1414
|
+
# Format username correctly
|
|
1415
|
+
username = _normalize_bot_user_id(homeserver, username)
|
|
1416
|
+
|
|
1417
|
+
logger.info(f"Using username: {username}")
|
|
1418
|
+
|
|
1419
|
+
# Validate username format
|
|
1420
|
+
if not username.startswith("@"):
|
|
1421
|
+
logger.warning(f"Username doesn't start with @: {username}")
|
|
1422
|
+
if username.count(":") != 1:
|
|
1423
|
+
logger.warning(
|
|
1424
|
+
f"Username has unexpected colon count: {username.count(':')}"
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
# Check for special characters in username that might cause issues
|
|
1428
|
+
username_special_chars = set(username) - set(
|
|
1429
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@:.-_"
|
|
1430
|
+
)
|
|
1431
|
+
if username_special_chars:
|
|
1432
|
+
logger.warning(
|
|
1433
|
+
f"Username contains unusual characters: {username_special_chars}"
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
# Get password
|
|
1437
|
+
if not password:
|
|
1438
|
+
password = getpass.getpass("Enter Matrix password: ")
|
|
1439
|
+
|
|
1440
|
+
# Simple password validation without logging sensitive information
|
|
1441
|
+
if password:
|
|
1442
|
+
logger.debug("Password provided for login")
|
|
1443
|
+
else:
|
|
1444
|
+
logger.warning("No password provided")
|
|
1445
|
+
|
|
1446
|
+
# Ask about logging out other sessions
|
|
1447
|
+
if logout_others is None:
|
|
1448
|
+
logout_others_input = input(
|
|
1449
|
+
"Log out other sessions? (Y/n) [Default: Yes]: "
|
|
1450
|
+
).lower()
|
|
1451
|
+
logout_others = (
|
|
1452
|
+
not logout_others_input.startswith("n") if logout_others_input else True
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
# Check for existing credentials to reuse device_id
|
|
1456
|
+
existing_device_id = None
|
|
1457
|
+
try:
|
|
1458
|
+
import json
|
|
1459
|
+
|
|
1460
|
+
config_dir = get_base_dir()
|
|
1461
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
1462
|
+
|
|
1463
|
+
if os.path.exists(credentials_path):
|
|
1464
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
1465
|
+
existing_creds = json.load(f)
|
|
1466
|
+
if (
|
|
1467
|
+
"device_id" in existing_creds
|
|
1468
|
+
and existing_creds["user_id"] == username
|
|
1469
|
+
):
|
|
1470
|
+
existing_device_id = existing_creds["device_id"]
|
|
1471
|
+
logger.info(f"Reusing existing device_id: {existing_device_id}")
|
|
1472
|
+
except Exception as e:
|
|
1473
|
+
logger.debug(f"Could not load existing credentials: {e}")
|
|
1474
|
+
|
|
1475
|
+
# Check if E2EE is enabled in configuration
|
|
1476
|
+
from mmrelay.config import is_e2ee_enabled, load_config
|
|
1477
|
+
|
|
1478
|
+
try:
|
|
1479
|
+
config = load_config()
|
|
1480
|
+
e2ee_enabled = is_e2ee_enabled(config)
|
|
1481
|
+
except Exception as e:
|
|
1482
|
+
logger.debug(f"Could not load config for E2EE check: {e}")
|
|
1483
|
+
e2ee_enabled = False
|
|
1484
|
+
|
|
1485
|
+
logger.debug(f"E2EE enabled in config: {e2ee_enabled}")
|
|
1486
|
+
|
|
1487
|
+
# Get the E2EE store path only if E2EE is enabled
|
|
1488
|
+
store_path = None
|
|
1489
|
+
if e2ee_enabled:
|
|
1490
|
+
store_path = get_e2ee_store_dir()
|
|
1491
|
+
os.makedirs(store_path, exist_ok=True)
|
|
1492
|
+
logger.debug(f"Using E2EE store path: {store_path}")
|
|
1493
|
+
else:
|
|
1494
|
+
logger.debug("E2EE disabled in configuration, not using store path")
|
|
1495
|
+
|
|
1496
|
+
# Create client config with E2EE based on configuration
|
|
1497
|
+
client_config = AsyncClientConfig(
|
|
1498
|
+
store_sync_tokens=True, encryption_enabled=e2ee_enabled
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
# Use the same SSL context as discovery client
|
|
1502
|
+
# ssl_context was created above for discovery
|
|
1503
|
+
|
|
1504
|
+
# Initialize client with E2EE support
|
|
1505
|
+
# Use most common pattern from matrix-nio examples: positional homeserver and user
|
|
1506
|
+
logger.debug("Creating AsyncClient with:")
|
|
1507
|
+
logger.debug(f" homeserver: {homeserver}")
|
|
1508
|
+
logger.debug(f" username: {username}")
|
|
1509
|
+
logger.debug(f" device_id: {existing_device_id}")
|
|
1510
|
+
logger.debug(f" store_path: {store_path}")
|
|
1511
|
+
logger.debug(f" e2ee_enabled: {e2ee_enabled}")
|
|
1512
|
+
|
|
1513
|
+
client = AsyncClient(
|
|
1514
|
+
homeserver,
|
|
1515
|
+
username,
|
|
1516
|
+
device_id=existing_device_id,
|
|
1517
|
+
store_path=store_path,
|
|
1518
|
+
config=client_config,
|
|
1519
|
+
ssl=ssl_context,
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
logger.debug("AsyncClient created successfully")
|
|
1523
|
+
|
|
1524
|
+
logger.info(f"Logging in as {username} to {homeserver}...")
|
|
1525
|
+
|
|
1526
|
+
# Login with consistent device name and timeout
|
|
1527
|
+
# Use appropriate device name based on E2EE configuration
|
|
1528
|
+
device_name = "mmrelay-e2ee" if e2ee_enabled else "mmrelay"
|
|
1529
|
+
try:
|
|
1530
|
+
# Set device_id on client if we have an existing one
|
|
1531
|
+
if existing_device_id:
|
|
1532
|
+
client.device_id = existing_device_id
|
|
1533
|
+
|
|
1534
|
+
logger.debug(f"Attempting login to {homeserver} as {username}")
|
|
1535
|
+
logger.debug("Login parameters:")
|
|
1536
|
+
logger.debug(f" device_name: {device_name}")
|
|
1537
|
+
logger.debug(f" password length: {len(password) if password else 0}")
|
|
1538
|
+
logger.debug(f" client.user: {client.user}")
|
|
1539
|
+
logger.debug(f" client.homeserver: {client.homeserver}")
|
|
1540
|
+
|
|
1541
|
+
# Test the API call that matrix-nio will make
|
|
1542
|
+
try:
|
|
1543
|
+
from nio.api import Api
|
|
1544
|
+
|
|
1545
|
+
method, path, data = Api.login(
|
|
1546
|
+
user=username,
|
|
1547
|
+
password=password,
|
|
1548
|
+
device_name=device_name,
|
|
1549
|
+
device_id=existing_device_id,
|
|
1550
|
+
)
|
|
1551
|
+
logger.debug("Matrix API call details:")
|
|
1552
|
+
logger.debug(f" method: {method}")
|
|
1553
|
+
logger.debug(f" path: {path}")
|
|
1554
|
+
logger.debug(f" data length: {len(data) if data else 0}")
|
|
1555
|
+
|
|
1556
|
+
# Parse the JSON to see the structure (without logging the password)
|
|
1557
|
+
import json
|
|
1558
|
+
|
|
1559
|
+
parsed_data = json.loads(data)
|
|
1560
|
+
safe_data = {
|
|
1561
|
+
k: (v if k != "password" else f"[{len(v)} chars]")
|
|
1562
|
+
for k, v in parsed_data.items()
|
|
1563
|
+
}
|
|
1564
|
+
logger.debug(f" parsed data: {safe_data}")
|
|
1565
|
+
|
|
1566
|
+
except Exception as e:
|
|
1567
|
+
logger.error(f"Failed to test API call: {e}")
|
|
1568
|
+
|
|
1569
|
+
response = await asyncio.wait_for(
|
|
1570
|
+
client.login(password, device_name=device_name),
|
|
1571
|
+
timeout=MATRIX_LOGIN_TIMEOUT,
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
# Debug: Log the response type and safe attributes only
|
|
1575
|
+
logger.debug(f"Login response type: {type(response).__name__}")
|
|
1576
|
+
|
|
1577
|
+
# Check specific attributes that should be present, masking sensitive data
|
|
1578
|
+
for attr in [
|
|
1579
|
+
"user_id",
|
|
1580
|
+
"device_id",
|
|
1581
|
+
"access_token",
|
|
1582
|
+
"status_code",
|
|
1583
|
+
"message",
|
|
1584
|
+
]:
|
|
1585
|
+
if hasattr(response, attr):
|
|
1586
|
+
value = getattr(response, attr)
|
|
1587
|
+
if attr == "access_token" and value:
|
|
1588
|
+
# Mask access token for security
|
|
1589
|
+
masked_value = (
|
|
1590
|
+
f"{value[:8]}...{value[-4:]}"
|
|
1591
|
+
if len(value) > 12
|
|
1592
|
+
else "***masked***"
|
|
1593
|
+
)
|
|
1594
|
+
logger.debug(
|
|
1595
|
+
f"Response.{attr}: {masked_value} (type: {type(value).__name__})"
|
|
1596
|
+
)
|
|
1597
|
+
else:
|
|
1598
|
+
logger.debug(
|
|
1599
|
+
f"Response.{attr}: {value} (type: {type(value).__name__})"
|
|
1600
|
+
)
|
|
1601
|
+
else:
|
|
1602
|
+
logger.debug(f"Response.{attr}: NOT PRESENT")
|
|
1603
|
+
except asyncio.TimeoutError:
|
|
1604
|
+
logger.exception(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
|
|
1605
|
+
logger.error(
|
|
1606
|
+
"This may indicate network connectivity issues or a slow Matrix server"
|
|
1607
|
+
)
|
|
1608
|
+
await client.close()
|
|
1609
|
+
return False
|
|
1610
|
+
except TypeError as e:
|
|
1611
|
+
# Handle the specific ">=" comparison error that can occur in matrix-nio
|
|
1612
|
+
if "'>=' not supported between instances of 'str' and 'int'" in str(e):
|
|
1613
|
+
logger.error("Matrix-nio library error during login (known issue)")
|
|
1614
|
+
logger.error(
|
|
1615
|
+
"This typically indicates invalid credentials or server response format issues"
|
|
1616
|
+
)
|
|
1617
|
+
logger.error("Troubleshooting steps:")
|
|
1618
|
+
logger.error("1. Verify your username and password are correct")
|
|
1619
|
+
logger.error("2. Check if your account is locked or suspended")
|
|
1620
|
+
logger.error("3. Try logging in through a web browser first")
|
|
1621
|
+
logger.error("4. Ensure your Matrix server supports the login API")
|
|
1622
|
+
logger.error(
|
|
1623
|
+
"5. Try using a different homeserver URL format (e.g., with https://)"
|
|
1624
|
+
)
|
|
1625
|
+
else:
|
|
1626
|
+
logger.exception("Type error during login")
|
|
1627
|
+
await client.close()
|
|
1628
|
+
return False
|
|
1629
|
+
except Exception as e:
|
|
1630
|
+
# Handle other exceptions during login (e.g., network errors)
|
|
1631
|
+
error_type = type(e).__name__
|
|
1632
|
+
logger.exception(f"Login failed with {error_type}")
|
|
1633
|
+
|
|
1634
|
+
# Provide specific guidance based on error type
|
|
1635
|
+
if isinstance(e, (ConnectionError, asyncio.TimeoutError)):
|
|
1636
|
+
logger.error("Network connectivity issue detected.")
|
|
1637
|
+
logger.error("Troubleshooting steps:")
|
|
1638
|
+
logger.error("1. Check your internet connection")
|
|
1639
|
+
logger.error(f"2. Verify the homeserver URL is correct: {homeserver}")
|
|
1640
|
+
logger.error("3. Check if the Matrix server is online")
|
|
1641
|
+
elif isinstance(e, (ssl.SSLError, ssl.CertificateError)):
|
|
1642
|
+
logger.error("SSL/TLS certificate issue detected.")
|
|
1643
|
+
logger.error(
|
|
1644
|
+
"This may indicate a problem with the server's SSL certificate."
|
|
1645
|
+
)
|
|
1646
|
+
elif "DNSError" in error_type or "NameResolutionError" in error_type:
|
|
1647
|
+
logger.error("DNS resolution failed.")
|
|
1648
|
+
logger.error(f"Cannot resolve hostname: {homeserver}")
|
|
1649
|
+
logger.error("Check your DNS settings and internet connection.")
|
|
1650
|
+
elif "'user_id' is a required property" in str(e):
|
|
1651
|
+
logger.error("Matrix server response validation failed.")
|
|
1652
|
+
logger.error("This typically indicates:")
|
|
1653
|
+
logger.error("1. Invalid username or password")
|
|
1654
|
+
logger.error("2. Server response format not as expected")
|
|
1655
|
+
logger.error("3. Matrix server compatibility issues")
|
|
1656
|
+
logger.error("Troubleshooting steps:")
|
|
1657
|
+
logger.error("1. Verify credentials by logging in via web browser")
|
|
1658
|
+
logger.error(
|
|
1659
|
+
"2. Try using the full homeserver URL (e.g., https://matrix.org)"
|
|
1660
|
+
)
|
|
1661
|
+
logger.error(
|
|
1662
|
+
"3. Check if your Matrix server is compatible with matrix-nio"
|
|
1663
|
+
)
|
|
1664
|
+
logger.error("4. Try a different Matrix server if available")
|
|
1665
|
+
|
|
1666
|
+
else:
|
|
1667
|
+
logger.error("Unexpected error during login.")
|
|
1668
|
+
|
|
1669
|
+
# Additional details already included in the message above.
|
|
1670
|
+
await client.close()
|
|
1671
|
+
return False
|
|
1672
|
+
|
|
1673
|
+
# Handle login response - check for access_token first (most reliable indicator)
|
|
1674
|
+
if hasattr(response, "access_token") and response.access_token:
|
|
1675
|
+
logger.info("Login successful!")
|
|
1676
|
+
|
|
1677
|
+
# Get the actual user_id from whoami() - this is the proper way
|
|
1678
|
+
try:
|
|
1679
|
+
whoami_response = await client.whoami()
|
|
1680
|
+
if hasattr(whoami_response, "user_id"):
|
|
1681
|
+
actual_user_id = whoami_response.user_id
|
|
1682
|
+
logger.debug(f"Got user_id from whoami: {actual_user_id}")
|
|
1683
|
+
else:
|
|
1684
|
+
# Fallback to response user_id or username
|
|
1685
|
+
actual_user_id = getattr(response, "user_id", username)
|
|
1686
|
+
logger.warning(
|
|
1687
|
+
f"whoami failed, using fallback user_id: {actual_user_id}"
|
|
1688
|
+
)
|
|
1689
|
+
except Exception as e:
|
|
1690
|
+
logger.warning(f"whoami call failed: {e}, using fallback")
|
|
1691
|
+
actual_user_id = getattr(response, "user_id", username)
|
|
1692
|
+
|
|
1693
|
+
# Save credentials to credentials.json
|
|
1694
|
+
credentials = {
|
|
1695
|
+
"homeserver": homeserver,
|
|
1696
|
+
"user_id": actual_user_id,
|
|
1697
|
+
"access_token": response.access_token,
|
|
1698
|
+
"device_id": getattr(response, "device_id", existing_device_id),
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
config_dir = get_base_dir()
|
|
1702
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
1703
|
+
save_credentials(credentials)
|
|
1704
|
+
logger.info(f"Credentials saved to {credentials_path}")
|
|
1705
|
+
|
|
1706
|
+
# Logout other sessions if requested
|
|
1707
|
+
if logout_others:
|
|
1708
|
+
logger.info("Logging out other sessions...")
|
|
1709
|
+
# Note: This would require additional implementation
|
|
1710
|
+
logger.warning("Logout others not yet implemented")
|
|
1711
|
+
|
|
1712
|
+
await client.close()
|
|
1713
|
+
return True
|
|
1714
|
+
else:
|
|
1715
|
+
# Handle login failure
|
|
1716
|
+
if hasattr(response, "status_code") and hasattr(response, "message"):
|
|
1717
|
+
status_code = response.status_code
|
|
1718
|
+
error_message = response.message
|
|
1719
|
+
|
|
1720
|
+
logger.error(f"Login failed: {type(response).__name__}")
|
|
1721
|
+
logger.error(f"Error message: {error_message}")
|
|
1722
|
+
logger.error(f"HTTP status code: {status_code}")
|
|
1723
|
+
|
|
1724
|
+
# Provide specific troubleshooting guidance
|
|
1725
|
+
if status_code == 401 or "M_FORBIDDEN" in str(error_message):
|
|
1726
|
+
logger.error(
|
|
1727
|
+
"Authentication failed - invalid username or password."
|
|
1728
|
+
)
|
|
1729
|
+
logger.error("Troubleshooting steps:")
|
|
1730
|
+
logger.error("1. Verify your username and password are correct")
|
|
1731
|
+
logger.error("2. Check if your account is locked or suspended")
|
|
1732
|
+
logger.error("3. Try logging in through a web browser first")
|
|
1733
|
+
logger.error(
|
|
1734
|
+
"4. Use 'mmrelay auth login' to set up new credentials"
|
|
1735
|
+
)
|
|
1736
|
+
elif status_code == 404:
|
|
1737
|
+
logger.error("User not found or homeserver not found.")
|
|
1738
|
+
logger.error(
|
|
1739
|
+
f"Check that the homeserver URL is correct: {homeserver}"
|
|
1740
|
+
)
|
|
1741
|
+
elif status_code == 429:
|
|
1742
|
+
logger.error("Rate limited - too many login attempts.")
|
|
1743
|
+
logger.error("Wait a few minutes before trying again.")
|
|
1744
|
+
elif status_code and int(status_code) >= 500:
|
|
1745
|
+
logger.error(
|
|
1746
|
+
"Matrix server error - the server is experiencing issues."
|
|
1747
|
+
)
|
|
1748
|
+
logger.error(
|
|
1749
|
+
"Try again later or contact your server administrator."
|
|
1750
|
+
)
|
|
1751
|
+
else:
|
|
1752
|
+
logger.error("Login failed for unknown reason.")
|
|
1753
|
+
logger.error(
|
|
1754
|
+
"Try using 'mmrelay auth login' for interactive setup."
|
|
1755
|
+
)
|
|
1756
|
+
else:
|
|
1757
|
+
logger.error(f"Unexpected login response: {type(response).__name__}")
|
|
1758
|
+
logger.error(
|
|
1759
|
+
"This may indicate a matrix-nio library issue or server problem."
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
await client.close()
|
|
1763
|
+
return False
|
|
1764
|
+
|
|
1765
|
+
except Exception:
|
|
1766
|
+
logger.exception("Error during login")
|
|
1767
|
+
try:
|
|
1768
|
+
await client.close()
|
|
1769
|
+
except Exception as e:
|
|
1770
|
+
# Ignore errors during client cleanup - connection may already be closed
|
|
1771
|
+
logger.debug(f"Ignoring error during client cleanup: {e}")
|
|
1772
|
+
return False
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
1776
|
+
"""
|
|
1777
|
+
Join the bot to a Matrix room by ID or alias.
|
|
1778
|
+
|
|
1779
|
+
Resolves a room alias (e.g. "#room:server") to its canonical room ID, updates the in-memory
|
|
1780
|
+
matrix_rooms mapping with the resolved ID (if available), and attempts to join the resolved
|
|
1781
|
+
room ID. No-op if the client is already joined to the room. Errors during alias resolution
|
|
1782
|
+
or join are caught and logged; the function does not raise exceptions.
|
|
1783
|
+
|
|
1784
|
+
Parameters documented only where meaning is not obvious:
|
|
1785
|
+
room_id_or_alias (str): A Matrix room identifier, either a canonical room ID (e.g. "!abc:server")
|
|
1786
|
+
or a room alias (starts with '#'). When an alias is provided, it will be resolved and
|
|
1787
|
+
the resolved room ID will be used for joining and recorded in the module's matrix_rooms mapping.
|
|
1788
|
+
|
|
1789
|
+
Returns:
|
|
1790
|
+
None
|
|
1791
|
+
"""
|
|
1792
|
+
|
|
1793
|
+
if not isinstance(room_id_or_alias, str):
|
|
1794
|
+
logger.error(
|
|
1795
|
+
"join_matrix_room expected a string room ID, received %r",
|
|
1796
|
+
room_id_or_alias,
|
|
1797
|
+
)
|
|
1798
|
+
return
|
|
1799
|
+
|
|
1800
|
+
room_id = room_id_or_alias
|
|
1801
|
+
|
|
1802
|
+
if room_id_or_alias.startswith("#"):
|
|
1803
|
+
try:
|
|
1804
|
+
response = await matrix_client.room_resolve_alias(room_id_or_alias)
|
|
1805
|
+
except (
|
|
1806
|
+
NioLocalProtocolError,
|
|
1807
|
+
NioRemoteProtocolError,
|
|
1808
|
+
NioErrorResponse,
|
|
1809
|
+
NioLocalTransportError,
|
|
1810
|
+
NioRemoteTransportError,
|
|
1811
|
+
asyncio.TimeoutError,
|
|
1812
|
+
):
|
|
1813
|
+
logger.exception("Error resolving alias '%s'", room_id_or_alias)
|
|
1814
|
+
return
|
|
1815
|
+
|
|
1816
|
+
room_id = getattr(response, "room_id", None) if response else None
|
|
1817
|
+
if not room_id:
|
|
1818
|
+
logger.error(
|
|
1819
|
+
"Failed to resolve alias '%s': %s",
|
|
1820
|
+
room_id_or_alias,
|
|
1821
|
+
getattr(response, "message", str(response)),
|
|
1822
|
+
)
|
|
1823
|
+
return
|
|
1824
|
+
|
|
1825
|
+
try:
|
|
1826
|
+
mapping = matrix_rooms
|
|
1827
|
+
except NameError:
|
|
1828
|
+
mapping = None
|
|
1829
|
+
|
|
1830
|
+
if mapping:
|
|
1831
|
+
try:
|
|
1832
|
+
_update_room_id_in_mapping(mapping, room_id_or_alias, room_id)
|
|
1833
|
+
except Exception:
|
|
1834
|
+
logger.debug(
|
|
1835
|
+
"Non-fatal error updating matrix_rooms for alias '%s'",
|
|
1836
|
+
room_id_or_alias,
|
|
1837
|
+
exc_info=True,
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
logger.info("Resolved alias '%s' -> '%s'", room_id_or_alias, room_id)
|
|
1841
|
+
|
|
1842
|
+
try:
|
|
1843
|
+
if room_id not in matrix_client.rooms:
|
|
1844
|
+
response = await matrix_client.join(room_id)
|
|
1845
|
+
joined_room_id = getattr(response, "room_id", None) if response else None
|
|
1846
|
+
if joined_room_id:
|
|
1847
|
+
logger.info(f"Joined room '{joined_room_id}' successfully")
|
|
1848
|
+
else:
|
|
1849
|
+
logger.error(
|
|
1850
|
+
"Failed to join room '%s': %s",
|
|
1851
|
+
room_id,
|
|
1852
|
+
getattr(response, "message", str(response)),
|
|
1853
|
+
)
|
|
1854
|
+
else:
|
|
1855
|
+
logger.debug(
|
|
1856
|
+
"Bot is already in room '%s', no action needed.",
|
|
1857
|
+
room_id,
|
|
1858
|
+
)
|
|
1859
|
+
except (
|
|
1860
|
+
NioLocalProtocolError,
|
|
1861
|
+
NioRemoteProtocolError,
|
|
1862
|
+
NioErrorResponse,
|
|
1863
|
+
NioLocalTransportError,
|
|
1864
|
+
NioRemoteTransportError,
|
|
1865
|
+
asyncio.TimeoutError,
|
|
1866
|
+
):
|
|
1867
|
+
logger.exception(f"Error joining room '{room_id}'")
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _get_e2ee_error_message():
|
|
1871
|
+
"""
|
|
1872
|
+
Return a user-facing string explaining why End-to-End Encryption (E2EE) is not enabled.
|
|
1873
|
+
|
|
1874
|
+
This queries the unified E2EE status (using the module-level config and config path)
|
|
1875
|
+
and converts that status into a concise error message suitable for logging or UI display.
|
|
1876
|
+
|
|
1877
|
+
Returns:
|
|
1878
|
+
str: A short, human-readable explanation of the current E2EE problem (empty or generic
|
|
1879
|
+
message if no specific issue is detected).
|
|
1880
|
+
"""
|
|
1881
|
+
from mmrelay.config import config_path
|
|
1882
|
+
from mmrelay.e2ee_utils import get_e2ee_error_message, get_e2ee_status
|
|
1883
|
+
|
|
1884
|
+
# Get unified E2EE status
|
|
1885
|
+
e2ee_status = get_e2ee_status(config, config_path)
|
|
1886
|
+
|
|
1887
|
+
# Return unified error message
|
|
1888
|
+
return get_e2ee_error_message(e2ee_status)
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
async def matrix_relay(
|
|
1892
|
+
room_id,
|
|
1893
|
+
message,
|
|
1894
|
+
longname,
|
|
1895
|
+
shortname,
|
|
1896
|
+
meshnet_name,
|
|
1897
|
+
portnum,
|
|
1898
|
+
meshtastic_id=None,
|
|
1899
|
+
meshtastic_replyId=None,
|
|
1900
|
+
meshtastic_text=None,
|
|
1901
|
+
emote=False,
|
|
1902
|
+
emoji=False,
|
|
1903
|
+
reply_to_event_id=None,
|
|
1904
|
+
):
|
|
1905
|
+
"""
|
|
1906
|
+
Relay a Meshtastic message into a Matrix room and optionally persist a Meshtastic⇄Matrix mapping for later interactions.
|
|
1907
|
+
|
|
1908
|
+
Formats the Meshtastic text into plain and HTML-safe formatted Matrix content, applies Matrix reply framing when reply_to_event_id is provided, enforces E2EE restrictions, sends the event via the configured Matrix client, and—when message-interactions are enabled—stores a mapping from the Meshtastic message to the created Matrix event for use by cross-network replies and reactions. Errors are logged; the function does not raise on send or storage failures.
|
|
1909
|
+
|
|
1910
|
+
Parameters:
|
|
1911
|
+
room_id (str): Matrix room ID or alias to send the message into.
|
|
1912
|
+
message (str): Text of the Meshtastic message to relay.
|
|
1913
|
+
longname (str): Sender long display name from Meshtastic.
|
|
1914
|
+
shortname (str): Sender short display name from Meshtastic.
|
|
1915
|
+
meshnet_name (str): Remote meshnet name associated with the incoming message.
|
|
1916
|
+
portnum (int): Meshtastic application/port number for the message.
|
|
1917
|
+
meshtastic_id (optional): Meshtastic message identifier; when provided and storage is enabled, used to persist a mapping to the created Matrix event.
|
|
1918
|
+
meshtastic_replyId (optional): Original Meshtastic message ID being replied to; included as metadata on the Matrix event.
|
|
1919
|
+
meshtastic_text (optional): Original Meshtastic text to store with the mapping; if omitted, the relayed message text is used.
|
|
1920
|
+
emote (bool, optional): If True, send the Matrix event as `m.emote` instead of `m.text`.
|
|
1921
|
+
emoji (bool, optional): If True, mark the event with an emoji flag used by downstream logic.
|
|
1922
|
+
reply_to_event_id (str, optional): Matrix event_id being replied to; if provided the outgoing event will include an `m.in_reply_to` relation and quoted/HTML reply content when the original mapping can be found.
|
|
1923
|
+
"""
|
|
1924
|
+
global config
|
|
1925
|
+
|
|
1926
|
+
# Log the current state of the config
|
|
1927
|
+
logger.debug(f"matrix_relay: config is {'available' if config else 'None'}")
|
|
1928
|
+
|
|
1929
|
+
matrix_client = await connect_matrix()
|
|
1930
|
+
|
|
1931
|
+
# Check if config is available
|
|
1932
|
+
if config is None:
|
|
1933
|
+
logger.error("No configuration available. Cannot relay message to Matrix.")
|
|
1934
|
+
return
|
|
1935
|
+
|
|
1936
|
+
# Get interaction settings
|
|
1937
|
+
interactions = get_interaction_settings(config)
|
|
1938
|
+
storage_enabled = message_storage_enabled(interactions)
|
|
1939
|
+
|
|
1940
|
+
# Retrieve db config for message_map pruning
|
|
1941
|
+
# Check database config for message map settings (preferred format)
|
|
1942
|
+
database_config = config.get("database", {})
|
|
1943
|
+
msg_map_config = database_config.get("msg_map", {})
|
|
1944
|
+
|
|
1945
|
+
# If not found in database config, check legacy db config
|
|
1946
|
+
if not msg_map_config:
|
|
1947
|
+
db_config = config.get("db", {})
|
|
1948
|
+
legacy_msg_map_config = db_config.get("msg_map", {})
|
|
1949
|
+
|
|
1950
|
+
if legacy_msg_map_config:
|
|
1951
|
+
msg_map_config = legacy_msg_map_config
|
|
1952
|
+
logger.warning(
|
|
1953
|
+
"Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
|
|
1954
|
+
)
|
|
1955
|
+
msgs_to_keep = msg_map_config.get(
|
|
1956
|
+
"msgs_to_keep", DEFAULT_MSGS_TO_KEEP
|
|
1957
|
+
) # Default from constants
|
|
1958
|
+
|
|
1959
|
+
try:
|
|
1960
|
+
# Always use our own local meshnet_name for outgoing events
|
|
1961
|
+
local_meshnet_name = config["meshtastic"]["meshnet_name"]
|
|
1962
|
+
|
|
1963
|
+
# Check if message contains HTML tags or markdown formatting
|
|
1964
|
+
has_html = bool(re.search(r"</?[a-zA-Z][^>]*>", message))
|
|
1965
|
+
has_markdown = bool(re.search(r"[*_`~]", message)) # Basic markdown indicators
|
|
1966
|
+
|
|
1967
|
+
# Process markdown/HTML if available; otherwise, safe fallback
|
|
1968
|
+
if has_markdown or has_html:
|
|
1969
|
+
try:
|
|
1970
|
+
import bleach # lazy import
|
|
1971
|
+
import markdown # lazy import
|
|
1972
|
+
|
|
1973
|
+
raw_html = markdown.markdown(message)
|
|
1974
|
+
formatted_body = bleach.clean(
|
|
1975
|
+
raw_html,
|
|
1976
|
+
tags=[
|
|
1977
|
+
"b",
|
|
1978
|
+
"strong",
|
|
1979
|
+
"i",
|
|
1980
|
+
"em",
|
|
1981
|
+
"code",
|
|
1982
|
+
"pre",
|
|
1983
|
+
"br",
|
|
1984
|
+
"blockquote",
|
|
1985
|
+
"a",
|
|
1986
|
+
"ul",
|
|
1987
|
+
"ol",
|
|
1988
|
+
"li",
|
|
1989
|
+
"p",
|
|
1990
|
+
],
|
|
1991
|
+
attributes={"a": ["href"]},
|
|
1992
|
+
strip=True,
|
|
1993
|
+
)
|
|
1994
|
+
plain_body = re.sub(r"</?[^>]*>", "", formatted_body)
|
|
1995
|
+
except ImportError:
|
|
1996
|
+
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
1997
|
+
plain_body = message
|
|
1998
|
+
else:
|
|
1999
|
+
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
2000
|
+
plain_body = message
|
|
2001
|
+
|
|
2002
|
+
content = {
|
|
2003
|
+
"msgtype": "m.text" if not emote else "m.emote",
|
|
2004
|
+
"body": plain_body,
|
|
2005
|
+
"meshtastic_longname": longname,
|
|
2006
|
+
"meshtastic_shortname": shortname,
|
|
2007
|
+
"meshtastic_meshnet": local_meshnet_name,
|
|
2008
|
+
"meshtastic_portnum": portnum,
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
# Always add format and formatted_body to avoid nio validation errors
|
|
2012
|
+
# where formatted_body becomes None and fails schema validation.
|
|
2013
|
+
content["format"] = "org.matrix.custom.html"
|
|
2014
|
+
content["formatted_body"] = formatted_body
|
|
2015
|
+
if meshtastic_id is not None:
|
|
2016
|
+
content["meshtastic_id"] = meshtastic_id
|
|
2017
|
+
if meshtastic_replyId is not None:
|
|
2018
|
+
content["meshtastic_replyId"] = meshtastic_replyId
|
|
2019
|
+
if meshtastic_text is not None:
|
|
2020
|
+
content["meshtastic_text"] = meshtastic_text
|
|
2021
|
+
if emoji:
|
|
2022
|
+
content["meshtastic_emoji"] = 1
|
|
2023
|
+
|
|
2024
|
+
# Add Matrix reply formatting if this is a reply
|
|
2025
|
+
if reply_to_event_id:
|
|
2026
|
+
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
|
|
2027
|
+
# For Matrix replies, we need to format the body with quoted content
|
|
2028
|
+
# Get the original message details for proper quoting
|
|
2029
|
+
try:
|
|
2030
|
+
orig = get_message_map_by_matrix_event_id(reply_to_event_id)
|
|
2031
|
+
if orig:
|
|
2032
|
+
# orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
|
|
2033
|
+
_, _, original_text, original_meshnet = orig
|
|
2034
|
+
|
|
2035
|
+
# Use the relay bot's user ID for attribution (this is correct for relay messages)
|
|
2036
|
+
bot_user_id = matrix_client.user_id
|
|
2037
|
+
original_sender_display = f"{longname}/{original_meshnet}"
|
|
2038
|
+
|
|
2039
|
+
# Create the quoted reply format
|
|
2040
|
+
safe_original = html.escape(original_text or "")
|
|
2041
|
+
safe_sender_display = re.sub(
|
|
2042
|
+
r"([\\`*_{}[\]()#+.!-])", r"\\\1", original_sender_display
|
|
2043
|
+
)
|
|
2044
|
+
quoted_text = (
|
|
2045
|
+
f"> <{bot_user_id}> [{safe_sender_display}]: {safe_original}"
|
|
2046
|
+
)
|
|
2047
|
+
content["body"] = f"{quoted_text}\n\n{plain_body}"
|
|
2048
|
+
|
|
2049
|
+
# Always use HTML formatting for replies since we need the mx-reply structure
|
|
2050
|
+
content["format"] = "org.matrix.custom.html"
|
|
2051
|
+
reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
|
|
2052
|
+
bot_link = f"https://matrix.to/#/{bot_user_id}"
|
|
2053
|
+
blockquote_content = (
|
|
2054
|
+
f'<a href="{reply_link}">In reply to</a> '
|
|
2055
|
+
f'<a href="{bot_link}">{bot_user_id}</a><br>'
|
|
2056
|
+
f"[{html.escape(original_sender_display)}]: {safe_original}"
|
|
2057
|
+
)
|
|
2058
|
+
content["formatted_body"] = (
|
|
2059
|
+
f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{formatted_body}"
|
|
2060
|
+
)
|
|
2061
|
+
else:
|
|
2062
|
+
logger.warning(
|
|
2063
|
+
f"Could not find original message for reply_to_event_id: {reply_to_event_id}"
|
|
2064
|
+
)
|
|
2065
|
+
except Exception as e:
|
|
2066
|
+
logger.error(f"Error formatting Matrix reply: {e}")
|
|
2067
|
+
|
|
2068
|
+
try:
|
|
2069
|
+
# Ensure matrix_client is not None
|
|
2070
|
+
if not matrix_client:
|
|
2071
|
+
logger.error("Matrix client is None. Cannot send message.")
|
|
2072
|
+
return
|
|
2073
|
+
|
|
2074
|
+
# Send the message with a timeout
|
|
2075
|
+
# For encrypted rooms, use ignore_unverified_devices=True
|
|
2076
|
+
# After checking working implementations, always use ignore_unverified_devices=True
|
|
2077
|
+
# for text messages to ensure encryption works properly
|
|
2078
|
+
room = (
|
|
2079
|
+
matrix_client.rooms.get(room_id)
|
|
2080
|
+
if matrix_client and hasattr(matrix_client, "rooms")
|
|
2081
|
+
else None
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
# Debug logging for encryption status
|
|
2085
|
+
if room:
|
|
2086
|
+
encrypted_status = getattr(room, "encrypted", "unknown")
|
|
2087
|
+
logger.debug(
|
|
2088
|
+
f"Room {room_id} encryption status: encrypted={encrypted_status}"
|
|
2089
|
+
)
|
|
2090
|
+
|
|
2091
|
+
# Additional E2EE debugging
|
|
2092
|
+
if encrypted_status is True:
|
|
2093
|
+
logger.debug(f"Sending encrypted message to room {room_id}")
|
|
2094
|
+
elif encrypted_status is False:
|
|
2095
|
+
logger.debug(f"Sending unencrypted message to room {room_id}")
|
|
2096
|
+
else:
|
|
2097
|
+
logger.warning(
|
|
2098
|
+
f"Room {room_id} encryption status is unknown - this may indicate E2EE issues"
|
|
2099
|
+
)
|
|
2100
|
+
else:
|
|
2101
|
+
logger.warning(
|
|
2102
|
+
f"Room {room_id} not found in client.rooms - cannot determine encryption status"
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
# Always use ignore_unverified_devices=True for text messages (like matrix-nio-send)
|
|
2106
|
+
logger.debug(
|
|
2107
|
+
"Sending message with ignore_unverified_devices=True (always for text messages)"
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
# Final check: Do not send to encrypted rooms if E2EE is not enabled
|
|
2111
|
+
if (
|
|
2112
|
+
room
|
|
2113
|
+
and getattr(room, "encrypted", False)
|
|
2114
|
+
and not getattr(matrix_client, "e2ee_enabled", False)
|
|
2115
|
+
):
|
|
2116
|
+
room_name = getattr(room, "display_name", room_id)
|
|
2117
|
+
error_message = _get_e2ee_error_message()
|
|
2118
|
+
logger.error(
|
|
2119
|
+
f"🔒 BLOCKED: Cannot send message to encrypted room '{room_name}' ({room_id})"
|
|
2120
|
+
)
|
|
2121
|
+
logger.error(f"Reason: {error_message}")
|
|
2122
|
+
logger.info(
|
|
2123
|
+
"💡 Tip: Run 'mmrelay config check' to validate your E2EE setup"
|
|
2124
|
+
)
|
|
2125
|
+
return
|
|
2126
|
+
|
|
2127
|
+
response = await asyncio.wait_for(
|
|
2128
|
+
matrix_client.room_send(
|
|
2129
|
+
room_id=room_id,
|
|
2130
|
+
message_type="m.room.message",
|
|
2131
|
+
content=content,
|
|
2132
|
+
ignore_unverified_devices=True,
|
|
2133
|
+
),
|
|
2134
|
+
timeout=MATRIX_ROOM_SEND_TIMEOUT, # Increased timeout
|
|
2135
|
+
)
|
|
2136
|
+
|
|
2137
|
+
# Log at info level, matching one-point-oh pattern
|
|
2138
|
+
logger.info(f"Sent inbound radio message to matrix room: {room_id}")
|
|
2139
|
+
# Additional details at debug level
|
|
2140
|
+
if hasattr(response, "event_id"):
|
|
2141
|
+
logger.debug(f"Message event_id: {response.event_id}")
|
|
2142
|
+
|
|
2143
|
+
except asyncio.TimeoutError:
|
|
2144
|
+
logger.error(f"Timeout sending message to Matrix room {room_id}")
|
|
2145
|
+
return
|
|
2146
|
+
except Exception:
|
|
2147
|
+
logger.exception(f"Error sending message to Matrix room {room_id}")
|
|
2148
|
+
return
|
|
2149
|
+
|
|
2150
|
+
# Only store message map if any interactions are enabled and conditions are met
|
|
2151
|
+
# This enables reactions and/or replies functionality based on configuration
|
|
2152
|
+
if (
|
|
2153
|
+
storage_enabled
|
|
2154
|
+
and meshtastic_id is not None
|
|
2155
|
+
and not emote
|
|
2156
|
+
and hasattr(response, "event_id")
|
|
2157
|
+
):
|
|
2158
|
+
try:
|
|
2159
|
+
await async_store_message_map(
|
|
2160
|
+
meshtastic_id,
|
|
2161
|
+
response.event_id,
|
|
2162
|
+
room_id,
|
|
2163
|
+
meshtastic_text if meshtastic_text else message,
|
|
2164
|
+
meshtastic_meshnet=local_meshnet_name,
|
|
2165
|
+
)
|
|
2166
|
+
logger.debug(f"Stored message map for meshtastic_id: {meshtastic_id}")
|
|
2167
|
+
|
|
2168
|
+
# If msgs_to_keep > 0, prune old messages after inserting a new one
|
|
2169
|
+
if msgs_to_keep > 0:
|
|
2170
|
+
await async_prune_message_map(msgs_to_keep)
|
|
2171
|
+
except Exception as e:
|
|
2172
|
+
logger.error(f"Error storing message map: {e}")
|
|
2173
|
+
|
|
2174
|
+
except asyncio.TimeoutError:
|
|
2175
|
+
logger.error("Timed out while waiting for Matrix response")
|
|
2176
|
+
except Exception:
|
|
2177
|
+
logger.exception(f"Error sending radio message to matrix room {room_id}")
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
|
|
2181
|
+
"""
|
|
2182
|
+
Truncate a string so its UTF-8 encoding fits within max_bytes.
|
|
2183
|
+
|
|
2184
|
+
Returns a substring whose UTF-8 byte length is at most `max_bytes`. If
|
|
2185
|
+
`max_bytes` falls in the middle of a multi-byte UTF-8 character, the
|
|
2186
|
+
incomplete character is dropped (decoding uses 'ignore').
|
|
2187
|
+
|
|
2188
|
+
Parameters:
|
|
2189
|
+
text (str): Input text to truncate.
|
|
2190
|
+
max_bytes (int): Maximum allowed size in bytes for the UTF-8 encoded result
|
|
2191
|
+
(defaults to DEFAULT_MESSAGE_TRUNCATE_BYTES).
|
|
2192
|
+
|
|
2193
|
+
Returns:
|
|
2194
|
+
str: Truncated string.
|
|
2195
|
+
"""
|
|
2196
|
+
truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
|
|
2197
|
+
return truncated_text
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def strip_quoted_lines(text: str) -> str:
|
|
2201
|
+
"""
|
|
2202
|
+
Removes lines starting with '>' from the input text.
|
|
2203
|
+
|
|
2204
|
+
This is typically used to exclude quoted content from Matrix replies, such as when processing reaction text.
|
|
2205
|
+
"""
|
|
2206
|
+
lines = text.splitlines()
|
|
2207
|
+
filtered = [line.strip() for line in lines if not line.strip().startswith(">")]
|
|
2208
|
+
return " ".join(line for line in filtered if line).strip()
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
async def get_user_display_name(room, event):
|
|
2212
|
+
"""
|
|
2213
|
+
Return the display name for the event sender, preferring a room-specific name.
|
|
2214
|
+
|
|
2215
|
+
If the room provides a per-room display name for the sender, that name is returned.
|
|
2216
|
+
Otherwise the function performs an asynchronous lookup against the homeserver for the
|
|
2217
|
+
user's global display name and returns it if present. If no display name is available,
|
|
2218
|
+
the sender's Matrix ID (MXID) is returned.
|
|
2219
|
+
|
|
2220
|
+
Returns:
|
|
2221
|
+
str: A human-readable display name or the sender's MXID.
|
|
2222
|
+
"""
|
|
2223
|
+
room_display_name = room.user_name(event.sender)
|
|
2224
|
+
if room_display_name:
|
|
2225
|
+
return room_display_name
|
|
2226
|
+
|
|
2227
|
+
display_name_response = await matrix_client.get_displayname(event.sender)
|
|
2228
|
+
return display_name_response.displayname or event.sender
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def format_reply_message(
|
|
2232
|
+
config,
|
|
2233
|
+
full_display_name,
|
|
2234
|
+
text,
|
|
2235
|
+
*,
|
|
2236
|
+
longname=None,
|
|
2237
|
+
shortname=None,
|
|
2238
|
+
meshnet_name=None,
|
|
2239
|
+
local_meshnet_name=None,
|
|
2240
|
+
mesh_text_override=None,
|
|
2241
|
+
):
|
|
2242
|
+
"""
|
|
2243
|
+
Format a reply message by prefixing a truncated display name and removing quoted lines.
|
|
2244
|
+
|
|
2245
|
+
The resulting message is prefixed with the first five characters of the user's display name followed by "[M]: ", has quoted lines removed, and is truncated to fit within the allowed message length.
|
|
2246
|
+
|
|
2247
|
+
Parameters:
|
|
2248
|
+
full_display_name (str): The user's full display name to be truncated for the prefix.
|
|
2249
|
+
text (str): The reply text, possibly containing quoted lines.
|
|
2250
|
+
|
|
2251
|
+
Returns:
|
|
2252
|
+
str: The formatted and truncated reply message.
|
|
2253
|
+
"""
|
|
2254
|
+
# Determine the base text to use (prefer the raw Meshtastic payload when present)
|
|
2255
|
+
base_text = mesh_text_override if mesh_text_override else text
|
|
2256
|
+
|
|
2257
|
+
clean_text = strip_quoted_lines(base_text).strip()
|
|
2258
|
+
|
|
2259
|
+
# Handle remote meshnet replies by using the remote sender's prefix format
|
|
2260
|
+
if meshnet_name and local_meshnet_name and meshnet_name != local_meshnet_name:
|
|
2261
|
+
sender_long = longname or full_display_name or shortname or "???"
|
|
2262
|
+
sender_short = shortname or sender_long[:SHORTNAME_FALLBACK_LENGTH] or "???"
|
|
2263
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
2264
|
+
|
|
2265
|
+
prefix_candidates = [
|
|
2266
|
+
f"[{sender_long}/{meshnet_name}]: ",
|
|
2267
|
+
f"[{sender_long}/{short_meshnet_name}]: ",
|
|
2268
|
+
f"{sender_long}/{meshnet_name}: ",
|
|
2269
|
+
f"{sender_long}/{short_meshnet_name}: ",
|
|
2270
|
+
f"{sender_short}/{meshnet_name}: ",
|
|
2271
|
+
f"{sender_short}/{short_meshnet_name}: ",
|
|
2272
|
+
]
|
|
2273
|
+
|
|
2274
|
+
matrix_prefix_full = get_matrix_prefix(
|
|
2275
|
+
config, sender_long, sender_short, meshnet_name
|
|
2276
|
+
)
|
|
2277
|
+
matrix_prefix_short = get_matrix_prefix(
|
|
2278
|
+
config, sender_long, sender_short, short_meshnet_name
|
|
2279
|
+
)
|
|
2280
|
+
prefix_candidates.extend([matrix_prefix_full, matrix_prefix_short])
|
|
2281
|
+
|
|
2282
|
+
for candidate in prefix_candidates:
|
|
2283
|
+
if candidate and clean_text.startswith(candidate):
|
|
2284
|
+
clean_text = clean_text[len(candidate) :].lstrip()
|
|
2285
|
+
break
|
|
2286
|
+
|
|
2287
|
+
if not clean_text and mesh_text_override:
|
|
2288
|
+
clean_text = strip_quoted_lines(mesh_text_override).strip()
|
|
2289
|
+
|
|
2290
|
+
mesh_prefix = f"{sender_short}/{short_meshnet_name}:"
|
|
2291
|
+
reply_body = f" {clean_text}" if clean_text else ""
|
|
2292
|
+
reply_message = f"{mesh_prefix}{reply_body}"
|
|
2293
|
+
return truncate_message(reply_message.strip())
|
|
2294
|
+
|
|
2295
|
+
# Default behavior for local Matrix users (retain existing prefix logic)
|
|
2296
|
+
prefix = get_meshtastic_prefix(config, full_display_name)
|
|
2297
|
+
reply_message = f"{prefix}{clean_text}" if clean_text else prefix.rstrip()
|
|
2298
|
+
return truncate_message(reply_message)
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
async def send_reply_to_meshtastic(
|
|
2302
|
+
reply_message,
|
|
2303
|
+
full_display_name,
|
|
2304
|
+
room_config,
|
|
2305
|
+
room,
|
|
2306
|
+
event,
|
|
2307
|
+
text,
|
|
2308
|
+
storage_enabled,
|
|
2309
|
+
local_meshnet_name,
|
|
2310
|
+
reply_id=None,
|
|
2311
|
+
):
|
|
2312
|
+
"""
|
|
2313
|
+
Enqueue a Matrix reply to be delivered over Meshtastic as either a structured reply or a regular broadcast.
|
|
2314
|
+
|
|
2315
|
+
If broadcasting is disabled this function does nothing. When storage_enabled is True, it constructs a mapping record that links the originating Matrix event to the Meshtastic message and attaches it to the queued message so replies and reactions can be correlated later. Errors are logged; the function does not raise.
|
|
2316
|
+
|
|
2317
|
+
Parameters:
|
|
2318
|
+
reply_message (str): Text payload already formatted for Meshtastic.
|
|
2319
|
+
full_display_name (str): Sender display name used in queue descriptions and logs.
|
|
2320
|
+
room_config (dict): Room-specific configuration; must contain "meshtastic_channel" (integer channel index).
|
|
2321
|
+
room: Matrix room object; its room_id is used for mapping metadata.
|
|
2322
|
+
event: Matrix event object; its event_id is used for mapping metadata.
|
|
2323
|
+
text (str): Original Matrix message text used when building mapping metadata.
|
|
2324
|
+
storage_enabled (bool): If True, create and attach a message-mapping record to the queued Meshtastic message.
|
|
2325
|
+
local_meshnet_name (str | None): Local meshnet name included in mapping metadata when present.
|
|
2326
|
+
reply_id (int | None): If provided, send as a structured Meshtastic reply targeting this Meshtastic message ID; otherwise send a regular broadcast.
|
|
2327
|
+
"""
|
|
2328
|
+
loop = asyncio.get_running_loop()
|
|
2329
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2330
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
2331
|
+
|
|
2332
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
2333
|
+
|
|
2334
|
+
broadcast_enabled = get_meshtastic_config_value(
|
|
2335
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2336
|
+
)
|
|
2337
|
+
logger.debug(f"broadcast_enabled = {broadcast_enabled}")
|
|
2338
|
+
|
|
2339
|
+
if broadcast_enabled:
|
|
2340
|
+
try:
|
|
2341
|
+
# Create mapping info once if storage is enabled
|
|
2342
|
+
mapping_info = None
|
|
2343
|
+
if storage_enabled:
|
|
2344
|
+
# Get message map configuration
|
|
2345
|
+
msgs_to_keep = _get_msgs_to_keep_config()
|
|
2346
|
+
|
|
2347
|
+
mapping_info = _create_mapping_info(
|
|
2348
|
+
event.event_id, room.room_id, text, local_meshnet_name, msgs_to_keep
|
|
2349
|
+
)
|
|
2350
|
+
|
|
2351
|
+
if reply_id is not None:
|
|
2352
|
+
# Send as a structured reply using our custom function
|
|
2353
|
+
# Queue the reply message
|
|
2354
|
+
success = queue_message(
|
|
2355
|
+
sendTextReply,
|
|
2356
|
+
meshtastic_interface,
|
|
2357
|
+
text=reply_message,
|
|
2358
|
+
reply_id=reply_id,
|
|
2359
|
+
channelIndex=meshtastic_channel,
|
|
2360
|
+
description=f"Reply from {full_display_name} to message {reply_id}",
|
|
2361
|
+
mapping_info=mapping_info,
|
|
2362
|
+
)
|
|
2363
|
+
|
|
2364
|
+
if success:
|
|
2365
|
+
# Get queue size to determine logging approach
|
|
2366
|
+
queue_size = get_message_queue().get_queue_size()
|
|
2367
|
+
|
|
2368
|
+
if queue_size > 1:
|
|
2369
|
+
meshtastic_logger.info(
|
|
2370
|
+
f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply (queued: {queue_size} messages)"
|
|
2371
|
+
)
|
|
2372
|
+
else:
|
|
2373
|
+
meshtastic_logger.info(
|
|
2374
|
+
f"Relaying Matrix reply from {full_display_name} to radio broadcast as structured reply"
|
|
2375
|
+
)
|
|
2376
|
+
else:
|
|
2377
|
+
meshtastic_logger.error(
|
|
2378
|
+
"Failed to relay structured reply to Meshtastic"
|
|
2379
|
+
)
|
|
2380
|
+
return
|
|
2381
|
+
else:
|
|
2382
|
+
# Send as regular message (fallback for when no reply_id is available)
|
|
2383
|
+
success = queue_message(
|
|
2384
|
+
meshtastic_interface.sendText,
|
|
2385
|
+
text=reply_message,
|
|
2386
|
+
channelIndex=meshtastic_channel,
|
|
2387
|
+
description=f"Reply from {full_display_name} (fallback to regular message)",
|
|
2388
|
+
mapping_info=mapping_info,
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
if success:
|
|
2392
|
+
# Get queue size to determine logging approach
|
|
2393
|
+
queue_size = get_message_queue().get_queue_size()
|
|
2394
|
+
|
|
2395
|
+
if queue_size > 1:
|
|
2396
|
+
meshtastic_logger.info(
|
|
2397
|
+
f"Relaying Matrix reply from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
|
|
2398
|
+
)
|
|
2399
|
+
else:
|
|
2400
|
+
meshtastic_logger.info(
|
|
2401
|
+
f"Relaying Matrix reply from {full_display_name} to radio broadcast"
|
|
2402
|
+
)
|
|
2403
|
+
else:
|
|
2404
|
+
meshtastic_logger.error(
|
|
2405
|
+
"Failed to relay reply message to Meshtastic"
|
|
2406
|
+
)
|
|
2407
|
+
return
|
|
2408
|
+
|
|
2409
|
+
# Message mapping is now handled automatically by the queue system
|
|
2410
|
+
|
|
2411
|
+
except Exception:
|
|
2412
|
+
meshtastic_logger.exception("Error sending Matrix reply to Meshtastic")
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
async def handle_matrix_reply(
|
|
2416
|
+
room,
|
|
2417
|
+
event,
|
|
2418
|
+
reply_to_event_id,
|
|
2419
|
+
text,
|
|
2420
|
+
room_config,
|
|
2421
|
+
storage_enabled,
|
|
2422
|
+
local_meshnet_name,
|
|
2423
|
+
config,
|
|
2424
|
+
*,
|
|
2425
|
+
mesh_text_override=None,
|
|
2426
|
+
longname=None,
|
|
2427
|
+
shortname=None,
|
|
2428
|
+
meshnet_name=None,
|
|
2429
|
+
):
|
|
2430
|
+
"""
|
|
2431
|
+
Forward a Matrix reply to Meshtastic when the replied-to Matrix event maps to a Meshtastic message.
|
|
2432
|
+
|
|
2433
|
+
If the Matrix event identified by reply_to_event_id has an associated Meshtastic mapping, format a Meshtastic reply that preserves sender attribution and enqueue it referencing the original Meshtastic message ID. If no mapping exists, do nothing.
|
|
2434
|
+
|
|
2435
|
+
Parameters:
|
|
2436
|
+
room: Matrix room object where the reply originated.
|
|
2437
|
+
event: Matrix event object representing the reply.
|
|
2438
|
+
reply_to_event_id (str): Matrix event ID being replied to; used to locate the Meshtastic mapping.
|
|
2439
|
+
text (str): The reply text from Matrix.
|
|
2440
|
+
room_config (dict): Per-room relay configuration used when sending to Meshtastic.
|
|
2441
|
+
storage_enabled (bool): Whether message mapping/storage is enabled.
|
|
2442
|
+
local_meshnet_name (str): Local meshnet name used to determine cross-meshnet formatting.
|
|
2443
|
+
config (dict): Global relay configuration passed to formatting routines.
|
|
2444
|
+
mesh_text_override (str | None): Optional override text to send instead of the derived text.
|
|
2445
|
+
longname (str | None): Sender long display name used for prefixing.
|
|
2446
|
+
shortname (str | None): Sender short display name used for prefixing.
|
|
2447
|
+
meshnet_name (str | None): Remote meshnet name associated with the original mapping, if any.
|
|
2448
|
+
|
|
2449
|
+
Returns:
|
|
2450
|
+
bool: `True` if a mapping was found and the reply was queued to Meshtastic, `False` otherwise.
|
|
2451
|
+
"""
|
|
2452
|
+
# Look up the original message in the message map
|
|
2453
|
+
loop = asyncio.get_running_loop()
|
|
2454
|
+
orig = await loop.run_in_executor(
|
|
2455
|
+
None, get_message_map_by_matrix_event_id, reply_to_event_id
|
|
2456
|
+
)
|
|
2457
|
+
if not orig:
|
|
2458
|
+
logger.debug(
|
|
2459
|
+
f"Original message for Matrix reply not found in DB: {reply_to_event_id}"
|
|
2460
|
+
)
|
|
2461
|
+
return False # Continue processing as normal message if original not found
|
|
2462
|
+
|
|
2463
|
+
# Extract the original meshtastic_id to use as reply_id
|
|
2464
|
+
# orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
|
|
2465
|
+
original_meshtastic_id = orig[0]
|
|
2466
|
+
|
|
2467
|
+
# Get user display name
|
|
2468
|
+
full_display_name = await get_user_display_name(room, event)
|
|
2469
|
+
|
|
2470
|
+
# Format the reply message
|
|
2471
|
+
reply_message = format_reply_message(
|
|
2472
|
+
config,
|
|
2473
|
+
full_display_name,
|
|
2474
|
+
text,
|
|
2475
|
+
longname=longname,
|
|
2476
|
+
shortname=shortname,
|
|
2477
|
+
meshnet_name=meshnet_name,
|
|
2478
|
+
local_meshnet_name=local_meshnet_name,
|
|
2479
|
+
mesh_text_override=mesh_text_override,
|
|
2480
|
+
)
|
|
2481
|
+
|
|
2482
|
+
logger.info(
|
|
2483
|
+
f"Relaying Matrix reply from {full_display_name} to Meshtastic as reply to message {original_meshtastic_id}"
|
|
2484
|
+
)
|
|
2485
|
+
|
|
2486
|
+
# Send the reply to Meshtastic with the original message ID as reply_id
|
|
2487
|
+
await send_reply_to_meshtastic(
|
|
2488
|
+
reply_message,
|
|
2489
|
+
full_display_name,
|
|
2490
|
+
room_config,
|
|
2491
|
+
room,
|
|
2492
|
+
event,
|
|
2493
|
+
text,
|
|
2494
|
+
storage_enabled,
|
|
2495
|
+
local_meshnet_name,
|
|
2496
|
+
reply_id=original_meshtastic_id,
|
|
2497
|
+
)
|
|
2498
|
+
|
|
2499
|
+
return True # Reply was handled, stop further processing
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
async def on_decryption_failure(room: MatrixRoom, event: MegolmEvent) -> None:
|
|
2503
|
+
"""
|
|
2504
|
+
Handle a MegolmEvent that failed to decrypt by requesting the needed session keys.
|
|
2505
|
+
|
|
2506
|
+
If a received encrypted event cannot be decrypted, this callback logs an error and attempts to request the missing keys from the device that sent them by creating and sending a to-device key request via the module-level Matrix client. The function will:
|
|
2507
|
+
- Set event.room_id to the room's id (monkey-patch) so the key request is properly scoped.
|
|
2508
|
+
- Create a key request from the event and send it with matrix_client.to_device().
|
|
2509
|
+
- Log success or any errors encountered.
|
|
2510
|
+
|
|
2511
|
+
If the module-level Matrix client is not available, the function logs an error and returns without sending a request.
|
|
2512
|
+
"""
|
|
2513
|
+
logger.error(
|
|
2514
|
+
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'! "
|
|
2515
|
+
f"This is usually temporary and resolves on its own. "
|
|
2516
|
+
f"If this persists, the bot's session may be corrupt. "
|
|
2517
|
+
f"{msg_retry_auth_login()}."
|
|
2518
|
+
)
|
|
2519
|
+
|
|
2520
|
+
# Attempt to request the keys for the failed event
|
|
2521
|
+
try:
|
|
2522
|
+
if not matrix_client:
|
|
2523
|
+
logger.error("Matrix client not available, cannot request keys.")
|
|
2524
|
+
return
|
|
2525
|
+
|
|
2526
|
+
# Monkey-patch the event object with the correct room_id
|
|
2527
|
+
event.room_id = room.room_id
|
|
2528
|
+
|
|
2529
|
+
request = event.as_key_request(matrix_client.user_id, matrix_client.device_id)
|
|
2530
|
+
await matrix_client.to_device(request)
|
|
2531
|
+
logger.info(f"Requested keys for failed decryption of event {event.event_id}")
|
|
2532
|
+
except Exception:
|
|
2533
|
+
logger.exception(f"Failed to request keys for event {event.event_id}")
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
# Callback for new messages in Matrix room
|
|
2537
|
+
async def on_room_message(
|
|
2538
|
+
room: MatrixRoom,
|
|
2539
|
+
event: Union[
|
|
2540
|
+
RoomMessageText,
|
|
2541
|
+
RoomMessageNotice,
|
|
2542
|
+
ReactionEvent,
|
|
2543
|
+
RoomMessageEmote,
|
|
2544
|
+
],
|
|
2545
|
+
) -> None:
|
|
2546
|
+
"""
|
|
2547
|
+
Handle an incoming Matrix room event and relay it to Meshtastic when applicable.
|
|
2548
|
+
|
|
2549
|
+
Processes text, notice, emote, and reaction events for configured rooms: ignores events from before the bot started and events sent by the bot itself; respects per-room configuration and global interaction settings; routes reactions back to the originating Meshtastic message when a mapping exists (including forwarding remote-meshnet emote reactions as radio text); bridges Matrix replies to Meshtastic replies when a corresponding mapping is found and replies are enabled; relays regular Matrix messages to Meshtastic using configured prefix and truncation rules; and honours detection-sensor forwarding when enabled. Integrates with the plugin system and treats recognized bot commands as non-relayed.
|
|
2550
|
+
|
|
2551
|
+
Side effects:
|
|
2552
|
+
- May enqueue Meshtastic send operations (text or data) via the internal queue.
|
|
2553
|
+
- May read and write persistent message mappings to support reply/reaction bridging.
|
|
2554
|
+
- May call Matrix APIs (e.g., to fetch display names) and connect to Meshtastic.
|
|
2555
|
+
"""
|
|
2556
|
+
# DEBUG: Log all Matrix message events to trace reception
|
|
2557
|
+
logger.debug(
|
|
2558
|
+
f"Received Matrix event in room {room.room_id}: {type(event).__name__}"
|
|
2559
|
+
)
|
|
2560
|
+
logger.debug(
|
|
2561
|
+
f"Event details - sender: {event.sender}, timestamp: {event.server_timestamp}"
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
# Importing here to avoid circular imports and to keep logic consistent
|
|
2565
|
+
# Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
|
|
2566
|
+
from mmrelay.message_queue import get_message_queue
|
|
2567
|
+
|
|
2568
|
+
# That logic occurs inside matrix_relay if needed.
|
|
2569
|
+
full_display_name = "Unknown user"
|
|
2570
|
+
message_timestamp = event.server_timestamp
|
|
2571
|
+
|
|
2572
|
+
# We do not relay messages that occurred before the bot started
|
|
2573
|
+
if message_timestamp < bot_start_time:
|
|
2574
|
+
return
|
|
2575
|
+
|
|
2576
|
+
# Do not process messages from the bot itself
|
|
2577
|
+
if event.sender == bot_user_id:
|
|
2578
|
+
return
|
|
2579
|
+
|
|
2580
|
+
# Note: MegolmEvent (encrypted) messages are handled by the `on_decryption_failure`
|
|
2581
|
+
# callback if they fail to decrypt. Successfully decrypted messages are automatically
|
|
2582
|
+
# converted to RoomMessageText/RoomMessageNotice/etc. by matrix-nio and handled normally.
|
|
2583
|
+
|
|
2584
|
+
# Find the room_config that matches this room, if any
|
|
2585
|
+
room_config = None
|
|
2586
|
+
iterable = matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
|
|
2587
|
+
for room_conf in iterable:
|
|
2588
|
+
if isinstance(room_conf, dict) and room_conf.get("id") == room.room_id:
|
|
2589
|
+
room_config = room_conf
|
|
2590
|
+
break
|
|
2591
|
+
|
|
2592
|
+
# Only proceed if the room is supported
|
|
2593
|
+
if not room_config:
|
|
2594
|
+
return
|
|
2595
|
+
|
|
2596
|
+
relates_to = event.source["content"].get("m.relates_to")
|
|
2597
|
+
global config
|
|
2598
|
+
|
|
2599
|
+
# Check if config is available
|
|
2600
|
+
if not config:
|
|
2601
|
+
logger.error("No configuration available for Matrix message processing.")
|
|
2602
|
+
|
|
2603
|
+
is_reaction = False
|
|
2604
|
+
reaction_emoji = None
|
|
2605
|
+
original_matrix_event_id = None
|
|
2606
|
+
|
|
2607
|
+
# Check if config is available
|
|
2608
|
+
if config is None:
|
|
2609
|
+
logger.error("No configuration available. Cannot process Matrix message.")
|
|
2610
|
+
return
|
|
2611
|
+
|
|
2612
|
+
# Get interaction settings
|
|
2613
|
+
interactions = get_interaction_settings(config)
|
|
2614
|
+
storage_enabled = message_storage_enabled(interactions)
|
|
2615
|
+
|
|
2616
|
+
# Check if this is a Matrix ReactionEvent (usually m.reaction)
|
|
2617
|
+
if isinstance(event, ReactionEvent):
|
|
2618
|
+
# This is a reaction event
|
|
2619
|
+
is_reaction = True
|
|
2620
|
+
logger.debug(f"Processing Matrix reaction event: {event.source}")
|
|
2621
|
+
if relates_to and "event_id" in relates_to and "key" in relates_to:
|
|
2622
|
+
# Extract the reaction emoji and the original event it relates to
|
|
2623
|
+
reaction_emoji = relates_to["key"]
|
|
2624
|
+
original_matrix_event_id = relates_to["event_id"]
|
|
2625
|
+
logger.debug(
|
|
2626
|
+
f"Original matrix event ID: {original_matrix_event_id}, Reaction emoji: {reaction_emoji}"
|
|
2627
|
+
)
|
|
2628
|
+
|
|
2629
|
+
# Check if this is a Matrix RoomMessageEmote (m.emote)
|
|
2630
|
+
if isinstance(event, RoomMessageEmote):
|
|
2631
|
+
logger.debug(f"Processing Matrix reaction event: {event.source}")
|
|
2632
|
+
# For RoomMessageEmote, treat as remote reaction if meshtastic_replyId exists
|
|
2633
|
+
is_reaction = True
|
|
2634
|
+
# We need to manually extract the reaction emoji from the body
|
|
2635
|
+
reaction_body = event.source["content"].get("body", "")
|
|
2636
|
+
reaction_match = re.search(r"reacted (.+?) to", reaction_body)
|
|
2637
|
+
reaction_emoji = reaction_match.group(1).strip() if reaction_match else "?"
|
|
2638
|
+
|
|
2639
|
+
text = event.body.strip() if (not is_reaction and hasattr(event, "body")) else ""
|
|
2640
|
+
|
|
2641
|
+
# Some Matrix relays (especially Meshtastic bridges) provide the raw mesh
|
|
2642
|
+
# payload alongside the formatted body. Prefer that when available so we do
|
|
2643
|
+
# not lose content if the formatted text is empty or stripped unexpectedly.
|
|
2644
|
+
mesh_text_override = event.source["content"].get("meshtastic_text")
|
|
2645
|
+
if isinstance(mesh_text_override, str):
|
|
2646
|
+
mesh_text_override = mesh_text_override.strip()
|
|
2647
|
+
if not mesh_text_override:
|
|
2648
|
+
mesh_text_override = None
|
|
2649
|
+
else:
|
|
2650
|
+
mesh_text_override = None
|
|
2651
|
+
|
|
2652
|
+
longname = event.source["content"].get("meshtastic_longname")
|
|
2653
|
+
shortname = event.source["content"].get("meshtastic_shortname", None)
|
|
2654
|
+
meshnet_name = event.source["content"].get("meshtastic_meshnet")
|
|
2655
|
+
meshtastic_replyId = event.source["content"].get("meshtastic_replyId")
|
|
2656
|
+
suppress = event.source["content"].get("mmrelay_suppress")
|
|
2657
|
+
|
|
2658
|
+
# If a message has suppress flag, do not process
|
|
2659
|
+
if suppress:
|
|
2660
|
+
return
|
|
2661
|
+
|
|
2662
|
+
# If this is a reaction and reactions are disabled, do nothing
|
|
2663
|
+
if is_reaction and not interactions["reactions"]:
|
|
2664
|
+
logger.debug(
|
|
2665
|
+
"Reaction event encountered but reactions are disabled. Doing nothing."
|
|
2666
|
+
)
|
|
2667
|
+
return
|
|
2668
|
+
|
|
2669
|
+
local_meshnet_name = config["meshtastic"]["meshnet_name"]
|
|
2670
|
+
|
|
2671
|
+
# Check if this is a Matrix reply (not a reaction)
|
|
2672
|
+
is_reply = False
|
|
2673
|
+
reply_to_event_id = None
|
|
2674
|
+
if not is_reaction and relates_to and "m.in_reply_to" in relates_to:
|
|
2675
|
+
reply_to_event_id = relates_to["m.in_reply_to"].get("event_id")
|
|
2676
|
+
if reply_to_event_id:
|
|
2677
|
+
is_reply = True
|
|
2678
|
+
logger.debug(f"Processing Matrix reply to event: {reply_to_event_id}")
|
|
2679
|
+
|
|
2680
|
+
# If this is a reaction and reactions are enabled, attempt to relay it
|
|
2681
|
+
if is_reaction and interactions["reactions"]:
|
|
2682
|
+
# Check if we need to relay a reaction from a remote meshnet to our local meshnet.
|
|
2683
|
+
# If meshnet_name != local_meshnet_name and meshtastic_replyId is present and this is an emote,
|
|
2684
|
+
# it's a remote reaction that needs to be forwarded as a text message describing the reaction.
|
|
2685
|
+
if (
|
|
2686
|
+
meshnet_name
|
|
2687
|
+
and meshnet_name != local_meshnet_name
|
|
2688
|
+
and meshtastic_replyId
|
|
2689
|
+
and isinstance(event, RoomMessageEmote)
|
|
2690
|
+
):
|
|
2691
|
+
logger.info(f"Relaying reaction from remote meshnet: {meshnet_name}")
|
|
2692
|
+
|
|
2693
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
2694
|
+
|
|
2695
|
+
# Format the reaction message for relaying to the local meshnet.
|
|
2696
|
+
# The necessary information is in the m.emote event
|
|
2697
|
+
if not shortname:
|
|
2698
|
+
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
2699
|
+
|
|
2700
|
+
meshtastic_text_db = event.source["content"].get("meshtastic_text", "")
|
|
2701
|
+
# Strip out any quoted lines from the text
|
|
2702
|
+
meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
|
|
2703
|
+
meshtastic_text_db = meshtastic_text_db.replace("\n", " ").replace(
|
|
2704
|
+
"\r", " "
|
|
2705
|
+
)
|
|
2706
|
+
|
|
2707
|
+
abbreviated_text = (
|
|
2708
|
+
meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
|
|
2709
|
+
if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
|
|
2710
|
+
else meshtastic_text_db
|
|
2711
|
+
)
|
|
2712
|
+
|
|
2713
|
+
reaction_message = f'{shortname}/{short_meshnet_name} reacted {reaction_emoji} to "{abbreviated_text}"'
|
|
2714
|
+
|
|
2715
|
+
# Relay the remote reaction to the local meshnet.
|
|
2716
|
+
loop = asyncio.get_running_loop()
|
|
2717
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2718
|
+
if not meshtastic_interface:
|
|
2719
|
+
logger.error(
|
|
2720
|
+
"Failed to connect to Meshtastic for remote reaction relay"
|
|
2721
|
+
)
|
|
2722
|
+
return
|
|
2723
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
2724
|
+
|
|
2725
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
2726
|
+
|
|
2727
|
+
if get_meshtastic_config_value(
|
|
2728
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2729
|
+
):
|
|
2730
|
+
meshtastic_logger.info(
|
|
2731
|
+
f"Relaying reaction from remote meshnet {meshnet_name} to radio broadcast"
|
|
2732
|
+
)
|
|
2733
|
+
logger.debug(
|
|
2734
|
+
f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
|
|
2735
|
+
)
|
|
2736
|
+
success = queue_message(
|
|
2737
|
+
meshtastic_interface.sendText,
|
|
2738
|
+
text=reaction_message,
|
|
2739
|
+
channelIndex=meshtastic_channel,
|
|
2740
|
+
description=f"Remote reaction from {meshnet_name}",
|
|
2741
|
+
)
|
|
2742
|
+
|
|
2743
|
+
if success:
|
|
2744
|
+
logger.debug(
|
|
2745
|
+
f"Queued remote reaction to Meshtastic: {reaction_message}"
|
|
2746
|
+
)
|
|
2747
|
+
else:
|
|
2748
|
+
logger.error("Failed to relay remote reaction to Meshtastic")
|
|
2749
|
+
return
|
|
2750
|
+
# We've relayed the remote reaction to our local mesh, so we're done.
|
|
2751
|
+
return
|
|
2752
|
+
|
|
2753
|
+
# If original_matrix_event_id is set, this is a reaction to some other matrix event
|
|
2754
|
+
if original_matrix_event_id:
|
|
2755
|
+
orig = get_message_map_by_matrix_event_id(original_matrix_event_id)
|
|
2756
|
+
if not orig:
|
|
2757
|
+
# If we don't find the original message in the DB, we suspect it's a reaction-to-reaction scenario
|
|
2758
|
+
logger.debug(
|
|
2759
|
+
"Original message for reaction not found in DB. Possibly a reaction-to-reaction scenario. Not forwarding."
|
|
2760
|
+
)
|
|
2761
|
+
return
|
|
2762
|
+
|
|
2763
|
+
# orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
|
|
2764
|
+
meshtastic_id, matrix_room_id, meshtastic_text_db, meshtastic_meshnet_db = (
|
|
2765
|
+
orig
|
|
2766
|
+
)
|
|
2767
|
+
# Get room-specific display name if available, fallback to global display name
|
|
2768
|
+
room_display_name = room.user_name(event.sender)
|
|
2769
|
+
if room_display_name:
|
|
2770
|
+
full_display_name = room_display_name
|
|
2771
|
+
else:
|
|
2772
|
+
# Fallback to global display name if room-specific name is not available
|
|
2773
|
+
display_name_response = await matrix_client.get_displayname(
|
|
2774
|
+
event.sender
|
|
2775
|
+
)
|
|
2776
|
+
full_display_name = display_name_response.displayname or event.sender
|
|
2777
|
+
|
|
2778
|
+
# If not from a remote meshnet, proceed as normal to relay back to the originating meshnet
|
|
2779
|
+
prefix = get_meshtastic_prefix(config, full_display_name)
|
|
2780
|
+
|
|
2781
|
+
# Remove quoted lines so we don't bring in the original '>' lines from replies
|
|
2782
|
+
meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
|
|
2783
|
+
meshtastic_text_db = meshtastic_text_db.replace("\n", " ").replace(
|
|
2784
|
+
"\r", " "
|
|
2785
|
+
)
|
|
2786
|
+
|
|
2787
|
+
abbreviated_text = (
|
|
2788
|
+
meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
|
|
2789
|
+
if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
|
|
2790
|
+
else meshtastic_text_db
|
|
2791
|
+
)
|
|
2792
|
+
|
|
2793
|
+
# Always use our local meshnet_name for outgoing events
|
|
2794
|
+
reaction_message = (
|
|
2795
|
+
f'{prefix}reacted {reaction_emoji} to "{abbreviated_text}"'
|
|
2796
|
+
)
|
|
2797
|
+
loop = asyncio.get_running_loop()
|
|
2798
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2799
|
+
if not meshtastic_interface:
|
|
2800
|
+
logger.error("Failed to connect to Meshtastic for local reaction relay")
|
|
2801
|
+
return
|
|
2802
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
2803
|
+
|
|
2804
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
2805
|
+
|
|
2806
|
+
if get_meshtastic_config_value(
|
|
2807
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2808
|
+
):
|
|
2809
|
+
meshtastic_logger.info(
|
|
2810
|
+
f"Relaying reaction from {full_display_name} to radio broadcast"
|
|
2811
|
+
)
|
|
2812
|
+
logger.debug(
|
|
2813
|
+
f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
|
|
2814
|
+
)
|
|
2815
|
+
success = queue_message(
|
|
2816
|
+
meshtastic_interface.sendText,
|
|
2817
|
+
text=reaction_message,
|
|
2818
|
+
channelIndex=meshtastic_channel,
|
|
2819
|
+
description=f"Local reaction from {full_display_name}",
|
|
2820
|
+
)
|
|
2821
|
+
|
|
2822
|
+
if success:
|
|
2823
|
+
logger.debug(
|
|
2824
|
+
f"Queued local reaction to Meshtastic: {reaction_message}"
|
|
2825
|
+
)
|
|
2826
|
+
else:
|
|
2827
|
+
logger.error("Failed to relay local reaction to Meshtastic")
|
|
2828
|
+
return
|
|
2829
|
+
return
|
|
2830
|
+
|
|
2831
|
+
# Handle Matrix replies to Meshtastic messages (only if replies are enabled)
|
|
2832
|
+
if is_reply and reply_to_event_id and interactions["replies"]:
|
|
2833
|
+
reply_handled = await handle_matrix_reply(
|
|
2834
|
+
room,
|
|
2835
|
+
event,
|
|
2836
|
+
reply_to_event_id,
|
|
2837
|
+
text,
|
|
2838
|
+
room_config,
|
|
2839
|
+
storage_enabled,
|
|
2840
|
+
local_meshnet_name,
|
|
2841
|
+
config,
|
|
2842
|
+
mesh_text_override=mesh_text_override,
|
|
2843
|
+
longname=longname,
|
|
2844
|
+
shortname=shortname,
|
|
2845
|
+
meshnet_name=meshnet_name,
|
|
2846
|
+
)
|
|
2847
|
+
if reply_handled:
|
|
2848
|
+
return
|
|
2849
|
+
|
|
2850
|
+
# For Matrix->Mesh messages from a remote meshnet, rewrite the message format
|
|
2851
|
+
if longname and meshnet_name:
|
|
2852
|
+
# Always include the meshnet_name in the full display name.
|
|
2853
|
+
full_display_name = f"{longname}/{meshnet_name}"
|
|
2854
|
+
|
|
2855
|
+
if meshnet_name != local_meshnet_name:
|
|
2856
|
+
# A message from a remote meshnet relayed into Matrix, now going back out
|
|
2857
|
+
logger.info(f"Processing message from remote meshnet: {meshnet_name}")
|
|
2858
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
2859
|
+
# If shortname is not available, derive it from the longname
|
|
2860
|
+
if shortname is None:
|
|
2861
|
+
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
2862
|
+
if mesh_text_override:
|
|
2863
|
+
text = mesh_text_override
|
|
2864
|
+
# Remove the original prefix to avoid double-tagging
|
|
2865
|
+
# Get the prefix that would have been used for this message
|
|
2866
|
+
original_prefix = get_matrix_prefix(
|
|
2867
|
+
config, longname, shortname, meshnet_name
|
|
2868
|
+
)
|
|
2869
|
+
if original_prefix and text.startswith(original_prefix):
|
|
2870
|
+
text = text[len(original_prefix) :]
|
|
2871
|
+
logger.debug(
|
|
2872
|
+
f"Removed original prefix '{original_prefix}' from remote meshnet message"
|
|
2873
|
+
)
|
|
2874
|
+
if not text and mesh_text_override:
|
|
2875
|
+
text = mesh_text_override
|
|
2876
|
+
text = truncate_message(text)
|
|
2877
|
+
# Use the configured prefix format for remote meshnet messages
|
|
2878
|
+
prefix = get_matrix_prefix(config, longname, shortname, short_meshnet_name)
|
|
2879
|
+
full_message = f"{prefix}{text}"
|
|
2880
|
+
if not text:
|
|
2881
|
+
logger.warning(
|
|
2882
|
+
"Remote meshnet message from %s had empty text after formatting; skipping relay",
|
|
2883
|
+
meshnet_name,
|
|
2884
|
+
)
|
|
2885
|
+
return
|
|
2886
|
+
else:
|
|
2887
|
+
# If this message is from our local meshnet (loopback), we ignore it
|
|
2888
|
+
return
|
|
2889
|
+
else:
|
|
2890
|
+
# Normal Matrix message from a Matrix user
|
|
2891
|
+
# Get room-specific display name if available, fallback to global display name
|
|
2892
|
+
room_display_name = room.user_name(event.sender)
|
|
2893
|
+
if room_display_name:
|
|
2894
|
+
full_display_name = room_display_name
|
|
2895
|
+
else:
|
|
2896
|
+
# Fallback to global display name if room-specific name is not available
|
|
2897
|
+
display_name_response = await matrix_client.get_displayname(event.sender)
|
|
2898
|
+
full_display_name = display_name_response.displayname or event.sender
|
|
2899
|
+
prefix = get_meshtastic_prefix(config, full_display_name, event.sender)
|
|
2900
|
+
logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
|
|
2901
|
+
full_message = f"{prefix}{text}"
|
|
2902
|
+
full_message = truncate_message(full_message)
|
|
2903
|
+
|
|
2904
|
+
# Plugin functionality
|
|
2905
|
+
from mmrelay.plugin_loader import load_plugins
|
|
2906
|
+
|
|
2907
|
+
plugins = load_plugins()
|
|
2908
|
+
|
|
2909
|
+
found_matching_plugin = False
|
|
2910
|
+
for plugin in plugins:
|
|
2911
|
+
if not found_matching_plugin:
|
|
2912
|
+
try:
|
|
2913
|
+
found_matching_plugin = await plugin.handle_room_message(
|
|
2914
|
+
room, event, full_message
|
|
2915
|
+
)
|
|
2916
|
+
if found_matching_plugin:
|
|
2917
|
+
logger.info(
|
|
2918
|
+
f"Processed command with plugin: {plugin.plugin_name} from {event.sender}"
|
|
2919
|
+
)
|
|
2920
|
+
except Exception as e:
|
|
2921
|
+
logger.error(
|
|
2922
|
+
f"Error processing message with plugin {plugin.plugin_name}: {e}"
|
|
2923
|
+
)
|
|
2924
|
+
|
|
2925
|
+
# Check if the message is a command directed at the bot
|
|
2926
|
+
is_command = False
|
|
2927
|
+
for plugin in plugins:
|
|
2928
|
+
for command in plugin.get_matrix_commands():
|
|
2929
|
+
if bot_command(command, event):
|
|
2930
|
+
is_command = True
|
|
2931
|
+
break
|
|
2932
|
+
if is_command:
|
|
2933
|
+
break
|
|
2934
|
+
|
|
2935
|
+
# If this is a command, we do not send it to the mesh
|
|
2936
|
+
if is_command:
|
|
2937
|
+
logger.debug("Message is a command, not sending to mesh")
|
|
2938
|
+
return
|
|
2939
|
+
|
|
2940
|
+
# Connect to Meshtastic
|
|
2941
|
+
loop = asyncio.get_running_loop()
|
|
2942
|
+
meshtastic_interface = await loop.run_in_executor(None, connect_meshtastic)
|
|
2943
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
2944
|
+
|
|
2945
|
+
if not meshtastic_interface:
|
|
2946
|
+
logger.error("Failed to connect to Meshtastic. Cannot relay message.")
|
|
2947
|
+
return
|
|
2948
|
+
|
|
2949
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
2950
|
+
|
|
2951
|
+
# If message is from Matrix and broadcast_enabled is True, relay to Meshtastic
|
|
2952
|
+
# Note: If relay_reactions is False, we won't store message_map, but we can still relay.
|
|
2953
|
+
# The lack of message_map storage just means no reaction bridging will occur.
|
|
2954
|
+
if not found_matching_plugin:
|
|
2955
|
+
if get_meshtastic_config_value(
|
|
2956
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2957
|
+
):
|
|
2958
|
+
portnum = event.source["content"].get("meshtastic_portnum")
|
|
2959
|
+
if portnum == DETECTION_SENSOR_APP:
|
|
2960
|
+
# If detection_sensor is enabled, forward this data as detection sensor data
|
|
2961
|
+
if get_meshtastic_config_value(
|
|
2962
|
+
config, "detection_sensor", DEFAULT_DETECTION_SENSOR
|
|
2963
|
+
):
|
|
2964
|
+
# Import meshtastic protobuf only when needed to delay logger creation
|
|
2965
|
+
import meshtastic.protobuf.portnums_pb2
|
|
2966
|
+
|
|
2967
|
+
success = queue_message(
|
|
2968
|
+
meshtastic_interface.sendData,
|
|
2969
|
+
data=full_message.encode("utf-8"),
|
|
2970
|
+
channelIndex=meshtastic_channel,
|
|
2971
|
+
portNum=meshtastic.protobuf.portnums_pb2.PortNum.DETECTION_SENSOR_APP,
|
|
2972
|
+
description=f"Detection sensor data from {full_display_name}",
|
|
2973
|
+
)
|
|
2974
|
+
|
|
2975
|
+
if success:
|
|
2976
|
+
# Get queue size to determine logging approach
|
|
2977
|
+
queue_size = get_message_queue().get_queue_size()
|
|
2978
|
+
|
|
2979
|
+
if queue_size > 1:
|
|
2980
|
+
meshtastic_logger.info(
|
|
2981
|
+
f"Relaying detection sensor data from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
|
|
2982
|
+
)
|
|
2983
|
+
else:
|
|
2984
|
+
meshtastic_logger.info(
|
|
2985
|
+
f"Relaying detection sensor data from {full_display_name} to radio broadcast"
|
|
2986
|
+
)
|
|
2987
|
+
# Note: Detection sensor messages are not stored in message_map because they are never replied to
|
|
2988
|
+
# Only TEXT_MESSAGE_APP messages need to be stored for reaction handling
|
|
2989
|
+
else:
|
|
2990
|
+
meshtastic_logger.error(
|
|
2991
|
+
"Failed to relay detection sensor data to Meshtastic"
|
|
2992
|
+
)
|
|
2993
|
+
return
|
|
2994
|
+
else:
|
|
2995
|
+
meshtastic_logger.debug(
|
|
2996
|
+
f"Detection sensor packet received from {full_display_name}, but detection sensor processing is disabled."
|
|
2997
|
+
)
|
|
2998
|
+
else:
|
|
2999
|
+
# Regular text message - logging will be handled by queue success handler
|
|
3000
|
+
pass
|
|
3001
|
+
|
|
3002
|
+
# Create mapping info if storage is enabled
|
|
3003
|
+
mapping_info = None
|
|
3004
|
+
if storage_enabled:
|
|
3005
|
+
# Check database config for message map settings (preferred format)
|
|
3006
|
+
msgs_to_keep = _get_msgs_to_keep_config()
|
|
3007
|
+
|
|
3008
|
+
mapping_info = _create_mapping_info(
|
|
3009
|
+
event.event_id,
|
|
3010
|
+
room.room_id,
|
|
3011
|
+
text,
|
|
3012
|
+
local_meshnet_name,
|
|
3013
|
+
msgs_to_keep,
|
|
3014
|
+
)
|
|
3015
|
+
|
|
3016
|
+
success = queue_message(
|
|
3017
|
+
meshtastic_interface.sendText,
|
|
3018
|
+
text=full_message,
|
|
3019
|
+
channelIndex=meshtastic_channel,
|
|
3020
|
+
description=f"Message from {full_display_name}",
|
|
3021
|
+
mapping_info=mapping_info,
|
|
3022
|
+
)
|
|
3023
|
+
|
|
3024
|
+
if success:
|
|
3025
|
+
# Get queue size to determine logging approach
|
|
3026
|
+
queue_size = get_message_queue().get_queue_size()
|
|
3027
|
+
|
|
3028
|
+
if queue_size > 1:
|
|
3029
|
+
meshtastic_logger.info(
|
|
3030
|
+
f"Relaying message from {full_display_name} to radio broadcast (queued: {queue_size} messages)"
|
|
3031
|
+
)
|
|
3032
|
+
else:
|
|
3033
|
+
meshtastic_logger.info(
|
|
3034
|
+
f"Relaying message from {full_display_name} to radio broadcast"
|
|
3035
|
+
)
|
|
3036
|
+
else:
|
|
3037
|
+
meshtastic_logger.error("Failed to relay message to Meshtastic")
|
|
3038
|
+
return
|
|
3039
|
+
# Message mapping is now handled automatically by the queue system
|
|
3040
|
+
else:
|
|
3041
|
+
logger.debug(
|
|
3042
|
+
f"broadcast_enabled is False - not relaying message from {full_display_name} to Meshtastic"
|
|
3043
|
+
)
|
|
3044
|
+
|
|
3045
|
+
|
|
3046
|
+
async def upload_image(
|
|
3047
|
+
client: AsyncClient, image: Image.Image, filename: str
|
|
3048
|
+
) -> UploadResponse:
|
|
3049
|
+
"""
|
|
3050
|
+
Uploads an image to Matrix and returns the UploadResponse containing the content URI.
|
|
3051
|
+
"""
|
|
3052
|
+
buffer = io.BytesIO()
|
|
3053
|
+
image.save(buffer, format="PNG")
|
|
3054
|
+
image_data = buffer.getvalue()
|
|
3055
|
+
|
|
3056
|
+
response, maybe_keys = await client.upload(
|
|
3057
|
+
io.BytesIO(image_data),
|
|
3058
|
+
content_type="image/png",
|
|
3059
|
+
filename=filename,
|
|
3060
|
+
filesize=len(image_data),
|
|
3061
|
+
)
|
|
3062
|
+
|
|
3063
|
+
return response
|
|
3064
|
+
|
|
3065
|
+
|
|
3066
|
+
async def send_room_image(
|
|
3067
|
+
client: AsyncClient, room_id: str, upload_response: UploadResponse
|
|
3068
|
+
):
|
|
3069
|
+
"""
|
|
3070
|
+
Sends an already uploaded image to the specified room.
|
|
3071
|
+
"""
|
|
3072
|
+
await client.room_send(
|
|
3073
|
+
room_id=room_id,
|
|
3074
|
+
message_type="m.room.message",
|
|
3075
|
+
content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""},
|
|
3076
|
+
)
|
|
3077
|
+
|
|
3078
|
+
|
|
3079
|
+
async def on_room_member(room: MatrixRoom, event: RoomMemberEvent) -> None:
|
|
3080
|
+
"""
|
|
3081
|
+
Callback to handle room member events, specifically tracking room-specific display name changes.
|
|
3082
|
+
This ensures we detect when users update their display names in specific rooms.
|
|
3083
|
+
|
|
3084
|
+
Note: This callback doesn't need to do any explicit processing since matrix-nio
|
|
3085
|
+
automatically updates the room state and room.user_name() will return the
|
|
3086
|
+
updated room-specific display name immediately after this event.
|
|
3087
|
+
"""
|
|
3088
|
+
# The callback is registered to ensure matrix-nio processes the event,
|
|
3089
|
+
# but no explicit action is needed since room.user_name() automatically
|
|
3090
|
+
# handles room-specific display names after the room state is updated.
|
|
3091
|
+
pass
|