From adc82eeb386087ead6f8a2b904d7c7e5eac33fb9 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 13 Nov 2026 10:55:41 +1100 Subject: [PATCH] fix(zendesk): unassign tickets whose live chat is stuck open Zendesk never ends a messaging chat that started while the ticket had an assignee, and the auto-assign trigger gives every ticket one. A customer writing back on a solved Twitter DM ticket (29557) was therefore visible to nobody but the API user until someone unassigned it by hand. The digest now unassigns every ticket its open-chat sweep finds, so the chat ends and the message lands on the ticket about 10 minutes later. A ticket Zendesk refuses to unassign stays listed for an admin. Dry runs, --no-discord and rehearsals still only list them. --- docs/jobs/zendesk-digest.md | 4 +++ src/session_ops/zendesk/api.py | 15 ++++++++++ src/session_ops/zendesk/triage.py | 49 ++++++++++++++++++++++++------- tests/zendesk/test_triage.py | 22 ++++++++++++++ 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/docs/jobs/zendesk-digest.md b/docs/jobs/zendesk-digest.md index d508ed1..929600b 100644 --- a/docs/jobs/zendesk-digest.md +++ b/docs/jobs/zendesk-digest.md @@ -85,6 +85,10 @@ 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. +## Live chats stuck open + +Zendesk writes a Twitter DM chat onto the ticket only when the chat ends, and it never ends a chat that started while the ticket had an assignee. The auto-assign trigger gives every ticket one, so a customer writing back on a solved DM ticket is invisible to everyone but the assignee. Each run sweeps the unsolved DM tickets for an open chat and unassigns them; the chat then ends about 10 minutes later and the message lands on the ticket. The digest lists them under 💬 and records none of them, so each is reported again with its newest message. A ticket Zendesk refused to unassign is listed apart, for an admin. `--dry-run`, `--no-discord` and a rehearsal list them without unassigning. + ## Drafting the known answers Before posting, the digest drafts a private reply note on each ticket it reports that diff --git a/src/session_ops/zendesk/api.py b/src/session_ops/zendesk/api.py index 368db7b..37290fa 100644 --- a/src/session_ops/zendesk/api.py +++ b/src/session_ops/zendesk/api.py @@ -234,6 +234,21 @@ def has_open_chat(session, subdomain, ticket_id): return False +def unassign(session, subdomain, ticket_id): + """Clear the ticket's assignee, which is what lets Zendesk end its live chat. + Returns whether Zendesk took it.""" + url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json" + try: + resp = session.request("PUT", url, json={"ticket": {"assignee_id": None}}) + except requests.RequestException as exc: + print(f"Note: could not unassign #{ticket_id} ({exc}).") + return False + if resp.status_code >= 400: + print(f"Note: unassigning #{ticket_id} returned {resp.status_code}.") + return False + return True + + STAR_SUBJECT = re.compile(r"^\s*([★☆]{1,10})") diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index db69fd5..659e42a 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -74,7 +74,7 @@ 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, has_open_chat, + fetch_tickets, has_open_chat, unassign, hydrate_requester_activity, is_store_review, review_platform, review_stars, ticket_url, zendesk_session) @@ -518,6 +518,16 @@ def find_open_chats(session, subdomain): return {t["id"] for t in tickets if has_open_chat(session, subdomain, t["id"])} +def release_open_chats(session, subdomain, ticket_ids): + """Unassign each ticket so Zendesk ends its chat and writes the customer's newest + message onto it, about 10 minutes later. Returns the ids Zendesk accepted. + + Zendesk never ends a chat that started while the ticket had an assignee, and the + auto-assign trigger gives every ticket one. + """ + return {tid for tid in ticket_ids if unassign(session, subdomain, tid)} + + # ---- The classification request --------------------------------------------- @@ -838,21 +848,34 @@ def build_non_english_line(findings, subdomain, updated_ids): f"{', '.join(links)}", set()) -def build_open_chat_line(ticket_ids, subdomain): +def build_open_chat_line(ticket_ids, subdomain, released=()): """The tickets whose live chat is still open, so nobody but the assignee can read the customer's newest message. None when there are none. - 💬 **2** live chats still open — newest message not on the ticket until an - admin unassigns it: #28835, #26946 + 💬 **2** live chats still open: unassigned, so the newest message reaches the + ticket in about 10 minutes: #28835, #26946 - Covers no ticket: one stays listed every run until its chat ends. + A ticket the run could not unassign is listed apart, for an admin. Covers no + ticket: the conversation it was classified from is missing its newest message. """ if not ticket_ids: return None - links = ", ".join(f"[#{tid}]({ticket_url(subdomain, tid)})" for tid in sorted(ticket_ids)) - plural = "" if len(ticket_ids) == 1 else "s" - return (f"{OPEN_CHAT_MARKER} **{len(ticket_ids)}** live chat{plural} still open — " - f"newest message not on the ticket until an admin unassigns it: {links}", set()) + + def part(ids, outcome): + links = ", ".join(f"[#{tid}]({ticket_url(subdomain, tid)})" for tid in sorted(ids)) + plural = "" if len(ids) == 1 else "s" + return f"{OPEN_CHAT_MARKER} **{len(ids)}** live chat{plural} still open: {outcome}: {links}" + + released = set(released) & set(ticket_ids) + stuck = set(ticket_ids) - released + parts = [] + if released: + parts.append(part(released, "unassigned, so the newest message reaches the ticket " + "in about 10 minutes")) + if stuck: + parts.append(part(stuck, "newest message not on the ticket until an admin " + "unassigns it")) + return "\n".join(parts), set() def build_drafted_line(ticket_ids, subdomain): @@ -992,7 +1015,8 @@ 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) + open_chats = build_open_chat_line((stats or {}).get("open_chats"), subdomain, + (stats or {}).get("released_chats") or ()) non_english = build_non_english_line(findings, subdomain, updated_ids) 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. @@ -1126,6 +1150,11 @@ def main(argv=None): 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.") + # A counts-only run must not write to tickets. + if not (args.dry_run or args.no_discord or rehearsing()): + stats["released_chats"] = release_open_chats(zd, subdomain, + stats["open_chats"]) + print(f"Unassigned {len(stats['released_chats'])} of them.") # Drop positive store reviews before anything expensive: they were 59% of all # tickets in the sample and never actionable. diff --git a/tests/zendesk/test_triage.py b/tests/zendesk/test_triage.py index d978eb7..c896992 100644 --- a/tests/zendesk/test_triage.py +++ b/tests/zendesk/test_triage.py @@ -794,8 +794,30 @@ def test_it_lists_the_swept_tickets_first(self): 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.assertIn("until an admin unassigns it", lines[0]) self.assertLess(lines[0].index("[#26946]"), lines[0].index("[#28835]")) + def test_released_tickets_are_listed_apart_from_stuck_ones(self): + stats = {"open_chats": {1, 2, 3}, "released_chats": {1, 3}} + messages, _ = triage.build_messages([finding(9)], "acme", stats) + lines = [l for l in digest_text(messages).splitlines() if "live chat" in l] + self.assertEqual(len(lines), 2) + self.assertIn("**2** live chats still open: unassigned", lines[0]) + self.assertIn("[#1]", lines[0]) + self.assertIn("[#3]", lines[0]) + self.assertIn("**1** live chat still open: newest message not on the ticket", lines[1]) + self.assertIn("[#2]", lines[1]) + + def test_release_unassigns_each_and_keeps_what_zendesk_took(self): + session = FakeSession([FakeResponse({}), FakeResponse({}, status_code=403)]) + with contextlib.redirect_stdout(io.StringIO()): + released = triage.release_open_chats(session, "acme", [4, 5]) + self.assertEqual(released, {4}) + method, url, kwargs = session.calls[0] + self.assertEqual(method, "PUT") + self.assertTrue(url.endswith("/tickets/4.json")) + self.assertEqual(kwargs["json"], {"ticket": {"assignee_id": None}}) + def test_it_covers_no_ticket(self): _, coverage = triage.build_messages([finding(1)], "acme", {"open_chats": {9}}) self.assertNotIn(9, set().union(*coverage))