signal.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  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
  20. from mausignald.types import Address, TrustLevel
  21. from mautrix.bridge.commands import SECTION_ADMIN, HelpSection, command_handler
  22. from mautrix.types import EventID
  23. from .. import portal as po, puppet as pu
  24. from ..util import normalize_number
  25. from .auth import make_qr
  26. from .typehint import CommandEvent
  27. try:
  28. import PIL as _
  29. import qrcode
  30. except ImportError:
  31. qrcode = None
  32. SECTION_SIGNAL = HelpSection("Signal actions", 20, "")
  33. async def _get_puppet_from_cmd(evt: CommandEvent) -> pu.Puppet | None:
  34. try:
  35. phone = normalize_number("".join(evt.args))
  36. except Exception:
  37. await evt.reply(
  38. f"**Usage:** `$cmdprefix+sp {evt.command} <phone>` "
  39. "(enter phone number in international format)"
  40. )
  41. return None
  42. puppet: pu.Puppet = await pu.Puppet.get_by_address(Address(number=phone))
  43. if not puppet.uuid and evt.sender.username:
  44. uuid = await evt.bridge.signal.find_uuid(evt.sender.username, puppet.number)
  45. if uuid:
  46. await puppet.handle_uuid_receive(uuid)
  47. return puppet
  48. def _format_safety_number(number: str) -> str:
  49. line_size = 20
  50. chunk_size = 5
  51. return "\n".join(
  52. " ".join(
  53. [
  54. number[chunk : chunk + chunk_size]
  55. for chunk in range(line, line + line_size, chunk_size)
  56. ]
  57. )
  58. for line in range(0, len(number), line_size)
  59. )
  60. def _pill(puppet: "pu.Puppet") -> str:
  61. return f"[{puppet.name}](https://matrix.to/#/{puppet.mxid})"
  62. @command_handler(
  63. needs_auth=True,
  64. management_only=False,
  65. help_section=SECTION_SIGNAL,
  66. help_text="Open a private chat portal with a specific phone number",
  67. help_args="<_phone_>",
  68. )
  69. async def pm(evt: CommandEvent) -> None:
  70. puppet = await _get_puppet_from_cmd(evt)
  71. if not puppet:
  72. return
  73. portal = await po.Portal.get_by_chat_id(
  74. puppet.address, receiver=evt.sender.username, create=True
  75. )
  76. if portal.mxid:
  77. await evt.reply(
  78. f"You already have a private chat with {puppet.name}: "
  79. f"[{portal.mxid}](https://matrix.to/#/{portal.mxid})"
  80. )
  81. await portal.main_intent.invite_user(portal.mxid, evt.sender.mxid)
  82. return
  83. await portal.create_matrix_room(evt.sender, puppet.address)
  84. await evt.reply(f"Created a portal room with {_pill(puppet)} and invited you to it")
  85. @command_handler(
  86. needs_auth=True,
  87. management_only=False,
  88. help_section=SECTION_SIGNAL,
  89. help_text="Join a Signal group with an invite link",
  90. help_args="<_link_>",
  91. )
  92. async def join(evt: CommandEvent) -> EventID:
  93. if len(evt.args) == 0:
  94. return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
  95. try:
  96. resp = await evt.bridge.signal.join_group(evt.sender.username, evt.args[0])
  97. if resp.pending_admin_approval:
  98. return await evt.reply(
  99. f"Successfully requested to join {resp.title}, waiting for admin approval."
  100. )
  101. else:
  102. return await evt.reply(f"Successfully joined {resp.title}")
  103. except Exception:
  104. evt.log.exception("Error trying to join group")
  105. await evt.reply("Failed to join group (see logs for more details)")
  106. @command_handler(
  107. needs_auth=True,
  108. management_only=False,
  109. help_section=SECTION_SIGNAL,
  110. help_text="Get the invite link to the current group",
  111. )
  112. async def invite_link(evt: CommandEvent) -> EventID:
  113. if not evt.is_portal:
  114. return await evt.reply("This is not a portal room.")
  115. group = await evt.bridge.signal.get_group(
  116. evt.sender.username, evt.portal.chat_id, evt.portal.revision
  117. )
  118. if not group:
  119. await evt.reply("Failed to get group info")
  120. elif not group.invite_link:
  121. await evt.reply("Invite link not available")
  122. else:
  123. await evt.reply(group.invite_link)
  124. @command_handler(
  125. needs_auth=True,
  126. management_only=False,
  127. help_section=SECTION_SIGNAL,
  128. help_text="View the safety number of a specific user",
  129. help_args="[--qr] [_phone_]",
  130. )
  131. async def safety_number(evt: CommandEvent) -> None:
  132. show_qr = evt.args and evt.args[0].lower() == "--qr"
  133. if show_qr:
  134. if not qrcode:
  135. await evt.reply("Can't generate QR code: qrcode and/or PIL not installed")
  136. return
  137. evt.args = evt.args[1:]
  138. if len(evt.args) == 0 and evt.portal and evt.portal.is_direct:
  139. puppet = await pu.Puppet.get_by_address(evt.portal.chat_id)
  140. else:
  141. puppet = await _get_puppet_from_cmd(evt)
  142. if not puppet:
  143. return
  144. resp = await evt.bridge.signal.get_identities(evt.sender.username, puppet.address)
  145. if not resp.identities:
  146. await evt.reply(f"No identities found for {_pill(puppet)}")
  147. return
  148. most_recent = resp.identities[0]
  149. for identity in resp.identities:
  150. if identity.added > most_recent.added:
  151. most_recent = identity
  152. uuid = resp.address.uuid or "unknown"
  153. await evt.reply(
  154. f"### {puppet.name}\n\n"
  155. f"**UUID:** {uuid} \n"
  156. f"**Trust level:** {most_recent.trust_level} \n"
  157. f"**Safety number:**\n"
  158. f"```\n{_format_safety_number(most_recent.safety_number)}\n```"
  159. )
  160. if show_qr and most_recent.qr_code_data:
  161. data = base64.b64decode(most_recent.qr_code_data)
  162. content = await make_qr(evt.main_intent, data, "verification-qr.png")
  163. await evt.main_intent.send_message(evt.room_id, content)
  164. @command_handler(
  165. needs_auth=True,
  166. management_only=False,
  167. help_section=SECTION_SIGNAL,
  168. help_text="Set your Signal profile name",
  169. help_args="<_name_>",
  170. )
  171. async def set_profile_name(evt: CommandEvent) -> None:
  172. await evt.bridge.signal.set_profile(evt.sender.username, name=" ".join(evt.args))
  173. await evt.reply("Successfully updated profile name")
  174. _trust_levels = [x.value for x in TrustLevel]
  175. @command_handler(
  176. needs_auth=True,
  177. management_only=False,
  178. help_section=SECTION_SIGNAL,
  179. help_text="Mark another user's safety number as trusted",
  180. help_args="<_recipient phone_> [_level_] <_safety number_>",
  181. )
  182. async def mark_trusted(evt: CommandEvent) -> EventID:
  183. if len(evt.args) < 2:
  184. return await evt.reply(
  185. "**Usage:** `$cmdprefix+sp mark-trusted <recipient phone> [level] <safety number>`"
  186. )
  187. number = normalize_number(evt.args[0])
  188. remaining_args = evt.args[1:]
  189. trust_level = TrustLevel.TRUSTED_VERIFIED
  190. if len(evt.args) > 2 and evt.args[1].upper() in _trust_levels:
  191. trust_level = TrustLevel(evt.args[1])
  192. remaining_args = evt.args[2:]
  193. safety_num = "".join(remaining_args).replace("\n", "")
  194. if len(safety_num) != 60 or not safety_num.isdecimal():
  195. return await evt.reply("That doesn't look like a valid safety number")
  196. try:
  197. await evt.bridge.signal.trust(
  198. evt.sender.username,
  199. Address(number=number),
  200. safety_number=safety_num,
  201. trust_level=trust_level,
  202. )
  203. except UnknownIdentityKey as e:
  204. return await evt.reply(f"Failed to mark {number} as {trust_level.human_str}: {e}")
  205. return await evt.reply(f"Successfully marked {number} as {trust_level.human_str}")
  206. @command_handler(
  207. needs_admin=False,
  208. needs_auth=True,
  209. help_section=SECTION_SIGNAL,
  210. help_text="Sync data from Signal",
  211. )
  212. async def sync(evt: CommandEvent) -> None:
  213. await evt.sender.sync()
  214. await evt.reply("Sync complete")
  215. @command_handler(
  216. needs_admin=True,
  217. needs_auth=False,
  218. help_section=SECTION_ADMIN,
  219. help_text="Send raw requests to signald",
  220. help_args="[--user] <type> <_json_>",
  221. )
  222. async def raw(evt: CommandEvent) -> None:
  223. add_username = False
  224. while True:
  225. flag = evt.args[0].lower()
  226. if flag == "--user":
  227. add_username = True
  228. else:
  229. break
  230. evt.args = evt.args[1:]
  231. type = evt.args[0]
  232. version = "v0"
  233. if "." in type:
  234. version, type = type.split(".", 1)
  235. try:
  236. args = json.loads(" ".join(evt.args[1:]))
  237. except json.JSONDecodeError as e:
  238. await evt.reply(f"JSON decode error: {e}")
  239. return
  240. if add_username:
  241. if version == "v0" or (version == "v1" and type in ("send", "react")):
  242. args["username"] = evt.sender.username
  243. else:
  244. args["account"] = evt.sender.username
  245. if version:
  246. args["version"] = version
  247. try:
  248. resp_type, resp_data = await evt.bridge.signal._raw_request(type, **args)
  249. except Exception as e:
  250. await evt.reply(f"Error sending request: {e}")
  251. else:
  252. if resp_data is None:
  253. await evt.reply(f"Got reply `{resp_type}` with no content")
  254. else:
  255. await evt.reply(
  256. f"Got reply `{resp_type}`:\n\n```json\n{json.dumps(resp_data, indent=2)}\n```"
  257. )