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:
0
zerver/webhooks/gong/__init__.py
Normal file
0
zerver/webhooks/gong/__init__.py
Normal file
46
zerver/webhooks/gong/doc.md
Normal file
46
zerver/webhooks/gong/doc.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Zulip Gong integration
|
||||
|
||||
Get notifications about completed Gong calls in Zulip!
|
||||
|
||||
{start_tabs}
|
||||
|
||||
1. {!create-an-incoming-webhook.md!}
|
||||
|
||||
1. {!generate-webhook-url-basic.md!}
|
||||
|
||||
1. From your Gong dashboard, navigate to **Admin center > Settings >
|
||||
Ecosystem > Automation rules**. Then, click **+ Add rule** to create a
|
||||
new automation rule for sending call data to Zulip.
|
||||
|
||||
1. Configure the [rule filters][gong-call-search] to specify which calls
|
||||
should trigger the webhook, and click **Save**.
|
||||
|
||||
1. In the **Action** dropdown, **Fire webhook** should be selected by
|
||||
default. Enter the webhook URL generated above, and select **URL
|
||||
includes key** for the authentication method.
|
||||
|
||||
1. Enter a name (e.g., "Zulip") and description for the automation rule.
|
||||
|
||||
1. Test the automation by clicking **Test now** with the preselected call
|
||||
data. Alternatively, you can choose to **Select a different call** for
|
||||
the test message.
|
||||
|
||||
1. Enable the rule by toggling the **Rule Status** switch.
|
||||
|
||||
{end_tabs}
|
||||
|
||||
{!congrats.md!}
|
||||
|
||||

