Coverage for slidge/core/dispatcher/message/message.py: 86%

274 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-10 12:26 +0000

1import base64 

2import hashlib 

3import logging 

4from copy import copy 

5from typing import TYPE_CHECKING, Any, NamedTuple 

6from xml.etree import ElementTree 

7 

8from slixmpp import JID, Message 

9from slixmpp.exceptions import XMPPError 

10from slixmpp.plugins.xep_0511.stanza import LinkMetadata 

11 

12from ....contact.contact import LegacyContact 

13from ....room.room import LegacyRoom 

14from ....util.types import ( 

15 AnyRecipient, 

16 AnyRoom, 

17 ContactMessageProtocol, 

18 ContactSticker, 

19 LinkPreview, 

20 Reply, 

21 RoomMessageProtocol, 

22 RoomSticker, 

23 XMPPAttachment, 

24 XMPPMessage, 

25) 

26from ....util.util import dict_to_named_tuple, remove_emoji_variation_selector_16 

27from ... import config 

28from ..util import DispatcherMixin, exceptions_to_xmpp_errors 

29 

30if TYPE_CHECKING: 

31 from slidge.util.types import AnyGateway 

32 

33 

34class _IncomingAttachment(NamedTuple): 

35 attachment: XMPPAttachment 

36 cid: str | None = None 

37 

38 

39class MessageContentMixin(DispatcherMixin): 

40 __slots__: list[str] = [] 

41 xmpp: "AnyGateway" # not strictly necessary, but helps ty and ty is nice 

42 

43 def __init__(self, xmpp: "AnyGateway") -> None: 

44 super().__init__(xmpp) 

45 xmpp.add_event_handler("legacy_message", self.on_legacy_message) 

46 xmpp.add_event_handler("message_retract", self.on_message_retract) 

47 xmpp.add_event_handler("groupchat_message", self.on_groupchat_message) 

48 xmpp.add_event_handler("reactions", self.on_reactions) 

49 

50 async def on_groupchat_message(self, msg: Message) -> None: 

51 await self.on_legacy_message(msg) 

52 

53 @exceptions_to_xmpp_errors 

54 async def on_legacy_message(self, msg: Message) -> None: 

55 """ 

56 Meant to be called from :class:`BaseGateway` only. 

57 

58 :param msg: 

59 :return: 

60 """ 

61 # we MUST not use `if m["replace"]["id"]` because it adds the tag if not 

62 # present. this is a problem for Room echoed messages 

63 if "apply_to" in msg: 

64 # ignore message retraction (handled by a specific method) 

65 # this is an old version of the protocol that depends on the 

66 # message fastening deprecated xep. 

67 return 

68 if "reactions" in msg: 

69 # ignore message reaction fallback. 

70 # the reaction itself is handled by self.react_from_msg(). 

71 return 

72 if "retract" in msg: 

73 # ignore message retraction fallback. 

74 # the retraction itself is handled by self.on_retract 

75 return 

76 recipient, thread = await self._get_recipient_and_thread(msg) 

77 replace = await self.__get_replace(msg, recipient) 

78 if msg.xml.find(".//{*}encrypted") is not None: 

79 raise XMPPError( 

80 "bad-request", "You cannot send encrypted messages through this gateway" 

81 ) 

82 body, reply = await self.__get_reply(msg, recipient) 

83 cid = self.__get_xhtml_sticker_cid(msg) 

84 

85 if cid: 

86 legacy_msg_id = await self.__dispatch_bob( 

87 msg.get_from(), 

88 cid, 

89 recipient, 

90 reply=reply, 

91 thread=thread, 

92 ) 

93 else: 

94 attachments = self.__get_attachments(msg) 

95 if len(attachments) == 1 and attachments[0].attachment.is_sticker: 

96 legacy_msg_id = await self.__dispatch_nonbob_sticker( 

97 attachments[0], 

98 recipient, 

99 msg["body"], 

100 reply=reply, 

101 thread=thread, 

102 ) 

