signal.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  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 mausignald.errors import UnknownIdentityKey
  20. from mausignald.types import Address
  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 .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. )