From c62bab0fd35465f8e04789fea74bd42a18fad613 Mon Sep 17 00:00:00 2001 From: examples-bot Date: Sat, 4 Apr 2026 16:52:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(examples):=20add=20460=20=E2=80=94=20Webex?= =?UTF-8?q?=20recording=20transcription=20(Node.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node.js/Express server that receives Webex meetingRecording.ready webhooks, downloads the recording audio, and transcribes it with Deepgram nova-3 (speaker diarization + smart formatting). šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../.env.example | 6 + .../.npmrc | 1 + .../README.md | 71 ++ .../package.json | 24 + .../pnpm-lock.yaml | 633 ++++++++++++++++++ .../src/server.js | 190 ++++++ .../tests/test.js | 202 ++++++ 7 files changed, 1127 insertions(+) create mode 100644 examples/460-webex-recording-transcription-node/.env.example create mode 100644 examples/460-webex-recording-transcription-node/.npmrc create mode 100644 examples/460-webex-recording-transcription-node/README.md create mode 100644 examples/460-webex-recording-transcription-node/package.json create mode 100644 examples/460-webex-recording-transcription-node/pnpm-lock.yaml create mode 100644 examples/460-webex-recording-transcription-node/src/server.js create mode 100644 examples/460-webex-recording-transcription-node/tests/test.js diff --git a/examples/460-webex-recording-transcription-node/.env.example b/examples/460-webex-recording-transcription-node/.env.example new file mode 100644 index 0000000..4da278a --- /dev/null +++ b/examples/460-webex-recording-transcription-node/.env.example @@ -0,0 +1,6 @@ +# Deepgram — https://console.deepgram.com/ +DEEPGRAM_API_KEY= + +# Webex — https://developer.webex.com/my-apps +WEBEX_BOT_TOKEN= +WEBEX_WEBHOOK_SECRET= diff --git a/examples/460-webex-recording-transcription-node/.npmrc b/examples/460-webex-recording-transcription-node/.npmrc new file mode 100644 index 0000000..cffe8cd --- /dev/null +++ b/examples/460-webex-recording-transcription-node/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/examples/460-webex-recording-transcription-node/README.md b/examples/460-webex-recording-transcription-node/README.md new file mode 100644 index 0000000..90a1ff9 --- /dev/null +++ b/examples/460-webex-recording-transcription-node/README.md @@ -0,0 +1,71 @@ +# Webex Recording Transcription with Deepgram + +Automatically transcribe Cisco Webex meeting recordings using Deepgram's nova-3 speech-to-text model. When a Webex meeting recording becomes available, this server receives a webhook, downloads the audio, transcribes it with Deepgram, and optionally posts the transcript back to a Webex space. + +## What you'll build + +A Node.js Express server that listens for Webex `meetingRecording.ready` webhooks, downloads the recording audio via the Webex REST API, sends it to Deepgram for transcription with speaker diarization, and logs the formatted transcript. + +## Prerequisites + +- Node.js 18+ +- pnpm +- Deepgram account — [get a free API key](https://console.deepgram.com/) +- Webex account — [create a bot](https://developer.webex.com/my-apps/new/bot) + +## Environment variables + +| Variable | Where to find it | +|----------|-----------------| +| `DEEPGRAM_API_KEY` | [Deepgram console](https://console.deepgram.com/) | +| `WEBEX_BOT_TOKEN` | [Webex Developer Portal → My Apps](https://developer.webex.com/my-apps) — copy the Bot Access Token | +| `WEBEX_WEBHOOK_SECRET` | You choose this value when creating the webhook via the Webex API | + +## Install and run + +```bash +cp .env.example .env +# Fill in your credentials in .env + +pnpm install +pnpm start +``` + +Then register a Webex webhook pointing to your server: + +```bash +curl -X POST https://webexapis.com/v1/webhooks \ + -H "Authorization: Bearer $WEBEX_BOT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Recording Transcription", + "targetUrl": "https://your-server.example.com/webhook", + "resource": "meetingRecordings", + "event": "ready", + "secret": "your-webhook-secret" + }' +``` + +## Key parameters + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `model` | `nova-3` | Deepgram's most accurate general-purpose model | +| `diarize` | `true` | Enables speaker labels for multi-speaker meetings | +| `smart_format` | `true` | Adds punctuation, capitalization, and number formatting | +| `paragraphs` | `true` | Groups transcript into readable paragraphs | + +## How it works + +1. A Webex meeting ends and the recording is processed by Webex +2. Webex sends a `meetingRecording.ready` webhook to this server +3. The server verifies the webhook signature (HMAC-SHA1) +4. It fetches the recording metadata from the Webex Recordings API +5. It downloads the audio file using the temporary direct download link +6. The audio buffer is sent to Deepgram's pre-recorded transcription API +7. The transcript is logged, with speaker labels and paragraph formatting +8. Optionally, the transcript is posted back to a Webex space + +## Starter templates + +[deepgram-starters](https://github.com/orgs/deepgram-starters/repositories) diff --git a/examples/460-webex-recording-transcription-node/package.json b/examples/460-webex-recording-transcription-node/package.json new file mode 100644 index 0000000..7d5b68f --- /dev/null +++ b/examples/460-webex-recording-transcription-node/package.json @@ -0,0 +1,24 @@ +{ + "name": "webex-recording-transcription-node", + "version": "1.0.0", + "description": "Transcribe Webex meeting recordings using Deepgram nova-3", + "main": "src/server.js", + "packageManager": "pnpm@9.6.0", + "scripts": { + "start": "node src/server.js", + "test": "node tests/test.js" + }, + "dependencies": { + "@deepgram/sdk": "5.0.0", + "dotenv": "16.4.7", + "express": "4.22.1" + }, + "pnpm": { + "overrides": { + "path-to-regexp": "0.1.13" + } + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/460-webex-recording-transcription-node/pnpm-lock.yaml b/examples/460-webex-recording-transcription-node/pnpm-lock.yaml new file mode 100644 index 0000000..787cf3d --- /dev/null +++ b/examples/460-webex-recording-transcription-node/pnpm-lock.yaml @@ -0,0 +1,633 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + path-to-regexp: 0.1.13 + +importers: + + .: + dependencies: + '@deepgram/sdk': + specifier: 5.0.0 + version: 5.0.0 + dotenv: + specifier: 16.4.7 + version: 16.4.7 + express: + specifier: 4.22.1 + version: 4.22.1 + +packages: + + '@deepgram/sdk@5.0.0': + resolution: {integrity: sha512-x1wMiOgDGqcLEaQpQBQLTtk5mLbXbYgcBEpp7cfJIyEtqdIGgijCZH+a/esiVp+xIcTYYroTxG47RVppZOHbWw==} + engines: {node: '>=18.0.0'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@deepgram/sdk@5.0.0': + dependencies: + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dotenv@16.4.7: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.13: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.1: {} + + toidentifier@1.0.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + ws@8.20.0: {} diff --git a/examples/460-webex-recording-transcription-node/src/server.js b/examples/460-webex-recording-transcription-node/src/server.js new file mode 100644 index 0000000..eb958fe --- /dev/null +++ b/examples/460-webex-recording-transcription-node/src/server.js @@ -0,0 +1,190 @@ +'use strict'; + +require('dotenv').config(); + +const crypto = require('crypto'); +const express = require('express'); +const { DeepgramClient } = require('@deepgram/sdk'); + +const PORT = process.env.PORT || 3000; + +const REQUIRED_ENV = [ + 'DEEPGRAM_API_KEY', + 'WEBEX_BOT_TOKEN', + 'WEBEX_WEBHOOK_SECRET', +]; + +function createApp() { + const webhookSecret = process.env.WEBEX_WEBHOOK_SECRET; + const botToken = process.env.WEBEX_BOT_TOKEN; + + const deepgram = new DeepgramClient({ apiKey: process.env.DEEPGRAM_API_KEY }); + + const app = express(); + + // Capture raw body for HMAC verification before JSON parsing. + app.use(express.json({ + verify: (req, _res, buf) => { req.rawBody = buf; }, + })); + + // ── Webex webhook endpoint ────────────────────────────────────────────────── + // Webex fires webhooks for events you subscribe to. + // We listen for "meetingRecording.ready" — fired when a meeting recording + // has been processed and is available for download. + app.post('/webhook', async (req, res) => { + // ← THIS verifies the webhook came from Webex by checking the HMAC-SHA1 + // signature in the x-spark-signature header against our shared secret. + const signature = req.headers['x-spark-signature']; + if (webhookSecret && signature) { + const expected = crypto + .createHmac('sha1', webhookSecret) + .update(req.rawBody) + .digest('hex'); + + if (signature !== expected) { + console.error('Invalid webhook signature — rejecting request'); + return res.status(401).json({ error: 'Invalid signature' }); + } + } + + const { resource, event, data } = req.body; + + if (resource !== 'meetingRecordings' || event !== 'ready') { + return res.json({ status: 'ignored', resource, event }); + } + + res.json({ status: 'processing' }); + + try { + await handleRecordingReady(data, deepgram, botToken); + } catch (err) { + console.error('Error processing recording:', err.message); + } + }); + + app.get('/health', (_req, res) => res.json({ status: 'ok' })); + + return app; +} + +// ── Recording handler ────────────────────────────────────────────────────────── +// When a recording is ready, Webex sends the recording ID in the webhook data. +// We fetch the recording details, download the audio, and transcribe with Deepgram. +async function handleRecordingReady(data, deepgram, botToken) { + const recordingId = data.id; + if (!recordingId) { + console.log('No recording ID in webhook data'); + return; + } + + console.log(`\nFetching recording details for: ${recordingId}`); + + // ← THIS fetches the recording metadata including the download URL. + // The Webex Recordings API requires a bot or user token with spark:recordings_read scope. + const detailResp = await fetch( + `https://webexapis.com/v1/recordings/${recordingId}`, + { headers: { Authorization: `Bearer ${botToken}` } } + ); + + if (!detailResp.ok) { + throw new Error(`Failed to fetch recording details: ${detailResp.status} ${await detailResp.text()}`); + } + + const recording = await detailResp.json(); + const topic = recording.topic || recording.meetingId || 'Untitled Meeting'; + console.log(`Processing: "${topic}"`); + console.log(`Format: ${recording.format}, Duration: ${recording.durationSeconds}s`); + + // ← THIS downloads the actual audio/video file from Webex. + // temporaryDirectDownloadLinks.audioDownloadLink is preferred — smaller file, faster transcription. + const downloadUrl = + recording.temporaryDirectDownloadLinks?.audioDownloadLink || + recording.temporaryDirectDownloadLinks?.recordingDownloadLink; + + if (!downloadUrl) { + throw new Error('No download link available — recording may still be processing'); + } + + const downloadResp = await fetch(downloadUrl); + if (!downloadResp.ok) { + throw new Error(`Failed to download recording: ${downloadResp.status}`); + } + + const audioBuffer = Buffer.from(await downloadResp.arrayBuffer()); + console.log(`Downloaded ${(audioBuffer.length / 1024 / 1024).toFixed(1)} MB`); + + // ← THIS sends the audio to Deepgram for transcription. + // transcribeFile takes (buffer, options) — buffer is the first arg. + // diarize: true enables speaker labels — essential for multi-speaker meetings. + // paragraphs: true produces readable paragraph-segmented output. + const result = await deepgram.listen.v1.media.transcribeFile(audioBuffer, { + model: 'nova-3', + smart_format: true, + diarize: true, + paragraphs: true, + tag: 'deepgram-examples', + }); + + // result.results.channels[0].alternatives[0].transcript + const transcript = result.results.channels[0].alternatives[0].transcript; + const paragraphs = result.results.channels[0].alternatives[0].paragraphs; + + console.log(`\n── Transcript: "${topic}" ──`); + console.log(transcript); + + if (paragraphs?.paragraphs) { + console.log(`\n── Paragraphs: ${paragraphs.paragraphs.length} ──`); + } + + const words = result.results.channels[0].alternatives[0].words; + if (words?.length > 0) { + const duration = words.at(-1).end; + console.log(`\nDuration: ${(duration / 60).toFixed(1)} min | Words: ${words.length}`); + } + + // ← THIS posts the transcript back to a Webex space if the recording has a roomId. + // In production you'd resolve the meeting to a space; here we log the transcript. + if (data.roomId && botToken) { + try { + const summary = transcript.length > 7000 + ? transcript.slice(0, 7000) + '\n\n… (truncated)' + : transcript; + + await fetch('https://webexapis.com/v1/messages', { + method: 'POST', + headers: { + Authorization: `Bearer ${botToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + roomId: data.roomId, + markdown: `**Meeting Transcript: ${topic}**\n\n${summary}`, + }), + }); + console.log('Transcript posted to Webex space'); + } catch (err) { + console.error('Failed to post transcript to space:', err.message); + } + } + + return { topic, transcript }; +} + +module.exports = { createApp, handleRecordingReady }; + +if (require.main === module) { + for (const key of REQUIRED_ENV) { + if (!process.env[key]) { + console.error(`Error: ${key} environment variable is not set.`); + console.error('Copy .env.example to .env and add your credentials.'); + process.exit(1); + } + } + + const app = createApp(); + app.listen(PORT, () => { + console.log(`Webex recording transcription server running on port ${PORT}`); + console.log(`Webhook endpoint: POST http://localhost:${PORT}/webhook`); + console.log(`Health check: GET http://localhost:${PORT}/health`); + }); +} diff --git a/examples/460-webex-recording-transcription-node/tests/test.js b/examples/460-webex-recording-transcription-node/tests/test.js new file mode 100644 index 0000000..8a7ff8d --- /dev/null +++ b/examples/460-webex-recording-transcription-node/tests/test.js @@ -0,0 +1,202 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const http = require('http'); + +// ── Credential check — MUST be first ────────────────────────────────────────── +const envExample = path.join(__dirname, '..', '.env.example'); +const required = fs.readFileSync(envExample, 'utf8') + .split('\n') + .filter(l => /^[A-Z][A-Z0-9_]+=/.test(l.trim())) + .map(l => l.split('=')[0].trim()); + +const missing = required.filter(k => !process.env[k]); +if (missing.length > 0) { + console.error(`MISSING_CREDENTIALS: ${missing.join(',')}`); + process.exit(2); +} +// ────────────────────────────────────────────────────────────────────────────── + +const { createApp } = require('../src/server.js'); + +function startServer(app) { + return new Promise((resolve, reject) => { + const server = http.createServer(app); + server.listen(0, () => resolve(server)); + server.on('error', reject); + }); +} + +async function testServerStarts() { + console.log('Test 1: createApp() returns a configured Express app...'); + + const app = createApp(); + if (typeof app !== 'function' && typeof app.listen !== 'function') { + throw new Error('createApp() did not return an Express application'); + } + + console.log('āœ“ createApp() returned an Express app'); +} + +async function testHealthEndpoint() { + console.log('\nTest 2: GET /health returns { status: "ok" }...'); + + const app = createApp(); + const server = await startServer(app); + const port = server.address().port; + + try { + const resp = await fetch(`http://localhost:${port}/health`); + const data = await resp.json(); + if (data.status !== 'ok') { + throw new Error(`Expected { status: "ok" }, got: ${JSON.stringify(data)}`); + } + console.log('āœ“ GET /health returned { status: "ok" }'); + } finally { + server.close(); + } +} + +async function testWebhookSignatureRejection() { + console.log('\nTest 3: POST /webhook — invalid signature returns 401...'); + + const app = createApp(); + const server = await startServer(app); + const port = server.address().port; + + try { + const body = JSON.stringify({ + resource: 'meetingRecordings', + event: 'ready', + data: { id: 'fake-id' }, + }); + + const resp = await fetch(`http://localhost:${port}/webhook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-spark-signature': 'invalidsignature', + }, + body, + }); + + if (resp.status !== 401) { + throw new Error(`Expected 401 for invalid signature, got ${resp.status}`); + } + + const data = await resp.json(); + if (!data.error) { + throw new Error('Expected error field in 401 response'); + } + + console.log('āœ“ Invalid signature correctly rejected with 401'); + } finally { + server.close(); + } +} + +async function testWebhookIgnoredEvent() { + console.log('\nTest 4: POST /webhook — non-recording event returns ignored...'); + + const app = createApp(); + const server = await startServer(app); + const port = server.address().port; + + try { + const body = JSON.stringify({ + resource: 'messages', + event: 'created', + data: { id: 'msg-123' }, + }); + + const hmac = crypto + .createHmac('sha1', process.env.WEBEX_WEBHOOK_SECRET) + .update(body) + .digest('hex'); + + const resp = await fetch(`http://localhost:${port}/webhook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-spark-signature': hmac, + }, + body, + }); + + if (!resp.ok) { + throw new Error(`Unexpected status ${resp.status}`); + } + + const data = await resp.json(); + if (data.status !== 'ignored') { + throw new Error(`Expected { status: "ignored" }, got: ${JSON.stringify(data)}`); + } + + console.log('āœ“ Non-recording event correctly returned { status: "ignored" }'); + } finally { + server.close(); + } +} + +async function testWebhookRecordingReadyAccepted() { + console.log('\nTest 5: POST /webhook — valid recording.ready returns processing...'); + + const app = createApp(); + const server = await startServer(app); + const port = server.address().port; + + try { + const body = JSON.stringify({ + resource: 'meetingRecordings', + event: 'ready', + data: { id: 'rec-test-123' }, + }); + + const hmac = crypto + .createHmac('sha1', process.env.WEBEX_WEBHOOK_SECRET) + .update(body) + .digest('hex'); + + const resp = await fetch(`http://localhost:${port}/webhook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-spark-signature': hmac, + }, + body, + }); + + if (!resp.ok) { + throw new Error(`Unexpected status ${resp.status}`); + } + + const data = await resp.json(); + if (data.status !== 'processing') { + throw new Error(`Expected { status: "processing" }, got: ${JSON.stringify(data)}`); + } + + console.log('āœ“ Valid recording.ready event accepted with { status: "processing" }'); + } finally { + server.close(); + } +} + +async function run() { + await testServerStarts(); + await testHealthEndpoint(); + await testWebhookSignatureRejection(); + await testWebhookIgnoredEvent(); + await testWebhookRecordingReadyAccepted(); +} + +run() + .then(() => { + console.log('\nāœ“ All tests passed'); + process.exit(0); + }) + .catch(err => { + console.error(`\nāœ— Test failed: ${err.message}`); + process.exit(1); + });