pax_global_header00006660000000000000000000000064151570443120014514gustar00rootroot0000000000000052 comment=e567b659a428c5805bb13e4c253177fd7b2201c5 bbernhard-pysignalclirestapi-e567b65/000077500000000000000000000000001515704431200177145ustar00rootroot00000000000000bbernhard-pysignalclirestapi-e567b65/.gitignore000066400000000000000000000000601515704431200217000ustar00rootroot00000000000000dist/ *.pyc build/ pysignalclirestapi.egg-info/ bbernhard-pysignalclirestapi-e567b65/LICENSE.txt000066400000000000000000000020501515704431200215340ustar00rootroot00000000000000MIT License Copyright (c) 2019 Bernhard B. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. bbernhard-pysignalclirestapi-e567b65/README.md000066400000000000000000000106271515704431200212010ustar00rootroot00000000000000Small python library for the [Signal Cli REST API](https://github.com/bbernhard/signal-cli-rest-api) ### Quickstart If you have set up the REST API already, you can start sending and receiving messages in Python! Intialize the client ``` # Seperated for clarity SERVER = "http://localhose:8080" # Your server address and port SERVER_NUMBER ="+123456789" # The phone number you registered with the API signal = SignalCliRestApi(SERVER,SERVER_NUMBER) ``` Send a message ``` myMessage = "Hello World" # Your message myFriendSteve = +987654321 # The number you want to message (must be registered with Signal) sendMe = signal.send_message(message=myMessage,recipients=myFriendSteve) ``` receive messages ``` myMessages = signal.receive(send_read_receipts=True) # Send read receipts so everyone knows you have seen their message ``` ## Endpoint progress tracking Anything that was already part of the package before I added stuff should work, but I haven't fully tested all the ones I have added, which is why I haven't tried to merge yet! If an endpoint is not listed, assume it is not added. ### General | Service | Status | Function Name | Notes | | --- | --- | --- | --- | | about | Working | about() | Was already here. | No other general endpoints have been added. ### Devices No device endpoints have been added. ### Accounts No accounts endpoints have been added. ### Groups | Service | Status | Function Name | Notes | | --- | --- | --- | --- | | GET groups | Working | list_groups() | | | POST groups | In Progress | create_group() | Have not run into any issues yet | | GET group | Working | get_group() | Want to try messing with the group IDs to see how it reacts | | PUT group | Working | update_group() | Images now work | | DELETE group | In Progress | delete_group() | Need to try deleting a group that I do not own | | POST group admins | In Progress | add_group_admins() | Need to try different number types and formats | | DELETE group admins | In Progress | remove_group_admins() | Need to try different number types and formats | | POST block group | Untested | block_group() | Need a group I didn't create to test with | | POST join group | Untested | join_group() | Need a group I didn't create to test with | | POST group members | In Progress | add_group_members() | Need to try different number types and formats | | DELETE group members | In Progress | remove_group_members() | Need to try different number types and formats | | POST quit group | Untested | leave_group() | Need a group I didn't create to test with | ### Messages | Service | Status | Function Name | Description | | --- | --- | --- | --- | | receive | Working | receive() | Was working when I got here, added some more args and a docstring | | send | Working* | send_message() | Have not tested with API V1 | ### Attachments | Service | Status | Function Name | Description | | --- | --- | --- | --- | | attachments | Working | list_attachments() | Converted to new sender | | GET attachment | Working | get_attachment() | Converted to new sender | | DELETE attachment | Working | delete_attachment() | Haven't touched | ### Profiles | Service | Status | Function Name | Description | | --- | --- | --- | --- | | profiles | Testing | update_profile | Converted to new sender, no issues yet | ### Identities | Service | Status | Function Name | Description | | --- | --- | --- | --- | | identities | Working | list_identities() | | | trust identities | In Progress | verify_identity() | Not sure if this should be renamed, also had some issues with the trust all known keys | ### Reactions | Service | Status | Function Name | Description | | --- | --- | --- | --- | | POST reaction | Working | add_reaction() | | | DELETE reaction | Working | remove_reaction() | | ### Receipts | Service | Status | Function Name | Description | | --- | --- | --- | --- | | receipts | In Progress | send_receipt() | | ### Search | Service | Status | Function Name | Description | | --- | --- | --- | --- | | search | Working* | search() | Seems to only work with numbers in your account region? | ### Sticker Packs No sticker pack endpoints have been added. ### Contacts | Service | Status | Function Name | Description | | --- | --- | --- | --- | | GET contacts | Working | get_contacts() | | | POST contacts | Untested | update_contact() | Must have API set up as main device, which mine is not | | sync contacts | Untested | sync_contacts() | Must have API set up as main device, which mine is not | bbernhard-pysignalclirestapi-e567b65/pysignalclirestapi/000077500000000000000000000000001515704431200236225ustar00rootroot00000000000000bbernhard-pysignalclirestapi-e567b65/pysignalclirestapi/__init__.py000066400000000000000000000002001515704431200257230ustar00rootroot00000000000000from pysignalclirestapi.api import SignalCliRestApi, SignalCliRestApiError, SignalCliRestApiAuth, SignalCliRestApiHTTPBasicAuth bbernhard-pysignalclirestapi-e567b65/pysignalclirestapi/api.py000066400000000000000000001177421515704431200247610ustar00rootroot00000000000000"""SignalCliRestApi Python library.""" import sys import base64 import json import asyncio import ssl from urllib.parse import urlencode from abc import ABC, abstractmethod from requests.models import HTTPBasicAuth from six import raise_from import requests from .helpers import bytes_to_base64 class SignalCliRestApiError(Exception): """SignalCliRestApiError base class.""" pass class SignalCliRestApiAuth(ABC): """SignalCliRestApiAuth base class.""" @abstractmethod def get_auth(): pass class SignalCliRestApiHTTPBasicAuth(SignalCliRestApiAuth): """SignalCliRestApiHTTPBasicAuth offers HTTP basic authentication.""" def __init__(self, basic_auth_user, basic_auth_pwd): self._auth = HTTPBasicAuth(basic_auth_user, basic_auth_pwd) def get_auth(self): return self._auth class SignalCliRestApi(object): """SignalCliRestApi implementation.""" def __init__(self, base_url, number, auth=None, verify_ssl=True): """Initialize the class.""" super(SignalCliRestApi, self).__init__() self._session = requests.Session() self._base_url = base_url self._number = number self._verify_ssl = verify_ssl if auth: assert issubclass( type(auth), SignalCliRestApiAuth), "Expecting a subclass of SignalCliRestApiAuth as auth parameter" self._auth = auth.get_auth() else: self._auth = None self._mode = self.mode() # init mode for receive with websockets def _format_params(self, params, endpoint:str=None): #TODO should this be called from _requester to reduce reduncancy? """Format parameters/args/data for API calls. If endpoint is set to "receive", boolean values will be converted to a string. Args: params (list): Parameters/args to format endpoint (str, optional): Optionally, include an endpoint if specific actions need to be taken with it. Returns: list: Formatted params/data """ # Create a JSON query object formatted_data = {} about = self.about() api_versions = about["versions"] for item, value in params.items(): # Check params, add anything that isn't blank to the query if value !=None: # Allow conditional formatting, depending on the endpoint if endpoint in ['receive']: # This is still needed as of 2025/03/19, but only for receive endpoint? value = 'true' if value is True else 'false' if value is False else value # Convert bool to string elif endpoint in ['send_message']: if "v2" in api_versions: if item == 'attachments_as_bytes': value = [ bytes_to_base64(attachment) for attachment in value ] item = 'base64_attachments' elif item == 'filenames': attachments = [] for filename in value: with open(filename, "rb") as ofile: base64_attachment = bytes_to_base64(ofile.read()) attachments.append(base64_attachment) value = attachments item = 'base64_attachments' else: # fall back to api version 1 to stay downwards compatible if item == 'filenames' and len(value) == 1: with open(value[0], "rb") as ofile: base64_attachment = bytes_to_base64(ofile.read()) attachment = base64_attachment value = attachment item = 'base64_attachments' elif item in ['members', 'admins']: # Convert single user sent as string to a list to prevent error value = [value] if isinstance(value, str) else value elif endpoint in ['update_group', 'update_profile']: # Format attachments if item == 'filename': with open(value, "rb") as ofile: value = bytes_to_base64(ofile.read()) item = 'base64_avatar' elif item == 'attachment_as_bytes': value = bytes_to_base64(value) item = 'base64_avatar' formatted_data.update({item : value}) return formatted_data def _requester(self, method, url, data=None, success_code:any=200, error_unknown=None, error_couldnt=None): """Internal requester Args: method (str): Rest API method. url (str): API url data (any, optional): Optional params or JSON data. success_code (ant, optional): Success code(s) returned by API call. Defaults to 200. error_unknown (str, optional): Custom error for "unknown error". error_couldnt (str, optional): Custom error for "Couldn't". """ #TODO try to move formatter here #if data: #self._format_params(params) params = None json = None if isinstance(success_code, list): pass else: # Make it a list success_code = [success_code] try: if method in ['post','put','delete']: json=data else: params=data resp = self._session.request(method=method, url=url, params=params, json=json, auth=self._auth, verify=self._verify_ssl) if resp.status_code not in success_code: json_resp = resp.json() if "error" in json_resp: raise SignalCliRestApiError(json_resp["error"]) raise SignalCliRestApiError( f"Unknown error {error_unknown}") else: return resp # Return raw response for now except Exception as exc: if exc.__class__ == SignalCliRestApiError: raise exc raise_from(SignalCliRestApiError(f"Couldn't {error_couldnt}: "), exc) def about(self): """Get general API information including capabilities, API version, and what mode is being used. Returns: dict: API information. """ resp = requests.get(self._base_url + "/v1/about", auth=self._auth, verify=self._verify_ssl) if resp.status_code == 200: return resp.json() return None def api_info(self): #TODO should this be removed? try: data = self.about() if data is None: return ["v1", 1] api_versions = data["versions"] build_nr = 1 try: build_nr = data["build"] except KeyError: pass return api_versions, build_nr except Exception as exc: raise_from(SignalCliRestApiError( "Couldn't determine REST API version"), exc) def has_capability(self, endpoint, capability, about=None): #TODO should this be _has_capability? if about is None: about = self.about() return capability in about.get("capabilities", {}).get(endpoint, []) def mode(self): data = self.about() mode = "unknown" try: mode = data["mode"] except KeyError: pass return mode def create_group(self, name:str, members:list, description:str=None, expiration_time:int=0, group_link:str='disabled', permissions:dict=None): """Create a Signal group. Args: name (str): Group name. members (str, list): Member(s) to add. Will accept a single user as a string, otherwise use a list. description (str, optional): Group description. eexpiration_time (int, optional): Disappearing Messages expiration in seconds. Defaults to None (disabled). group_link (str, optional): Allow users to join from a link. Options are 'disabled', 'enabled', 'enabled-with-approval'. Defaults to 'disabled'. permissions (dict, optional): Set additional permissions (see below). Permissions: add_members (str): Whether group members can add users. Options are 'only-admins', 'every-member'. Defaults to 'only-admins'. edit_group (str): Whether group members can edit (update) the group. Options are 'only-admins', 'every-member'. Defaults to 'only-admins'. Returns: dict: Group ID. """ members = [members] if isinstance(members, str) else members params = {'name': name, 'members': members, 'description': description, 'expiration_time': expiration_time, 'group_link': group_link, 'permissions': permissions} url = self._base_url + "/v1/groups/" + self._number data = self._format_params(params) #TODO confirm whether 200 is ever returned request = self._requester(method='post', url=url, data=data, success_code=[201,200], error_unknown='while creating Signal Messenger group', error_couldnt='create Signal Messenger group') return request.json() def list_groups(self): """List all Signal groups. Includes groups you are no longer apart of. Returns: list: Your groups. """ url = self._base_url + "/v1/groups/" + self._number request = self._requester(method='get', url=url, success_code=200, error_unknown='while listing Signal Messenger groups', error_couldnt='list Signal Messenger groups') return request.json() def get_group(self, groupid:str): """Get a single Signal group. Args: groupid (str): Signal group ID. Returns: dict: Group details. """ url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) request = self._requester(method='get', url=url, success_code=200, error_unknown='while getting Signal Messenger group', error_couldnt='get Signal Messenger group') return request.json() def update_group(self, groupid:str, name:str=None, description:str=None, expiration_time:int=None, filename:str=None, attachment_as_bytes:str=None): """Update a signal group. Use filename OR attachment_as_bytes, not both! Args: groupid (str): Signal group ID. name (str, optional): Updated group name. description (str, optional): Updated group description. expiration_time (int, optional): Disappearing Messages expiration in seconds. Defaults to None (disabled). filename (str, optional): Filename of new profile image. attachment_as_bytes (str, optional): Attachment(s) in bytes format. """ params = {'groupid': groupid, 'name': name, 'description': description, 'expiration_time': expiration_time, 'filename': filename, 'attachment_as_bytes': attachment_as_bytes,} if filename is not None and attachment_as_bytes is not None: raise_from(SignalCliRestApiError(f"Can't use filename and attachment_as_bytes, please only send one")) url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) data = self._format_params(params, 'update_group') # TODO add some sort of confirmation for the user request = self._requester(method='put', url=url ,data=data, success_code=204, error_unknown='while updating Signal Messenger group', error_couldnt='update Signal Messenger group') #return request def delete_group(self, groupid:str): """Delete a Signal group. Args: groupid (str): Signal group ID. """ url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) request = self._requester(method='delete', url=url, success_code=200, error_unknown='while deleting Signal Messenger group', error_couldnt='delete Signal Messenger group') def join_group(self, groupid:str): """Join a Signal group by ID. Args: groupid (str): Signal group ID to join. """ url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/join' #TODO if success is not clear, add an additional call to get_group() and return the details request = self._requester(method='post', url=url, success_code=204, error_unknown='while joining Signal Messenger group', error_couldnt='join Signal Messenger group') #return request.json() def leave_group(self, groupid:str): """Leave a Signal group. Args: groupid (str): Signal group ID. """ url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/quit' request = self._requester(method='post', url=url, success_code=204, error_unknown='while leaving Signal Messenger group', error_couldnt='leave Signal Messenger group') #return request.json() def block_group(self, groupid:str): """Block a Signal group. Args: groupid (str): Signal group ID. """ url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/block' request = self._requester(method='post', url=url, success_code=204, error_unknown='while blocking Signal Messenger group', error_couldnt='block Signal Messenger group') #return request.json() def add_group_members(self, groupid:str, members:list): """Add user(s) (members) to a Signal group. Args: groupid (str): _Signal group ID. members (str, list): Member(s) to add. Will accept a single user as a string, otherwise use a list. """ params = {'groupid': groupid, 'members': members} url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/members' data = self._format_params(params) request = self._requester(method='post', url=url, data=data, success_code=204, error_unknown='while adding members to Signal Messenger group', error_couldnt='add members to Signal Messenger group') pass #TODO add some sort of response? def remove_group_members(self, groupid:str, members:list): """Remove user(s) (members) to a Signal group. Args: groupid (str): _Signal group ID. members (str, list): Member(s) to remove. Will accept a single user as a string, otherwise use a list. """ params = {'groupid': groupid, 'members': members} url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/members' data = self._format_params(params) request = self._requester(method='delete', url=url, data=data, success_code=204, error_unknown='while removing members from Signal Messenger group', error_couldnt='remove members from Signal Messenger group') def add_group_admins(self, groupid:str, admins:list): """Promote user(s) to admin of a Signal group. User must already be in the group to be promoted. Args: groupid (str): _Signal group ID. admins (str, list): Users(s) to promote. Will accept a single user as a string, otherwise use a list. """ params = {'groupid': groupid, 'admins': admins} url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/admins' data = self._format_params(params) request = self._requester(method='post', url=url, data=data, success_code=204, error_unknown='while adding admins to Signal Messenger group', error_couldnt='add admins to Signal Messenger group') def remove_group_admins(self, groupid:str, admins:list): """Demote admin(s) of a Signal group. Demoting a user will not remove them from the group. Args: groupid (str): _Signal group ID. admins (str, list): Users(s) to demote. Will accept a single user as a string, otherwise use a list. """ unformatted_data = {'groupid': groupid, 'admins': admins} url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/admins' data = self._format_params(unformatted_data) request = self._requester(method='delete', url=url, data=data, success_code=204, error_unknown='while removing admins from Signal Messenger group', error_couldnt='remove admins from Signal Messenger group') def _ws_url_for_receive(self, data: dict) -> str: """ Convert base_url (http/https) + receive endpoint to ws/wss URL with query params. """ base = self._base_url.rstrip("/") if base.startswith("https://"): ws_base = "wss://" + base.removeprefix("https://") elif base.startswith("http://"): ws_base = "ws://" + base.removeprefix("http://") else: # If user provided host:port without scheme, default to ws:// ws_base = "ws://" + base query = urlencode({k: v for k, v in (data or {}).items() if v is not None}) path = f"/v1/receive/{self._number}" return f"{ws_base}{path}" + (f"?{query}" if query else "") def _ws_headers(self) -> dict: """ WebSocket handshake headers. Supports HTTP Basic auth if configured via SignalCliRestApiHTTPBasicAuth. """ headers = {} if isinstance(self._auth, HTTPBasicAuth): user = getattr(self._auth, "username", None) pwd = getattr(self._auth, "password", None) if user is not None and pwd is not None: token = base64.b64encode(f"{user}:{pwd}".encode("utf-8")).decode("ascii") headers["Authorization"] = f"Basic {token}" return headers async def receive_ws( self, ignore_attachments: bool = False, ignore_stories: bool = False, send_read_receipts: bool = False, max_messages: int | None = None, timeout: int = 1, ) -> list: """ Receive messages via websocket (json-rpc mode). Collects messages until: - max_messages is reached, OR - no message arrives for `timeout` seconds (silence timeout) Returns: list: list of received message envelopes (dicts) """ try: import websockets # dependency already in pyproject except Exception as exc: raise_from( SignalCliRestApiError("websockets package is required for json-rpc receive"), exc, ) params = { "ignore_attachments": ignore_attachments, "ignore_stories": ignore_stories, "send_read_receipts": send_read_receipts, "max_messages": max_messages, "timeout": timeout, } data = self._format_params(params=params, endpoint="receive") ws_url = self._ws_url_for_receive(data) ssl_ctx = None if ws_url.startswith("wss://"): ssl_ctx = ssl.create_default_context() if not self._verify_ssl: ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE received: list = [] try: async with websockets.connect( ws_url, additional_headers=self._ws_headers() or None, ssl=ssl_ctx, ) as websocket: while True: if max_messages is not None and len(received) >= int(max_messages): break try: raw = await asyncio.wait_for(websocket.recv(), timeout=timeout) except asyncio.TimeoutError: # "silence" timeout => return what we have so far break try: received.append(json.loads(raw)) except json.JSONDecodeError: # Keep behavior conservative: ignore malformed frames continue except Exception as exc: raise_from( SignalCliRestApiError("Couldn't receive Signal Messenger data via websocket"), exc, ) return received def receive( self, ignore_attachments: bool = False, ignore_stories: bool = False, send_read_receipts: bool = False, max_messages: int = None, timeout: int = 1, ): """Receive (get) Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint. Returns: list: List of messages """ if self._mode == "json-rpc": # If we're already inside an event loop, we can't asyncio.run(). try: asyncio.get_running_loop() except RuntimeError: return asyncio.run( self.receive_ws( ignore_attachments=ignore_attachments, ignore_stories=ignore_stories, send_read_receipts=send_read_receipts, max_messages=max_messages, timeout=timeout, ) ) raise SignalCliRestApiError( "receive() cannot be called from inside a running event loop in json-rpc mode. " "Use: await receive_ws(...) instead." ) params = { "ignore_attachments": ignore_attachments, "ignore_stories": ignore_stories, "send_read_receipts": send_read_receipts, "max_messages": max_messages, "timeout": timeout, } url = self._base_url + "/v1/receive/" + self._number data = self._format_params(params=params, endpoint="receive") request = self._requester( method="get", url=url, data=data, success_code=200, error_unknown="while receiving Signal Messenger data", error_couldnt="receive Signal Messenger data", ) return request.json() def update_profile(self, name:str, filename:str=None, attachment_as_bytes:str=None): """Update Signal profile. Use filename OR attachment_as_bytes, not both! Args: name (str, optional): New profile name. filename (str, optional): Filename of new avatar. attachment_as_bytes (str, optional): Attachment(s) in bytes format. """ params = {'name': name, 'filename':filename, 'attachment_as_bytes': attachment_as_bytes} if filename is not None and attachment_as_bytes is not None: raise_from(SignalCliRestApiError(f"Can't use filename and attachment_as_bytes, please only send one")) url = self._base_url + "/v1/profiles/" + self._number data = self._format_params(params, 'update_group') # TODO add some sort of confirmation for the user request = self._requester(method='put', url=url ,data=data, success_code=204, error_unknown='while updating profile', error_couldnt='update profile') #return request def send_message(self, message:str, recipients:list, notify_self:bool=False, filenames=None, attachments_as_bytes:list=None, mentions:list=None, quote_timestamp:int=None, quote_author:str=None, quote_message:str=None, quote_mentions:list=None, text_mode="normal"): """Send a message to one (or more) recipients. Supports attachments, styled text, mentioning, and quoting if using V2. Args: message (str): Message. recipients (list): Recipient(s). notify_self (bool, optional): Requires API version 0.92+. If set to Ture, other devices linked to the same account will get a notification for messages you send. Defaults to False (no notification). filenames (str, optional): Filename(s) to be sent. attachments_as_bytes (list, optional): Attachment(s) in bytes format (inside a list). mentions (list, optional): Mention another user. See formatting below. quote_timestamp (int, optional): Timestamp of qouted message. quote_author (str, optional): The quoted message author. quote_message (str, optional): The quoted message content. quote_mentions (list, optional): Any mentions contained within the quote. text_mode (str, optional): Set text mode ["styled","normal"]. See styled text options below. Defaults to "normal". Mention objects should be formatted as dict/JSON and need to contain the following. author (str): The person you are mention. length (int): The length of the mention. start (int): The starting character of the mention. Text styling (must set text_mode to "styled") \*italic text* \*\*bold text** \~strikethrough text~ ||spoiler|| \`monospace` Returns: dict: Sent message timestamp. """ if isinstance(recipients,str): # If sending "recipients" in data, recipients must be sent as a list, even it is a single recipient. recipients = [recipients] params = {'message': message, 'recipients':recipients, 'notify_self': notify_self, 'filenames': filenames, 'attachments_as_bytes': attachments_as_bytes, 'mentions': mentions, 'quote_timestamp': quote_timestamp, 'quote_author': quote_author, 'quote_message': quote_message, 'quote_mentions': quote_mentions, 'text_mode':text_mode, 'number':self._number # whoops, thats kind of important to have } # fall back to old api version to stay downwards compatible. about = self.about() api_versions = about["versions"] endpoint = "v2/send" if "v2" not in api_versions: endpoint = "v1/send" url = self._base_url + f'/{endpoint}' if filenames is not None and len(filenames) > 1: if "v2" not in api_versions: # multiple attachments only allowed when api version >= v2 raise SignalCliRestApiError( "This signal-cli-rest-api version is not capable of sending multiple attachments. Please upgrade your signal-cli-rest-api docker container!") if mentions and not self.has_capability(endpoint, "mentions"): raise SignalCliRestApiError( "This signal-cli-rest-api version is not capable of sending mentions. Please upgrade your signal-cli-rest-api docker container!") if (quote_timestamp or quote_author or quote_message or quote_mentions) and not self.has_capability(endpoint, "quotes"): raise SignalCliRestApiError( "This signal-cli-rest-api version is not capable of sending quotes. Please upgrade your signal-cli-rest-api docker container!") data = self._format_params(params, endpoint='send_message') response = self._requester(method='post', url=url, data=data, success_code=201, error_unknown='while sending message', error_couldnt='send message') return json.loads(response.content) def add_reaction(self, reaction:str, recipient:str, timestamp:int, target_author:str=None): """Add (send) a reaction to a message. Uses timestamp to identify the message to react to. Reacting to a message that you have already reacted to will overwrite the previous reaction. Warning! Data in reaction and timestamp field is not validated and will not return an error, even if it is wrong. Args: reaction (str): Reaction. Must be an Emoji. recipient (str): Message recipient. Eg: +15555555555, or group ID. timestamp (int): Message timestamp to add reaction to. target_author (str, optional): The target message author. If not provided, recipient will be used. Returns: Nothing is returned. """ # If target author isn't provided, default to recipient target_author = target_author if target_author else recipient params = {'reaction': reaction, 'recipient': recipient, 'timestamp': timestamp, 'target_author': target_author} url = self._base_url + "/v1/reactions/" + self._number data = self._format_params(params) self._requester(method='post', url=url, data=data, success_code=204, error_unknown='while adding reaction', error_couldnt='add reaction') def remove_reaction(self, recipient:str, timestamp:int, target_author:str=None): #TODO if groupID is sent with no recipient ID, throw an error """Remove (delete) a reaction to a message. Uses timestamp to identify the message. Warning! Data in timestamp field is not validated and will not return an error, even if it is wrong. This includes trying to remove a reaction that does not exist. Args: recipient (str): Message recipient. Eg: +15555555555, or group ID. timestamp (int): Message timestamp to remove reaction from. target_author (str, optional): The target message author. If not provided, recipient will be used. Returns: Nothing is returned. """ target_author = target_author if target_author else recipient params = {'recipient': recipient, 'timestamp': timestamp, 'target_author': target_author} url = self._base_url + "/v1/reactions/" + self._number data = self._format_params(params) self._requester(method='delete', url=url, data=data, success_code=204, error_unknown='while removing reaction', error_couldnt='remove reaction') def list_attachments(self): """Get a list of all files (attachments) in Signal's media folder. Returns: list: List of files. """ url = self._base_url + "/v1/attachments" request = self._requester(method='get', url=url, success_code=200, error_unknown='while listing attachments', error_couldnt='list attachments') return request.json() def get_attachment(self, attachment_id:str): """Get a signal file (attachment) in bytes. Args: attachment_id (str): File (attachment) name. Returns: bytes: Attachment in bytes. """ url = self._base_url + "/v1/attachments/" + attachment_id request = self._requester(method='get', url=url, success_code=200, error_unknown='while getting attachment', error_couldnt='get attachment') return request.content def delete_attachment(self, attachment_id): """Delete file (attachment) from filesystem Args: attachment_id (str): File (attachment) name. """ try: url = self._base_url + "/v1/attachments/" + attachment_id resp = requests.delete(url, auth=self._auth, verify=self._verify_ssl) if resp.status_code != 204: json_resp = resp.json() if "error" in json_resp: raise SignalCliRestApiError(json_resp["error"]) raise SignalCliRestApiError("Unknown error while deleting attachment") except Exception as exc: if exc.__class__ == SignalCliRestApiError: raise exc raise_from(SignalCliRestApiError("Couldn't delete attachment: "), exc) def search(self, numbers): """Check if one or more phone numbers are registered with the Signal Service.""" try: url = self._base_url + "/v1/search" params = {"number": self._number, "numbers": numbers} resp = requests.get(url, params=params, auth=self._auth, verify=self._verify_ssl) if resp.status_code != 200: json_resp = resp.json() if "error" in json_resp: raise SignalCliRestApiError(json_resp["error"]) raise SignalCliRestApiError("Unknown error while searching phone numbers") return resp.json() except Exception as exc: if exc.__class__ == SignalCliRestApiError: raise exc raise_from(SignalCliRestApiError("Couldn't search for phone numbers: "), exc) def get_contacts(self): """Get all Signal contacts for your account. Returns: list: List of contacts. """ url = self._base_url + "/v1/contacts/" +self._number request = self._requester(method='get', url=url, success_code=200, error_unknown='while updating profile', error_couldnt='update profile') return request.json() def update_contact(self, contact:str, name:str=None, expiration_in_seconds:int=None): """Update a signal Contact. Must be the main device. If you linked your account to SignalCli via a QR code, this won't work. Args: contact (str): Contact number to update. name (str, optional): Contact name. Defaults to None. expiration_in_seconds (int, optional): Disappearing Messages expiration in seconds. Defaults to None (disabled). """ params = {'recipient': contact, # Field is actually named recipient, but I think it makes more sense to cal it contact 'name': name, 'expiration_in_seconds': expiration_in_seconds} url = self._base_url + "/v1/contacts/" + self._number data = self._format_params(params) request = self._requester(method='put', url=url, data=data, success_code=204, error_unknown='while updating profile', error_couldnt='update profile') return request.json() def sync_contacts(self): """Send a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device. """ url = self._base_url + "/v1/contacts/" + self._number +'/sync' self._requester(method='post', url=url, success_code=204, error_unknown='while updating profile', error_couldnt='update profile') def send_receipt(self, recipient:str, timestamp:int, receipt_type:str='read'): """Mark a message as read or viewed. See the difference between read and viewed below. From AsamK, the signal-cli maintainer: "viewed" receipts are used e.g. for voice notes. When the user sees the voice note, a "read" receipt is sent, when the user has listened to the voice note, a "viewed" receipt is sent (displayed as a blue dot in the apps). Args: recipient (str): _Message recipient. Eg: +15555555555, or group ID. timestamp (int): Message timestamp to mark as read/viewed. receipt_type (str, optional): Receipt type. Can be 'read', 'viewed'. Defaults to 'read'. """ params = {'recipient': recipient, 'timestamp': timestamp, 'receipt_type': receipt_type} url = self._base_url + "/v1/receipts/" + self._number data = self._format_params(params) request = self._requester(method='post', url=url, data=data, success_code=204, error_unknown='while sending receipt', error_couldnt='send receipt') #return request.json() #TODO confirm if this returns anything def list_indentities(self): """List all identities for your Signal account. Order of identities may change between calls Returns: list: List of identities. """ url = self._base_url + "/v1/identities/" + self._number request = self._requester(method='get', url=url, success_code=200, error_unknown='getting identities', error_couldnt='get identities') return request.json() def verify_indentity(self, number_to_trust:str, verified_safety_number:str, trust_all_known_keys:bool=False): """Verify/Trust an identity. Args: number_to_trust (str): Number to mark as verified/trusted. verified_safety_number (str): Safety number of identity. Can be gotten from list_identities() trust_all_known_keys (bool, optional): If set to True, all known keys of this user are trusted. Only recommended for testing! Defaults to False. """ params = {'verified_safety_number': verified_safety_number, 'trust_all_known_keys': trust_all_known_keys} url = self._base_url + "/v1/identities/" + self._number +'/trust/' + number_to_trust data = self._format_params(params) request = self._requester(method='put', url=url, data=data, success_code=204, error_unknown='while verifying identity', error_couldnt='verify identity') def link_with_qr(self, device_name:str, qrcode_version:int=10): """Generate QR code to link a device Args: device_name (str): Device name. qrcode_version (int, optional): QRCode version. Defaults to 10. Returns: str: base64 encoded QR code PNG. """ url = self._base_url + "/v1/qrcodelink" params = {'device_name': device_name, 'qrcode_version': qrcode_version} data = self._format_params(params=params) request = self._requester(method='get', url=url, data=data, success_code=200, error_unknown='generating QR code', error_couldnt='generate QR code') return bytes_to_base64(request.content) def list_accounts(self): """List all registered/linked accounts. Returns: list: Phone numbers of linked/registered accounts. """ url = self._base_url + "/v1/accounts" request = self._requester(method='get', url=url, success_code=200, error_unknown='getting linked/registered accounts', error_couldnt='get linked/registered accounts') return request.json() def add_pin(self, pin:str): #TODO test if you have a device where this is the main account """Add pin to your Signal account. Does not work if signal-cli is not set as the main device. Args: pin (str): Pin Returns: _type_: _description_ """ #TODO add return type url = self._base_url + "/v1/accounts/" + self._number + "/pin" params = {'pin': pin} data = self._format_params(params=params) request = self._requester(method='post', url=url, data=data, success_code=201, error_unknown='setting account pin', error_couldnt='set account pin') return request.json() def remove_pin(self): """Remove pin from your Signal account. Does not work if signal-cli is not set as the main device. Returns: _type_: _description_ """#TODO add return type url = self._base_url + "/v1/accounts/" + self._number + "/pin" request = self._requester(method='delete', url=url, success_code=204, error_unknown='removing account pin', error_couldnt='remove account pin') return request.json()bbernhard-pysignalclirestapi-e567b65/pysignalclirestapi/helpers.py000066400000000000000000000005641515704431200256430ustar00rootroot00000000000000"""Helper functions""" import base64 from sys import version_info def bytes_to_base64(in_bytes: bytes) -> str: """Converts bytes to base64-encoded str using the appropriate system version. """ if version_info >= (3, 0): return str(base64.b64encode(in_bytes), encoding="utf-8") else: return str(base64.b64encode(in_bytes)).encode("utf-8") bbernhard-pysignalclirestapi-e567b65/setup.cfg000066400000000000000000000000501515704431200215300ustar00rootroot00000000000000[metadata] description-file = README.md bbernhard-pysignalclirestapi-e567b65/setup.py000066400000000000000000000013571515704431200214340ustar00rootroot00000000000000import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="pysignalclirestapi", version="0.3.25", author="Bernhard B.", author_email="bernhard@liftingcoder.com", description="Small python library for the Signal Cli REST API", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/bbernhard/pysignalclirestapi", packages=setuptools.find_packages(), classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], python_requires='>=2.7', install_requires=[ "requests", "six" ] )