Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
249 lines
8.8 KiB
Python
249 lines
8.8 KiB
Python
from django.http import HttpRequest, HttpResponse
|
|
from pydantic import Json
|
|
|
|
from zerver.decorator import webhook_view
|
|
from zerver.lib.response import json_success
|
|
from zerver.lib.timestamp import datetime_to_global_time
|
|
from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint
|
|
from zerver.lib.validator import (
|
|
WildValue,
|
|
check_bool,
|
|
check_int,
|
|
check_iso_datetime,
|
|
check_none_or,
|
|
check_string,
|
|
check_url,
|
|
)
|
|
from zerver.lib.webhooks.common import check_send_webhook_message, get_setup_webhook_message
|
|
from zerver.models import UserProfile
|
|
|
|
TOPIC_TEMPLATE = "Gong call: {call_title}"
|
|
MESSAGE_TEMPLATE = "**[{call_title}]({call_url})** started at {time} and lasted for {duration}."
|
|
SECTION_TEMPLATE = "**{heading}**:\n{body}"
|
|
TOPICS_TEMPLATE = "**Topics**: {topics}"
|
|
TOP_CONTENT_LIMIT = 3
|
|
PARTICIPANTS_LIMIT = 5
|
|
COMMENTS_LIMIT = 3
|
|
|
|
|
|
class Helper:
|
|
def __init__(
|
|
self,
|
|
payload: WildValue,
|
|
include_trackers: bool,
|
|
include_topics: bool,
|
|
include_participants: bool,
|
|
include_participant_contacts: bool,
|
|
include_public_comments: bool,
|
|
) -> None:
|
|
self.payload = payload
|
|
self.include_trackers = include_trackers
|
|
self.include_topics = include_topics
|
|
self.include_participants = include_participants
|
|
self.include_participant_contacts = include_participant_contacts
|
|
self.include_public_comments = include_public_comments
|
|
|
|
|
|
def format_duration_to_string(duration: int) -> str:
|
|
# The duration value is in seconds.
|
|
if duration < 60:
|
|
return f"{duration} {'second' if duration == 1 else 'seconds'}"
|
|
total_minutes = (duration + 30) // 60
|
|
hours, minutes = divmod(total_minutes, 60)
|
|
hour_word = "hour" if hours == 1 else "hours"
|
|
minute_word = "minute" if minutes == 1 else "minutes"
|
|
if hours > 0 and minutes > 0:
|
|
return f"{hours} {hour_word} {minutes} {minute_word}"
|
|
if hours > 0:
|
|
return f"{hours} {hour_word}"
|
|
return f"{minutes} {minute_word}"
|
|
|
|
|
|
def get_participants(parties_payload: WildValue, include_participant_contacts: bool) -> str:
|
|
# Gong may omit a party's name, email, and phone for unidentified callers
|
|
# (https://help.gong.io/docs/uploading-calls-from-a-non-integrated-telephony-system).
|
|
labels: list[str] = []
|
|
unidentified = 0
|
|
for party in parties_payload:
|
|
if len(labels) == PARTICIPANTS_LIMIT:
|
|
break
|
|
name = party.get("name")
|
|
contact_parts = (
|
|
[
|
|
party[key].tame(check_string)
|
|
for key in ("emailAddress", "phoneNumber")
|
|
if party.get(key)
|
|
]
|
|
if include_participant_contacts
|
|
else []
|
|
)
|
|
|
|
label = name.tame(check_string) if name else (contact_parts[0] if contact_parts else None)
|
|
if label is None:
|
|
unidentified += 1
|
|
continue
|
|
title = party.get("title")
|
|
title_part = f": {title.tame(check_string)}" if title else ""
|
|
details = [c for c in contact_parts if c != label]
|
|
contact_part = f" ({', '.join(details)})" if details else ""
|
|
labels.append(f"* {label}{title_part}{contact_part}")
|
|
|
|
if not labels:
|
|
if unidentified:
|
|
noun = "participant" if unidentified == 1 else "participants"
|
|
return f"* {unidentified} {noun}"
|
|
return ""
|
|
|
|
if additional_participants := len(parties_payload) - len(labels):
|
|
noun = "participant" if additional_participants == 1 else "participants"
|
|
labels.append(f"* and {additional_participants} more {noun}")
|
|
return "\n".join(labels)
|
|
|
|
|
|
def get_trackers(trackers_payload: WildValue) -> str:
|
|
trackers = []
|
|
for tracker in trackers_payload:
|
|
if (count := tracker["count"].tame(check_int)) == 0:
|
|
continue
|
|
trackers.append((tracker["name"].tame(check_string), count, tracker.get("phrases")))
|
|
trackers.sort(key=lambda tracker: tracker[1], reverse=True)
|
|
|
|
tracker_lines = []
|
|
for name, count, phrases in trackers[:TOP_CONTENT_LIMIT]:
|
|
line = f"* {name} ({count})"
|
|
if phrases:
|
|
sorted_phrases = sorted(
|
|
(
|
|
(phrase["phrase"].tame(check_string), phrase["count"].tame(check_int))
|
|
for phrase in phrases
|
|
),
|
|
key=lambda phrase: phrase[1],
|
|
reverse=True,
|
|
)
|
|
phrase_text = ", ".join(f"{text} ({n})" for text, n in sorted_phrases)
|
|
line += f": {phrase_text}"
|
|
tracker_lines.append(line)
|
|
return "\n".join(tracker_lines)
|
|
|
|
|
|
def get_topics(topics_payload: WildValue) -> str:
|
|
topics = [
|
|
(topic["name"].tame(check_string), topic["duration"].tame(check_int))
|
|
for topic in topics_payload
|
|
]
|
|
topics.sort(key=lambda topic: topic[1], reverse=True)
|
|
|
|
lines = []
|
|
for name, duration in topics[:TOP_CONTENT_LIMIT]:
|
|
if duration > 0:
|
|
lines.append(f"{name} ({format_duration_to_string(duration)})")
|
|
else:
|
|
lines.append(f"{name}")
|
|
|
|
topics_line = ", ".join(lines) if lines else ""
|
|
if (extra_topics := len(topics) - TOP_CONTENT_LIMIT) > 0:
|
|
topics_line += f" and {extra_topics} more {'topic' if extra_topics == 1 else 'topics'}"
|
|
return topics_line + "." if topics_line else ""
|
|
|
|
|
|
def get_public_comments(comments_payload: WildValue) -> str:
|
|
comments = sorted(
|
|
comments_payload,
|
|
key=lambda comment: comment["posted"].tame(check_iso_datetime),
|
|
reverse=True,
|
|
)
|
|
lines = []
|
|
for comment in comments[:COMMENTS_LIMIT]:
|
|
text = comment["comment"].tame(check_string)
|
|
lines.append(f"* {text}")
|
|
additional_comments = len(comments) - len(lines)
|
|
if additional_comments:
|
|
lines.append(f"* and {additional_comments} more")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def handle_test_message(helper: Helper) -> tuple[str, str]:
|
|
# Gong's "Test now" sends a real, already-processed call, so render the
|
|
# actual message and prefix it with the standard setup confirmation.
|
|
_, body = handle_call_processed_message(helper)
|
|
return (
|
|
"Gong Test",
|
|
f"{get_setup_webhook_message('Gong')}\n\n{body}",
|
|
)
|
|
|
|
|
|
def handle_call_processed_message(helper: Helper) -> tuple[str, str]:
|
|
call_data = helper.payload["callData"]
|
|
meta_data = call_data["metaData"]
|
|
# call_title can be an optional field and can be omitted if not present.
|
|
call_title = meta_data.get("title").tame(check_none_or(check_string))
|
|
topic = (
|
|
TOPIC_TEMPLATE.format(call_title=call_title)
|
|
if call_title
|
|
else f"Gong call (ID: {meta_data['id'].tame(check_string)})"
|
|
)
|
|
|
|
body = MESSAGE_TEMPLATE.format(
|
|
call_title=call_title or "Gong call",
|
|
call_url=meta_data["url"].tame(check_url),
|
|
time=datetime_to_global_time(meta_data["started"].tame(check_iso_datetime)),
|
|
duration=format_duration_to_string(meta_data["duration"].tame(check_int)),
|
|
)
|
|
if helper.include_participants and (
|
|
participants := get_participants(call_data["parties"], helper.include_participant_contacts)
|
|
):
|
|
body += "\n\n" + SECTION_TEMPLATE.format(heading="Participants", body=participants)
|
|
|
|
if content := call_data.get("content"):
|
|
if (
|
|
helper.include_trackers
|
|
and "trackers" in content
|
|
and (top_trackers := get_trackers(content["trackers"]))
|
|
):
|
|
body += "\n\n" + SECTION_TEMPLATE.format(
|
|
heading="Top Trackers (by count)", body=top_trackers
|
|
)
|
|
if (
|
|
helper.include_topics
|
|
and "topics" in content
|
|
and (topics := get_topics(content["topics"]))
|
|
):
|
|
body += "\n\n" + TOPICS_TEMPLATE.format(topics=topics)
|
|
|
|
if (
|
|
helper.include_public_comments
|
|
and (public_comments_list := call_data["collaboration"].get("publicComments"))
|
|
and (public_comments := get_public_comments(public_comments_list))
|
|
):
|
|
body += "\n\n" + SECTION_TEMPLATE.format(heading="Comments", body=public_comments)
|
|
return topic, body
|
|
|
|
|
|
@webhook_view("Gong")
|
|
@typed_endpoint
|
|
def api_gong_webhook(
|
|
request: HttpRequest,
|
|
user_profile: UserProfile,
|
|
*,
|
|
payload: JsonBodyPayload[WildValue],
|
|
include_trackers: Json[bool] = True,
|
|
include_topics: Json[bool] = True,
|
|
include_participants: Json[bool] = True,
|
|
include_participant_contacts: Json[bool] = True,
|
|
include_public_comments: Json[bool] = False,
|
|
) -> HttpResponse:
|
|
helper = Helper(
|
|
payload,
|
|
include_trackers,
|
|
include_topics,
|
|
include_participants,
|
|
include_participant_contacts,
|
|
include_public_comments,
|
|
)
|
|
if payload.get("isTest").tame(check_bool):
|
|
topic, body = handle_test_message(helper)
|
|
else:
|
|
topic, body = handle_call_processed_message(helper)
|
|
check_send_webhook_message(request, user_profile, topic, body)
|
|
return json_success(request)
|