commune-cli 0.1.5__tar.gz → 0.1.8__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.
- {commune_cli-0.1.5 → commune_cli-0.1.8}/.gitignore +8 -1
- {commune_cli-0.1.5 → commune_cli-0.1.8}/PKG-INFO +1 -1
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/credits.py +39 -1
- commune_cli-0.1.8/commune_cli/commands/phone_numbers.py +297 -0
- commune_cli-0.1.8/commune_cli/commands/sms.py +354 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/pyproject.toml +1 -1
- commune_cli-0.1.5/commune_cli/commands/phone_numbers.py +0 -92
- commune_cli-0.1.5/commune_cli/commands/sms.py +0 -187
- {commune_cli-0.1.5 → commune_cli-0.1.8}/README.md +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/__init__.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/banner.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/client.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/__init__.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/attachments.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/config_cmd.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/data.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/delivery.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/dmarc.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/domains.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/inboxes.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/messages.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/search.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/threads.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/commands/webhooks.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/config.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/errors.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/main.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/output.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/commune_cli/state.py +0 -0
- {commune_cli-0.1.5 → commune_cli-0.1.8}/uv.lock +0 -0
|
@@ -79,4 +79,11 @@ build/
|
|
|
79
79
|
.mypy_cache/
|
|
80
80
|
.ruff_cache/
|
|
81
81
|
*.egg-info/
|
|
82
|
-
pip-wheel-metadata/
|
|
82
|
+
pip-wheel-metadata/
|
|
83
|
+
|
|
84
|
+
# Large model/binary files (GitHub 100MB limit)
|
|
85
|
+
launch-video/whisper.cpp/
|
|
86
|
+
launch-video/documents/
|
|
87
|
+
|
|
88
|
+
# OpenNext build output
|
|
89
|
+
frontend/.open-next/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commune-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Official CLI for the Commune email API — agent-native, pipe-friendly, covers every API surface.
|
|
5
5
|
Project-URL: Homepage, https://commune.email
|
|
6
6
|
Project-URL: Documentation, https://docs.commune.email
|
|
@@ -6,7 +6,7 @@ import typer
|
|
|
6
6
|
|
|
7
7
|
from ..client import CommuneClient
|
|
8
8
|
from ..errors import api_error, auth_required_error, network_error
|
|
9
|
-
from ..output import print_list, print_record
|
|
9
|
+
from ..output import print_list, print_record, print_success
|
|
10
10
|
from ..state import AppState
|
|
11
11
|
|
|
12
12
|
app = typer.Typer(help="Credit balance and available bundles.", no_args_is_help=True)
|
|
@@ -64,3 +64,41 @@ def credits_bundles(
|
|
|
64
64
|
("Description", "description"),
|
|
65
65
|
],
|
|
66
66
|
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("checkout")
|
|
70
|
+
def credits_checkout(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
bundle: str = typer.Argument(..., help="Bundle to purchase: starter, growth, or scale."),
|
|
73
|
+
return_url: str = typer.Option(None, "--return-url", help="URL to redirect to after payment (optional)."),
|
|
74
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Create a Stripe checkout session to purchase credits. POST /v1/credits/checkout."""
|
|
77
|
+
state: AppState = ctx.obj or AppState()
|
|
78
|
+
if not state.has_any_auth():
|
|
79
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
80
|
+
|
|
81
|
+
payload: dict = {"bundle": bundle}
|
|
82
|
+
if return_url:
|
|
83
|
+
payload["return_url"] = return_url
|
|
84
|
+
|
|
85
|
+
client = CommuneClient.from_state(state)
|
|
86
|
+
try:
|
|
87
|
+
r = client.post("/v1/credits/checkout", json=payload)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
90
|
+
|
|
91
|
+
if not r.is_success:
|
|
92
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
93
|
+
|
|
94
|
+
data = r.json()
|
|
95
|
+
if json_output or state.should_json():
|
|
96
|
+
from ..output import print_json
|
|
97
|
+
print_json(data)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
checkout_url = data.get("checkout_url") or data.get("checkoutUrl", "")
|
|
101
|
+
credits = data.get("credits", "")
|
|
102
|
+
price = data.get("price", "")
|
|
103
|
+
print_success(f"Checkout session created for [bold]{bundle}[/bold] bundle ({credits} credits, ${price}).")
|
|
104
|
+
typer.echo(f"\nOpen this URL in your browser to complete payment:\n{checkout_url}")
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""commune phone-numbers — phone number management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..client import CommuneClient
|
|
10
|
+
from ..errors import api_error, auth_required_error, network_error
|
|
11
|
+
from ..output import print_list, print_record, print_success
|
|
12
|
+
from ..state import AppState
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Phone number management: list, get, provision, release, settings.", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def phone_numbers_list(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""List all phone numbers in your organization. GET /v1/phone-numbers."""
|
|
23
|
+
state: AppState = ctx.obj or AppState()
|
|
24
|
+
if not state.has_any_auth():
|
|
25
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
26
|
+
|
|
27
|
+
client = CommuneClient.from_state(state)
|
|
28
|
+
try:
|
|
29
|
+
r = client.get("/v1/phone-numbers")
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
32
|
+
|
|
33
|
+
if not r.is_success:
|
|
34
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
35
|
+
|
|
36
|
+
print_list(
|
|
37
|
+
r.json(),
|
|
38
|
+
json_output=json_output or state.should_json(),
|
|
39
|
+
title="Phone Numbers",
|
|
40
|
+
columns=[
|
|
41
|
+
("ID", "id"),
|
|
42
|
+
("Number", "phoneNumber"),
|
|
43
|
+
("Friendly Name", "friendlyName"),
|
|
44
|
+
("Status", "status"),
|
|
45
|
+
("Created", "createdAt"),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("get")
|
|
51
|
+
def phone_numbers_get(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
phone_number_id: str = typer.Argument(..., help="Phone number ID."),
|
|
54
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Get a specific phone number. GET /v1/phone-numbers/{phoneNumberId}."""
|
|
57
|
+
state: AppState = ctx.obj or AppState()
|
|
58
|
+
if not state.has_any_auth():
|
|
59
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
60
|
+
|
|
61
|
+
client = CommuneClient.from_state(state)
|
|
62
|
+
try:
|
|
63
|
+
r = client.get(f"/v1/phone-numbers/{phone_number_id}")
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
66
|
+
|
|
67
|
+
if not r.is_success:
|
|
68
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
69
|
+
|
|
70
|
+
print_record(r.json(), json_output=json_output or state.should_json(), title="Phone Number")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("available")
|
|
74
|
+
def phone_numbers_available(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
type: Optional[str] = typer.Option("TollFree", "--type", help="Number type: TollFree or Local."),
|
|
77
|
+
country: Optional[str] = typer.Option("US", "--country", help="Two-letter country code."),
|
|
78
|
+
limit: Optional[int] = typer.Option(20, "--limit", help="Maximum results to return."),
|
|
79
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""List available phone numbers to purchase. GET /v1/phone-numbers/available."""
|
|
82
|
+
state: AppState = ctx.obj or AppState()
|
|
83
|
+
if not state.has_any_auth():
|
|
84
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
85
|
+
|
|
86
|
+
client = CommuneClient.from_state(state)
|
|
87
|
+
try:
|
|
88
|
+
r = client.get("/v1/phone-numbers/available", params={
|
|
89
|
+
"type": type,
|
|
90
|
+
"country": country,
|
|
91
|
+
"limit": limit,
|
|
92
|
+
})
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
95
|
+
|
|
96
|
+
if not r.is_success:
|
|
97
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
98
|
+
|
|
99
|
+
print_list(
|
|
100
|
+
r.json(),
|
|
101
|
+
json_output=json_output or state.should_json(),
|
|
102
|
+
title="Available Phone Numbers",
|
|
103
|
+
columns=[
|
|
104
|
+
("Number", "phoneNumber"),
|
|
105
|
+
("Friendly Name", "friendlyName"),
|
|
106
|
+
("Region", "region"),
|
|
107
|
+
("Locality", "locality"),
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("provision")
|
|
113
|
+
def phone_numbers_provision(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
phone_number: Optional[str] = typer.Option(None, "--phone-number", help="Specific E.164 number to buy (e.g. +18005551234). Auto-selected if omitted."),
|
|
116
|
+
type: Optional[str] = typer.Option("tollfree", "--type", help="Number type: tollfree or local."),
|
|
117
|
+
country: Optional[str] = typer.Option("US", "--country", help="Two-letter country code."),
|
|
118
|
+
friendly_name: Optional[str] = typer.Option(None, "--friendly-name", help="Human-readable label for this number."),
|
|
119
|
+
area_code: Optional[str] = typer.Option(None, "--area-code", help="Preferred area code (local numbers only)."),
|
|
120
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Purchase/provision a phone number for SMS. POST /v1/phone-numbers."""
|
|
123
|
+
state: AppState = ctx.obj or AppState()
|
|
124
|
+
if not state.has_any_auth():
|
|
125
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
126
|
+
|
|
127
|
+
payload: dict = {"type": type, "country": country}
|
|
128
|
+
if phone_number:
|
|
129
|
+
payload["phone_number"] = phone_number
|
|
130
|
+
if friendly_name:
|
|
131
|
+
payload["friendly_name"] = friendly_name
|
|
132
|
+
if area_code:
|
|
133
|
+
payload["area_code"] = area_code
|
|
134
|
+
|
|
135
|
+
client = CommuneClient.from_state(state)
|
|
136
|
+
try:
|
|
137
|
+
r = client.post("/v1/phone-numbers", json=payload)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
140
|
+
|
|
141
|
+
if not r.is_success:
|
|
142
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
143
|
+
|
|
144
|
+
print_record(r.json(), json_output=json_output or state.should_json(), title="Provisioned Phone Number")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("release")
|
|
148
|
+
def phone_numbers_release(
|
|
149
|
+
ctx: typer.Context,
|
|
150
|
+
phone_number_id: str = typer.Argument(..., help="Phone number ID to release."),
|
|
151
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
152
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Release a provisioned phone number. DELETE /v1/phone-numbers/{id}."""
|
|
155
|
+
state: AppState = ctx.obj or AppState()
|
|
156
|
+
if not state.has_any_auth():
|
|
157
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
158
|
+
|
|
159
|
+
if not yes:
|
|
160
|
+
typer.confirm(
|
|
161
|
+
f"Are you sure you want to release phone number {phone_number_id}? This cannot be undone.",
|
|
162
|
+
abort=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
client = CommuneClient.from_state(state)
|
|
166
|
+
try:
|
|
167
|
+
r = client.delete(f"/v1/phone-numbers/{phone_number_id}")
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
170
|
+
|
|
171
|
+
if not r.is_success:
|
|
172
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
173
|
+
|
|
174
|
+
if json_output or state.should_json():
|
|
175
|
+
from ..output import print_json
|
|
176
|
+
print_json(r.json())
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
print_success(f"Phone number [bold]{phone_number_id}[/bold] released.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("settings")
|
|
183
|
+
def phone_numbers_settings(
|
|
184
|
+
ctx: typer.Context,
|
|
185
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Get SMS quota settings for your organization. GET /v1/phone-settings."""
|
|
188
|
+
state: AppState = ctx.obj or AppState()
|
|
189
|
+
if not state.has_any_auth():
|
|
190
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
191
|
+
|
|
192
|
+
client = CommuneClient.from_state(state)
|
|
193
|
+
try:
|
|
194
|
+
r = client.get("/v1/phone-settings")
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
197
|
+
|
|
198
|
+
if not r.is_success:
|
|
199
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
200
|
+
|
|
201
|
+
print_record(r.json(), json_output=json_output or state.should_json(), title="SMS Settings")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command("update")
|
|
205
|
+
def phone_numbers_update(
|
|
206
|
+
ctx: typer.Context,
|
|
207
|
+
phone_number_id: str = typer.Argument(..., help="Phone number ID to update."),
|
|
208
|
+
friendly_name: Optional[str] = typer.Option(None, "--friendly-name", help="Human-readable label for this number."),
|
|
209
|
+
auto_reply: Optional[str] = typer.Option(None, "--auto-reply", help="Auto-reply message body (empty string to disable)."),
|
|
210
|
+
auto_reply_enabled: Optional[bool] = typer.Option(None, "--auto-reply-enabled/--no-auto-reply-enabled", help="Enable or disable auto-reply."),
|
|
211
|
+
webhook_endpoint: Optional[str] = typer.Option(None, "--webhook-endpoint", help="HTTPS URL to receive SMS event webhooks."),
|
|
212
|
+
webhook_secret: Optional[str] = typer.Option(None, "--webhook-secret", help="Webhook signing secret."),
|
|
213
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Update a phone number's settings. PATCH /v1/phone-numbers/{id}."""
|
|
216
|
+
state: AppState = ctx.obj or AppState()
|
|
217
|
+
if not state.has_any_auth():
|
|
218
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
219
|
+
|
|
220
|
+
payload: dict = {}
|
|
221
|
+
if friendly_name is not None:
|
|
222
|
+
payload["friendly_name"] = friendly_name
|
|
223
|
+
if auto_reply is not None or auto_reply_enabled is not None:
|
|
224
|
+
auto_reply_obj: dict = {}
|
|
225
|
+
if auto_reply_enabled is not None:
|
|
226
|
+
auto_reply_obj["enabled"] = auto_reply_enabled
|
|
227
|
+
if auto_reply is not None:
|
|
228
|
+
auto_reply_obj["body"] = auto_reply
|
|
229
|
+
payload["auto_reply"] = auto_reply_obj
|
|
230
|
+
if webhook_endpoint is not None:
|
|
231
|
+
webhook: dict = {"endpoint": webhook_endpoint}
|
|
232
|
+
if webhook_secret is not None:
|
|
233
|
+
webhook["secret"] = webhook_secret
|
|
234
|
+
payload["webhook"] = webhook
|
|
235
|
+
|
|
236
|
+
if not payload:
|
|
237
|
+
from ..errors import validation_error
|
|
238
|
+
validation_error("No fields to update. Provide at least one option.", json_output=json_output or state.should_json())
|
|
239
|
+
|
|
240
|
+
client = CommuneClient.from_state(state)
|
|
241
|
+
try:
|
|
242
|
+
r = client.patch(f"/v1/phone-numbers/{phone_number_id}", json=payload)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
245
|
+
|
|
246
|
+
if not r.is_success:
|
|
247
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
248
|
+
|
|
249
|
+
print_record(r.json(), json_output=json_output or state.should_json(), title="Updated Phone Number")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command("set-allow-list")
|
|
253
|
+
def phone_numbers_set_allow_list(
|
|
254
|
+
ctx: typer.Context,
|
|
255
|
+
phone_number_id: str = typer.Argument(..., help="Phone number ID."),
|
|
256
|
+
numbers: list[str] = typer.Option(..., "--number", help="E.164 number to allow (repeat for multiple, e.g. --number +15551234567 --number +15559876543). Pass no --number flags to clear."),
|
|
257
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Set allow list for a phone number (only listed numbers can message it). PUT /v1/phone-numbers/{id}/allow-list."""
|
|
260
|
+
state: AppState = ctx.obj or AppState()
|
|
261
|
+
if not state.has_any_auth():
|
|
262
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
263
|
+
|
|
264
|
+
client = CommuneClient.from_state(state)
|
|
265
|
+
try:
|
|
266
|
+
r = client.put(f"/v1/phone-numbers/{phone_number_id}/allow-list", json={"numbers": numbers})
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
269
|
+
|
|
270
|
+
if not r.is_success:
|
|
271
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
272
|
+
|
|
273
|
+
print_record(r.json(), json_output=json_output or state.should_json(), title="Updated Phone Number")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command("set-block-list")
|
|
277
|
+
def phone_numbers_set_block_list(
|
|
278
|
+
ctx: typer.Context,
|
|
279
|
+
phone_number_id: str = typer.Argument(..., help="Phone number ID."),
|
|
280
|
+
numbers: list[str] = typer.Option(..., "--number", help="E.164 number to block (repeat for multiple). Pass no --number flags to clear."),
|
|
281
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Set block list for a phone number (listed numbers are rejected). PUT /v1/phone-numbers/{id}/block-list."""
|
|
284
|
+
state: AppState = ctx.obj or AppState()
|
|
285
|
+
if not state.has_any_auth():
|
|
286
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
287
|
+
|
|
288
|
+
client = CommuneClient.from_state(state)
|
|
289
|
+
try:
|
|
290
|
+
r = client.put(f"/v1/phone-numbers/{phone_number_id}/block-list", json={"numbers": numbers})
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
293
|
+
|
|
294
|
+
if not r.is_success:
|
|
295
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
296
|
+
|
|
297
|
+
print_record(r.json(), json_output=json_output or state.should_json(), title="Updated Phone Number")
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""commune sms — send SMS and manage conversations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..client import CommuneClient
|
|
11
|
+
from ..errors import api_error, auth_required_error, network_error, validation_error
|
|
12
|
+
from ..output import print_json, print_list, print_record, print_success
|
|
13
|
+
from ..state import AppState
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Send SMS and manage conversations.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("send")
|
|
19
|
+
def sms_send(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
to: str = typer.Option(..., "--to", help="Recipient phone number in E.164 format (e.g. +1234567890)."),
|
|
22
|
+
body: str = typer.Option(..., "--body", help="SMS message body."),
|
|
23
|
+
phone_number_id: Optional[str] = typer.Option(
|
|
24
|
+
None,
|
|
25
|
+
"--phone-number-id",
|
|
26
|
+
help="Phone number ID to send from. Uses org default if omitted.",
|
|
27
|
+
),
|
|
28
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Send an SMS message. POST /v1/sms/send."""
|
|
31
|
+
state: AppState = ctx.obj or AppState()
|
|
32
|
+
if not state.has_any_auth():
|
|
33
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
34
|
+
|
|
35
|
+
payload: dict = {"to": to, "body": body}
|
|
36
|
+
if phone_number_id:
|
|
37
|
+
payload["phone_number_id"] = phone_number_id
|
|
38
|
+
|
|
39
|
+
client = CommuneClient.from_state(state)
|
|
40
|
+
try:
|
|
41
|
+
r = client.post("/v1/sms/send", json=payload)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
44
|
+
|
|
45
|
+
if not r.is_success:
|
|
46
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
47
|
+
|
|
48
|
+
data = r.json()
|
|
49
|
+
if json_output or state.should_json():
|
|
50
|
+
print_json(data)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
msg_id = data.get("id") or data.get("messageId", "")
|
|
54
|
+
print_success(f"SMS sent. ID: [bold]{msg_id}[/bold]")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("conversations")
|
|
58
|
+
def sms_conversations(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
phone_number_id: Optional[str] = typer.Option(
|
|
61
|
+
None,
|
|
62
|
+
"--phone-number-id",
|
|
63
|
+
help="Filter by phone number ID.",
|
|
64
|
+
),
|
|
65
|
+
limit: Optional[int] = typer.Option(20, "--limit", help="Maximum results to return."),
|
|
66
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""List SMS conversations. GET /v1/sms/conversations."""
|
|
69
|
+
state: AppState = ctx.obj or AppState()
|
|
70
|
+
if not state.has_any_auth():
|
|
71
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
72
|
+
|
|
73
|
+
client = CommuneClient.from_state(state)
|
|
74
|
+
try:
|
|
75
|
+
r = client.get("/v1/sms/conversations", params={
|
|
76
|
+
"phone_number_id": phone_number_id,
|
|
77
|
+
"limit": limit,
|
|
78
|
+
})
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
81
|
+
|
|
82
|
+
if not r.is_success:
|
|
83
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
84
|
+
|
|
85
|
+
print_list(
|
|
86
|
+
r.json(),
|
|
87
|
+
json_output=json_output or state.should_json(),
|
|
88
|
+
title="SMS Conversations",
|
|
89
|
+
columns=[
|
|
90
|
+
("Remote Number", "remoteNumber"),
|
|
91
|
+
("Last Message", "lastMessage"),
|
|
92
|
+
("Direction", "lastDirection"),
|
|
93
|
+
("Updated", "updatedAt"),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("thread")
|
|
99
|
+
def sms_thread(
|
|
100
|
+
ctx: typer.Context,
|
|
101
|
+
remote_number: str = typer.Argument(..., help="Remote phone number (E.164, e.g. +1234567890)."),
|
|
102
|
+
phone_number_id: str = typer.Option(
|
|
103
|
+
...,
|
|
104
|
+
"--phone-number-id",
|
|
105
|
+
help="Phone number ID for the conversation.",
|
|
106
|
+
),
|
|
107
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Get the message thread with a specific number. GET /v1/sms/conversations/{remoteNumber}."""
|
|
110
|
+
state: AppState = ctx.obj or AppState()
|
|
111
|
+
if not state.has_any_auth():
|
|
112
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
113
|
+
|
|
114
|
+
encoded = quote(remote_number, safe="")
|
|
115
|
+
client = CommuneClient.from_state(state)
|
|
116
|
+
try:
|
|
117
|
+
r = client.get(
|
|
118
|
+
f"/v1/sms/conversations/{encoded}",
|
|
119
|
+
params={"phone_number_id": phone_number_id},
|
|
120
|
+
)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
123
|
+
|
|
124
|
+
if not r.is_success:
|
|
125
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
126
|
+
|
|
127
|
+
data = r.json()
|
|
128
|
+
if json_output or state.should_json():
|
|
129
|
+
print_json(data)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
messages = data if isinstance(data, list) else data.get("data", data)
|
|
133
|
+
print_list(
|
|
134
|
+
messages,
|
|
135
|
+
json_output=False,
|
|
136
|
+
title=f"Thread with {remote_number}",
|
|
137
|
+
columns=[
|
|
138
|
+
("ID", "id"),
|
|
139
|
+
("Direction", "direction"),
|
|
140
|
+
("Body", "body"),
|
|
141
|
+
("Sent At", "createdAt"),
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command("search")
|
|
147
|
+
def sms_search(
|
|
148
|
+
ctx: typer.Context,
|
|
149
|
+
query: str = typer.Argument(..., help="Search query."),
|
|
150
|
+
phone_number_id: Optional[str] = typer.Option(
|
|
151
|
+
None,
|
|
152
|
+
"--phone-number-id",
|
|
153
|
+
help="Scope search to a specific phone number.",
|
|
154
|
+
),
|
|
155
|
+
limit: Optional[int] = typer.Option(20, "--limit", help="Maximum results to return."),
|
|
156
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Search SMS messages. GET /v1/sms/search."""
|
|
159
|
+
state: AppState = ctx.obj or AppState()
|
|
160
|
+
if not state.has_any_auth():
|
|
161
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
162
|
+
|
|
163
|
+
client = CommuneClient.from_state(state)
|
|
164
|
+
try:
|
|
165
|
+
r = client.get("/v1/sms/search", params={
|
|
166
|
+
"q": query,
|
|
167
|
+
"phone_number_id": phone_number_id,
|
|
168
|
+
"limit": limit,
|
|
169
|
+
})
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
172
|
+
|
|
173
|
+
if not r.is_success:
|
|
174
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
175
|
+
|
|
176
|
+
print_list(
|
|
177
|
+
r.json(),
|
|
178
|
+
json_output=json_output or state.should_json(),
|
|
179
|
+
title=f"SMS Search: {query}",
|
|
180
|
+
columns=[
|
|
181
|
+
("ID", "id"),
|
|
182
|
+
("From", "from"),
|
|
183
|
+
("To", "to"),
|
|
184
|
+
("Body", "body"),
|
|
185
|
+
("Date", "createdAt"),
|
|
186
|
+
],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command("suppressions")
|
|
191
|
+
def sms_suppressions(
|
|
192
|
+
ctx: typer.Context,
|
|
193
|
+
phone_number_id: Optional[str] = typer.Option(
|
|
194
|
+
None,
|
|
195
|
+
"--phone-number-id",
|
|
196
|
+
help="Filter by phone number ID.",
|
|
197
|
+
),
|
|
198
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""List SMS suppressed numbers (opted out via STOP). GET /v1/sms/suppressions."""
|
|
201
|
+
state: AppState = ctx.obj or AppState()
|
|
202
|
+
if not state.has_any_auth():
|
|
203
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
204
|
+
|
|
205
|
+
client = CommuneClient.from_state(state)
|
|
206
|
+
try:
|
|
207
|
+
r = client.get("/v1/sms/suppressions", params={"phone_number_id": phone_number_id})
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
210
|
+
|
|
211
|
+
if not r.is_success:
|
|
212
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
213
|
+
|
|
214
|
+
print_list(
|
|
215
|
+
r.json(),
|
|
216
|
+
json_output=json_output or state.should_json(),
|
|
217
|
+
title="SMS Suppressions",
|
|
218
|
+
columns=[
|
|
219
|
+
("Phone Number", "phone_number"),
|
|
220
|
+
("Reason", "reason"),
|
|
221
|
+
("Created", "created_at"),
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command("remove-suppression")
|
|
227
|
+
def sms_remove_suppression(
|
|
228
|
+
ctx: typer.Context,
|
|
229
|
+
phone_number: str = typer.Argument(..., help="E.164 phone number to remove from suppression list (e.g. +15551234567)."),
|
|
230
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Remove a number from the SMS suppression list. DELETE /v1/sms/suppressions/{phoneNumber}."""
|
|
233
|
+
state: AppState = ctx.obj or AppState()
|
|
234
|
+
if not state.has_any_auth():
|
|
235
|
+
auth_required_error(json_output=json_output or state.should_json())
|
|
236
|
+
|
|
237
|
+
from urllib.parse import quote
|
|
238
|
+
encoded = quote(phone_number, safe="")
|
|
239
|
+
client = CommuneClient.from_state(state)
|
|
240
|
+
try:
|
|
241
|
+
r = client.delete(f"/v1/sms/suppressions/{encoded}")
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
network_error(exc, json_output=json_output or state.should_json())
|
|
244
|
+
|
|
245
|
+
if not r.is_success:
|
|
246
|
+
api_error(r, json_output=json_output or state.should_json())
|
|
247
|
+
|
|
248
|
+
if json_output or state.should_json():
|
|
249
|
+
from ..output import print_json
|
|
250
|
+
print_json(r.json())
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
print_success(f"[bold]{phone_number}[/bold] removed from suppression list.")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@app.command("listen")
|
|
257
|
+
def sms_listen(
|
|
258
|
+
ctx: typer.Context,
|
|
259
|
+
phone_number_id: Optional[str] = typer.Option(
|
|
260
|
+
None,
|
|
261
|
+
"--phone-number-id",
|
|
262
|
+
help="Listen for events on a specific phone number. Omit to listen on all.",
|
|
263
|
+
),
|
|
264
|
+
events: Optional[str] = typer.Option(
|
|
265
|
+
None,
|
|
266
|
+
"--events",
|
|
267
|
+
help="Comma-separated event types to filter: sms.received,sms.sent,sms.status_updated (default: all SMS events).",
|
|
268
|
+
),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Stream SMS events in real time (like az logs). Ctrl+C to stop."""
|
|
271
|
+
import sys
|
|
272
|
+
import time
|
|
273
|
+
from rich.console import Console
|
|
274
|
+
from rich.text import Text
|
|
275
|
+
|
|
276
|
+
state: AppState = ctx.obj or AppState()
|
|
277
|
+
if not state.has_any_auth():
|
|
278
|
+
auth_required_error(json_output=False)
|
|
279
|
+
|
|
280
|
+
console = Console()
|
|
281
|
+
|
|
282
|
+
base_url = state.base_url or "https://api.commune.email"
|
|
283
|
+
api_key = state.api_key or state.session_token or ""
|
|
284
|
+
|
|
285
|
+
params: list[tuple[str, str]] = []
|
|
286
|
+
if phone_number_id:
|
|
287
|
+
params.append(("phone_number_id", phone_number_id))
|
|
288
|
+
if events:
|
|
289
|
+
params.append(("events", events))
|
|
290
|
+
else:
|
|
291
|
+
params.append(("events", "sms.received,sms.sent,sms.status_updated"))
|
|
292
|
+
|
|
293
|
+
import httpx
|
|
294
|
+
from urllib.parse import urlencode
|
|
295
|
+
|
|
296
|
+
qs = urlencode(params)
|
|
297
|
+
url = f"{base_url.rstrip('/')}/v1/events/stream?{qs}"
|
|
298
|
+
|
|
299
|
+
label = f"phone number [bold]{phone_number_id}[/bold]" if phone_number_id else "all phone numbers"
|
|
300
|
+
console.print(f"\n[dim]Listening for SMS events on {label}... (Ctrl+C to stop)[/dim]\n")
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
with httpx.Client(timeout=None) as client:
|
|
304
|
+
with client.stream(
|
|
305
|
+
"GET",
|
|
306
|
+
url,
|
|
307
|
+
headers={"Authorization": f"Bearer {api_key}", "Accept": "text/event-stream"},
|
|
308
|
+
) as response:
|
|
309
|
+
if response.status_code != 200:
|
|
310
|
+
console.print(f"[red]Error {response.status_code}:[/red] {response.text}")
|
|
311
|
+
raise SystemExit(1)
|
|
312
|
+
|
|
313
|
+
event_type = None
|
|
314
|
+
for line in response.iter_lines():
|
|
315
|
+
if line.startswith("event:"):
|
|
316
|
+
event_type = line[6:].strip()
|
|
317
|
+
elif line.startswith("data:"):
|
|
318
|
+
raw = line[5:].strip()
|
|
319
|
+
if raw == "[DONE]" or raw == "":
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
import json as _json
|
|
323
|
+
data = _json.loads(raw)
|
|
324
|
+
except Exception:
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
ts = data.get("created_at", time.strftime("%H:%M:%S"))
|
|
328
|
+
if len(ts) > 8:
|
|
329
|
+
ts = ts[11:19] if "T" in ts else ts[:8]
|
|
330
|
+
|
|
331
|
+
ev = event_type or data.get("type", "event")
|
|
332
|
+
from_num = data.get("from_number", data.get("from", ""))
|
|
333
|
+
to_num = data.get("to_number", data.get("to", ""))
|
|
334
|
+
body = data.get("content", data.get("body", ""))
|
|
335
|
+
status = data.get("delivery_status", "")
|
|
336
|
+
|
|
337
|
+
color = "green" if ev == "sms.received" else "blue" if ev == "sms.sent" else "yellow"
|
|
338
|
+
direction = f"[dim]{from_num}[/dim] → [dim]{to_num}[/dim]" if from_num and to_num else ""
|
|
339
|
+
status_str = f" · [dim]{status}[/dim]" if status else ""
|
|
340
|
+
|
|
341
|
+
console.print(
|
|
342
|
+
f"[[dim]{ts}[/dim]] [{color}]{ev:<20}[/{color}] {direction}{status_str}"
|
|
343
|
+
)
|
|
344
|
+
if body:
|
|
345
|
+
preview = body[:100] + ("…" if len(body) > 100 else "")
|
|
346
|
+
console.print(f" [dim]\"{preview}\"[/dim]")
|
|
347
|
+
elif line == "":
|
|
348
|
+
event_type = None
|
|
349
|
+
|
|
350
|
+
except KeyboardInterrupt:
|
|
351
|
+
console.print("\n[dim]Stopped.[/dim]")
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
console.print(f"[red]Connection error:[/red] {exc}")
|
|
354
|
+
raise SystemExit(1)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "commune-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "Official CLI for the Commune email API — agent-native, pipe-friendly, covers every API surface."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"""commune phone-numbers — phone number management."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Optional
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
from ..client import CommuneClient
|
|
10
|
-
from ..errors import api_error, auth_required_error, network_error
|
|
11
|
-
from ..output import print_list, print_record
|
|
12
|
-
from ..state import AppState
|
|
13
|
-
|
|
14
|
-
app = typer.Typer(help="Phone number management: list, get, settings.", no_args_is_help=True)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@app.command("list")
|
|
18
|
-
def phone_numbers_list(
|
|
19
|
-
ctx: typer.Context,
|
|
20
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
21
|
-
) -> None:
|
|
22
|
-
"""List all phone numbers in your organization. GET /v1/phone-numbers."""
|
|
23
|
-
state: AppState = ctx.obj or AppState()
|
|
24
|
-
if not state.has_any_auth():
|
|
25
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
26
|
-
|
|
27
|
-
client = CommuneClient.from_state(state)
|
|
28
|
-
try:
|
|
29
|
-
r = client.get("/v1/phone-numbers")
|
|
30
|
-
except Exception as exc:
|
|
31
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
32
|
-
|
|
33
|
-
if not r.is_success:
|
|
34
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
35
|
-
|
|
36
|
-
print_list(
|
|
37
|
-
r.json(),
|
|
38
|
-
json_output=json_output or state.should_json(),
|
|
39
|
-
title="Phone Numbers",
|
|
40
|
-
columns=[
|
|
41
|
-
("ID", "id"),
|
|
42
|
-
("Number", "phoneNumber"),
|
|
43
|
-
("Friendly Name", "friendlyName"),
|
|
44
|
-
("Status", "status"),
|
|
45
|
-
("Created", "createdAt"),
|
|
46
|
-
],
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@app.command("get")
|
|
51
|
-
def phone_numbers_get(
|
|
52
|
-
ctx: typer.Context,
|
|
53
|
-
phone_number_id: str = typer.Argument(..., help="Phone number ID."),
|
|
54
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
55
|
-
) -> None:
|
|
56
|
-
"""Get a specific phone number. GET /v1/phone-numbers/{phoneNumberId}."""
|
|
57
|
-
state: AppState = ctx.obj or AppState()
|
|
58
|
-
if not state.has_any_auth():
|
|
59
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
60
|
-
|
|
61
|
-
client = CommuneClient.from_state(state)
|
|
62
|
-
try:
|
|
63
|
-
r = client.get(f"/v1/phone-numbers/{phone_number_id}")
|
|
64
|
-
except Exception as exc:
|
|
65
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
66
|
-
|
|
67
|
-
if not r.is_success:
|
|
68
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
69
|
-
|
|
70
|
-
print_record(r.json(), json_output=json_output or state.should_json(), title="Phone Number")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@app.command("settings")
|
|
74
|
-
def phone_numbers_settings(
|
|
75
|
-
ctx: typer.Context,
|
|
76
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
77
|
-
) -> None:
|
|
78
|
-
"""Get SMS quota settings for your organization. GET /v1/phone-settings."""
|
|
79
|
-
state: AppState = ctx.obj or AppState()
|
|
80
|
-
if not state.has_any_auth():
|
|
81
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
82
|
-
|
|
83
|
-
client = CommuneClient.from_state(state)
|
|
84
|
-
try:
|
|
85
|
-
r = client.get("/v1/phone-settings")
|
|
86
|
-
except Exception as exc:
|
|
87
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
88
|
-
|
|
89
|
-
if not r.is_success:
|
|
90
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
91
|
-
|
|
92
|
-
print_record(r.json(), json_output=json_output or state.should_json(), title="SMS Settings")
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
"""commune sms — send SMS and manage conversations."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Optional
|
|
6
|
-
from urllib.parse import quote
|
|
7
|
-
|
|
8
|
-
import typer
|
|
9
|
-
|
|
10
|
-
from ..client import CommuneClient
|
|
11
|
-
from ..errors import api_error, auth_required_error, network_error, validation_error
|
|
12
|
-
from ..output import print_json, print_list, print_record, print_success
|
|
13
|
-
from ..state import AppState
|
|
14
|
-
|
|
15
|
-
app = typer.Typer(help="Send SMS and manage conversations.", no_args_is_help=True)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@app.command("send")
|
|
19
|
-
def sms_send(
|
|
20
|
-
ctx: typer.Context,
|
|
21
|
-
to: str = typer.Option(..., "--to", help="Recipient phone number in E.164 format (e.g. +1234567890)."),
|
|
22
|
-
body: str = typer.Option(..., "--body", help="SMS message body."),
|
|
23
|
-
phone_number_id: Optional[str] = typer.Option(
|
|
24
|
-
None,
|
|
25
|
-
"--phone-number-id",
|
|
26
|
-
help="Phone number ID to send from. Uses org default if omitted.",
|
|
27
|
-
),
|
|
28
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
29
|
-
) -> None:
|
|
30
|
-
"""Send an SMS message. POST /v1/sms/send."""
|
|
31
|
-
state: AppState = ctx.obj or AppState()
|
|
32
|
-
if not state.has_any_auth():
|
|
33
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
34
|
-
|
|
35
|
-
payload: dict = {"to": to, "body": body}
|
|
36
|
-
if phone_number_id:
|
|
37
|
-
payload["phone_number_id"] = phone_number_id
|
|
38
|
-
|
|
39
|
-
client = CommuneClient.from_state(state)
|
|
40
|
-
try:
|
|
41
|
-
r = client.post("/v1/sms/send", json=payload)
|
|
42
|
-
except Exception as exc:
|
|
43
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
44
|
-
|
|
45
|
-
if not r.is_success:
|
|
46
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
47
|
-
|
|
48
|
-
data = r.json()
|
|
49
|
-
if json_output or state.should_json():
|
|
50
|
-
print_json(data)
|
|
51
|
-
return
|
|
52
|
-
|
|
53
|
-
msg_id = data.get("id") or data.get("messageId", "")
|
|
54
|
-
print_success(f"SMS sent. ID: [bold]{msg_id}[/bold]")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@app.command("conversations")
|
|
58
|
-
def sms_conversations(
|
|
59
|
-
ctx: typer.Context,
|
|
60
|
-
phone_number_id: Optional[str] = typer.Option(
|
|
61
|
-
None,
|
|
62
|
-
"--phone-number-id",
|
|
63
|
-
help="Filter by phone number ID.",
|
|
64
|
-
),
|
|
65
|
-
limit: Optional[int] = typer.Option(20, "--limit", help="Maximum results to return."),
|
|
66
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
67
|
-
) -> None:
|
|
68
|
-
"""List SMS conversations. GET /v1/sms/conversations."""
|
|
69
|
-
state: AppState = ctx.obj or AppState()
|
|
70
|
-
if not state.has_any_auth():
|
|
71
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
72
|
-
|
|
73
|
-
client = CommuneClient.from_state(state)
|
|
74
|
-
try:
|
|
75
|
-
r = client.get("/v1/sms/conversations", params={
|
|
76
|
-
"phone_number_id": phone_number_id,
|
|
77
|
-
"limit": limit,
|
|
78
|
-
})
|
|
79
|
-
except Exception as exc:
|
|
80
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
81
|
-
|
|
82
|
-
if not r.is_success:
|
|
83
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
84
|
-
|
|
85
|
-
print_list(
|
|
86
|
-
r.json(),
|
|
87
|
-
json_output=json_output or state.should_json(),
|
|
88
|
-
title="SMS Conversations",
|
|
89
|
-
columns=[
|
|
90
|
-
("Remote Number", "remoteNumber"),
|
|
91
|
-
("Last Message", "lastMessage"),
|
|
92
|
-
("Direction", "lastDirection"),
|
|
93
|
-
("Updated", "updatedAt"),
|
|
94
|
-
],
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
@app.command("thread")
|
|
99
|
-
def sms_thread(
|
|
100
|
-
ctx: typer.Context,
|
|
101
|
-
remote_number: str = typer.Argument(..., help="Remote phone number (E.164, e.g. +1234567890)."),
|
|
102
|
-
phone_number_id: str = typer.Option(
|
|
103
|
-
...,
|
|
104
|
-
"--phone-number-id",
|
|
105
|
-
help="Phone number ID for the conversation.",
|
|
106
|
-
),
|
|
107
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
108
|
-
) -> None:
|
|
109
|
-
"""Get the message thread with a specific number. GET /v1/sms/conversations/{remoteNumber}."""
|
|
110
|
-
state: AppState = ctx.obj or AppState()
|
|
111
|
-
if not state.has_any_auth():
|
|
112
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
113
|
-
|
|
114
|
-
encoded = quote(remote_number, safe="")
|
|
115
|
-
client = CommuneClient.from_state(state)
|
|
116
|
-
try:
|
|
117
|
-
r = client.get(
|
|
118
|
-
f"/v1/sms/conversations/{encoded}",
|
|
119
|
-
params={"phone_number_id": phone_number_id},
|
|
120
|
-
)
|
|
121
|
-
except Exception as exc:
|
|
122
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
123
|
-
|
|
124
|
-
if not r.is_success:
|
|
125
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
126
|
-
|
|
127
|
-
data = r.json()
|
|
128
|
-
if json_output or state.should_json():
|
|
129
|
-
print_json(data)
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
messages = data if isinstance(data, list) else data.get("data", data)
|
|
133
|
-
print_list(
|
|
134
|
-
messages,
|
|
135
|
-
json_output=False,
|
|
136
|
-
title=f"Thread with {remote_number}",
|
|
137
|
-
columns=[
|
|
138
|
-
("ID", "id"),
|
|
139
|
-
("Direction", "direction"),
|
|
140
|
-
("Body", "body"),
|
|
141
|
-
("Sent At", "createdAt"),
|
|
142
|
-
],
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
@app.command("search")
|
|
147
|
-
def sms_search(
|
|
148
|
-
ctx: typer.Context,
|
|
149
|
-
query: str = typer.Argument(..., help="Search query."),
|
|
150
|
-
phone_number_id: Optional[str] = typer.Option(
|
|
151
|
-
None,
|
|
152
|
-
"--phone-number-id",
|
|
153
|
-
help="Scope search to a specific phone number.",
|
|
154
|
-
),
|
|
155
|
-
limit: Optional[int] = typer.Option(20, "--limit", help="Maximum results to return."),
|
|
156
|
-
json_output: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
157
|
-
) -> None:
|
|
158
|
-
"""Search SMS messages. GET /v1/sms/search."""
|
|
159
|
-
state: AppState = ctx.obj or AppState()
|
|
160
|
-
if not state.has_any_auth():
|
|
161
|
-
auth_required_error(json_output=json_output or state.should_json())
|
|
162
|
-
|
|
163
|
-
client = CommuneClient.from_state(state)
|
|
164
|
-
try:
|
|
165
|
-
r = client.get("/v1/sms/search", params={
|
|
166
|
-
"q": query,
|
|
167
|
-
"phone_number_id": phone_number_id,
|
|
168
|
-
"limit": limit,
|
|
169
|
-
})
|
|
170
|
-
except Exception as exc:
|
|
171
|
-
network_error(exc, json_output=json_output or state.should_json())
|
|
172
|
-
|
|
173
|
-
if not r.is_success:
|
|
174
|
-
api_error(r, json_output=json_output or state.should_json())
|
|
175
|
-
|
|
176
|
-
print_list(
|
|
177
|
-
r.json(),
|
|
178
|
-
json_output=json_output or state.should_json(),
|
|
179
|
-
title=f"SMS Search: {query}",
|
|
180
|
-
columns=[
|
|
181
|
-
("ID", "id"),
|
|
182
|
-
("From", "from"),
|
|
183
|
-
("To", "to"),
|
|
184
|
-
("Body", "body"),
|
|
185
|
-
("Date", "createdAt"),
|
|
186
|
-
],
|
|
187
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|