signal-cli-py 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from .config import SignalConfig
2
+ from .signal_client import SignalClient
3
+
4
+ __version__ = "0.2.0"
5
+
6
+ __all__ = [
7
+ "SignalClient",
8
+ "SignalConfig",
9
+ "__version__",
10
+ ]
signal_cli/cli.py ADDED
@@ -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()
signal_cli/config.py ADDED
@@ -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)
signal_cli/py.typed ADDED
@@ -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,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,10 @@
1
+ signal_cli/__init__.py,sha256=Klwp2wRn4VoZGbAvMXl2hQbhmlZ9gDnF7amgf_vptcI,170
2
+ signal_cli/cli.py,sha256=OSgXEvASd6lUHpAXIgGntGGg-_syqONu1bgFFBQ4Cf4,10161
3
+ signal_cli/config.py,sha256=ED0qefl_CAoo7MSqVhLcyKoVgXjSdOhzbxJ6oJHMuYE,5409
4
+ signal_cli/py.typed,sha256=YUvbSJSsAPIx-bbZpt3LGQLdW8tD7XUMIwnTgMXrm1U,67
5
+ signal_cli/signal_client.py,sha256=IM3prNFBM_LvQkGV8iPjiRxauBP9FYFeFGaRqAsm9MA,4963
6
+ signal_cli_py-0.2.0.dist-info/METADATA,sha256=HbNptG86wPEviZINK2zT3fFYAwXXbszSqgGZzQGWAnM,4224
7
+ signal_cli_py-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ signal_cli_py-0.2.0.dist-info/entry_points.txt,sha256=KuQHjMPrjYWFfCejw7WnBXUK5PM8v_Wu7mF3wRYotDk,50
9
+ signal_cli_py-0.2.0.dist-info/licenses/LICENSE,sha256=RdsZRd_ULmihSkt4jAJ2r8ELxF_PU0AHAFd8P3JOW7w,1082
10
+ signal_cli_py-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ signal-cli = signal_cli.cli:app
@@ -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.