mmrelay 1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +9 -0
- mmrelay/cli.py +384 -0
- mmrelay/config.py +218 -0
- mmrelay/config_checker.py +133 -0
- mmrelay/db_utils.py +309 -0
- mmrelay/log_utils.py +107 -0
- mmrelay/main.py +281 -0
- mmrelay/matrix_utils.py +754 -0
- mmrelay/meshtastic_utils.py +569 -0
- mmrelay/plugin_loader.py +336 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +212 -0
- mmrelay/plugins/debug_plugin.py +17 -0
- mmrelay/plugins/drop_plugin.py +120 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +55 -0
- mmrelay/plugins/map_plugin.py +323 -0
- mmrelay/plugins/mesh_relay_plugin.py +134 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +118 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +208 -0
- mmrelay/setup_utils.py +263 -0
- mmrelay-1.0.dist-info/METADATA +160 -0
- mmrelay-1.0.dist-info/RECORD +29 -0
- mmrelay-1.0.dist-info/WHEEL +5 -0
- mmrelay-1.0.dist-info/entry_points.txt +2 -0
- mmrelay-1.0.dist-info/licenses/LICENSE +21 -0
- mmrelay-1.0.dist-info/top_level.txt +1 -0
mmrelay/matrix_utils.py
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import re
|
|
4
|
+
import ssl
|
|
5
|
+
import time
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
import certifi
|
|
9
|
+
import meshtastic.protobuf.portnums_pb2
|
|
10
|
+
from nio import (
|
|
11
|
+
AsyncClient,
|
|
12
|
+
AsyncClientConfig,
|
|
13
|
+
MatrixRoom,
|
|
14
|
+
ReactionEvent,
|
|
15
|
+
RoomMessageEmote,
|
|
16
|
+
RoomMessageNotice,
|
|
17
|
+
RoomMessageText,
|
|
18
|
+
UploadResponse,
|
|
19
|
+
WhoamiError,
|
|
20
|
+
)
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
from mmrelay.db_utils import (
|
|
24
|
+
get_message_map_by_matrix_event_id,
|
|
25
|
+
prune_message_map,
|
|
26
|
+
store_message_map,
|
|
27
|
+
)
|
|
28
|
+
from mmrelay.log_utils import get_logger
|
|
29
|
+
|
|
30
|
+
# Do not import plugin_loader here to avoid circular imports
|
|
31
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
32
|
+
|
|
33
|
+
# Global config variable that will be set from config.py
|
|
34
|
+
config = None
|
|
35
|
+
|
|
36
|
+
# These will be set in connect_matrix()
|
|
37
|
+
matrix_homeserver = None
|
|
38
|
+
matrix_rooms = None
|
|
39
|
+
matrix_access_token = None
|
|
40
|
+
bot_user_id = None
|
|
41
|
+
bot_user_name = None # Detected upon logon
|
|
42
|
+
bot_start_time = int(
|
|
43
|
+
time.time() * 1000
|
|
44
|
+
) # Timestamp when the bot starts, used to filter out old messages
|
|
45
|
+
|
|
46
|
+
logger = get_logger(name="Matrix")
|
|
47
|
+
|
|
48
|
+
matrix_client = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def bot_command(command, event):
|
|
52
|
+
"""
|
|
53
|
+
Checks if the given command is directed at the bot,
|
|
54
|
+
accounting for variations in different Matrix clients.
|
|
55
|
+
"""
|
|
56
|
+
full_message = event.body.strip()
|
|
57
|
+
content = event.source.get("content", {})
|
|
58
|
+
formatted_body = content.get("formatted_body", "")
|
|
59
|
+
|
|
60
|
+
# Remove HTML tags and extract the text content
|
|
61
|
+
text_content = re.sub(r"<[^>]+>", "", formatted_body).strip()
|
|
62
|
+
|
|
63
|
+
# Check for simple !command format first
|
|
64
|
+
if full_message.startswith(f"!{command}") or text_content.startswith(f"!{command}"):
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# Check if the message starts with bot_user_id or bot_user_name
|
|
68
|
+
if full_message.startswith(bot_user_id) or text_content.startswith(bot_user_id):
|
|
69
|
+
# Construct a regex pattern to match variations of bot mention and command
|
|
70
|
+
pattern = rf"^(?:{re.escape(bot_user_id)}|{re.escape(bot_user_name)}|[#@].+?)[,:;]?\s*!{command}"
|
|
71
|
+
return bool(re.match(pattern, full_message)) or bool(
|
|
72
|
+
re.match(pattern, text_content)
|
|
73
|
+
)
|
|
74
|
+
elif full_message.startswith(bot_user_name) or text_content.startswith(
|
|
75
|
+
bot_user_name
|
|
76
|
+
):
|
|
77
|
+
# Construct a regex pattern to match variations of bot mention and command
|
|
78
|
+
pattern = rf"^(?:{re.escape(bot_user_id)}|{re.escape(bot_user_name)}|[#@].+?)[,:;]?\s*!{command}"
|
|
79
|
+
return bool(re.match(pattern, full_message)) or bool(
|
|
80
|
+
re.match(pattern, text_content)
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def connect_matrix(passed_config=None):
|
|
87
|
+
"""
|
|
88
|
+
Establish a connection to the Matrix homeserver.
|
|
89
|
+
Sets global matrix_client and detects the bot's display name.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
passed_config: The configuration dictionary to use (will update global config)
|
|
93
|
+
"""
|
|
94
|
+
global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
|
|
95
|
+
|
|
96
|
+
# Update the global config if a config is passed
|
|
97
|
+
if passed_config is not None:
|
|
98
|
+
config = passed_config
|
|
99
|
+
|
|
100
|
+
# Check if config is available
|
|
101
|
+
if config is None:
|
|
102
|
+
logger.error("No configuration available. Cannot connect to Matrix.")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Extract Matrix configuration
|
|
106
|
+
matrix_homeserver = config["matrix"]["homeserver"]
|
|
107
|
+
matrix_rooms = config["matrix_rooms"]
|
|
108
|
+
matrix_access_token = config["matrix"]["access_token"]
|
|
109
|
+
bot_user_id = config["matrix"]["bot_user_id"]
|
|
110
|
+
|
|
111
|
+
# Check if client already exists
|
|
112
|
+
if matrix_client:
|
|
113
|
+
return matrix_client
|
|
114
|
+
|
|
115
|
+
# Create SSL context using certifi's certificates
|
|
116
|
+
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
117
|
+
|
|
118
|
+
# Initialize the Matrix client with custom SSL context
|
|
119
|
+
client_config = AsyncClientConfig(encryption_enabled=False)
|
|
120
|
+
matrix_client = AsyncClient(
|
|
121
|
+
homeserver=matrix_homeserver,
|
|
122
|
+
user=bot_user_id,
|
|
123
|
+
config=client_config,
|
|
124
|
+
ssl=ssl_context,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Set the access_token and user_id
|
|
128
|
+
matrix_client.access_token = matrix_access_token
|
|
129
|
+
matrix_client.user_id = bot_user_id
|
|
130
|
+
|
|
131
|
+
# Attempt to retrieve the device_id using whoami()
|
|
132
|
+
whoami_response = await matrix_client.whoami()
|
|
133
|
+
if isinstance(whoami_response, WhoamiError):
|
|
134
|
+
logger.error(f"Failed to retrieve device_id: {whoami_response.message}")
|
|
135
|
+
matrix_client.device_id = None
|
|
136
|
+
else:
|
|
137
|
+
matrix_client.device_id = whoami_response.device_id
|
|
138
|
+
if matrix_client.device_id:
|
|
139
|
+
logger.debug(f"Retrieved device_id: {matrix_client.device_id}")
|
|
140
|
+
else:
|
|
141
|
+
logger.warning("device_id not returned by whoami()")
|
|
142
|
+
|
|
143
|
+
# Fetch the bot's display name
|
|
144
|
+
response = await matrix_client.get_displayname(bot_user_id)
|
|
145
|
+
if hasattr(response, "displayname"):
|
|
146
|
+
bot_user_name = response.displayname
|
|
147
|
+
else:
|
|
148
|
+
bot_user_name = bot_user_id # Fallback if display name is not set
|
|
149
|
+
|
|
150
|
+
return matrix_client
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
154
|
+
"""Join a Matrix room by its ID or alias."""
|
|
155
|
+
try:
|
|
156
|
+
if room_id_or_alias.startswith("#"):
|
|
157
|
+
# If it's a room alias, resolve it to a room ID
|
|
158
|
+
response = await matrix_client.room_resolve_alias(room_id_or_alias)
|
|
159
|
+
if not response.room_id:
|
|
160
|
+
logger.error(
|
|
161
|
+
f"Failed to resolve room alias '{room_id_or_alias}': {response.message}"
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
room_id = response.room_id
|
|
165
|
+
# Update the room ID in the matrix_rooms list
|
|
166
|
+
for room_config in matrix_rooms:
|
|
167
|
+
if room_config["id"] == room_id_or_alias:
|
|
168
|
+
room_config["id"] = room_id
|
|
169
|
+
break
|
|
170
|
+
else:
|
|
171
|
+
room_id = room_id_or_alias
|
|
172
|
+
|
|
173
|
+
# Attempt to join the room if not already joined
|
|
174
|
+
if room_id not in matrix_client.rooms:
|
|
175
|
+
response = await matrix_client.join(room_id)
|
|
176
|
+
if response and hasattr(response, "room_id"):
|
|
177
|
+
logger.info(f"Joined room '{room_id_or_alias}' successfully")
|
|
178
|
+
else:
|
|
179
|
+
logger.error(
|
|
180
|
+
f"Failed to join room '{room_id_or_alias}': {response.message}"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
logger.debug(f"Bot is already in room '{room_id_or_alias}'")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def matrix_relay(
|
|
189
|
+
room_id,
|
|
190
|
+
message,
|
|
191
|
+
longname,
|
|
192
|
+
shortname,
|
|
193
|
+
meshnet_name,
|
|
194
|
+
portnum,
|
|
195
|
+
meshtastic_id=None,
|
|
196
|
+
meshtastic_replyId=None,
|
|
197
|
+
meshtastic_text=None,
|
|
198
|
+
emote=False,
|
|
199
|
+
emoji=False,
|
|
200
|
+
):
|
|
201
|
+
"""
|
|
202
|
+
Relay a message from Meshtastic to Matrix, optionally storing message maps.
|
|
203
|
+
|
|
204
|
+
IMPORTANT CHANGE: Now, we only store message maps if `relay_reactions` is True.
|
|
205
|
+
If `relay_reactions` is False, we skip storing to the message map entirely.
|
|
206
|
+
This helps maintain privacy and prevents message_map usage unless needed.
|
|
207
|
+
|
|
208
|
+
Additionally, if `msgs_to_keep` > 0, we prune the oldest messages after storing
|
|
209
|
+
to prevent database bloat and maintain privacy.
|
|
210
|
+
"""
|
|
211
|
+
global config
|
|
212
|
+
|
|
213
|
+
# Log the current state of the config
|
|
214
|
+
logger.debug(f"matrix_relay: config is {'available' if config else 'None'}")
|
|
215
|
+
|
|
216
|
+
matrix_client = await connect_matrix()
|
|
217
|
+
|
|
218
|
+
# Check if config is available
|
|
219
|
+
if config is None:
|
|
220
|
+
logger.error("No configuration available. Cannot relay message to Matrix.")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Retrieve relay_reactions configuration; default to False now if not specified.
|
|
224
|
+
relay_reactions = config["meshtastic"].get("relay_reactions", False)
|
|
225
|
+
|
|
226
|
+
# Retrieve db config for message_map pruning
|
|
227
|
+
# Check database config for message map settings (preferred format)
|
|
228
|
+
database_config = config.get("database", {})
|
|
229
|
+
msg_map_config = database_config.get("msg_map", {})
|
|
230
|
+
|
|
231
|
+
# If not found in database config, check legacy db config
|
|
232
|
+
if not msg_map_config:
|
|
233
|
+
db_config = config.get("db", {})
|
|
234
|
+
legacy_msg_map_config = db_config.get("msg_map", {})
|
|
235
|
+
|
|
236
|
+
if legacy_msg_map_config:
|
|
237
|
+
msg_map_config = legacy_msg_map_config
|
|
238
|
+
logger.warning(
|
|
239
|
+
"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."
|
|
240
|
+
)
|
|
241
|
+
msgs_to_keep = msg_map_config.get(
|
|
242
|
+
"msgs_to_keep", 500
|
|
243
|
+
) # Default is 500 if not specified
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
# Always use our own local meshnet_name for outgoing events
|
|
247
|
+
local_meshnet_name = config["meshtastic"]["meshnet_name"]
|
|
248
|
+
content = {
|
|
249
|
+
"msgtype": "m.text" if not emote else "m.emote",
|
|
250
|
+
"body": message,
|
|
251
|
+
"meshtastic_longname": longname,
|
|
252
|
+
"meshtastic_shortname": shortname,
|
|
253
|
+
"meshtastic_meshnet": local_meshnet_name,
|
|
254
|
+
"meshtastic_portnum": portnum,
|
|
255
|
+
}
|
|
256
|
+
if meshtastic_id is not None:
|
|
257
|
+
content["meshtastic_id"] = meshtastic_id
|
|
258
|
+
if meshtastic_replyId is not None:
|
|
259
|
+
content["meshtastic_replyId"] = meshtastic_replyId
|
|
260
|
+
if meshtastic_text is not None:
|
|
261
|
+
content["meshtastic_text"] = meshtastic_text
|
|
262
|
+
if emoji:
|
|
263
|
+
content["meshtastic_emoji"] = 1
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
# Ensure matrix_client is not None
|
|
267
|
+
if not matrix_client:
|
|
268
|
+
logger.error("Matrix client is None. Cannot send message.")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
# Send the message with a timeout
|
|
272
|
+
response = await asyncio.wait_for(
|
|
273
|
+
matrix_client.room_send(
|
|
274
|
+
room_id=room_id,
|
|
275
|
+
message_type="m.room.message",
|
|
276
|
+
content=content,
|
|
277
|
+
),
|
|
278
|
+
timeout=10.0, # Increased timeout
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Log at info level, matching one-point-oh pattern
|
|
282
|
+
logger.info(f"Sent inbound radio message to matrix room: {room_id}")
|
|
283
|
+
# Additional details at debug level
|
|
284
|
+
if hasattr(response, "event_id"):
|
|
285
|
+
logger.debug(f"Message event_id: {response.event_id}")
|
|
286
|
+
|
|
287
|
+
except asyncio.TimeoutError:
|
|
288
|
+
logger.error(f"Timeout sending message to Matrix room {room_id}")
|
|
289
|
+
return
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.error(f"Error sending message to Matrix room {room_id}: {e}")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# Only store message map if relay_reactions is True and meshtastic_id is present and not an emote.
|
|
295
|
+
# If relay_reactions is False, we skip storing entirely.
|
|
296
|
+
if (
|
|
297
|
+
relay_reactions
|
|
298
|
+
and meshtastic_id is not None
|
|
299
|
+
and not emote
|
|
300
|
+
and hasattr(response, "event_id")
|
|
301
|
+
):
|
|
302
|
+
try:
|
|
303
|
+
# Store the message map
|
|
304
|
+
store_message_map(
|
|
305
|
+
meshtastic_id,
|
|
306
|
+
response.event_id,
|
|
307
|
+
room_id,
|
|
308
|
+
meshtastic_text if meshtastic_text else message,
|
|
309
|
+
meshtastic_meshnet=local_meshnet_name,
|
|
310
|
+
)
|
|
311
|
+
logger.debug(f"Stored message map for meshtastic_id: {meshtastic_id}")
|
|
312
|
+
|
|
313
|
+
# If msgs_to_keep > 0, prune old messages after inserting a new one
|
|
314
|
+
if msgs_to_keep > 0:
|
|
315
|
+
prune_message_map(msgs_to_keep)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"Error storing message map: {e}")
|
|
318
|
+
|
|
319
|
+
except asyncio.TimeoutError:
|
|
320
|
+
logger.error("Timed out while waiting for Matrix response")
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def truncate_message(text, max_bytes=227):
|
|
326
|
+
"""
|
|
327
|
+
Truncate the given text to fit within the specified byte size.
|
|
328
|
+
|
|
329
|
+
:param text: The text to truncate.
|
|
330
|
+
:param max_bytes: The maximum allowed byte size for the truncated text.
|
|
331
|
+
:return: The truncated text.
|
|
332
|
+
"""
|
|
333
|
+
truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
|
|
334
|
+
return truncated_text
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def strip_quoted_lines(text: str) -> str:
|
|
338
|
+
"""
|
|
339
|
+
Remove lines that begin with '>' to avoid including
|
|
340
|
+
the original quoted part of a Matrix reply in reaction text.
|
|
341
|
+
"""
|
|
342
|
+
lines = text.splitlines()
|
|
343
|
+
filtered = [line for line in lines if not line.strip().startswith(">")]
|
|
344
|
+
return " ".join(filtered).strip()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# Callback for new messages in Matrix room
|
|
348
|
+
async def on_room_message(
|
|
349
|
+
room: MatrixRoom,
|
|
350
|
+
event: Union[RoomMessageText, RoomMessageNotice, ReactionEvent, RoomMessageEmote],
|
|
351
|
+
) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Handle new messages and reactions in Matrix. For reactions, we ensure that when relaying back
|
|
354
|
+
to Meshtastic, we always apply our local meshnet_name to outgoing events.
|
|
355
|
+
|
|
356
|
+
We must be careful not to relay reactions to reactions (reaction-chains),
|
|
357
|
+
especially remote reactions that got relayed into the room as m.emote events,
|
|
358
|
+
as we do not store them in the database. If we can't find the original message in the DB,
|
|
359
|
+
it likely means it's a reaction to a reaction, and we stop there.
|
|
360
|
+
|
|
361
|
+
Additionally, we only deal with message_map storage (and thus reaction linking)
|
|
362
|
+
if relay_reactions is True. If it's False, none of these mappings are stored or used.
|
|
363
|
+
"""
|
|
364
|
+
# Importing here to avoid circular imports and to keep logic consistent
|
|
365
|
+
# Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
|
|
366
|
+
# That logic occurs inside matrix_relay if needed.
|
|
367
|
+
full_display_name = "Unknown user"
|
|
368
|
+
message_timestamp = event.server_timestamp
|
|
369
|
+
|
|
370
|
+
# We do not relay messages that occurred before the bot started
|
|
371
|
+
if message_timestamp < bot_start_time:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# Do not process messages from the bot itself
|
|
375
|
+
if event.sender == bot_user_id:
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# Find the room_config that matches this room, if any
|
|
379
|
+
room_config = None
|
|
380
|
+
for room_conf in matrix_rooms:
|
|
381
|
+
if room_conf["id"] == room.room_id:
|
|
382
|
+
room_config = room_conf
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
# Only proceed if the room is supported
|
|
386
|
+
if not room_config:
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
relates_to = event.source["content"].get("m.relates_to")
|
|
390
|
+
global config
|
|
391
|
+
|
|
392
|
+
# Check if config is available
|
|
393
|
+
if not config:
|
|
394
|
+
logger.error("No configuration available for Matrix message processing.")
|
|
395
|
+
|
|
396
|
+
is_reaction = False
|
|
397
|
+
reaction_emoji = None
|
|
398
|
+
original_matrix_event_id = None
|
|
399
|
+
|
|
400
|
+
# Check if config is available
|
|
401
|
+
if config is None:
|
|
402
|
+
logger.error("No configuration available. Cannot process Matrix message.")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Retrieve relay_reactions option from config, now defaulting to False
|
|
406
|
+
relay_reactions = config["meshtastic"].get("relay_reactions", False)
|
|
407
|
+
|
|
408
|
+
# Check if this is a Matrix ReactionEvent (usually m.reaction)
|
|
409
|
+
if isinstance(event, ReactionEvent):
|
|
410
|
+
# This is a reaction event
|
|
411
|
+
is_reaction = True
|
|
412
|
+
logger.debug(f"Processing Matrix reaction event: {event.source}")
|
|
413
|
+
if relates_to and "event_id" in relates_to and "key" in relates_to:
|
|
414
|
+
# Extract the reaction emoji and the original event it relates to
|
|
415
|
+
reaction_emoji = relates_to["key"]
|
|
416
|
+
original_matrix_event_id = relates_to["event_id"]
|
|
417
|
+
logger.debug(
|
|
418
|
+
f"Original matrix event ID: {original_matrix_event_id}, Reaction emoji: {reaction_emoji}"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Check if this is a Matrix RoomMessageEmote (m.emote)
|
|
422
|
+
if isinstance(event, RoomMessageEmote):
|
|
423
|
+
logger.debug(f"Processing Matrix reaction event: {event.source}")
|
|
424
|
+
# For RoomMessageEmote, treat as remote reaction if meshtastic_replyId exists
|
|
425
|
+
is_reaction = True
|
|
426
|
+
# We need to manually extract the reaction emoji from the body
|
|
427
|
+
reaction_body = event.source["content"].get("body", "")
|
|
428
|
+
reaction_match = re.search(r"reacted (.+?) to", reaction_body)
|
|
429
|
+
reaction_emoji = reaction_match.group(1).strip() if reaction_match else "?"
|
|
430
|
+
|
|
431
|
+
text = event.body.strip() if (not is_reaction and hasattr(event, "body")) else ""
|
|
432
|
+
|
|
433
|
+
longname = event.source["content"].get("meshtastic_longname")
|
|
434
|
+
shortname = event.source["content"].get("meshtastic_shortname", None)
|
|
435
|
+
meshnet_name = event.source["content"].get("meshtastic_meshnet")
|
|
436
|
+
meshtastic_replyId = event.source["content"].get("meshtastic_replyId")
|
|
437
|
+
suppress = event.source["content"].get("mmrelay_suppress")
|
|
438
|
+
|
|
439
|
+
# If a message has suppress flag, do not process
|
|
440
|
+
if suppress:
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
# If this is a reaction and relay_reactions is False, do nothing
|
|
444
|
+
if is_reaction and not relay_reactions:
|
|
445
|
+
logger.debug(
|
|
446
|
+
"Reaction event encountered but relay_reactions is disabled. Doing nothing."
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
local_meshnet_name = config["meshtastic"]["meshnet_name"]
|
|
451
|
+
|
|
452
|
+
# If this is a reaction and relay_reactions is True, attempt to relay it
|
|
453
|
+
if is_reaction and relay_reactions:
|
|
454
|
+
# Check if we need to relay a reaction from a remote meshnet to our local meshnet.
|
|
455
|
+
# If meshnet_name != local_meshnet_name and meshtastic_replyId is present and this is an emote,
|
|
456
|
+
# it's a remote reaction that needs to be forwarded as a text message describing the reaction.
|
|
457
|
+
if (
|
|
458
|
+
meshnet_name
|
|
459
|
+
and meshnet_name != local_meshnet_name
|
|
460
|
+
and meshtastic_replyId
|
|
461
|
+
and isinstance(event, RoomMessageEmote)
|
|
462
|
+
):
|
|
463
|
+
logger.info(f"Relaying reaction from remote meshnet: {meshnet_name}")
|
|
464
|
+
|
|
465
|
+
short_meshnet_name = meshnet_name[:4]
|
|
466
|
+
|
|
467
|
+
# Format the reaction message for relaying to the local meshnet.
|
|
468
|
+
# The necessary information is in the m.emote event
|
|
469
|
+
if not shortname:
|
|
470
|
+
shortname = longname[:3] if longname else "???"
|
|
471
|
+
|
|
472
|
+
meshtastic_text_db = event.source["content"].get("meshtastic_text", "")
|
|
473
|
+
# Strip out any quoted lines from the text
|
|
474
|
+
meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
|
|
475
|
+
meshtastic_text_db = meshtastic_text_db.replace("\n", " ").replace(
|
|
476
|
+
"\r", " "
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
abbreviated_text = (
|
|
480
|
+
meshtastic_text_db[:40] + "..."
|
|
481
|
+
if len(meshtastic_text_db) > 40
|
|
482
|
+
else meshtastic_text_db
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
reaction_message = f'{shortname}/{short_meshnet_name} reacted {reaction_emoji} to "{abbreviated_text}"'
|
|
486
|
+
|
|
487
|
+
# Relay the remote reaction to the local meshnet.
|
|
488
|
+
meshtastic_interface = connect_meshtastic()
|
|
489
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
490
|
+
|
|
491
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
492
|
+
|
|
493
|
+
if config["meshtastic"]["broadcast_enabled"]:
|
|
494
|
+
meshtastic_logger.info(
|
|
495
|
+
f"Relaying reaction from remote meshnet {meshnet_name} to radio broadcast"
|
|
496
|
+
)
|
|
497
|
+
logger.debug(
|
|
498
|
+
f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
|
|
499
|
+
)
|
|
500
|
+
meshtastic_interface.sendText(
|
|
501
|
+
text=reaction_message, channelIndex=meshtastic_channel
|
|
502
|
+
)
|
|
503
|
+
# We've relayed the remote reaction to our local mesh, so we're done.
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# If original_matrix_event_id is set, this is a reaction to some other matrix event
|
|
507
|
+
if original_matrix_event_id:
|
|
508
|
+
orig = get_message_map_by_matrix_event_id(original_matrix_event_id)
|
|
509
|
+
if not orig:
|
|
510
|
+
# If we don't find the original message in the DB, we suspect it's a reaction-to-reaction scenario
|
|
511
|
+
logger.debug(
|
|
512
|
+
"Original message for reaction not found in DB. Possibly a reaction-to-reaction scenario. Not forwarding."
|
|
513
|
+
)
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
# orig = (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
|
|
517
|
+
meshtastic_id, matrix_room_id, meshtastic_text_db, meshtastic_meshnet_db = (
|
|
518
|
+
orig
|
|
519
|
+
)
|
|
520
|
+
display_name_response = await matrix_client.get_displayname(event.sender)
|
|
521
|
+
full_display_name = display_name_response.displayname or event.sender
|
|
522
|
+
|
|
523
|
+
# If not from a remote meshnet, proceed as normal to relay back to the originating meshnet
|
|
524
|
+
short_display_name = full_display_name[:5]
|
|
525
|
+
prefix = f"{short_display_name}[M]: "
|
|
526
|
+
|
|
527
|
+
# Remove quoted lines so we don't bring in the original '>' lines from replies
|
|
528
|
+
meshtastic_text_db = strip_quoted_lines(meshtastic_text_db)
|
|
529
|
+
meshtastic_text_db = meshtastic_text_db.replace("\n", " ").replace(
|
|
530
|
+
"\r", " "
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
abbreviated_text = (
|
|
534
|
+
meshtastic_text_db[:40] + "..."
|
|
535
|
+
if len(meshtastic_text_db) > 40
|
|
536
|
+
else meshtastic_text_db
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Always use our local meshnet_name for outgoing events
|
|
540
|
+
reaction_message = (
|
|
541
|
+
f'{prefix}reacted {reaction_emoji} to "{abbreviated_text}"'
|
|
542
|
+
)
|
|
543
|
+
meshtastic_interface = connect_meshtastic()
|
|
544
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
545
|
+
|
|
546
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
547
|
+
|
|
548
|
+
if config["meshtastic"]["broadcast_enabled"]:
|
|
549
|
+
meshtastic_logger.info(
|
|
550
|
+
f"Relaying reaction from {full_display_name} to radio broadcast"
|
|
551
|
+
)
|
|
552
|
+
logger.debug(
|
|
553
|
+
f"Sending reaction to Meshtastic with meshnet={local_meshnet_name}: {reaction_message}"
|
|
554
|
+
)
|
|
555
|
+
meshtastic_interface.sendText(
|
|
556
|
+
text=reaction_message, channelIndex=meshtastic_channel
|
|
557
|
+
)
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
# For Matrix->Mesh messages from a remote meshnet, rewrite the message format
|
|
561
|
+
if longname and meshnet_name:
|
|
562
|
+
# Always include the meshnet_name in the full display name.
|
|
563
|
+
full_display_name = f"{longname}/{meshnet_name}"
|
|
564
|
+
|
|
565
|
+
if meshnet_name != local_meshnet_name:
|
|
566
|
+
# A message from a remote meshnet relayed into Matrix, now going back out
|
|
567
|
+
logger.info(f"Processing message from remote meshnet: {meshnet_name}")
|
|
568
|
+
short_meshnet_name = meshnet_name[:4]
|
|
569
|
+
# If shortname is not available, derive it from the longname
|
|
570
|
+
if shortname is None:
|
|
571
|
+
shortname = longname[:3] if longname else "???"
|
|
572
|
+
# Remove the original prefix "[longname/meshnet]: " to avoid double-tagging
|
|
573
|
+
text = re.sub(rf"^\[{full_display_name}\]: ", "", text)
|
|
574
|
+
text = truncate_message(text)
|
|
575
|
+
full_message = f"{shortname}/{short_meshnet_name}: {text}"
|
|
576
|
+
else:
|
|
577
|
+
# If this message is from our local meshnet (loopback), we ignore it
|
|
578
|
+
return
|
|
579
|
+
else:
|
|
580
|
+
# Normal Matrix message from a Matrix user
|
|
581
|
+
display_name_response = await matrix_client.get_displayname(event.sender)
|
|
582
|
+
full_display_name = display_name_response.displayname or event.sender
|
|
583
|
+
short_display_name = full_display_name[:5]
|
|
584
|
+
prefix = f"{short_display_name}[M]: "
|
|
585
|
+
logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
|
|
586
|
+
full_message = f"{prefix}{text}"
|
|
587
|
+
text = truncate_message(text)
|
|
588
|
+
|
|
589
|
+
# Plugin functionality
|
|
590
|
+
from mmrelay.plugin_loader import load_plugins
|
|
591
|
+
|
|
592
|
+
plugins = load_plugins()
|
|
593
|
+
|
|
594
|
+
found_matching_plugin = False
|
|
595
|
+
for plugin in plugins:
|
|
596
|
+
if not found_matching_plugin:
|
|
597
|
+
try:
|
|
598
|
+
found_matching_plugin = await plugin.handle_room_message(
|
|
599
|
+
room, event, full_message
|
|
600
|
+
)
|
|
601
|
+
if found_matching_plugin:
|
|
602
|
+
logger.info(
|
|
603
|
+
f"Processed command with plugin: {plugin.plugin_name} from {event.sender}"
|
|
604
|
+
)
|
|
605
|
+
except Exception as e:
|
|
606
|
+
logger.error(
|
|
607
|
+
f"Error processing message with plugin {plugin.plugin_name}: {e}"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Check if the message is a command directed at the bot
|
|
611
|
+
is_command = False
|
|
612
|
+
for plugin in plugins:
|
|
613
|
+
for command in plugin.get_matrix_commands():
|
|
614
|
+
if bot_command(command, event):
|
|
615
|
+
is_command = True
|
|
616
|
+
break
|
|
617
|
+
if is_command:
|
|
618
|
+
break
|
|
619
|
+
|
|
620
|
+
# If this is a command, we do not send it to the mesh
|
|
621
|
+
if is_command:
|
|
622
|
+
logger.debug("Message is a command, not sending to mesh")
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
# Connect to Meshtastic
|
|
626
|
+
meshtastic_interface = connect_meshtastic()
|
|
627
|
+
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
628
|
+
|
|
629
|
+
if not meshtastic_interface:
|
|
630
|
+
logger.error("Failed to connect to Meshtastic. Cannot relay message.")
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
meshtastic_channel = room_config["meshtastic_channel"]
|
|
634
|
+
|
|
635
|
+
# If message is from Matrix and broadcast_enabled is True, relay to Meshtastic
|
|
636
|
+
# Note: If relay_reactions is False, we won't store message_map, but we can still relay.
|
|
637
|
+
# The lack of message_map storage just means no reaction bridging will occur.
|
|
638
|
+
if not found_matching_plugin:
|
|
639
|
+
if config["meshtastic"]["broadcast_enabled"]:
|
|
640
|
+
portnum = event.source["content"].get("meshtastic_portnum")
|
|
641
|
+
if portnum == "DETECTION_SENSOR_APP":
|
|
642
|
+
# If detection_sensor is enabled, forward this data as detection sensor data
|
|
643
|
+
if config["meshtastic"].get("detection_sensor", False):
|
|
644
|
+
sent_packet = meshtastic_interface.sendData(
|
|
645
|
+
data=full_message.encode("utf-8"),
|
|
646
|
+
channelIndex=meshtastic_channel,
|
|
647
|
+
portNum=meshtastic.protobuf.portnums_pb2.PortNum.DETECTION_SENSOR_APP,
|
|
648
|
+
)
|
|
649
|
+
# If relay_reactions is True, we store the message map for these messages as well.
|
|
650
|
+
# If False, skip storing.
|
|
651
|
+
if relay_reactions and sent_packet and hasattr(sent_packet, "id"):
|
|
652
|
+
store_message_map(
|
|
653
|
+
sent_packet.id,
|
|
654
|
+
event.event_id,
|
|
655
|
+
room.room_id,
|
|
656
|
+
text,
|
|
657
|
+
meshtastic_meshnet=local_meshnet_name,
|
|
658
|
+
)
|
|
659
|
+
# Check database config for message map settings (preferred format)
|
|
660
|
+
database_config = config.get("database", {})
|
|
661
|
+
msg_map_config = database_config.get("msg_map", {})
|
|
662
|
+
|
|
663
|
+
# If not found in database config, check legacy db config
|
|
664
|
+
if not msg_map_config:
|
|
665
|
+
db_config = config.get("db", {})
|
|
666
|
+
legacy_msg_map_config = db_config.get("msg_map", {})
|
|
667
|
+
|
|
668
|
+
if legacy_msg_map_config:
|
|
669
|
+
msg_map_config = legacy_msg_map_config
|
|
670
|
+
logger.warning(
|
|
671
|
+
"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."
|
|
672
|
+
)
|
|
673
|
+
msgs_to_keep = msg_map_config.get("msgs_to_keep", 500)
|
|
674
|
+
if msgs_to_keep > 0:
|
|
675
|
+
prune_message_map(msgs_to_keep)
|
|
676
|
+
else:
|
|
677
|
+
meshtastic_logger.debug(
|
|
678
|
+
f"Detection sensor packet received from {full_display_name}, but detection sensor processing is disabled."
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
meshtastic_logger.info(
|
|
682
|
+
f"Relaying message from {full_display_name} to radio broadcast"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
sent_packet = meshtastic_interface.sendText(
|
|
687
|
+
text=full_message, channelIndex=meshtastic_channel
|
|
688
|
+
)
|
|
689
|
+
except Exception as e:
|
|
690
|
+
meshtastic_logger.error(f"Error sending message to Meshtastic: {e}")
|
|
691
|
+
return
|
|
692
|
+
# Store message_map only if relay_reactions is True
|
|
693
|
+
if relay_reactions and sent_packet and hasattr(sent_packet, "id"):
|
|
694
|
+
store_message_map(
|
|
695
|
+
sent_packet.id,
|
|
696
|
+
event.event_id,
|
|
697
|
+
room.room_id,
|
|
698
|
+
text,
|
|
699
|
+
meshtastic_meshnet=local_meshnet_name,
|
|
700
|
+
)
|
|
701
|
+
# Check database config for message map settings (preferred format)
|
|
702
|
+
database_config = config.get("database", {})
|
|
703
|
+
msg_map_config = database_config.get("msg_map", {})
|
|
704
|
+
|
|
705
|
+
# If not found in database config, check legacy db config
|
|
706
|
+
if not msg_map_config:
|
|
707
|
+
db_config = config.get("db", {})
|
|
708
|
+
legacy_msg_map_config = db_config.get("msg_map", {})
|
|
709
|
+
|
|
710
|
+
if legacy_msg_map_config:
|
|
711
|
+
msg_map_config = legacy_msg_map_config
|
|
712
|
+
logger.warning(
|
|
713
|
+
"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."
|
|
714
|
+
)
|
|
715
|
+
msgs_to_keep = msg_map_config.get("msgs_to_keep", 500)
|
|
716
|
+
if msgs_to_keep > 0:
|
|
717
|
+
prune_message_map(msgs_to_keep)
|
|
718
|
+
else:
|
|
719
|
+
logger.debug(
|
|
720
|
+
f"Broadcast not supported: Message from {full_display_name} dropped."
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
async def upload_image(
|
|
725
|
+
client: AsyncClient, image: Image.Image, filename: str
|
|
726
|
+
) -> UploadResponse:
|
|
727
|
+
"""
|
|
728
|
+
Uploads an image to Matrix and returns the UploadResponse containing the content URI.
|
|
729
|
+
"""
|
|
730
|
+
buffer = io.BytesIO()
|
|
731
|
+
image.save(buffer, format="PNG")
|
|
732
|
+
image_data = buffer.getvalue()
|
|
733
|
+
|
|
734
|
+
response, maybe_keys = await client.upload(
|
|
735
|
+
io.BytesIO(image_data),
|
|
736
|
+
content_type="image/png",
|
|
737
|
+
filename=filename,
|
|
738
|
+
filesize=len(image_data),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
return response
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
async def send_room_image(
|
|
745
|
+
client: AsyncClient, room_id: str, upload_response: UploadResponse
|
|
746
|
+
):
|
|
747
|
+
"""
|
|
748
|
+
Sends an already uploaded image to the specified room.
|
|
749
|
+
"""
|
|
750
|
+
await client.room_send(
|
|
751
|
+
room_id=room_id,
|
|
752
|
+
message_type="m.room.message",
|
|
753
|
+
content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""},
|
|
754
|
+
)
|