signal.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. # mautrix-signal - A Matrix-Signal puppeting bridge
  2. # Copyright (C) 2022 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. import base64
  18. import json
  19. from mausignald.errors import UnknownIdentityKey, UnregisteredUserError
  20. from mausignald.types import Address, GroupID, TrustLevel
  21. from mautrix.appservice import IntentAPI
  22. from mautrix.bridge.commands import SECTION_ADMIN, HelpSection, command_handler
  23. from mautrix.types import ContentURI, EventID, EventType, PowerLevelStateEventContent, RoomID
  24. from .. import portal as po, puppet as pu
  25. from ..util import normalize_number
  26. from .auth import make_qr
  27. from .typehint import CommandEvent
  28. try:
  29. import PIL as _
  30. import qrcode
  31. except ImportError:
  32. qrcode = None
  33. SECTION_SIGNAL = HelpSection("Signal actions", 20, "")
  34. async def _get_puppet_from_cmd(evt: CommandEvent) -> pu.Puppet | None:
  35. try:
  36. phone = normalize_number("".join(evt.args))
  37. except Exception:
  38. await evt.reply(
  39. f"**Usage:** `$cmdprefix+sp {evt.command} <phone>` "
  40. "(enter phone number in international format)"
  41. )
  42. return None
  43. puppet: pu.Puppet = await pu.Puppet.get_by_address(Address(number=phone))
  44. if not puppet.uuid and evt.sender.username:
  45. try:
  46. uuid = await evt.bridge.signal.find_uuid(evt.sender.username, puppet.number)
  47. except UnregisteredUserError:
  48. await evt.reply("User not registered")
  49. return None
  50. if uuid:
  51. await puppet.handle_uuid_receive(uuid)
  52. return puppet
  53. def _format_safety_number(number: str) -> str:
  54. line_size = 20
  55. chunk_size = 5
  56. return "\n".join(
  57. " ".join(
  58. [
  59. number[chunk : chunk + chunk_size]
  60. for chunk in range(line, line + line_size, chunk_size)
  61. ]
  62. )
  63. for line in range(0, len(number), line_size)
  64. )
  65. def _pill(puppet: "pu.Puppet") -> str:
  66. return f"[{puppet.name}](https://matrix.to/#/{puppet.mxid})"
  67. @command_handler(
  68. needs_auth=True,
  69. management_only=False,
  70. help_section=SECTION_SIGNAL,
  71. help_text="Open a private chat portal with a specific phone number",
  72. help_args="<_phone_>",
  73. )
  74. async def pm(evt: CommandEvent) -> None:
  75. puppet = await _get_puppet_from_cmd(evt)
  76. if not puppet:
  77. return
  78. portal = await po.Portal.get_by_chat_id(
  79. puppet.address, receiver=evt.sender.username, create=True
  80. )
  81. if portal.mxid:
  82. await evt.reply(
  83. f"You already have a private chat with {puppet.name}: "
  84. f"[{portal.mxid}](https://matrix.to/#/{portal.mxid})"
  85. )
  86. await portal.main_intent.invite_user(portal.mxid, evt.sender.mxid)
  87. return
  88. await portal.create_matrix_room(evt.sender, puppet.address)
  89. await evt.reply(f"Created a portal room with {_pill(puppet)} and invited you to it")
  90. @command_handler(
  91. needs_auth=True,
  92. management_only=False,
  93. help_section=SECTION_SIGNAL,
  94. help_text="Join a Signal group with an invite link",
  95. help_args="<_link_>",
  96. )
  97. async def join(evt: CommandEvent) -> EventID:
  98. if len(evt.args) == 0:
  99. return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
  100. try:
  101. resp = await evt.bridge.signal.join_group(evt.sender.username, evt.args[0])
  102. if resp.pending_admin_approval:
  103. return await evt.reply(
  104. f"Successfully requested to join {resp.title}, waiting for admin approval."
  105. )
  106. else:
  107. return await evt.reply(f"Successfully joined {resp.title}")
  108. except Exception:
  109. evt.log.exception("Error trying to join group")
  110. await evt.reply("Failed to join group (see logs for more details)")
  111. @command_handler(
  112. needs_auth=True,
  113. management_only=False,
  114. help_section=SECTION_SIGNAL,
  115. help_text="Get the invite link to the current group",
  116. )
  117. async def invite_link(evt: CommandEvent) -> EventID:
  118. if not evt.is_portal:
  119. return await evt.reply("This is not a portal room.")
  120. group = await evt.bridge.signal.get_group(
  121. evt.sender.username, evt.portal.chat_id, evt.portal.revision
  122. )
  123. if not group:
  124. await evt.reply("Failed to get group info")
  125. elif not group.invite_link:
  126. await evt.reply("Invite link not available")
  127. else:
  128. await evt.reply(group.invite_link)
  129. @command_handler(
  130. needs_auth=True,
  131. management_only=False,
  132. help_section=SECTION_SIGNAL,
  133. help_text="View the safety number of a specific user",
  134. help_args="[--qr] [_phone_]",
  135. )
  136. async def safety_number(evt: CommandEvent) -> None:
  137. show_qr = evt.args and evt.args[0].lower() == "--qr"
  138. if show_qr:
  139. if not qrcode:
  140. await evt.reply("Can't generate QR code: qrcode and/or PIL not installed")
  141. return
  142. evt.args = evt.args[1:]
  143. if len(evt.args) == 0 and evt.portal and evt.portal.is_direct:
  144. puppet = await pu.Puppet.get_by_address(evt.portal.chat_id)
  145. else:
  146. puppet = await _get_puppet_from_cmd(evt)
  147. if not puppet:
  148. return
  149. resp = await evt.bridge.signal.get_identities(evt.sender.username, puppet.address)
  150. if not resp.identities:
  151. await evt.reply(f"No identities found for {_pill(puppet)}")
  152. return
  153. most_recent = resp.identities[0]
  154. for identity in resp.identities:
  155. if identity.added > most_recent.added:
  156. most_recent = identity
  157. uuid = resp.address.uuid or "unknown"
  158. await evt.reply(
  159. f"### {puppet.name}\n\n"
  160. f"**UUID:** {uuid} \n"
  161. f"**Trust level:** {most_recent.trust_level} \n"
  162. f"**Safety number:**\n"
  163. f"```\n{_format_safety_number(most_recent.safety_number)}\n```"
  164. )
  165. if show_qr and most_recent.qr_code_data:
  166. data = base64.b64decode(most_recent.qr_code_data)
  167. content = await make_qr(evt.main_intent, data, "verification-qr.png")
  168. await evt.main_intent.send_message(evt.room_id, content)
  169. @command_handler(
  170. needs_auth=True,
  171. management_only=False,
  172. help_section=SECTION_SIGNAL,
  173. help_text="Set your Signal profile name",
  174. help_args="<_name_>",
  175. )
  176. async def set_profile_name(evt: CommandEvent) -> None:
  177. await evt.bridge.signal.set_profile(evt.sender.username, name=" ".join(evt.args))
  178. await evt.reply("Successfully updated profile name")
  179. _trust_levels = [x.value for x in TrustLevel]
  180. @command_handler(
  181. needs_auth=True,
  182. management_only=False,
  183. help_section=SECTION_SIGNAL,
  184. help_text="Mark another user's safety number as trusted",
  185. help_args="<_recipient phone_> [_level_] <_safety number_>",
  186. )
  187. async def mark_trusted(evt: CommandEvent) -> EventID:
  188. if len(evt.args) < 2:
  189. return await evt.reply(
  190. "**Usage:** `$cmdprefix+sp mark-trusted <recipient phone> [level] <safety number>`"
  191. )
  192. number = normalize_number(evt.args[0])
  193. remaining_args = evt.args[1:]
  194. trust_level = TrustLevel.TRUSTED_VERIFIED
  195. if len(evt.args) > 2 and evt.args[1].upper() in _trust_levels:
  196. trust_level = TrustLevel(evt.args[1])
  197. remaining_args = evt.args[2:]
  198. safety_num = "".join(remaining_args).replace("\n", "")
  199. if len(safety_num) != 60 or not safety_num.isdecimal():
  200. return await evt.reply("That doesn't look like a valid safety number")
  201. try:
  202. await evt.bridge.signal.trust(
  203. evt.sender.username,
  204. Address(number=number),
  205. safety_number=safety_num,
  206. trust_level=trust_level,
  207. )
  208. except UnknownIdentityKey as e:
  209. return await evt.reply(f"Failed to mark {number} as {trust_level.human_str}: {e}")
  210. return await evt.reply(f"Successfully marked {number} as {trust_level.human_str}")
  211. @command_handler(
  212. needs_admin=False,
  213. needs_auth=True,
  214. help_section=SECTION_SIGNAL,
  215. help_text="Sync data from Signal",
  216. )
  217. async def sync(evt: CommandEvent) -> None:
  218. await evt.sender.sync()
  219. await evt.reply("Sync complete")
  220. @command_handler(
  221. needs_admin=True,
  222. needs_auth=False,
  223. help_section=SECTION_ADMIN,
  224. help_text="Send raw requests to signald",
  225. help_args="[--user] <type> <_json_>",
  226. )
  227. async def raw(evt: CommandEvent) -> None:
  228. add_username = False
  229. while True:
  230. flag = evt.args[0].lower()
  231. if flag == "--user":
  232. add_username = True
  233. else:
  234. break
  235. evt.args = evt.args[1:]
  236. type = evt.args[0]
  237. version = "v0"
  238. if "." in type:
  239. version, type = type.split(".", 1)
  240. try:
  241. args = json.loads(" ".join(evt.args[1:]))
  242. except json.JSONDecodeError as e:
  243. await evt.reply(f"JSON decode error: {e}")
  244. return
  245. if add_username:
  246. if version == "v0" or (version == "v1" and type in ("send", "react")):
  247. args["username"] = evt.sender.username
  248. else:
  249. args["account"] = evt.sender.username
  250. if version:
  251. args["version"] = version
  252. try:
  253. resp_type, resp_data = await evt.bridge.signal._raw_request(type, **args)
  254. except Exception as e:
  255. await evt.reply(f"Error sending request: {e}")
  256. else:
  257. if resp_data is None:
  258. await evt.reply(f"Got reply `{resp_type}` with no content")
  259. else:
  260. await evt.reply(
  261. f"Got reply `{resp_type}`:\n\n```json\n{json.dumps(resp_data, indent=2)}\n```"
  262. )
  263. missing_power_warning = (
  264. "Warning: The bridge bot ([{bot_mxid}](https://matrix.to/#/{bot_mxid})) does not have "
  265. "sufficient privileges to change power levels on Matrix. Power level changes will not be "
  266. "bridged."
  267. )
  268. low_power_warning = (
  269. "Warning: The bridge bot ([{bot_mxid}](https://matrix.to/#/{bot_mxid})) has a power level "
  270. "below or equal to 50. Bridged moderator rights are currently hardcoded to PL 50, so the "
  271. "bridge bot must have a higher level to properly bridge them."
  272. )
  273. meta_power_warning = (
  274. "Warning: Permissions for changing name, topic and avatar cannot be set separately on Signal. "
  275. "Changes to those may not be bridged properly, unless the permissions are set to the same "
  276. "level or lower than state_default."
  277. )
  278. @command_handler(
  279. needs_auth=True,
  280. management_only=False,
  281. help_section=SECTION_SIGNAL,
  282. help_text="Create a Signal group for the current Matrix room.",
  283. )
  284. async def create(evt: CommandEvent) -> EventID:
  285. if evt.portal:
  286. return await evt.reply("This is already a portal room.")
  287. title, about, levels, encrypted, avatar_url = await get_initial_state(
  288. evt.az.intent, evt.room_id
  289. )
  290. portal = po.Portal(
  291. chat_id=GroupID(""),
  292. mxid=evt.room_id,
  293. name=title,
  294. topic=about or "",
  295. encrypted=encrypted,
  296. receiver="",
  297. avatar_url=avatar_url,
  298. )
  299. bot_pl = levels.get_user_level(evt.az.bot_mxid)
  300. if bot_pl < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
  301. await evt.reply(missing_power_warning.format(bot_mxid=evt.az.bot_mxid))
  302. elif bot_pl <= 50:
  303. await evt.reply(low_power_warning.format(bot_mxid=evt.az.bot_mxid))
  304. if levels.state_default < 50 and (
  305. levels.events[EventType.ROOM_NAME] >= 50
  306. or levels.events[EventType.ROOM_AVATAR] >= 50
  307. or levels.events[EventType.ROOM_TOPIC] >= 50
  308. ):
  309. await evt.reply(meta_power_warning)
  310. await portal.create_signal_group(evt.sender, levels)
  311. await evt.reply(f"Signal chat created. ID: {portal.chat_id}")
  312. @command_handler(
  313. name="id",
  314. needs_auth=True,
  315. management_only=False,
  316. help_section=SECTION_SIGNAL,
  317. help_text="Get the ID of the Signal chat where this room is bridged.",
  318. )
  319. async def get_id(evt: CommandEvent) -> EventID:
  320. if evt.portal:
  321. await evt.reply(f"This room is bridged to Signal chat ID `{evt.portal.chat_id}`.")
  322. await evt.reply("This is not a portal room.")
  323. @command_handler(
  324. needs_auth=True,
  325. management_only=False,
  326. help_section=SECTION_SIGNAL,
  327. help_text="Bridge the current Matrix room to the Signal chat with the given ID.",
  328. help_args="<id>",
  329. )
  330. async def bridge(evt: CommandEvent) -> EventID:
  331. if len(evt.args) == 0:
  332. return await evt.reply(
  333. "**Usage:** `$cmdprefix+sp bridge <Signal chat ID> [Matrix room ID]`"
  334. )
  335. if evt.portal:
  336. return await evt.reply("This is already a portal room.")
  337. chat_id = None
  338. try:
  339. chat_id= GroupID(evt.args[0])
  340. except ValueError:
  341. pass
  342. if not chat_id:
  343. return await evt.reply(
  344. "That doesn't seem like a Signal chat ID.\n\n"
  345. "Bridging private chats to existing rooms is not allowed."
  346. )
  347. portal = await po.Portal.get_by_chat_id(
  348. chat_id, create=True
  349. )
  350. title, about, levels, encrypted, avatar_url = await get_initial_state(
  351. evt.az.intent, evt.room_id
  352. )
  353. if portal.mxid:
  354. await evt.reply(
  355. "That Signal chat already has a portal at "
  356. f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). "
  357. )
  358. await portal.main_intent.invite_user(portal.mxid, evt.sender.mxid)
  359. return
  360. portal = po.Portal(
  361. chat_id=chat_id,
  362. mxid=evt.room_id,
  363. name=title,
  364. topic=about or "",
  365. encrypted=encrypted,
  366. receiver="",
  367. avatar_url=avatar_url,
  368. )
  369. bot_pl = levels.get_user_level(evt.az.bot_mxid)
  370. if bot_pl < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
  371. await evt.reply(missing_power_warning.format(bot_mxid=evt.az.bot_mxid))
  372. elif bot_pl <= 50:
  373. await evt.reply(low_power_warning.format(bot_mxid=evt.az.bot_mxid))
  374. if levels.state_default < 50 and (
  375. levels.events[EventType.ROOM_NAME] >= 50
  376. or levels.events[EventType.ROOM_AVATAR] >= 50
  377. or levels.events[EventType.ROOM_TOPIC] >= 50
  378. ):
  379. await evt.reply(meta_power_warning)
  380. await portal.bridge_signal_group(evt.sender, levels)
  381. await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
  382. async def get_initial_state(
  383. intent: IntentAPI, room_id: RoomID
  384. ) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool, ContentURI | None]:
  385. state = await intent.get_state(room_id)
  386. title: str | None = None
  387. about: str | None = None
  388. levels: PowerLevelStateEventContent | None = None
  389. encrypted: bool = False
  390. avatar_url: ContentURI | None = None
  391. for event in state:
  392. try:
  393. if event.type == EventType.ROOM_NAME:
  394. title = event.content.name
  395. elif event.type == EventType.ROOM_TOPIC:
  396. about = event.content.topic
  397. elif event.type == EventType.ROOM_POWER_LEVELS:
  398. levels = event.content
  399. elif event.type == EventType.ROOM_CANONICAL_ALIAS:
  400. title = title or event.content.canonical_alias
  401. elif event.type == EventType.ROOM_ENCRYPTION:
  402. encrypted = True
  403. elif event.type == EventType.ROOM_AVATAR:
  404. avatar_url = event.content.url
  405. except KeyError:
  406. # Some state event probably has empty content
  407. pass
  408. return title, about, levels, encrypted, avatar_url