signal-cli-py 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- signal_cli_py-0.2.0/.gitignore +38 -0
- signal_cli_py-0.2.0/LICENSE +21 -0
- signal_cli_py-0.2.0/PKG-INFO +141 -0
- signal_cli_py-0.2.0/README.md +111 -0
- signal_cli_py-0.2.0/pyproject.toml +74 -0
- signal_cli_py-0.2.0/signal_cli/__init__.py +10 -0
- signal_cli_py-0.2.0/signal_cli/cli.py +287 -0
- signal_cli_py-0.2.0/signal_cli/config.py +145 -0
- signal_cli_py-0.2.0/signal_cli/py.typed +1 -0
- signal_cli_py-0.2.0/signal_cli/signal_client.py +151 -0
- signal_cli_py-0.2.0/tests/__init__.py +1 -0
- signal_cli_py-0.2.0/tests/conftest.py +30 -0
- signal_cli_py-0.2.0/tests/test_client.py +64 -0
- signal_cli_py-0.2.0/tests/test_config.py +39 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
*.egg-info/
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
*.log
|
|
7
|
+
|
|
8
|
+
# Config files with real data (never commit)
|
|
9
|
+
signal.json
|
|
10
|
+
*.signal.json
|
|
11
|
+
|
|
12
|
+
# Test artifacts
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
htmlcov/
|
|
16
|
+
.coverage
|
|
17
|
+
|
|
18
|
+
# Local development virtualenv
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
.venv
|
|
22
|
+
venv
|
|
23
|
+
|
|
24
|
+
# Editor / IDE
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
*.swo
|
|
28
|
+
|
|
29
|
+
# VS Code - keep useful project settings, ignore user-specific ones
|
|
30
|
+
.vscode/*
|
|
31
|
+
!.vscode/settings.json
|
|
32
|
+
!.vscode/extensions.json
|
|
33
|
+
!.vscode/tasks.json
|
|
34
|
+
!.vscode/launch.json
|
|
35
|
+
|
|
36
|
+
# Generated images (if any are committed for docs)
|
|
37
|
+
*.png
|
|
38
|
+
!docs/*.png
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 signal-cli-py contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: signal-cli-py
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Reusable Python library and CLI for sending text and images via signal-cli-rest-api. Supports both individuals (phone numbers) and groups.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jower999/signal-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/jower999/signal-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/jower999/signal-cli/issues
|
|
8
|
+
Author: signal-cli maintainers
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: delivery,notifications,signal,signal-cli,signal-messaging
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Communications :: Chat
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: requests>=2.32.0
|
|
23
|
+
Requires-Dist: typer[all]>=0.12.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: responses>=0.25.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# signal-cli
|
|
32
|
+
|
|
33
|
+
A reusable Python library and CLI for sending text messages and images via [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api).
|
|
34
|
+
|
|
35
|
+
It supports sending to both **Signal groups** and **individual phone numbers**, and can discover groups your linked account is a member of.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install signal-cli-py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For CLI usage via isolated environment (recommended):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pipx install signal-cli-py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> **Note**: After installing, the command is still `signal-cli` (not `signal-cli-py`).
|
|
50
|
+
> Example: `signal-cli send --recipient team "Hello"`
|
|
51
|
+
>
|
|
52
|
+
> This is intentional — the PyPI package name is `signal-cli-py` to avoid a naming conflict with an older package.
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
### As a Library
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from signal_cli import SignalClient, SignalConfig
|
|
60
|
+
|
|
61
|
+
# Load from default config (~/.signal-cli/config.json)
|
|
62
|
+
client = SignalClient()
|
|
63
|
+
|
|
64
|
+
# Send to a saved recipient (group or phone number)
|
|
65
|
+
client.send("Hello from Python", recipient="team-updates")
|
|
66
|
+
|
|
67
|
+
# Send with an image
|
|
68
|
+
client.send(
|
|
69
|
+
"Weekly report",
|
|
70
|
+
recipient="+46700000001",
|
|
71
|
+
attachments=[{"filename": "report.png", "data": base64_data}]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Discover live groups from your linked account
|
|
75
|
+
groups = client.list_remote_groups()
|
|
76
|
+
for g in groups:
|
|
77
|
+
print(g["name"], g["id"])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### As a CLI
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Basic setup (stores number + API URL)
|
|
84
|
+
signal-cli setup
|
|
85
|
+
|
|
86
|
+
# List groups your linked account can see
|
|
87
|
+
signal-cli group-list --available
|
|
88
|
+
|
|
89
|
+
# Send a message
|
|
90
|
+
signal-cli send --recipient team-updates "Hello team"
|
|
91
|
+
|
|
92
|
+
# Send with image
|
|
93
|
+
signal-cli send --recipient team-updates --image ./chart.png "Weekly update"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
By default, configuration is stored at `~/.signal-cli/config.json`.
|
|
99
|
+
|
|
100
|
+
You can override the config location in two ways:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
# Explicit path
|
|
104
|
+
cfg = SignalConfig(config_path="/path/to/my-signal.json")
|
|
105
|
+
client = SignalClient(config=cfg)
|
|
106
|
+
|
|
107
|
+
# Via environment variable
|
|
108
|
+
export SIGNAL_CLI_CONFIG=/path/to/my-signal.json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Important: Linked Device Behavior
|
|
112
|
+
|
|
113
|
+
This tool is designed to be used as a **linked device** (not a primary registration).
|
|
114
|
+
|
|
115
|
+
- New groups created on your phone may not immediately appear when calling `list_remote_groups()` or `group-list --available`.
|
|
116
|
+
- Common triggers that make new groups visible:
|
|
117
|
+
- Send/receive a message inside the group from your phone
|
|
118
|
+
- Restart the `signal-cli-rest-api` container
|
|
119
|
+
- Re-link the device (most reliable)
|
|
120
|
+
|
|
121
|
+
If a group is missing, the recommended first step is to send a message in it from your phone and then re-check.
|
|
122
|
+
|
|
123
|
+
## Docker Requirement
|
|
124
|
+
|
|
125
|
+
Sending requires a running `signal-cli-rest-api` container (usually managed via Docker Compose).
|
|
126
|
+
|
|
127
|
+
The recommended compose file is installed to `~/.signal-cli/docker-compose.yml` (or you can manage it yourself).
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cd signal-cli
|
|
133
|
+
python -m venv .venv
|
|
134
|
+
source .venv/bin/activate
|
|
135
|
+
pip install -e ".[dev]"
|
|
136
|
+
pytest
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# signal-cli
|
|
2
|
+
|
|
3
|
+
A reusable Python library and CLI for sending text messages and images via [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api).
|
|
4
|
+
|
|
5
|
+
It supports sending to both **Signal groups** and **individual phone numbers**, and can discover groups your linked account is a member of.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install signal-cli-py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For CLI usage via isolated environment (recommended):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pipx install signal-cli-py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> **Note**: After installing, the command is still `signal-cli` (not `signal-cli-py`).
|
|
20
|
+
> Example: `signal-cli send --recipient team "Hello"`
|
|
21
|
+
>
|
|
22
|
+
> This is intentional — the PyPI package name is `signal-cli-py` to avoid a naming conflict with an older package.
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### As a Library
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from signal_cli import SignalClient, SignalConfig
|
|
30
|
+
|
|
31
|
+
# Load from default config (~/.signal-cli/config.json)
|
|
32
|
+
client = SignalClient()
|
|
33
|
+
|
|
34
|
+
# Send to a saved recipient (group or phone number)
|
|
35
|
+
client.send("Hello from Python", recipient="team-updates")
|
|
36
|
+
|
|
37
|
+
# Send with an image
|
|
38
|
+
client.send(
|
|
39
|
+
"Weekly report",
|
|
40
|
+
recipient="+46700000001",
|
|
41
|
+
attachments=[{"filename": "report.png", "data": base64_data}]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Discover live groups from your linked account
|
|
45
|
+
groups = client.list_remote_groups()
|
|
46
|
+
for g in groups:
|
|
47
|
+
print(g["name"], g["id"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### As a CLI
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Basic setup (stores number + API URL)
|
|
54
|
+
signal-cli setup
|
|
55
|
+
|
|
56
|
+
# List groups your linked account can see
|
|
57
|
+
signal-cli group-list --available
|
|
58
|
+
|
|
59
|
+
# Send a message
|
|
60
|
+
signal-cli send --recipient team-updates "Hello team"
|
|
61
|
+
|
|
62
|
+
# Send with image
|
|
63
|
+
signal-cli send --recipient team-updates --image ./chart.png "Weekly update"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
By default, configuration is stored at `~/.signal-cli/config.json`.
|
|
69
|
+
|
|
70
|
+
You can override the config location in two ways:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# Explicit path
|
|
74
|
+
cfg = SignalConfig(config_path="/path/to/my-signal.json")
|
|
75
|
+
client = SignalClient(config=cfg)
|
|
76
|
+
|
|
77
|
+
# Via environment variable
|
|
78
|
+
export SIGNAL_CLI_CONFIG=/path/to/my-signal.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Important: Linked Device Behavior
|
|
82
|
+
|
|
83
|
+
This tool is designed to be used as a **linked device** (not a primary registration).
|
|
84
|
+
|
|
85
|
+
- New groups created on your phone may not immediately appear when calling `list_remote_groups()` or `group-list --available`.
|
|
86
|
+
- Common triggers that make new groups visible:
|
|
87
|
+
- Send/receive a message inside the group from your phone
|
|
88
|
+
- Restart the `signal-cli-rest-api` container
|
|
89
|
+
- Re-link the device (most reliable)
|
|
90
|
+
|
|
91
|
+
If a group is missing, the recommended first step is to send a message in it from your phone and then re-check.
|
|
92
|
+
|
|
93
|
+
## Docker Requirement
|
|
94
|
+
|
|
95
|
+
Sending requires a running `signal-cli-rest-api` container (usually managed via Docker Compose).
|
|
96
|
+
|
|
97
|
+
The recommended compose file is installed to `~/.signal-cli/docker-compose.yml` (or you can manage it yourself).
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cd signal-cli
|
|
103
|
+
python -m venv .venv
|
|
104
|
+
source .venv/bin/activate
|
|
105
|
+
pip install -e ".[dev]"
|
|
106
|
+
pytest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "signal-cli-py"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Reusable Python library and CLI for sending text and images via signal-cli-rest-api. Supports both individuals (phone numbers) and groups."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "signal-cli maintainers" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["signal", "signal-cli", "signal-messaging", "notifications", "delivery"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Communications :: Chat",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"typer[all]>=0.12.0",
|
|
27
|
+
"requests>=2.32.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"responses>=0.25.0",
|
|
34
|
+
"ruff>=0.4.0",
|
|
35
|
+
"black>=24.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
signal-cli = "signal_cli.cli:app"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/jower999/signal-cli"
|
|
43
|
+
Repository = "https://github.com/jower999/signal-cli"
|
|
44
|
+
Issues = "https://github.com/jower999/signal-cli/issues"
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["hatchling"]
|
|
48
|
+
build-backend = "hatchling.build"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["signal_cli"]
|
|
52
|
+
include = ["signal_cli/py.typed"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = [
|
|
56
|
+
"signal_cli/",
|
|
57
|
+
"tests/",
|
|
58
|
+
"README.md",
|
|
59
|
+
"pyproject.toml",
|
|
60
|
+
]
|
|
61
|
+
exclude = [
|
|
62
|
+
"**/__pycache__/",
|
|
63
|
+
"**/*.pyc",
|
|
64
|
+
".pytest_cache/",
|
|
65
|
+
".vscode/",
|
|
66
|
+
".venv/",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
python_files = ["test_*.py"]
|
|
72
|
+
python_classes = ["Test*"]
|
|
73
|
+
python_functions = ["test_*"]
|
|
74
|
+
addopts = "-v --tb=short"
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import requests
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .config import SignalConfig
|
|
14
|
+
from .signal_client import SignalClient
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Signal messaging client (send to groups and individuals)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_config() -> SignalConfig:
|
|
20
|
+
return SignalConfig.load()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def setup():
|
|
25
|
+
"""Interactive setup for Signal integration."""
|
|
26
|
+
config = get_config()
|
|
27
|
+
|
|
28
|
+
number = typer.prompt("Your Signal phone number (e.g. +491234567890)")
|
|
29
|
+
api_url = typer.prompt("signal-cli-rest-api URL", default="http://localhost:8080")
|
|
30
|
+
|
|
31
|
+
config.number = number
|
|
32
|
+
config.api_url = api_url
|
|
33
|
+
config.save()
|
|
34
|
+
typer.echo("✅ Basic configuration saved.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("group-add")
|
|
38
|
+
def group_add(name: str, recipient: str):
|
|
39
|
+
"""Add or update a named recipient (can be a group ID or a phone number)."""
|
|
40
|
+
config = get_config()
|
|
41
|
+
config.recipients[name] = recipient
|
|
42
|
+
config.save()
|
|
43
|
+
typer.echo(f"✅ Recipient '{name}' saved.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("group-list")
|
|
47
|
+
def group_list(
|
|
48
|
+
available: bool = typer.Option(
|
|
49
|
+
False,
|
|
50
|
+
"--available",
|
|
51
|
+
"--remote",
|
|
52
|
+
help="List groups the linked Signal account is actually a member of (live from the service).",
|
|
53
|
+
)
|
|
54
|
+
):
|
|
55
|
+
"""List saved named recipients, or live groups from Signal with --available."""
|
|
56
|
+
config = get_config()
|
|
57
|
+
|
|
58
|
+
if available:
|
|
59
|
+
try:
|
|
60
|
+
client = SignalClient(config)
|
|
61
|
+
groups = client.list_remote_groups()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
typer.echo(f"Failed to fetch groups from Signal: {e}")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
if not groups:
|
|
67
|
+
typer.echo(
|
|
68
|
+
"No groups found for this account (or the account is not yet linked)."
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
typer.echo("\nAvailable groups from your linked Signal account:\n")
|
|
73
|
+
for i, g in enumerate(groups, 1):
|
|
74
|
+
gid = g.get("id") or g.get("groupId") or g.get("internal_id", "unknown")
|
|
75
|
+
name = g.get("name") or g.get("title") or "(no name)"
|
|
76
|
+
members = g.get("members", [])
|
|
77
|
+
is_admin = g.get("isAdmin", False)
|
|
78
|
+
typer.echo(f" {i}. {name}")
|
|
79
|
+
typer.echo(f" ID: {gid}")
|
|
80
|
+
typer.echo(
|
|
81
|
+
f" Members: {len(members)} | Admin: {'yes' if is_admin else 'no'}"
|
|
82
|
+
)
|
|
83
|
+
typer.echo()
|
|
84
|
+
|
|
85
|
+
# Interactive picker to save groups with friendly names
|
|
86
|
+
if typer.confirm(
|
|
87
|
+
"Would you like to save any of these groups with a friendly name?",
|
|
88
|
+
default=False,
|
|
89
|
+
):
|
|
90
|
+
for g in groups:
|
|
91
|
+
gid = g.get("id") or g.get("groupId") or g.get("internal_id")
|
|
92
|
+
if not gid:
|
|
93
|
+
continue
|
|
94
|
+
default_name = g.get("name") or g.get("title") or ""
|
|
95
|
+
if typer.confirm(
|
|
96
|
+
f"Save group '{default_name or gid}' ?", default=False
|
|
97
|
+
):
|
|
98
|
+
friendly = typer.prompt(
|
|
99
|
+
"Enter a friendly name for this group", default=default_name
|
|
100
|
+
)
|
|
101
|
+
if friendly:
|
|
102
|
+
config.recipients[friendly] = gid
|
|
103
|
+
config.save()
|
|
104
|
+
typer.echo(f"✅ Saved as '{friendly}'")
|
|
105
|
+
typer.echo(
|
|
106
|
+
"\nDone. Use 'group-list' (without --available) to see your saved names."
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Default: list saved named recipients
|
|
111
|
+
if not config.recipients:
|
|
112
|
+
typer.echo(
|
|
113
|
+
"No recipients configured yet. Use 'group-add <name> <id-or-phone>' or 'group-list --available' to discover groups."
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
typer.echo("Saved recipients (groups and individuals):\n")
|
|
118
|
+
for name, rid in config.recipients.items():
|
|
119
|
+
typer.echo(f" {name}: {rid}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("group-remove")
|
|
123
|
+
def group_remove(name: str):
|
|
124
|
+
"""Remove a saved named recipient."""
|
|
125
|
+
config = get_config()
|
|
126
|
+
if name in config.recipients:
|
|
127
|
+
del config.recipients[name]
|
|
128
|
+
config.save()
|
|
129
|
+
typer.echo(f"✅ Removed recipient '{name}'")
|
|
130
|
+
else:
|
|
131
|
+
typer.echo(f"Recipient '{name}' not found.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def send(
|
|
136
|
+
recipient: Optional[str] = typer.Option(
|
|
137
|
+
None,
|
|
138
|
+
"--recipient",
|
|
139
|
+
"-r",
|
|
140
|
+
help="Recipient name, group ID, or phone number (+467...). Use 'group-list --available' to discover groups.",
|
|
141
|
+
),
|
|
142
|
+
group: Optional[str] = typer.Option(
|
|
143
|
+
None,
|
|
144
|
+
"--group",
|
|
145
|
+
"-g",
|
|
146
|
+
help="Deprecated alias for --recipient (kept for compatibility with --share).",
|
|
147
|
+
),
|
|
148
|
+
message: Optional[str] = typer.Argument(
|
|
149
|
+
None, help="Message text (not needed if --json is used)"
|
|
150
|
+
),
|
|
151
|
+
images: Optional[List[Path]] = typer.Option(None, "--image", "-i"),
|
|
152
|
+
json_input: bool = typer.Option(False, "--json"),
|
|
153
|
+
):
|
|
154
|
+
"""Send a message (with optional images) to a Signal recipient (group or individual)."""
|
|
155
|
+
config = get_config()
|
|
156
|
+
|
|
157
|
+
if not config.number:
|
|
158
|
+
typer.echo("Please run `signal-cli setup` first.")
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
|
|
161
|
+
# Support both --recipient (new) and --group (legacy alias)
|
|
162
|
+
target = recipient or group
|
|
163
|
+
|
|
164
|
+
attachments = []
|
|
165
|
+
|
|
166
|
+
if json_input:
|
|
167
|
+
data = json.load(sys.stdin)
|
|
168
|
+
message = data.get("message") or message
|
|
169
|
+
target = data.get("recipient") or data.get("group") or target
|
|
170
|
+
attachments = data.get("attachments", [])
|
|
171
|
+
else:
|
|
172
|
+
if images:
|
|
173
|
+
for img_path in images:
|
|
174
|
+
if str(img_path) in ("-", "/dev/stdin"):
|
|
175
|
+
raw = sys.stdin.buffer.read()
|
|
176
|
+
if not raw:
|
|
177
|
+
typer.echo(
|
|
178
|
+
"Error: No image data received on stdin for --image -"
|
|
179
|
+
)
|
|
180
|
+
raise typer.Exit(1)
|
|
181
|
+
b64 = base64.b64encode(raw).decode("utf-8")
|
|
182
|
+
attachments.append({"filename": "image.png", "data": b64})
|
|
183
|
+
elif not img_path.exists():
|
|
184
|
+
typer.echo(f"Image not found: {img_path}")
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
else:
|
|
187
|
+
with open(img_path, "rb") as f:
|
|
188
|
+
data = base64.b64encode(f.read()).decode("utf-8")
|
|
189
|
+
attachments.append({"filename": img_path.name, "data": data})
|
|
190
|
+
|
|
191
|
+
if not target:
|
|
192
|
+
typer.echo(
|
|
193
|
+
"Missing recipient. Use --recipient / -r (or the legacy --group / -g)."
|
|
194
|
+
)
|
|
195
|
+
raise typer.Exit(1)
|
|
196
|
+
|
|
197
|
+
if not message and not attachments:
|
|
198
|
+
typer.echo(
|
|
199
|
+
"Error: MESSAGE is required (or provide attachments via --json or --image)"
|
|
200
|
+
)
|
|
201
|
+
raise typer.Exit(1)
|
|
202
|
+
|
|
203
|
+
client = SignalClient(config)
|
|
204
|
+
result = client.send(message, recipient=target, attachments=attachments)
|
|
205
|
+
typer.echo(f"✅ Message sent. Timestamp: {result.get('timestamp')}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def link(device_name: str = "signal-cli"):
|
|
210
|
+
"""Generate a linking QR code and open it in your browser."""
|
|
211
|
+
config = get_config()
|
|
212
|
+
|
|
213
|
+
if not config.api_url:
|
|
214
|
+
typer.echo("No API URL configured. Please run 'signal-cli setup' first.")
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
url = f"{config.api_url}/v1/qrcodelink?device_name={device_name}"
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
response = requests.get(url, timeout=15)
|
|
221
|
+
response.raise_for_status()
|
|
222
|
+
|
|
223
|
+
content_type = response.headers.get("Content-Type", "").lower()
|
|
224
|
+
is_png = "image" in content_type or response.content[:8] == b"\x89PNG\r\n\x1a\n"
|
|
225
|
+
|
|
226
|
+
typer.echo("\n✅ Linking QR code generated!")
|
|
227
|
+
|
|
228
|
+
if is_png:
|
|
229
|
+
# Modern behavior of signal-cli-rest-api: the endpoint returns the QR PNG directly
|
|
230
|
+
png_path = Path(tempfile.gettempdir()) / "signal-cli-link-qr.png"
|
|
231
|
+
png_path.write_bytes(response.content)
|
|
232
|
+
|
|
233
|
+
typer.echo(f"\nQR code saved as PNG: {png_path}")
|
|
234
|
+
typer.echo("Opening image viewer...")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
subprocess.run(["open", str(png_path)], check=False)
|
|
238
|
+
typer.echo("→ QR code image opened in your default image viewer.")
|
|
239
|
+
except Exception:
|
|
240
|
+
typer.echo("→ Could not auto-open the image.")
|
|
241
|
+
typer.echo(f" Please open this file manually:\n {png_path}")
|
|
242
|
+
|
|
243
|
+
# We don't have the raw text URI in this case (it's inside the PNG)
|
|
244
|
+
linking_uri = "(binary PNG QR code returned by API)"
|
|
245
|
+
|
|
246
|
+
else:
|
|
247
|
+
# Older behavior: the endpoint returned the raw tsdevice:/... text URI
|
|
248
|
+
linking_uri = response.text.strip()
|
|
249
|
+
|
|
250
|
+
typer.echo(f"\nLinking URI:\n{linking_uri}\n")
|
|
251
|
+
|
|
252
|
+
# URL-encode and let quickchart turn it into a QR image
|
|
253
|
+
encoded_uri = urllib.parse.quote(linking_uri, safe="")
|
|
254
|
+
qr_url = f"https://quickchart.io/qr?text={encoded_uri}&size=300"
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
webbrowser.open(qr_url)
|
|
258
|
+
typer.echo("→ A QR code has been opened in your browser.")
|
|
259
|
+
except Exception:
|
|
260
|
+
typer.echo("→ Could not open browser automatically.")
|
|
261
|
+
typer.echo(
|
|
262
|
+
f" Open this link manually to see the QR code:\n {qr_url}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
typer.echo("\nNext steps:")
|
|
266
|
+
typer.echo("1. Open the Signal app on your **phone**")
|
|
267
|
+
typer.echo("2. Go to Profile → Linked Devices → 'Link New Device'")
|
|
268
|
+
typer.echo("3. Scan the QR code")
|
|
269
|
+
typer.echo("\nAfter scanning, wait 10–20 seconds, then run:")
|
|
270
|
+
typer.echo(" signal-cli status")
|
|
271
|
+
|
|
272
|
+
except requests.exceptions.RequestException as e:
|
|
273
|
+
typer.echo(f"Failed to generate linking URI: {e}")
|
|
274
|
+
typer.echo(
|
|
275
|
+
"Make sure the signal-cli-rest-api is running on the configured URL."
|
|
276
|
+
)
|
|
277
|
+
typer.echo(
|
|
278
|
+
"If you see 'UnsupportedOperationException', edit docker-compose.signal.yml"
|
|
279
|
+
)
|
|
280
|
+
typer.echo(
|
|
281
|
+
"to use MODE=native (or normal) instead of json-rpc, then restart the container."
|
|
282
|
+
)
|
|
283
|
+
raise typer.Exit(1)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
app()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Dict, Optional, Union
|
|
6
|
+
|
|
7
|
+
# Default configuration directory for standalone use.
|
|
8
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".signal-cli"
|
|
9
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SignalConfig:
|
|
13
|
+
"""
|
|
14
|
+
Configuration for the Signal delivery client.
|
|
15
|
+
|
|
16
|
+
Supports:
|
|
17
|
+
- Custom config file location (constructor or SIGNAL_CLI_CONFIG env var)
|
|
18
|
+
- Backward compatibility with very old config files that used "groups" instead of "recipients"
|
|
19
|
+
- Named shortcuts that can point to either Signal groups or individual phone numbers
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config_path: Optional[Union[str, Path]] = None):
|
|
23
|
+
self._config_path = self._resolve_config_path(config_path)
|
|
24
|
+
|
|
25
|
+
self.number: Optional[str] = None
|
|
26
|
+
self.api_url: str = "http://localhost:8080"
|
|
27
|
+
|
|
28
|
+
# Canonical storage (new name)
|
|
29
|
+
self.recipients: Dict[str, str] = {}
|
|
30
|
+
|
|
31
|
+
# Legacy compatibility shim (see @property below)
|
|
32
|
+
self._migrated_from_groups = False
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------- #
|
|
35
|
+
# Path resolution
|
|
36
|
+
# --------------------------------------------------------------------- #
|
|
37
|
+
def _resolve_config_path(self, explicit: Optional[Union[str, Path]]) -> Path:
|
|
38
|
+
if explicit:
|
|
39
|
+
return Path(explicit).expanduser()
|
|
40
|
+
env = os.environ.get("SIGNAL_CLI_CONFIG")
|
|
41
|
+
if env:
|
|
42
|
+
return Path(env).expanduser()
|
|
43
|
+
return DEFAULT_CONFIG_FILE
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def config_path(self) -> Path:
|
|
47
|
+
"""The actual path this config instance will read from / write to."""
|
|
48
|
+
return self._config_path
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------- #
|
|
51
|
+
# Legacy "groups" compatibility shim
|
|
52
|
+
# --------------------------------------------------------------------- #
|
|
53
|
+
@property
|
|
54
|
+
def groups(self) -> Dict[str, str]:
|
|
55
|
+
"""Backward-compat alias. Prefer .recipients in new code."""
|
|
56
|
+
warnings.warn(
|
|
57
|
+
"SignalConfig.groups is deprecated and will be removed in 0.3.0. "
|
|
58
|
+
"Use .recipients instead.",
|
|
59
|
+
DeprecationWarning,
|
|
60
|
+
stacklevel=2,
|
|
61
|
+
)
|
|
62
|
+
return self.recipients
|
|
63
|
+
|
|
64
|
+
@groups.setter
|
|
65
|
+
def groups(self, value: Dict[str, str]):
|
|
66
|
+
warnings.warn(
|
|
67
|
+
"SignalConfig.groups is deprecated and will be removed in 0.3.0. "
|
|
68
|
+
"Use .recipients instead.",
|
|
69
|
+
DeprecationWarning,
|
|
70
|
+
stacklevel=2,
|
|
71
|
+
)
|
|
72
|
+
self.recipients = value
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------- #
|
|
75
|
+
# Persistence
|
|
76
|
+
# --------------------------------------------------------------------- #
|
|
77
|
+
@classmethod
|
|
78
|
+
def load(cls, config_path: Optional[Union[str, Path]] = None) -> "SignalConfig":
|
|
79
|
+
config = cls(config_path=config_path)
|
|
80
|
+
path = config._config_path
|
|
81
|
+
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return config
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(path.read_text())
|
|
87
|
+
except Exception:
|
|
88
|
+
# Corrupt file — start fresh but keep the path
|
|
89
|
+
return config
|
|
90
|
+
|
|
91
|
+
config.number = data.get("number")
|
|
92
|
+
config.api_url = data.get("api_url", config.api_url)
|
|
93
|
+
|
|
94
|
+
# New key takes precedence
|
|
95
|
+
if "recipients" in data:
|
|
96
|
+
config.recipients = data.get("recipients", {}) or {}
|
|
97
|
+
elif "groups" in data:
|
|
98
|
+
# One-time migration from legacy "groups" key (very old config files)
|
|
99
|
+
config.recipients = data.get("groups", {}) or {}
|
|
100
|
+
config._migrated_from_groups = True
|
|
101
|
+
|
|
102
|
+
# Auto-clean the file on first migration so users don't keep the old key forever
|
|
103
|
+
if config._migrated_from_groups:
|
|
104
|
+
try:
|
|
105
|
+
config.save()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # non-fatal
|
|
108
|
+
|
|
109
|
+
return config
|
|
110
|
+
|
|
111
|
+
def save(self) -> None:
|
|
112
|
+
"""Persist configuration. Always writes under the modern 'recipients' key."""
|
|
113
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
data: Dict[str, object] = {
|
|
116
|
+
"number": self.number,
|
|
117
|
+
"api_url": self.api_url,
|
|
118
|
+
"recipients": self.recipients,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# If we migrated on this run, we already dropped the old key by not writing it.
|
|
122
|
+
self._config_path.write_text(json.dumps(data, indent=2))
|
|
123
|
+
|
|
124
|
+
# --------------------------------------------------------------------- #
|
|
125
|
+
# Resolution helpers
|
|
126
|
+
# --------------------------------------------------------------------- #
|
|
127
|
+
def resolve_recipient(self, name_or_id: str) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Resolve a friendly name (or raw ID/phone) to the actual Signal recipient.
|
|
130
|
+
|
|
131
|
+
Works for both saved group IDs and saved individual phone numbers.
|
|
132
|
+
If the value is not a known name, it is returned as-is (assumed to be
|
|
133
|
+
a raw group ID or phone number).
|
|
134
|
+
"""
|
|
135
|
+
return self.recipients.get(name_or_id, name_or_id)
|
|
136
|
+
|
|
137
|
+
# Keep old name working during the transition period (used by SignalClient today)
|
|
138
|
+
def get_group_id(self, name_or_id: str) -> str:
|
|
139
|
+
"""Deprecated alias for resolve_recipient. Will be removed in 0.3.0."""
|
|
140
|
+
warnings.warn(
|
|
141
|
+
"get_group_id() is deprecated, use resolve_recipient() instead.",
|
|
142
|
+
DeprecationWarning,
|
|
143
|
+
stacklevel=2,
|
|
144
|
+
)
|
|
145
|
+
return self.resolve_recipient(name_or_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file marks the package as supporting type checking (PEP 561)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import List, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .config import SignalConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SignalClient:
|
|
11
|
+
"""
|
|
12
|
+
Client for sending messages and attachments via signal-cli-rest-api.
|
|
13
|
+
|
|
14
|
+
Can be used either with a SignalConfig or with direct credentials.
|
|
15
|
+
Standalone usage with explicit number/api_url is fully supported.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
config: Optional[SignalConfig] = None,
|
|
21
|
+
number: Optional[str] = None,
|
|
22
|
+
api_url: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Create a SignalClient.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: A SignalConfig instance (provides number, api_url, and named recipients).
|
|
29
|
+
number: Override the sending phone number (E.164 format).
|
|
30
|
+
api_url: Override the signal-cli-rest-api base URL.
|
|
31
|
+
"""
|
|
32
|
+
if config is None:
|
|
33
|
+
config = SignalConfig()
|
|
34
|
+
|
|
35
|
+
self.config = config
|
|
36
|
+
self._number = number or config.number
|
|
37
|
+
self._api_url = api_url or config.api_url
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def number(self) -> Optional[str]:
|
|
41
|
+
return self._number
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def api_url(self) -> str:
|
|
45
|
+
return self._api_url
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
# Core sending
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
def send(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
recipient: Optional[str] = None,
|
|
54
|
+
attachments: Optional[List[Dict[str, str]]] = None,
|
|
55
|
+
*,
|
|
56
|
+
# Deprecated alias kept for backward compatibility during transition
|
|
57
|
+
group: Optional[str] = None,
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""
|
|
60
|
+
Send a text message (optionally with attachments) to a recipient.
|
|
61
|
+
|
|
62
|
+
The recipient can be:
|
|
63
|
+
- A friendly name saved in config.recipients
|
|
64
|
+
- A raw Signal group ID (group.XXXX...)
|
|
65
|
+
- A phone number in E.164 format (+467...)
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
message: Text to send.
|
|
69
|
+
recipient: Target (name, group ID, or phone number). Preferred parameter.
|
|
70
|
+
attachments: Optional list of {"filename": , "data": base64} dicts.
|
|
71
|
+
group: Deprecated alias for recipient. Will be removed in 0.3.0.
|
|
72
|
+
"""
|
|
73
|
+
if group is not None:
|
|
74
|
+
if recipient is None:
|
|
75
|
+
recipient = group
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError("Specify either 'recipient' or 'group', not both.")
|
|
78
|
+
warnings.warn(
|
|
79
|
+
"The 'group' parameter is deprecated. Use 'recipient' instead.",
|
|
80
|
+
DeprecationWarning,
|
|
81
|
+
stacklevel=2,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not recipient:
|
|
85
|
+
raise ValueError("recipient is required")
|
|
86
|
+
|
|
87
|
+
recipient_id = self.config.resolve_recipient(recipient)
|
|
88
|
+
|
|
89
|
+
if not self.number:
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"No sending phone number configured. "
|
|
92
|
+
"Run `signal-cli setup` or pass number= to SignalClient()."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
payload: Dict[str, object] = {
|
|
96
|
+
"number": self.number,
|
|
97
|
+
"message": message,
|
|
98
|
+
"recipients": [recipient_id],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if attachments:
|
|
102
|
+
payload["base64_attachments"] = [
|
|
103
|
+
self._format_attachment(att) for att in attachments
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
response = requests.post(
|
|
107
|
+
f"{self.api_url}/v2/send",
|
|
108
|
+
json=payload,
|
|
109
|
+
timeout=60,
|
|
110
|
+
)
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
return response.json()
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------ #
|
|
115
|
+
# Group discovery (powers `group list --available`)
|
|
116
|
+
# ------------------------------------------------------------------ #
|
|
117
|
+
def list_remote_groups(self) -> List[dict]:
|
|
118
|
+
"""
|
|
119
|
+
Fetch the list of Signal groups the linked account is a member of.
|
|
120
|
+
|
|
121
|
+
This calls the signal-cli-rest-api endpoint:
|
|
122
|
+
GET /v1/groups/{number}
|
|
123
|
+
|
|
124
|
+
Returns the raw list of group objects (containing id, name, members, etc.).
|
|
125
|
+
"""
|
|
126
|
+
if not self.number:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"Cannot list groups: no phone number is configured on this client."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
url = f"{self.api_url}/v1/groups/{self.number}"
|
|
132
|
+
resp = requests.get(url, timeout=30)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
return resp.json()
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
# Helpers
|
|
138
|
+
# ------------------------------------------------------------------ #
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _format_attachment(attachment: Dict[str, str]) -> str:
|
|
141
|
+
data = attachment["data"]
|
|
142
|
+
if data.startswith("data:"):
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
filename = attachment.get("filename", "attachment.bin")
|
|
146
|
+
mime_type = (
|
|
147
|
+
attachment.get("content_type")
|
|
148
|
+
or mimetypes.guess_type(filename)[0]
|
|
149
|
+
or "application/octet-stream"
|
|
150
|
+
)
|
|
151
|
+
return f"data:{mime_type};filename={filename};base64,{data}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests for the signal-cli package
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def temp_config_path():
|
|
8
|
+
"""Provide a temporary config file path for isolated tests."""
|
|
9
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
10
|
+
path = Path(tmpdir) / "test-signal.json"
|
|
11
|
+
yield path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def config_with_legacy_groups(temp_config_path):
|
|
16
|
+
"""Create a SignalConfig file that still uses the old 'groups' key (for migration testing)."""
|
|
17
|
+
data = {
|
|
18
|
+
"number": "+46700000001",
|
|
19
|
+
"api_url": "http://localhost:8080",
|
|
20
|
+
"groups": {
|
|
21
|
+
"team": "group.ABC123",
|
|
22
|
+
"boss": "+46700000002",
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
temp_config_path.write_text(str(data).replace("'", '"')) # simple json-like
|
|
26
|
+
# Better to write proper JSON
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
temp_config_path.write_text(json.dumps(data, indent=2))
|
|
30
|
+
return temp_config_path
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import responses
|
|
2
|
+
|
|
3
|
+
from signal_cli.config import SignalConfig
|
|
4
|
+
from signal_cli.signal_client import SignalClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@responses.activate
|
|
8
|
+
def test_list_remote_groups_returns_data():
|
|
9
|
+
"""SignalClient.list_remote_groups should call the correct endpoint and return the response."""
|
|
10
|
+
number = "+46700000001" # single source of truth for test data
|
|
11
|
+
|
|
12
|
+
cfg = SignalConfig()
|
|
13
|
+
cfg.number = number
|
|
14
|
+
cfg.api_url = "http://localhost:8080"
|
|
15
|
+
|
|
16
|
+
mock_response = [
|
|
17
|
+
{"id": "group.ABC", "name": "Team A", "members": ["+1", "+2"]},
|
|
18
|
+
{"id": "group.DEF", "name": "Team B", "members": ["+3"]},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
responses.add(
|
|
22
|
+
responses.GET,
|
|
23
|
+
f"http://localhost:8080/v1/groups/{number}",
|
|
24
|
+
json=mock_response,
|
|
25
|
+
status=200,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
client = SignalClient(config=cfg)
|
|
29
|
+
groups = client.list_remote_groups()
|
|
30
|
+
|
|
31
|
+
assert len(groups) == 2
|
|
32
|
+
assert groups[0]["name"] == "Team A"
|
|
33
|
+
assert responses.calls[0].request.url.endswith(f"/v1/groups/{number}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@responses.activate
|
|
37
|
+
def test_send_uses_recipient():
|
|
38
|
+
"""SignalClient.send should resolve the recipient and call /v2/send with correct payload."""
|
|
39
|
+
cfg = SignalConfig()
|
|
40
|
+
cfg.number = "+46700000001"
|
|
41
|
+
cfg.api_url = "http://localhost:8080"
|
|
42
|
+
cfg.recipients["team"] = "group.XYZ123"
|
|
43
|
+
|
|
44
|
+
responses.add(
|
|
45
|
+
responses.POST,
|
|
46
|
+
"http://localhost:8080/v2/send",
|
|
47
|
+
json={"timestamp": 123456789},
|
|
48
|
+
status=200,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
client = SignalClient(config=cfg)
|
|
52
|
+
result = client.send("Hello team", recipient="team")
|
|
53
|
+
|
|
54
|
+
assert result["timestamp"] == 123456789
|
|
55
|
+
|
|
56
|
+
request = responses.calls[0].request
|
|
57
|
+
payload = (
|
|
58
|
+
request.body.decode()
|
|
59
|
+
if isinstance(request.body, (bytes, bytearray))
|
|
60
|
+
else request.body
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert "group.XYZ123" in payload # the resolved recipient
|
|
64
|
+
assert "Hello team" in payload
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from signal_cli.config import SignalConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_load_with_legacy_groups_migrates_to_recipients(config_with_legacy_groups):
|
|
7
|
+
"""Loading a config that still has the old 'groups' key should migrate to 'recipients'."""
|
|
8
|
+
cfg = SignalConfig.load(config_with_legacy_groups)
|
|
9
|
+
|
|
10
|
+
assert "team" in cfg.recipients
|
|
11
|
+
assert cfg.recipients["team"] == "group.ABC123"
|
|
12
|
+
assert "boss" in cfg.recipients
|
|
13
|
+
|
|
14
|
+
# After load + save, the file should no longer contain the old key
|
|
15
|
+
data = json.loads(config_with_legacy_groups.read_text())
|
|
16
|
+
assert "recipients" in data
|
|
17
|
+
assert "groups" not in data
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_custom_config_path(temp_config_path):
|
|
21
|
+
"""SignalConfig should respect an explicit config_path."""
|
|
22
|
+
cfg = SignalConfig(config_path=temp_config_path)
|
|
23
|
+
cfg.number = "+123456"
|
|
24
|
+
cfg.recipients["test"] = "group.TEST"
|
|
25
|
+
cfg.save()
|
|
26
|
+
|
|
27
|
+
assert temp_config_path.exists()
|
|
28
|
+
|
|
29
|
+
# Load it back
|
|
30
|
+
cfg2 = SignalConfig.load(temp_config_path)
|
|
31
|
+
assert cfg2.number == "+123456"
|
|
32
|
+
assert cfg2.recipients["test"] == "group.TEST"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_load_nonexistent_path_returns_empty_config(temp_config_path):
|
|
36
|
+
"""Loading a path that does not exist should return a default config."""
|
|
37
|
+
cfg = SignalConfig.load(temp_config_path)
|
|
38
|
+
assert cfg.number is None
|
|
39
|
+
assert cfg.recipients == {}
|