|
||||
|
||||
|
||||
### Related documentation
|
||||
|
||||
- [Gong's webhook documentation][gong-webhook-rule]
|
||||
|
||||
- [Filtering Gong calls][gong-call-search]
|
||||
|
||||
{!webhooks-url-specification.md!}
|
||||
|
||||
[gong-webhook-rule]: https://help.gong.io/docs/create-a-webhook-rule
|
||||
[gong-call-search]: https://help.gong.io/docs/search-for-calls
|
||||
299
zerver/webhooks/gong/fixtures/call_completed.json
Normal file
299
zerver/webhooks/gong/fixtures/call_completed.json
Normal file
@@ -0,0 +1,299 @@
|
||||
{
|
||||
"isTest": false,
|
||||
"callData": {
|
||||
"metaData": {
|
||||
"id": "5599332235511222779",
|
||||
"url": "http://local.gong-it.net:8080/call?id=5599332235511222771",
|
||||
"title": "Sales team meeting",
|
||||
"scheduled": "2019-10-18T14:00:00-07:00",
|
||||
"started": "2019-10-18T14:03:37.041977-07:00",
|
||||
"duration": 2159,
|
||||
"primaryUserId": "7744111777663220493",
|
||||
"direction": "Conference",
|
||||
"system": "Zoom",
|
||||
"scope": "External",
|
||||
"media": "Video",
|
||||
"language": "eng"
|
||||
},
|
||||
"context": [
|
||||
{
|
||||
"system": "Salesforce",
|
||||
"objects": [
|
||||
{
|
||||
"objectType": "Opportunity",
|
||||
"objectId": "0061Q00000mkU1fQAE",
|
||||
"fields": [
|
||||
{
|
||||
"name": "StageName",
|
||||
"value": "Open Renewal"
|
||||
},
|
||||
{
|
||||
"name": "Number_of_seats__c",
|
||||
"value": 28
|
||||
},
|
||||
{
|
||||
"name": "Amount",
|
||||
"value": 50000
|
||||
},
|
||||
{
|
||||
"name": "Probability",
|
||||
"value": 85
|
||||
},
|
||||
{
|
||||
"name": "CloseDate",
|
||||
"value": "2019-12-30"
|
||||
},
|
||||
{
|
||||
"name": "ARR_impact_rollup__c",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "Name",
|
||||
"value": "Acme - Renewal (28)"
|
||||
},
|
||||
{
|
||||
"name": "Type",
|
||||
"value": "Renewal"
|
||||
},
|
||||
{
|
||||
"name": "Primary_contact_persona__c",
|
||||
"value": "Manager of Sales"
|
||||
},
|
||||
{
|
||||
"name": "ARR_future_impact__c",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "MRR__c",
|
||||
"value": 4166.67
|
||||
},
|
||||
{
|
||||
"name": "Must_Win__c",
|
||||
"value": "false"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"objectType": "Account",
|
||||
"objectId": "0011630000vmLBaEEN",
|
||||
"fields": [
|
||||
{
|
||||
"name": "Industry",
|
||||
"value": "Internet Software & Services"
|
||||
},
|
||||
{
|
||||
"name": "Acme2__c",
|
||||
"value": 1121
|
||||
},
|
||||
{
|
||||
"name": "ARR__c",
|
||||
"value": 25166.67
|
||||
},
|
||||
{
|
||||
"name": "CS_Tier__c",
|
||||
"value": "3. Gold"
|
||||
},
|
||||
{
|
||||
"name": "Account_Behavior_Score__c",
|
||||
"value": 5150
|
||||
},
|
||||
{
|
||||
"name": "Website",
|
||||
"value": "acme.com"
|
||||
},
|
||||
{
|
||||
"name": "Sales_Team_Size__c",
|
||||
"value": "232"
|
||||
},
|
||||
{
|
||||
"name": "Score__c",
|
||||
"value": 120
|
||||
},
|
||||
{
|
||||
"name": "Size_Category__c",
|
||||
"value": "2. Enterprise"
|
||||
},
|
||||
{
|
||||
"name": "Name",
|
||||
"value": "Acme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"parties": [
|
||||
{
|
||||
"id": "7409609343412403343",
|
||||
"emailAddress": "bwilliams@example.com",
|
||||
"name": "Bo Williams",
|
||||
"title": "Sales Enablement Manager - Sales Development",
|
||||
"speakerId": "1177336688551133887",
|
||||
"context": [
|
||||
{
|
||||
"system": "Salesforce",
|
||||
"objects": [
|
||||
{
|
||||
"objectType": "Contact",
|
||||
"objectId": "0331Z0000571AdPQDU",
|
||||
"fields": [
|
||||
{
|
||||
"name": "AccountOwnerId__c",
|
||||
"value": "0551Z00000KdYa3"
|
||||
},
|
||||
{
|
||||
"name": "Account_Name__c",
|
||||
"value": "Acme"
|
||||
},
|
||||
{
|
||||
"name": "Account_Status__c",
|
||||
"value": "Closed Won ARR: $34166.67"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"affiliation": "External",
|
||||
"methods": [
|
||||
"Invitee",
|
||||
"Attendee"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "9048546196233268852",
|
||||
"emailAddress": "klin@example.com",
|
||||
"name": "Kevin Lin",
|
||||
"title": "Customer Success Manager",
|
||||
"userId": "3022101605611220443",
|
||||
"speakerId": "7265406288145283521",
|
||||
"context": [
|
||||
{
|
||||
"system": "Salesforce",
|
||||
"objects": [
|
||||
{
|
||||
"objectType": "User",
|
||||
"objectId": "00557000007AJB1DDO",
|
||||
"fields": [
|
||||
{
|
||||
"name": "PlayGongVoicePrompt__c",
|
||||
"value": "false"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"affiliation": "Internal",
|
||||
"methods": [
|
||||
"Attendee"
|
||||
]
|
||||
}
|
||||
],
|
||||
"content": {
|
||||
"trackers": [
|
||||
{
|
||||
"name": "Products",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"name": "Renewal (positive)",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"name": "Competitors",
|
||||
"count": 1,
|
||||
"phrases": [
|
||||
{
|
||||
"count": 1,
|
||||
"phrase": "Zlymbo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CRM",
|
||||
"count": 3,
|
||||
"phrases": [
|
||||
{
|
||||
"count": 3,
|
||||
"phrase": "Salesforce"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"topics": [
|
||||
{
|
||||
"name": "Integrations",
|
||||
"duration": 517
|
||||
},
|
||||
{
|
||||
"name": "Differentiation",
|
||||
"duration": 203
|
||||
},
|
||||
{
|
||||
"name": "Customer Success",
|
||||
"duration": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"interaction": {
|
||||
"speakers": [
|
||||
{
|
||||
"id": "9048546196233268852",
|
||||
"userId": "7041101707613290443",
|
||||
"talkTime": 1032
|
||||
}
|
||||
],
|
||||
"interactionStats": [
|
||||
{
|
||||
"name": "Talk Ratio",
|
||||
"value": 0.51
|
||||
},
|
||||
{
|
||||
"name": "Longest Monologue",
|
||||
"value": 150
|
||||
},
|
||||
{
|
||||
"name": "Longest Customer Story",
|
||||
"value": 107
|
||||
},
|
||||
{
|
||||
"name": "Interactivity",
|
||||
"value": 5.07
|
||||
},
|
||||
{
|
||||
"name": "Patience",
|
||||
"value": 0.86
|
||||
}
|
||||
],
|
||||
"video": [
|
||||
{
|
||||
"name": "Browser",
|
||||
"duration": 0
|
||||
},
|
||||
{
|
||||
"name": "Presentation",
|
||||
"duration": 0
|
||||
},
|
||||
{
|
||||
"name": "Webcam",
|
||||
"duration": 1091.83
|
||||
}
|
||||
]
|
||||
},
|
||||
"collaboration": {
|
||||
"publicComments": [
|
||||
{
|
||||
"id": "2255211009900224213",
|
||||
"audioStartTime": 1800.8911,
|
||||
"audioEndTime": 1800.8911,
|
||||
"commenterUserId": "7041101707613290443",
|
||||
"comment": "Might be helpful to loop us in on Outreach issue with Outreach team",
|
||||
"posted": "2019-10-19T00:34:39.113071+03:00",
|
||||
"inReplyTo": null,
|
||||
"duringCall": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
362
zerver/webhooks/gong/tests.py
Normal file
362
zerver/webhooks/gong/tests.py
Normal file
@@ -0,0 +1,362 @@
|
||||
import orjson
|
||||
|
||||
from zerver.lib.test_classes import WebhookTestCase
|
||||
from zerver.lib.validator import wrap_wild_value
|
||||
from zerver.webhooks.fixtureless_integrations import BO_EMAIL, BO_NAME, KEVIN_EMAIL, KEVIN_NAME
|
||||
from zerver.webhooks.gong.view import (
|
||||
format_duration_to_string,
|
||||
get_participants,
|
||||
get_public_comments,
|
||||
get_topics,
|
||||
get_trackers,
|
||||
)
|
||||
|
||||
|
||||
class GongHookTests(WebhookTestCase):
|
||||
CALL_TITLE = "Sales team meeting"
|
||||
EXPECTED_TOPIC = f"Gong call: {CALL_TITLE}"
|
||||
MESSAGE_INTRO = "**[{title}](http://local.gong-it.net:8080/call?id=5599332235511222771)** started at <time:2019-10-18T14:03:37-07:00> and lasted for 36 minutes."
|
||||
PARTICIPANTS_SECTION = (
|
||||
"**Participants**:\n"
|
||||
f"* {BO_NAME}: Sales Enablement Manager - Sales Development ({BO_EMAIL})\n"
|
||||
f"* {KEVIN_NAME}: Customer Success Manager ({KEVIN_EMAIL})"
|
||||
)
|
||||
TRACKERS_SECTION = (
|
||||
"**Top Trackers (by count)**:\n"
|
||||
"* Renewal (positive) (7)\n"
|
||||
"* CRM (3): Salesforce (3)\n"
|
||||
"* Products (1)"
|
||||
)
|
||||
TOPICS_SECTION = (
|
||||
"**Topics**: Integrations (9 minutes), Differentiation (3 minutes), Customer Success."
|
||||
)
|
||||
EXPECTED_CALL_MESSAGE = (
|
||||
f"{MESSAGE_INTRO}\n\n{PARTICIPANTS_SECTION}\n\n{TRACKERS_SECTION}\n\n{TOPICS_SECTION}"
|
||||
)
|
||||
|
||||
def test_gong_normal_call_payload(self) -> None:
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
self.EXPECTED_CALL_MESSAGE.format(title=self.CALL_TITLE),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_gong_test_call(self) -> None:
|
||||
self.subscribe(self.test_user, self.channel_name)
|
||||
payload = orjson.loads(self.get_body("call_completed"))
|
||||
payload["isTest"] = True
|
||||
msg = self.send_webhook_payload(
|
||||
self.test_user,
|
||||
self.url,
|
||||
orjson.dumps(payload).decode(),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assert_channel_message(
|
||||
message=msg,
|
||||
channel_name=self.channel_name,
|
||||
topic_name="Gong Test",
|
||||
content=f"Gong webhook has been successfully configured.\n\n{self.EXPECTED_CALL_MESSAGE.format(title=self.CALL_TITLE)}",
|
||||
)
|
||||
|
||||
def test_call_without_title(self) -> None:
|
||||
self.subscribe(self.test_user, self.channel_name)
|
||||
payload = orjson.loads(self.get_body("call_completed"))
|
||||
payload["callData"]["metaData"].pop("title")
|
||||
msg = self.send_webhook_payload(
|
||||
self.test_user,
|
||||
self.url,
|
||||
orjson.dumps(payload).decode(),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assert_channel_message(
|
||||
message=msg,
|
||||
channel_name=self.channel_name,
|
||||
topic_name="Gong call (ID: 5599332235511222779)",
|
||||
content=self.EXPECTED_CALL_MESSAGE.format(title="Gong call"),
|
||||
)
|
||||
|
||||
def test_call_without_participants(self) -> None:
|
||||
self.subscribe(self.test_user, self.channel_name)
|
||||
payload = orjson.loads(self.get_body("call_completed"))
|
||||
payload["callData"]["parties"] = []
|
||||
msg = self.send_webhook_payload(
|
||||
self.test_user,
|
||||
self.url,
|
||||
orjson.dumps(payload).decode(),
|
||||
content_type="application/json",
|
||||
)
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.assert_channel_message(
|
||||
message=msg,
|
||||
channel_name=self.channel_name,
|
||||
topic_name=self.EXPECTED_TOPIC,
|
||||
content=f"{intro}\n\n{self.TRACKERS_SECTION}\n\n{self.TOPICS_SECTION}",
|
||||
)
|
||||
|
||||
def test_call_without_trackers(self) -> None:
|
||||
self.subscribe(self.test_user, self.channel_name)
|
||||
payload = orjson.loads(self.get_body("call_completed"))
|
||||
payload["callData"]["content"].pop("trackers")
|
||||
msg = self.send_webhook_payload(
|
||||
self.test_user,
|
||||
self.url,
|
||||
orjson.dumps(payload).decode(),
|
||||
content_type="application/json",
|
||||
)
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.assert_channel_message(
|
||||
message=msg,
|
||||
channel_name=self.channel_name,
|
||||
topic_name=self.EXPECTED_TOPIC,
|
||||
content=f"{intro}\n\n{self.PARTICIPANTS_SECTION}\n\n{self.TOPICS_SECTION}",
|
||||
)
|
||||
|
||||
def test_call_without_topics(self) -> None:
|
||||
self.subscribe(self.test_user, self.channel_name)
|
||||
payload = orjson.loads(self.get_body("call_completed"))
|
||||
payload["callData"]["content"].pop("topics")
|
||||
msg = self.send_webhook_payload(
|
||||
self.test_user,
|
||||
self.url,
|
||||
orjson.dumps(payload).decode(),
|
||||
content_type="application/json",
|
||||
)
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.assert_channel_message(
|
||||
message=msg,
|
||||
channel_name=self.channel_name,
|
||||
topic_name=self.EXPECTED_TOPIC,
|
||||
content=f"{intro}\n\n{self.PARTICIPANTS_SECTION}\n\n{self.TRACKERS_SECTION}",
|
||||
)
|
||||
|
||||
def test_call_with_top_trackers_disabled(self) -> None:
|
||||
self.url = self.build_webhook_url(include_trackers="false")
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
f"{intro}\n\n{self.PARTICIPANTS_SECTION}\n\n{self.TOPICS_SECTION}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_call_with_top_topics_disabled(self) -> None:
|
||||
self.url = self.build_webhook_url(include_topics="false")
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
f"{intro}\n\n{self.PARTICIPANTS_SECTION}\n\n{self.TRACKERS_SECTION}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_call_with_participants_disabled(self) -> None:
|
||||
self.url = self.build_webhook_url(include_participants="false")
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
f"{intro}\n\n{self.TRACKERS_SECTION}\n\n{self.TOPICS_SECTION}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_call_with_participant_contacts_disabled(self) -> None:
|
||||
self.url = self.build_webhook_url(include_participant_contacts="false")
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
participants = (
|
||||
"**Participants**:\n"
|
||||
f"* {BO_NAME}: Sales Enablement Manager - Sales Development\n"
|
||||
f"* {KEVIN_NAME}: Customer Success Manager"
|
||||
)
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
f"{intro}\n\n{participants}\n\n{self.TRACKERS_SECTION}\n\n{self.TOPICS_SECTION}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_call_with_participants_off_but_contacts_on(self) -> None:
|
||||
self.url = self.build_webhook_url(
|
||||
include_participants="false", include_participant_contacts="true"
|
||||
)
|
||||
intro = self.MESSAGE_INTRO.format(title=self.CALL_TITLE)
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
f"{intro}\n\n{self.TRACKERS_SECTION}\n\n{self.TOPICS_SECTION}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_call_with_public_comments_enabled(self) -> None:
|
||||
self.url = self.build_webhook_url(include_public_comments="true")
|
||||
comments = (
|
||||
"**Comments**:\n* Might be helpful to loop us in on Outreach issue with Outreach team"
|
||||
)
|
||||
self.check_webhook(
|
||||
"call_completed",
|
||||
self.EXPECTED_TOPIC,
|
||||
f"{self.EXPECTED_CALL_MESSAGE.format(title=self.CALL_TITLE)}\n\n{comments}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_get_participants(self) -> None:
|
||||
name = KEVIN_NAME
|
||||
title = "Customer Success Manager"
|
||||
email = KEVIN_EMAIL
|
||||
phone = "5556543210"
|
||||
test_cases = [
|
||||
([{"name": name}], f"* {name}"),
|
||||
([{"name": name, "title": title}], f"* {name}: {title}"),
|
||||
([{"name": name, "emailAddress": email}], f"* {name} ({email})"),
|
||||
([{"name": name, "phoneNumber": phone}], f"* {name} ({phone})"),
|
||||
(
|
||||
[{"name": name, "title": title, "emailAddress": email}],
|
||||
f"* {name}: {title} ({email})",
|
||||
),
|
||||
(
|
||||
[{"name": name, "title": title, "phoneNumber": phone}],
|
||||
f"* {name}: {title} ({phone})",
|
||||
),
|
||||
(
|
||||
[{"name": name, "emailAddress": email, "phoneNumber": phone}],
|
||||
f"* {name} ({email}, {phone})",
|
||||
),
|
||||
(
|
||||
[{"name": name, "title": title, "emailAddress": email, "phoneNumber": phone}],
|
||||
f"* {name}: {title} ({email}, {phone})",
|
||||
),
|
||||
([{"emailAddress": email}], f"* {email}"),
|
||||
([{"phoneNumber": phone}], f"* {phone}"),
|
||||
([{"emailAddress": email, "phoneNumber": phone}], f"* {email} ({phone})"),
|
||||
(
|
||||
[{"title": title, "emailAddress": email, "phoneNumber": phone}],
|
||||
f"* {email}: {title} ({phone})",
|
||||
),
|
||||
([{"affiliation": "External"}], "* 1 participant"),
|
||||
(
|
||||
[{"affiliation": "External"}] * 3,
|
||||
"* 3 participants",
|
||||
),
|
||||
(
|
||||
[{"name": f"Person {i}"} for i in range(6)],
|
||||
"* Person 0\n* Person 1\n* Person 2\n* Person 3\n* Person 4\n* and 1 more participant",
|
||||
),
|
||||
(
|
||||
[{"name": f"Person {i}"} for i in range(7)],
|
||||
"* Person 0\n* Person 1\n* Person 2\n* Person 3\n* Person 4\n* and 2 more participants",
|
||||
),
|
||||
(
|
||||
[{"name": "Alice"}, {"name": "Bob"}] + [{"affiliation": "External"}] * 2,
|
||||
"* Alice\n* Bob\n* and 2 more participants",
|
||||
),
|
||||
(
|
||||
[{"name": f"Person {i}"} for i in range(6)] + [{"affiliation": "External"}] * 2,
|
||||
"* Person 0\n* Person 1\n* Person 2\n* Person 3\n* Person 4\n* and 3 more participants",
|
||||
),
|
||||
([], ""),
|
||||
]
|
||||
|
||||
for parties, expected_output in test_cases:
|
||||
with self.subTest(parties=parties):
|
||||
payload = wrap_wild_value("parties", parties)
|
||||
self.assertEqual(get_participants(payload, True), expected_output)
|
||||
|
||||
no_contacts_cases = [
|
||||
([{"name": name, "title": title, "emailAddress": email}], f"* {name}: {title}"),
|
||||
([{"name": name, "emailAddress": email, "phoneNumber": phone}], f"* {name}"),
|
||||
(
|
||||
[{"name": "Alice"}, {"emailAddress": email}, {"phoneNumber": phone}],
|
||||
"* Alice\n* and 2 more participants",
|
||||
),
|
||||
([{"title": title, "emailAddress": email}], "* 1 participant"),
|
||||
]
|
||||
for parties, expected_output in no_contacts_cases:
|
||||
with self.subTest(parties=parties, include_participant_contacts=False):
|
||||
payload = wrap_wild_value("parties", parties)
|
||||
self.assertEqual(
|
||||
get_participants(payload, False),
|
||||
expected_output,
|
||||
)
|
||||
|
||||
def test_get_trackers(self) -> None:
|
||||
payload = wrap_wild_value(
|
||||
"trackers",
|
||||
[
|
||||
{"name": "Products", "count": 1},
|
||||
{"name": "Renewal (positive)", "count": 7},
|
||||
{"name": "ERP", "count": 0},
|
||||
{
|
||||
"name": "Pricing",
|
||||
"count": 5,
|
||||
"phrases": [
|
||||
{"count": 2, "phrase": "budget"},
|
||||
{"count": 3, "phrase": "discount"},
|
||||
],
|
||||
},
|
||||
{"name": "CRM", "count": 3, "phrases": [{"count": 3, "phrase": "Salesforce"}]},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
get_trackers(payload),
|
||||
"* Renewal (positive) (7)\n"
|
||||
"* Pricing (5): discount (3), budget (2)\n"
|
||||
"* CRM (3): Salesforce (3)",
|
||||
)
|
||||
|
||||
self.assertEqual(get_trackers(wrap_wild_value("trackers", [])), "")
|
||||
|
||||
def test_get_topics(self) -> None:
|
||||
topics = [
|
||||
{"name": "Small talk", "duration": 0},
|
||||
{"name": "Pricing", "duration": 180},
|
||||
{"name": "Next steps", "duration": 45},
|
||||
{"name": "Revenue", "duration": 0},
|
||||
]
|
||||
self.assertEqual(
|
||||
get_topics(wrap_wild_value("topics", topics)),
|
||||
"Pricing (3 minutes), Next steps (45 seconds), Small talk and 1 more topic.",
|
||||
)
|
||||
|
||||
topics.append({"name": "Wrap-up", "duration": 0})
|
||||
self.assertEqual(
|
||||
get_topics(wrap_wild_value("topics", topics)),
|
||||
"Pricing (3 minutes), Next steps (45 seconds), Small talk and 2 more topics.",
|
||||
)
|
||||
|
||||
self.assertEqual(get_topics(wrap_wild_value("topics", [])), "")
|
||||
|
||||
def test_get_public_comments(self) -> None:
|
||||
payload = wrap_wild_value(
|
||||
"publicComments",
|
||||
[
|
||||
{"comment": "Third", "posted": "2019-10-19T03:00:00+00:00"},
|
||||
{"comment": "First", "posted": "2019-10-19T01:00:00+00:00"},
|
||||
{"comment": "Second", "posted": "2019-10-19T02:00:00+00:00"},
|
||||
{"comment": "Fourth", "posted": "2019-10-19T04:00:00+00:00"},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
get_public_comments(payload),
|
||||
"* Fourth\n* Third\n* Second\n* and 1 more",
|
||||
)
|
||||
|
||||
self.assertEqual(get_public_comments(wrap_wild_value("publicComments", [])), "")
|
||||
|
||||
def test_format_duration_to_string(self) -> None:
|
||||
test_cases = [
|
||||
(0, "0 seconds"),
|
||||
(1, "1 second"),
|
||||
(45, "45 seconds"),
|
||||
(59, "59 seconds"),
|
||||
(60, "1 minute"),
|
||||
(3599, "1 hour"),
|
||||
(3600, "1 hour"),
|
||||
(3645, "1 hour 1 minute"),
|
||||
(3661, "1 hour 1 minute"),
|
||||
(7200, "2 hours"),
|
||||
(7320, "2 hours 2 minutes"),
|
||||
]
|
||||
|
||||
for duration, expected_output in test_cases:
|
||||
with self.subTest(duration=duration):
|
||||
self.assertEqual(format_duration_to_string(duration), expected_output)
|
||||
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