103 else: 

104 legacy_msg_id = await self.__dispatch_msg( 

105 replace=replace, 

106 body=body, 

107 attachments=tuple(a.attachment for a in attachments), 

108 recipient=recipient, 

109 thread=thread, 

110 reply=reply, 

111 slix_msg=msg, 

112 ) 

113 

114 if isinstance(recipient, LegacyRoom): 

115 await recipient.echo(msg, legacy_msg_id, replace=replace) 

116 else: 

117 self.__ack(msg) 

118 

119 if not legacy_msg_id: 

120 return 

121 

122 with self.xmpp.store.session() as orm: 

123 if recipient.is_group: 

124 self.xmpp.store.id_map.set_origin( 

125 orm, recipient._stored.id, legacy_msg_id, msg.get_id() 

126 ) 

127 else: 

128 self.xmpp.store.id_map.set_msg( 

129 orm, 

130 recipient._stored.id, 

131 legacy_msg_id, 

132 [msg.get_id()], 

133 False, 

134 ) 

135 orm.commit() 

136 

137 def __get_xhtml_sticker_cid(self, msg: Message) -> str | None: 

138 if "html" not in msg: 

139 return None 

140 body = ElementTree.fromstring("<body>" + msg["html"].get_body() + "</body>") 

141 p = body.findall("p") 

142 if p is None: 

143 return None 

144 if len(p) != 1: 

145 return None 

146 if p[0].text is not None and p[0].text.strip(): 

147 return None 

148 

149 images = p[0].findall("img") 

150 if len(images) != 1: 

151 return None 

152 # no text, single img ⇒ this is a sticker 

153 # other cases should be interpreted as "custom emojis" in text 

154 src = images[0].get("src") 

155 if src is None: 

156 return None 

157 if src.startswith("cid:"): 

158 return src.removeprefix("cid:") 

159 return None 

160 

161 async def __get_replace[T: LegacyContact | AnyRoom]( 

162 self, msg: Message, recipient: T 

163 ) -> str | None: 

164 if "replace" not in msg or "id" not in msg["replace"]: 

165 return None 

166 return ( 

167 self._xmpp_msg_id_to_legacy(msg["replace"]["id"], recipient, True) or None 

168 ) 

169 

170 def __get_attachments(self, msg: Message) -> list[_IncomingAttachment]: 

171 is_sticker = "sticker" in msg 

172 

173 if ( 

174 "sfs" in msg 

175 and "sources" in msg["sfs"] 

176 and "url-data" in msg["sfs"]["sources"] 

177 and "target" in msg["sfs"]["sources"]["url-data"] 

178 ): 

179 # TODO: support "attach source in later message", cf https://xmpp.org/extensions/xep-0447.html#example-5 

180 # TODO: support for other sources than URL 

181 # TODO: support for multiattachments in single message. 

182 # What do we do if is_sticker and multiple files? 

183 content_type = cid = None 

184 if "file" in msg["sfs"]: 

185 content_type = msg["sfs"]["file"]["media-type"] or None 

186 if "hash" in msg["sfs"]["file"]: 

187 algo = msg["sfs"]["file"]["hash"]["algo"] 

188 h = msg["sfs"]["file"]["hash"]["value"] 

189 cid = f"{algo}+{h}" if algo and h else None 

190 attachment = _IncomingAttachment( 

191 XMPPAttachment( 

192 url=msg["sfs"]["sources"]["url-data"]["target"], 

193 http=self.xmpp.http, 

194 is_sticker=is_sticker, 

195 content_type=content_type, 

196 ), 

197 cid=cid, 

198 ) 

199 return [attachment] 

200 

201 if "oob" in msg: 

202 return [ 

203 _IncomingAttachment( 

204 XMPPAttachment( 

205 url=msg["oob"]["url"], 

206 http=self.xmpp.http, 

207 is_sticker=is_sticker, 

208 ), 

209 ) 

210 ] 

211 

