snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
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
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
This commit is contained in:
248
zerver/webhooks/gong/view.py
Normal file
248
zerver/webhooks/gong/view.py
Normal file
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user