signal.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. # mautrix-signal - A Matrix-Signal puppeting bridge
  2. # Copyright (C) 2020 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 typing import Optional
  17. import base64
  18. import json
  19. from mautrix.bridge.commands import SECTION_ADMIN, HelpSection, command_handler
  20. from mautrix.types import EventID
  21. from mausignald.errors import UnknownIdentityKey
  22. from mausignald.types import Address
  23. from .. import portal as po
  24. from .. import puppet as pu
  25. from .auth import make_qr, remove_extra_chars
  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) -> Optional["pu.Puppet"]:
  34. if len(evt.args) == 0 or not evt.args[0].startswith("+"):
  35. await evt.reply(
  36. f"**Usage:** `$cmdprefix+sp {evt.command} <phone>` "
  37. "(enter phone number in international format)"
  38. )
  39. return None
  40. phone = "".join(evt.args).translate(remove_extra_chars)
  41. if not phone[1:].isdecimal():
  42. await evt.reply(
  43. f"**Usage:** `$cmdprefix+sp {evt.command} <phone>` "
  44. "(enter phone number in international format)"
  45. )
  46. return None
  47. return await pu.Puppet.get_by_address(Address(number=phone))
  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="Get the invite link to the current group",
  90. )
  91. async def invite_link(evt: CommandEvent) -> EventID:
  92. if not evt.is_portal:
  93. return await evt.reply("This is not a portal room.")
  94. group = await evt.bridge.signal.get_group(
  95. evt.sender.username, evt.portal.chat_id, evt.portal.revision
  96. )
  97. if not group:
  98. await evt.reply("Failed to get group info")
  99. elif not group.invite_link:
  100. await evt.reply("Invite link not available")
  101. else:
  102. await evt.reply(group.invite_link)
  103. @command_handler(
  104. needs_auth=True,
  105. management_only=False,
  106. help_section=SECTION_SIGNAL,
  107. help_text="View the safety number of a specific user",
  108. help_args="[--qr] [_phone_]",
  109. )
  110. async def safety_number(evt: CommandEvent) -> None:
  111. show_qr = evt.args and evt.args[0].lower() == "--qr"
  112. if show_qr:
  113. if not qrcode:
  114. await evt.reply("Can't generate QR code: qrcode and/or PIL not installed")
  115. return
  116. evt.args = evt.args[1:]
  117. if len(evt.args) == 0 and evt.portal and evt.portal.is_direct:
  118. puppet = await pu.Puppet.get_by_address(evt.portal.chat_id)
  119. else:
  120. puppet = await _get_puppet_from_cmd(evt)
  121. if not puppet:
  122. return
  123. resp = await evt.bridge.signal.get_identities(evt.sender.username, puppet.address)
  124. if not resp.identities:
  125. await evt.reply(f"No identities found for {_pill(puppet)}")
  126. return
  127. most_recent = resp.identities[0]
  128. for identity in resp.identities:
  129. if identity.added > most_recent.added:
  130. most_recent = identity
  131. uuid = resp.address.uuid or "unknown"
  132. await evt.reply(
  133. f"### {puppet.name}\n\n"
  134. f"**UUID:** {uuid} \n"
  135. f"**Trust level:** {most_recent.trust_level} \n"
  136. f"**Safety number:**\n"
  137. f"```\n{_format_safety_number(most_recent.safety_number)}\n```"
  138. )
  139. if show_qr and most_recent.qr_code_data:
  140. data = base64.b64decode(most_recent.qr_code_data)
  141. content = await make_qr(evt.main_intent, data, "verification-qr.png")
  142. await evt.main_intent.send_message(evt.room_id, content)
  143. @command_handler(
  144. needs_auth=True,
  145. management_only=False,
  146. help_section=SECTION_SIGNAL,
  147. help_text="Set your Signal profile name",
  148. help_args="<_name_>",
  149. )
  150. async def set_profile_name(evt: CommandEvent) -> None:
  151. await evt.bridge.signal.set_profile(evt.sender.username, name=" ".join(evt.args))
  152. await evt.reply("Successfully updated profile name")
  153. @command_handler(
  154. needs_auth=True,
  155. management_only=False,
  156. help_section=SECTION_SIGNAL,
  157. help_text="Mark another user's safety number as trusted",
  158. help_args="<_recipient phone_> <_safety number_>",
  159. )
  160. async def mark_trusted(evt: CommandEvent) -> EventID:
  161. if len(evt.args) < 2:
  162. return await evt.reply(
  163. "**Usage:** `$cmdprefix+sp mark-trusted <recipient phone> " "<safety number>`"
  164. )
  165. number = evt.args[0].translate(remove_extra_chars)
  166. safety_num = "".join(evt.args[1:]).replace("\n", "")
  167. if len(safety_num) != 60 or not safety_num.isdecimal():
  168. return await evt.reply("That doesn't look like a valid safety number")
  169. try:
  170. await evt.bridge.signal.trust(
  171. evt.sender.username,
  172. Address(number=number),
  173. safety_number=safety_num,
  174. trust_level="TRUSTED_VERIFIED",
  175. )
  176. except UnknownIdentityKey as e:
  177. return await evt.reply(f"Failed to mark {number} as trusted: {e}")
  178. return await evt.reply(f"Successfully marked {number} as trusted")
  179. @command_handler(
  180. needs_admin=False,
  181. needs_auth=True,
  182. help_section=SECTION_SIGNAL,
  183. help_text="Sync data from Signal",
  184. )
  185. async def sync(evt: CommandEvent) -> None:
  186. await evt.sender.sync()
  187. await evt.reply("Sync complete")
  188. @command_handler(
  189. needs_admin=True,
  190. needs_auth=False,
  191. help_section=SECTION_ADMIN,
  192. help_text="Send raw requests to signald",
  193. help_args="[--user] <type> <_json_>",
  194. )
  195. async def raw(evt: CommandEvent) -> None:
  196. add_username = False
  197. while True:
  198. flag = evt.args[0].lower()
  199. if flag == "--user":
  200. add_username = True
  201. else:
  202. break
  203. evt.args = evt.args[1:]
  204. type = evt.args[0]
  205. version = "v0"
  206. if "." in type:
  207. version, type = type.split(".", 1)
  208. try:
  209. args = json.loads(" ".join(evt.args[1:]))
  210. except json.JSONDecodeError as e:
  211. await evt.reply(f"JSON decode error: {e}")
  212. return
  213. if add_username:
  214. if version == "v0" or (version == "v1" and type in ("send", "react")):
  215. args["username"] = evt.sender.username
  216. else:
  217. args["account"] = evt.sender.username
  218. if version:
  219. args["version"] = version
  220. try:
  221. resp_type, resp_data = await evt.bridge.signal._raw_request(type, **args)
  222. except Exception as e:
  223. await evt.reply(f"Error sending request: {e}")
  224. else:
  225. if resp_data is None:
  226. await evt.reply(f"Got reply `{resp_type}` with no content")
  227. else:
  228. await evt.reply(
  229. f"Got reply `{resp_type}`:\n\n" f"```json\n{json.dumps(resp_data, indent=2)}\n```"
  230. )