212 if ( 

213 "reference" in msg 

214 and "sims" in msg["reference"] 

215 and "sources" in msg["reference"]["sims"] 

216 ): 

217 for source in msg["reference"]["sims"]["sources"]["substanzas"]: 

218 if source["uri"].startswith("http"): 

219 attachment = _IncomingAttachment( 

220 XMPPAttachment( 

221 url=source["uri"], 

222 http=self.xmpp.http, 

223 is_sticker=is_sticker, 

224 ), 

225 ) 

226 break 

227 else: 

228 return [] 

229 if "file" in msg["reference"]["sims"]: 

230 attachment.attachment.content_type = msg["media-type"] or None 

231 return [attachment] 

232 

233 return [] 

234 

235 async def __dispatch_msg( 

236 self, 

237 *, 

238 replace: str | None, 

239 reply: Reply | None, 

240 body: str | None, 

241 attachments: tuple[XMPPAttachment, ...], 

242 recipient: AnyRecipient, 

243 thread: str | None, 

244 slix_msg: Message, 

245 ) -> str | None: 

246 if replace: 

247 if body is not None: 

248 body = body.strip() 

249 if not body and not attachments and recipient.RETRACTION: 

250 await recipient.on_retract(replace, thread=thread) 

251 return None 

252 if not recipient.CORRECTION: 

253 if recipient.RETRACTION: 

254 await recipient.on_retract(replace, thread=thread) 

255 replace = None 

256 elif body: 

257 body = "Correction:\n" + body 

258 

259 if not any([attachments, body]): 

260 log.debug( 

261 "Ignoring msg with id '%s' because no body or attachments found", 

262 slix_msg.get_id(), 

263 ) 

264 return None 

265 link_previews = ( 

266 parse_link_previews(slix_msg["link_metadatas"]) 

267 if "link_metadata" in slix_msg 

268 else () 

269 ) 

270 

271 for attachment in attachments: 

272 if not body: 

273 body = None 

274 break 

275 body = body.replace(attachment.url, "").strip() 

276 

277 msg = XMPPMessage[Any]( 

278 body=body, 

279 attachments=attachments, 

280 reply=reply, 

281 thread=thread, 

282 link_previews=link_previews, 

283 replace=replace, 

284 ) 

285 if recipient.is_group: 

286 msg.mentions = tuple(await recipient.parse_mentions(body)) 

287 assert isinstance(msg, RoomMessageProtocol) 

288 else: 

289 assert isinstance(msg, ContactMessageProtocol) 

290 return await recipient.on_message(msg) 

291 

292 @exceptions_to_xmpp_errors 

293 async def on_message_retract(self, msg: Message) -> None: 

294 recipient, thread = await self._get_recipient_and_thread(msg) 

295 if not recipient.RETRACTION: 

296 raise XMPPError( 

297 "bad-request", 

298 "This legacy service does not support message retraction.", 

299 ) 

300 xmpp_id: str = msg["retract"]["id"] 

301 legacy_id = self._xmpp_msg_id_to_legacy(xmpp_id, recipient, origin=True) 

302 await recipient.on_retract(legacy_id, thread=thread) 

303 if isinstance(recipient, LegacyRoom): 

304 await recipient.echo(msg, None) 

305 self.__ack(msg) 

306 

307 @exceptions_to_xmpp_errors 

308 async def on_reactions(self, msg: Message) -> None: 

309 recipient, thread = await self._get_recipient_and_thread(msg) 

310 react_to: str = msg["reactions"]["id"] 

311 

312 legacy_id = self._xmpp_msg_id_to_legacy(react_to, recipient) 

313 

314 if not legacy_id: 

315 log.debug("Ignored reaction from user") 

316 raise XMPPError( 

317 "internal-server-error", 

318 "Could not convert the XMPP msg ID to a legacy ID", 

319 ) 

320 

321 emojis = [ 

322 remove_emoji_variation_selector_16(r["value"]) for r in msg["reactions"] 

323 ] 

