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.
- hound_cli/__init__.py +1 -0
- hound_cli/__main__.py +60 -0
- hound_cli/agents.py +19 -0
- hound_cli/aws.py +340 -0
- hound_cli/contracts/__init__.py +1 -0
- hound_cli/contracts/domain.py +129 -0
- hound_cli/contracts/workflow.py +76 -0
- hound_cli/inbox/__init__.py +1 -0
- hound_cli/inbox/command.py +212 -0
- hound_cli/inbox/email.py +210 -0
- hound_cli/inbox/sms.py +190 -0
- hound_cli/reports/__init__.py +1 -0
- hound_cli/reports/close.py +115 -0
- hound_cli/reports/files.py +69 -0
- hound_cli/reports/open.py +113 -0
- hound_cli/reports/render.py +116 -0
- hound_cli/reports/reviews.py +120 -0
- hound_cli/reports/service.py +81 -0
- hound_cli/roster.py +64 -0
- hound_cli/targets/__init__.py +1 -0
- hound_cli/targets/configure.py +163 -0
- hound_cli/targets/verification.py +81 -0
- hound_cli/ui.py +5 -0
- hound_cli/workflows/__init__.py +1 -0
- hound_cli/workflows/launch.py +444 -0
- hound_cli/workflows/logs.py +197 -0
- hound_cli/workflows/selection.py +77 -0
- hound_cli/workflows/status.py +153 -0
- hound_cli/workflows/stop.py +108 -0
- hound_cli-0.1.0.dist-info/METADATA +79 -0
- hound_cli-0.1.0.dist-info/RECORD +35 -0
- hound_cli-0.1.0.dist-info/WHEEL +5 -0
- hound_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hound_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- hound_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
hound_cli/inbox/email.py
ADDED
|
@@ -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."""
|