hound-cli 0.1.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.
@@ -0,0 +1,212 @@
1
+ """Coordinate read-only access to account-local email and SMS inboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ import click
9
+ import questionary
10
+ import urllib3
11
+ from botocore.exceptions import BotoCoreError, ClientError
12
+
13
+ from hound_cli.aws import _aws_account_id, aws_client, operator_only
14
+ from hound_cli.inbox.email import (
15
+ EmailSummary,
16
+ ParsedEmail,
17
+ email_bucket_name,
18
+ list_email_messages,
19
+ read_email_message,
20
+ validate_agent_recipient,
21
+ )
22
+ from hound_cli.inbox.sms import SmsMessage, list_sms_messages, load_twilio_credentials, read_sms_message
23
+ from hound_cli.roster import load_roster, validate_phone_number
24
+ from hound_cli.ui import MENU_STYLE
25
+
26
+ DEFAULT_LOOKBACK = "24h"
27
+ _DURATION = re.compile(r"^(?P<amount>[1-9][0-9]*)(?P<unit>[mhd])$")
28
+
29
+
30
+ def parse_lookback(value: str) -> timedelta:
31
+ """Parse a compact positive duration, capped by the seven-day inbox window."""
32
+ match = _DURATION.fullmatch(value.strip().lower())
33
+ if match is None:
34
+ raise click.BadParameter("use a duration such as 30m, 2h, or 7d", param_hint="--since")
35
+ amount = int(match.group("amount"))
36
+ unit = match.group("unit")
37
+ duration = timedelta(**{{"m": "minutes", "h": "hours", "d": "days"}[unit]: amount})
38
+ if duration > timedelta(days=7):
39
+ raise click.BadParameter("cannot exceed the seven-day inbox window", param_hint="--since")
40
+ return duration
41
+
42
+
43
+ def _safe_terminal(value: str) -> str:
44
+ """Remove terminal control characters from untrusted message text."""
45
+ return "".join(character for character in value if character in "\n\t" or character.isprintable())
46
+
47
+
48
+ def _select_agent(roster: dict, requested_agent: str | None) -> tuple[str, dict]:
49
+ if requested_agent is not None:
50
+ if requested_agent not in roster:
51
+ available = ", ".join(sorted(roster))
52
+ raise click.ClickException(
53
+ f"Agent {requested_agent!r} is not in the roster. Available agents: {available}."
54
+ )
55
+ return requested_agent, roster[requested_agent]
56
+
57
+ choices = [
58
+ questionary.Choice(
59
+ title=" — ".join(value for value in (agent_id, agent["email"], agent.get("phone_number")) if value),
60
+ value=agent_id,
61
+ )
62
+ for agent_id, agent in sorted(roster.items())
63
+ ]
64
+ agent_id = questionary.select("Agent inbox", choices=choices, style=MENU_STYLE).unsafe_ask()
65
+ if agent_id is None:
66
+ raise click.Abort()
67
+ return agent_id, roster[agent_id]
68
+
69
+
70
+ def _select_channel(agent_id: str, agent: dict, requested_channel: str | None) -> str:
71
+ channels = ["email"]
72
+ if agent.get("phone_number"):
73
+ channels.append("sms")
74
+ if requested_channel is not None:
75
+ if requested_channel not in channels:
76
+ raise click.ClickException(f"Agent {agent_id!r} does not have {requested_channel} configured.")
77
+ return requested_channel
78
+ if len(channels) == 1:
79
+ return channels[0]
80
+ selected = questionary.select("Inbox channel", choices=channels, style=MENU_STYLE).unsafe_ask()
81
+ if selected is None:
82
+ raise click.Abort()
83
+ return selected
84
+
85
+
86
+ def _select_email(messages: list[EmailSummary]) -> EmailSummary:
87
+ choices = [
88
+ questionary.Choice(
89
+ title=(
90
+ f"{message.received_at.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')} — "
91
+ f"{_safe_terminal(message.sender) or '(unknown sender)'} — "
92
+ f"{_safe_terminal(message.subject) or '(no subject)'} — {message.email_id[-8:]}"
93
+ ),
94
+ value=message,
95
+ )
96
+ for message in messages
97
+ ]
98
+ message = questionary.select("Message", choices=choices, style=MENU_STYLE).unsafe_ask()
99
+ if message is None:
100
+ raise click.Abort()
101
+ return message
102
+
103
+
104
+ def _select_sms(messages: list[SmsMessage]) -> SmsMessage:
105
+ choices = [
106
+ questionary.Choice(
107
+ title=(
108
+ f"{message.received_at.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')} — "
109
+ f"{_safe_terminal(message.sender) or '(unknown sender)'} — {message.sid[-8:]}"
110
+ ),
111
+ value=message,
112
+ )
113
+ for message in messages
114
+ ]
115
+ message = questionary.select("Message", choices=choices, style=MENU_STYLE).unsafe_ask()
116
+ if message is None:
117
+ raise click.Abort()
118
+ return message
119
+
120
+
121
+ def _display_email(agent_id: str, recipient: str, summary: EmailSummary, email: ParsedEmail) -> None:
122
+ click.echo(f"\nAgent: {agent_id}")
123
+ click.echo("Channel: email")
124
+ click.echo(f"Inbox: {recipient}")
125
+ click.echo(f"Received: {summary.received_at.astimezone().isoformat()}")
126
+ click.echo(f"From: {_safe_terminal(email.sender) or '—'}")
127
+ click.echo(f"To: {_safe_terminal(', '.join(email.recipients)) or '—'}")
128
+ click.echo(f"Sent: {_safe_terminal(email.sent_date) or '—'}")
129
+ click.echo(f"Subject: {_safe_terminal(email.subject) or '—'}")
130
+ click.echo(f"Message-ID: {_safe_terminal(email.message_id) or '—'}")
131
+ click.echo("\nBody:\n")
132
+ click.echo(_safe_terminal(email.body) or "(No readable text body.)")
133
+ if email.links:
134
+ click.echo("\nLinks:")
135
+ for link in email.links:
136
+ click.echo(f" {_safe_terminal(link)}")
137
+
138
+
139
+ def _display_sms(agent_id: str, message: SmsMessage) -> None:
140
+ click.echo(f"\nAgent: {agent_id}")
141
+ click.echo("Channel: sms")
142
+ click.echo(f"Inbox: {_safe_terminal(message.recipient)}")
143
+ click.echo(f"Received: {message.received_at.astimezone().isoformat()}")
144
+ click.echo(f"From: {_safe_terminal(message.sender) or '—'}")
145
+ click.echo(f"To: {_safe_terminal(message.recipient)}")
146
+ click.echo(f"Status: {_safe_terminal(message.status) or '—'}")
147
+ click.echo(f"Message SID: {_safe_terminal(message.sid)}")
148
+ click.echo("\nBody:\n")
149
+ click.echo(_safe_terminal(message.body) or "(Empty message body.)")
150
+
151
+
152
+ def _show_email(agent_id: str, agent: dict, cutoff: datetime, since: str) -> None:
153
+ recipient = validate_agent_recipient(agent_id, agent["email"])
154
+ bucket = email_bucket_name(_aws_account_id())
155
+ s3 = aws_client("s3")
156
+ try:
157
+ messages = list_email_messages(s3, bucket=bucket, agent_id=agent_id, cutoff=cutoff)
158
+ if not messages:
159
+ raise click.ClickException(
160
+ f"No email messages for {recipient} in the last {since}. Send a message or increase --since."
161
+ )
162
+ summary = _select_email(messages)
163
+ email = read_email_message(
164
+ s3,
165
+ bucket=bucket,
166
+ agent_id=agent_id,
167
+ email_id=summary.email_id,
168
+ cutoff=cutoff,
169
+ )
170
+ except (BotoCoreError, ClientError, KeyError) as exc:
171
+ raise click.ClickException("Could not read the agent email inbox for this account.") from exc
172
+ _display_email(agent_id, recipient, summary, email)
173
+
174
+
175
+ def _show_sms(agent_id: str, agent: dict, secrets, cutoff: datetime, since: str) -> None:
176
+ phone_number = validate_phone_number(agent["phone_number"])
177
+ try:
178
+ credentials = load_twilio_credentials(secrets)
179
+ except (BotoCoreError, ClientError, KeyError) as exc:
180
+ raise click.ClickException("Could not load the Twilio inbox credential for this account.") from exc
181
+ http = urllib3.PoolManager()
182
+ messages = list_sms_messages(http, credentials, recipient=phone_number, cutoff=cutoff)
183
+ if not messages:
184
+ raise click.ClickException(
185
+ f"No SMS messages for {phone_number} in the last {since}. Send a message or increase --since."
186
+ )
187
+ selected = _select_sms(messages)
188
+ message = read_sms_message(
189
+ http,
190
+ credentials,
191
+ recipient=phone_number,
192
+ message_sid=selected.sid,
193
+ cutoff=cutoff,
194
+ )
195
+ _display_sms(agent_id, message)
196
+
197
+
198
+ @click.command("inbox")
199
+ @click.option("--agent", "agent_id", help="Stable agent ID from hound/agents.")
200
+ @click.option("--channel", type=click.Choice(("email", "sms"), case_sensitive=False), help="Inbox channel.")
201
+ @click.option("--since", default=DEFAULT_LOOKBACK, show_default=True, help="Recent window: 30m, 2h, or up to 7d.")
202
+ @operator_only
203
+ def inbox(agent_id: str | None, channel: str | None, since: str) -> None:
204
+ """Read recent agent email or SMS for this account."""
205
+ cutoff = datetime.now(UTC) - parse_lookback(since)
206
+ secrets = aws_client("secretsmanager")
207
+ selected_agent, agent = _select_agent(load_roster(secrets), agent_id)
208
+ selected_channel = _select_channel(selected_agent, agent, channel)
209
+ if selected_channel == "email":
210
+ _show_email(selected_agent, agent, cutoff, since)
211
+ else:
212
+ _show_sms(selected_agent, agent, secrets, cutoff, since)
@@ -0,0 +1,210 @@
1
+ """Read account-local agent email messages from SES-backed S3 storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from email.message import Message
9
+ from email.parser import BytesParser
10
+ from email.policy import default
11
+ from email.utils import getaddresses
12
+ from html.parser import HTMLParser
13
+
14
+ import click
15
+
16
+ from hound_cli.roster import validate_agent_id
17
+
18
+ EMAIL_MESSAGE_LIMIT = 20
19
+ _URL = re.compile(r"https?://[^\s<>\"']+")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class EmailSummary:
24
+ """Email metadata shown while selecting a stored message."""
25
+
26
+ email_id: str
27
+ received_at: datetime
28
+ subject: str
29
+ sender: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ParsedEmail:
34
+ """Decoded operator-facing email content."""
35
+
36
+ subject: str
37
+ sender: str
38
+ recipients: tuple[str, ...]
39
+ sent_date: str
40
+ message_id: str
41
+ body: str
42
+ links: tuple[str, ...]
43
+
44
+
45
+ class _ReadableHtml(HTMLParser):
46
+ """Extract readable text and links without rendering untrusted HTML."""
47
+
48
+ _BLOCK_TAGS = frozenset({"br", "div", "li", "p", "table", "tr"})
49
+
50
+ def __init__(self) -> None:
51
+ super().__init__(convert_charrefs=True)
52
+ self._ignored_depth = 0
53
+ self.text: list[str] = []
54
+ self.links: list[str] = []
55
+
56
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
57
+ tag = tag.lower()
58
+ if tag in {"script", "style"}:
59
+ self._ignored_depth += 1
60
+ return
61
+ if self._ignored_depth:
62
+ return
63
+ if tag in self._BLOCK_TAGS:
64
+ self.text.append("\n")
65
+ if tag == "a":
66
+ href = dict(attrs).get("href")
67
+ if href and href.startswith(("http://", "https://")):
68
+ self.links.append(href)
69
+
70
+ def handle_endtag(self, tag: str) -> None:
71
+ tag = tag.lower()
72
+ if tag in {"script", "style"} and self._ignored_depth:
73
+ self._ignored_depth -= 1
74
+ elif not self._ignored_depth and tag in self._BLOCK_TAGS:
75
+ self.text.append("\n")
76
+
77
+ def handle_data(self, data: str) -> None:
78
+ if not self._ignored_depth:
79
+ self.text.append(data)
80
+
81
+
82
+ def email_bucket_name(account_id: str) -> str:
83
+ """Return the deterministic account-local raw-email bucket name."""
84
+ if not re.fullmatch(r"[0-9]{12}", account_id):
85
+ raise ValueError("AWS account ID must contain exactly 12 digits.")
86
+ return f"hound-agent-email-{account_id}"
87
+
88
+
89
+ def _agent_prefix(agent_id: str) -> str:
90
+ return f"{validate_agent_id(agent_id)}/"
91
+
92
+
93
+ def validate_agent_recipient(agent_id: str, recipient: str) -> str:
94
+ """Require the roster recipient local part to match its stable agent ID."""
95
+ validate_agent_id(agent_id)
96
+ normalized = recipient.strip().lower()
97
+ expected_suffix = ".hound-agents.com"
98
+ local_part, separator, domain = normalized.partition("@")
99
+ if separator != "@" or local_part != agent_id or not domain.endswith(expected_suffix):
100
+ raise click.ClickException(
101
+ f"Agent {agent_id!r} must use {agent_id}@<domain-label>.hound-agents.com in hound/agents."
102
+ )
103
+ domain_label = domain[: -len(expected_suffix)]
104
+ try:
105
+ validate_agent_id(domain_label)
106
+ except click.ClickException as exc:
107
+ raise click.ClickException(f"Agent {agent_id!r} has an invalid Hound email domain in hound/agents.") from exc
108
+ return normalized
109
+
110
+
111
+ def _addresses(message: Message) -> tuple[str, ...]:
112
+ headers = [*message.get_all("to", []), *message.get_all("cc", [])]
113
+ return tuple(address for _name, address in getaddresses(headers) if address)
114
+
115
+
116
+ def _content(part: Message | None) -> str:
117
+ if part is None:
118
+ return ""
119
+ try:
120
+ content = part.get_content()
121
+ except LookupError, UnicodeDecodeError:
122
+ payload = part.get_payload(decode=True) or b""
123
+ return payload.decode("utf-8", errors="replace")
124
+ return content if isinstance(content, str) else content.decode("utf-8", errors="replace")
125
+
126
+
127
+ def parse_email(raw_message: bytes) -> ParsedEmail:
128
+ """Decode one raw RFC 5322 message without executing or rendering HTML."""
129
+ message = BytesParser(policy=default).parsebytes(raw_message)
130
+ plain_part = message.get_body(preferencelist=("plain",))
131
+ html_part = message.get_body(preferencelist=("html",))
132
+
133
+ body = _content(plain_part)
134
+ links: list[str] = []
135
+ if html_part is not None:
136
+ parser = _ReadableHtml()
137
+ parser.feed(_content(html_part))
138
+ links.extend(parser.links)
139
+ if not body:
140
+ body = "".join(parser.text)
141
+
142
+ body = re.sub(r"[ \t]+\n", "\n", body)
143
+ body = re.sub(r"\n{3,}", "\n\n", body).strip()
144
+ links.extend(_URL.findall(body))
145
+
146
+ return ParsedEmail(
147
+ subject=str(message.get("subject", "")),
148
+ sender=str(message.get("from", "")),
149
+ recipients=_addresses(message),
150
+ sent_date=str(message.get("date", "")),
151
+ message_id=str(message.get("message-id", "")),
152
+ body=body,
153
+ links=tuple(dict.fromkeys(link.rstrip(".,);]") for link in links)),
154
+ )
155
+
156
+
157
+ def list_email_messages(
158
+ s3,
159
+ *,
160
+ bucket: str,
161
+ agent_id: str,
162
+ cutoff: datetime,
163
+ limit: int = EMAIL_MESSAGE_LIMIT,
164
+ ) -> list[EmailSummary]:
165
+ """List recent messages beneath exactly one agent prefix."""
166
+ prefix = _agent_prefix(agent_id)
167
+ objects: list[dict] = []
168
+ continuation_token: str | None = None
169
+ while True:
170
+ request = {"Bucket": bucket, "Prefix": prefix}
171
+ if continuation_token:
172
+ request["ContinuationToken"] = continuation_token
173
+ response = s3.list_objects_v2(**request)
174
+ objects.extend(item for item in response.get("Contents", []) if item["LastModified"] >= cutoff)
175
+ if not response.get("IsTruncated"):
176
+ break
177
+ continuation_token = response.get("NextContinuationToken")
178
+ if not continuation_token:
179
+ raise click.ClickException("S3 returned a truncated inbox listing without a continuation token.")
180
+
181
+ objects.sort(key=lambda item: item["LastModified"], reverse=True)
182
+ messages: list[EmailSummary] = []
183
+ for item in objects:
184
+ object_key = item["Key"]
185
+ email_id = object_key[len(prefix) :] if object_key.startswith(prefix) else ""
186
+ if not email_id or "/" in email_id:
187
+ continue
188
+ parsed = parse_email(s3.get_object(Bucket=bucket, Key=object_key)["Body"].read())
189
+ messages.append(
190
+ EmailSummary(
191
+ email_id=email_id,
192
+ received_at=item["LastModified"],
193
+ subject=parsed.subject,
194
+ sender=parsed.sender,
195
+ )
196
+ )
197
+ if len(messages) == limit:
198
+ break
199
+ return messages
200
+
201
+
202
+ def read_email_message(s3, *, bucket: str, agent_id: str, email_id: str, cutoff: datetime) -> ParsedEmail:
203
+ """Read one recent message while re-enforcing its recipient prefix."""
204
+ if not email_id or "/" in email_id or email_id in {".", ".."}:
205
+ raise click.ClickException("Invalid inbox message ID.")
206
+ object_key = f"{_agent_prefix(agent_id)}{email_id}"
207
+ response = s3.get_object(Bucket=bucket, Key=object_key)
208
+ if response["LastModified"] < cutoff:
209
+ raise click.ClickException("That inbox message is older than the requested --since window.")
210
+ return parse_email(response["Body"].read())
hound_cli/inbox/sms.py ADDED
@@ -0,0 +1,190 @@
1
+ """Read account-local agent SMS messages from Twilio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from email.utils import parsedate_to_datetime
11
+
12
+ import click
13
+ import urllib3
14
+
15
+ from hound_cli.aws import get_secret
16
+ from hound_cli.roster import validate_phone_number
17
+
18
+ TWILIO_SECRET_ID = "hound/twilio"
19
+ MAX_MESSAGES = 20
20
+ _ACCOUNT_SID = re.compile(r"^AC[0-9a-fA-F]{32}$")
21
+ _API_KEY_SID = re.compile(r"^SK[0-9a-fA-F]{32}$")
22
+ _MESSAGE_SID = re.compile(r"^(?:SM|MM)[0-9a-fA-F]{32}$")
23
+ _TWILIO_FIELDS = frozenset({"TWILIO_ACCOUNT_SID", "TWILIO_API_KEY", "TWILIO_API_SECRET"})
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TwilioCredentials:
28
+ """Restricted credentials for one managed account's Twilio subaccount."""
29
+
30
+ account_sid: str
31
+ api_key_sid: str
32
+ api_secret: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SmsMessage:
37
+ """Operator-facing Twilio message."""
38
+
39
+ sid: str
40
+ received_at: datetime
41
+ sender: str
42
+ recipient: str
43
+ direction: str
44
+ status: str
45
+ body: str
46
+
47
+
48
+ def load_twilio_credentials(secrets_client) -> TwilioCredentials:
49
+ """Load and validate the account-local read-only Twilio credential."""
50
+ try:
51
+ secret = get_secret(secrets_client, TWILIO_SECRET_ID)
52
+ except (TypeError, json.JSONDecodeError) as exc:
53
+ raise click.ClickException(f"'{TWILIO_SECRET_ID}' must contain a JSON object.") from exc
54
+ if secret is None:
55
+ raise click.ClickException(f"Twilio credentials were not found at '{TWILIO_SECRET_ID}'.")
56
+ if not isinstance(secret, dict) or not _TWILIO_FIELDS.issubset(secret):
57
+ raise click.ClickException(f"'{TWILIO_SECRET_ID}' must contain: {', '.join(sorted(_TWILIO_FIELDS))}.")
58
+
59
+ values = {key: secret[key].strip() if isinstance(secret[key], str) else "" for key in _TWILIO_FIELDS}
60
+ if not _ACCOUNT_SID.fullmatch(values["TWILIO_ACCOUNT_SID"]):
61
+ raise click.ClickException(f"'{TWILIO_SECRET_ID}' contains an invalid TWILIO_ACCOUNT_SID.")
62
+ if not _API_KEY_SID.fullmatch(values["TWILIO_API_KEY"]):
63
+ raise click.ClickException(f"'{TWILIO_SECRET_ID}' contains an invalid TWILIO_API_KEY.")
64
+ if not values["TWILIO_API_SECRET"]:
65
+ raise click.ClickException(f"'{TWILIO_SECRET_ID}' contains an empty TWILIO_API_SECRET.")
66
+ return TwilioCredentials(
67
+ account_sid=values["TWILIO_ACCOUNT_SID"],
68
+ api_key_sid=values["TWILIO_API_KEY"],
69
+ api_secret=values["TWILIO_API_SECRET"],
70
+ )
71
+
72
+
73
+ def _authorization(credentials: TwilioCredentials) -> str:
74
+ value = f"{credentials.api_key_sid}:{credentials.api_secret}".encode()
75
+ return f"Basic {base64.b64encode(value).decode('ascii')}"
76
+
77
+
78
+ def _request_json(http, credentials: TwilioCredentials, url: str, *, fields: dict | None = None) -> dict:
79
+ try:
80
+ response = http.request(
81
+ "GET",
82
+ url,
83
+ fields=fields,
84
+ headers={"Accept": "application/json", "Authorization": _authorization(credentials)},
85
+ timeout=urllib3.Timeout(connect=5, read=15),
86
+ retries=False,
87
+ )
88
+ except urllib3.exceptions.HTTPError as exc:
89
+ raise click.ClickException("Could not reach the Twilio inbox for this account.") from exc
90
+ if response.status != 200:
91
+ raise click.ClickException(f"Twilio inbox request failed with HTTP {response.status}.")
92
+ try:
93
+ value = json.loads(response.data)
94
+ except (TypeError, UnicodeDecodeError, json.JSONDecodeError) as exc:
95
+ raise click.ClickException("Twilio returned an invalid inbox response.") from exc
96
+ if not isinstance(value, dict):
97
+ raise click.ClickException("Twilio returned an invalid inbox response.")
98
+ return value
99
+
100
+
101
+ def _received_at(message: dict) -> datetime:
102
+ raw = message.get("date_sent") or message.get("date_created")
103
+ if not isinstance(raw, str):
104
+ raise ValueError("missing message timestamp")
105
+ received_at = parsedate_to_datetime(raw)
106
+ if received_at.tzinfo is None:
107
+ received_at = received_at.replace(tzinfo=UTC)
108
+ return received_at.astimezone(UTC)
109
+
110
+
111
+ def _parse_message(message: dict) -> SmsMessage:
112
+ sid = message.get("sid")
113
+ sender = message.get("from")
114
+ recipient = message.get("to")
115
+ direction = message.get("direction")
116
+ status = message.get("status")
117
+ body = message.get("body")
118
+ if not isinstance(sid, str) or not _MESSAGE_SID.fullmatch(sid):
119
+ raise ValueError("invalid message SID")
120
+ if not all(isinstance(value, str) for value in (sender, recipient, direction, status, body)):
121
+ raise ValueError("invalid message fields")
122
+ return SmsMessage(
123
+ sid=sid,
124
+ received_at=_received_at(message),
125
+ sender=sender,
126
+ recipient=recipient,
127
+ direction=direction,
128
+ status=status,
129
+ body=body,
130
+ )
131
+
132
+
133
+ def list_sms_messages(
134
+ http,
135
+ credentials: TwilioCredentials,
136
+ *,
137
+ recipient: str,
138
+ cutoff: datetime,
139
+ ) -> list[SmsMessage]:
140
+ """List recent inbound messages for exactly one roster phone number."""
141
+ recipient = validate_phone_number(recipient)
142
+ url = f"https://api.twilio.com/2010-04-01/Accounts/{credentials.account_sid}/Messages.json"
143
+ payload = _request_json(
144
+ http,
145
+ credentials,
146
+ url,
147
+ fields={"To": recipient, "PageSize": MAX_MESSAGES},
148
+ )
149
+ raw_messages = payload.get("messages")
150
+ if not isinstance(raw_messages, list):
151
+ raise click.ClickException("Twilio returned an invalid message list.")
152
+
153
+ messages: list[SmsMessage] = []
154
+ for raw_message in raw_messages:
155
+ if not isinstance(raw_message, dict):
156
+ continue
157
+ if raw_message.get("direction") != "inbound" or raw_message.get("to") != recipient:
158
+ continue
159
+ try:
160
+ message = _parse_message(raw_message)
161
+ except TypeError, ValueError:
162
+ continue
163
+ if message.received_at >= cutoff:
164
+ messages.append(message)
165
+ messages.sort(key=lambda message: message.received_at, reverse=True)
166
+ return messages[:MAX_MESSAGES]
167
+
168
+
169
+ def read_sms_message(
170
+ http,
171
+ credentials: TwilioCredentials,
172
+ *,
173
+ recipient: str,
174
+ message_sid: str,
175
+ cutoff: datetime,
176
+ ) -> SmsMessage:
177
+ """Read one recent message while re-enforcing its exact recipient."""
178
+ recipient = validate_phone_number(recipient)
179
+ if not _MESSAGE_SID.fullmatch(message_sid):
180
+ raise click.ClickException("Invalid SMS message SID.")
181
+ url = f"https://api.twilio.com/2010-04-01/Accounts/{credentials.account_sid}/Messages/{message_sid}.json"
182
+ try:
183
+ message = _parse_message(_request_json(http, credentials, url))
184
+ except (TypeError, ValueError) as exc:
185
+ raise click.ClickException("Twilio returned an invalid SMS message.") from exc
186
+ if message.direction != "inbound" or message.recipient != recipient:
187
+ raise click.ClickException("That SMS message does not belong to the selected agent phone number.")
188
+ if message.received_at < cutoff:
189
+ raise click.ClickException("That SMS message is older than the requested --since window.")
190
+ return message
@@ -0,0 +1 @@
1
+ """Customer report review commands."""