324 error_msg = None 

325 

326 if recipient.REACTIONS_SINGLE_EMOJI and len(emojis) > 1: 

327 error_msg = "Maximum 1 emoji/message" 

328 

329 if ( 

330 not error_msg 

331 and (subset := await recipient.available_emojis(legacy_id)) 

332 and not set(emojis).issubset(subset) 

333 ): 

334 error_msg = ( 

335 f"You can only react with the following emojis: {''.join(subset)}" 

336 ) 

337 

338 if error_msg: 

339 recipient._session.send_gateway_message(error_msg) 

340 if not isinstance(recipient, LegacyRoom): 

341 # no need to carbon for groups, we just don't echo the stanza 

342 recipient.react(legacy_id, carbon=True) 

343 await recipient.on_react(legacy_id, [], thread=thread) 

344 raise XMPPError( 

345 "policy-violation", 

346 text=error_msg, 

347 clear=False, 

348 ) 

349 

350 await recipient.on_react(legacy_id, emojis, thread=thread) 

351 if isinstance(recipient, LegacyRoom): 

352 await recipient.echo(msg, None) 

353 else: 

354 self.__ack(msg) 

355 

356 with self.xmpp.store.session() as orm: 

357 multi = self.xmpp.store.id_map.get_xmpp( 

358 orm, recipient._stored.id, legacy_id, recipient.is_group 

359 ) 

360 multi = [m for m in multi if react_to != m] 

361 if not multi: 

362 return 

363 

364 if isinstance(recipient, LegacyRoom): 

365 for xmpp_id in multi: 

366 mc = copy(msg) 

367 mc["reactions"]["id"] = xmpp_id 

368 await recipient.echo(mc) 

369 elif isinstance(recipient, LegacyContact): 

370 for xmpp_id in multi: 

371 recipient.react(legacy_id, emojis, xmpp_id=xmpp_id, carbon=True) 

372 

373 def __ack(self, msg: Message) -> None: 

374 if not self.xmpp.PROPER_RECEIPTS: 

375 self.xmpp.delivery_receipt.ack(msg) 

376 

377 async def __get_reply( 

378 self, msg: Message, recipient: AnyRecipient 

379 ) -> tuple[str, Reply | None]: 

380 if "reply" not in msg: 

381 return msg["body"], None 

382 

383 session = recipient._session 

384 

385 try: 

386 msg_id = self._xmpp_msg_id_to_legacy(msg["reply"]["id"], recipient) 

387 except XMPPError: 

388 session.log.debug( 

389 "Could not determine reply-to legacy msg ID, sending quote instead." 

390 ) 

391 return redact_url(msg["body"]), None 

392 

393 if "fallback" in msg and ( 

394 isinstance(recipient, LegacyRoom) or recipient.REPLIES 

395 ): 

396 text = msg["fallback"].get_stripped_body( 

397 self.xmpp.plugin["xep_0461"].namespace 

398 ) 

399 try: 

400 fallback = redact_url(msg["reply"].get_fallback_body()) 

401 except AttributeError: 

402 fallback = None 

403 else: 

404 text = msg["body"] 

405 fallback = None 

406 

407 jid = JID(msg["reply"]["to"]) 

408 reply = Reply(msg_id, fallback) 

409 if recipient.is_group: 

410 if not jid: 

411 recipient.log.warning( 

412 "Invalid replied-to JID: %s, assuming reply to the system participant.", 

413 jid, 

414 ) 

415 resource = jid.resource 

416 if resource: 

417 await recipient.participants.ensure_filled() 

418 participant = recipient.participants.get_by_resource(resource) 

419 if participant is None: 

420 participant = recipient.participants.create( 

421 nick=resource, temp=True 

422 ) 

423 reply.to = participant 

424 else: 

425 reply.to = recipient.get_system_participant() 

426 else: 

427 if jid.bare == session.user_jid.bare: 

428 reply.to = "self" 

429 elif jid.bare == recipient.jid.bare: 

