puppet.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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:
  146. return
  147. self.number = number
  148. self.by_number[self.number] = self
  149. await self._set_number(number)
  150. async for portal in p.Portal.find_private_chats_with(Address(number=number)):
  151. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  152. portal.handle_uuid_receive(self.uuid)
  153. prev_mxid = self.get_mxid_from_id(Address(number=number))
  154. if await self.az.state_store.is_registered(prev_mxid):
  155. prev_intent = self.az.intent.user(prev_mxid)
  156. await self._migrate_memberships(prev_intent, self.default_mxid_intent)
  157. async def _handle_uuid_receive(self, uuid: UUID) -> None:
  158. self.log.debug(f"Found UUID for user: {uuid}")
  159. user = await u.User.get_by_username(self.number)
  160. if user and not user.uuid:
  161. user.uuid = self.uuid
  162. user.by_uuid[user.uuid] = user
  163. await user.update()
  164. self.uuid = uuid
  165. self.by_uuid[self.uuid] = self
  166. await self._set_uuid(uuid)
  167. async for portal in p.Portal.find_private_chats_with(Address(number=self.number)):
  168. self.log.trace(f"Updating chat_id of private chat portal {portal.receiver}")
  169. portal.handle_uuid_receive(self.uuid)
  170. prev_intent = self.default_mxid_intent
  171. self.default_mxid = self.get_mxid_from_id(self.address)
  172. self.default_mxid_intent = self.az.intent.user(self.default_mxid)
  173. self.intent = self._fresh_intent()
  174. await self.default_mxid_intent.ensure_registered()
  175. if self.name:
  176. await self.default_mxid_intent.set_displayname(self.name)
  177. self.log = Puppet.log.getChild(str(uuid))
  178. await self._migrate_memberships(prev_intent, self.default_mxid_intent)
  179. async def _migrate_memberships(self, prev_intent: IntentAPI, new_intent: IntentAPI) -> None:
  180. self.log.debug(f"Migrating memberships {prev_intent.mxid} -> {new_intent.mxid}")
  181. try:
  182. joined_rooms = await prev_intent.get_joined_rooms()
  183. except MForbidden as e:
  184. self.log.debug(
  185. f"Got MForbidden ({e.message}) when getting joined rooms of old mxid, "
  186. "assuming there are no rooms to rejoin"
  187. )
  188. return
  189. for room_id in joined_rooms:
  190. await prev_intent.invite_user(room_id, self.default_mxid)
  191. await self._migrate_powers(prev_intent, new_intent, room_id)
  192. await prev_intent.leave_room(room_id)
  193. await new_intent.join_room_by_id(room_id)
  194. async def _migrate_powers(
  195. self, prev_intent: IntentAPI, new_intent: IntentAPI, room_id: RoomID
  196. ) -> None:
  197. try:
  198. powers: PowerLevelStateEventContent
  199. powers = await prev_intent.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
  200. user_level = powers.get_user_level(prev_intent.mxid)
  201. pl_state_level = powers.get_event_level(EventType.ROOM_POWER_LEVELS)
  202. if user_level >= pl_state_level > powers.users_default:
  203. powers.ensure_user_level(new_intent.mxid, user_level)
  204. await prev_intent.send_state_event(room_id, EventType.ROOM_POWER_LEVELS, powers)
  205. except Exception:
  206. self.log.warning("Failed to migrate power levels", exc_info=True)
  207. async def update_info(self, info: Profile | Address, source: u.User) -> None:
  208. address = info.address if isinstance(info, Profile) else info
  209. if address.uuid and not self.uuid:
  210. await self.handle_uuid_receive(address.uuid)
  211. if address.number and not self.number:
  212. await self.handle_number_receive(address.number)
  213. self.log.debug("Updating info with %s (source: %s)", info, source.mxid)
  214. async with self._update_info_lock:
  215. update = False
  216. if isinstance(info, Profile) or self.name is None:
  217. update = await self._update_name(info) or update
  218. if isinstance(info, Profile):
  219. update = await self._update_avatar(info.avatar) or update
  220. elif self.config["bridge.contact_list_names"] != "disallow" and self.number:
  221. # Try to use a contact list avatar
  222. update = await self._update_avatar(f"contact-{self.number}") or update
  223. if update:
  224. await self.update()
  225. asyncio.create_task(self._update_portal_meta())
  226. @staticmethod
  227. def fmt_phone(number: str) -> str:
  228. if phonenumbers is None:
  229. return number
  230. parsed = phonenumbers.parse(number)
  231. fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
  232. return phonenumbers.format_number(parsed, fmt)
  233. @classmethod
  234. def _get_displayname(cls, info: Profile | Address) -> tuple[str, int]:
  235. quality = 10
  236. if isinstance(info, Profile):
  237. address = info.address
  238. name = None
  239. contact_names = cls.config["bridge.contact_list_names"]
  240. if info.profile_name:
  241. name = info.profile_name
  242. quality = 90 if contact_names == "prefer" else 100
  243. if info.contact_name:
  244. if contact_names == "prefer":
  245. quality = 100
  246. name = info.contact_name
  247. elif contact_names == "allow" and not name:
  248. quality = 50
  249. name = info.contact_name
  250. names = name.split("\x00") if name else []
  251. else:
  252. address = info
  253. names = []
  254. data = {
  255. "first_name": names[0] if len(names) > 0 else "",
  256. "last_name": names[-1] if len(names) > 1 else "",
  257. "full_name": " ".join(names),
  258. "phone": cls.fmt_phone(address.number) if address.number else None,
  259. "uuid": str(address.uuid) if address.uuid else None,
  260. "displayname": "Unknown user",
  261. }
  262. for pref in cls.config["bridge.displayname_preference"]:
  263. value = data.get(pref.replace(" ", "_"))
  264. if value:
  265. data["displayname"] = value
  266. break
  267. return cls.config["bridge.displayname_template"].format(**data), quality
  268. async def _update_name(self, info: Profile | Address) -> bool:
  269. name, quality = self._get_displayname(info)
  270. if quality >= self.name_quality and (name != self.name or not self.name_set):
  271. self.log.debug(
  272. "Updating name from '%s' to '%s' (quality: %d)", self.name, name, quality
  273. )
  274. self.name = name
  275. self.name_quality = quality
  276. try:
  277. await self.default_mxid_intent.set_displayname(self.name)
  278. self.name_set = True
  279. except Exception:
  280. self.log.exception("Error setting displayname")
  281. self.name_set = False
  282. return True
  283. elif name != self.name or not self.name_set:
  284. self.log.debug(
  285. "Not updating name from '%s' to '%s', new quality (%d) is lower than old (%d)",
  286. self.name,
  287. name,
  288. quality,
  289. self.name_quality,
  290. )
  291. elif self.name_quality == 0:
  292. # Name matches, but quality is not stored in database - store it now
  293. self.name_quality = quality
  294. return True
  295. return False
  296. @staticmethod
  297. async def upload_avatar(
  298. self: Puppet | p.Portal, path: str, intent: IntentAPI
  299. ) -> bool | tuple[str, ContentURI]:
  300. if not path:
  301. return False
  302. if not path.startswith("/"):
  303. path = os.path.join(self.config["signal.avatar_dir"], path)
  304. try:
  305. with open(path, "rb") as file:
  306. data = file.read()
  307. except FileNotFoundError:
  308. return False
  309. if not data:
  310. return False
  311. new_hash = hashlib.sha256(data).hexdigest()
  312. if self.avatar_set and new_hash == self.avatar_hash:
  313. return False
  314. mxc = await intent.upload_media(data, async_upload=self.config["homeserver.async_media"])
  315. return new_hash, mxc
  316. async def _update_avatar(self, path: str) -> bool:
  317. res = await Puppet.upload_avatar(self, path, self.default_mxid_intent)
  318. if res is False:
  319. return False
  320. self.avatar_hash, self.avatar_url = res
  321. try:
  322. await self.default_mxid_intent.set_avatar_url(self.avatar_url)
  323. self.avatar_set = True
  324. except Exception:
  325. self.log.exception("Error setting avatar")
  326. self.avatar_set = False
  327. return True
  328. async def _update_portal_meta(self) -> None:
  329. async for portal in p.Portal.find_private_chats_with(self.address):
  330. if portal.receiver == self.number:
  331. # This is a note to self chat, don't change the name
  332. continue
  333. try:
  334. await portal.update_puppet_name(self.name)
  335. await portal.update_puppet_avatar(self.avatar_hash, self.avatar_url)
  336. except Exception:
  337. self.log.exception(f"Error updating portal meta for {portal.receiver}")
  338. async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
  339. portal: p.Portal = await p.Portal.get_by_mxid(room_id)
  340. if not portal or not portal.is_direct:
  341. return True
  342. elif portal.chat_id.uuid and self.uuid:
  343. return portal.chat_id.uuid != self.uuid
  344. elif portal.chat_id.number and self.number:
  345. return portal.chat_id.number != self.number
  346. else:
  347. return True
  348. # region Database getters
  349. def _add_to_cache(self) -> None:
  350. if self.uuid:
  351. self.by_uuid[self.uuid] = self
  352. if self.number:
  353. self.by_number[self.number] = self
  354. if self.custom_mxid:
  355. self.by_custom_mxid[self.custom_mxid] = self
  356. async def save(self) -> None:
  357. await self.update()
  358. @classmethod
  359. async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Puppet | None:
  360. address = cls.get_id_from_mxid(mxid)
  361. if not address:
  362. return None
  363. return await cls.get_by_address(address, create)
  364. @classmethod
  365. @async_getter_lock
  366. async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
  367. try:
  368. return cls.by_custom_mxid[mxid]
  369. except KeyError:
  370. pass
  371. puppet = cast(cls, await super().get_by_custom_mxid(mxid))
  372. if puppet:
  373. puppet._add_to_cache()
  374. return puppet
  375. return None
  376. @classmethod
  377. def get_id_from_mxid(cls, mxid: UserID) -> Address | None:
  378. identifier = cls.mxid_template.parse(mxid)
  379. if not identifier:
  380. return None
  381. if identifier.startswith("phone_"):
  382. return Address(number="+" + identifier[len("phone_") :])
  383. else:
  384. try:
  385. return Address(uuid=UUID(identifier.upper()))
  386. except ValueError:
  387. return None
  388. @classmethod
  389. def get_mxid_from_id(cls, address: Address) -> UserID:
  390. if address.uuid:
  391. identifier = str(address.uuid).lower()
  392. elif address.number:
  393. identifier = f"phone_{address.number.lstrip('+')}"
  394. else:
  395. raise ValueError("Empty address")
  396. return UserID(cls.mxid_template.format_full(identifier))
  397. @classmethod
  398. @async_getter_lock
  399. async def get_by_address(cls, address: Address, create: bool = True) -> Puppet | None:
  400. puppet = await cls._get_by_address(address, create)
  401. if puppet and address.uuid and not puppet.uuid:
  402. # We found a UUID for this user, store it ASAP
  403. await puppet.handle_uuid_receive(address.uuid)
  404. return puppet
  405. @classmethod
  406. async def _get_by_address(cls, address: Address, create: bool = True) -> Puppet | None:
  407. if not address.is_valid:
  408. raise ValueError("Empty address")
  409. if address.uuid:
  410. try:
  411. return cls.by_uuid[address.uuid]
  412. except KeyError:
  413. pass
  414. if address.number:
  415. try:
  416. return cls.by_number[address.number]
  417. except KeyError:
  418. pass
  419. puppet = cast(cls, await super().get_by_address(address))
  420. if puppet is not None:
  421. puppet._add_to_cache()
  422. return puppet
  423. if create:
  424. puppet = cls(address.uuid, address.number)
  425. await puppet.insert()
  426. puppet._add_to_cache()
  427. return puppet
  428. return None
  429. @classmethod
  430. async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
  431. puppets = await super().all_with_custom_mxid()
  432. puppet: cls
  433. for index, puppet in enumerate(puppets):
  434. try:
  435. yield cls.by_uuid[puppet.uuid]
  436. except KeyError:
  437. try:
  438. yield cls.by_number[puppet.number]
  439. except KeyError:
  440. puppet._add_to_cache()
  441. yield puppet
  442. # endregion