signal.py 13 KB

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