signal.py 13 KB

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