signal.py 8.4 KB

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