puppet.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. # mautrix-signal - A Matrix-Signal puppeting bridge
  2. # Copyright (C) 2021 Tulir Asokan
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. from __future__ import annotations
  17. from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
  18. from uuid import UUID
  19. import asyncio
  20. import hashlib
  21. import os.path
  22. from yarl import URL
  23. from mausignald.types import Address, Profile
  24. from mautrix.appservice import IntentAPI
  25. from mautrix.bridge import BasePuppet, async_getter_lock
  26. from mautrix.errors import MForbidden
  27. from mautrix.types import (
  28. ContentURI,
  29. EventType,
  30. PowerLevelStateEventContent,
  31. RoomID,
  32. SyncToken,
  33. UserID,
  34. )
  35. from mautrix.util.simple_template import SimpleTemplate
  36. from . import portal as p, user as u
  37. from .config import Config
  38. from .db import Puppet as DBPuppet
  39. if TYPE_CHECKING:
  40. from .__main__ import SignalBridge
  41. try:
  42. import phonenumbers
  43. except ImportError:
  44. phonenumbers = None
  45. class Puppet(DBPuppet, BasePuppet):
  46. by_uuid: dict[UUID, Puppet] = {}
  47. by_number: dict[str, Puppet] = {}
  48. by_custom_mxid: dict[UserID, Puppet] = {}
  49. hs_domain: str
  50. mxid_template: SimpleTemplate[str]
  51. config: Config
  52. default_mxid_intent: IntentAPI
  53. default_mxid: UserID
  54. _uuid_lock: asyncio.Lock
  55. _update_info_lock: asyncio.Lock
  56. def __init__(
  57. self,
  58. uuid: UUID | None,
  59. number: str | None,
  60. name: str | None = None,
  61. name_quality: int = 0,
  62. avatar_url: ContentURI | None = None,
  63. avatar_hash: str | None = None,
  64. name_set: bool = False,
  65. avatar_set: bool = False,
  66. uuid_registered: bool = False,
  67. number_registered: bool = False,
  68. custom_mxid: UserID | None = None,
  69. access_token: str | None = None,
  70. next_batch: SyncToken | None = None,
  71. base_url: URL | None = None,
  72. ) -> None:
  73. super().__init__(
  74. uuid=uuid,
  75. number=number,
  76. name=name,
  77. name_quality=name_quality,
  78. avatar_url=avatar_url,
  79. avatar_hash=avatar_hash,
  80. name_set=name_set,
  81. avatar_set=avatar_set,
  82. uuid_registered=uuid_registered,
  83. number_registered=number_registered,
  84. custom_mxid=custom_mxid,
  85. access_token=access_token,
  86. next_batch=next_batch,
  87. base_url=base_url,
  88. )
  89. self.log = self.log.getChild(str(uuid) if uuid else number)
  90. self.default_mxid = self.get_mxid_from_id(self.address)
  91. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  92. self.intent = self._fresh_intent()
  93. self._uuid_lock = asyncio.Lock()
  94. self._update_info_lock = asyncio.Lock()
  95. @classmethod
  96. def init_cls(cls, bridge: "SignalBridge") -> AsyncIterable[Awaitable[None]]:
  97. cls.config = bridge.config
  98. cls.loop = bridge.loop
  99. cls.mx = bridge.matrix
  100. cls.az = bridge.az
  101. cls.hs_domain = cls.config["homeserver.domain"]
  102. cls.mxid_template = SimpleTemplate(
  103. cls.config["bridge.username_template"],
  104. "userid",
  105. prefix="@",
  106. suffix=f":{cls.hs_domain}",
  107. type=str,
  108. )
  109. cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
  110. cls.homeserver_url_map = {
  111. server: URL(url)
  112. for server, url in cls.config["bridge.double_puppet_server_map"].items()
  113. }
  114. cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
  115. cls.login_shared_secret_map = {
  116. server: secret.encode("utf-8")
  117. for server, secret in cls.config["bridge.login_shared_secret_map"].items()
  118. }
  119. cls.login_device_name = "Signal Bridge"
  120. return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
  121. def intent_for(self, portal: p.Portal) -> IntentAPI:
  122. if portal.chat_id == self.address:
  123. return self.default_mxid_intent
  124. return self.intent
  125. @property
  126. def is_registered(self) -> bool:
  127. return self.uuid_registered if self.uuid is not None else self.number_registered
  128. @is_registered.setter
  129. def is_registered(self, value: bool) -> None:
  130. if self.uuid is not None:
  131. self.uuid_registered = value
  132. else:
  133. self.number_registered = value
  134. @property
  135. def address(self) -> Address:
  136. return Address(uuid=self.uuid, number=self.number)
  137. async def handle_uuid_receive(self, uuid: UUID) -> None:
  138. async with self._uuid_lock:
  139. if self.uuid:
  140. # Received UUID was handled while this call was waiting
  141. return
  142. await self._handle_uuid_receive(uuid)
  143. async def handle_number_receive(self, number: str) -> None:
  144. async with self._uuid_lock:
  145. if self.number == number:
  146. return
  147. if self.number:
  148. self.by_number.pop(self.number, None)
  149. self.number = number
  150. self.by_number[self.number] = self
  151. await self._set_number(number)
  152. async for portal in p.Portal.find_private_chats_with(Address(number=number)):
  153. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  154. portal.handle_uuid_receive(self.uuid)
  155. prev_mxid = self.get_mxid_from_id(Address(number=number))
  156. if await self.az.state_store.is_registered(prev_mxid):
  157. prev_intent = self.az.intent.user(prev_mxid)
  158. await self._migrate_memberships(prev_intent, self.default_mxid_intent)
  159. async def _handle_uuid_receive(self, uuid: UUID) -> None:
  160. self.log.debug(f"Found UUID for user: {uuid}")
  161. user = await u.User.get_by_username(self.number)
  162. if user and not user.uuid:
  163. user.uuid = self.uuid
  164. user.by_uuid[user.uuid] = user
  165. await user.update()
  166. self.uuid = uuid
  167. self.by_uuid[self.uuid] = self
  168. await self._set_uuid(uuid)
  169. async for portal in p.Portal.find_private_chats_with(Address(number=self.number)):
  170. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  171. portal.handle_uuid_receive(self.uuid)
  172. prev_intent = self.default_mxid_intent
  173. self.default_mxid = self.get_mxid_from_id(self.address)
  174. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  175. self.intent = self._fresh_intent()
  176. await self.default_mxid_intent.ensure_registered()
  177. if self.name:
  178. await self.default_mxid_intent.set_displayname(self.name)
  179. self.log = Puppet.log.getChild(str(uuid))
  180. await self._migrate_memberships(prev_intent, self.default_mxid_intent)
  181. async def _migrate_memberships(self, prev_intent: IntentAPI, new_intent: IntentAPI) -> None:
  182. self.log.debug(f"Migrating memberships {prev_intent.mxid} -> {new_intent.mxid}")
  183. try:
  184. joined_rooms = await prev_intent.get_joined_rooms()
  185. except MForbidden as e:
  186. self.log.debug(
  187. f"Got MForbidden ({e.message}) when getting joined rooms of old mxid, "
  188. "assuming there are no rooms to rejoin"
  189. )
  190. return
  191. for room_id in joined_rooms:
  192. await prev_intent.invite_user(room_id, self.default_mxid)
  193. await self._migrate_powers(prev_intent, new_intent, room_id)
  194. await prev_intent.leave_room(room_id)
  195. await new_intent.join_room_by_id(room_id)
  196. async def _migrate_powers(
  197. self, prev_intent: IntentAPI, new_intent: IntentAPI, room_id: RoomID
  198. ) -> None:
  199. try:
  200. powers: PowerLevelStateEventContent
  201. powers = await prev_intent.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
  202. user_level = powers.get_user_level(prev_intent.mxid)
  203. pl_state_level = powers.get_event_level(EventType.ROOM_POWER_LEVELS)
  204. if user_level >= pl_state_level > powers.users_default:
  205. powers.ensure_user_level(new_intent.mxid, user_level)
  206. await prev_intent.send_state_event(room_id, EventType.ROOM_POWER_LEVELS, powers)
  207. except Exception:
  208. self.log.warning("Failed to migrate power levels", exc_info=True)
  209. async def update_info(self, info: Profile | Address, source: u.User) -> None:
  210. update = False
  211. address = info.address if isinstance(info, Profile) else info
  212. if address.uuid and not self.uuid:
  213. await self.handle_uuid_receive(address.uuid)
  214. if address.number and address.number != self.number:
  215. await self.handle_number_receive(address.number)
  216. update = True
  217. self.log.debug("Updating info with %s (source: %s)", info, source.mxid)
  218. async with self._update_info_lock:
  219. if isinstance(info, Profile) or self.name is None:
  220. update = await self._update_name(info) or update
  221. if isinstance(info, Profile):
  222. update = await self._update_avatar(info.avatar) or update
  223. elif self.config["bridge.contact_list_names"] != "disallow" and self.number:
  224. # Try to use a contact list avatar
  225. update = await self._update_avatar(f"contact-{self.number}") or update
  226. if update:
  227. await self.update()
  228. asyncio.create_task(self._update_portal_meta())
  229. @staticmethod
  230. def fmt_phone(number: str) -> str:
  231. if phonenumbers is None:
  232. return number
  233. parsed = phonenumbers.parse(number)
  234. fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
  235. return phonenumbers.format_number(parsed, fmt)
  236. @classmethod
  237. def _get_displayname(cls, info: Profile | Address) -> tuple[str, int]:
  238. quality = 10
  239. if isinstance(info, Profile):
  240. address = info.address
  241. name = None
  242. contact_names = cls.config["bridge.contact_list_names"]
  243. if info.profile_name:
  244. name = info.profile_name
  245. quality = 90 if contact_names == "prefer" else 100
  246. if info.contact_name:
  247. if contact_names == "prefer":
  248. quality = 100
  249. name = info.contact_name
  250. elif contact_names == "allow" and not name:
  251. quality = 50
  252. name = info.contact_name
  253. names = name.split("\x00") if name else []
  254. else:
  255. address = info
  256. names = []
  257. data = {
  258. "first_name": names[0] if len(names) > 0 else "",
  259. "last_name": names[-1] if len(names) > 1 else "",
  260. "full_name": " ".join(names),
  261. "phone": cls.fmt_phone(address.number) if address.number else None,
  262. "uuid": str(address.uuid) if address.uuid else None,
  263. "displayname": "Unknown user",
  264. }
  265. for pref in cls.config["bridge.displayname_preference"]:
  266. value = data.get(pref.replace(" ", "_"))
  267. if value:
  268. data["displayname"] = value
  269. break
  270. return cls.config["bridge.displayname_template"].format(**data), quality
  271. async def _update_name(self, info: Profile | Address) -> bool:
  272. name, quality = self._get_displayname(info)
  273. if quality >= self.name_quality and (name != self.name or not self.name_set):
  274. self.log.debug(
  275. "Updating name from '%s' to '%s' (quality: %d)", self.name, name, quality
  276. )
  277. self.name = name
  278. self.name_quality = quality
  279. try:
  280. await self.default_mxid_intent.set_displayname(self.name)
  281. self.name_set = True
  282. except Exception:
  283. self.log.exception("Error setting displayname")
  284. self.name_set = False
  285. return True
  286. elif name != self.name or not self.name_set:
  287. self.log.debug(
  288. "Not updating name from '%s' to '%s', new quality (%d) is lower than old (%d)",
  289. self.name,
  290. name,
  291. quality,
  292. self.name_quality,
  293. )
  294. elif self.name_quality == 0:
  295. # Name matches, but quality is not stored in database - store it now
  296. self.name_quality = quality
  297. return True
  298. return False
  299. @staticmethod
  300. async def upload_avatar(
  301. self: Puppet | p.Portal, path: str, intent: IntentAPI
  302. ) -> bool | tuple[str, ContentURI]:
  303. if not path:
  304. return False
  305. if not path.startswith("/"):
  306. path = os.path.join(self.config["signal.avatar_dir"], path)
  307. try:
  308. with open(path, "rb") as file:
  309. data = file.read()
  310. except FileNotFoundError:
  311. return False
  312. if not data:
  313. return False
  314. new_hash = hashlib.sha256(data).hexdigest()
  315. if self.avatar_set and new_hash == self.avatar_hash:
  316. return False
  317. mxc = await intent.upload_media(data, async_upload=self.config["homeserver.async_media"])
  318. return new_hash, mxc
  319. async def _update_avatar(self, path: str) -> bool:
  320. res = await Puppet.upload_avatar(self, path, self.default_mxid_intent)
  321. if res is False:
  322. return False
  323. self.avatar_hash, self.avatar_url = res
  324. try:
  325. await self.default_mxid_intent.set_avatar_url(self.avatar_url)
  326. self.avatar_set = True
  327. except Exception:
  328. self.log.exception("Error setting avatar")
  329. self.avatar_set = False
  330. return True
  331. async def _update_portal_meta(self) -> None:
  332. async for portal in p.Portal.find_private_chats_with(self.address):
  333. if portal.receiver == self.number:
  334. # This is a note to self chat, don't change the name
  335. continue
  336. try:
  337. await portal.update_puppet_name(self.name)
  338. await portal.update_puppet_avatar(self.avatar_hash, self.avatar_url)
  339. if self.number:
  340. await portal.update_puppet_number(self.fmt_phone(self.number))
  341. except Exception:
  342. self.log.exception(f"Error updating portal meta for {portal.receiver}")
  343. async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
  344. portal: p.Portal = await p.Portal.get_by_mxid(room_id)
  345. if not portal or not portal.is_direct:
  346. return True
  347. elif portal.chat_id.uuid and self.uuid:
  348. return portal.chat_id.uuid != self.uuid
  349. elif portal.chat_id.number and self.number:
  350. return portal.chat_id.number != self.number
  351. else:
  352. return True
  353. # region Database getters
  354. def _add_to_cache(self) -> None:
  355. if self.uuid:
  356. self.by_uuid[self.uuid] = self
  357. if self.number:
  358. self.by_number[self.number] = self
  359. if self.custom_mxid:
  360. self.by_custom_mxid[self.custom_mxid] = self
  361. async def save(self) -> None:
  362. await self.update()
  363. @classmethod
  364. async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Puppet | None:
  365. address = cls.get_id_from_mxid(mxid)
  366. if not address:
  367. return None
  368. return await cls.get_by_address(address, create)
  369. @classmethod
  370. @async_getter_lock
  371. async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
  372. try:
  373. return cls.by_custom_mxid[mxid]
  374. except KeyError:
  375. pass
  376. puppet = cast(cls, await super().get_by_custom_mxid(mxid))
  377. if puppet:
  378. puppet._add_to_cache()
  379. return puppet
  380. return None
  381. @classmethod
  382. def get_id_from_mxid(cls, mxid: UserID) -> Address | None:
  383. identifier = cls.mxid_template.parse(mxid)
  384. if not identifier:
  385. return None
  386. if identifier.startswith("phone_"):
  387. return Address(number="+" + identifier[len("phone_") :])
  388. else:
  389. try:
  390. return Address(uuid=UUID(identifier.upper()))
  391. except ValueError:
  392. return None
  393. @classmethod
  394. def get_mxid_from_id(cls, address: Address) -> UserID:
  395. if address.uuid:
  396. identifier = str(address.uuid).lower()
  397. elif address.number:
  398. identifier = f"phone_{address.number.lstrip('+')}"
  399. else:
  400. raise ValueError("Empty address")
  401. return UserID(cls.mxid_template.format_full(identifier))
  402. @classmethod
  403. @async_getter_lock
  404. async def get_by_address(cls, address: Address, create: bool = True) -> Puppet | None:
  405. puppet = await cls._get_by_address(address, create)
  406. if puppet and address.uuid and not puppet.uuid:
  407. # We found a UUID for this user, store it ASAP
  408. await puppet.handle_uuid_receive(address.uuid)
  409. return puppet
  410. @classmethod
  411. async def _get_by_address(cls, address: Address, create: bool = True) -> Puppet | None:
  412. if not address.is_valid:
  413. raise ValueError("Empty address")
  414. if address.uuid:
  415. try:
  416. return cls.by_uuid[address.uuid]
  417. except KeyError:
  418. pass
  419. if address.number:
  420. try:
  421. return cls.by_number[address.number]
  422. except KeyError:
  423. pass
  424. puppet = cast(cls, await super().get_by_address(address))
  425. if puppet is not None:
  426. puppet._add_to_cache()
  427. return puppet
  428. if create:
  429. puppet = cls(address.uuid, address.number)
  430. await puppet.insert()
  431. puppet._add_to_cache()
  432. return puppet
  433. return None
  434. @classmethod
  435. async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
  436. puppets = await super().all_with_custom_mxid()
  437. puppet: cls
  438. for index, puppet in enumerate(puppets):
  439. try:
  440. yield cls.by_uuid[puppet.uuid]
  441. except KeyError:
  442. try:
  443. yield cls.by_number[puppet.number]
  444. except KeyError:
  445. puppet._add_to_cache()
  446. yield puppet
  447. # endregion