430 reply.to = "contact" 

431 else: 

432 recipient.log.warning( 

433 "Invalid replied-to JID: %r, assuming reply to the contact (%r)", 

434 jid, 

435 recipient.jid, 

436 ) 

437 

438 return text, reply 

439 

440 async def __dispatch_nonbob_sticker( 

441 self, 

442 attachment: _IncomingAttachment, 

443 recipient: AnyRecipient, 

444 fallback: str, 

445 reply: Reply | None = None, 

446 thread: str | None = None, 

447 ) -> str | None: 

448 if attachment.cid: 

449 with self.xmpp.store.session() as orm: 

450 sticker = self.xmpp.store.bob.get_sticker(orm, attachment.cid) 

451 else: 

452 sticker = None 

453 if sticker is None: 

454 async with attachment.attachment.get() as response: 

455 response.raise_for_status() 

456 data = await response.read() 

457 cid = "sha256+" + hashlib.sha256(data).hexdigest() 

458 with self.xmpp.store.session() as orm: 

459 sticker = self.xmpp.store.bob.set_sticker( 

460 orm, cid, data, attachment.attachment.content_type 

461 ) 

462 orm.commit() 

463 if sticker is None: 

464 msg = XMPPMessage[Any]( 

465 body=f"{attachment.attachment.url}\n{fallback}", 

466 thread=thread, 

467 reply=reply, 

468 ) 

469 if recipient.is_group: 

470 assert isinstance(msg, RoomMessageProtocol) 

471 else: 

472 assert isinstance(msg, ContactMessageProtocol) 

473 return await recipient.on_message(msg) 

474 

475 sticker.thread = thread 

476 sticker.reply = reply 

477 return await recipient.on_sticker(sticker) # type:ignore[arg-type] 

478 

479 async def __dispatch_bob( 

480 self, 

481 from_: JID, 

482 cid: str, 

483 recipient: AnyRecipient, 

484 reply: Reply | None = None, 

485 thread: str | None = None, 

486 ) -> str | None: 

487 with self.xmpp.store.session() as orm: 

488 sticker = self.xmpp.store.bob.get_sticker(orm, cid) 

489 if sticker is None: 

490 await self.xmpp.plugin["xep_0231"].get_bob( 

491 from_, cid, ifrom=self.xmpp.boundjid 

492 ) 

493 with self.xmpp.store.session() as orm: 

494 sticker = self.xmpp.store.bob.get_sticker(orm, cid) 

495 assert sticker is not None 

496 sticker.reply = reply 

497 sticker.thread = thread 

498 if recipient.is_group: 

499 assert isinstance(sticker, RoomSticker) 

500 else: 

501 assert isinstance(sticker, ContactSticker) 

502 return await recipient.on_sticker(sticker) 

503 

504 

505def redact_url(text: str) -> str: 

506 needle = config.NO_UPLOAD_URL_PREFIX or config.UPLOAD_URL_PREFIX 

507 if not needle: 

508 return text 

509 return text.replace(needle, "") 

510 

511 

512def parse_link_previews(link_metadatas: list[LinkMetadata]) -> tuple[LinkPreview, ...]: 

513 result = [] 

514 for link_metadata in link_metadatas: 

515 preview: LinkPreview = dict_to_named_tuple(link_metadata, LinkPreview) # type:ignore[arg-type] 

516 if ( 

517 preview.image 

518 and isinstance(preview.image, str) 

519 and preview.image.startswith("data:image/jpeg;base64,") 

520 ): 

521 try: 

522 image = base64.b64decode( 

523 preview.image.removeprefix("data:image/jpeg;base64,") 

524 ) 

525 except Exception as e: # noqa: BLE001 

526 log.warning( 

527 "Could not decode base64-encoded image: %s '%s'", e, preview.image 

528 ) 

529 else: 

530 preview = preview._replace(image=image) 

531 result.append(preview) 

532 return tuple(result) 

533 

534 

535log = logging.getLogger(__name__)