├── .gitignore ├── LICENSE.txt ├── README.md ├── pysignalclirestapi ├── __init__.py ├── api.py └── helpers.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *.pyc 3 | build/ 4 | pysignalclirestapi.egg-info/ 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2019 Bernhard B. 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Small python library for the [Signal Cli REST API](https://github.com/bbernhard/signal-cli-rest-api) 2 | 3 | ### Quickstart 4 | If you have set up the REST API already, you can start sending and receiving messages in Python! 5 | 6 | Intialize the client 7 | ``` 8 | # Seperated for clarity 9 | SERVER = "http://localhose:8080" # Your server address and port 10 | SERVER_NUMBER ="+123456789" # The phone number you registered with the API 11 | 12 | 13 | signal = SignalCliRestApi(SERVER,SERVER_NUMBER) 14 | ``` 15 | 16 | Send a message 17 | ``` 18 | myMessage = "Hello World" # Your message 19 | myFriendSteve = +987654321 # The number you want to message (must be registered with Signal) 20 | 21 | sendMe = signal.send_message(message=myMessage,recipients=myFriendSteve) 22 | 23 | ``` 24 | receive messages 25 | ``` 26 | myMessages = signal.receive(send_read_receipts=True) # Send read receipts so everyone knows you have seen their message 27 | ``` 28 | 29 | ## Endpoint progress tracking 30 | 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. 31 | 32 | ### General 33 | | Service | Status | Function Name | Notes | 34 | | --- | --- | --- | --- | 35 | | about | Working | about() | Was already here. | 36 | No other general endpoints have been added. 37 | ### Devices 38 | No device endpoints have been added. 39 | ### Accounts 40 | No accounts endpoints have been added. 41 | ### Groups 42 | | Service | Status | Function Name | Notes | 43 | | --- | --- | --- | --- | 44 | | GET groups | Working | list_groups() | | 45 | | POST groups | In Progress | create_group() | Have not run into any issues yet | 46 | | GET group | Working | get_group() | Want to try messing with the group IDs to see how it reacts | 47 | | PUT group | Working | update_group() | Images now work | 48 | | DELETE group | In Progress | delete_group() | Need to try deleting a group that I do not own | 49 | | POST group admins | In Progress | add_group_admins() | Need to try different number types and formats | 50 | | DELETE group admins | In Progress | remove_group_admins() | Need to try different number types and formats | 51 | | POST block group | Untested | block_group() | Need a group I didn't create to test with | 52 | | POST join group | Untested | join_group() | Need a group I didn't create to test with | 53 | | POST group members | In Progress | add_group_members() | Need to try different number types and formats | 54 | | DELETE group members | In Progress | remove_group_members() | Need to try different number types and formats | 55 | | POST quit group | Untested | leave_group() | Need a group I didn't create to test with | 56 | 57 | ### Messages 58 | | Service | Status | Function Name | Description | 59 | | --- | --- | --- | --- | 60 | | receive | Working | receive() | Was working when I got here, added some more args and a docstring | 61 | | send | Working* | send_message() | Have not tested with API V1 | 62 | 63 | ### Attachments 64 | | Service | Status | Function Name | Description | 65 | | --- | --- | --- | --- | 66 | | attachments | Working | list_attachments() | Converted to new sender | 67 | | GET attachment | Working | get_attachment() | Converted to new sender | 68 | | DELETE attachment | Working | delete_attachment() | Haven't touched | 69 | 70 | ### Profiles 71 | | Service | Status | Function Name | Description | 72 | | --- | --- | --- | --- | 73 | | profiles | Testing | update_profile | Converted to new sender, no issues yet | 74 | 75 | ### Identities 76 | | Service | Status | Function Name | Description | 77 | | --- | --- | --- | --- | 78 | | identities | Working | list_identities() | | 79 | | trust identities | In Progress | verify_identity() | Not sure if this should be renamed, also had some issues with the trust all known keys | 80 | 81 | ### Reactions 82 | | Service | Status | Function Name | Description | 83 | | --- | --- | --- | --- | 84 | | POST reaction | Working | add_reaction() | | 85 | | DELETE reaction | Working | remove_reaction() | | 86 | 87 | ### Receipts 88 | | Service | Status | Function Name | Description | 89 | | --- | --- | --- | --- | 90 | | receipts | In Progress | send_receipt() | | 91 | ### Search 92 | | Service | Status | Function Name | Description | 93 | | --- | --- | --- | --- | 94 | | search | Working* | search() | Seems to only work with numbers in your account region? | 95 | 96 | ### Sticker Packs 97 | No sticker pack endpoints have been added. 98 | 99 | ### Contacts 100 | | Service | Status | Function Name | Description | 101 | | --- | --- | --- | --- | 102 | | GET contacts | Working | get_contacts() | | 103 | | POST contacts | Untested | update_contact() | Must have API set up as main device, which mine is not | 104 | | sync contacts | Untested | sync_contacts() | Must have API set up as main device, which mine is not | 105 | 106 | -------------------------------------------------------------------------------- /pysignalclirestapi/__init__.py: -------------------------------------------------------------------------------- 1 | from pysignalclirestapi.api import SignalCliRestApi, SignalCliRestApiError, SignalCliRestApiAuth, SignalCliRestApiHTTPBasicAuth 2 | -------------------------------------------------------------------------------- /pysignalclirestapi/api.py: -------------------------------------------------------------------------------- 1 | """SignalCliRestApi Python library.""" 2 | 3 | import sys 4 | import base64 5 | import json 6 | from abc import ABC, abstractmethod 7 | from requests.models import HTTPBasicAuth 8 | from six import raise_from 9 | import requests 10 | from .helpers import bytes_to_base64 11 | 12 | 13 | class SignalCliRestApiError(Exception): 14 | """SignalCliRestApiError base class.""" 15 | pass 16 | 17 | 18 | class SignalCliRestApiAuth(ABC): 19 | """SignalCliRestApiAuth base class.""" 20 | 21 | @abstractmethod 22 | def get_auth(): 23 | pass 24 | 25 | 26 | class SignalCliRestApiHTTPBasicAuth(SignalCliRestApiAuth): 27 | """SignalCliRestApiHTTPBasicAuth offers HTTP basic authentication.""" 28 | 29 | def __init__(self, basic_auth_user, basic_auth_pwd): 30 | self._auth = HTTPBasicAuth(basic_auth_user, basic_auth_pwd) 31 | 32 | def get_auth(self): 33 | return self._auth 34 | 35 | 36 | class SignalCliRestApi(object): 37 | """SignalCliRestApi implementation.""" 38 | 39 | def __init__(self, base_url, number, auth=None, verify_ssl=True): 40 | """Initialize the class.""" 41 | super(SignalCliRestApi, self).__init__() 42 | self._session = requests.Session() 43 | self._base_url = base_url 44 | self._number = number 45 | self._verify_ssl = verify_ssl 46 | 47 | if auth: 48 | assert issubclass( 49 | type(auth), SignalCliRestApiAuth), "Expecting a subclass of SignalCliRestApiAuth as auth parameter" 50 | self._auth = auth.get_auth() 51 | else: 52 | self._auth = None 53 | 54 | def _format_params(self, params, endpoint:str=None): #TODO should this be called from _requester to reduce reduncancy? 55 | """Format parameters/args/data for API calls. 56 | 57 | If endpoint is set to "receive", boolean values will be converted to a string. 58 | 59 | Args: 60 | params (list): Parameters/args to format 61 | endpoint (str, optional): Optionally, include an endpoint if specific actions need to be taken with it. 62 | 63 | Returns: 64 | list: Formatted params/data 65 | """ 66 | 67 | # Create a JSON query object 68 | formatted_data = {} 69 | about = self.about() 70 | api_versions = about["versions"] 71 | 72 | for item, value in params.items(): # Check params, add anything that isn't blank to the query 73 | if value !=None: 74 | # Allow conditional formatting, depending on the endpoint 75 | if endpoint in ['receive']: # This is still needed as of 2025/03/19, but only for receive endpoint? 76 | value = 'true' if value is True else 'false' if value is False else value # Convert bool to string 77 | 78 | elif endpoint in ['send_message']: 79 | if "v2" in api_versions: 80 | if item == 'attachments_as_bytes': 81 | value = [ 82 | bytes_to_base64(attachment) for attachment in value 83 | ] 84 | item = 'base64_attachments' 85 | 86 | elif item == 'filenames': 87 | attachments = [] 88 | for filename in value: 89 | with open(filename, "rb") as ofile: 90 | base64_attachment = bytes_to_base64(ofile.read()) 91 | attachments.append(base64_attachment) 92 | value = attachments 93 | item = 'base64_attachments' 94 | 95 | else: # fall back to api version 1 to stay downwards compatible 96 | if item == 'filenames' and len(value) == 1: 97 | with open(value[0], "rb") as ofile: 98 | base64_attachment = bytes_to_base64(ofile.read()) 99 | attachment = base64_attachment 100 | value = attachment 101 | item = 'base64_attachments' 102 | 103 | elif item in ['members', 'admins']: # Convert single user sent as string to a list to prevent error 104 | value = [value] if isinstance(value, str) else value 105 | 106 | elif endpoint in ['update_group', 'update_profile']: # Format attachments 107 | if item == 'filename': 108 | with open(value, "rb") as ofile: 109 | value = bytes_to_base64(ofile.read()) 110 | item = 'base64_avatar' 111 | elif item == 'attachment_as_bytes': 112 | value = bytes_to_base64(value) 113 | item = 'base64_avatar' 114 | 115 | formatted_data.update({item : value}) 116 | 117 | return formatted_data 118 | 119 | def _requester(self, method, url, data=None, success_code:any=200, error_unknown=None, error_couldnt=None): 120 | """Internal requester 121 | 122 | Args: 123 | method (str): Rest API method. 124 | url (str): API url 125 | data (any, optional): Optional params or JSON data. 126 | success_code (ant, optional): Success code(s) returned by API call. Defaults to 200. 127 | error_unknown (str, optional): Custom error for "unknown error". 128 | error_couldnt (str, optional): Custom error for "Couldn't". 129 | """ 130 | #TODO try to move formatter here 131 | #if data: 132 | #self._format_params(params) 133 | params = None 134 | json = None 135 | if isinstance(success_code, list): 136 | pass 137 | else: # Make it a list 138 | success_code = [success_code] 139 | try: 140 | 141 | if method in ['post','put','delete']: 142 | json=data 143 | 144 | else: 145 | params=data 146 | 147 | resp = self._session.request(method=method, url=url, params=params, json=json, auth=self._auth, verify=self._verify_ssl) 148 | if resp.status_code not in success_code: 149 | json_resp = resp.json() 150 | if "error" in json_resp: 151 | raise SignalCliRestApiError(json_resp["error"]) 152 | raise SignalCliRestApiError( 153 | f"Unknown error {error_unknown}") 154 | else: 155 | return resp # Return raw response for now 156 | except Exception as exc: 157 | if exc.__class__ == SignalCliRestApiError: 158 | raise exc 159 | raise_from(SignalCliRestApiError(f"Couldn't {error_couldnt}: "), exc) 160 | 161 | def about(self): 162 | """Get general API information including capabilities, API version, and what mode is being used. 163 | 164 | Returns: 165 | dict: API information. 166 | """ 167 | 168 | resp = requests.get(self._base_url + "/v1/about", auth=self._auth, verify=self._verify_ssl) 169 | if resp.status_code == 200: 170 | return resp.json() 171 | return None 172 | 173 | def api_info(self): #TODO should this be removed? 174 | try: 175 | data = self.about() 176 | if data is None: 177 | return ["v1", 1] 178 | api_versions = data["versions"] 179 | build_nr = 1 180 | try: 181 | build_nr = data["build"] 182 | except KeyError: 183 | pass 184 | 185 | return api_versions, build_nr 186 | 187 | except Exception as exc: 188 | raise_from(SignalCliRestApiError( 189 | "Couldn't determine REST API version"), exc) 190 | 191 | def has_capability(self, endpoint, capability, about=None): #TODO should this be _has_capability? 192 | if about is None: 193 | about = self.about() 194 | 195 | return capability in about.get("capabilities", {}).get(endpoint, []) 196 | 197 | def mode(self): 198 | data = self.about() 199 | 200 | mode = "unknown" 201 | try: 202 | mode = data["mode"] 203 | except KeyError: 204 | pass 205 | return mode 206 | 207 | def create_group(self, name:str, members:list, description:str=None, expiration_time:int=0, group_link:str='disabled', permissions:dict=None): 208 | """Create a Signal group. 209 | 210 | Args: 211 | name (str): Group name. 212 | members (str, list): Member(s) to add. Will accept a single user as a string, otherwise use a list. 213 | description (str, optional): Group description. 214 | eexpiration_time (int, optional): Disappearing Messages expiration in seconds. Defaults to None (disabled). 215 | group_link (str, optional): Allow users to join from a link. Options are 'disabled', 'enabled', 'enabled-with-approval'. Defaults to 'disabled'. 216 | permissions (dict, optional): Set additional permissions (see below). 217 | 218 | Permissions: 219 | add_members (str): Whether group members can add users. Options are 'only-admins', 'every-member'. Defaults to 'only-admins'. 220 | edit_group (str): Whether group members can edit (update) the group. Options are 'only-admins', 'every-member'. Defaults to 'only-admins'. 221 | 222 | Returns: 223 | dict: Group ID. 224 | """ 225 | members = [members] if isinstance(members, str) else members 226 | params = {'name': name, 227 | 'members': members, 228 | 'description': description, 229 | 'expiration_time': expiration_time, 230 | 'group_link': group_link, 231 | 'permissions': permissions} 232 | 233 | url = self._base_url + "/v1/groups/" + self._number 234 | data = self._format_params(params) 235 | #TODO confirm whether 200 is ever returned 236 | 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') 237 | return request.json() 238 | 239 | def list_groups(self): 240 | """List all Signal groups. 241 | 242 | Includes groups you are no longer apart of. 243 | 244 | Returns: 245 | list: Your groups. 246 | """ 247 | url = self._base_url + "/v1/groups/" + self._number 248 | 249 | request = self._requester(method='get', url=url, success_code=200, error_unknown='while listing Signal Messenger groups', error_couldnt='list Signal Messenger groups') 250 | return request.json() 251 | 252 | def get_group(self, groupid:str): 253 | """Get a single Signal group. 254 | 255 | Args: 256 | groupid (str): Signal group ID. 257 | 258 | Returns: 259 | dict: Group details. 260 | """ 261 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) 262 | 263 | request = self._requester(method='get', url=url, success_code=200, error_unknown='while getting Signal Messenger group', error_couldnt='get Signal Messenger group') 264 | return request.json() 265 | 266 | def update_group(self, groupid:str, name:str=None, description:str=None, expiration_time:int=None, filename:str=None, attachment_as_bytes:str=None): 267 | """Update a signal group. 268 | 269 | Use filename OR attachment_as_bytes, not both! 270 | 271 | Args: 272 | groupid (str): Signal group ID. 273 | name (str, optional): Updated group name. 274 | description (str, optional): Updated group description. 275 | expiration_time (int, optional): Disappearing Messages expiration in seconds. Defaults to None (disabled). 276 | filename (str, optional): Filename of new profile image. 277 | attachment_as_bytes (str, optional): Attachment(s) in bytes format. 278 | """ 279 | params = {'groupid': groupid, 280 | 'name': name, 281 | 'description': description, 282 | 'expiration_time': expiration_time, 283 | 'filename': filename, 284 | 'attachment_as_bytes': attachment_as_bytes,} 285 | 286 | if filename is not None and attachment_as_bytes is not None: 287 | raise_from(SignalCliRestApiError(f"Can't use filename and attachment_as_bytes, please only send one")) 288 | 289 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) 290 | data = self._format_params(params, 'update_group') 291 | # TODO add some sort of confirmation for the user 292 | 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') 293 | #return request 294 | def delete_group(self, groupid:str): 295 | """Delete a Signal group. 296 | 297 | Args: 298 | groupid (str): Signal group ID. 299 | """ 300 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) 301 | 302 | request = self._requester(method='delete', url=url, success_code=200, error_unknown='while deleting Signal Messenger group', error_couldnt='delete Signal Messenger group') 303 | 304 | def join_group(self, groupid:str): 305 | """Join a Signal group by ID. 306 | 307 | Args: 308 | groupid (str): Signal group ID to join. 309 | """ 310 | 311 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/join' 312 | #TODO if success is not clear, add an additional call to get_group() and return the details 313 | request = self._requester(method='post', url=url, success_code=204, error_unknown='while joining Signal Messenger group', error_couldnt='join Signal Messenger group') 314 | #return request.json() 315 | 316 | def leave_group(self, groupid:str): 317 | """Leave a Signal group. 318 | 319 | Args: 320 | groupid (str): Signal group ID. 321 | """ 322 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/quit' 323 | 324 | request = self._requester(method='post', url=url, success_code=204, error_unknown='while leaving Signal Messenger group', error_couldnt='leave Signal Messenger group') 325 | #return request.json() 326 | 327 | def block_group(self, groupid:str): 328 | """Block a Signal group. 329 | 330 | Args: 331 | groupid (str): Signal group ID. 332 | """ 333 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/block' 334 | 335 | request = self._requester(method='post', url=url, success_code=204, error_unknown='while blocking Signal Messenger group', error_couldnt='block Signal Messenger group') 336 | #return request.json() 337 | 338 | def add_group_members(self, groupid:str, members:list): 339 | """Add user(s) (members) to a Signal group. 340 | 341 | Args: 342 | groupid (str): _Signal group ID. 343 | members (str, list): Member(s) to add. Will accept a single user as a string, otherwise use a list. 344 | """ 345 | 346 | params = {'groupid': groupid, 347 | 'members': members} 348 | 349 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/members' 350 | data = self._format_params(params) 351 | 352 | 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') 353 | pass 354 | #TODO add some sort of response? 355 | 356 | def remove_group_members(self, groupid:str, members:list): 357 | """Remove user(s) (members) to a Signal group. 358 | 359 | Args: 360 | groupid (str): _Signal group ID. 361 | members (str, list): Member(s) to remove. Will accept a single user as a string, otherwise use a list. 362 | """ 363 | 364 | params = {'groupid': groupid, 365 | 'members': members} 366 | 367 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/members' 368 | data = self._format_params(params) 369 | 370 | 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') 371 | 372 | def add_group_admins(self, groupid:str, admins:list): 373 | """Promote user(s) to admin of a Signal group. User must already be in the group to be promoted. 374 | 375 | Args: 376 | groupid (str): _Signal group ID. 377 | admins (str, list): Users(s) to promote. Will accept a single user as a string, otherwise use a list. 378 | """ 379 | 380 | params = {'groupid': groupid, 381 | 'admins': admins} 382 | 383 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/admins' 384 | data = self._format_params(params) 385 | 386 | 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') 387 | 388 | def remove_group_admins(self, groupid:str, admins:list): 389 | """Demote admin(s) of a Signal group. Demoting a user will not remove them from the group. 390 | 391 | Args: 392 | groupid (str): _Signal group ID. 393 | admins (str, list): Users(s) to demote. Will accept a single user as a string, otherwise use a list. 394 | """ 395 | 396 | unformatted_data = {'groupid': groupid, 397 | 'admins': admins} 398 | 399 | url = self._base_url + "/v1/groups/" + self._number + '/' + str(groupid) + '/admins' 400 | data = self._format_params(unformatted_data) 401 | 402 | 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') 403 | 404 | def receive(self, ignore_attachments:bool=False, ignore_stories:bool=False, send_read_receipts:bool=False, max_messages:int=None, timeout:int=1): #TODO Allow this to detect and work with websocket 405 | """Receive (get) Signal Messages from the Signal Network. 406 | 407 | 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. 408 | 409 | Args: 410 | ignore_attachments (bool, optional): Ignore attachments. Defaults to False. 411 | ignore_stories (bool, optional): Ignore stories. Defaults to False. 412 | send_read_receipts (bool, optional): Send read receipts. Defaults to False. 413 | max_messages (int, optional): Maximum messages to get per request. Messages will be returned oldest to newest. Defaults to None (unlimited). 414 | timeout (int, optional): Receive timeout in seconds. Defaults to 1. 415 | 416 | Returns: 417 | list: List of messages 418 | """ 419 | params = {'ignore_attachments': ignore_attachments, 420 | 'ignore_stories': ignore_stories, 421 | 'send_read_receipts': send_read_receipts, 422 | 'max_messages': max_messages, 423 | 'timeout': timeout} 424 | 425 | url = self._base_url + "/v1/receive/" + self._number 426 | data = self._format_params(params=params, endpoint='receive') 427 | 428 | 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') 429 | return request.json() 430 | 431 | def update_profile(self, name:str, filename:str=None, attachment_as_bytes:str=None): 432 | """Update Signal profile. 433 | 434 | Use filename OR attachment_as_bytes, not both! 435 | 436 | Args: 437 | name (str, optional): New profile name. 438 | filename (str, optional): Filename of new avatar. 439 | attachment_as_bytes (str, optional): Attachment(s) in bytes format. 440 | """ 441 | params = {'name': name, 442 | 'filename':filename, 443 | 'attachment_as_bytes': attachment_as_bytes} 444 | 445 | if filename is not None and attachment_as_bytes is not None: 446 | raise_from(SignalCliRestApiError(f"Can't use filename and attachment_as_bytes, please only send one")) 447 | 448 | url = self._base_url + "/v1/profiles/" + self._number 449 | data = self._format_params(params, 'update_group') 450 | # TODO add some sort of confirmation for the user 451 | request = self._requester(method='put', url=url ,data=data, success_code=204, error_unknown='while updating profile', error_couldnt='update profile') 452 | #return request 453 | 454 | def send_message(self, message:str, recipients:list, notify_self:bool=False, filenames=None, attachments_as_bytes:list=None, 455 | mentions:list=None, quote_timestamp:int=None, quote_author:str=None, quote_message:str=None, 456 | quote_mentions:list=None, text_mode="normal"): 457 | """Send a message to one (or more) recipients. 458 | 459 | Supports attachments, styled text, mentioning, and quoting if using V2. 460 | 461 | Args: 462 | message (str): Message. 463 | recipients (list): Recipient(s). 464 | 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). 465 | filenames (str, optional): Filename(s) to be sent. 466 | attachments_as_bytes (list, optional): Attachment(s) in bytes format (inside a list). 467 | mentions (list, optional): Mention another user. See formatting below. 468 | quote_timestamp (int, optional): Timestamp of qouted message. 469 | quote_author (str, optional): The quoted message author. 470 | quote_message (str, optional): The quoted message content. 471 | quote_mentions (list, optional): Any mentions contained within the quote. 472 | text_mode (str, optional): Set text mode ["styled","normal"]. See styled text options below. Defaults to "normal". 473 | 474 | Mention objects should be formatted as dict/JSON and need to contain the following. 475 | author (str): The person you are mention. 476 | length (int): The length of the mention. 477 | start (int): The starting character of the mention. 478 | 479 | Text styling (must set text_mode to "styled") 480 | \*italic text* 481 | \*\*bold text** 482 | \~strikethrough text~ 483 | ||spoiler|| 484 | \`monospace` 485 | 486 | Returns: 487 | dict: Sent message timestamp. 488 | """ 489 | if isinstance(recipients,str): # If sending "recipients" in data, recipients must be sent as a list, even it is a single recipient. 490 | recipients = [recipients] 491 | 492 | params = {'message': message, 493 | 'recipients':recipients, 494 | 'notify_self': notify_self, 495 | 'filenames': filenames, 496 | 'attachments_as_bytes': attachments_as_bytes, 497 | 'mentions': mentions, 498 | 'quote_timestamp': quote_timestamp, 499 | 'quote_author': quote_author, 500 | 'quote_message': quote_message, 501 | 'quote_mentions': quote_mentions, 502 | 'text_mode':text_mode, 503 | 'number':self._number # whoops, thats kind of important to have 504 | } 505 | 506 | # fall back to old api version to stay downwards compatible. 507 | about = self.about() 508 | api_versions = about["versions"] 509 | endpoint = "v2/send" 510 | if "v2" not in api_versions: 511 | endpoint = "v1/send" 512 | 513 | url = self._base_url + f'/{endpoint}' 514 | 515 | if filenames is not None and len(filenames) > 1: 516 | if "v2" not in api_versions: # multiple attachments only allowed when api version >= v2 517 | raise SignalCliRestApiError( 518 | "This signal-cli-rest-api version is not capable of sending multiple attachments. Please upgrade your signal-cli-rest-api docker container!") 519 | if mentions and not self.has_capability(endpoint, "mentions"): 520 | raise SignalCliRestApiError( 521 | "This signal-cli-rest-api version is not capable of sending mentions. Please upgrade your signal-cli-rest-api docker container!") 522 | if (quote_timestamp or quote_author or quote_message or quote_mentions) and not self.has_capability(endpoint, "quotes"): 523 | raise SignalCliRestApiError( 524 | "This signal-cli-rest-api version is not capable of sending quotes. Please upgrade your signal-cli-rest-api docker container!") 525 | 526 | 527 | data = self._format_params(params, endpoint='send_message') 528 | response = self._requester(method='post', url=url, data=data, success_code=201, error_unknown='while sending message', error_couldnt='send message') 529 | return json.loads(response.content) 530 | 531 | def add_reaction(self, reaction:str, recipient:str, timestamp:int, target_author:str=None): 532 | """Add (send) a reaction to a message. Uses timestamp to identify the message to react to. 533 | 534 | Reacting to a message that you have already reacted to will overwrite the previous reaction. 535 | 536 | Warning! Data in reaction and timestamp field is not validated and will not return an error, even if it is wrong. 537 | 538 | Args: 539 | reaction (str): Reaction. Must be an Emoji. 540 | recipient (str): Message recipient. Eg: +15555555555, or group ID. 541 | timestamp (int): Message timestamp to add reaction to. 542 | target_author (str, optional): The target message author. If not provided, recipient will be used. 543 | 544 | Returns: 545 | Nothing is returned. 546 | """ 547 | # If target author isn't provided, default to recipient 548 | target_author = target_author if target_author else recipient 549 | 550 | params = {'reaction': reaction, 551 | 'recipient': recipient, 552 | 'timestamp': timestamp, 553 | 'target_author': target_author} 554 | 555 | url = self._base_url + "/v1/reactions/" + self._number 556 | data = self._format_params(params) 557 | 558 | self._requester(method='post', url=url, data=data, success_code=204, error_unknown='while adding reaction', error_couldnt='add reaction') 559 | 560 | def remove_reaction(self, recipient:str, timestamp:int, target_author:str=None): #TODO if groupID is sent with no recipient ID, throw an error 561 | """Remove (delete) a reaction to a message. Uses timestamp to identify the message. 562 | 563 | 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. 564 | 565 | Args: 566 | recipient (str): Message recipient. Eg: +15555555555, or group ID. 567 | timestamp (int): Message timestamp to remove reaction from. 568 | target_author (str, optional): The target message author. If not provided, recipient will be used. 569 | 570 | Returns: 571 | Nothing is returned. 572 | """ 573 | target_author = target_author if target_author else recipient 574 | 575 | params = {'recipient': recipient, 576 | 'timestamp': timestamp, 577 | 'target_author': target_author} 578 | 579 | url = self._base_url + "/v1/reactions/" + self._number 580 | data = self._format_params(params) 581 | 582 | self._requester(method='delete', url=url, data=data, success_code=204, error_unknown='while removing reaction', error_couldnt='remove reaction') 583 | 584 | def list_attachments(self): 585 | """Get a list of all files (attachments) in Signal's media folder. 586 | 587 | Returns: 588 | list: List of files. 589 | """ 590 | url = self._base_url + "/v1/attachments" 591 | 592 | request = self._requester(method='get', url=url, success_code=200, error_unknown='while listing attachments', error_couldnt='list attachments') 593 | return request.json() 594 | 595 | def get_attachment(self, attachment_id:str): 596 | """Get a signal file (attachment) in bytes. 597 | 598 | Args: 599 | attachment_id (str): File (attachment) name. 600 | 601 | 602 | Returns: 603 | bytes: Attachment in bytes. 604 | """ 605 | url = self._base_url + "/v1/attachments/" + attachment_id 606 | 607 | request = self._requester(method='get', url=url, success_code=200, error_unknown='while getting attachment', error_couldnt='get attachment') 608 | return request.content 609 | 610 | def delete_attachment(self, attachment_id): 611 | """Delete file (attachment) from filesystem 612 | 613 | Args: 614 | attachment_id (str): File (attachment) name. 615 | """ 616 | 617 | try: 618 | url = self._base_url + "/v1/attachments/" + attachment_id 619 | 620 | resp = requests.delete(url, auth=self._auth, verify=self._verify_ssl) 621 | if resp.status_code != 204: 622 | json_resp = resp.json() 623 | if "error" in json_resp: 624 | raise SignalCliRestApiError(json_resp["error"]) 625 | raise SignalCliRestApiError("Unknown error while deleting attachment") 626 | except Exception as exc: 627 | if exc.__class__ == SignalCliRestApiError: 628 | raise exc 629 | raise_from(SignalCliRestApiError("Couldn't delete attachment: "), exc) 630 | 631 | def search(self, numbers): 632 | """Check if one or more phone numbers are registered with the Signal Service.""" 633 | 634 | try: 635 | url = self._base_url + "/v1/search" 636 | params = {"number": self._number, "numbers": numbers} 637 | 638 | resp = requests.get(url, params=params, auth=self._auth, verify=self._verify_ssl) 639 | if resp.status_code != 200: 640 | json_resp = resp.json() 641 | if "error" in json_resp: 642 | raise SignalCliRestApiError(json_resp["error"]) 643 | raise SignalCliRestApiError("Unknown error while searching phone numbers") 644 | 645 | return resp.json() 646 | except Exception as exc: 647 | if exc.__class__ == SignalCliRestApiError: 648 | raise exc 649 | raise_from(SignalCliRestApiError("Couldn't search for phone numbers: "), exc) 650 | 651 | def get_contacts(self): 652 | """Get all Signal contacts for your account. 653 | 654 | Returns: 655 | list: List of contacts. 656 | """ 657 | url = self._base_url + "/v1/contacts/" +self._number 658 | 659 | request = self._requester(method='get', url=url, success_code=200, error_unknown='while updating profile', error_couldnt='update profile') 660 | return request.json() 661 | 662 | def update_contact(self, contact:str, name:str=None, expiration_in_seconds:int=None): 663 | """Update a signal Contact. Must be the main device. If you linked your account to SignalCli via a QR code, this won't work. 664 | 665 | Args: 666 | contact (str): Contact number to update. 667 | name (str, optional): Contact name. Defaults to None. 668 | expiration_in_seconds (int, optional): Disappearing Messages expiration in seconds. Defaults to None (disabled). 669 | """ 670 | params = {'recipient': contact, # Field is actually named recipient, but I think it makes more sense to cal it contact 671 | 'name': name, 672 | 'expiration_in_seconds': expiration_in_seconds} 673 | 674 | url = self._base_url + "/v1/contacts/" + self._number 675 | data = self._format_params(params) 676 | 677 | request = self._requester(method='put', url=url, data=data, success_code=204, error_unknown='while updating profile', error_couldnt='update profile') 678 | return request.json() 679 | 680 | def sync_contacts(self): 681 | """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. 682 | """ 683 | 684 | url = self._base_url + "/v1/contacts/" + self._number +'/sync' 685 | self._requester(method='post', url=url, success_code=204, error_unknown='while updating profile', error_couldnt='update profile') 686 | 687 | def send_receipt(self, recipient:str, timestamp:int, receipt_type:str='read'): 688 | """Mark a message as read or viewed. See the difference between read and viewed below. 689 | 690 | 691 | 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). 692 | 693 | Args: 694 | recipient (str): _Message recipient. Eg: +15555555555, or group ID. 695 | timestamp (int): Message timestamp to mark as read/viewed. 696 | receipt_type (str, optional): Receipt type. Can be 'read', 'viewed'. Defaults to 'read'. 697 | """ 698 | 699 | params = {'recipient': recipient, 700 | 'timestamp': timestamp, 701 | 'receipt_type': receipt_type} 702 | url = self._base_url + "/v1/receipts/" + self._number 703 | data = self._format_params(params) 704 | 705 | request = self._requester(method='post', url=url, data=data, success_code=204, error_unknown='while sending receipt', error_couldnt='send receipt') 706 | #return request.json() #TODO confirm if this returns anything 707 | 708 | def list_indentities(self): 709 | """List all identities for your Signal account. 710 | 711 | Order of identities may change between calls 712 | 713 | Returns: 714 | list: List of identities. 715 | """ 716 | 717 | url = self._base_url + "/v1/identities/" + self._number 718 | 719 | request = self._requester(method='get', url=url, success_code=200, error_unknown='getting identities', error_couldnt='get identities') 720 | return request.json() 721 | 722 | def verify_indentity(self, number_to_trust:str, verified_safety_number:str, trust_all_known_keys:bool=False): 723 | """Verify/Trust an identity. 724 | 725 | Args: 726 | number_to_trust (str): Number to mark as verified/trusted. 727 | verified_safety_number (str): Safety number of identity. Can be gotten from list_identities() 728 | 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. 729 | """ 730 | 731 | params = {'verified_safety_number': verified_safety_number, 732 | 'trust_all_known_keys': trust_all_known_keys} 733 | url = self._base_url + "/v1/identities/" + self._number +'/trust/' + number_to_trust 734 | data = self._format_params(params) 735 | 736 | request = self._requester(method='put', url=url, data=data, success_code=204, error_unknown='while verifying identity', error_couldnt='verify identity') 737 | 738 | def link_with_qr(self, device_name:str, qrcode_version:int=10): 739 | """Generate QR code to link a device 740 | 741 | Args: 742 | device_name (str): Device name. 743 | qrcode_version (int, optional): QRCode version. Defaults to 10. 744 | 745 | Returns: 746 | str: base64 encoded QR code PNG. 747 | """ 748 | url = self._base_url + "/v1/qrcodelink" 749 | 750 | params = {'device_name': device_name, 751 | 'qrcode_version': qrcode_version} 752 | 753 | data = self._format_params(params=params) 754 | 755 | request = self._requester(method='get', url=url, data=data, success_code=200, error_unknown='generating QR code', error_couldnt='generate QR code') 756 | return bytes_to_base64(request.content) 757 | 758 | def list_accounts(self): 759 | """List all registered/linked accounts. 760 | 761 | Returns: 762 | list: Phone numbers of linked/registered accounts. 763 | """ 764 | url = self._base_url + "/v1/accounts" 765 | 766 | request = self._requester(method='get', url=url, success_code=200, error_unknown='getting linked/registered accounts', error_couldnt='get linked/registered accounts') 767 | return request.json() 768 | 769 | def add_pin(self, pin:str): #TODO test if you have a device where this is the main account 770 | """Add pin to your Signal account. Does not work if signal-cli is not set as the main device. 771 | 772 | Args: 773 | pin (str): Pin 774 | 775 | Returns: 776 | _type_: _description_ 777 | """ #TODO add return type 778 | url = self._base_url + "/v1/accounts/" + self._number + "/pin" 779 | params = {'pin': pin} 780 | 781 | data = self._format_params(params=params) 782 | 783 | request = self._requester(method='post', url=url, data=data, success_code=201, error_unknown='setting account pin', error_couldnt='set account pin') 784 | return request.json() 785 | 786 | def remove_pin(self): 787 | """Remove pin from your Signal account. Does not work if signal-cli is not set as the main device. 788 | 789 | Returns: 790 | _type_: _description_ 791 | """#TODO add return type 792 | url = self._base_url + "/v1/accounts/" + self._number + "/pin" 793 | 794 | request = self._requester(method='delete', url=url, success_code=204, error_unknown='removing account pin', error_couldnt='remove account pin') 795 | return request.json() -------------------------------------------------------------------------------- /pysignalclirestapi/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions""" 2 | 3 | import base64 4 | from sys import version_info 5 | 6 | def bytes_to_base64(in_bytes: bytes) -> str: 7 | """Converts bytes to base64-encoded str using the appropriate system version. 8 | """ 9 | if version_info >= (3, 0): 10 | return str(base64.b64encode(in_bytes), encoding="utf-8") 11 | else: 12 | return str(base64.b64encode(in_bytes)).encode("utf-8") 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="pysignalclirestapi", 8 | version="0.3.24", 9 | author="Bernhard B.", 10 | author_email="bernhard@liftingcoder.com", 11 | description="Small python library for the Signal Cli REST API", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/bbernhard/pysignalclirestapi", 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ], 21 | python_requires='>=2.7', 22 | install_requires=[ 23 | "requests", 24 | "six" 25 | ] 26 | ) 27 | --------------------------------------------------------------------------------