diff --git a/docs/jobs/zendesk-digest.md b/docs/jobs/zendesk-digest.md index 76b52c1..d508ed1 100644 --- a/docs/jobs/zendesk-digest.md +++ b/docs/jobs/zendesk-digest.md @@ -85,6 +85,24 @@ Two limits bound a message and whichever binds first splits it โ€” 40 components **That is what lets everything here go over one incoming webhook** โ€” the digest, the positive-review tally and the failure alerts alike. A webhook no application owns may send non-interactive components and nothing else, so adding an interactive one means moving the digest back to a bot token and a channel id. The request needs `?with_components=true` (`digest_webhook_url`): Discord ignores the `components` field on a webhook post without it, and the digest is nothing but components. +## Drafting the known answers + +Before posting, the digest drafts a private reply note on each ticket it reports that +support has not answered yet and that a brief support already sent answers; see +[Drafts nobody asked for](zendesk-relay.md#drafts-nobody-asked-for). A ๐Ÿ“ line lists +them under the header. Up to three Claude calls a ticket: placement in the house +answers (skipped when the ticket is already tagged), the choice of brief, and the draft. +Only a ticket placed in one of the groups in `DRAFT_GROUPS` gets past placement. + +| | | +| --- | --- | +| Enabled by | `--known-briefs PATH`, which `digest_job.py` sets beside `--state`, and `ZENDESK_HOUSE_ANSWERS` | +| Cache | `known_briefs.json`: briefs read from `tags:claude-sent`, topped up each run, dropped after 180 days. Losing it costs one backfill of those 180 days; a truncated search or a failed comment read rescans from the same point next run | +| Fixed answers | a law-enforcement request gets the standard reply word for word; an abuse report is composed from a standing brief | +| Not drafted | spam and positive reviews, groups outside `DRAFT_GROUPS`, tickets with a live chat open, tickets already tagged `claude-drafted`, `claude-queued` or `claude-sent`, and any ticket with a public reply from support or a private note from anyone but the API user | +| Bounds | 20 minutes of drafting a run, highest priority first; stops after three failures. A failure never fails the digest | +| Dry run | `--dry-run` and a rehearsal compose but write no note and no cache; `--no-discord` drafts nothing | + ## Deduplication The window is 72h against runs a day apart, so consecutive runs overlap. A state file (`--state`) records each reported ticket's `requester_updated_at`, giving three outcomes per ticket: diff --git a/docs/jobs/zendesk-relay.md b/docs/jobs/zendesk-relay.md index cf94bbf..211ac84 100644 --- a/docs/jobs/zendesk-relay.md +++ b/docs/jobs/zendesk-relay.md @@ -36,6 +36,33 @@ which publishes that option **verbatim** and moves the ticket to `pending`. A ba `claude: reply` sends the only option when there is one, and refuses to guess when there are several. +## Drafts nobody asked for + +The weekday digest writes the same draft note on its own, before it posts, when a +ticket nobody has answered matches an answer support already gave. A known answer is a +`claude: draft` brief that went out as a ticket's first reply with no amendment, from +the same house-answer group. Claude picks the one that fully answers the ticket, or +none, and rejects briefs that name another ticket, promise an action, or carry a fact +that has since expired, like a pending release. + +Only the groups in `DRAFT_GROUPS` are drafted: those whose first reply support has +settled on, like reports of illegal content, lost recovery phrases, unrecoverable data +and the common delivery, notification, connection and call failures. A ticket placed +anywhere else, or nowhere, is left to an agent. + +A law-enforcement request gets no brief: it gets the standard reply in +`STANDARD_REPLIES` ([auto_draft.py](../../src/session_ops/zendesk/auto_draft.py)), word +for word and untranslated, because what Session tells an authority must not vary. + +An abuse report, by the digest's own category and in one of those groups, is composed +from the standing brief in `STANDING_BRIEFS`, in the reporter's language: we can block +the reported Session ID from the communities Session operates, and that is all. + +The note says which ticket the brief came from and carries `[claude:auto:]` +instead of a command's markers. Send it with `claude: reply N`. A `claude: draft` after +it starts over from the new brief rather than amending it: amending keeps wording an +agent approved, and nobody approved this one. + ## The commands | Command | What it does | Touches the customer | diff --git a/src/session_ops/zendesk/api.py b/src/session_ops/zendesk/api.py index df5e2a9..368db7b 100644 --- a/src/session_ops/zendesk/api.py +++ b/src/session_ops/zendesk/api.py @@ -201,6 +201,39 @@ def hydrate_requester_activity(session, subdomain, tickets): return hydrated +MESSAGING_CHANNEL_PREFIX = "sunshine_conversations" +CHAT_EVENTS = ("ChatStartedEvent", "ChatEndedEvent") + + +def is_messaging(ticket): + return ((ticket.get("via") or {}).get("channel") or "").startswith(MESSAGING_CHANNEL_PREFIX) + + +def has_open_chat(session, subdomain, ticket_id): + """Whether the ticket's newest live chat has not ended; None when it cannot be read. + + Zendesk writes a chat into the ticket as a comment only when it ends, so until then + the customer's newest messages are on no comment, and only the assignee can open + the chat. One that starts while the ticket has an assignee is never ended for it. + """ + url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}/audits.json" + try: + resp = session.request("GET", url, attempts=2, + params={"sort_order": "desc", "per_page": 100}) + if resp.status_code >= 400: + print(f"Note: audits for #{ticket_id} returned {resp.status_code}.") + return None + audits = resp.json().get("audits", []) + except (requests.RequestException, ValueError) as exc: + print(f"Note: could not read the audits for #{ticket_id} ({exc}).") + return None + for audit in audits: + for event in reversed(audit.get("events") or []): + if event.get("type") in CHAT_EVENTS: + return event["type"] == "ChatStartedEvent" + return False + + STAR_SUBJECT = re.compile(r"^\s*([โ˜…โ˜†]{1,10})") diff --git a/src/session_ops/zendesk/auto_draft.py b/src/session_ops/zendesk/auto_draft.py new file mode 100644 index 0000000..f864ad2 --- /dev/null +++ b/src/session_ops/zendesk/auto_draft.py @@ -0,0 +1,430 @@ +""" +Draft a reply, unprompted, on the digest's tickets that support already knows how to answer. + +"Knows" means a human wrote that answer before: a `claude: draft` brief, for the same +problem group in the house answers, that went out as the ticket's first reply with no +amendment. Those briefs are cached in a file beside the digest's state and topped up +from `tags:claude-sent` on every run. + +For each new ticket nobody has answered, Claude places it in a group; only a group in +DRAFT_GROUPS is drafted. Claude picks the past brief that fully answers it (or none), +and composes from that brief exactly as `claude: draft` would. A group in +STANDARD_REPLIES gets its fixed text instead, uncomposed, and a category in +STANDING_BRIEFS is composed from its fixed brief. +Each is the same private draft note, so an agent sends it with `claude: reply N`, or +replaces it with `claude: draft `. + +Nothing here writes a public comment. Like the rest of the Zendesk jobs, nothing is +printed but ticket ids and counts: this repo and its logs are public. +""" +import json +import re +import sys +import textwrap +import time +from datetime import date, timedelta + +from session_ops.shared.discord import clip +from session_ops.shared.state import write_json +from session_ops.zendesk import api, claude_api, note_reply + +CACHE_VERSION = 1 +# Older briefs drop out: a fact support stated half a year ago is likelier to be stale. +BRIEF_MAX_AGE_DAYS = 180 +# Zendesk's date search is day-granular in the account's timezone, and `updated>` is +# exclusive, so each run re-reads two days to be sure nothing slips between runs. +RESCAN_DAYS = 2 +MAX_CANDIDATES = 6 +SELECT_TIMEOUT_SECONDS = 120 +# Bounds the digest's runtime: a ticket costs up to three Claude calls. +BUDGET_SECONDS = 20 * 60 +# A dead key or an exhausted balance fails every ticket the same way. +MAX_FAILURES = 3 +# A runaway guard on the backfill, far above the sent tickets one retention window holds. +MAX_SENT_TICKETS = 20000 + +SKIP_CATEGORIES = frozenset({"spam_or_solicitation", "positive_review"}) + +# The house-answer groups whose first reply support settled on; the rest vary too much +# per ticket for an unprompted draft to be worth a reviewer's time. +DRAFT_GROUPS = frozenset({ + "reporting-illegal-content", + "needs-more-detail", + "account-unrecoverable-without-recovery-phrase", + "recovery-phrase-location-and-login-basics", + "deleted-data-not-recoverable", + "lost-contacts-not-restorable", + "law-enforcement-requests", + "push-notifications-not-arriving", + "messages-failing-to-send", + "connection-path-errors", + "attachments-failing-to-send", + "call-failures-and-quality", + "messages-notified-but-not-appearing", + "delete-account-and-data", + "leaked-recovery-phrase", +}) + +# House-answer groups answered with one fixed text, never composed or translated: what +# Session says to an authority has to be the same every time. Sent only by a human's +# `claude: reply`, like every other draft. +STANDARD_REPLIES = { + "law-enforcement-requests": ("law-enforcement requests", """\ +Weโ€™ve received your request. + +Hereโ€™s what we can share about Session and legal requests: +Session is a decentralized, privacy-focused messenger, and we are not the sole operators of the network. +The Session Technology Foundation does not collect or store personal data such as phone numbers, email addresses, or identifying metadata like IP addresses. +Because of how Session is built, we donโ€™t have the technical ability to preserve or provide information about user identities, connections, or data. That includes IP logs or timestamps for messages, calls, video calls, or media. + +What we can do: +If you provide the relevant Session ID, we can block that ID from accessing Session-operated communities, which are mainly used for Session updates and information. If youโ€™d like us to take that step, share the ID and weโ€™ll proceed. + +Legal process: +As a Swiss organization, legal inquiries must be processed via the Swiss Post and Telecommunications Surveillance Service (PTSS) under Art. 26 para. 2 VรœPF. +For foreign authorities, requests need to come through mutual legal assistance channels under the Swiss Federal Act on International Mutual Assistance in Criminal Matters. We cannot act on foreign production or preservation orders sent directly to us. + +If you need anything clarified or want us to action a community block with a Session ID, please reply here."""), +} + + +# Triage categories answered from a standing brief, composed in the reporter's language. +STANDING_BRIEFS = { + "abuse_report": ("the usual reply to abuse reports", """\ +Thank them for the report and say we take it seriously. +Session is decentralized: nobody, Session included, can ban, remove or deactivate a Session ID across the network, or see what it sends. +The one thing we can do is block the reported Session ID from the communities Session itself operates. If they gave the full Session ID, say we can do that with it; if not, ask for it. +If the account or the content also appears on another website or app, they should report it to that platform too. +They should not send us the illegal content itself."""), +} + + +def standard_result(text): + """A fixed reply in the shape compose returns, as its only option.""" + return {"language": "English", "language_code": "en", "is_english": True, + "options": [{"approach": "the standard reply", "reply_en": text, + "translated": text, "back_translation": ""}]} + + +# ---- What support already answered ------------------------------------------- + + +def sent_first_brief(comments, api_user): + """(brief, sent_at) when the ticket's first reply went out from one unamended + `claude: draft`, else None. `comments` oldest first. + + Amended or redrafted briefs are left out: each is a change to a draft, not an answer. + """ + draft = None + for comment in comments: + author = comment.get("author_id") + if comment.get("public"): + if author != api_user: + continue + if draft and draft["replied"] and draft["brief"]: + return draft["brief"], comment.get("created_at") + return None + if author == api_user: + continue + parsed = note_reply.parse_command(note_reply.comment_text(comment)) + if not parsed: + continue + action, brief = parsed + if action == "draft": + if draft: + return None + draft = {"brief": brief, "replied": False} + elif action == "reply" and draft: + draft["replied"] = True + return None + + +def load_cache(path): + """The brief cache, or an empty one when it is missing or unreadable: rebuilding it + costs one backfill of `tags:claude-sent`, never a wrong draft.""" + try: + with open(path, encoding="utf-8") as handle: + cache = json.load(handle) + if cache.get("version") == CACHE_VERSION and isinstance(cache.get("briefs"), dict): + return cache + except (OSError, ValueError, AttributeError): + pass + return {"version": CACHE_VERSION, "scanned_through": None, "briefs": {}} + + +def refresh_cache(session, subdomain, api_user, cache, today): + """Add the briefs sent since the last scan, and age out the old ones. + + The scan only moves on when it read everything: a truncated search or a failed + comment read is retried from the same point next run. + """ + cutoff = (today - timedelta(days=BRIEF_MAX_AGE_DAYS)).isoformat() + # A scan stuck incomplete would otherwise reread a window growing past the retention. + since = max(cache.get("scanned_through") or cutoff, cutoff) + query = (f"type:ticket tags:claude-sent updated>{since} " + "order_by:created_at sort:desc") + tickets, matched = api.fetch_every_ticket(session, subdomain, query, MAX_SENT_TICKETS) + complete = matched is None or len(tickets) >= matched + added = 0 + for ticket in tickets: + group, platform = note_reply.tagged_placement(ticket) + if not group: + continue + comments = api.fetch_comments(session, subdomain, ticket["id"], order="asc", + optional=True) + if comments is None: + complete = False + continue + found = sent_first_brief(comments, api_user) + if found: + cache["briefs"][str(ticket["id"])] = { + "group": group, "platform": platform or "unknown", + "brief": found[0], "sent_at": found[1]} + added += 1 + cache["briefs"] = {tid: b for tid, b in cache["briefs"].items() + if (b.get("sent_at") or "") >= cutoff} + if complete: + cache["scanned_through"] = (today - timedelta(days=RESCAN_DAYS)).isoformat() + print(f"Known briefs: read {len(tickets)} of {matched} sent ticket(s), {added} " + f"first-reply brief(s); {len(cache['briefs'])} cached" + + ("." if complete else "; incomplete, rescanned next run.")) + return cache + + +def candidates(cache, group): + """The group's distinct briefs, most used then most recent first. + + Each is {brief, uses, latest, platforms, source}, `source` being the newest ticket + it was sent on. + """ + merged = {} + for tid, entry in cache["briefs"].items(): + if entry.get("group") != group: + continue + key = re.sub(r"\s+", " ", entry["brief"]).strip().lower() + row = merged.setdefault(key, {"brief": entry["brief"], "uses": 0, "latest": "", + "platforms": set(), "source": tid}) + row["uses"] += 1 + row["platforms"].add(entry.get("platform") or "unknown") + if (entry.get("sent_at") or "") > row["latest"]: + row.update(latest=entry["sent_at"], source=tid, brief=entry["brief"]) + rows = sorted(merged.values(), key=lambda r: (r["uses"], r["latest"]), reverse=True) + return rows[:MAX_CANDIDATES] + + +# ---- Choosing a brief ------------------------------------------------------- + +SELECT_SCHEMA = { + "type": "object", "additionalProperties": False, + "required": ["choice", "reason"], + "properties": { + "choice": {"type": "integer", + "description": "The number of the brief that answers this ticket, " + "or 0 when none does."}, + "reason": {"type": "string", "description": "One short sentence, in English."}, + }, +} + +SELECT_SYSTEM = textwrap.dedent( + """ + You decide whether an answer Session support already gave other customers also + answers this one, unchanged. Session is a private messenger. + + You are given one new ticket and numbered BRIEFS: the substance of a first reply + support wrote for the same kind of problem, each one reviewed by a human and sent. + Pick the brief that, on its own, answers what this customer actually asks. Answer + 0 when none does. + + Pick a brief only when all of these hold: + - It answers this customer's actual question or problem, not just its topic. + - It needs nothing it does not say: it names no other ticket, person or + conversation, and does not rely on details only that other customer gave. + - Every fact in it is still true TODAY. A brief that mentions a pending or + upcoming release, a fix being prepared, "this week", "soon" or an ETA was true + when it was sent; reject it unless it was sent within the last 14 days. + - It does not say an action was or will be taken on that customer's case: an + account banned, a bug filed, a refund, an escalation. Nobody has acted on this + ticket. + + Asking for logs or missing details is a valid answer when the ticket lacks what + support needs to help. A brief is an agent's shorthand: judge what it says, not + how it reads. Prefer one sent several times. + + When in doubt, answer 0. A missing draft costs an agent one note; a wrong one + costs a reviewer's time and may be sent. + """ +).strip() + + +def build_select_prompt(ticket, sample, rows, today): + parts = [f"TODAY: {today.isoformat()}", "", + f"THE TICKET: {(ticket.get('subject') or '')[:200]}", sample, "", "BRIEFS:"] + for number, row in enumerate(rows, 1): + parts += [f"{number}. sent {row['uses']} time(s), most recently " + f"{(row['latest'] or '')[:10]}, platforms: " + f"{', '.join(sorted(row['platforms']))}", + row["brief"], ""] + return "\n".join(parts) + + +def choose_brief(ticket, sample, rows, today): + """The chosen row, or None.""" + found = claude_api.claude_json( + "medium", SELECT_SYSTEM, SELECT_SCHEMA, + build_select_prompt(ticket, sample, rows, today), + SELECT_TIMEOUT_SECONDS, f"the brief choice for #{ticket['id']}") + choice = found.get("choice") + if isinstance(choice, int) and 1 <= choice <= len(rows): + return rows[choice - 1] + return None + + +# ---- Drafting --------------------------------------------------------------- + + +def eligible(ticket, comments, authors, api_user): + """Whether nobody has answered or picked up this ticket yet. + + Only a first reply: a brief that answered someone's opening message does not answer + their follow-up. Any private note from a person means someone is on it. + """ + tags = set(ticket.get("tags") or []) + if tags & {note_reply.TAG_DRAFTED, note_reply.TAG_QUEUED, note_reply.TAG_SENT}: + return False + for comment in comments: + author = comment.get("author_id") + if comment.get("public"): + if author not in authors: + return False + elif author != api_user and author not in authors: + return False + return True + + +def write_draft(session, subdomain, ticket_id, result, brief, unprompted, dry_run, + cell=None, title=None, covering=None, tags=()): + if dry_run: + return + note_reply.write_to_ticket( + session, subdomain, ticket_id, + note_reply.build_draft_note(result, brief, None, cell=cell, title=title, + covering=covering, unprompted=unprompted), + public=False, as_html=True, add_tags=[note_reply.TAG_DRAFTED, *tags]) + + +def draft_ticket(session, subdomain, ticket, cache, book, api_user, today, dry_run, + category=None): + """Draft one ticket. Returns the source ticket id whose brief was reused, the group + or category of a standard reply or standing brief, or None. + + `category` is the digest's. Placement runs first even for a category with a + standing brief: a police request about abuse has to get the fixed legal text. + """ + ticket_id = ticket["id"] + comments = api.fetch_comments(session, subdomain, ticket_id) + public = [c for c in comments if c.get("public")] + authors = api.customer_authors(session, subdomain, ticket, public) + if not eligible(ticket, comments, authors, api_user): + return None + sample = api.customer_text(session, subdomain, ticket, comments, + note_reply.CUSTOMER_SAMPLE_CHARS, authors) + group, cell, covering, title, precedent, new_tags = note_reply.ground( + ticket, sample, book) + if group not in DRAFT_GROUPS: + if new_tags and not dry_run: + note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) + return None + if group in STANDARD_REPLIES: + name, text = STANDARD_REPLIES[group] + print(f"#{ticket_id}: {group}, drafted the standard reply.") + write_draft(session, subdomain, ticket_id, standard_result(text), None, + (f"the standard reply to {name}, word for word", None, group), + dry_run, tags=new_tags) + return group + if category in STANDING_BRIEFS: + name, brief = STANDING_BRIEFS[category] + result = note_reply.compose(clip(sample, note_reply.CUSTOMER_SAMPLE_CHARS), brief) + print(f"#{ticket_id}: {category}, drafted {len(result['options'])} option(s) " + f"from the standing brief.") + write_draft(session, subdomain, ticket_id, result, brief, + (name, "Standing brief:", category), dry_run, tags=new_tags) + return category + rows = candidates(cache, group) + if not rows: + if new_tags and not dry_run: + note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) + return None + row = choose_brief(ticket, sample, rows, today) + if not row: + print(f"#{ticket_id}: {group}, no known brief answers it.") + if new_tags and not dry_run: + note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) + return None + result = note_reply.compose(clip(sample, note_reply.CUSTOMER_SAMPLE_CHARS), + row["brief"], precedent=precedent) + print(f"#{ticket_id}: {group}, drafted {len(result['options'])} option(s) from the " + f"brief sent on #{row['source']}.") + write_draft(session, subdomain, ticket_id, result, row["brief"], + (f"a reply unprompted, reusing the brief support sent on #{row['source']}", + f"Brief reused from #{row['source']}:", row["source"]), + dry_run, cell, title, covering, new_tags) + return row["source"] + + +def draft_known(session, subdomain, tickets, findings, cache_path, skip_ids=(), + dry_run=False): + """Draft every ticket in `tickets` a known brief answers. Returns the drafted ids. + + `findings` is the digest's classification, read for the category; `skip_ids` are + tickets whose newest message is not on the ticket yet. A dry run writes neither + notes nor the cache. Every failure here is reported and survived: the digest is + the job, and these drafts are a head start on it. + """ + book = note_reply.load_house() + if not book: + print(f"Note: no house answers ({note_reply.HOUSE_ENV}), so no ticket can be " + f"matched to a known brief; drafting skipped.") + return [] + today = date.today() + started = time.monotonic() + try: + api_user = note_reply.api_user_id(session, subdomain) + cache = refresh_cache(session, subdomain, api_user, load_cache(cache_path), today) + # Broad on purpose, here and per ticket: drafting runs before the digest posts, so + # anything it raises would cost the digest itself. + except (Exception, SystemExit) as exc: + print(f"Note: could not read the known briefs ({type(exc).__name__}: {exc}); " + f"drafting skipped.") + return [] + if not dry_run: + try: + write_json(cache_path, cache) + except OSError as exc: + print(f"Note: could not save the known briefs ({exc}).", file=sys.stderr) + + category = {f.get("id"): f.get("category") for f in findings} + rank = {f.get("id"): f.get("priority_rank", 9999) for f in findings} + queue = sorted((t for t in tickets + if t["id"] in category and t["id"] not in skip_ids + and category[t["id"]] not in SKIP_CATEGORIES), + key=lambda t: rank[t["id"]]) + drafted, failures = [], 0 + for position, ticket in enumerate(queue): + if time.monotonic() - started > BUDGET_SECONDS: + print(f"Drafting stopped at its time budget; {len(queue) - position} " + f"ticket(s) not considered.") + break + try: + if draft_ticket(session, subdomain, ticket, cache, book, api_user, today, + dry_run, category[ticket["id"]]): + drafted.append(ticket["id"]) + except (Exception, SystemExit) as exc: + failures += 1 + print(f"#{ticket['id']}: not drafted ({type(exc).__name__}: {exc}).", + file=sys.stderr) + if failures >= MAX_FAILURES: + print(f"Drafting stopped after {failures} failures.") + break + print(f"Drafted {len(drafted)} of {len(queue)} ticket(s) from known briefs" + + (" (dry run, nothing written)." if dry_run else ".")) + return drafted diff --git a/src/session_ops/zendesk/digest_job.py b/src/session_ops/zendesk/digest_job.py index 8db05b5..c954e14 100644 --- a/src/session_ops/zendesk/digest_job.py +++ b/src/session_ops/zendesk/digest_job.py @@ -1,5 +1,8 @@ """The weekday Zendesk run: solve positive app-store reviews, then post the digest. +The digest drafts a private reply note on the tickets a brief support already sent +answers, before it posts; the brief cache sits beside its dedup state. + In that order because a solved review leaves the digest's `status" + markers = auto_marker(unprompted[2]) if unprompted \ + else f"{done_marker(comment_id)} {draft_marker(comment_id)}" out += [para(f"To send it exactly as above, {send}"), para("To change it, add a private note reading claude: draft "), - para(f"{done_marker(comment_id)} {draft_marker(comment_id)}")] + para(markers)] return "".join(out) @@ -883,24 +922,15 @@ def run_draft(session, subdomain, ticket, comments, command, api_user, dry_run): if any(c.get("public") and c.get("author_id") in authors | {api_user} for c in newer): shown = {} + # Amending keeps what the agent approved, and nobody approved an unprompted draft. + if shown and AUTO_MARKER_PREFIX in comment_text(comments[len(newer)]): + shown = {} previous = "\n\n".join(f"Option {n}:\n{shown[n]}" for n in sorted(shown)) or None thread = follow_up(comments, authors) sample = api.customer_text(session, subdomain, ticket, comments, CUSTOMER_SAMPLE_CHARS, authors) - book = load_house() - group, platform = tagged_placement(ticket) - new_tags = [] - if book and not group: - group, platform = place_ticket(book, ticket, sample) - # Cached on the ticket so a revision does not pay for the same call again, - # and so the placement is visible to a human who disagrees with it. - new_tags = ([f"{TAG_GROUP_PREFIX}{group}"] if group else []) + \ - ([f"{TAG_PLATFORM_PREFIX}{platform}"] if platform else []) - cell, covering = house_cell(book, group, platform) - title = next((g["title"] for g in (book or {}).get("groups", []) - if g["key"] == group), group) - precedent = render_precedent(cell, title, covering) if cell else None + group, cell, covering, title, precedent, new_tags = ground(ticket, sample, load_house()) if cell: print(f"#{ticket_id}: grounded in {group}/{covering} " f"({cell['n']} solved, {cell['consistency']} consistency).") @@ -1248,6 +1278,15 @@ def say(session, subdomain, ticket_id, comment_id, text, dry_run, error=True): # ---- Entry point ------------------------------------------------------------ +# A live chat keeps the customer's newest messages off the ticket, so these would +# answer, translate or send against a conversation that has moved on. +READS_CUSTOMER = ("draft", "reply", "english") +OPEN_CHAT_REFUSAL = ( + "The customer's newest message is still in a live chat, so it is not on this ticket " + "yet and nothing was done. Zendesk adds it once the chat ends, about 10 minutes after " + "the ticket has no assignee. Then add your note again.") + + def latest_command(comments, api_user, session, subdomain): """The newest private note that is a command from someone allowed to give one. @@ -1309,6 +1348,10 @@ def main(argv=None): print(f"#{args.ticket}: this command was already handled; nothing written.") clear_queued(session, subdomain, args.ticket, args.dry_run) return + if (command["action"] in READS_CUSTOMER and api.is_messaging(ticket) + and api.has_open_chat(session, subdomain, args.ticket)): + say(session, subdomain, args.ticket, command["id"], OPEN_CHAT_REFUSAL, args.dry_run) + return if command["action"] == "draft": run_draft(session, subdomain, ticket, comments, command, diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index 9346897..db69fd5 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -71,13 +71,14 @@ from session_ops.shared import discord, http, state as dedup from session_ops.shared.discord import clip, post_to_discord -from session_ops.shared.env import get_env +from session_ops.shared.env import get_env, rehearsing from session_ops.shared.text import squash from session_ops.zendesk.api import (REVIEW_CHANNEL, SEARCH_RESULT_LIMIT, fetch_comments, - fetch_tickets, + fetch_tickets, has_open_chat, hydrate_requester_activity, is_store_review, review_platform, review_stars, ticket_url, zendesk_session) +from session_ops.zendesk import auto_draft from session_ops.zendesk.claude_api import claude_json, model as newest_opus # Never analyzed. A store review cannot be answered the way a ticket can: it takes @@ -498,6 +499,25 @@ def hydrate_descriptions(session, subdomain, tickets): return hydrated +# Twitter DM is the only messaging channel connected to the account. +OPEN_CHAT_QUERY = "type:ticket status COLLAPSED_LINKS_CHARS: + break + links.append(link) + if len(ticket_ids) > len(links): + links.append(f"+{len(ticket_ids) - len(links)} more") + return (f"{DRAFTED_MARKER} **{len(ticket_ids)}** drafted from a known answer, to " + f"check and send with claude: reply N โ€” {', '.join(links)}", set()) + + def build_header(findings, highlights, stats=None): """The lead lines: what was looked at, the category tally, duplicate clusters. @@ -929,9 +992,11 @@ def build_messages(findings, subdomain, stats=None, updated_ids=None): if omitted: header += (f"\nShowing the top **{len(shown)}** of " f"**{len(shown) + len(omitted)}** worth looking into.") + open_chats = build_open_chat_line((stats or {}).get("open_chats"), subdomain) non_english = build_non_english_line(findings, subdomain, updated_ids) - # First, under the header: it is the run's to-do list for `claude: english`. - entries = [non_english] if non_english else [] + drafted = build_drafted_line((stats or {}).get("drafted"), subdomain) + # First, under the header: the run's to-do lists, for an admin and for the `claude:` notes. + entries = [line for line in (open_chats, non_english, drafted) if line] entries += [ (build_ticket_line(f, subdomain, is_update=f.get("id") in updated_ids), {f.get("id")}) @@ -991,6 +1056,11 @@ def main(argv=None): "real run, after Discord accepts the post.") parser.add_argument("--state-retention-days", type=int, default=30, metavar="N", help="Forget state entries older than N days (default: 30).") + parser.add_argument("--known-briefs", metavar="PATH", + help="Cache of briefs support already sent. When set, tickets one " + "of them answers get a private draft note before the digest " + "is posted. Needs ZENDESK_HOUSE_ANSWERS. Writes to Zendesk " + "except under --dry-run; skipped under --no-discord.") parser.add_argument("--dump-batch", metavar="PATH", help="Write the batch (tickets, prompt, schema) to PATH and exit, for " "hand-classification. WARNING: writes ticket content to disk.") @@ -1053,6 +1123,9 @@ def main(argv=None): stats["total_unsolved"] = fetch_total_unsolved(zd, subdomain) stats["total_unsolved_non_review"] = fetch_total_unsolved( zd, subdomain, BACKLOG_NON_REVIEW_QUERY) + stats["open_chats"] = find_open_chats(zd, subdomain) + if stats["open_chats"]: + print(f"{len(stats['open_chats'])} ticket(s) have a live chat still open.") # Drop positive store reviews before anything expensive: they were 59% of all # tickets in the sample and never actionable. @@ -1139,11 +1212,21 @@ def main(argv=None): print(f"{len(findings)} tickets classified; {len(shown) + len(omitted)} worth looking into" + (f" ({len(omitted)} beyond the display cap)." if omitted else ".")) + # Classified from a conversation missing its newest message, so left unrecorded: + # once the chat ends the ticket is reported again with that message in it. + open_chats = stats.get("open_chats") or set() + # Before the post, so the digest can say which tickets already carry a draft. Not + # under --no-discord: a counts-only run must not write to tickets. + if args.known_briefs and zd and classified and not args.no_discord: + stats["drafted"] = auto_draft.draft_known( + zd, subdomain, classified, findings, args.known_briefs, open_chats, + dry_run=args.dry_run or rehearsing()) + messages, coverage = build_messages(findings, subdomain, stats, updated_ids) if args.dry_run: print(json.dumps(messages, indent=2, ensure_ascii=False)) if args.state: - would = set().union(*coverage) if coverage else set() + would = (set().union(*coverage) if coverage else set()) - open_chats print(f"(dry run: would record " f"{sum(1 for t in classified if t.get('id') in would)} tickets " f"in {args.state})") @@ -1167,7 +1250,7 @@ def main(argv=None): # Record only tickets covered by messages Discord actually accepted, so a partial # failure neither reposts what landed nor suppresses what didn't. if args.state and state is not None: - delivered = set().union(*coverage[:posted]) if posted else set() + delivered = (set().union(*coverage[:posted]) if posted else set()) - open_chats recorded = [t for t in classified if t.get("id") in delivered] kept, pruned = save_state(args.state, state, recorded, args.state_retention_days) print(f"Recorded {len(recorded)} tickets; state now tracks {kept} " diff --git a/tests/zendesk/test_api.py b/tests/zendesk/test_api.py index 9dbfaa6..59d513c 100644 --- a/tests/zendesk/test_api.py +++ b/tests/zendesk/test_api.py @@ -67,5 +67,46 @@ def test_an_optional_read_notes_every_failure_and_returns_none(self): self.assertIn("#7", out.getvalue()) +class TestHasOpenChat(unittest.TestCase): + def audits(self, *events_per_audit): + return FakeSession([FakeResponse({"audits": [ + {"events": [{"type": t} for t in events]} for events in events_per_audit]})]) + + def test_the_newest_chat_event_decides(self): + cases = { + "started, not ended": (["Change", "ChatStartedEvent"], [], True), + "ended after it started": (["ChatEndedEvent", "Change"], ["ChatStartedEvent"], False), + "never a chat": (["Change"], [], False), + } + for name, (newest, older, expected) in cases.items(): + with self.subTest(name): + self.assertIs(api.has_open_chat(self.audits(newest, older), "acme", 7), + expected) + + def test_within_one_audit_the_last_event_is_the_newest(self): + session = self.audits(["ChatStartedEvent", "ChatEndedEvent"]) + self.assertFalse(api.has_open_chat(session, "acme", 7)) + + def test_audits_are_read_newest_first(self): + session = self.audits([]) + api.has_open_chat(session, "acme", 7) + _, url, kwargs = session.calls[0] + self.assertTrue(url.endswith("/tickets/7/audits.json")) + self.assertEqual(kwargs["params"]["sort_order"], "desc") + + def test_an_unreadable_history_is_unknown(self): + for response in (FakeResponse({}, status_code=500), NonJsonResponse(), + requests.ConnectionError("down")): + with self.subTest(response=type(response).__name__): + with contextlib.redirect_stdout(io.StringIO()): + self.assertIsNone(api.has_open_chat(FakeSession([response]), "acme", 7)) + + def test_only_sunshine_channels_are_messaging(self): + self.assertTrue(api.is_messaging( + {"via": {"channel": "sunshine_conversations_twitter_dm"}})) + self.assertFalse(api.is_messaging({"via": {"channel": "email"}})) + self.assertFalse(api.is_messaging({})) + + if __name__ == "__main__": unittest.main() diff --git a/tests/zendesk/test_auto_draft.py b/tests/zendesk/test_auto_draft.py new file mode 100644 index 0000000..3df11da --- /dev/null +++ b/tests/zendesk/test_auto_draft.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Tests for the digest's unprompted drafts from briefs support already sent. + +Stdlib unittest, offline, same stubs as the rest. Run from anywhere: + + uv run python -m unittest tests.zendesk.test_auto_draft +""" +import os +import tempfile +import unittest +from datetime import date + +import requests + +from session_ops.zendesk import api, auto_draft, claude_api, note_reply, triage +from tests.zendesk.test_note_reply import ( + AGENT, API_USER, GERMAN, comment, draft_note, fake_session, posted_note) +from tests.zendesk.test_triage import FakeResponse, Patched + +TODAY = date(2026, 11, 2) +CUSTOMER = 42 +BOOK = {"groups": [{"key": "call-failures-and-quality", "title": "Calls failing"}], + "cells": {"call-failures-and-quality|any": {"answer": "Ask for logs.", "n": 30, + "consistency": "high"}}} + + +def public(body, author=CUSTOMER, cid=1, at="2026-10-01T00:00:00Z"): + return dict(comment(body, author=author, public=True, cid=cid), created_at=at) + + +def cmd(body, cid): + return dict(comment(body, author=AGENT, cid=cid), created_at="2026-10-01T00:00:00Z") + + +def cache(*entries): + return {"version": auto_draft.CACHE_VERSION, "scanned_through": None, + "briefs": {str(tid): {"group": group, "platform": "unknown", "brief": brief, + "sent_at": at} + for tid, group, brief, at in entries}} + + +class SentFirstBrief(unittest.TestCase): + def test_an_unamended_brief_that_was_sent_is_known(self): + found = auto_draft.sent_first_brief( + [public("help"), cmd("claude: draft - need the logs", 2), cmd("claude: reply 1", 3), + public("Please send logs", author=API_USER, cid=4, at="2026-10-02T00:00:00Z")], + API_USER) + self.assertEqual(found, ("need the logs", "2026-10-02T00:00:00Z")) + + def test_an_amended_brief_is_not_an_answer(self): + """The second brief is a change to the first draft, not an answer on its own.""" + self.assertIsNone(auto_draft.sent_first_brief( + [public("help"), cmd("claude: draft - need the logs", 2), + cmd("claude: draft - also ask the version", 3), cmd("claude: reply", 4), + public("Please send logs", author=API_USER, cid=5)], API_USER)) + + def test_a_draft_nobody_sent_is_not_an_answer(self): + self.assertIsNone(auto_draft.sent_first_brief( + [public("help"), cmd("claude: draft - need the logs", 2)], API_USER)) + + def test_a_follow_up_brief_is_not_a_first_answer(self): + self.assertIsNone(auto_draft.sent_first_brief( + [public("help"), public("first reply", author=API_USER, cid=2), + cmd("claude: draft - thanks for the logs", 3), cmd("claude: reply", 4), + public("second reply", author=API_USER, cid=5)], API_USER)) + + +class Candidates(unittest.TestCase): + def test_the_same_brief_is_merged_and_counted_most_used_first(self): + rows = auto_draft.candidates(cache( + (1, "call-failures-and-quality", "need the logs", "2026-10-01T00:00:00Z"), + (2, "call-failures-and-quality", "Need the logs", "2026-10-05T00:00:00Z"), + (3, "call-failures-and-quality", "we have no ETA", "2026-10-20T00:00:00Z"), + (4, "other", "unrelated", "2026-10-20T00:00:00Z")), "call-failures-and-quality") + self.assertEqual([(r["uses"], r["source"]) for r in rows], [(2, "2"), (1, "3")]) + + def test_old_briefs_age_out_and_the_scan_moves_on(self): + stale = cache((1, "call-failures-and-quality", "old", "2026-01-01T00:00:00Z"), + (2, "call-failures-and-quality", "new", "2026-10-01T00:00:00Z")) + stale["scanned_through"] = "2026-10-30" + session = fake_session(FakeResponse({"results": [], "count": 0, "next_page": None})) + refreshed = auto_draft.refresh_cache(session, "sub", API_USER, stale, TODAY) + self.assertEqual(list(refreshed["briefs"]), ["2"]) + self.assertEqual(refreshed["scanned_through"], "2026-10-31") + self.assertIn("updated>2026-10-30", str(session.calls[0])) + + def refresh(self, tickets, matched, comments): + start = cache() + start["scanned_through"] = "2026-10-30" + seen = {} + + def fake_search(session, subdomain, query, max_tickets): + seen["query"] = query + return tickets, matched + with Patched(api, fetch_every_ticket=fake_search, + fetch_comments=lambda *a, **k: comments): + return auto_draft.refresh_cache(None, "sub", API_USER, start, TODAY), seen + + def test_a_first_run_reads_only_the_retention_window(self): + seen = {} + + def fake_search(session, subdomain, query, max_tickets): + seen["query"] = query + return [], 0 + with Patched(api, fetch_every_ticket=fake_search): + auto_draft.refresh_cache(None, "sub", API_USER, cache(), TODAY) + self.assertIn("updated>2026-05-06", seen["query"]) + self.assertIn("order_by:created_at sort:desc", seen["query"]) + + def test_a_stuck_scan_never_reaches_past_the_retention_window(self): + seen = {} + + def fake_search(session, subdomain, query, max_tickets): + seen["query"] = query + return [], 0 + stuck = cache() + stuck["scanned_through"] = "2026-01-01" + with Patched(api, fetch_every_ticket=fake_search): + auto_draft.refresh_cache(None, "sub", API_USER, stuck, TODAY) + self.assertIn("updated>2026-05-06", seen["query"]) + + def test_a_failed_comment_read_is_retried_next_run(self): + refreshed, _ = self.refresh([{"id": 5, "tags": ["grp-call-failures-and-quality"]}], + 1, None) + self.assertEqual(refreshed["scanned_through"], "2026-10-30") + + def test_a_truncated_search_is_retried_next_run(self): + refreshed, _ = self.refresh([], 1500, []) + self.assertEqual(refreshed["scanned_through"], "2026-10-30") + + def test_a_complete_scan_moves_on(self): + refreshed, seen = self.refresh( + [{"id": 5, "tags": ["grp-call-failures-and-quality"]}], 1, + [public("help"), cmd("claude: draft - need the logs", 2), + cmd("claude: reply", 3), public("logs?", author=API_USER, cid=4, + at="2026-10-31T00:00:00Z")]) + self.assertEqual(refreshed["scanned_through"], "2026-10-31") + self.assertEqual(refreshed["briefs"]["5"]["brief"], "need the logs") + self.assertIn("updated>2026-10-30", seen["query"]) + + def test_an_unreadable_cache_starts_over(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "k.json") + with open(path, "w") as handle: + handle.write("{not json") + self.assertEqual(auto_draft.load_cache(path)["briefs"], {}) + + +class Eligible(unittest.TestCase): + def test_an_unanswered_ticket_is_eligible(self): + self.assertTrue(auto_draft.eligible({"tags": []}, [public("help")], {CUSTOMER}, + API_USER)) + + def test_a_ticket_support_answered_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": []}, [public("reply", author=API_USER, cid=2), public("help")], + {CUSTOMER}, API_USER)) + + def test_a_ticket_an_agent_is_already_on_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": []}, [cmd("claude: english", 2), public("help")], {CUSTOMER}, + API_USER)) + + def test_a_ticket_a_person_left_a_note_on_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": []}, [cmd("escalated to devs", 2), public("help")], {CUSTOMER}, + API_USER)) + + def test_a_note_from_the_api_user_does_not_count(self): + self.assertTrue(auto_draft.eligible( + {"tags": []}, [comment("translation", author=API_USER, cid=2), + public("help")], {CUSTOMER}, API_USER)) + + def test_a_ticket_already_drafted_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": [note_reply.TAG_DRAFTED]}, [public("help")], {CUSTOMER}, API_USER)) + + +class DraftTicket(unittest.TestCase): + TICKET = {"id": 7, "requester_id": CUSTOMER, "subject": "calls fail", + "tags": ["grp-call-failures-and-quality", "plat-android"]} + KNOWN = cache((99, "call-failures-and-quality", "need the logs of both devices", "2026-10-20T00:00:00Z")) + + def run_draft(self, choice, dry_run=False): + seen = {} + + def fake_compose(sample, brief, previous=None, precedent=None, thread=None): + seen.update(brief=brief, previous=previous, precedent=precedent) + return GERMAN + + session = fake_session(FakeResponse({"comments": [public("Anrufe gehen nicht")]}), + FakeResponse({"ticket": {}})) + with Patched(claude_api, claude_json=lambda *a, **k: {"choice": choice, + "reason": "x"}), \ + Patched(note_reply, compose=fake_compose): + source = auto_draft.draft_ticket(session, "sub", self.TICKET, self.KNOWN, + BOOK, API_USER, TODAY, dry_run) + return source, seen, session + + def test_a_known_brief_is_drafted_as_a_private_note(self): + source, seen, session = self.run_draft(1) + self.assertEqual(source, "99") + self.assertEqual(seen["brief"], "need the logs of both devices") + self.assertIsNone(seen["previous"]) + self.assertIn("Ask for logs.", seen["precedent"]) + writes = [kw["json"] for m, u, kw in session.calls if m == "PUT"] + note = writes[0]["ticket"]["comment"] + self.assertIs(note["public"], False) + self.assertNotIn("status", writes[0]["ticket"]) + self.assertIn(note_reply.auto_marker("99"), note["html_body"]) + self.assertIn("Nobody has reviewed it yet", note["html_body"]) + self.assertIn(note_reply.TAG_DRAFTED, writes[1]["tags"]) + + def test_no_matching_brief_writes_nothing(self): + source, seen, session = self.run_draft(0) + self.assertIsNone(source) + self.assertEqual(seen, {}) + self.assertFalse([c for c in session.calls if c[0] != "GET"]) + + def test_a_group_not_in_the_list_is_not_drafted(self): + def no_claude(*a, **k): + raise AssertionError("an unlisted group is never drafted") + session = fake_session(FakeResponse({"comments": [public("add dark mode")]})) + with Patched(claude_api, claude_json=no_claude), \ + Patched(note_reply, compose=no_claude): + source = auto_draft.draft_ticket( + session, "sub", {"id": 7, "requester_id": CUSTOMER, + "tags": ["grp-feature-requests"]}, + cache((99, "feature-requests", "noted", "2026-10-20T00:00:00Z")), + BOOK, API_USER, TODAY, False) + self.assertIsNone(source) + self.assertFalse([c for c in session.calls if c[0] != "GET"]) + + def test_a_dry_run_writes_nothing(self): + source, _, session = self.run_draft(1, dry_run=True) + self.assertEqual(source, "99") + self.assertFalse([c for c in session.calls if c[0] != "GET"]) + + +class StandardReply(unittest.TestCase): + TICKET = {"id": 8, "requester_id": CUSTOMER, "subject": "Police Request", + "tags": ["grp-law-enforcement-requests"]} + + def run_draft(self): + def no_claude(*a, **k): + raise AssertionError("a standard reply is never composed") + session = fake_session(FakeResponse({"comments": [public("Dear Sir or Madam")]}), + FakeResponse({"ticket": {}})) + with Patched(claude_api, claude_json=no_claude), \ + Patched(note_reply, compose=no_claude): + source = auto_draft.draft_ticket(session, "sub", self.TICKET, cache(), BOOK, + API_USER, TODAY, dry_run=False) + return source, session + + def test_it_is_drafted_word_for_word_as_one_private_option(self): + source, session = self.run_draft() + self.assertEqual(source, "law-enforcement-requests") + note = session.calls[1][2]["json"]["ticket"]["comment"] + self.assertIs(note["public"], False) + self.assertIn("Nobody has reviewed it yet", note["html_body"]) + _, text = auto_draft.STANDARD_REPLIES["law-enforcement-requests"] + self.assertEqual(note_reply.find_draft([posted_note(note["html_body"])], API_USER), + {1: text}) + + +class AbuseReport(unittest.TestCase): + def run_draft(self, group): + seen = {} + + def fake_compose(sample, brief, previous=None, precedent=None, thread=None): + seen.update(brief=brief) + return GERMAN + + def no_claude(*a, **k): + raise AssertionError("an abuse report needs no brief choice") + session = fake_session(FakeResponse({"comments": [public("Bitte sperren")]}), + FakeResponse({"ticket": {}})) + with Patched(claude_api, claude_json=no_claude), \ + Patched(note_reply, compose=fake_compose): + source = auto_draft.draft_ticket( + session, "sub", {"id": 9, "requester_id": CUSTOMER, + "tags": [f"grp-{group}"]}, cache(), + BOOK, API_USER, TODAY, False, category="abuse_report") + return source, seen, session + + def test_a_police_request_about_abuse_gets_the_fixed_legal_text(self): + source, seen, _ = self.run_draft("law-enforcement-requests") + self.assertEqual(source, "law-enforcement-requests") + self.assertEqual(seen, {}) + + def test_it_is_composed_from_the_standing_brief(self): + source, seen, session = self.run_draft("reporting-illegal-content") + self.assertEqual(source, "abuse_report") + self.assertIn("communities Session itself operates", seen["brief"]) + note = session.calls[1][2]["json"]["ticket"]["comment"] + self.assertIs(note["public"], False) + self.assertIn("Standing brief:", note["html_body"]) + self.assertIn(note_reply.auto_marker("abuse_report"), note["html_body"]) + + +class DraftKnown(unittest.TestCase): + def test_a_transport_error_never_stops_the_digest(self): + def down(*a, **k): + raise requests.ConnectionError("down") + tickets = [{"id": n, "tags": []} for n in (1, 2, 3, 4)] + findings = [{"id": n, "category": "bug_report"} for n in (1, 2, 3, 4)] + with tempfile.TemporaryDirectory() as tmp, \ + Patched(note_reply, load_house=lambda: BOOK, + api_user_id=lambda *a: API_USER), \ + Patched(auto_draft, refresh_cache=lambda s, sd, u, c, t: c), \ + Patched(api, fetch_comments=down): + drafted = auto_draft.draft_known(None, "sub", tickets, findings, + os.path.join(tmp, "k.json")) + self.assertEqual(drafted, []) + + def test_a_failed_cache_refresh_skips_drafting(self): + def down(*a, **k): + raise requests.ConnectionError("down") + with Patched(note_reply, load_house=lambda: BOOK, + api_user_id=lambda *a: API_USER), \ + Patched(auto_draft, refresh_cache=down): + self.assertEqual(auto_draft.draft_known(None, "sub", [], [], "/nonexistent"), + []) + + +class AfterAnUnpromptedDraft(unittest.TestCase): + def auto_note(self): + note = note_reply.build_draft_note(GERMAN, "need the logs", None, + unprompted=("a reply", "Brief:", 99)) + return posted_note(note, cid=10) + + def test_the_note_is_not_a_command(self): + text = note_reply.comment_text(self.auto_note()) + self.assertIsNone(note_reply.parse_command(text)) + + def test_reply_sends_its_option(self): + self.assertEqual(note_reply.find_draft([self.auto_note()], API_USER), + {1: GERMAN["options"][0]["translated"]}) + + def test_a_new_brief_replaces_it_rather_than_amending_it(self): + """Amending keeps the wording an agent approved, and nobody approved this one.""" + seen = {} + + def fake(sample, brief, previous=None, precedent=None, thread=None): + seen.update(previous=previous) + return GERMAN + with Patched(note_reply, compose=fake): + note_reply.run_draft( + fake_session(FakeResponse({"ticket": {}})), "sub", + {"id": 7, "requester_id": CUSTOMER}, + [comment("claude: draft - x", cid=11), self.auto_note()], + {"id": 11, "author": AGENT, "action": "draft", "brief": "x"}, + API_USER, dry_run=False) + self.assertIsNone(seen["previous"]) + + def test_a_reviewed_draft_is_still_amended(self): + seen = {} + + def fake(sample, brief, previous=None, precedent=None, thread=None): + seen.update(previous=previous) + return GERMAN + with Patched(note_reply, compose=fake): + note_reply.run_draft( + fake_session(FakeResponse({"ticket": {}})), "sub", + {"id": 7, "requester_id": CUSTOMER}, + [comment("claude: draft - x", cid=11), draft_note("Erster", cid=10)], + {"id": 11, "author": AGENT, "action": "draft", "brief": "x"}, + API_USER, dry_run=False) + self.assertIn("Erster", seen["previous"]) + + +class DigestLine(unittest.TestCase): + def test_the_digest_lists_the_drafted_tickets(self): + line, covers = triage.build_drafted_line([5, 6], "sub") + self.assertIn("**2** drafted", line) + self.assertIn("claude: reply N", line) + self.assertEqual(covers, set()) + + def test_no_drafts_no_line(self): + self.assertIsNone(triage.build_drafted_line([], "sub")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/zendesk/test_digest_job.py b/tests/zendesk/test_digest_job.py index 34667dd..2f53eed 100644 --- a/tests/zendesk/test_digest_job.py +++ b/tests/zendesk/test_digest_job.py @@ -41,6 +41,11 @@ def test_the_resolver_runs_before_the_digest(self): self.assertEqual(calls[0][1], ["--apply"]) self.assertIn("--state", calls[1][1]) + def test_the_brief_cache_sits_beside_the_dedup_state(self): + _, calls = self.run_job(["--state", "/s/seen.json"]) + argv = calls[1][1] + self.assertEqual(argv[argv.index("--known-briefs") + 1], "/s/known_briefs.json") + def test_a_failed_resolve_is_reported_and_does_not_cost_the_digest(self): outcome, calls = self.run_job([], resolver=SystemExit("Zendesk returned 500")) self.assertEqual(len(calls), 2) diff --git a/tests/zendesk/test_note_reply.py b/tests/zendesk/test_note_reply.py index 94fc94c..85414df 100644 --- a/tests/zendesk/test_note_reply.py +++ b/tests/zendesk/test_note_reply.py @@ -1287,22 +1287,45 @@ def test_the_brief_is_bounded(self): class Model(unittest.TestCase): - def run_main(self, action, flag=()): + def run_main(self, action, flag=(), ticket=None, open_chat=False): env = {"ZENDESK_SUBDOMAIN": "sub", "ZENDESK_EMAIL": "e", "ZENDESK_API_TOKEN": "t"} command = {"id": 9, "author": AGENT, "action": action, "brief": "x"} ran = [] with mock.patch.dict(os.environ, env), \ Patched(api, zendesk_session=lambda *a: object(), - fetch_ticket=lambda *a: {"id": 7, "status": "open"}, - fetch_comments=lambda *a, **k: []), \ + fetch_ticket=lambda *a: ticket or {"id": 7, "status": "open"}, + fetch_comments=lambda *a, **k: [], + has_open_chat=lambda *a: open_chat), \ Patched(note_reply, api_user_id=lambda *a: API_USER, latest_command=lambda *a: command, + say=lambda *a, **k: ran.append(("said", a[4])), run_draft=lambda *a: ran.append("draft"), + run_reply=lambda *a: ran.append("reply"), run_solve=lambda *a: ran.append("solve")), \ Patched(claude_api, model=lambda: sys.exit("Claude API is down")): note_reply.main(["--ticket", "7", *flag]) return ran + DM = {"id": 7, "status": "open", "via": {"channel": "sunshine_conversations_twitter_dm"}} + + def test_an_open_chat_refuses_what_reads_the_customer(self): + for action in note_reply.READS_CUSTOMER: + with self.subTest(action=action): + self.assertEqual(self.run_main(action, ticket=self.DM, open_chat=True), + [("said", note_reply.OPEN_CHAT_REFUSAL)]) + + def test_an_open_chat_does_not_block_solve(self): + self.assertEqual(self.run_main("solve", ticket=self.DM, open_chat=True), ["solve"]) + + def test_an_ended_or_unknown_chat_lets_the_command_run(self): + for state in (False, None): + with self.subTest(state=state): + self.assertEqual(self.run_main("reply", ticket=self.DM, open_chat=state), + ["reply"]) + + def test_a_non_messaging_ticket_is_never_checked(self): + self.assertEqual(self.run_main("draft", open_chat=True), ["draft"]) + def test_a_command_that_needs_no_claude_runs_without_the_model_lookup(self): self.assertEqual(self.run_main("solve"), ["solve"]) diff --git a/tests/zendesk/test_triage.py b/tests/zendesk/test_triage.py index fd112c5..d978eb7 100644 --- a/tests/zendesk/test_triage.py +++ b/tests/zendesk/test_triage.py @@ -9,6 +9,7 @@ tests drive fetch_tickets with a stub session instead. """ import contextlib +import io import json import os import tempfile @@ -785,6 +786,38 @@ def test_no_line_when_every_ticket_is_flagged(self): self.assertEqual(self.lines([finding(1)]), []) +class TestOpenChatLine(unittest.TestCase): + """Tickets whose newest customer message is stuck in a live chat.""" + + def test_it_lists_the_swept_tickets_first(self): + findings = [finding(1), finding(2, language="German")] + messages, _ = triage.build_messages(findings, "acme", {"open_chats": {28835, 26946}}) + lines = [l for l in digest_text(messages).splitlines() if "[#" in l] + self.assertIn("**2** live chats still open", lines[0]) + self.assertLess(lines[0].index("[#26946]"), lines[0].index("[#28835]")) + + def test_it_covers_no_ticket(self): + _, coverage = triage.build_messages([finding(1)], "acme", {"open_chats": {9}}) + self.assertNotIn(9, set().union(*coverage)) + + def test_no_open_chat_no_line(self): + self.assertNotIn("live chat", digest_text(triage.build_messages([finding(1)], "acme", {})[0])) + + def test_the_sweep_keeps_only_tickets_with_an_open_chat(self): + search = FakeResponse({"count": 2, "results": [ + {"result_type": "ticket", "id": 1}, {"result_type": "ticket", "id": 2}]}) + session = FakeSession([ + search, + FakeResponse({"audits": [{"events": [{"type": "ChatStartedEvent"}]}]}), + FakeResponse({"audits": [{"events": [{"type": "ChatEndedEvent"}]}]})]) + self.assertEqual(triage.find_open_chats(session, "acme"), {1}) + + def test_a_failed_sweep_does_not_stop_the_digest(self): + session = FakeSession([FakeResponse({}, status_code=500)]) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(triage.find_open_chats(session, "acme"), set()) + + class TestNonEnglishLine(unittest.TestCase): """The new tickets not in English, linked on one line for `claude: english`."""