diff --git a/package-lock.json b/package-lock.json index 771a6aa..0df07ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15883,6 +15883,14 @@ "resolved": "packages/v1-ready/deel", "link": true }, + "node_modules/@friggframework/api-module-fathom": { + "resolved": "packages/v1-ready/fathom", + "link": true + }, + "node_modules/@friggframework/api-module-fireflies": { + "resolved": "packages/v1-ready/fireflies", + "link": true + }, "node_modules/@friggframework/api-module-frigg-scale-test": { "resolved": "packages/v1-ready/frigg-scale-test", "link": true @@ -15891,6 +15899,10 @@ "resolved": "packages/v1-ready/frontify", "link": true }, + "node_modules/@friggframework/api-module-gong": { + "resolved": "packages/v1-ready/gong", + "link": true + }, "node_modules/@friggframework/api-module-google-calendar": { "resolved": "packages/v1-ready/google-calendar", "link": true @@ -15919,10 +15931,18 @@ "resolved": "packages/needs-updating/microsoft-teams", "link": true }, + "node_modules/@friggframework/api-module-otter": { + "resolved": "packages/v1-ready/otter", + "link": true + }, "node_modules/@friggframework/api-module-pipedrive": { "resolved": "packages/v1-ready/pipedrive", "link": true }, + "node_modules/@friggframework/api-module-quo": { + "resolved": "packages/v1-ready/quo", + "link": true + }, "node_modules/@friggframework/api-module-reevo": { "resolved": "packages/v1-ready/reevo", "link": true @@ -45615,6 +45635,197 @@ "node": ">=12" } }, + "packages/v1-ready/fathom": { + "name": "@friggframework/api-module-fathom", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3", + "js-yaml": "^4.1.0" + } + }, + "packages/v1-ready/fathom/node_modules/@friggframework/core": { + "version": "2.0.0-next.107", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.107.tgz", + "integrity": "sha512-t3t030L4e7TzoJ0f3BjkiHt4bUohGNQvQw7xVBHBazfwFSyJFmzasu0DzwTjDIQKiTBk64/O1yOZrZwzS9hkJA==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "common-tags": "^1.8.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/fathom/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/fathom/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/fathom/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "packages/v1-ready/fireflies": { + "name": "@friggframework/api-module-fireflies", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3" + } + }, + "packages/v1-ready/fireflies/node_modules/@friggframework/core": { + "version": "2.0.0-next.107", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.107.tgz", + "integrity": "sha512-t3t030L4e7TzoJ0f3BjkiHt4bUohGNQvQw7xVBHBazfwFSyJFmzasu0DzwTjDIQKiTBk64/O1yOZrZwzS9hkJA==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "common-tags": "^1.8.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/fireflies/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/fireflies/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/fireflies/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/v1-ready/frigg-scale-test": { "name": "@friggframework/api-module-frigg-scale-test", "version": "0.1.0", @@ -49883,6 +50094,105 @@ "node": ">=12" } }, + "packages/v1-ready/gong": { + "name": "@friggframework/api-module-gong", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "eslint": "^8.22.0", + "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.3", + "js-yaml": "^4.1.0", + "prettier": "^2.7.1" + } + }, + "packages/v1-ready/gong/node_modules/@friggframework/core": { + "version": "2.0.0-next.107", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.107.tgz", + "integrity": "sha512-t3t030L4e7TzoJ0f3BjkiHt4bUohGNQvQw7xVBHBazfwFSyJFmzasu0DzwTjDIQKiTBk64/O1yOZrZwzS9hkJA==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "common-tags": "^1.8.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/gong/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/gong/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/gong/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/v1-ready/google-calendar": { "name": "@friggframework/api-module-google-calendar", "version": "1.1.3", @@ -55838,6 +56148,102 @@ "node": ">=12" } }, + "packages/v1-ready/otter": { + "name": "@friggframework/api-module-otter", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3", + "js-yaml": "^4.1.0" + } + }, + "packages/v1-ready/otter/node_modules/@friggframework/core": { + "version": "2.0.0-next.107", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.107.tgz", + "integrity": "sha512-t3t030L4e7TzoJ0f3BjkiHt4bUohGNQvQw7xVBHBazfwFSyJFmzasu0DzwTjDIQKiTBk64/O1yOZrZwzS9hkJA==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "common-tags": "^1.8.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/otter/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/otter/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/otter/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/v1-ready/pipedrive": { "name": "@friggframework/api-module-pipedrive", "version": "2.0.0", @@ -55936,6 +56342,105 @@ "uuid": "dist/bin/uuid" } }, + "packages/v1-ready/quo": { + "name": "@friggframework/api-module-quo", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "eslint": "^8.22.0", + "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.3", + "js-yaml": "^4.1.0", + "prettier": "^2.7.1" + } + }, + "packages/v1-ready/quo/node_modules/@friggframework/core": { + "version": "2.0.0-next.107", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.107.tgz", + "integrity": "sha512-t3t030L4e7TzoJ0f3BjkiHt4bUohGNQvQw7xVBHBazfwFSyJFmzasu0DzwTjDIQKiTBk64/O1yOZrZwzS9hkJA==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "common-tags": "^1.8.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/quo/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/quo/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/quo/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/v1-ready/reevo": { "name": "@friggframework/api-module-reevo", "version": "1.0.0", diff --git a/packages/v1-ready/fathom/.env.example b/packages/v1-ready/fathom/.env.example new file mode 100644 index 0000000..f1aee37 --- /dev/null +++ b/packages/v1-ready/fathom/.env.example @@ -0,0 +1,4 @@ +# Fathom (fathom.video) API key. +# Generate under Fathom > User Settings > API Access. +# Sent on every request as the `X-Api-Key` header. +FATHOM_API_KEY=your_fathom_api_key_here diff --git a/packages/v1-ready/fathom/LICENSE.md b/packages/v1-ready/fathom/LICENSE.md new file mode 100644 index 0000000..c307ce6 --- /dev/null +++ b/packages/v1-ready/fathom/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Left Hook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/v1-ready/fathom/README.md b/packages/v1-ready/fathom/README.md new file mode 100644 index 0000000..01f2a23 --- /dev/null +++ b/packages/v1-ready/fathom/README.md @@ -0,0 +1,163 @@ +# Fathom API Module (`@friggframework/api-module-fathom`) + +A [Frigg](https://friggframework.org) API module for [Fathom](https://fathom.video) +— the AI meeting recorder. It wraps Fathom's public REST API so a Frigg +integration can list meetings/recordings, pull transcripts and summaries, read +team members, and register webhooks. + +Integrations consume it the standard Frigg way: + +```javascript +const meetings = await this.fathom.api.listMeetings({ include_summary: true }); +const { transcript } = await this.fathom.api.getTranscript(recordingId); +``` + +## Authentication + +Fathom uses **API-key** authentication. Generate a key in Fathom under +**User Settings → API Access**, and the module sends it on every request as the +`X-Api-Key` header: + +```bash +curl https://api.fathom.ai/external/v1/meetings -H "X-Api-Key: YOUR_API_KEY" +``` + +- **Base URL:** `https://api.fathom.ai/external/v1` +- **Rate limit:** 60 requests/minute across all of an account's API keys. + +Set `FATHOM_API_KEY` in your environment (see `.env.example`). The module also +exposes a JSON-Schema authorization form (`getAuthorizationRequirements`) so the +key can be collected through the Frigg auth UI / `frigg auth` CLI. + +## API Methods + +| Method | HTTP | Purpose | +|---|---|---| +| `listMeetings(params)` | `GET /meetings` | List meetings/recordings (paginated via `next_cursor`). | +| `listAllMeetings(params, opts)` | — | Convenience: follows `next_cursor` and returns a flat array. | +| `getTranscript(recordingId, params)` | `GET /recordings/{id}/transcript` | Transcript segments (or async POST to `destination_url`). | +| `getSummary(recordingId, params)` | `GET /recordings/{id}/summary` | Markdown-formatted call summary. | +| `listTeamMembers(params)` | `GET /team_members` | People on the Fathom account (optional `cursor`, `team`). | +| `createWebhook(data)` | `POST /webhooks` | Register a webhook for new meeting content. | + +### `listMeetings` parameters (all optional) + +`cursor`, `created_after`, `created_before`, `meeting_type`, +`include_transcript`, `include_summary`, `include_action_items`, +`include_highlights`, `include_crm_matches`, `calendar_invitees_domains_type`, +and the array filters `recorded_by[]` (emails), `teams[]`, +`calendar_invitees_domains[]`. Array values are serialized with the `key[]` +repeated-key convention. + +### Meeting shape (fields used by consumers) + +```jsonc +{ + "recording_id": 12345, + "title": "Acme <> Left Hook", + "meeting_title": "Discovery call", + "meeting_type": "external", + "url": "https://fathom.video/calls/12345", + "share_url": "https://fathom.video/share/...", + "created_at": "2026-08-11T18:00:00Z", + "scheduled_start_time": "2026-08-11T18:00:00Z", + "scheduled_end_time": "2026-08-11T18:30:00Z", + "recording_start_time": "2026-08-11T18:01:00Z", + "recording_end_time": "2026-08-11T18:29:00Z", + "calendar_invitees": [ + { "name": "Jane Buyer", "email": "jane@acme.com", + "email_domain": "acme.com", "is_external": true, + "matched_speaker_display_name": "Jane" } + ], + "recorded_by": { "name": "Sean", "email": "sean@lefthook.co", + "email_domain": "lefthook.co", "team": "Left Hook" } +} +``` + +Attendee emails live on `calendar_invitees[].email` (with `is_external` and +`email_domain`), which is what you match against a CRM/Reevo contact. The +recorder is `recorded_by.email`. + +### Transcript shape + +```jsonc +{ + "transcript": [ + { "speaker": { "display_name": "Jane", + "matched_calendar_invitee_email": "jane@acme.com" }, + "text": "...", "timestamp": "00:01:12" } + ] +} +``` + +### Summary shape + +```jsonc +{ "summary": { "template_name": "General", "markdown_formatted": "## ..." } } +``` + +## Webhooks (this integration is webhook-driven) + +Create a webhook with `createWebhook`: + +```javascript +await this.fathom.api.createWebhook({ + destination_url: 'https://your-frigg-app/webhooks/fathom', + triggered_for: ['my_recordings', 'shared_external_recordings'], + include_summary: true, + include_transcript: true, + include_action_items: true, +}); +``` + +- `triggered_for` (required, ≥1): `my_recordings`, + `shared_external_recordings`, `my_shared_with_team_recordings`, + `shared_team_recordings`. +- At least one of `include_transcript`, `include_summary`, + `include_action_items`, `include_crm_matches` must be `true`. +- The response includes a `secret` (`whsec_...`) used to verify delivery + signatures. + +**Event:** `new-meeting-content-ready` — delivered after a meeting is processed. +The payload carries the same meeting fields listed above (recording id, titles, +timestamps, `share_url`, `calendar_invitees[]`, `recorded_by`) plus the opted-in +`summary` / `transcript` / `action_items`. + +**Signature verification:** each delivery carries `webhook-id`, +`webhook-timestamp`, and `webhook-signature` headers. Verify by HMAC-SHA256 over +`{id}.{timestamp}.{rawBody}` using the base64-decoded portion of the webhook +secret after the `whsec_` prefix, comparing in constant time within a 5-minute +timestamp tolerance (Svix-style signing). + +## Documented-endpoint notes + +The public REST API documents **list** meetings only (no single-meeting `GET` +by id) — fetch a specific recording's content via the transcript/summary +endpoints keyed on `recording_id`. There is **no `/me` identity endpoint**, so +the module derives account identity from the first meeting's `recorded_by`, +falling back to a stable label. `list-webhooks` / `delete-webhook` are not +documented at the time of writing, so only `createWebhook` is modeled. + +## Testing + +```bash +npm install +npm test +``` + +Tests are fully offline — HTTP methods are stubbed and assertions are made on the +request options the module builds. No API key or network access is required. + +## Sources + +- Fathom Developer Hub — https://developers.fathom.ai/ +- List meetings — https://developers.fathom.ai/api-reference/meetings/list-meetings +- Get transcript — https://developers.fathom.ai/api-reference/recordings/get-transcript +- Get summary — https://developers.fathom.ai/api-reference/recordings/get-summary +- List team members — https://developers.fathom.ai/api-reference/team-members/list-team-members +- Create a webhook — https://developers.fathom.ai/api-reference/webhooks/create-a-webhook +- Webhooks overview — https://developers.fathom.ai/webhooks + +## License + +MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/v1-ready/fathom/api.js b/packages/v1-ready/fathom/api.js new file mode 100644 index 0000000..b86d946 --- /dev/null +++ b/packages/v1-ready/fathom/api.js @@ -0,0 +1,184 @@ +const { ApiKeyRequester, get } = require('@friggframework/core'); + +/** + * Fathom (fathom.video) API client. + * + * Auth: API key sent in the `X-Api-Key` request header. + * curl https://api.fathom.ai/external/v1/meetings -H "X-Api-Key: YOUR_API_KEY" + * + * Base URL: https://api.fathom.ai/external/v1 + * Rate limit: 60 requests/minute across all of an account's API keys. + * + * Docs: https://developers.fathom.ai/ + */ +class Api extends ApiKeyRequester { + constructor(params = {}) { + super(params); + + // ApiKeyRequester puts `headers[this.api_key_name] = this.api_key` + this.api_key_name = 'X-Api-Key'; + this.api_key = + get(params, 'api_key', null) || + get(params, 'access_token', null) || + get(params, 'apiKey', null); + + this.baseUrl = 'https://api.fathom.ai/external/v1'; + + this.URLs = { + meetings: '/meetings', + teamMembers: '/team_members', + transcript: (recordingId) => + `/recordings/${recordingId}/transcript`, + summary: (recordingId) => `/recordings/${recordingId}/summary`, + webhooks: '/webhooks', + }; + } + + // ---- Query helpers ----------------------------------------------------- + + /** + * Build a query string that supports both scalar params and array params + * (Fathom array filters use the `key[]` repeated-key convention). The core + * Requester's built-in query builder can't emit repeated keys, so we build + * the string here and append it to the URL directly. + */ + _buildQuery(params = {}) { + const usp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const v of value) { + if (v === undefined || v === null) continue; + usp.append(`${key}[]`, String(v)); + } + } else { + usp.append(key, String(value)); + } + } + const qs = usp.toString(); + return qs ? `?${qs}` : ''; + } + + // ---- Meetings ---------------------------------------------------------- + + /** + * List meetings/recordings. + * GET /meetings + * + * Supported params (all optional): + * cursor, created_after, created_before, meeting_type, + * include_transcript, include_summary, include_action_items, + * include_highlights, include_crm_matches, + * calendar_invitees_domains_type, + * recorded_by[] (array of emails), teams[] (array), + * calendar_invitees_domains[] (array of domains) + * + * Response: { limit, next_cursor, items: [ Meeting ] } + * Each Meeting includes recording_id, title, meeting_title, share_url, + * url, scheduled_start_time, recording_start_time/end_time, + * calendar_invitees[{ name, email, email_domain, is_external }], + * recorded_by{ name, email, email_domain, team }. + */ + async listMeetings(params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.meetings + this._buildQuery(params), + }); + } + + /** + * Convenience: page through every meeting, following `next_cursor`. + * Returns a flat array of meeting items. + */ + async listAllMeetings(params = {}, { maxPages = 50 } = {}) { + const all = []; + let cursor = params.cursor; + let pages = 0; + do { + const page = await this.listMeetings({ ...params, cursor }); + if (Array.isArray(page.items)) all.push(...page.items); + cursor = page.next_cursor; + pages += 1; + } while (cursor && pages < maxPages); + return all; + } + + // ---- Recording content ------------------------------------------------- + + /** + * Get a recording's transcript. + * GET /recordings/{recording_id}/transcript + * + * With no destination_url the transcript is returned directly: + * { transcript: [ { speaker: { display_name, + * matched_calendar_invitee_email }, text, timestamp } ] } + * With destination_url it is POSTed there asynchronously and the + * endpoint returns { destination_url }. + */ + async getTranscript(recordingId, params = {}) { + return this._get({ + url: + this.baseUrl + + this.URLs.transcript(recordingId) + + this._buildQuery(params), + }); + } + + /** + * Get a recording's summary. + * GET /recordings/{recording_id}/summary + * + * Direct response: { summary: { template_name, markdown_formatted } } + * Async (destination_url): { destination_url } + */ + async getSummary(recordingId, params = {}) { + return this._get({ + url: + this.baseUrl + + this.URLs.summary(recordingId) + + this._buildQuery(params), + }); + } + + // ---- Team members ------------------------------------------------------ + + /** + * List team members. + * GET /team_members + * Optional params: cursor, team (filter by team name). + * Response: { limit, next_cursor, items: [ { name, email, created_at } ] }. + */ + async listTeamMembers(params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.teamMembers + this._buildQuery(params), + }); + } + + // ---- Webhooks ---------------------------------------------------------- + + /** + * Create a webhook. + * POST /webhooks + * + * body: { + * destination_url, // required + * triggered_for: [ 'my_recordings' | 'shared_external_recordings' + * | 'my_shared_with_team_recordings' + * | 'shared_team_recordings' ], // required + * include_transcript?, include_summary?, + * include_action_items?, include_crm_matches? // >=1 must be true + * } + * + * Response: { id, url, secret, created_at, triggered_for, include_* } + * The returned `secret` (whsec_...) verifies delivery signatures + * (webhook-id / webhook-timestamp / webhook-signature headers, HMAC-SHA256). + */ + async createWebhook(data) { + return this._post({ + url: this.baseUrl + this.URLs.webhooks, + headers: { 'Content-Type': 'application/json' }, + body: data, + }); + } +} + +module.exports = { Api }; diff --git a/packages/v1-ready/fathom/defaultConfig.json b/packages/v1-ready/fathom/defaultConfig.json new file mode 100644 index 0000000..2b000ec --- /dev/null +++ b/packages/v1-ready/fathom/defaultConfig.json @@ -0,0 +1,10 @@ +{ + "name": "fathom", + "config": { + "apiKey": true, + "batch": { + "concurrency": 3, + "delay": 1000 + } + } +} diff --git a/packages/v1-ready/fathom/definition.js b/packages/v1-ready/fathom/definition.js new file mode 100644 index 0000000..643c141 --- /dev/null +++ b/packages/v1-ready/fathom/definition.js @@ -0,0 +1,110 @@ +require('dotenv').config(); +const crypto = require('crypto'); +const { Api } = require('./api'); +const { get } = require('@friggframework/core'); +const config = require('./defaultConfig.json'); + +/** + * Fathom is API-key authenticated (X-Api-Key header). There is no OAuth flow + * and no dedicated "/me" identity endpoint on the public REST API, so identity + * is derived from the first meeting's `recorded_by` where available. + * + * When no meeting is available (empty list, missing `recorded_by.email`, or an + * API error) we fall back to a sha256 fingerprint of the API key itself — a + * stable, non-reversible, per-credential identifier that is always available + * and never collides across accounts. This mirrors the gong/otter modules. + * + * There is deliberately NO shared constant fallback: two different customers + * must never map to the same entity/credential. + */ +const keyFingerprint = (apiKey) => + crypto.createHash('sha256').update(String(apiKey)).digest('hex'); + +async function resolveAccountIdentity(api) { + try { + const result = await api.listMeetings({}); + const first = Array.isArray(result?.items) ? result.items[0] : null; + const recordedBy = first?.recorded_by; + if (recordedBy?.email) { + return { + externalId: recordedBy.email, + name: recordedBy.team || recordedBy.name || recordedBy.email, + }; + } + } catch (e) { + // fall through to the per-credential key fingerprint + } + + const apiKey = api?.api_key; + if (!apiKey) { + throw new Error( + 'Fathom: cannot derive a stable account identity — no meeting identity and no API key to fingerprint.' + ); + } + return { externalId: keyFingerprint(apiKey), name: 'Fathom' }; +} + +const Definition = { + API: Api, + getName: () => config.name, + moduleName: config.name, + modelName: 'Fathom', + requiredAuthMethods: { + getAuthorizationRequirements: () => ({ + type: 'apiKey', + data: { + jsonSchema: { + title: 'Fathom Authentication', + type: 'object', + required: ['api_key'], + properties: { + api_key: { + type: 'string', + title: 'API Key', + }, + }, + }, + uiSchema: { + api_key: { + 'ui:widget': 'password', + 'ui:help': + 'Generate an API key in Fathom under User Settings > API Access. Sent as the X-Api-Key header.', + }, + }, + }, + }), + setAuthParams: async (api, params) => { + const apiKey = + get(params, 'api_key', null) || + get(params, 'access_token', null); + api.setApiKey(apiKey); + }, + getEntityDetails: async (api, callbackParams, tokenResponse, userId) => { + const identity = await resolveAccountIdentity(api); + return { + identifiers: { externalId: identity.externalId, userId }, + details: { name: identity.name }, + }; + }, + getCredentialDetails: async (api, userId) => { + const identity = await resolveAccountIdentity(api); + return { + identifiers: { externalId: identity.externalId, userId }, + details: {}, + }; + }, + apiPropertiesToPersist: { + credential: ['api_key'], + entity: [], + }, + testAuthRequest: async (api) => { + // Any authenticated call proves the key works. + return api.listMeetings({}); + }, + }, + env: { + api_key: process.env.FATHOM_API_KEY, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/fathom/fathom.openapi.yaml b/packages/v1-ready/fathom/fathom.openapi.yaml new file mode 100644 index 0000000..7839b73 --- /dev/null +++ b/packages/v1-ready/fathom/fathom.openapi.yaml @@ -0,0 +1,364 @@ +openapi: 3.0.3 +info: + title: Fathom Public API + version: "1.0.0" + description: >- + Fathom (fathom.video) is an AI meeting recorder. This is the public REST API + surface documented at https://developers.fathom.ai/. Fathom publishes its own + OpenAPI document (rendered on the Developer Hub); this file is Left Hook's + curated subset covering exactly the endpoints the @friggframework/api-module-fathom + client in api.js implements. It is the source of truth the hand-written client + mirrors 1:1 (one method per operationId). + contact: + name: Left Hook + url: https://lefthook.com +servers: + - url: https://api.fathom.ai/external/v1 + description: Fathom public API +security: + - ApiKeyAuth: [] +tags: + - name: Meetings + - name: Recordings + - name: Team Members + - name: Webhooks +paths: + /meetings: + get: + tags: [Meetings] + operationId: listMeetings + summary: List meetings/recordings + description: >- + Returns a page of meetings. Paginate by passing the previous response's + `next_cursor` as `cursor`. `include_transcript` and `include_summary` + are unavailable to OAuth apps. + parameters: + - in: query + name: cursor + schema: { type: string } + description: Pagination cursor from a previous response's `next_cursor`. + - in: query + name: created_after + schema: { type: string, format: date-time } + description: Only meetings created at/after this ISO 8601 timestamp. + - in: query + name: created_before + schema: { type: string, format: date-time } + description: Only meetings created at/before this ISO 8601 timestamp. + - in: query + name: meeting_type + schema: { type: string } + description: Filter by meeting type name. + - in: query + name: include_transcript + schema: { type: boolean, default: false } + - in: query + name: include_summary + schema: { type: boolean, default: false } + - in: query + name: include_action_items + schema: { type: boolean, default: false } + - in: query + name: include_highlights + schema: { type: boolean, default: false } + - in: query + name: include_crm_matches + schema: { type: boolean, default: false } + - in: query + name: calendar_invitees_domains_type + schema: + type: string + enum: [all, only_internal, one_or_more_external] + - in: query + name: recorded_by + description: >- + Array filter — emails of recorders. Serialized as the repeated + `recorded_by[]` key. + schema: + type: array + items: { type: string, format: email } + style: form + explode: true + - in: query + name: teams + description: Array filter — team names. Serialized as repeated `teams[]`. + schema: + type: array + items: { type: string } + style: form + explode: true + - in: query + name: calendar_invitees_domains + description: >- + Array filter — company domains. Serialized as repeated + `calendar_invitees_domains[]`. + schema: + type: array + items: { type: string } + style: form + explode: true + responses: + "200": { $ref: "#/components/responses/MeetingListResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /recordings/{recording_id}/transcript: + parameters: + - $ref: "#/components/parameters/RecordingId" + get: + tags: [Recordings] + operationId: getTranscript + summary: Get a recording's transcript + description: >- + Without `destination_url` the transcript is returned directly. With + `destination_url` it is POSTed there asynchronously and the endpoint + returns `{ destination_url }`. + parameters: + - in: query + name: destination_url + schema: { type: string, format: uri } + description: If provided, the transcript is POSTed here instead of returned. + responses: + "200": { $ref: "#/components/responses/TranscriptResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /recordings/{recording_id}/summary: + parameters: + - $ref: "#/components/parameters/RecordingId" + get: + tags: [Recordings] + operationId: getSummary + summary: Get a recording's summary + description: >- + Without `destination_url` the summary is returned directly. With + `destination_url` it is POSTed there asynchronously and the endpoint + returns `{ destination_url }`. + parameters: + - in: query + name: destination_url + schema: { type: string, format: uri } + description: If provided, the summary is POSTed here instead of returned. + responses: + "200": { $ref: "#/components/responses/SummaryResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /team_members: + get: + tags: [Team Members] + operationId: listTeamMembers + summary: List team members + description: People on the Fathom account. + parameters: + - in: query + name: cursor + schema: { type: string } + description: Pagination cursor from a previous response's `next_cursor`. + - in: query + name: team + schema: { type: string } + description: Filter by team name. + responses: + "200": { $ref: "#/components/responses/TeamMemberListResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /webhooks: + post: + tags: [Webhooks] + operationId: createWebhook + summary: Create a webhook + description: >- + Register a webhook that fires when new meeting content is ready. At least + one of `include_transcript`, `include_summary`, `include_action_items`, + or `include_crm_matches` must be true. The response `secret` (whsec_...) + verifies delivery signatures (webhook-id / webhook-timestamp / + webhook-signature headers, HMAC-SHA256). + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCreate" } + responses: + "201": { $ref: "#/components/responses/WebhookResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-Api-Key + parameters: + RecordingId: + in: path + name: recording_id + required: true + schema: { type: integer } + description: The numeric id of the meeting recording. + responses: + MeetingListResponse: + description: A page of meetings. + content: + application/json: + schema: + type: object + properties: + limit: { type: integer, nullable: true } + next_cursor: { type: string, nullable: true } + items: + type: array + items: { $ref: "#/components/schemas/Meeting" } + TranscriptResponse: + description: The transcript, or an async-delivery confirmation. + content: + application/json: + schema: + oneOf: + - type: object + properties: + transcript: + type: array + items: { $ref: "#/components/schemas/TranscriptSegment" } + - $ref: "#/components/schemas/AsyncDelivery" + SummaryResponse: + description: The summary, or an async-delivery confirmation. + content: + application/json: + schema: + oneOf: + - type: object + properties: + summary: { $ref: "#/components/schemas/Summary" } + - $ref: "#/components/schemas/AsyncDelivery" + TeamMemberListResponse: + description: A page of team members. + content: + application/json: + schema: + type: object + properties: + limit: { type: integer, nullable: true } + next_cursor: { type: string, nullable: true } + items: + type: array + items: { $ref: "#/components/schemas/TeamMember" } + WebhookResponse: + description: The created webhook. + content: + application/json: + schema: { $ref: "#/components/schemas/Webhook" } + BadRequest: + description: Invalid query parameters or request body. + Unauthorized: + description: Missing or invalid API key. + RateLimited: + description: Rate limit exceeded (60 requests/minute per account). + schemas: + AsyncDelivery: + type: object + description: Returned when a destination_url was supplied. + properties: + destination_url: { type: string, format: uri } + Invitee: + type: object + properties: + name: { type: string } + email: { type: string, format: email } + email_domain: { type: string } + is_external: { type: boolean } + matched_speaker_display_name: { type: string, nullable: true } + Recorder: + type: object + properties: + name: { type: string } + email: { type: string, format: email } + email_domain: { type: string } + team: { type: string, nullable: true } + Meeting: + type: object + properties: + recording_id: { type: integer } + title: { type: string } + meeting_title: { type: string } + meeting_type: { type: string } + url: { type: string, format: uri } + share_url: { type: string, format: uri } + created_at: { type: string, format: date-time } + scheduled_start_time: { type: string, format: date-time } + scheduled_end_time: { type: string, format: date-time } + recording_start_time: { type: string, format: date-time } + recording_end_time: { type: string, format: date-time } + calendar_invitees: + type: array + items: { $ref: "#/components/schemas/Invitee" } + recorded_by: { $ref: "#/components/schemas/Recorder" } + TranscriptSegment: + type: object + properties: + speaker: + type: object + properties: + display_name: { type: string } + matched_calendar_invitee_email: + type: string + format: email + nullable: true + text: { type: string } + timestamp: + type: string + description: Time relative to meeting start, HH:MM:SS. + Summary: + type: object + properties: + template_name: { type: string, nullable: true } + markdown_formatted: { type: string, nullable: true } + TeamMember: + type: object + properties: + name: { type: string } + email: { type: string, format: email } + created_at: { type: string, format: date-time } + WebhookCreate: + type: object + required: [destination_url, triggered_for] + description: >- + At least one of include_transcript, include_summary, + include_action_items, or include_crm_matches must be true. + properties: + destination_url: + type: string + format: uri + description: The endpoint that will receive webhook deliveries. + triggered_for: + type: array + minItems: 1 + items: + type: string + enum: + - my_recordings + - shared_external_recordings + - my_shared_with_team_recordings + - shared_team_recordings + include_transcript: { type: boolean, default: false } + include_summary: { type: boolean, default: false } + include_action_items: { type: boolean, default: false } + include_crm_matches: { type: boolean, default: false } + Webhook: + type: object + properties: + id: { type: string } + url: { type: string, format: uri } + secret: + type: string + description: whsec_... value used to verify delivery signatures. + created_at: { type: string, format: date-time } + triggered_for: + type: array + items: { type: string } + include_transcript: { type: boolean } + include_summary: { type: boolean } + include_action_items: { type: boolean } + include_crm_matches: { type: boolean } diff --git a/packages/v1-ready/fathom/index.js b/packages/v1-ready/fathom/index.js new file mode 100644 index 0000000..3c94a63 --- /dev/null +++ b/packages/v1-ready/fathom/index.js @@ -0,0 +1,7 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); + +module.exports = { + Api, + Definition, +}; diff --git a/packages/v1-ready/fathom/package.json b/packages/v1-ready/fathom/package.json new file mode 100644 index 0000000..c1010ba --- /dev/null +++ b/packages/v1-ready/fathom/package.json @@ -0,0 +1,32 @@ +{ + "name": "@friggframework/api-module-fathom", + "version": "1.0.0", + "description": "Fathom (fathom.video) API module that lets the Frigg Framework interact with Fathom meetings, recordings, transcripts, summaries, and webhooks", + "main": "index.js", + "scripts": { + "test": "jest", + "lint:fix": "prettier --write --loglevel error . && eslint . --fix" + }, + "keywords": [ + "frigg", + "fathom", + "fathom.video", + "api-module", + "meetings", + "transcripts" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3", + "js-yaml": "^4.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/fathom/tests/api.test.js b/packages/v1-ready/fathom/tests/api.test.js new file mode 100644 index 0000000..d12f379 --- /dev/null +++ b/packages/v1-ready/fathom/tests/api.test.js @@ -0,0 +1,159 @@ +const { Api } = require('../api'); + +// Fully offline: we stub the low-level HTTP methods (_get/_post) and assert on +// the request options the API methods construct. No network is touched. +function makeApi(overrides = {}) { + const api = new Api({ api_key: 'test-key', ...overrides }); + api._captured = []; + api._get = async (options) => { + api._captured.push({ method: 'GET', ...options }); + return { items: [], next_cursor: null }; + }; + api._post = async (options) => { + api._captured.push({ method: 'POST', ...options }); + return { id: 'wh_1' }; + }; + return api; +} + +describe('Fathom Api', () => { + describe('construction & auth', () => { + it('sets the base URL and X-Api-Key header name', () => { + const api = new Api({ api_key: 'abc' }); + expect(api.baseUrl).toBe('https://api.fathom.ai/external/v1'); + expect(api.api_key_name).toBe('X-Api-Key'); + expect(api.api_key).toBe('abc'); + }); + + it('accepts access_token as an alias for api_key', () => { + const api = new Api({ access_token: 'from-token' }); + expect(api.api_key).toBe('from-token'); + }); + + it('adds the X-Api-Key auth header', async () => { + const api = new Api({ api_key: 'secret' }); + const headers = await api.addAuthHeaders({}); + expect(headers['X-Api-Key']).toBe('secret'); + }); + + it('reports authenticated only with a non-empty key', () => { + expect(new Api({ api_key: 'x' }).isAuthenticated()).toBe(true); + expect(new Api({}).isAuthenticated()).toBe(false); + }); + }); + + describe('listMeetings', () => { + it('hits GET /meetings', async () => { + const api = makeApi(); + await api.listMeetings(); + expect(api._captured[0].url).toBe( + 'https://api.fathom.ai/external/v1/meetings' + ); + }); + + it('serializes scalar filters as query params', async () => { + const api = makeApi(); + await api.listMeetings({ + cursor: 'c1', + include_summary: true, + created_after: '2026-01-01T00:00:00Z', + }); + const { url } = api._captured[0]; + expect(url).toContain('cursor=c1'); + expect(url).toContain('include_summary=true'); + expect(url).toContain( + 'created_after=2026-01-01T00%3A00%3A00Z' + ); + }); + + it('serializes array filters with the key[] convention', async () => { + const api = makeApi(); + await api.listMeetings({ + recorded_by: ['a@x.com', 'b@x.com'], + teams: ['Sales'], + }); + const { url } = api._captured[0]; + expect(url).toContain('recorded_by%5B%5D=a%40x.com'); + expect(url).toContain('recorded_by%5B%5D=b%40x.com'); + expect(url).toContain('teams%5B%5D=Sales'); + }); + + it('omits null/undefined params', async () => { + const api = makeApi(); + await api.listMeetings({ cursor: undefined, meeting_type: null }); + expect(api._captured[0].url).toBe( + 'https://api.fathom.ai/external/v1/meetings' + ); + }); + }); + + describe('listAllMeetings', () => { + it('follows next_cursor and flattens items', async () => { + const api = new Api({ api_key: 'k' }); + const pages = [ + { items: [{ recording_id: 1 }], next_cursor: 'p2' }, + { items: [{ recording_id: 2 }], next_cursor: null }, + ]; + let call = 0; + api._get = async () => pages[call++]; + const all = await api.listAllMeetings(); + expect(all.map((m) => m.recording_id)).toEqual([1, 2]); + }); + }); + + describe('recording content', () => { + it('getTranscript hits the transcript path', async () => { + const api = makeApi(); + await api.getTranscript(12345); + expect(api._captured[0].url).toBe( + 'https://api.fathom.ai/external/v1/recordings/12345/transcript' + ); + }); + + it('getTranscript forwards destination_url', async () => { + const api = makeApi(); + await api.getTranscript(1, { + destination_url: 'https://hook.example.com/t', + }); + expect(api._captured[0].url).toContain( + 'destination_url=https%3A%2F%2Fhook.example.com%2Ft' + ); + }); + + it('getSummary hits the summary path', async () => { + const api = makeApi(); + await api.getSummary(999); + expect(api._captured[0].url).toBe( + 'https://api.fathom.ai/external/v1/recordings/999/summary' + ); + }); + }); + + describe('listTeamMembers', () => { + it('hits GET /team_members', async () => { + const api = makeApi(); + await api.listTeamMembers(); + expect(api._captured[0].url).toBe( + 'https://api.fathom.ai/external/v1/team_members' + ); + }); + }); + + describe('createWebhook', () => { + it('POSTs to /webhooks with the body', async () => { + const api = makeApi(); + const body = { + destination_url: 'https://hook.example.com', + triggered_for: ['my_recordings'], + include_summary: true, + }; + await api.createWebhook(body); + const captured = api._captured[0]; + expect(captured.method).toBe('POST'); + expect(captured.url).toBe( + 'https://api.fathom.ai/external/v1/webhooks' + ); + expect(captured.body).toEqual(body); + }); + }); +}); diff --git a/packages/v1-ready/fathom/tests/definition.test.js b/packages/v1-ready/fathom/tests/definition.test.js new file mode 100644 index 0000000..83ddc37 --- /dev/null +++ b/packages/v1-ready/fathom/tests/definition.test.js @@ -0,0 +1,180 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { Definition } = require('../definition'); +const { Api } = require('../api'); + +const sha256 = (v) => + crypto.createHash('sha256').update(String(v)).digest('hex'); + +// Offline: exercises the Definition auth methods with a fake api whose network +// calls are stubbed. No real HTTP. +describe('Fathom Definition', () => { + it('is an api-key module named fathom wired to the Api class', () => { + expect(Definition.moduleName).toBe('fathom'); + expect(Definition.getName()).toBe('fathom'); + expect(Definition.API).toBe(Api); + expect(Definition.modelName).toBe('Fathom'); + }); + + it('exposes an apiKey authorization form for the api_key field', () => { + const reqs = Definition.requiredAuthMethods.getAuthorizationRequirements(); + expect(reqs.type).toBe('apiKey'); + expect(reqs.data.jsonSchema.required).toContain('api_key'); + expect(reqs.data.jsonSchema.properties.api_key.type).toBe('string'); + expect(reqs.data.uiSchema.api_key['ui:widget']).toBe('password'); + }); + + it('persists only the api_key credential', () => { + expect(Definition.requiredAuthMethods.apiPropertiesToPersist).toEqual({ + credential: ['api_key'], + entity: [], + }); + }); + + it('setAuthParams sets the api key from form params', async () => { + const api = new Api({}); + await Definition.requiredAuthMethods.setAuthParams(api, { + api_key: 'form-key', + }); + expect(api.api_key).toBe('form-key'); + }); + + it('getEntityDetails derives externalId from recorded_by email', async () => { + const api = new Api({ api_key: 'k' }); + api.listMeetings = async () => ({ + items: [ + { + recording_id: 1, + recorded_by: { + name: 'Sean', + email: 'sean@lefthook.co', + team: 'Left Hook', + }, + }, + ], + }); + const details = await Definition.requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-1' + ); + expect(details.identifiers.externalId).toBe('sean@lefthook.co'); + expect(details.identifiers.userId).toBe('user-1'); + expect(details.details.name).toBe('Left Hook'); + }); + + it('getEntityDetails falls back to the api-key fingerprint with no meetings', async () => { + const api = new Api({ api_key: 'key-abc' }); + api.listMeetings = async () => ({ items: [] }); + const details = await Definition.requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-2' + ); + // Genuinely per-account: a sha256 of the key, NOT a shared constant. + expect(details.identifiers.externalId).toBe(sha256('key-abc')); + expect(details.identifiers.externalId).not.toBe('fathom-account'); + }); + + it('getEntityDetails falls back to the fingerprint when recorded_by has no email', async () => { + const api = new Api({ api_key: 'key-xyz' }); + api.listMeetings = async () => ({ + items: [{ recording_id: 1, recorded_by: { name: 'Anon' } }], + }); + const details = await Definition.requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-2b' + ); + expect(details.identifiers.externalId).toBe(sha256('key-xyz')); + }); + + it('getEntityDetails uses the fingerprint if the API throws', async () => { + const api = new Api({ api_key: 'key-boom' }); + api.listMeetings = async () => { + throw new Error('boom'); + }; + const details = await Definition.requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-3' + ); + expect(details.identifiers.externalId).toBe(sha256('key-boom')); + }); + + it('fingerprint fallback is STABLE for the same key and UNIQUE across keys', async () => { + const makeApi = (key) => { + const api = new Api({ api_key: key }); + api.listMeetings = async () => ({ items: [] }); + return api; + }; + const idFor = async (key) => + ( + await Definition.requiredAuthMethods.getEntityDetails( + makeApi(key), + {}, + {}, + 'u' + ) + ).identifiers.externalId; + const credIdFor = async (key) => + ( + await Definition.requiredAuthMethods.getCredentialDetails( + makeApi(key), + 'u' + ) + ).identifiers.externalId; + + // Same key -> same id (stable). + expect(await idFor('cust-A-key')).toBe(await idFor('cust-A-key')); + // Two different customers -> different ids (no collision). + expect(await idFor('cust-A-key')).not.toBe(await idFor('cust-B-key')); + // Entity and credential agree for one account. + expect(await idFor('cust-A-key')).toBe(await credIdFor('cust-A-key')); + }); + + it('getCredentialDetails falls back to the api-key fingerprint', async () => { + const api = new Api({ api_key: 'cred-key' }); + api.listMeetings = async () => ({ items: [] }); + const details = + await Definition.requiredAuthMethods.getCredentialDetails( + api, + 'user-c' + ); + expect(details.identifiers.externalId).toBe(sha256('cred-key')); + }); + + it('throws rather than returning a shared constant when nothing can be derived', async () => { + const api = new Api({}); + api.api_key = null; + api.listMeetings = async () => ({ items: [] }); + await expect( + Definition.requiredAuthMethods.getEntityDetails(api, {}, {}, 'u') + ).rejects.toThrow(/stable account identity/i); + }); + + it('retains NO hardcoded constant fallback in the source', () => { + const src = fs.readFileSync( + path.join(__dirname, '..', 'definition.js'), + 'utf8' + ); + expect(src).not.toMatch(/externalId:\s*['"]fathom-account['"]/); + expect(src).not.toMatch(/return\s*\{\s*externalId:\s*['"]fathom-account['"]/); + }); + + it('testAuthRequest performs an authenticated list call', async () => { + const api = new Api({ api_key: 'k' }); + let called = false; + api.listMeetings = async () => { + called = true; + return { items: [] }; + }; + await Definition.requiredAuthMethods.testAuthRequest(api); + expect(called).toBe(true); + }); +}); diff --git a/packages/v1-ready/fathom/tests/spec-sync.test.js b/packages/v1-ready/fathom/tests/spec-sync.test.js new file mode 100644 index 0000000..fc069dc --- /dev/null +++ b/packages/v1-ready/fathom/tests/spec-sync.test.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { Api } = require('../api'); + +const spec = yaml.load( + fs.readFileSync(path.join(__dirname, '..', 'fathom.openapi.yaml'), 'utf8') +); + +const specOperationIds = Object.values(spec.paths).flatMap((item) => + Object.entries(item) + .filter(([m]) => ['get', 'post', 'patch', 'put', 'delete'].includes(m)) + .map(([, op]) => op.operationId) +); + +const clientMethods = Object.getOwnPropertyNames(Api.prototype).filter( + (m) => typeof Api.prototype[m] === 'function' && m !== 'constructor' +); + +describe('OpenAPI spec ↔ client sync', () => { + it('every operationId has a matching client method', () => { + const missing = specOperationIds.filter( + (op) => !clientMethods.includes(op) + ); + expect(missing).toEqual([]); + }); + + it('the base server URL matches the client baseUrl', () => { + const api = new Api({ api_key: 'x' }); + expect(spec.servers[0].url).toBe(api.baseUrl); + }); + + it('declares X-Api-Key apiKey security matching the client', () => { + const scheme = spec.components.securitySchemes.ApiKeyAuth; + const api = new Api({ api_key: 'x' }); + expect(scheme.type).toBe('apiKey'); + expect(scheme.in).toBe('header'); + expect(scheme.name).toBe('X-Api-Key'); + // The security scheme header must be exactly what the client sends. + expect(scheme.name).toBe(api.api_key_name); + }); +}); diff --git a/packages/v1-ready/fireflies/.env.example b/packages/v1-ready/fireflies/.env.example new file mode 100644 index 0000000..0b2bceb --- /dev/null +++ b/packages/v1-ready/fireflies/.env.example @@ -0,0 +1,2 @@ +# Fireflies.ai API key — from fireflies.ai > Integrations > Fireflies API +FIREFLIES_API_KEY=your_fireflies_api_key_here diff --git a/packages/v1-ready/fireflies/LICENSE.md b/packages/v1-ready/fireflies/LICENSE.md new file mode 100644 index 0000000..43423f7 --- /dev/null +++ b/packages/v1-ready/fireflies/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Left Hook / Frigg Framework contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/v1-ready/fireflies/README.md b/packages/v1-ready/fireflies/README.md new file mode 100644 index 0000000..5e41466 --- /dev/null +++ b/packages/v1-ready/fireflies/README.md @@ -0,0 +1,91 @@ +# @friggframework/api-module-fireflies + +Frigg API module for [Fireflies.ai](https://fireflies.ai) — the AI meeting notetaker. +Provides access to meeting transcripts, summaries, and attendee data for use in +Frigg integrations (e.g. matching a call's attendees to a CRM contact by email). + +## API shape + +Fireflies exposes a **single GraphQL endpoint**: + +``` +POST https://api.fireflies.ai/graphql +Authorization: Bearer +Content-Type: application/json + +{ "query": "...", "variables": { ... } } +``` + +Docs: + +## Authentication + +This is an **API-key** module built on `ApiKeyRequester`. The key is a bearer +token. The module sets `api_key_name = 'Authorization'` and overrides +`addAuthHeaders()` to emit `Authorization: Bearer ` — so the stored +credential is the bare key (no `"Bearer "` prefix persisted to the database). + +Get your key from **fireflies.ai → Integrations → Fireflies API**. + +```env +FIREFLIES_API_KEY=your_fireflies_api_key_here +``` + +## Usage + +```javascript +const { Api } = require('@friggframework/api-module-fireflies'); + +const api = new Api({ api_key: process.env.FIREFLIES_API_KEY }); + +// Confirm the key / identify the account +const user = await api.getUser(); + +// List recent transcripts (all args optional) +const transcripts = await api.listTranscripts({ + limit: 25, + skip: 0, + fromDate: '2026-01-01T00:00:00.000Z', +}); + +// Full transcript: attendees (with emails), summary, sentences +const transcript = await api.getTranscript(transcripts[0].id); + +// Just the AI summary +const summary = await api.getTranscriptSummary(transcript.id); + +// Keyword search +const hits = await api.searchTranscripts('pricing', { limit: 10 }); +``` + +## Methods + +| Method | GraphQL | Purpose | +|---|---|---| +| `getUser()` | `query { user { ... } }` | Authenticated account; used by the auth test | +| `listTranscripts(params)` | `transcripts(limit, skip, fromDate, toDate, organizer_email, participant_email, keyword, mine)` | Page through meetings, newest first | +| `getTranscript(id)` | `transcript(id)` | One meeting with `meeting_attendees` (email), `summary`, `sentences` | +| `getTranscriptSummary(id)` | `transcript(id) { summary }` | AI summary only | +| `searchTranscripts(keyword, params)` | `transcripts(keyword)` | Keyword search wrapper | +| `graphql(query, variables)` | — | Low-level transport; unwraps `data`, throws on `errors[]` | + +### Attendee email fields + +`meeting_attendees` carries `{ displayName, email, name, phoneNumber, location }` +per attendee; the top-level transcript also carries `organizer_email`, +`host_email`, and a `participants` (email) array. These are what an integration +uses to match a call to a CRM contact. + +## Testing + +```bash +npm install +npx jest +``` + +Tests are fully offline — a fake `fetch` is injected and the suite asserts the +GraphQL request body and the `Authorization: Bearer` header. + +## License + +MIT diff --git a/packages/v1-ready/fireflies/api.js b/packages/v1-ready/fireflies/api.js new file mode 100644 index 0000000..f25e0f0 --- /dev/null +++ b/packages/v1-ready/fireflies/api.js @@ -0,0 +1,286 @@ +const { ApiKeyRequester, ModuleConstants, get } = require('@friggframework/core'); + +/** + * Fireflies.ai API module. + * + * Fireflies exposes a single GraphQL endpoint at https://api.fireflies.ai/graphql. + * Every call is an HTTP POST whose JSON body is `{ query, variables }`. + * Auth is a bearer token: `Authorization: Bearer `. + * + * Auth design note: + * ApiKeyRequester.addAuthHeaders() sets `headers[this.api_key_name] = this.api_key`. + * Fireflies needs the value prefixed with the word "Bearer", so we override + * addAuthHeaders() to emit `Authorization: Bearer ` from the raw key. This + * keeps the stored credential clean (just the key, no "Bearer " prefix) while + * still producing the exact header Fireflies requires. (The alternative — + * api_key_name='Authorization' with a 'Bearer '-prefixed api_key — also works; + * the override is used so the persisted secret is the bare key.) + * + * Docs: + * https://docs.fireflies.ai/graphql-api/authorization + * https://docs.fireflies.ai/graphql-api/query/transcripts + * https://docs.fireflies.ai/graphql-api/query/transcript + */ +class Api extends ApiKeyRequester { + constructor(params) { + super(params); + this.baseUrl = 'https://api.fireflies.ai/graphql'; + + // Accept the key under any of the common param names. + const apiKey = + get(params, 'api_key', null) || + get(params, 'access_token', null) || + get(params, 'apiKey', null); + this.api_key_name = 'Authorization'; + if (apiKey) { + this.setApiKey(apiKey); + } + } + + /** + * Emit `Authorization: Bearer `. The stored credential is the bare + * key; the "Bearer " prefix is added here so it never lives in the DB. + */ + async addAuthHeaders(headers) { + const h = headers || {}; + if (this.api_key) { + h[this.api_key_name] = `Bearer ${this.api_key}`; + } + return h; + } + + getAuthorizationRequirements() { + return { + url: null, + type: ModuleConstants.authType.apiKey, + data: { + jsonSchema: { + title: 'Fireflies.ai Authentication', + type: 'object', + required: ['api_key'], + properties: { + api_key: { + type: 'string', + title: 'API Key', + }, + }, + }, + uiSchema: { + api_key: { + 'ui:widget': 'password', + 'ui:help': + 'From fireflies.ai: Integrations > Fireflies API > copy your API key.', + 'ui:placeholder': 'Fireflies API key', + }, + }, + }, + }; + } + + /** + * Core GraphQL transport. Posts `{ query, variables }` to the single + * endpoint and unwraps `data`, throwing on a GraphQL `errors` array. + */ + async graphql(query, variables = {}) { + const options = { + url: this.baseUrl, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: { query, variables }, + }; + const response = await this._post(options); + if (response && Array.isArray(response.errors) && response.errors.length) { + const message = response.errors + .map((e) => e.message) + .filter(Boolean) + .join('; '); + throw new Error(`Fireflies GraphQL error: ${message}`); + } + return response ? response.data : undefined; + } + + // ---- Auth / identity ------------------------------------------------ + + /** + * The authenticated user. Used by the module's auth-test / entity flow. + * `query { user { user_id name email } }` + */ + async getUser() { + const query = `query { + user { + user_id + name + email + is_admin + } + }`; + const data = await this.graphql(query); + return data ? data.user : undefined; + } + + // ---- Transcripts ---------------------------------------------------- + + /** + * List meeting transcripts, newest first. All args optional. + * `transcripts(limit, skip, fromDate, toDate, organizers, participants, + * keyword, mine)` + * + * Note: the public signature still accepts a single `organizerEmail` / + * `participantEmail` string; Fireflies deprecated the scalar + * `organizer_email` / `participant_email` args in favor of the array + * `organizers: [String]` / `participants: [String]`, so a single email is + * wrapped in an array internally. + */ + async listTranscripts(params = {}) { + const query = `query ListTranscripts( + $limit: Int + $skip: Int + $fromDate: DateTime + $toDate: DateTime + $organizers: [String] + $participants: [String] + $keyword: String + $mine: Boolean + ) { + transcripts( + limit: $limit + skip: $skip + fromDate: $fromDate + toDate: $toDate + organizers: $organizers + participants: $participants + keyword: $keyword + mine: $mine + ) { + id + title + date + dateString + duration + host_email + organizer_email + participants + meeting_link + transcript_url + meeting_attendees { + displayName + email + name + phoneNumber + location + } + } + }`; + + const variables = {}; + if (params.limit !== undefined) variables.limit = params.limit; + if (params.skip !== undefined) variables.skip = params.skip; + if (params.fromDate !== undefined) variables.fromDate = params.fromDate; + if (params.toDate !== undefined) variables.toDate = params.toDate; + if (params.organizerEmail !== undefined) + variables.organizers = Array.isArray(params.organizerEmail) + ? params.organizerEmail + : [params.organizerEmail]; + if (params.participantEmail !== undefined) + variables.participants = Array.isArray(params.participantEmail) + ? params.participantEmail + : [params.participantEmail]; + if (params.keyword !== undefined) variables.keyword = params.keyword; + if (params.mine !== undefined) variables.mine = params.mine; + + const data = await this.graphql(query, variables); + return data ? data.transcripts : []; + } + + /** + * A single transcript with full detail: attendees (with emails), + * summary, and every sentence. + * `transcript(id: ID!)` + */ + async getTranscript(id) { + const query = `query GetTranscript($id: String!) { + transcript(id: $id) { + id + title + date + dateString + duration + host_email + organizer_email + participants + meeting_link + transcript_url + audio_url + video_url + meeting_attendees { + displayName + email + name + phoneNumber + location + } + speakers { + id + name + } + summary { + overview + short_summary + keywords + action_items + bullet_gist + gist + outline + shorthand_bullet + topics_discussed + } + sentences { + index + speaker_name + speaker_id + text + start_time + end_time + } + } + }`; + const data = await this.graphql(query, { id }); + return data ? data.transcript : undefined; + } + + /** + * Just the AI summary block for a transcript. + */ + async getTranscriptSummary(id) { + const query = `query GetTranscriptSummary($id: String!) { + transcript(id: $id) { + id + title + summary { + overview + short_summary + keywords + action_items + bullet_gist + gist + outline + shorthand_bullet + topics_discussed + } + } + }`; + const data = await this.graphql(query, { id }); + return data ? data.transcript : undefined; + } + + /** + * Keyword search across transcripts. Thin wrapper over listTranscripts. + */ + async searchTranscripts(keyword, params = {}) { + return this.listTranscripts({ ...params, keyword }); + } +} + +module.exports = { Api }; diff --git a/packages/v1-ready/fireflies/defaultConfig.json b/packages/v1-ready/fireflies/defaultConfig.json new file mode 100644 index 0000000..00fa2ae --- /dev/null +++ b/packages/v1-ready/fireflies/defaultConfig.json @@ -0,0 +1,10 @@ +{ + "name": "fireflies", + "config": { + "apiKey": true, + "batch": { + "concurrency": 3, + "delay": 1000 + } + } +} diff --git a/packages/v1-ready/fireflies/definition.js b/packages/v1-ready/fireflies/definition.js new file mode 100644 index 0000000..654c71c --- /dev/null +++ b/packages/v1-ready/fireflies/definition.js @@ -0,0 +1,88 @@ +require('dotenv').config(); +const { Api } = require('./api'); +const { get } = require('@friggframework/core'); +const config = require('./defaultConfig.json'); + +const Definition = { + API: Api, + getName: () => config.name, + moduleName: config.name, + modelName: 'Fireflies', + requiredAuthMethods: { + // API-key module: renders the interactive CLI / hosted auth form. + getAuthorizationRequirements: (api) => + api.getAuthorizationRequirements(), + + // Core's process-authorization-callback calls setAuthParams(api, params) + // for every non-oauth2 module. Without it, the real callback throws + // `TypeError: setAuthParams is not a function`. Set the key onto the api. + setAuthParams: async (api, params) => { + const key = + get(params, 'api_key', null) || + get(params, 'access_token', null) || + get(params?.data || {}, 'api_key', null) || + get(params?.data || {}, 'access_token', null); + if (key) api.setApiKey(key); + }, + + // API-key exchange is a no-op — the key IS the credential. Persist it. + getToken: async (api, params) => { + const apiKey = + get(params, 'api_key', null) || + get(params, 'access_token', null) || + get(params.data || {}, 'api_key', null); + if (apiKey) { + api.setApiKey(apiKey); + } + return { access_token: api.api_key, api_key: api.api_key }; + }, + + getEntityDetails: async (api, callbackParams, tokenResponse, userId) => { + const user = await api.getUser(); + if (!user || !user.user_id) { + throw new Error( + 'Fireflies user query failed to return valid user info. ' + + 'Response: ' + + JSON.stringify(user) + ); + } + return { + identifiers: { externalId: user.user_id, userId }, + details: { name: user.name || user.email }, + }; + }, + + getCredentialDetails: async (api, userId) => { + const user = await api.getUser(); + if (!user || !user.user_id) { + throw new Error( + 'Fireflies user query failed to return valid user info. ' + + 'Response: ' + + JSON.stringify(user) + ); + } + return { + identifiers: { externalId: user.user_id, userId }, + details: {}, + }; + }, + + testAuthRequest: async (api) => { + const user = await api.getUser(); + if (!user || !user.user_id) { + throw new Error('Fireflies token is not valid'); + } + return user; + }, + + apiPropertiesToPersist: { + credential: ['access_token', 'api_key'], + entity: [], + }, + }, + env: { + api_key: process.env.FIREFLIES_API_KEY, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/fireflies/fireflies.operations.json b/packages/v1-ready/fireflies/fireflies.operations.json new file mode 100644 index 0000000..0396c13 --- /dev/null +++ b/packages/v1-ready/fireflies/fireflies.operations.json @@ -0,0 +1,183 @@ +{ + "info": { + "title": "Fireflies.ai GraphQL API", + "version": "1.0.0", + "description": "Canonical machine-readable manifest of the GraphQL operations the hand-written client in api.js sends. Fireflies exposes a SINGLE GraphQL endpoint, so there is no REST path surface to describe with OpenAPI; this manifest is the GraphQL-native equivalent of the reevo.openapi.yaml used elsewhere in this library. Every operation, its variables, and its selected fields were verified 1:1 against the public Fireflies GraphQL docs (see `docs` on each operation and `sources` below) and against what api.js actually transmits. It is the source of truth the client mirrors; tests/spec-sync.test.js asserts client and manifest agree.", + "contact": { "name": "Left Hook", "url": "https://lefthook.com" } + }, + "endpoint": "https://api.fireflies.ai/graphql", + "transport": { + "method": "POST", + "contentType": "application/json", + "accept": "application/json", + "bodyShape": "{ query, variables }", + "note": "GraphQL: one endpoint, one HTTP method (POST). The operation is selected by the query text in the request body, not by URL path or HTTP verb." + }, + "auth": { + "type": "bearer", + "header": "Authorization", + "valueFormat": "Bearer ", + "credential": "Fireflies API key (Settings > Developer Settings / Integrations > Fireflies API)", + "note": "The stored credential is the bare key; api.js's addAuthHeaders() adds the 'Bearer ' prefix at request time so it never lives in the DB.", + "docs": "https://docs.fireflies.ai/fundamentals/authorization" + }, + "sources": [ + "https://docs.fireflies.ai/fundamentals/authorization", + "https://docs.fireflies.ai/graphql-api/query/user", + "https://docs.fireflies.ai/graphql-api/query/transcripts", + "https://docs.fireflies.ai/graphql-api/query/transcript" + ], + "operations": [ + { + "operationId": "getUser", + "clientMethod": "getUser", + "type": "query", + "graphqlOperation": "user", + "summary": "Fetch the authenticated user (owner of the API key). Used by the module's auth-test / entity / credential flow.", + "docs": "https://docs.fireflies.ai/graphql-api/query/user", + "variables": [], + "argsSentToServer": {}, + "selectedFields": ["user_id", "name", "email", "is_admin"], + "query": "query { user { user_id name email is_admin } }", + "returns": "data.user", + "grounding": "Docs confirm the User type exposes user_id, name, email, is_admin (schema listing: user_id, recent_transcript, recent_meeting, num_transcripts, name, minutes_consumed, is_admin, integrations, email, user_groups). `user` with no id argument returns the API-key owner." + }, + { + "operationId": "listTranscripts", + "clientMethod": "listTranscripts", + "type": "query", + "graphqlOperation": "transcripts", + "summary": "List meeting transcripts, newest first. All arguments optional.", + "docs": "https://docs.fireflies.ai/graphql-api/query/transcripts", + "variables": [ + { "name": "limit", "type": "Int" }, + { "name": "skip", "type": "Int" }, + { "name": "fromDate", "type": "DateTime" }, + { "name": "toDate", "type": "DateTime" }, + { "name": "organizers", "type": "[String]" }, + { "name": "participants", "type": "[String]" }, + { "name": "keyword", "type": "String" }, + { "name": "mine", "type": "Boolean" } + ], + "argsSentToServer": { + "limit": "$limit", + "skip": "$skip", + "fromDate": "$fromDate", + "toDate": "$toDate", + "organizers": "$organizers", + "participants": "$participants", + "keyword": "$keyword", + "mine": "$mine" + }, + "selectedFields": [ + "id", + "title", + "date", + "dateString", + "duration", + "host_email", + "organizer_email", + "participants", + "meeting_link", + "transcript_url", + "meeting_attendees { displayName email name phoneNumber location }" + ], + "query": "query ListTranscripts( $limit: Int $skip: Int $fromDate: DateTime $toDate: DateTime $organizers: [String] $participants: [String] $keyword: String $mine: Boolean ) { transcripts( limit: $limit skip: $skip fromDate: $fromDate toDate: $toDate organizers: $organizers participants: $participants keyword: $keyword mine: $mine ) { id title date dateString duration host_email organizer_email participants meeting_link transcript_url meeting_attendees { displayName email name phoneNumber location } } }", + "returns": "data.transcripts", + "grounding": "Docs confirm transcripts(...) accepts limit (Int), skip (Int), fromDate (DateTime), toDate (DateTime), organizers ([String]), participants ([String]), keyword (String), mine (Boolean). Fireflies deprecated the scalar organizer_email / participant_email arguments in favor of the array organizers / participants arguments; the client's public methods still accept a single email and wrap it in an array internally. meeting_attendees fields (displayName, email, name, phoneNumber, location) confirmed on the Transcript type." + }, + { + "operationId": "getTranscript", + "clientMethod": "getTranscript", + "type": "query", + "graphqlOperation": "transcript", + "summary": "A single transcript in full detail: attendees (with emails), speakers, AI summary, and every sentence.", + "docs": "https://docs.fireflies.ai/graphql-api/query/transcript", + "variables": [{ "name": "id", "type": "String!" }], + "argsSentToServer": { "id": "$id" }, + "selectedFields": [ + "id", + "title", + "date", + "dateString", + "duration", + "host_email", + "organizer_email", + "participants", + "meeting_link", + "transcript_url", + "audio_url", + "video_url", + "meeting_attendees { displayName email name phoneNumber location }", + "speakers { id name }", + "summary { overview short_summary keywords action_items bullet_gist gist outline shorthand_bullet topics_discussed }", + "sentences { index speaker_name speaker_id text start_time end_time }" + ], + "query": "query GetTranscript($id: String!) { transcript(id: $id) { id title date dateString duration host_email organizer_email participants meeting_link transcript_url audio_url video_url meeting_attendees { displayName email name phoneNumber location } speakers { id name } summary { overview short_summary keywords action_items bullet_gist gist outline shorthand_bullet topics_discussed } sentences { index speaker_name speaker_id text start_time end_time } } }", + "returns": "data.transcript", + "grounding": "Docs confirm the query signature is transcript(id: String!). All selected fields verified on the Transcript type: top-level (id, title, date, dateString, duration, host_email, organizer_email, participants, meeting_link, transcript_url, audio_url, video_url); speakers { id name }; meeting_attendees { displayName email name phoneNumber location }; summary { overview short_summary keywords action_items bullet_gist gist outline shorthand_bullet topics_discussed }; sentences { index speaker_name speaker_id text start_time end_time }." + }, + { + "operationId": "getTranscriptSummary", + "clientMethod": "getTranscriptSummary", + "type": "query", + "graphqlOperation": "transcript", + "summary": "Only the AI summary block for a transcript (subset of the getTranscript selection).", + "docs": "https://docs.fireflies.ai/graphql-api/query/transcript", + "variables": [{ "name": "id", "type": "String!" }], + "argsSentToServer": { "id": "$id" }, + "selectedFields": [ + "id", + "title", + "summary { overview short_summary keywords action_items bullet_gist gist outline shorthand_bullet topics_discussed }" + ], + "query": "query GetTranscriptSummary($id: String!) { transcript(id: $id) { id title summary { overview short_summary keywords action_items bullet_gist gist outline shorthand_bullet topics_discussed } } }", + "returns": "data.transcript", + "grounding": "Same transcript(id: String!) query as getTranscript; selects only id, title, and the summary block. All summary fields verified on the Transcript.summary type." + }, + { + "operationId": "searchTranscripts", + "clientMethod": "searchTranscripts", + "type": "query", + "graphqlOperation": "transcripts", + "summary": "Keyword search across transcripts. Convenience wrapper that presets the `keyword` argument and delegates to the transcripts query (identical wire query to listTranscripts).", + "docs": "https://docs.fireflies.ai/graphql-api/query/transcripts", + "variables": [ + { "name": "limit", "type": "Int" }, + { "name": "skip", "type": "Int" }, + { "name": "fromDate", "type": "DateTime" }, + { "name": "toDate", "type": "DateTime" }, + { "name": "organizers", "type": "[String]" }, + { "name": "participants", "type": "[String]" }, + { "name": "keyword", "type": "String" }, + { "name": "mine", "type": "Boolean" } + ], + "argsSentToServer": { + "limit": "$limit", + "skip": "$skip", + "fromDate": "$fromDate", + "toDate": "$toDate", + "organizers": "$organizers", + "participants": "$participants", + "keyword": "$keyword", + "mine": "$mine" + }, + "selectedFields": [ + "id", + "title", + "date", + "dateString", + "duration", + "host_email", + "organizer_email", + "participants", + "meeting_link", + "transcript_url", + "meeting_attendees { displayName email name phoneNumber location }" + ], + "query": "query ListTranscripts( $limit: Int $skip: Int $fromDate: DateTime $toDate: DateTime $organizers: [String] $participants: [String] $keyword: String $mine: Boolean ) { transcripts( limit: $limit skip: $skip fromDate: $fromDate toDate: $toDate organizers: $organizers participants: $participants keyword: $keyword mine: $mine ) { id title date dateString duration host_email organizer_email participants meeting_link transcript_url meeting_attendees { displayName email name phoneNumber location } } }", + "returns": "data.transcripts", + "grounding": "Delegates to listTranscripts with keyword preset, so it emits the exact same `transcripts` query. `keyword` is the documented (non-deprecated) search argument." + } + ] +} diff --git a/packages/v1-ready/fireflies/index.js b/packages/v1-ready/fireflies/index.js new file mode 100644 index 0000000..3c94a63 --- /dev/null +++ b/packages/v1-ready/fireflies/index.js @@ -0,0 +1,7 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); + +module.exports = { + Api, + Definition, +}; diff --git a/packages/v1-ready/fireflies/package.json b/packages/v1-ready/fireflies/package.json new file mode 100644 index 0000000..50b4d89 --- /dev/null +++ b/packages/v1-ready/fireflies/package.json @@ -0,0 +1,23 @@ +{ + "name": "@friggframework/api-module-fireflies", + "version": "1.0.0", + "description": "Fireflies.ai API module that lets the Frigg Framework interact with Fireflies.ai (GraphQL)", + "main": "index.js", + "scripts": { + "lint:fix": "prettier --write --loglevel error . && eslint . --fix", + "test": "jest" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3" + }, + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/fireflies/tests/api.test.js b/packages/v1-ready/fireflies/tests/api.test.js new file mode 100644 index 0000000..77a39cf --- /dev/null +++ b/packages/v1-ready/fireflies/tests/api.test.js @@ -0,0 +1,201 @@ +const { Api } = require('../api'); + +/** + * Offline tests. We inject a fake `fetch` (supported by the Requester base via + * the `fetch` constructor param) and assert on the exact request the module + * builds: the GraphQL endpoint, the `Authorization: Bearer ` header, and + * the JSON `{ query, variables }` body. + */ +function makeApi(captured, responseData) { + const fakeFetch = async (url, options) => { + captured.url = url; + captured.options = options; + return { + status: 200, + headers: { + get: (name) => + name.toLowerCase() === 'content-type' + ? 'application/json' + : null, + }, + json: async () => ({ data: responseData }), + text: async () => JSON.stringify({ data: responseData }), + }; + }; + return new Api({ api_key: 'ff_test_key_123', fetch: fakeFetch }); +} + +describe('Fireflies Api', () => { + describe('auth header', () => { + it('sends Authorization: Bearer to the GraphQL endpoint', async () => { + const captured = {}; + const api = makeApi(captured, { user: { user_id: 'u1' } }); + + await api.getUser(); + + expect(captured.url).toBe('https://api.fireflies.ai/graphql'); + expect(captured.options.method).toBe('POST'); + expect(captured.options.headers.Authorization).toBe( + 'Bearer ff_test_key_123' + ); + expect(captured.options.headers['Content-Type']).toBe( + 'application/json' + ); + }); + + it('does not persist the "Bearer " prefix on the stored key', () => { + const api = makeApi({}, {}); + expect(api.api_key).toBe('ff_test_key_123'); + }); + }); + + describe('listTranscripts()', () => { + it('POSTs a GraphQL body with the transcripts query and variables', async () => { + const captured = {}; + const api = makeApi(captured, { + transcripts: [{ id: 't1', title: 'Call' }], + }); + + const result = await api.listTranscripts({ + limit: 5, + skip: 0, + fromDate: '2026-01-01T00:00:00.000Z', + }); + + const body = JSON.parse(captured.options.body); + expect(body.query).toContain('transcripts('); + expect(body.query).toContain('meeting_attendees'); + expect(body.query).toContain('email'); + expect(body.variables).toEqual({ + limit: 5, + skip: 0, + fromDate: '2026-01-01T00:00:00.000Z', + }); + expect(result).toEqual([{ id: 't1', title: 'Call' }]); + }); + + it('omits variables that were not supplied', async () => { + const captured = {}; + const api = makeApi(captured, { transcripts: [] }); + + await api.listTranscripts({ limit: 10 }); + + const body = JSON.parse(captured.options.body); + expect(body.variables).toEqual({ limit: 10 }); + }); + + it('uses the array organizers/participants args (not the deprecated scalar *_email args) and wraps a single email', async () => { + const captured = {}; + const api = makeApi(captured, { transcripts: [] }); + + await api.listTranscripts({ + organizerEmail: 'host@example.com', + participantEmail: 'guest@example.com', + }); + + const body = JSON.parse(captured.options.body); + // deprecated scalar args must be gone from the query text + expect(body.query).not.toContain('organizer_email:'); + expect(body.query).not.toContain('participant_email:'); + expect(body.query).toContain('organizers: $organizers'); + expect(body.query).toContain('participants: $participants'); + expect(body.query).toContain('$organizers: [String]'); + expect(body.query).toContain('$participants: [String]'); + // a single email is wrapped in an array on the wire + expect(body.variables).toEqual({ + organizers: ['host@example.com'], + participants: ['guest@example.com'], + }); + }); + + it('passes an array of emails through unchanged', async () => { + const captured = {}; + const api = makeApi(captured, { transcripts: [] }); + + await api.listTranscripts({ + organizerEmail: ['a@example.com', 'b@example.com'], + }); + + const body = JSON.parse(captured.options.body); + expect(body.variables).toEqual({ + organizers: ['a@example.com', 'b@example.com'], + }); + }); + }); + + describe('getTranscript()', () => { + it('POSTs the single-transcript query with an id variable and requests summary + sentences', async () => { + const captured = {}; + const api = makeApi(captured, { + transcript: { id: 't42', title: 'Deep Dive' }, + }); + + const result = await api.getTranscript('t42'); + + const body = JSON.parse(captured.options.body); + expect(body.query).toContain('transcript(id: $id)'); + expect(body.query).toContain('summary {'); + expect(body.query).toContain('sentences {'); + expect(body.query).toContain('meeting_attendees {'); + expect(body.variables).toEqual({ id: 't42' }); + expect(result).toEqual({ id: 't42', title: 'Deep Dive' }); + }); + }); + + describe('getTranscriptSummary()', () => { + it('requests only the summary block', async () => { + const captured = {}; + const api = makeApi(captured, { + transcript: { id: 't7', summary: { overview: 'x' } }, + }); + + await api.getTranscriptSummary('t7'); + + const body = JSON.parse(captured.options.body); + expect(body.query).toContain('summary {'); + expect(body.query).not.toContain('sentences {'); + expect(body.variables).toEqual({ id: 't7' }); + }); + }); + + describe('searchTranscripts()', () => { + it('passes the keyword through as a transcripts variable', async () => { + const captured = {}; + const api = makeApi(captured, { transcripts: [] }); + + await api.searchTranscripts('pricing', { limit: 3 }); + + const body = JSON.parse(captured.options.body); + expect(body.variables).toEqual({ limit: 3, keyword: 'pricing' }); + }); + }); + + describe('graphql() error handling', () => { + it('throws when the response carries a GraphQL errors array', async () => { + const fakeFetch = async () => ({ + status: 200, + headers: { + get: () => 'application/json', + }, + json: async () => ({ + errors: [{ message: 'Not authorized' }], + }), + text: async () => + JSON.stringify({ errors: [{ message: 'Not authorized' }] }), + }); + const api = new Api({ api_key: 'k', fetch: fakeFetch }); + + await expect(api.getUser()).rejects.toThrow(/Not authorized/); + }); + }); + + describe('getAuthorizationRequirements()', () => { + it('returns an apiKey JSON Schema form with a password api_key field', () => { + const api = new Api({ api_key: 'k' }); + const reqs = api.getAuthorizationRequirements(); + expect(reqs.type).toBe('apiKey'); + expect(reqs.data.jsonSchema.required).toContain('api_key'); + expect(reqs.data.uiSchema.api_key['ui:widget']).toBe('password'); + }); + }); +}); diff --git a/packages/v1-ready/fireflies/tests/definition.test.js b/packages/v1-ready/fireflies/tests/definition.test.js new file mode 100644 index 0000000..286ff94 --- /dev/null +++ b/packages/v1-ready/fireflies/tests/definition.test.js @@ -0,0 +1,132 @@ +const { Definition } = require('../definition'); + +const { requiredAuthMethods } = Definition; + +const validUser = { + user_id: 'ff-user-9', + name: 'Test User', + email: 'test@example.com', +}; + +function makeStubApi(user, keyRef = {}) { + return { + api_key: keyRef.api_key, + getUser: async () => user, + setApiKey(k) { + this.api_key = k; + keyRef.api_key = k; + }, + }; +} + +describe('Fireflies Definition', () => { + it('is an api-key module named "fireflies"', () => { + expect(Definition.moduleName).toBe('fireflies'); + expect(Definition.getName()).toBe('fireflies'); + }); + + describe('getToken()', () => { + it('stores the supplied api_key and returns it as the credential', async () => { + const api = makeStubApi(validUser); + const token = await requiredAuthMethods.getToken(api, { + api_key: 'sk_from_form', + }); + expect(api.api_key).toBe('sk_from_form'); + expect(token).toEqual({ + access_token: 'sk_from_form', + api_key: 'sk_from_form', + }); + }); + }); + + describe('setAuthParams()', () => { + it('exists (core calls it on the real non-oauth2 callback)', () => { + expect(typeof requiredAuthMethods.setAuthParams).toBe('function'); + }); + + it('sets the api key from api_key', async () => { + const api = makeStubApi(validUser); + await requiredAuthMethods.setAuthParams(api, { + api_key: 'sk_from_callback', + }); + expect(api.api_key).toBe('sk_from_callback'); + }); + + it('falls back to access_token and to nested data', async () => { + const a1 = makeStubApi(validUser); + await requiredAuthMethods.setAuthParams(a1, { + access_token: 'sk_access', + }); + expect(a1.api_key).toBe('sk_access'); + + const a2 = makeStubApi(validUser); + await requiredAuthMethods.setAuthParams(a2, { + data: { api_key: 'sk_nested' }, + }); + expect(a2.api_key).toBe('sk_nested'); + }); + }); + + describe('testAuthRequest()', () => { + it('resolves with the user payload for a valid key', async () => { + const api = makeStubApi(validUser); + await expect( + requiredAuthMethods.testAuthRequest(api) + ).resolves.toEqual(validUser); + }); + + it('rejects when the user query returns nothing', async () => { + const api = makeStubApi(undefined); + await expect( + requiredAuthMethods.testAuthRequest(api) + ).rejects.toThrow(/not valid/i); + }); + }); + + describe('getEntityDetails()', () => { + it('returns identifiers keyed on the Fireflies user_id', async () => { + const api = makeStubApi(validUser); + const result = await requiredAuthMethods.getEntityDetails( + api, + null, + null, + 'frigg-user-1' + ); + expect(result.identifiers).toEqual({ + externalId: 'ff-user-9', + userId: 'frigg-user-1', + }); + expect(result.details.name).toBe('Test User'); + }); + + it('rejects when the user payload lacks user_id', async () => { + const api = makeStubApi({ email: 'x@y.com' }); + await expect( + requiredAuthMethods.getEntityDetails(api, null, null, 'u') + ).rejects.toThrow(/valid user info/i); + }); + }); + + describe('getCredentialDetails()', () => { + it('returns identifiers with empty details', async () => { + const api = makeStubApi(validUser); + const result = await requiredAuthMethods.getCredentialDetails( + api, + 'frigg-user-1' + ); + expect(result.identifiers).toEqual({ + externalId: 'ff-user-9', + userId: 'frigg-user-1', + }); + expect(result.details).toEqual({}); + }); + }); + + describe('apiPropertiesToPersist', () => { + it('persists the credential key', () => { + expect( + requiredAuthMethods.apiPropertiesToPersist.credential + ).toEqual(expect.arrayContaining(['api_key'])); + }); + }); +}); diff --git a/packages/v1-ready/fireflies/tests/spec-sync.test.js b/packages/v1-ready/fireflies/tests/spec-sync.test.js new file mode 100644 index 0000000..f282a6d --- /dev/null +++ b/packages/v1-ready/fireflies/tests/spec-sync.test.js @@ -0,0 +1,136 @@ +const fs = require('fs'); +const path = require('path'); +const { Api } = require('../api'); + +/** + * GraphQL manifest <-> client sync. + * + * Fireflies is a single-endpoint GraphQL API, so there is no REST path surface + * for an OpenAPI document to describe. This is the GraphQL-native equivalent of + * the reevo.openapi.yaml + spec-sync.test.js pair used elsewhere in the library: + * fireflies.operations.json is the canonical manifest of the operations the + * client sends, and this test asserts the hand-written client in api.js and the + * manifest cannot drift apart. + * + * It is stronger than a name-only check: it drives every client method through a + * fake `fetch`, captures the exact request, and asserts the endpoint, the + * `Authorization: Bearer ` header, and the whitespace-normalized GraphQL + * query string all match what the manifest declares. + */ +const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'fireflies.operations.json'), 'utf8') +); + +// GraphQL ignores insignificant whitespace; compare on a normalized form so the +// manifest is coupled to the operation/fields/variables, not to indentation. +const normalize = (s) => String(s).replace(/\s+/g, ' ').trim(); + +// Api.prototype members that are transport/config, not GraphQL operations. +const NON_OPERATION_METHODS = new Set([ + 'constructor', + 'graphql', // the shared transport + 'addAuthHeaders', // auth plumbing + 'getAuthorizationRequirements', // static form descriptor, no network call +]); + +// How to invoke each client method so it emits its request. Keyed by clientMethod. +const INVOKE = { + getUser: [], + listTranscripts: [{ limit: 1 }], + getTranscript: ['transcript-id-1'], + getTranscriptSummary: ['transcript-id-1'], + searchTranscripts: ['pricing'], +}; + +function makeApi(captured) { + const fakeFetch = async (url, options) => { + captured.url = url; + captured.options = options; + return { + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ data: {} }), + text: async () => JSON.stringify({ data: {} }), + }; + }; + return new Api({ api_key: 'spec_sync_key', fetch: fakeFetch }); +} + +const clientMethods = Object.getOwnPropertyNames(Api.prototype).filter( + (m) => typeof Api.prototype[m] === 'function' && m !== 'constructor' +); +const graphqlIssuingMethods = clientMethods.filter( + (m) => !NON_OPERATION_METHODS.has(m) +); +const manifestMethods = manifest.operations.map((op) => op.clientMethod); + +describe('GraphQL manifest <-> client sync', () => { + it('every manifest operation has a matching client method', () => { + const missing = manifestMethods.filter( + (m) => !clientMethods.includes(m) + ); + expect(missing).toEqual([]); + }); + + it('every GraphQL-issuing client method is declared in the manifest (1:1, no orphans)', () => { + const undeclared = graphqlIssuingMethods.filter( + (m) => !manifestMethods.includes(m) + ); + expect(undeclared).toEqual([]); + }); + + it('operationId equals clientMethod for every operation', () => { + for (const op of manifest.operations) { + expect(op.operationId).toBe(op.clientMethod); + } + }); + + it('the manifest endpoint matches the client baseUrl', () => { + const api = new Api({ api_key: 'x' }); + expect(manifest.endpoint).toBe(api.baseUrl); + }); + + it('declares Bearer auth via the Authorization header', () => { + expect(manifest.auth.type).toBe('bearer'); + expect(manifest.auth.header).toBe('Authorization'); + expect(manifest.auth.valueFormat).toBe('Bearer '); + }); + + it('every operation, when invoked, POSTs the exact query the manifest declares', async () => { + for (const op of manifest.operations) { + const captured = {}; + const api = makeApi(captured); + const args = INVOKE[op.clientMethod]; + expect(args).toBeDefined(); // guard: a new method needs an INVOKE entry + await api[op.clientMethod](...args); + + // endpoint + transport + expect(captured.url).toBe(manifest.endpoint); + expect(captured.options.method).toBe(manifest.transport.method); + expect(captured.options.headers['Content-Type']).toBe( + manifest.transport.contentType + ); + + // auth: Authorization: Bearer + expect(captured.options.headers[manifest.auth.header]).toBe( + 'Bearer spec_sync_key' + ); + + // the actual GraphQL query matches the manifest 1:1 (normalized) + const body = JSON.parse(captured.options.body); + expect(normalize(body.query)).toBe(normalize(op.query)); + } + }); + + it('each operation selects the fields the manifest lists', async () => { + for (const op of manifest.operations) { + const captured = {}; + const api = makeApi(captured); + await api[op.clientMethod](...INVOKE[op.clientMethod]); + const query = normalize(JSON.parse(captured.options.body).query); + for (const field of op.selectedFields) { + expect(query).toContain(normalize(field)); + } + } + }); +}); diff --git a/packages/v1-ready/gong/.env.example b/packages/v1-ready/gong/.env.example new file mode 100644 index 0000000..5fdf134 --- /dev/null +++ b/packages/v1-ready/gong/.env.example @@ -0,0 +1,10 @@ +# Gong Basic auth credentials. Combined as Base64(accessKey:accessKeySecret) +# and sent in the `Authorization: Basic ` header. +# Create them under Company Settings → API in Gong (technical administrators only). +GONG_ACCESS_KEY=your_gong_access_key_here +GONG_ACCESS_KEY_SECRET=your_gong_access_key_secret_here + +# Optional: your company-specific API base URL (e.g. https://us-55616.api.gong.io). +# Find it at https://app.gong.io/company/api-authentication. Defaults to +# https://api.gong.io/v2 when unset. +GONG_BASE_URL= diff --git a/packages/v1-ready/gong/LICENSE.md b/packages/v1-ready/gong/LICENSE.md new file mode 100644 index 0000000..77f5cc2 --- /dev/null +++ b/packages/v1-ready/gong/LICENSE.md @@ -0,0 +1,16 @@ +MIT License + +Copyright (c) 2022 Left Hook Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/v1-ready/gong/README.md b/packages/v1-ready/gong/README.md new file mode 100644 index 0000000..b26edba --- /dev/null +++ b/packages/v1-ready/gong/README.md @@ -0,0 +1,107 @@ +# @friggframework/api-module-gong + +A Frigg API module for [Gong](https://www.gong.io) — the Revenue Intelligence +platform that records, transcribes, and analyzes customer-facing conversations. +This module lets a Frigg integration list and retrieve calls, pull detailed call +data (including attendee emails), fetch transcripts, and list users. + +## Features + +- HTTP Basic authentication (Access Key + Access Key Secret) +- List calls by date range and retrieve a single call +- Detailed call data via `POST /v2/calls/extensive`, exposing `parties[].emailAddress` + (the field used to match a conversation attendee to a CRM contact) +- Call transcripts via `POST /v2/calls/transcript` +- List/retrieve users +- Company-specific base URL support + +## Installation + +```bash +npm install @friggframework/api-module-gong +``` + +## Authentication + +Gong uses HTTP Basic auth. Credentials are combined as +`Base64(accessKey:accessKeySecret)` and sent in the +`Authorization: Basic ` header. Create an Access Key + Secret under +**Company Settings → API** in Gong (you must be a technical administrator). + +The module accepts the Gong-native names `access_key` and `access_key_secret` +and maps them onto the framework's Basic auth `username`/`password`. + +### Environment variables + +```env +GONG_ACCESS_KEY=your_gong_access_key +GONG_ACCESS_KEY_SECRET=your_gong_access_key_secret +# Optional; defaults to https://api.gong.io/v2 +GONG_BASE_URL=https://us-55616.api.gong.io +``` + +Your company-specific base URL is shown at +`https://app.gong.io/company/api-authentication`. + +## Usage + +```javascript +const { Api, Definition } = require('@friggframework/api-module-gong'); + +const api = new Api({ + access_key: process.env.GONG_ACCESS_KEY, + access_key_secret: process.env.GONG_ACCESS_KEY_SECRET, + base_url: process.env.GONG_BASE_URL, // optional +}); + +// List calls in a date range +const calls = await api.listCalls({ + fromDateTime: '2026-08-01T00:00:00Z', + toDateTime: '2026-08-19T00:00:00Z', +}); + +// Detailed call data with attendee emails +const detailed = await api.listCallsExtensive({ + filter: { callIds: ['7782342274025502988'] }, + contentSelector: { exposedFields: { parties: true } }, +}); +// → detailed.calls[0].parties[].emailAddress + +// Transcripts +const transcripts = await api.getTranscripts({ + filter: { callIds: ['7782342274025502988'] }, +}); +``` + +## API reference (endpoints) + +| Method | Path | Client method | +|--------|------|---------------| +| GET | `/v2/calls` | `listCalls(params)` | +| GET | `/v2/calls/{id}` | `getCall(callId)` | +| POST | `/v2/calls/extensive` | `listCallsExtensive(body)` | +| POST | `/v2/calls/transcript` | `getTranscripts(body)` | +| GET | `/v2/users` | `listUsers(params)` | +| GET | `/v2/users/{id}` | `getUser(userId)` | + +The machine-readable contract is in [`gong.openapi.yaml`](./gong.openapi.yaml), +kept in sync with `api.js` by `tests/spec-sync.test.js`. + +## Notes + +- **Rate limits:** 3 requests/second and 10,000 requests/day by default. On + `429`, respect the `Retry-After` header. +- **API access tier:** Gong's public API requires an API-enabled Gong package; + Access Keys are created by a technical administrator. If your workspace does + not have API access, contact Gong to enable it. This module is built to the + public documentation regardless. +- **Attendee emails:** only `POST /v2/calls/extensive` returns `parties[]` with + `emailAddress`. The plain `GET /v2/calls` list does not include party emails. + Transcripts reference speakers by `speakerId`, which you resolve to an email + via the `parties` returned from the extensive endpoint. + +Docs: https://help.gong.io/apidocs/introduction-2 + +## License + +MIT diff --git a/packages/v1-ready/gong/api.js b/packages/v1-ready/gong/api.js new file mode 100644 index 0000000..3bd7079 --- /dev/null +++ b/packages/v1-ready/gong/api.js @@ -0,0 +1,188 @@ +const { BasicAuthRequester, ModuleConstants, get } = require('@friggframework/core'); + +/** + * Gong public API client. + * + * Gong (gong.io) is a Revenue Intelligence platform that records, transcribes, + * and analyzes customer-facing conversations (calls, meetings, emails). Its + * public API is a REST surface authenticated with HTTP Basic auth: an Access + * Key and an Access Key Secret are combined as `Base64(accessKey:accessKeySecret)` + * and sent in the `Authorization: Basic ` header. + * + * - Create credentials: https://app.gong.io/company/api (technical admin only) + * - Base URL: company-specific, discoverable at + * https://app.gong.io/company/api-authentication (e.g. https://us-55616.api.gong.io). + * The generic host https://api.gong.io/v2 is the documented default and is + * used here unless a `base_url` param overrides it. + * - Rate limits: 3 calls/sec, 10,000 calls/day; 429 + Retry-After when exceeded. + * + * Docs: https://help.gong.io/apidocs/introduction-2 + * + * The canonical machine-readable contract lives in ./gong.openapi.yaml — this + * client mirrors it 1:1 (one method per operationId). Keep them in sync. + */ +class Api extends BasicAuthRequester { + constructor(params) { + super(params); + + // Gong Basic auth = Base64(accessKey:accessKeySecret). We accept the + // Gong-native names (access_key / access_key_secret) and map them onto + // BasicAuthRequester's username/password, which build the header. + this.access_key = get(params, 'access_key', null); + this.access_key_secret = get(params, 'access_key_secret', null); + if (this.access_key) this.username = this.access_key; + if (this.access_key_secret) this.password = this.access_key_secret; + + // Company-specific base URL is supported; default to the documented host. + this.baseUrl = get(params, 'base_url', null) || 'https://api.gong.io/v2'; + + this.URLs = { + calls: '/calls', + callById: (callId) => `/calls/${callId}`, + callsExtensive: '/calls/extensive', + callsTranscript: '/calls/transcript', + users: '/users', + userById: (userId) => `/users/${userId}`, + }; + } + + getAuthorizationRequirements() { + return { + url: null, + type: ModuleConstants.authType.basic, + data: { + jsonSchema: { + type: 'object', + required: ['access_key', 'access_key_secret'], + properties: { + access_key: { + type: 'string', + title: 'Access Key', + }, + access_key_secret: { + type: 'string', + title: 'Access Key Secret', + }, + }, + }, + uiSchema: { + access_key: { + 'ui:help': + 'Create an Access Key under Company Settings → API in Gong (technical administrators only).', + 'ui:placeholder': 'Your Gong Access Key', + }, + access_key_secret: { + 'ui:widget': 'password', + 'ui:help': + 'The Access Key Secret shown alongside your Access Key. Sent as Base64(accessKey:accessKeySecret) in the Basic Authorization header.', + 'ui:placeholder': 'Your Gong Access Key Secret', + }, + }, + }, + }; + } + + // ---- Calls -------------------------------------------------------------- + + /** + * List calls that took place in a date range. + * Query params: fromDateTime, toDateTime (ISO-8601), cursor, workspaceId. + * Returns a page of call metadata plus a `records.cursor` for pagination. + */ + async listCalls(params = {}) { + const query = {}; + if (params.fromDateTime) query.fromDateTime = params.fromDateTime; + if (params.toDateTime) query.toDateTime = params.toDateTime; + if (params.cursor) query.cursor = params.cursor; + if (params.workspaceId) query.workspaceId = params.workspaceId; + + const options = { + url: this.baseUrl + this.URLs.calls, + query, + }; + return this._get(options); + } + + /** Retrieve a single call's metadata by id. */ + async getCall(callId) { + const options = { + url: this.baseUrl + this.URLs.callById(callId), + }; + return this._get(options); + } + + /** + * Retrieve detailed call data by filter. This is the endpoint that carries + * attendee emails: request `contentSelector.exposedFields.parties: true` and + * each returned `calls[].parties[]` includes `emailAddress`, `name`, + * `affiliation` (Internal/External), `speakerId`, `userId`, and `phoneNumber`. + * + * body = { + * filter: { fromDateTime, toDateTime, callIds, primaryUserIds, workspaceId }, + * contentSelector: { exposedFields: { parties: true, ... }, context, contextTiming }, + * cursor + * } + */ + async listCallsExtensive(body) { + const options = { + url: this.baseUrl + this.URLs.callsExtensive, + headers: { 'Content-Type': 'application/json' }, + body, + }; + return this._post(options); + } + + /** + * Retrieve transcripts for calls. Requires `filter` (by callIds and/or a + * date range). Returns `callTranscripts[]`, each with a `transcript[]` of + * monologues keyed by `speakerId` (resolve speakers to emails via the + * `parties` from listCallsExtensive). + * + * body = { filter: { callIds, fromDateTime, toDateTime, workspaceId }, cursor } + */ + async getTranscripts(body) { + const options = { + url: this.baseUrl + this.URLs.callsTranscript, + headers: { 'Content-Type': 'application/json' }, + body, + }; + return this._post(options); + } + + // ---- Users -------------------------------------------------------------- + + /** List all users. Query params: cursor, includeAvatars. */ + async listUsers(params = {}) { + const query = {}; + if (params.cursor) query.cursor = params.cursor; + if (params.includeAvatars !== undefined) { + query.includeAvatars = params.includeAvatars; + } + const options = { + url: this.baseUrl + this.URLs.users, + query, + }; + return this._get(options); + } + + /** Retrieve a single user by id. */ + async getUser(userId) { + const options = { + url: this.baseUrl + this.URLs.userById(userId), + }; + return this._get(options); + } + + // ---- Auth check --------------------------------------------------------- + + /** + * Lightweight authenticated request used to validate credentials. Listing a + * single user page returns 200 for valid keys; invalid keys return 401, + * which the requester surfaces as an error. + */ + async testAuth() { + return this.listUsers({ includeAvatars: false }); + } +} + +module.exports = { Api }; diff --git a/packages/v1-ready/gong/defaultConfig.json b/packages/v1-ready/gong/defaultConfig.json new file mode 100644 index 0000000..029fbcf --- /dev/null +++ b/packages/v1-ready/gong/defaultConfig.json @@ -0,0 +1,10 @@ +{ + "name": "gong", + "label": "Gong", + "authType": "basic", + "productUrl": "https://www.gong.io", + "apiDocs": "https://help.gong.io/apidocs/introduction-2", + "logoUrl": "https://www.gong.io/favicon.ico", + "categories": ["Revenue Intelligence", "Sales", "Conversation Intelligence"], + "description": "Gong Revenue Intelligence API module — list/retrieve calls, detailed call data with attendee emails, transcripts, and users." +} diff --git a/packages/v1-ready/gong/definition.js b/packages/v1-ready/gong/definition.js new file mode 100644 index 0000000..48f9bad --- /dev/null +++ b/packages/v1-ready/gong/definition.js @@ -0,0 +1,89 @@ +require('dotenv').config(); +const crypto = require('crypto'); +const { Api } = require('./api'); +const { get } = require('@friggframework/core'); +const config = require('./defaultConfig.json'); + +// Gong issues a static Access Key + Secret (Basic auth, no OAuth), so there is +// no external account id returned at auth time. We derive a stable, +// non-reversible identifier from the access key so the same credentials always +// map to the same entity/credential. +const keyFingerprint = (accessKey) => + crypto.createHash('sha256').update(String(accessKey)).digest('hex'); + +const Definition = { + API: Api, + getName: function () { + return config.name; + }, + moduleName: config.name, + modelName: 'Gong', + requiredAuthMethods: { + // Renders the interactive CLI / hosted auth form for this Basic-auth + // (API-key style) module. Delegates to the Api class definition. + getAuthorizationRequirements: async (api) => + api.getAuthorizationRequirements(), + + // On the auth callback the user-entered Access Key + Secret arrive in + // `params` (flat or nested under `params.data`). Wire them onto the + // BasicAuthRequester so the Base64(accessKey:accessKeySecret) header is + // built for testAuthRequest and every subsequent call. Without this the + // form credentials are silently dropped and requests run unauthenticated. + setAuthParams: async function (api, params) { + const data = (params && params.data) || {}; + const accessKey = + get(params, 'access_key', null) || + get(data, 'access_key', null); + const accessKeySecret = + get(params, 'access_key_secret', null) || + get(data, 'access_key_secret', null); + + if (accessKey) { + api.access_key = accessKey; + api.username = accessKey; + } + if (accessKeySecret) { + api.access_key_secret = accessKeySecret; + api.password = accessKeySecret; + } + return api; + }, + getEntityDetails: async function ( + api, + callbackParams, + tokenResponse, + userId + ) { + return { + identifiers: { + externalId: keyFingerprint(api.access_key || api.username), + userId, + }, + details: {}, + }; + }, + apiPropertiesToPersist: { + credential: ['access_key', 'access_key_secret'], + entity: [], + }, + getCredentialDetails: async function (api, userId) { + return { + identifiers: { + externalId: keyFingerprint(api.access_key || api.username), + userId, + }, + details: {}, + }; + }, + testAuthRequest: async function (api) { + return api.testAuth(); + }, + }, + env: { + access_key: process.env.GONG_ACCESS_KEY, + access_key_secret: process.env.GONG_ACCESS_KEY_SECRET, + base_url: process.env.GONG_BASE_URL, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/gong/gong.openapi.yaml b/packages/v1-ready/gong/gong.openapi.yaml new file mode 100644 index 0000000..f92ee3e --- /dev/null +++ b/packages/v1-ready/gong/gong.openapi.yaml @@ -0,0 +1,304 @@ +openapi: 3.0.3 +info: + title: Gong Public API (subset) + version: "1.0.0" + description: >- + Gong (gong.io) is a Revenue Intelligence platform. This is the subset of + Gong's public REST API used by the @friggframework/api-module-gong module, + authored by Left Hook from Gong's published API reference + (https://help.gong.io/apidocs/introduction-2). It is the source of truth the + hand-written client in api.js mirrors (one method per operationId). + contact: + name: Left Hook + url: https://lefthook.com +servers: + - url: https://api.gong.io/v2 + description: >- + Gong public API default host. Companies also have a dedicated base URL + (e.g. https://us-55616.api.gong.io) shown at + https://app.gong.io/company/api-authentication. +security: + - BasicAuth: [] +tags: + - name: Calls + - name: Users +paths: + /calls: + get: + tags: [Calls] + operationId: listCalls + summary: List calls in a date range + parameters: + - in: query + name: fromDateTime + schema: { type: string, format: date-time } + description: ISO-8601 start of the date range. + - in: query + name: toDateTime + schema: { type: string, format: date-time } + description: ISO-8601 end of the date range. + - in: query + name: cursor + schema: { type: string } + description: Pagination cursor from a prior response's records.cursor. + - in: query + name: workspaceId + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/CallsList" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /calls/{id}: + get: + tags: [Calls] + operationId: getCall + summary: Retrieve a single call's metadata + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /calls/extensive: + post: + tags: [Calls] + operationId: listCallsExtensive + summary: Retrieve detailed call data by filter (includes attendee emails) + description: >- + Returns detailed call data. Set contentSelector.exposedFields.parties to + true to receive calls[].parties[], each carrying emailAddress, name, + affiliation (Internal/External), speakerId, userId, and phoneNumber. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CallsExtensiveRequest" } + responses: + "200": { $ref: "#/components/responses/CallsExtensiveResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /calls/transcript: + post: + tags: [Calls] + operationId: getTranscripts + summary: Retrieve transcripts of calls + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TranscriptRequest" } + responses: + "200": { $ref: "#/components/responses/TranscriptResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + /users: + get: + tags: [Users] + operationId: listUsers + summary: List all users + parameters: + - in: query + name: cursor + schema: { type: string } + - in: query + name: includeAvatars + schema: { type: boolean } + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /users/{id}: + get: + tags: [Users] + operationId: getUser + summary: Retrieve a single user + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic + description: Base64(accessKey:accessKeySecret) in the Authorization header. + schemas: + CallsExtensiveRequest: + type: object + required: [filter] + properties: + cursor: + type: string + filter: + type: object + properties: + fromDateTime: { type: string, format: date-time } + toDateTime: { type: string, format: date-time } + callIds: + type: array + items: { type: string } + primaryUserIds: + type: array + items: { type: string } + workspaceId: { type: string } + contentSelector: + type: object + properties: + context: + type: string + enum: [None, Extended] + contextTiming: + type: array + items: { type: string, enum: [Now, TimeOfCall] } + exposedFields: + type: object + properties: + parties: { type: boolean } + content: + type: object + properties: + structure: { type: boolean } + topics: { type: boolean } + trackers: { type: boolean } + brief: { type: boolean } + outline: { type: boolean } + highlights: { type: boolean } + callOutcome: { type: boolean } + keyPoints: { type: boolean } + interaction: + type: object + properties: + speakers: { type: boolean } + video: { type: boolean } + personInteractionStats: { type: boolean } + questions: { type: boolean } + collaboration: + type: object + properties: + publicComments: { type: boolean } + media: { type: boolean } + Party: + type: object + properties: + id: { type: string } + emailAddress: + type: string + description: The attendee's email address (used to match a CRM contact). + name: { type: string } + speakerId: { type: string } + userId: { type: string } + phoneNumber: { type: string } + affiliation: + type: string + enum: [Internal, External, Unknown] + methods: + type: array + items: { type: string } + TranscriptRequest: + type: object + required: [filter] + properties: + cursor: + type: string + filter: + type: object + properties: + callIds: + type: array + items: { type: string } + fromDateTime: { type: string, format: date-time } + toDateTime: { type: string, format: date-time } + workspaceId: { type: string } + responses: + ObjectResponse: + description: A JSON object response. + content: + application/json: + schema: { type: object } + CallsList: + description: A paged list of call metadata. + content: + application/json: + schema: + type: object + properties: + requestId: { type: string } + records: + type: object + properties: + totalRecords: { type: integer } + currentPageSize: { type: integer } + currentPageNumber: { type: integer } + cursor: { type: string } + calls: + type: array + items: { type: object } + CallsExtensiveResponse: + description: Detailed call data, including parties with attendee emails. + content: + application/json: + schema: + type: object + properties: + requestId: { type: string } + records: + type: object + properties: + cursor: { type: string } + calls: + type: array + items: + type: object + properties: + metaData: { type: object } + parties: + type: array + items: { $ref: "#/components/schemas/Party" } + TranscriptResponse: + description: Transcripts for the requested calls. + content: + application/json: + schema: + type: object + properties: + requestId: { type: string } + records: + type: object + properties: + cursor: { type: string } + callTranscripts: + type: array + items: + type: object + properties: + callId: { type: string } + transcript: + type: array + items: + type: object + properties: + speakerId: { type: string } + topic: { type: string } + sentences: + type: array + items: + type: object + properties: + start: { type: integer } + end: { type: integer } + text: { type: string } + Unauthorized: + description: Missing or invalid credentials. + content: + application/json: + schema: { type: object } + RateLimited: + description: Rate limit exceeded; see the Retry-After header. + content: + application/json: + schema: { type: object } diff --git a/packages/v1-ready/gong/index.js b/packages/v1-ready/gong/index.js new file mode 100644 index 0000000..3c94a63 --- /dev/null +++ b/packages/v1-ready/gong/index.js @@ -0,0 +1,7 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); + +module.exports = { + Api, + Definition, +}; diff --git a/packages/v1-ready/gong/package.json b/packages/v1-ready/gong/package.json new file mode 100644 index 0000000..8717b8c --- /dev/null +++ b/packages/v1-ready/gong/package.json @@ -0,0 +1,28 @@ +{ + "name": "@friggframework/api-module-gong", + "version": "1.0.0", + "prettier": "@friggframework/prettier-config", + "description": "Gong API module that lets the Frigg Framework interact with Gong (Revenue Intelligence / conversation intelligence)", + "main": "index.js", + "scripts": { + "lint:fix": "prettier --write --loglevel error . && eslint . --fix", + "test": "jest" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "eslint": "^8.22.0", + "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.3", + "js-yaml": "^4.1.0", + "prettier": "^2.7.1" + }, + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/gong/tests/api.test.js b/packages/v1-ready/gong/tests/api.test.js new file mode 100644 index 0000000..9d453a7 --- /dev/null +++ b/packages/v1-ready/gong/tests/api.test.js @@ -0,0 +1,145 @@ +const { Api } = require('../api'); + +function makeApi(params = { access_key: 'ak-123', access_key_secret: 'sk-123' }) { + const api = new Api(params); + // Capture requests instead of hitting the network. + api.sent = []; + const record = (method) => async (options) => { + api.sent.push({ method, ...options }); + return { ok: true }; + }; + api._get = record('GET'); + api._post = record('POST'); + api._patch = record('PATCH'); + api._delete = record('DELETE'); + return api; +} + +describe('Gong Api', () => { + describe('auth', () => { + it('maps access_key/access_key_secret onto Basic auth username/password', () => { + const api = new Api({ + access_key: 'ak', + access_key_secret: 'sk', + }); + expect(api.access_key).toBe('ak'); + expect(api.access_key_secret).toBe('sk'); + expect(api.username).toBe('ak'); + expect(api.password).toBe('sk'); + }); + + it('builds the Base64(accessKey:accessKeySecret) Basic header', async () => { + const api = new Api({ + access_key: 'ak', + access_key_secret: 'sk', + }); + const headers = await api.addAuthHeaders({}); + const expected = + 'Basic ' + Buffer.from('ak:sk').toString('base64'); + expect(headers['Authorization']).toBe(expected); + }); + + it('defaults to the documented Gong base URL', () => { + const api = new Api({ access_key: 'ak', access_key_secret: 'sk' }); + expect(api.baseUrl).toBe('https://api.gong.io/v2'); + }); + + it('honors a company-specific base_url override', () => { + const api = new Api({ + access_key: 'ak', + access_key_secret: 'sk', + base_url: 'https://us-55616.api.gong.io', + }); + expect(api.baseUrl).toBe('https://us-55616.api.gong.io'); + }); + }); + + describe('calls endpoints', () => { + it('listCalls GETs /calls with date-range query params', async () => { + const api = makeApi(); + await api.listCalls({ + fromDateTime: '2026-08-01T00:00:00Z', + toDateTime: '2026-08-19T00:00:00Z', + cursor: 'abc', + }); + const req = api.sent[0]; + expect(req.method).toBe('GET'); + expect(req.url).toBe('https://api.gong.io/v2/calls'); + expect(req.query).toEqual({ + fromDateTime: '2026-08-01T00:00:00Z', + toDateTime: '2026-08-19T00:00:00Z', + cursor: 'abc', + }); + }); + + it('getCall GETs /calls/{id}', async () => { + const api = makeApi(); + await api.getCall('call-9'); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe('https://api.gong.io/v2/calls/call-9'); + }); + + it('listCallsExtensive POSTs the filter/contentSelector body to /calls/extensive', async () => { + const api = makeApi(); + const body = { + filter: { callIds: ['call-9'] }, + contentSelector: { exposedFields: { parties: true } }, + }; + await api.listCallsExtensive(body); + const req = api.sent[0]; + expect(req.method).toBe('POST'); + expect(req.url).toBe('https://api.gong.io/v2/calls/extensive'); + expect(req.body).toEqual(body); + expect(req.headers['Content-Type']).toBe('application/json'); + }); + + it('getTranscripts POSTs to /calls/transcript', async () => { + const api = makeApi(); + await api.getTranscripts({ filter: { callIds: ['call-9'] } }); + const req = api.sent[0]; + expect(req.method).toBe('POST'); + expect(req.url).toBe('https://api.gong.io/v2/calls/transcript'); + expect(req.body).toEqual({ filter: { callIds: ['call-9'] } }); + }); + }); + + describe('users endpoints', () => { + it('listUsers GETs /users with the includeAvatars flag', async () => { + const api = makeApi(); + await api.listUsers({ includeAvatars: false }); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe('https://api.gong.io/v2/users'); + expect(api.sent[0].query).toEqual({ includeAvatars: false }); + }); + + it('getUser GETs /users/{id}', async () => { + const api = makeApi(); + await api.getUser('user-1'); + expect(api.sent[0].url).toBe('https://api.gong.io/v2/users/user-1'); + }); + }); + + describe('testAuth', () => { + it('performs a lightweight authenticated users listing', async () => { + const api = makeApi(); + await api.testAuth(); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe('https://api.gong.io/v2/users'); + }); + }); + + describe('getAuthorizationRequirements', () => { + it('declares a basic requirement for access_key and access_key_secret', () => { + const api = new Api({ access_key: 'ak', access_key_secret: 'sk' }); + const reqs = api.getAuthorizationRequirements(); + expect(reqs.type).toBe('basic'); + expect(reqs.data.jsonSchema.required).toEqual([ + 'access_key', + 'access_key_secret', + ]); + expect(reqs.data.uiSchema.access_key_secret['ui:widget']).toBe( + 'password' + ); + }); + }); +}); diff --git a/packages/v1-ready/gong/tests/definition.test.js b/packages/v1-ready/gong/tests/definition.test.js new file mode 100644 index 0000000..e8a99a5 --- /dev/null +++ b/packages/v1-ready/gong/tests/definition.test.js @@ -0,0 +1,178 @@ +const { Definition } = require('../definition'); +const { Api } = require('../api'); + +const { requiredAuthMethods } = Definition; + +describe('Gong Definition', () => { + it('is named gong and models the Gong entity', () => { + expect(Definition.getName()).toBe('gong'); + expect(Definition.moduleName).toBe('gong'); + expect(Definition.modelName).toBe('Gong'); + }); + + it('persists the access key and secret on the credential', () => { + expect(requiredAuthMethods.apiPropertiesToPersist.credential).toEqual([ + 'access_key', + 'access_key_secret', + ]); + }); + + describe('key fingerprinting', () => { + it('derives a stable, non-reversible externalId from the access key', async () => { + const api = { access_key: 'secret-key' }; + const entity = await requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-1' + ); + const credential = await requiredAuthMethods.getCredentialDetails( + api, + 'user-1' + ); + + // Same key → same id (idempotent linkage). + expect(entity.identifiers.externalId).toBe( + credential.identifiers.externalId + ); + // Never the raw key. + expect(entity.identifiers.externalId).not.toBe('secret-key'); + // sha256 hex. + expect(entity.identifiers.externalId).toMatch(/^[a-f0-9]{64}$/); + expect(entity.identifiers.userId).toBe('user-1'); + }); + + it('falls back to the Basic-auth username when access_key is absent', async () => { + const fromKey = await requiredAuthMethods.getEntityDetails( + { access_key: 'ak' }, + {}, + {}, + 'u' + ); + const fromUsername = await requiredAuthMethods.getEntityDetails( + { username: 'ak' }, + {}, + {}, + 'u' + ); + expect(fromKey.identifiers.externalId).toBe( + fromUsername.identifiers.externalId + ); + }); + + it('produces different ids for different keys', async () => { + const a = await requiredAuthMethods.getEntityDetails( + { access_key: 'key-a' }, + {}, + {}, + 'u' + ); + const b = await requiredAuthMethods.getEntityDetails( + { access_key: 'key-b' }, + {}, + {}, + 'u' + ); + expect(a.identifiers.externalId).not.toBe(b.identifiers.externalId); + }); + }); + + describe('getAuthorizationRequirements', () => { + it('delegates to the api form definition so the CLI renders it', async () => { + let called = false; + const api = { + getAuthorizationRequirements: () => { + called = true; + return { type: 'basic', data: { jsonSchema: {} } }; + }, + }; + const reqs = + await requiredAuthMethods.getAuthorizationRequirements(api); + expect(called).toBe(true); + expect(reqs.type).toBe('basic'); + }); + }); + + describe('setAuthParams', () => { + it('wires the form access key onto the Basic-auth username and password', async () => { + const api = {}; + await requiredAuthMethods.setAuthParams(api, { + access_key: 'ak-123', + access_key_secret: 'secret-xyz', + }); + + // The request-wiring the header is built from. + expect(api.username).toBe('ak-123'); + expect(api.password).toBe('secret-xyz'); + // Gong-native names kept in sync for fingerprinting/persistence. + expect(api.access_key).toBe('ak-123'); + expect(api.access_key_secret).toBe('secret-xyz'); + }); + + it('reads form fields nested under params.data', async () => { + const api = {}; + await requiredAuthMethods.setAuthParams(api, { + data: { + access_key: 'nested-ak', + access_key_secret: 'nested-secret', + }, + }); + expect(api.username).toBe('nested-ak'); + expect(api.password).toBe('nested-secret'); + }); + + it('does not overwrite existing creds when params are empty', async () => { + const api = { username: 'existing', password: 'existing-secret' }; + await requiredAuthMethods.setAuthParams(api, {}); + expect(api.username).toBe('existing'); + expect(api.password).toBe('existing-secret'); + }); + + it('makes the credentials usable for a real Basic-auth request', async () => { + // Mirrors what the framework does on the callback: instantiate the + // real Api, then apply the form params via setAuthParams. + const api = new Api({}); + expect(api.username).toBeNull(); + expect(api.password).toBeNull(); + + await requiredAuthMethods.setAuthParams(api, { + access_key: 'AK', + access_key_secret: 'SK', + }); + + // BasicAuthRequester builds Base64(username:password) from these. + expect(api.username).toBe('AK'); + expect(api.password).toBe('SK'); + expect( + Buffer.from(`${api.username}:${api.password}`).toString('base64') + ).toBe(Buffer.from('AK:SK').toString('base64')); + }); + }); + + describe('testAuthRequest', () => { + it('delegates to the api testAuth check', async () => { + let called = false; + const api = { + testAuth: async () => { + called = true; + return { ok: true }; + }, + }; + await expect( + requiredAuthMethods.testAuthRequest(api) + ).resolves.toEqual({ ok: true }); + expect(called).toBe(true); + }); + + it('propagates auth failures from the api', async () => { + const api = { + testAuth: async () => { + throw new Error('401 Unauthorized'); + }, + }; + await expect( + requiredAuthMethods.testAuthRequest(api) + ).rejects.toThrow(/401/); + }); + }); +}); diff --git a/packages/v1-ready/gong/tests/spec-sync.test.js b/packages/v1-ready/gong/tests/spec-sync.test.js new file mode 100644 index 0000000..25920f0 --- /dev/null +++ b/packages/v1-ready/gong/tests/spec-sync.test.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { Api } = require('../api'); + +const spec = yaml.load( + fs.readFileSync(path.join(__dirname, '..', 'gong.openapi.yaml'), 'utf8') +); + +const specOperationIds = Object.values(spec.paths).flatMap((item) => + Object.entries(item) + .filter(([m]) => ['get', 'post', 'patch', 'put', 'delete'].includes(m)) + .map(([, op]) => op.operationId) +); + +const clientMethods = Object.getOwnPropertyNames(Api.prototype).filter( + (m) => typeof Api.prototype[m] === 'function' && m !== 'constructor' +); + +describe('OpenAPI spec ↔ client sync', () => { + it('every operationId has a matching client method', () => { + const missing = specOperationIds.filter( + (op) => !clientMethods.includes(op) + ); + expect(missing).toEqual([]); + }); + + it('the base server URL matches the client default baseUrl', () => { + const api = new Api({ access_key: 'x', access_key_secret: 'y' }); + expect(spec.servers[0].url).toBe(api.baseUrl); + }); + + it('declares HTTP Basic auth security', () => { + const scheme = spec.components.securitySchemes.BasicAuth; + expect(scheme.type).toBe('http'); + expect(scheme.scheme).toBe('basic'); + }); +}); diff --git a/packages/v1-ready/otter/.env.example b/packages/v1-ready/otter/.env.example new file mode 100644 index 0000000..4c19a1c --- /dev/null +++ b/packages/v1-ready/otter/.env.example @@ -0,0 +1,4 @@ +# Otter.ai Public API key (Enterprise workspaces only). +# Create it in Otter under Integrations → Developer → Create key. +# Sent on every request as the `Authorization: Bearer ` header. +OTTER_API_KEY=your_otter_api_key_here diff --git a/packages/v1-ready/otter/LICENSE.md b/packages/v1-ready/otter/LICENSE.md new file mode 100644 index 0000000..c307ce6 --- /dev/null +++ b/packages/v1-ready/otter/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Left Hook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/v1-ready/otter/README.md b/packages/v1-ready/otter/README.md new file mode 100644 index 0000000..affa7cd --- /dev/null +++ b/packages/v1-ready/otter/README.md @@ -0,0 +1,84 @@ +# @friggframework/api-module-otter + +A [Frigg](https://friggframework.org) API module for the **Otter.ai Public API**. + +## Is there really an Otter.ai API? + +Yes — now. Otter.ai spent years with **no** official public developer API; the +only option was an unofficial, reverse-engineered community client +([omerdn1/otter.ai-api](https://github.com/omerdn1/otter.ai-api)), and Otter's own +help center answered "Does Otter offer an open API?" with a no. + +That changed. Otter shipped an official **Public API** (help-center article last +updated **April 23, 2026**), available for **Enterprise** workspaces. This module +targets that official API. + +- **Base URL:** `https://api.otter.ai/v1` +- **Auth:** Bearer token — `Authorization: Bearer `. Create a key in + Otter under **Integrations → Developer → Create key**. +- **Availability:** Enterprise workspaces only. If you do not see the Developer + tab, contact your Otter account manager. +- **Scope:** read channels, conversations, transcripts, audio, action items, + insights, outlines, and workspace details; plus workspace webhooks + (`conversation.completed`, `conversation.shared`). + +Sources: [Otter.ai Public API (Help Center)](https://help.otter.ai/hc/en-us/articles/36130822688279-Otter-ai-Public-API), +[Workspace Webhooks](https://help.otter.ai/hc/en-us/articles/35634832371735-Workspace-Webhooks). + +## Install + +```bash +npm install @friggframework/api-module-otter +``` + +## Usage + +```javascript +const { Api, Definition } = require('@friggframework/api-module-otter'); + +const otter = new Api({ api_token: process.env.OTTER_API_KEY }); + +await otter.getWorkspace(); +await otter.listChannels(); +const { conversations, next_cursor } = await otter.listConversations({ page_size: 25 }); +const convo = await otter.getConversation('conv-123', { include: ['transcript', 'action_items'] }); +``` + +In an integration, consume it through the standard Frigg pattern: + +```javascript +const convo = await this.otter.api.getConversation(id, { include: 'all' }); +``` + +## Authentication + +This is an API-key (Bearer) module. The interactive `frigg auth` CLI and the +hosted auth UI render the form declared by `getAuthorizationRequirements()` +(a single masked `api_token` field). The raw token is persisted on the +credential as `api_token`; the module adds the `Authorization: Bearer` prefix at +request time, so re-hydration never double-prefixes. + +## API surface + +| Method | HTTP | Path | +|---|---|---| +| `getWorkspace()` | GET | `/workspace` | +| `listChannels(query)` | GET | `/channels` | +| `listConversations(query)` | GET | `/conversations` | +| `getConversation(id, {include})` | GET | `/conversations/{id}` | +| `getConversationTranscript(id)` | GET | `/conversations/{id}/transcript` | +| `getConversationAudio(id)` | GET | `/conversations/{id}/audio` | +| `testAuth()` | GET | `/workspace` | + +`listConversations` returns results in reverse chronological order with +cursor-based pagination. `getConversation`'s `include` accepts any of +`transcript`, `action_items`, `insights`, `outline`, or `all`. + +Conversation records expose `abstract_summary`, `action_items`, `insights`, +`outline`, `transcript`, `conf_join_url`, and `calendar_guests` — the emails of +users invited to the calendar event, which is how a consuming integration links +a conversation to a CRM contact. + +## License + +MIT diff --git a/packages/v1-ready/otter/api.js b/packages/v1-ready/otter/api.js new file mode 100644 index 0000000..702006c --- /dev/null +++ b/packages/v1-ready/otter/api.js @@ -0,0 +1,144 @@ +const { ApiKeyRequester, ModuleConstants, get } = require('@friggframework/core'); + +/** + * Otter.ai Public API client. + * + * Otter.ai historically had NO public developer API — only an unofficial, + * reverse-engineered community client existed. That changed: Otter shipped an + * official **Public API** (help-center article last updated April 23, 2026), + * available for Enterprise workspaces. It is a Bearer-token authenticated REST + * surface rooted at `https://api.otter.ai/v1` that exposes channels, + * conversations, transcripts, audio, action items, insights, outlines, workspace + * details, and workspace webhooks (conversation.completed / conversation.shared). + * + * Auth: an API key minted under Integrations → Developer → Create key, sent as + * `Authorization: Bearer `. We model that on top of ApiKeyRequester by + * setting the header NAME to `Authorization` and the header VALUE to + * `Bearer `; the raw token is kept separately (`api_token`) so it can be + * persisted and re-hydrated without double-prefixing. + * + * Docs: https://help.otter.ai/hc/en-us/articles/36130822688279-Otter-ai-Public-API + * https://help.otter.ai/hc/en-us/articles/35634832371735-Workspace-Webhooks + */ +class Api extends ApiKeyRequester { + constructor(params) { + super(params); + + // Accept the token under any of the friendly names, tolerate a caller + // that already prefixed "Bearer ", and store the raw token once. + const raw = + get(params, 'api_token', null) || + get(params, 'access_token', null) || + get(params, 'api_key', null); + const token = raw ? String(raw).replace(/^Bearer\s+/i, '').trim() : null; + + this.api_token = token; + // ApiKeyRequester.addAuthHeaders() sets headers[api_key_name] = api_key. + this.api_key_name = 'Authorization'; + this.api_key = token ? `Bearer ${token}` : null; + + this.baseUrl = 'https://api.otter.ai/v1'; + + this.URLs = { + workspace: '/workspace', + channels: '/channels', + conversations: '/conversations', + conversationById: (id) => `/conversations/${id}`, + }; + } + + getAuthorizationRequirements() { + return { + url: null, + type: ModuleConstants.authType.apiKey, + data: { + jsonSchema: { + title: 'Otter.ai Authentication', + type: 'object', + required: ['api_token'], + properties: { + api_token: { + type: 'string', + title: 'API Key', + }, + }, + }, + uiSchema: { + api_token: { + 'ui:widget': 'password', + 'ui:help': + 'Create a key in Otter under Integrations → Developer → Create key (Enterprise workspaces only). Sent as the Authorization: Bearer header.', + 'ui:placeholder': 'Your Otter.ai API key', + }, + }, + }, + }; + } + + // ---- Workspace ---------------------------------------------------------- + + /** Get the workspace for the authenticated user (id, name, owner, type…). */ + async getWorkspace() { + return this._get({ url: this.baseUrl + this.URLs.workspace }); + } + + // ---- Channels ----------------------------------------------------------- + + /** List channels for the authenticated user (alphabetical by name). */ + async listChannels(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.channels, query }); + } + + // ---- Conversations ------------------------------------------------------ + + /** + * List conversations for the authenticated user. Reverse chronological + * (most recent first), cursor-based pagination. Supports `cursor` and + * `page_size`, plus optional filters (e.g. `channel_id`). + */ + async listConversations(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.conversations, query }); + } + + /** + * Get one conversation's summary and details. Pass `include` to embed + * related data: any of `transcript`, `action_items`, `insights`, `outline`, + * or `all`. Accepts a string or an array (joined with commas). + */ + async getConversation(conversationId, { include } = {}) { + const query = {}; + if (include) { + query.include = Array.isArray(include) ? include.join(',') : include; + } + return this._get({ + url: this.baseUrl + this.URLs.conversationById(conversationId), + query, + }); + } + + /** + * Get the full transcript for a conversation. + * + * Otter exposes the transcript as an `include` option on the + * conversation-detail endpoint, not as a standalone path — so this + * delegates to `getConversation(id, { include: 'transcript' })`. The method + * name is preserved because consumers (e.g. the reevo--frigg app's + * `get_transcript` tool) call it directly. + */ + async getConversationTranscript(conversationId) { + return this.getConversation(conversationId, { include: 'transcript' }); + } + + // ---- Auth check --------------------------------------------------------- + + /** + * Lightweight authenticated request used to validate the API key. + * A valid key returns 200 with the workspace; an invalid key returns 401, + * which the requester surfaces as an error. + */ + async testAuth() { + return this.getWorkspace(); + } +} + +module.exports = { Api }; diff --git a/packages/v1-ready/otter/defaultConfig.json b/packages/v1-ready/otter/defaultConfig.json new file mode 100644 index 0000000..e861003 --- /dev/null +++ b/packages/v1-ready/otter/defaultConfig.json @@ -0,0 +1,10 @@ +{ + "name": "otter", + "label": "Otter.ai", + "authType": "apiKey", + "productUrl": "https://otter.ai", + "apiDocs": "https://help.otter.ai/hc/en-us/articles/36130822688279-Otter-ai-Public-API", + "logoUrl": "https://otter.ai/favicon.ico", + "categories": ["Conversation Intelligence", "Meetings", "Transcription"], + "description": "Otter.ai Public API module — read workspace, channels, conversations, transcripts, action items, insights, and outlines from an Enterprise Otter workspace." +} diff --git a/packages/v1-ready/otter/definition.js b/packages/v1-ready/otter/definition.js new file mode 100644 index 0000000..e05fbdd --- /dev/null +++ b/packages/v1-ready/otter/definition.js @@ -0,0 +1,76 @@ +require('dotenv').config(); +const crypto = require('crypto'); +const { get } = require('@friggframework/core'); +const { Api } = require('./api'); +const config = require('./defaultConfig.json'); + +// Otter issues a static API key (Bearer token, no OAuth), so there is no +// external account id returned at auth time. We derive a stable, non-reversible +// identifier from the raw token so the same key always maps to the same +// entity/credential. +const keyFingerprint = (token) => + crypto.createHash('sha256').update(String(token)).digest('hex'); + +const Definition = { + API: Api, + getName: function () { + return config.name; + }, + moduleName: config.name, + modelName: 'Otter', + requiredAuthMethods: { + setAuthParams: async function (api, params) { + // The form submits the user-entered API token; rehydrate the api + // client exactly as its constructor does — store the raw token and + // set the Authorization header value to `Bearer `, stripping + // any pre-existing "Bearer " prefix so it is never doubled. + const raw = ( + get(params, 'api_token', null) || + get(params, 'api_key', null) || + '' + ).replace(/^Bearer\s+/i, ''); + if (raw) { + api.api_token = raw; + api.setApiKey('Bearer ' + raw); + } + }, + getAuthorizationRequirements: async function (api) { + return api.getAuthorizationRequirements(); + }, + getEntityDetails: async function ( + api, + callbackParams, + tokenResponse, + userId + ) { + return { + identifiers: { + externalId: keyFingerprint(api.api_token), + userId, + }, + details: {}, + }; + }, + apiPropertiesToPersist: { + credential: ['api_token'], + entity: [], + }, + getCredentialDetails: async function (api, userId) { + return { + identifiers: { + externalId: keyFingerprint(api.api_token), + userId, + }, + details: {}, + }; + }, + testAuthRequest: async function (api) { + return api.testAuth(); + }, + }, + env: { + api_token: process.env.OTTER_API_KEY, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/otter/index.js b/packages/v1-ready/otter/index.js new file mode 100644 index 0000000..3c94a63 --- /dev/null +++ b/packages/v1-ready/otter/index.js @@ -0,0 +1,7 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); + +module.exports = { + Api, + Definition, +}; diff --git a/packages/v1-ready/otter/otter.openapi.yaml b/packages/v1-ready/otter/otter.openapi.yaml new file mode 100644 index 0000000..6e79409 --- /dev/null +++ b/packages/v1-ready/otter/otter.openapi.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.3 +info: + title: Otter.ai Public API + version: "1.0.0" + description: >- + Otter.ai is an AI meeting assistant (transcription, summaries, action items, + insights). This is the official Public REST API, available for Enterprise + workspaces, documented at + https://help.otter.ai/hc/en-us/articles/36130822688279-Otter-ai-Public-API + and https://help.otter.ai/hc/en-us/articles/35634832371735-Workspace-Webhooks. + Otter does not publish its own OpenAPI document; this spec was authored by + Left Hook from Otter's help-center reference as the canonical machine-readable + contract for the @friggframework/api-module-otter module. It is the source of + truth the hand-written client in api.js mirrors — every operationId here is a + method on that client, and only the endpoints the client implements appear. + contact: + name: Left Hook + url: https://lefthook.com +servers: + - url: https://api.otter.ai/v1 + description: Otter.ai public API +security: + - BearerAuth: [] +tags: + - name: Workspace + - name: Channels + - name: Conversations +paths: + /workspace: + get: + tags: [Workspace] + operationId: getWorkspace + summary: Get the workspace for the authenticated user + description: >- + Returns the workspace (id, name, owner, type…) for the authenticated + user. Also used as the module's lightweight auth check. + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /channels: + get: + tags: [Channels] + operationId: listChannels + summary: List channels for the authenticated user + description: >- + Lists channels for the authenticated user, returned in alphabetical + order by channel name. + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /conversations: + get: + tags: [Conversations] + operationId: listConversations + summary: List conversations for the authenticated user + description: >- + Lists conversations in reverse chronological order (most recent first) + with cursor-based pagination. Optional filters may be supplied as query + parameters (e.g. channel_id). + parameters: + - in: query + name: cursor + required: false + schema: { type: string } + description: Opaque cursor from a prior page's next_cursor. + - in: query + name: page_size + required: false + schema: { type: integer } + description: Number of conversations to return per page. + - in: query + name: channel_id + required: false + schema: { type: string } + description: Restrict results to a single channel. + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /conversations/{conversation_id}: + parameters: + - $ref: "#/components/parameters/ConversationId" + get: + tags: [Conversations] + operationId: getConversation + summary: Get one conversation's summary and details + description: >- + Returns a single conversation's summary and details. Use `include` to + embed related data in the response. + parameters: + - in: query + name: include + required: false + schema: + type: string + enum: [transcript, action_items, insights, outline, all] + description: >- + Comma-separated list of related data to embed — any of transcript, + action_items, insights, outline — or `all`. + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } +components: + securitySchemes: + BearerAuth: + type: apiKey + in: header + name: Authorization + description: >- + An API key minted under Integrations → Developer → Create key + (Enterprise workspaces only), sent as the value `Bearer ` in the + Authorization header. The module builds this on ApiKeyRequester by + setting the header name to `Authorization` and the header value to + `Bearer `. + parameters: + ConversationId: + in: path + name: conversation_id + required: true + schema: { type: string } + description: The Otter conversation id. + responses: + ObjectResponse: + description: A single object. + content: + application/json: + schema: { type: object, additionalProperties: true } + ListResponse: + description: A list of matching objects (cursor-paginated where applicable). + content: + application/json: + schema: + type: object + additionalProperties: true + Unauthorized: + description: Missing or invalid API key. + NotFound: + description: Conversation not found. diff --git a/packages/v1-ready/otter/package.json b/packages/v1-ready/otter/package.json new file mode 100644 index 0000000..29588bb --- /dev/null +++ b/packages/v1-ready/otter/package.json @@ -0,0 +1,25 @@ +{ + "name": "@friggframework/api-module-otter", + "version": "1.0.0", + "prettier": "@friggframework/prettier-config", + "description": "Otter.ai Public API module that lets the Frigg Framework read Otter conversations, transcripts, insights, and action items", + "main": "index.js", + "scripts": { + "lint:fix": "prettier --write --loglevel error . && eslint . --fix", + "test": "jest" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3", + "js-yaml": "^4.1.0" + }, + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/otter/tests/api.test.js b/packages/v1-ready/otter/tests/api.test.js new file mode 100644 index 0000000..90ac302 --- /dev/null +++ b/packages/v1-ready/otter/tests/api.test.js @@ -0,0 +1,133 @@ +const { Api } = require('../api'); + +function makeApi(params = { api_token: 'test-key-123' }) { + const api = new Api(params); + // Capture requests instead of hitting the network. + api.sent = []; + const record = (method) => async (options) => { + api.sent.push({ method, ...options }); + return { ok: true }; + }; + api._get = record('GET'); + api._post = record('POST'); + api._patch = record('PATCH'); + api._delete = record('DELETE'); + return api; +} + +describe('Otter Api', () => { + describe('auth', () => { + it('uses the Authorization header with a Bearer-prefixed value', () => { + const api = new Api({ api_token: 'abc' }); + expect(api.api_key_name).toBe('Authorization'); + expect(api.api_key).toBe('Bearer abc'); + expect(api.api_token).toBe('abc'); + }); + + it('accepts the token under api_key or access_token too', () => { + expect(new Api({ api_key: 'abc' }).api_token).toBe('abc'); + expect(new Api({ access_token: 'abc' }).api_token).toBe('abc'); + }); + + it('strips a pre-existing Bearer prefix so it is never doubled', () => { + const api = new Api({ api_token: 'Bearer abc' }); + expect(api.api_token).toBe('abc'); + expect(api.api_key).toBe('Bearer abc'); + }); + + it('injects the Bearer token into request headers via addAuthHeaders', async () => { + const api = new Api({ api_token: 'abc' }); + const headers = await api.addAuthHeaders({}); + expect(headers['Authorization']).toBe('Bearer abc'); + }); + + it('points at the Otter public API base URL', () => { + const api = new Api({ api_token: 'abc' }); + expect(api.baseUrl).toBe('https://api.otter.ai/v1'); + }); + + it('reports authenticated only with a non-empty key', () => { + expect(new Api({ api_token: 'abc' }).isAuthenticated()).toBe(true); + expect(new Api({ api_token: '' }).isAuthenticated()).toBe(false); + expect(new Api({}).isAuthenticated()).toBe(false); + }); + }); + + describe('endpoints', () => { + it('getWorkspace gets /workspace', async () => { + const api = makeApi(); + await api.getWorkspace(); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe('https://api.otter.ai/v1/workspace'); + }); + + it('listChannels gets /channels', async () => { + const api = makeApi(); + await api.listChannels(); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe('https://api.otter.ai/v1/channels'); + }); + + it('listConversations gets /conversations and forwards pagination query', async () => { + const api = makeApi(); + await api.listConversations({ page_size: 25, cursor: 'abc' }); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe( + 'https://api.otter.ai/v1/conversations' + ); + expect(api.sent[0].query).toEqual({ page_size: 25, cursor: 'abc' }); + }); + + it('getConversation gets /conversations/{id}', async () => { + const api = makeApi(); + await api.getConversation('conv-1'); + expect(api.sent[0].url).toBe( + 'https://api.otter.ai/v1/conversations/conv-1' + ); + expect(api.sent[0].query).toEqual({}); + }); + + it('getConversation joins an include array into a comma string', async () => { + const api = makeApi(); + await api.getConversation('conv-1', { + include: ['transcript', 'action_items'], + }); + expect(api.sent[0].query).toEqual({ + include: 'transcript,action_items', + }); + }); + + it('getConversation passes an include string through unchanged', async () => { + const api = makeApi(); + await api.getConversation('conv-1', { include: 'all' }); + expect(api.sent[0].query).toEqual({ include: 'all' }); + }); + + it('getConversationTranscript delegates to getConversation with include=transcript', async () => { + const api = makeApi(); + await api.getConversationTranscript('conv-1'); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe( + 'https://api.otter.ai/v1/conversations/conv-1' + ); + expect(api.sent[0].query).toEqual({ include: 'transcript' }); + }); + + it('testAuth performs a lightweight workspace fetch', async () => { + const api = makeApi(); + await api.testAuth(); + expect(api.sent[0].method).toBe('GET'); + expect(api.sent[0].url).toBe('https://api.otter.ai/v1/workspace'); + }); + }); + + describe('getAuthorizationRequirements', () => { + it('declares an apiKey requirement for api_token', () => { + const api = new Api({ api_token: 'abc' }); + const reqs = api.getAuthorizationRequirements(); + expect(reqs.type).toBe('apiKey'); + expect(reqs.data.jsonSchema.required).toContain('api_token'); + expect(reqs.data.uiSchema.api_token['ui:widget']).toBe('password'); + }); + }); +}); diff --git a/packages/v1-ready/otter/tests/definition.test.js b/packages/v1-ready/otter/tests/definition.test.js new file mode 100644 index 0000000..6875e67 --- /dev/null +++ b/packages/v1-ready/otter/tests/definition.test.js @@ -0,0 +1,132 @@ +const { Definition } = require('../definition'); + +const { requiredAuthMethods } = Definition; + +describe('Otter Definition', () => { + it('is named otter and models the Otter entity', () => { + expect(Definition.getName()).toBe('otter'); + expect(Definition.moduleName).toBe('otter'); + expect(Definition.modelName).toBe('Otter'); + }); + + it('persists the api_token on the credential', () => { + expect(requiredAuthMethods.apiPropertiesToPersist.credential).toContain( + 'api_token' + ); + }); + + describe('key fingerprinting', () => { + it('derives a stable, non-reversible externalId from the api token', async () => { + const api = { api_token: 'secret-key' }; + const entity = await requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-1' + ); + const credential = await requiredAuthMethods.getCredentialDetails( + api, + 'user-1' + ); + + // Same key → same id (idempotent linkage). + expect(entity.identifiers.externalId).toBe( + credential.identifiers.externalId + ); + // Never the raw key. + expect(entity.identifiers.externalId).not.toBe('secret-key'); + // sha256 hex. + expect(entity.identifiers.externalId).toMatch(/^[a-f0-9]{64}$/); + expect(entity.identifiers.userId).toBe('user-1'); + }); + + it('produces different ids for different keys', async () => { + const a = await requiredAuthMethods.getEntityDetails( + { api_token: 'key-a' }, + {}, + {}, + 'u' + ); + const b = await requiredAuthMethods.getEntityDetails( + { api_token: 'key-b' }, + {}, + {}, + 'u' + ); + expect(a.identifiers.externalId).not.toBe(b.identifiers.externalId); + }); + }); + + describe('setAuthParams', () => { + it('rehydrates the api from a form-submitted api_token', async () => { + const calls = []; + const api = { setApiKey: (v) => calls.push(v) }; + await requiredAuthMethods.setAuthParams(api, { + api_token: 'form-key', + }); + expect(api.api_token).toBe('form-key'); + expect(calls).toEqual(['Bearer form-key']); + }); + + it('accepts the token under api_key too', async () => { + const api = { setApiKey: jest.fn() }; + await requiredAuthMethods.setAuthParams(api, { api_key: 'k2' }); + expect(api.api_token).toBe('k2'); + expect(api.setApiKey).toHaveBeenCalledWith('Bearer k2'); + }); + + it('strips a pre-existing Bearer prefix so it is never doubled', async () => { + const api = { setApiKey: jest.fn() }; + await requiredAuthMethods.setAuthParams(api, { + api_token: 'Bearer k3', + }); + expect(api.api_token).toBe('k3'); + expect(api.setApiKey).toHaveBeenCalledWith('Bearer k3'); + }); + + it('does nothing when no token is supplied', async () => { + const api = { setApiKey: jest.fn() }; + await requiredAuthMethods.setAuthParams(api, {}); + expect(api.api_token).toBeUndefined(); + expect(api.setApiKey).not.toHaveBeenCalled(); + }); + }); + + describe('getAuthorizationRequirements', () => { + it('delegates to the api getAuthorizationRequirements', async () => { + const api = { + getAuthorizationRequirements: () => ({ type: 'apiKey' }), + }; + await expect( + requiredAuthMethods.getAuthorizationRequirements(api) + ).resolves.toEqual({ type: 'apiKey' }); + }); + }); + + describe('testAuthRequest', () => { + it('delegates to the api testAuth check', async () => { + let called = false; + const api = { + testAuth: async () => { + called = true; + return { ok: true }; + }, + }; + await expect( + requiredAuthMethods.testAuthRequest(api) + ).resolves.toEqual({ ok: true }); + expect(called).toBe(true); + }); + + it('propagates auth failures from the api', async () => { + const api = { + testAuth: async () => { + throw new Error('401 Unauthorized'); + }, + }; + await expect( + requiredAuthMethods.testAuthRequest(api) + ).rejects.toThrow(/401/); + }); + }); +}); diff --git a/packages/v1-ready/otter/tests/spec-sync.test.js b/packages/v1-ready/otter/tests/spec-sync.test.js new file mode 100644 index 0000000..4b1dac6 --- /dev/null +++ b/packages/v1-ready/otter/tests/spec-sync.test.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { Api } = require('../api'); + +const spec = yaml.load( + fs.readFileSync(path.join(__dirname, '..', 'otter.openapi.yaml'), 'utf8') +); + +const specOperationIds = Object.values(spec.paths).flatMap((item) => + Object.entries(item) + .filter(([m]) => ['get', 'post', 'patch', 'put', 'delete'].includes(m)) + .map(([, op]) => op.operationId) +); + +const clientMethods = Object.getOwnPropertyNames(Api.prototype).filter( + (m) => typeof Api.prototype[m] === 'function' && m !== 'constructor' +); + +describe('OpenAPI spec ↔ client sync', () => { + it('every operationId has a matching client method', () => { + const missing = specOperationIds.filter( + (op) => !clientMethods.includes(op) + ); + expect(missing).toEqual([]); + }); + + it('the base server URL matches the client baseUrl', () => { + const api = new Api({ api_token: 'x' }); + expect(spec.servers[0].url).toBe(api.baseUrl); + }); + + it('declares Bearer/apiKey Authorization security', () => { + const scheme = spec.components.securitySchemes.BearerAuth; + expect(scheme.type).toBe('apiKey'); + expect(scheme.in).toBe('header'); + expect(scheme.name).toBe('Authorization'); + }); +}); diff --git a/packages/v1-ready/quo/.env.example b/packages/v1-ready/quo/.env.example new file mode 100644 index 0000000..02fabe0 --- /dev/null +++ b/packages/v1-ready/quo/.env.example @@ -0,0 +1,8 @@ +# Quo (formerly OpenPhone) API module + +# Your Quo / OpenPhone API key (Settings -> API). +# Sent RAW in the Authorization header (no "Bearer " prefix). +QUO_API_KEY= + +# Optional base URL override. Defaults to https://api.openphone.com/v1 +QUO_BASE_URL=https://api.openphone.com/v1 diff --git a/packages/v1-ready/quo/LICENSE.md b/packages/v1-ready/quo/LICENSE.md new file mode 100644 index 0000000..c307ce6 --- /dev/null +++ b/packages/v1-ready/quo/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Left Hook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/v1-ready/quo/README.md b/packages/v1-ready/quo/README.md new file mode 100644 index 0000000..2f03a8d --- /dev/null +++ b/packages/v1-ready/quo/README.md @@ -0,0 +1,100 @@ +# @friggframework/api-module-quo + +Frigg API module for **Quo** — the business phone / calling product formerly known as **OpenPhone**. The REST API still lives at `api.openphone.com`; the docs have moved to [quo.com/docs](https://www.quo.com/docs). + +## Features + +- **API-key authentication** (`ApiKeyRequester`) — the key is sent **raw** in the `Authorization` header (no `Bearer` prefix). +- Calls: list, get by ID, recordings, transcripts, summaries. +- Messages: list, get, send. +- Contacts, phone numbers, users. +- Webhooks: list/get/create (calls, messages, call-summaries, call-transcripts)/delete. + +## Installation + +```bash +npm install @friggframework/api-module-quo +``` + +## Configuration + +```env +# Your Quo / OpenPhone API key (Settings -> API) +QUO_API_KEY=op_xxx + +# Optional base URL override (defaults to https://api.openphone.com/v1) +QUO_BASE_URL=https://api.openphone.com/v1 +``` + +## Authentication + +The Quo API **does not use a Bearer token**. The key goes directly in the header: + +``` +Authorization: +``` + +This module sets `api_key_name = 'Authorization'` and stores the raw key, so `ApiKeyRequester.addAuthHeaders` produces exactly that. + +## Usage + +```javascript +const { Api } = require('@friggframework/api-module-quo'); + +const api = new Api({ api_key: process.env.QUO_API_KEY }); + +// List calls for a given Quo number + external participant +const calls = await api.listCalls({ + phoneNumberId: 'PN123abc', + participants: ['+15555550123'], + maxResults: 50, +}); + +// Enrich a single call +const call = await api.getCall('AC...'); +const recordings = await api.getCallRecordings('AC...'); // GET /call-recordings/{id} +const transcript = await api.getCallTranscript('AC...'); // GET /call-transcripts/{id} +const summary = await api.getCallSummary('AC...'); // GET /call-summaries/{id} + +// Messages +const messages = await api.listMessages({ + phoneNumberId: 'PN123abc', + participants: ['+15555550123'], +}); +``` + +## Endpoint reference + +Base URL: `https://api.openphone.com/v1` + +| Method | Path | Client method | +|---|---|---| +| GET | `/calls` | `listCalls(query)` | +| GET | `/calls/{id}` | `getCall(id)` | +| GET | `/call-recordings/{callId}` | `getCallRecordings(callId)` | +| GET | `/call-transcripts/{id}` | `getCallTranscript(callId)` | +| GET | `/call-summaries/{callId}` | `getCallSummary(callId)` | +| GET | `/messages` | `listMessages(query)` | +| GET | `/messages/{id}` | `getMessage(id)` | +| POST | `/messages` | `sendMessage(body)` | +| GET | `/contacts` | `listContacts(query)` | +| GET | `/phone-numbers` | `listPhoneNumbers(query)` | +| GET | `/users` | `listUsers(query)` | +| GET/POST/DELETE | `/webhooks*` | `listWebhooks` / `create*Webhook` / `deleteWebhook` | + +**List parameters** (`listCalls` / `listMessages`): `phoneNumberId` (required, `^PN...`), `participants[]` (E.164; max 1 for calls, 10 for messages), `userId` (`^US...`), `maxResults` (1–100), `pageToken`, `createdAfter`, `createdBefore`. + +### Phone-number matching (for CRM sync) + +Quo/phone data keys on **phone number**, not email. Call objects carry `participants` (E.164) and `direction`; the external party's number is the participant that is not the workspace's own Quo number. Downstream integrations that resolve a CRM record should match on that phone number (e.g. Reevo's `retrieveAccountAndContact({ contact_phone_number })`, URL-encoding `+` as `%2B`). + +## Doc sources + +- Authentication: https://www.quo.com/docs/mdx/api-reference/authentication.md +- List calls: https://www.quo.com/docs/mdx/api-reference/calls/list-calls.md +- Call recordings / transcripts / summaries: `.../calls/get-recordings-for-a-call.md`, `.../calls/get-a-transcription-for-a-call.md`, `.../calls/get-a-summary-for-a-call.md` +- List messages: https://www.quo.com/docs/mdx/api-reference/messages/list-messages.md + +## License + +MIT diff --git a/packages/v1-ready/quo/api.js b/packages/v1-ready/quo/api.js new file mode 100644 index 0000000..c90c1e2 --- /dev/null +++ b/packages/v1-ready/quo/api.js @@ -0,0 +1,198 @@ +const { get, ApiKeyRequester } = require('@friggframework/core'); + +/** + * Quo (formerly OpenPhone) API client. + * + * Auth: API key sent RAW in the `Authorization` header (no `Bearer` prefix). + * Authorization: + * + * Base URL: https://api.openphone.com/v1 + * Docs: https://www.quo.com/docs/api-reference/authentication + */ +class Api extends ApiKeyRequester { + constructor(params = {}) { + super(params); + + // Quo/OpenPhone uses the Authorization header with the raw key. + this.api_key_name = 'Authorization'; + + // Accept either `api_key` or `access_token` (Frigg credential persistence + // uses access_token for API-key modules by convention). + const apiKey = + get(params, 'api_key', null) || + get(params, 'access_token', null) || + process.env.QUO_API_KEY || + null; + if (apiKey) { + this.setApiKey(apiKey); + } + + this.baseUrl = get(params, 'baseUrl', null) || + process.env.QUO_BASE_URL || + 'https://api.openphone.com/v1'; + + this.URLs = { + // Calls + calls: '/calls', + callById: (id) => `/calls/${id}`, + callRecordings: (callId) => `/call-recordings/${callId}`, + callTranscript: (callId) => `/call-transcripts/${callId}`, + callSummary: (callId) => `/call-summaries/${callId}`, + // Messages + messages: '/messages', + messageById: (id) => `/messages/${id}`, + // Contacts + contacts: '/contacts', + contactById: (id) => `/contacts/${id}`, + // Phone numbers + phoneNumbers: '/phone-numbers', + phoneNumberById: (id) => `/phone-numbers/${id}`, + // Users + users: '/users', + userById: (id) => `/users/${id}`, + // Webhooks + webhooks: '/webhooks', + webhookById: (id) => `/webhooks/${id}`, + webhookCalls: '/webhooks/calls', + webhookMessages: '/webhooks/messages', + webhookCallSummaries: '/webhooks/call-summaries', + webhookCallTranscripts: '/webhooks/call-transcripts', + }; + } + + // ---- Phone numbers / users (used for auth test + resolving IDs) ---- + + async listPhoneNumbers(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.phoneNumbers, query }); + } + + async getPhoneNumber(id) { + return this._get({ url: this.baseUrl + this.URLs.phoneNumberById(id) }); + } + + async listUsers(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.users, query }); + } + + async getUser(id) { + return this._get({ url: this.baseUrl + this.URLs.userById(id) }); + } + + // ---- Calls ---- + + /** + * List calls. + * @param {object} query - phoneNumberId (required, ^PN...), participants[] + * (E.164, max 1), userId (^US...), maxResults (1-100), pageToken, + * createdAfter, createdBefore. + */ + async listCalls(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.calls, query }); + } + + async getCall(id) { + return this._get({ url: this.baseUrl + this.URLs.callById(id) }); + } + + async getCallRecordings(callId) { + return this._get({ url: this.baseUrl + this.URLs.callRecordings(callId) }); + } + + async getCallTranscript(callId) { + return this._get({ url: this.baseUrl + this.URLs.callTranscript(callId) }); + } + + async getCallSummary(callId) { + return this._get({ url: this.baseUrl + this.URLs.callSummary(callId) }); + } + + // ---- Messages ---- + + /** + * List messages. + * @param {object} query - phoneNumberId (required, ^PN...), participants[] + * (E.164, max 10), userId, maxResults (1-100), pageToken, + * createdAfter, createdBefore. + */ + async listMessages(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.messages, query }); + } + + async getMessage(id) { + return this._get({ url: this.baseUrl + this.URLs.messageById(id) }); + } + + async sendMessage(body) { + return this._post({ + url: this.baseUrl + this.URLs.messages, + headers: { 'Content-Type': 'application/json' }, + body, + }); + } + + // ---- Contacts ---- + + async listContacts(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.contacts, query }); + } + + async getContact(id) { + return this._get({ url: this.baseUrl + this.URLs.contactById(id) }); + } + + async createContact(body) { + return this._post({ + url: this.baseUrl + this.URLs.contacts, + headers: { 'Content-Type': 'application/json' }, + body, + }); + } + + // ---- Webhooks ---- + + async listWebhooks(query = {}) { + return this._get({ url: this.baseUrl + this.URLs.webhooks, query }); + } + + async getWebhook(id) { + return this._get({ url: this.baseUrl + this.URLs.webhookById(id) }); + } + + async createCallWebhook(body) { + return this._post({ + url: this.baseUrl + this.URLs.webhookCalls, + headers: { 'Content-Type': 'application/json' }, + body, + }); + } + + async createMessageWebhook(body) { + return this._post({ + url: this.baseUrl + this.URLs.webhookMessages, + headers: { 'Content-Type': 'application/json' }, + body, + }); + } + + async createCallSummaryWebhook(body) { + return this._post({ + url: this.baseUrl + this.URLs.webhookCallSummaries, + headers: { 'Content-Type': 'application/json' }, + body, + }); + } + + async createCallTranscriptWebhook(body) { + return this._post({ + url: this.baseUrl + this.URLs.webhookCallTranscripts, + headers: { 'Content-Type': 'application/json' }, + body, + }); + } + + async deleteWebhook(id) { + return this._delete({ url: this.baseUrl + this.URLs.webhookById(id) }); + } +} + +module.exports = { Api }; diff --git a/packages/v1-ready/quo/defaultConfig.json b/packages/v1-ready/quo/defaultConfig.json new file mode 100644 index 0000000..7b3230b --- /dev/null +++ b/packages/v1-ready/quo/defaultConfig.json @@ -0,0 +1,10 @@ +{ + "name": "quo", + "config": { + "apiKey": true, + "batch": { + "concurrency": 3, + "delay": 1000 + } + } +} diff --git a/packages/v1-ready/quo/definition.js b/packages/v1-ready/quo/definition.js new file mode 100644 index 0000000..ff880f9 --- /dev/null +++ b/packages/v1-ready/quo/definition.js @@ -0,0 +1,109 @@ +require('dotenv').config(); +const crypto = require('crypto'); +const { get } = require('@friggframework/core'); +const { Api } = require('./api'); +const config = require('./defaultConfig.json'); + +// Quo (OpenPhone) authenticates with a single static API key and returns no +// stable account/workspace id at auth time. We derive a stable, non-reversible +// identifier from the API key itself (mirroring the gong/otter modules) so the +// same credential always maps to the same entity/credential. A phone-number id +// is NOT stable — reordering or deleting a number would change it and fork a new +// entity on re-auth — and a shared constant collides across accounts, so +// neither is acceptable as the externalId. +const keyFingerprint = (apiKey) => { + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + throw new Error( + 'Quo: cannot derive a stable externalId — no API key present on the api instance.' + ); + } + return crypto.createHash('sha256').update(apiKey).digest('hex'); +}; + +const Definition = { + API: Api, + getName: () => config.name, + moduleName: config.name, + modelName: 'Quo', + requiredAuthMethods: { + // API-key module: render an interactive form (CLI + hosted UI) + getAuthorizationRequirements: () => ({ + type: 'apiKey', + data: { + jsonSchema: { + title: 'Quo API Authorization', + type: 'object', + required: ['api_key'], + properties: { + api_key: { + type: 'string', + title: 'API Key', + }, + }, + }, + uiSchema: { + api_key: { + 'ui:widget': 'password', + 'ui:help': + 'Your Quo (OpenPhone) API key from Settings → API. Sent raw in the Authorization header (no Bearer prefix).', + 'ui:placeholder': 'API Key', + }, + }, + }, + }), + + setAuthParams: async (api, params) => { + const apiKey = + get(params, 'api_key', null) || + get(params, 'access_token', null); + if (apiKey) { + api.setApiKey(apiKey); + } + }, + + // Any authenticated call verifies the key. listPhoneNumbers is cheap + // and always available on a valid workspace token. + testAuthRequest: async (api) => api.listPhoneNumbers(), + + getEntityDetails: async (api, callbackParams, tokenResponse, userId) => { + // externalId is a stable sha256 fingerprint of the API key — unique + // per credential and unchanged across phone-number churn. The + // workspace's first phone number is still used only for a friendly + // display name (best-effort), never for identity. + const externalId = keyFingerprint(api.api_key); + let name = 'Quo Workspace'; + try { + const phoneNumbers = await api.listPhoneNumbers(); + const first = get(phoneNumbers, 'data', [])[0] || {}; + name = first.name || first.number || name; + } catch (e) { + // Display name is non-critical; identity does not depend on it. + } + return { + identifiers: { externalId, user: userId }, + details: { name }, + }; + }, + + getCredentialDetails: async (api, userId) => { + const externalId = keyFingerprint(api.api_key); + return { + identifiers: { externalId, user: userId }, + details: {}, + }; + }, + + apiPropertiesToPersist: { + // ApiKeyRequester stores the key; persist it as access_token so the + // Api constructor rehydrates it on the next instantiation. + credential: ['access_token', 'api_key'], + entity: [], + }, + }, + env: { + api_key: process.env.QUO_API_KEY, + base_url: process.env.QUO_BASE_URL, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/quo/index.js b/packages/v1-ready/quo/index.js new file mode 100644 index 0000000..3c94a63 --- /dev/null +++ b/packages/v1-ready/quo/index.js @@ -0,0 +1,7 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); + +module.exports = { + Api, + Definition, +}; diff --git a/packages/v1-ready/quo/jest.config.js b/packages/v1-ready/quo/jest.config.js new file mode 100644 index 0000000..7654ac3 --- /dev/null +++ b/packages/v1-ready/quo/jest.config.js @@ -0,0 +1,7 @@ +/* + * Offline unit tests only — no live API calls, no database. + */ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.js'], +}; diff --git a/packages/v1-ready/quo/package.json b/packages/v1-ready/quo/package.json new file mode 100644 index 0000000..2ec3008 --- /dev/null +++ b/packages/v1-ready/quo/package.json @@ -0,0 +1,36 @@ +{ + "name": "@friggframework/api-module-quo", + "version": "1.0.0", + "description": "Quo (formerly OpenPhone) API module for the Frigg Framework — calls, recordings, transcripts, summaries, and messages.", + "main": "index.js", + "scripts": { + "lint:fix": "prettier --write --loglevel error . && eslint . --fix", + "test": "jest" + }, + "author": "Left Hook", + "license": "MIT", + "keywords": [ + "frigg", + "quo", + "openphone", + "voip", + "calls", + "sms", + "api-module" + ], + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "eslint": "^8.22.0", + "jest": "^28.1.3", + "jest-environment-jsdom": "^28.1.3", + "js-yaml": "^4.1.0", + "prettier": "^2.7.1" + }, + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/quo/quo.openapi.yaml b/packages/v1-ready/quo/quo.openapi.yaml new file mode 100644 index 0000000..b56edbe --- /dev/null +++ b/packages/v1-ready/quo/quo.openapi.yaml @@ -0,0 +1,507 @@ +openapi: 3.0.3 +info: + title: Quo (OpenPhone) Public API + version: "1.0.0" + description: >- + Quo (formerly OpenPhone) is a business phone system. This is the public REST + API surface documented at https://www.quo.com/docs/api-reference (the + OpenPhone docs at https://www.openphone.com/docs/api-reference 301-redirect + here). This spec covers exactly the endpoints the + @friggframework/api-module-quo client (api.js) implements — calls, + recordings, transcripts, summaries, messages, contacts, phone numbers, + users, and webhook CRUD — and is the source of truth that hand-written + client mirrors. + + + AUTH QUIRK — NO `Bearer` PREFIX. Quo/OpenPhone sends the API key RAW in the + `Authorization` header: + + Authorization: YOUR_API_KEY + + The docs state explicitly: "The Quo API does not use a Bearer token for + authentication." + (https://www.quo.com/docs/api-reference/authentication). This is why the + security scheme below is modeled as an `apiKey` scheme in the `Authorization` + header — NOT as `http`/`bearer`. The client sets `api_key_name = + 'Authorization'` and writes the key verbatim, with no `Bearer ` prefix. + contact: + name: Left Hook + url: https://lefthook.com +servers: + - url: https://api.openphone.com/v1 + description: Quo/OpenPhone production API +security: + - ApiKeyAuth: [] +tags: + - name: Calls + - name: Messages + - name: Contacts + - name: Phone Numbers + - name: Users + - name: Webhooks +paths: + /calls: + get: + tags: [Calls] + operationId: listCalls + summary: List calls + description: >- + Fetch a paginated list of calls associated with a specific Quo number + and another number. + parameters: + - in: query + name: phoneNumberId + required: true + schema: { type: string, pattern: "^PN(.*)$" } + description: The Quo phone number id to scope the calls to. + - in: query + name: participants + schema: + type: array + maxItems: 1 + items: { type: string } + description: E.164 participant filter (max 1). + - in: query + name: userId + schema: { type: string, pattern: "^US(.*)$" } + - in: query + name: maxResults + schema: { type: integer, minimum: 1, maximum: 100 } + - in: query + name: pageToken + schema: { type: string } + - in: query + name: createdAfter + schema: { type: string, format: date-time } + - in: query + name: createdBefore + schema: { type: string, format: date-time } + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /calls/{id}: + parameters: + - $ref: "#/components/parameters/CallId" + get: + tags: [Calls] + operationId: getCall + summary: Get a call by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /call-recordings/{callId}: + parameters: + - $ref: "#/components/parameters/CallIdPath" + get: + tags: [Calls] + operationId: getCallRecordings + summary: Get recordings for a call + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /call-transcripts/{id}: + parameters: + - $ref: "#/components/parameters/CallId" + get: + tags: [Calls] + operationId: getCallTranscript + summary: Get a transcription for a call + description: Available on Business/Scale plans. + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /call-summaries/{callId}: + parameters: + - $ref: "#/components/parameters/CallIdPath" + get: + tags: [Calls] + operationId: getCallSummary + summary: Get a summary for a call + description: Available on Business/Scale plans. + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /messages: + get: + tags: [Messages] + operationId: listMessages + summary: List messages + parameters: + - in: query + name: phoneNumberId + required: true + schema: { type: string, pattern: "^PN(.*)$" } + - in: query + name: participants + schema: + type: array + maxItems: 10 + items: { type: string } + description: E.164 participant filter (max 10). + - in: query + name: userId + schema: { type: string, pattern: "^US(.*)$" } + - in: query + name: maxResults + schema: { type: integer, minimum: 1, maximum: 100 } + - in: query + name: pageToken + schema: { type: string } + - in: query + name: createdAfter + schema: { type: string, format: date-time } + - in: query + name: createdBefore + schema: { type: string, format: date-time } + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Messages] + operationId: sendMessage + summary: Send a text message + description: Send a text message from your Quo number to a recipient. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MessageSend" } + responses: + "202": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /messages/{id}: + parameters: + - $ref: "#/components/parameters/MessageId" + get: + tags: [Messages] + operationId: getMessage + summary: Get a message by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /contacts: + get: + tags: [Contacts] + operationId: listContacts + summary: List contacts + parameters: + - in: query + name: externalIds + schema: + type: array + items: { type: string } + - in: query + name: sources + schema: + type: array + items: { type: string } + - in: query + name: maxResults + schema: { type: integer, minimum: 1, maximum: 100 } + - in: query + name: pageToken + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Contacts] + operationId: createContact + summary: Create a contact + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ContactCreate" } + responses: + "201": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /contacts/{id}: + parameters: + - $ref: "#/components/parameters/ContactId" + get: + tags: [Contacts] + operationId: getContact + summary: Get a contact by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /phone-numbers: + get: + tags: [Phone Numbers] + operationId: listPhoneNumbers + summary: List phone numbers + description: >- + View the Quo phone numbers in the workspace and their associated users. + Used by the module's auth test. + parameters: + - in: query + name: userId + schema: { type: string, pattern: "^US(.*)$" } + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /phone-numbers/{id}: + parameters: + - $ref: "#/components/parameters/PhoneNumberId" + get: + tags: [Phone Numbers] + operationId: getPhoneNumber + summary: Get a phone number by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /users: + get: + tags: [Users] + operationId: listUsers + summary: List users + parameters: + - in: query + name: maxResults + schema: { type: integer, minimum: 1, maximum: 100 } + - in: query + name: pageToken + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserId" + get: + tags: [Users] + operationId: getUser + summary: Get a user by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /webhooks: + get: + tags: [Webhooks] + operationId: listWebhooks + summary: Lists all webhooks + description: Display all webhooks for a user. + parameters: + - in: query + name: userId + schema: { type: string, pattern: "^US(.*)$" } + responses: + "200": { $ref: "#/components/responses/ListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /webhooks/{id}: + parameters: + - $ref: "#/components/parameters/WebhookId" + get: + tags: [Webhooks] + operationId: getWebhook + summary: Get a webhook by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [Webhooks] + operationId: deleteWebhook + summary: Delete a webhook by ID + responses: + "200": { $ref: "#/components/responses/ObjectResponse" } + "404": { $ref: "#/components/responses/NotFound" } + /webhooks/calls: + post: + tags: [Webhooks] + operationId: createCallWebhook + summary: Create a new webhook for calls + description: Creates a new webhook that triggers on events from calls. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCreate" } + responses: + "201": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /webhooks/messages: + post: + tags: [Webhooks] + operationId: createMessageWebhook + summary: Create a new webhook for messages + description: Creates a new webhook that triggers on events from messages. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCreate" } + responses: + "201": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /webhooks/call-summaries: + post: + tags: [Webhooks] + operationId: createCallSummaryWebhook + summary: Create a new webhook for call summaries + description: Creates a new webhook that triggers on call summary events. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCreate" } + responses: + "201": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + /webhooks/call-transcripts: + post: + tags: [Webhooks] + operationId: createCallTranscriptWebhook + summary: Create a new webhook for call transcripts + description: Creates a new webhook that triggers on call transcript events. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCreate" } + responses: + "201": { $ref: "#/components/responses/ObjectResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } +components: + securitySchemes: + # NO Bearer prefix — the raw API key is the entire Authorization header value. + # https://www.quo.com/docs/api-reference/authentication + ApiKeyAuth: + type: apiKey + in: header + name: Authorization + description: >- + Raw API key sent as the whole `Authorization` header value, with NO + `Bearer ` prefix. e.g. `Authorization: op_live_xxx`. + parameters: + CallId: + in: path + name: id + required: true + schema: { type: string, pattern: "^AC(.*)$" } + description: The unique identifier of the call. + CallIdPath: + in: path + name: callId + required: true + schema: { type: string, pattern: "^AC(.*)$" } + description: The unique identifier of the call. + MessageId: + in: path + name: id + required: true + schema: { type: string } + ContactId: + in: path + name: id + required: true + schema: { type: string } + PhoneNumberId: + in: path + name: id + required: true + schema: { type: string, pattern: "^PN(.*)$" } + UserId: + in: path + name: id + required: true + schema: { type: string, pattern: "^US(.*)$" } + WebhookId: + in: path + name: id + required: true + schema: { type: string } + responses: + ObjectResponse: + description: A single object, wrapped in a `data` envelope. + content: + application/json: + schema: + type: object + additionalProperties: true + properties: + data: { type: object, additionalProperties: true } + ListResponse: + description: A paginated list, wrapped in a `data` envelope with pagination cursors. + content: + application/json: + schema: + type: object + additionalProperties: true + properties: + data: + type: array + items: { type: object, additionalProperties: true } + totalItems: { type: integer } + nextPageToken: { type: string, nullable: true } + Unauthorized: + description: Missing or invalid API key. + NotFound: + description: Object not found. + schemas: + MessageSend: + type: object + required: [content, from, to] + properties: + content: { type: string, description: The text body of the message. } + from: + type: string + description: The sending Quo phone number id (^PN...) or E.164 number. + to: + type: array + items: { type: string } + description: Recipient phone numbers in E.164 format. + userId: { type: string, pattern: "^US(.*)$" } + setInboxStatus: + type: string + enum: [done] + ContactCreate: + type: object + required: [defaultFields] + properties: + defaultFields: + type: object + properties: + firstName: { type: string } + lastName: { type: string } + company: { type: string } + role: { type: string } + emails: + type: array + items: + type: object + properties: + name: { type: string } + value: { type: string, format: email } + phoneNumbers: + type: array + items: + type: object + properties: + name: { type: string } + value: { type: string } + customFields: + type: array + items: + type: object + additionalProperties: true + source: { type: string } + externalId: { type: string } + WebhookCreate: + type: object + required: [url, events] + properties: + url: { type: string, format: uri, description: The destination URL for webhook deliveries. } + events: + type: array + items: { type: string } + description: The event types to subscribe to. + label: { type: string } + userId: { type: string, pattern: "^US(.*)$" } + resourceIds: + type: array + items: { type: string } + description: Phone number ids (^PN...) or "*" for all. + status: + type: string + enum: [enabled, disabled] diff --git a/packages/v1-ready/quo/tests/api.test.js b/packages/v1-ready/quo/tests/api.test.js new file mode 100644 index 0000000..97fc59a --- /dev/null +++ b/packages/v1-ready/quo/tests/api.test.js @@ -0,0 +1,199 @@ +const { Api } = require('../api'); + +describe('Quo Api (offline)', () => { + describe('construction / auth', () => { + it('sets the Authorization header name and raw key (no Bearer prefix)', async () => { + const api = new Api({ api_key: 'op_test_123' }); + expect(api.api_key_name).toBe('Authorization'); + expect(api.api_key).toBe('op_test_123'); + expect(api.isAuthenticated()).toBe(true); + + const headers = await api.addAuthHeaders({}); + expect(headers.Authorization).toBe('op_test_123'); + expect(headers.Authorization).not.toMatch(/^Bearer /); + }); + + it('accepts access_token as an alias for api_key (credential rehydration)', () => { + const api = new Api({ access_token: 'op_from_credential' }); + expect(api.api_key).toBe('op_from_credential'); + expect(api.isAuthenticated()).toBe(true); + }); + + it('is unauthenticated with no key', () => { + const api = new Api({}); + expect(api.isAuthenticated()).toBe(false); + }); + + it('defaults the base URL to the production Quo/OpenPhone host', () => { + const api = new Api({ api_key: 'x' }); + expect(api.baseUrl).toBe('https://api.openphone.com/v1'); + }); + + it('honors a baseUrl override', () => { + const api = new Api({ api_key: 'x', baseUrl: 'https://example.test/v1' }); + expect(api.baseUrl).toBe('https://example.test/v1'); + }); + }); + + describe('URL builders', () => { + const api = new Api({ api_key: 'x' }); + + it('builds call endpoints', () => { + expect(api.URLs.calls).toBe('/calls'); + expect(api.URLs.callById('AC1')).toBe('/calls/AC1'); + expect(api.URLs.callRecordings('AC1')).toBe('/call-recordings/AC1'); + expect(api.URLs.callTranscript('AC1')).toBe('/call-transcripts/AC1'); + expect(api.URLs.callSummary('AC1')).toBe('/call-summaries/AC1'); + }); + + it('builds message and contact endpoints', () => { + expect(api.URLs.messages).toBe('/messages'); + expect(api.URLs.messageById('AC9')).toBe('/messages/AC9'); + expect(api.URLs.contacts).toBe('/contacts'); + }); + + it('exposes the read methods used by the Reevo integration', () => { + for (const m of ['listCalls', 'getCall', 'getCallRecordings', + 'getCallTranscript', 'getCallSummary', 'listMessages', + 'listPhoneNumbers']) { + expect(typeof api[m]).toBe('function'); + } + }); + }); + + describe('request wiring (mocked transport)', () => { + it('listCalls issues a GET to /calls with the query', async () => { + const api = new Api({ api_key: 'x' }); + const spy = jest + .spyOn(api, '_get') + .mockResolvedValue({ data: [] }); + + await api.listCalls({ phoneNumberId: 'PN1', maxResults: 50 }); + + expect(spy).toHaveBeenCalledWith({ + url: 'https://api.openphone.com/v1/calls', + query: { phoneNumberId: 'PN1', maxResults: 50 }, + }); + }); + + it('getCallTranscript issues a GET to /call-transcripts/{id}', async () => { + const api = new Api({ api_key: 'x' }); + const spy = jest.spyOn(api, '_get').mockResolvedValue({ dialogue: [] }); + + await api.getCallTranscript('AC42'); + + expect(spy).toHaveBeenCalledWith({ + url: 'https://api.openphone.com/v1/call-transcripts/AC42', + }); + }); + }); + + // Full request-wiring coverage: every endpoint method asserts the ACTUAL + // transport verb (_get / _post / _delete), URL, and (for writes) the body, + // so a wrong path/verb/body would fail — not just a missing method. + describe('request wiring — every endpoint (mocked transport)', () => { + const BASE = 'https://api.openphone.com/v1'; + const JSON_HEADERS = { 'Content-Type': 'application/json' }; + + let api; + let getSpy; + let postSpy; + let deleteSpy; + + beforeEach(() => { + api = new Api({ api_key: 'x' }); + getSpy = jest.spyOn(api, '_get').mockResolvedValue({ data: [] }); + postSpy = jest.spyOn(api, '_post').mockResolvedValue({ id: 'new' }); + deleteSpy = jest.spyOn(api, '_delete').mockResolvedValue({}); + }); + + // ---- GET endpoints: [method, args, expected {url, query?}] ---- + const GET_CASES = [ + ['getCall', ['AC1'], { url: `${BASE}/calls/AC1` }], + ['getCallRecordings', ['AC1'], { url: `${BASE}/call-recordings/AC1` }], + ['getCallSummary', ['AC1'], { url: `${BASE}/call-summaries/AC1` }], + [ + 'listMessages', + [{ phoneNumberId: 'PN1', maxResults: 25 }], + { url: `${BASE}/messages`, query: { phoneNumberId: 'PN1', maxResults: 25 } }, + ], + ['getMessage', ['AC9'], { url: `${BASE}/messages/AC9` }], + [ + 'listContacts', + [{ maxResults: 10 }], + { url: `${BASE}/contacts`, query: { maxResults: 10 } }, + ], + ['getContact', ['CT7'], { url: `${BASE}/contacts/CT7` }], + [ + 'listPhoneNumbers', + [{ userId: 'US1' }], + { url: `${BASE}/phone-numbers`, query: { userId: 'US1' } }, + ], + ['getPhoneNumber', ['PN5'], { url: `${BASE}/phone-numbers/PN5` }], + [ + 'listUsers', + [{ maxResults: 5 }], + { url: `${BASE}/users`, query: { maxResults: 5 } }, + ], + ['getUser', ['US3'], { url: `${BASE}/users/US3` }], + [ + 'listWebhooks', + [{ userId: 'US2' }], + { url: `${BASE}/webhooks`, query: { userId: 'US2' } }, + ], + ['getWebhook', ['WH1'], { url: `${BASE}/webhooks/WH1` }], + ]; + + it.each(GET_CASES)( + '%s issues a GET to the correct URL (and query)', + async (method, args, expected) => { + await api[method](...args); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith(expected); + expect(postSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + } + ); + + // ---- POST endpoints: [method, bodyArg, expectedUrl] ---- + const POST_CASES = [ + ['sendMessage', { content: 'hi', to: ['+15555550100'] }, `${BASE}/messages`], + ['createContact', { firstName: 'Ada' }, `${BASE}/contacts`], + ['createCallWebhook', { url: 'https://cb/1' }, `${BASE}/webhooks/calls`], + ['createMessageWebhook', { url: 'https://cb/2' }, `${BASE}/webhooks/messages`], + [ + 'createCallSummaryWebhook', + { url: 'https://cb/3' }, + `${BASE}/webhooks/call-summaries`, + ], + [ + 'createCallTranscriptWebhook', + { url: 'https://cb/4' }, + `${BASE}/webhooks/call-transcripts`, + ], + ]; + + it.each(POST_CASES)( + '%s issues a POST to the correct URL with the JSON body', + async (method, body, url) => { + await api[method](body); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith({ + url, + headers: JSON_HEADERS, + body, + }); + expect(getSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + } + ); + + it('deleteWebhook issues a DELETE to /webhooks/{id}', async () => { + await api.deleteWebhook('WH9'); + expect(deleteSpy).toHaveBeenCalledTimes(1); + expect(deleteSpy).toHaveBeenCalledWith({ url: `${BASE}/webhooks/WH9` }); + expect(getSpy).not.toHaveBeenCalled(); + expect(postSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/v1-ready/quo/tests/definition.test.js b/packages/v1-ready/quo/tests/definition.test.js new file mode 100644 index 0000000..aa7640d --- /dev/null +++ b/packages/v1-ready/quo/tests/definition.test.js @@ -0,0 +1,105 @@ +const { Definition } = require('../definition'); +const { Api } = require('../api'); + +describe('Quo Definition (offline)', () => { + it('is an API-key module named "quo"', () => { + expect(Definition.moduleName).toBe('quo'); + expect(Definition.getName()).toBe('quo'); + expect(Definition.API).toBe(Api); + expect(Definition.modelName).toBe('Quo'); + }); + + it('exposes an apiKey authorization form requiring api_key (masked)', () => { + const reqs = Definition.requiredAuthMethods.getAuthorizationRequirements(); + expect(reqs.type).toBe('apiKey'); + expect(reqs.data.jsonSchema.required).toContain('api_key'); + expect(reqs.data.uiSchema.api_key['ui:widget']).toBe('password'); + }); + + it('setAuthParams sets the key on the api instance', async () => { + const api = new Api({}); + expect(api.isAuthenticated()).toBe(false); + await Definition.requiredAuthMethods.setAuthParams(api, { api_key: 'k1' }); + expect(api.api_key).toBe('k1'); + expect(api.isAuthenticated()).toBe(true); + }); + + it('persists the key as access_token for rehydration', () => { + expect(Definition.requiredAuthMethods.apiPropertiesToPersist.credential) + .toEqual(expect.arrayContaining(['access_token'])); + }); + + it('testAuthRequest calls an authenticated endpoint', async () => { + const api = new Api({ api_key: 'k' }); + const spy = jest.spyOn(api, 'listPhoneNumbers').mockResolvedValue({ data: [] }); + await Definition.requiredAuthMethods.testAuthRequest(api); + expect(spy).toHaveBeenCalled(); + }); + + it('getEntityDetails uses the first phone number only for the display name, not identity', async () => { + const api = new Api({ api_key: 'k' }); + jest.spyOn(api, 'listPhoneNumbers').mockResolvedValue({ + data: [{ id: 'PN123', name: 'Sales Line', number: '+15555550100' }], + }); + const details = await Definition.requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-1' + ); + // Identity is a sha256 fingerprint of the key — NOT the phone-number id. + expect(details.identifiers.externalId).not.toBe('PN123'); + expect(details.identifiers.externalId).toMatch(/^[a-f0-9]{64}$/); + expect(details.identifiers.user).toBe('user-1'); + expect(details.details.name).toBe('Sales Line'); + }); + + describe('externalId identity (key fingerprint)', () => { + const { requiredAuthMethods } = Definition; + + const entityIdFor = async (key) => { + const api = new Api({ api_key: key }); + // A phone-number listing must not influence identity. + jest.spyOn(api, 'listPhoneNumbers').mockResolvedValue({ + data: [{ id: `PN-${Math.random()}` }], + }); + const d = await requiredAuthMethods.getEntityDetails(api, {}, {}, 'u'); + return d.identifiers.externalId; + }; + + it('is a stable sha256 hex derived from the key, never a constant', async () => { + const first = await entityIdFor('op_key_alpha'); + const second = await entityIdFor('op_key_alpha'); + expect(first).toMatch(/^[a-f0-9]{64}$/); + // Stable: same key → same id, regardless of phone-number churn. + expect(first).toBe(second); + // Never the removed shared constant. + expect(first).not.toBe('quo-workspace'); + // Never the raw key. + expect(first).not.toBe('op_key_alpha'); + }); + + it('is unique per key (different keys → different ids)', async () => { + const a = await entityIdFor('op_key_alpha'); + const b = await entityIdFor('op_key_beta'); + expect(a).not.toBe(b); + }); + + it('getEntityDetails and getCredentialDetails agree for the same key', async () => { + const api = new Api({ api_key: 'op_key_shared' }); + jest.spyOn(api, 'listPhoneNumbers').mockResolvedValue({ data: [] }); + const entity = await requiredAuthMethods.getEntityDetails(api, {}, {}, 'u'); + const credential = await requiredAuthMethods.getCredentialDetails(api, 'u'); + expect(entity.identifiers.externalId).toBe( + credential.identifiers.externalId + ); + }); + + it('throws rather than emitting a shared constant when no key is present', async () => { + const api = new Api({}); + await expect( + requiredAuthMethods.getCredentialDetails(api, 'u') + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/v1-ready/quo/tests/spec-sync.test.js b/packages/v1-ready/quo/tests/spec-sync.test.js new file mode 100644 index 0000000..5b80c75 --- /dev/null +++ b/packages/v1-ready/quo/tests/spec-sync.test.js @@ -0,0 +1,45 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { Api } = require('../api'); + +const spec = yaml.load( + fs.readFileSync(path.join(__dirname, '..', 'quo.openapi.yaml'), 'utf8') +); + +const specOperationIds = Object.values(spec.paths).flatMap((item) => + Object.entries(item) + .filter(([m]) => ['get', 'post', 'patch', 'put', 'delete'].includes(m)) + .map(([, op]) => op.operationId) +); + +const clientMethods = Object.getOwnPropertyNames(Api.prototype).filter( + (m) => typeof Api.prototype[m] === 'function' && m !== 'constructor' +); + +describe('OpenAPI spec ↔ client sync', () => { + it('every operationId has a matching client method', () => { + const missing = specOperationIds.filter( + (op) => !clientMethods.includes(op) + ); + expect(missing).toEqual([]); + }); + + it('the base server URL matches the client default baseUrl', () => { + const api = new Api({ api_key: 'x' }); + expect(spec.servers[0].url).toBe(api.baseUrl); + }); + + it('declares Authorization apiKey security with NO Bearer prefix', () => { + const scheme = spec.components.securitySchemes.ApiKeyAuth; + expect(scheme.type).toBe('apiKey'); + expect(scheme.in).toBe('header'); + // Quo/OpenPhone sends the raw key in `Authorization` — not `x-api-key`, + // and NOT as an http/bearer scheme. The client mirrors this via + // api_key_name = 'Authorization'. + expect(scheme.name).toBe('Authorization'); + expect(scheme.type).not.toBe('http'); + const api = new Api({ api_key: 'x' }); + expect(api.api_key_name).toBe(scheme.name); + }); +});