@jskit-ai/connectors-catalog 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/docs/ai-upstream-notices.md +51 -0
- package/docs/ai.md +191 -0
- package/docs/airtable.md +130 -0
- package/docs/algolia.md +190 -0
- package/docs/amazon-redshift.md +229 -0
- package/docs/amplitude.md +210 -0
- package/docs/apify.md +162 -0
- package/docs/apollo-io.md +200 -0
- package/docs/asana.md +126 -0
- package/docs/ashby.md +188 -0
- package/docs/atlassian.md +224 -0
- package/docs/attention.md +162 -0
- package/docs/aws-athena.md +150 -0
- package/docs/aws-credentials.md +75 -0
- package/docs/aws-s3.md +193 -0
- package/docs/bigquery.md +146 -0
- package/docs/brevo.md +122 -0
- package/docs/calendly.md +158 -0
- package/docs/canva.md +191 -0
- package/docs/chargebee.md +192 -0
- package/docs/clay.md +194 -0
- package/docs/clickhouse.md +242 -0
- package/docs/confidence-exp.md +74 -0
- package/docs/confidence-flags.md +75 -0
- package/docs/confidence-oauth.md +137 -0
- package/docs/contentful.md +123 -0
- package/docs/databricks.md +180 -0
- package/docs/dbt-semantic-layer.md +175 -0
- package/docs/elevenlabs.md +102 -0
- package/docs/figma.md +132 -0
- package/docs/firebase-cloud-messaging.md +262 -0
- package/docs/firecrawl.md +137 -0
- package/docs/fireflies.md +95 -0
- package/docs/fireworks-ai.md +53 -0
- package/docs/gatewayapi.md +113 -0
- package/docs/gemini-enterprise.md +224 -0
- package/docs/github-api.md +133 -0
- package/docs/gitlab-api.md +138 -0
- package/docs/gmail.md +133 -0
- package/docs/gong.md +205 -0
- package/docs/google-ads.md +156 -0
- package/docs/google-analytics.md +202 -0
- package/docs/google-docs.md +105 -0
- package/docs/google-drive.md +108 -0
- package/docs/google-maps-platform.md +208 -0
- package/docs/google-oauth.md +123 -0
- package/docs/google-search-console.md +124 -0
- package/docs/google-sheets.md +94 -0
- package/docs/google-slides.md +95 -0
- package/docs/granola.md +178 -0
- package/docs/hex.md +161 -0
- package/docs/heygen.md +124 -0
- package/docs/hubspot.md +142 -0
- package/docs/incident-io.md +118 -0
- package/docs/inngest.md +178 -0
- package/docs/klipy.md +149 -0
- package/docs/lexware.md +144 -0
- package/docs/lightspeed.md +193 -0
- package/docs/linear.md +137 -0
- package/docs/linkedin.md +196 -0
- package/docs/logo-dev.md +152 -0
- package/docs/mailgun.md +153 -0
- package/docs/mapbox.md +174 -0
- package/docs/microsoft-excel.md +97 -0
- package/docs/microsoft-fabric.md +249 -0
- package/docs/microsoft-oauth.md +143 -0
- package/docs/microsoft-onedrive.md +76 -0
- package/docs/microsoft-onenote.md +75 -0
- package/docs/microsoft-outlook.md +90 -0
- package/docs/microsoft-powerpoint.md +119 -0
- package/docs/microsoft-sharepoint.md +75 -0
- package/docs/microsoft-teams.md +72 -0
- package/docs/microsoft-word.md +119 -0
- package/docs/miro.md +117 -0
- package/docs/n8n.md +169 -0
- package/docs/notion.md +137 -0
- package/docs/oura.md +161 -0
- package/docs/paddle.md +168 -0
- package/docs/perplexity.md +85 -0
- package/docs/pipedrive.md +120 -0
- package/docs/polar.md +63 -0
- package/docs/posthog.md +227 -0
- package/docs/prestashop.md +151 -0
- package/docs/replicate.md +60 -0
- package/docs/resend.md +153 -0
- package/docs/salesforce.md +234 -0
- package/docs/sanity.md +122 -0
- package/docs/semrush.md +276 -0
- package/docs/sentry.md +112 -0
- package/docs/sevdesk.md +107 -0
- package/docs/shopify.md +206 -0
- package/docs/slack.md +257 -0
- package/docs/snowflake.md +271 -0
- package/docs/storyblok.md +130 -0
- package/docs/stripe.md +77 -0
- package/docs/supabase.md +51 -0
- package/docs/tally.md +105 -0
- package/docs/telegram.md +165 -0
- package/docs/tiktok.md +201 -0
- package/docs/twilio.md +284 -0
- package/docs/twitch.md +428 -0
- package/docs/wave.md +440 -0
- package/docs/wix.md +1249 -0
- package/docs/wiz.md +177 -0
- package/docs/woocommerce.md +400 -0
- package/docs/wordpress-com.md +358 -0
- package/docs/wordpress-self-hosted.md +252 -0
- package/docs/workday.md +292 -0
- package/docs/x-twitter.md +188 -0
- package/docs/xero.md +429 -0
- package/docs/zoho-books.md +214 -0
- package/docs/zoho-crm.md +192 -0
- package/package.json +153 -0
- package/patterns/ai-connections/PATTERN.md +84 -0
- package/patterns/ai-connections/example/ai-model.js +11 -0
- package/patterns/ai-connections/example/perplexity-answer.js +29 -0
- package/patterns/api-key-connection/PATTERN.md +368 -0
- package/patterns/api-key-connection/example/connections.js +18 -0
- package/patterns/api-key-connection/example/integrations.json +19 -0
- package/patterns/assistant-mcp/PATTERN.md +110 -0
- package/patterns/assistant-mcp/example/integrations.json +13 -0
- package/patterns/assistant-mcp-oauth/PATTERN.md +235 -0
- package/patterns/assistant-mcp-oauth/example/integrations.json +102 -0
- package/patterns/aws-storage-queries/PATTERN.md +169 -0
- package/patterns/aws-storage-queries/example/formats/data-formats.js +35 -0
- package/patterns/aws-storage-queries/example/formats/package-lock.json +49 -0
- package/patterns/aws-storage-queries/example/formats/package.json +12 -0
- package/patterns/aws-storage-queries/example/formats/verify-formats.mjs +37 -0
- package/patterns/aws-storage-queries/example/integrations.json +38 -0
- package/patterns/aws-storage-queries/example/s3-transfer.js +34 -0
- package/patterns/event-delivery/PATTERN.md +151 -0
- package/patterns/event-delivery/example/integrations.json +17 -0
- package/patterns/firebase-messaging/PATTERN.md +156 -0
- package/patterns/firebase-messaging/example/server/notifications.js +23 -0
- package/patterns/google-ads-search/PATTERN.md +92 -0
- package/patterns/google-ads-search/example/ads-setup.js +25 -0
- package/patterns/oauth-connection/PATTERN.md +724 -0
- package/patterns/oauth-connection/example/integrations.json +20 -0
- package/patterns/paddle-catalogue/PATTERN.md +78 -0
- package/patterns/paddle-catalogue/example/create-products.js +38 -0
- package/patterns/public-image/PATTERN.md +84 -0
- package/patterns/public-image/example/integrations.json +13 -0
- package/patterns/public-image/example/logo-url.js +16 -0
- package/patterns/redshift-queries/PATTERN.md +150 -0
- package/patterns/redshift-queries/example/integrations.json +33 -0
- package/patterns/source-scanning/PATTERN.md +83 -0
- package/patterns/source-scanning/example/source-scanner.js +13 -0
- package/src/client/firebase-cloud-messaging.js +10 -0
- package/src/client/google-analytics.js +72 -0
- package/src/client/google-maps-platform.js +39 -0
- package/src/client/logo-dev.js +36 -0
- package/src/server/ai.js +46 -0
- package/src/server/airtable.js +67 -0
- package/src/server/algolia.js +63 -0
- package/src/server/amazon-redshift.js +96 -0
- package/src/server/amplitude.js +31 -0
- package/src/server/apify.js +68 -0
- package/src/server/apollo-io.js +85 -0
- package/src/server/asana.js +64 -0
- package/src/server/ashby.js +61 -0
- package/src/server/atlassian.js +32 -0
- package/src/server/attention.js +93 -0
- package/src/server/aws-athena.js +82 -0
- package/src/server/aws-s3.js +49 -0
- package/src/server/awsTransport.js +75 -0
- package/src/server/bigquery.js +97 -0
- package/src/server/brevo.js +97 -0
- package/src/server/calendly.js +77 -0
- package/src/server/canva.js +13 -0
- package/src/server/chargebee.js +88 -0
- package/src/server/clay.js +78 -0
- package/src/server/clickhouse.js +66 -0
- package/src/server/confidence.js +29 -0
- package/src/server/contentful.js +40 -0
- package/src/server/databricks.js +55 -0
- package/src/server/dbt-semantic-layer.js +95 -0
- package/src/server/elevenlabs.js +81 -0
- package/src/server/figma.js +24 -0
- package/src/server/firebase-cloud-messaging.js +111 -0
- package/src/server/firecrawl.js +70 -0
- package/src/server/fireflies.js +31 -0
- package/src/server/fireworks-ai.js +16 -0
- package/src/server/gatewayapi.js +48 -0
- package/src/server/gemini-enterprise.js +47 -0
- package/src/server/github-api.js +90 -0
- package/src/server/gitlab-api.js +67 -0
- package/src/server/gmail.js +60 -0
- package/src/server/gong.js +52 -0
- package/src/server/google-ads-search.js +134 -0
- package/src/server/google-ads.js +80 -0
- package/src/server/google-docs.js +36 -0
- package/src/server/google-drive.js +71 -0
- package/src/server/google-maps-platform.js +90 -0
- package/src/server/google-search-console.js +31 -0
- package/src/server/google-sheets.js +60 -0
- package/src/server/google-slides.js +36 -0
- package/src/server/google.js +61 -0
- package/src/server/granola.js +115 -0
- package/src/server/graphqlOperation.js +15 -0
- package/src/server/hex.js +29 -0
- package/src/server/heygen.js +54 -0
- package/src/server/hubspot.js +85 -0
- package/src/server/incident-io.js +68 -0
- package/src/server/inngest.js +86 -0
- package/src/server/jsonOperation.js +34 -0
- package/src/server/klipy.js +34 -0
- package/src/server/lexware.js +59 -0
- package/src/server/lightspeed.js +82 -0
- package/src/server/linear.js +52 -0
- package/src/server/linkedin.js +74 -0
- package/src/server/mailgun.js +76 -0
- package/src/server/mapbox.js +46 -0
- package/src/server/mcpRegistration.js +41 -0
- package/src/server/mcpTransport.js +60 -0
- package/src/server/microsoft-excel.js +67 -0
- package/src/server/microsoft-fabric.js +81 -0
- package/src/server/microsoft-onedrive.js +58 -0
- package/src/server/microsoft-onenote.js +52 -0
- package/src/server/microsoft-outlook.js +58 -0
- package/src/server/microsoft-powerpoint.js +5 -0
- package/src/server/microsoft-sharepoint.js +46 -0
- package/src/server/microsoft-teams.js +29 -0
- package/src/server/microsoft-word.js +5 -0
- package/src/server/microsoft.js +88 -0
- package/src/server/miro.js +29 -0
- package/src/server/n8n.js +78 -0
- package/src/server/notion.js +76 -0
- package/src/server/oura.js +48 -0
- package/src/server/paddle.js +83 -0
- package/src/server/perplexity.js +15 -0
- package/src/server/pipedrive.js +95 -0
- package/src/server/polar.js +20 -0
- package/src/server/posthog.js +44 -0
- package/src/server/prestashop.js +42 -0
- package/src/server/replicate.js +42 -0
- package/src/server/resend.js +93 -0
- package/src/server/salesforce.js +84 -0
- package/src/server/sanity.js +31 -0
- package/src/server/semrush.js +359 -0
- package/src/server/sentry.js +30 -0
- package/src/server/sevdesk.js +20 -0
- package/src/server/shopify.js +99 -0
- package/src/server/slack.js +143 -0
- package/src/server/snowflake.js +139 -0
- package/src/server/storyblok.js +49 -0
- package/src/server/stripe.js +13 -0
- package/src/server/supabase.js +13 -0
- package/src/server/tally.js +78 -0
- package/src/server/telegram.js +55 -0
- package/src/server/tiktok.js +86 -0
- package/src/server/twilio.js +90 -0
- package/src/server/twitch.js +409 -0
- package/src/server/wave.js +520 -0
- package/src/server/wix.js +1048 -0
- package/src/server/wiz.js +117 -0
- package/src/server/woocommerce.js +412 -0
- package/src/server/wordpress-com.js +236 -0
- package/src/server/wordpress-self-hosted.js +134 -0
- package/src/server/workday.js +136 -0
- package/src/server/x-twitter.js +110 -0
- package/src/server/xero.js +397 -0
- package/src/server/zoho-books.js +90 -0
- package/src/server/zoho-crm.js +89 -0
- package/src/server/zohoOAuth.js +21 -0
- package/src/shared/ai-models.json +8049 -0
- package/src/shared/ai.js +63 -0
- package/src/shared/algolia.js +29 -0
- package/src/shared/amazon-redshift.js +51 -0
- package/src/shared/amplitude.js +28 -0
- package/src/shared/atlassian.js +43 -0
- package/src/shared/aws.js +72 -0
- package/src/shared/canva.js +66 -0
- package/src/shared/chargebee.js +26 -0
- package/src/shared/clickhouse.js +38 -0
- package/src/shared/confidence.js +28 -0
- package/src/shared/databricks.js +33 -0
- package/src/shared/dbt-semantic-layer.js +32 -0
- package/src/shared/definitions.js +140 -0
- package/src/shared/figma.js +21 -0
- package/src/shared/firebase-cloud-messaging.js +45 -0
- package/src/shared/gemini-enterprise.js +33 -0
- package/src/shared/gong.js +33 -0
- package/src/shared/google-ads-search.js +31 -0
- package/src/shared/google-ads.js +28 -0
- package/src/shared/google-maps-platform.js +28 -0
- package/src/shared/google.js +133 -0
- package/src/shared/granola.js +25 -0
- package/src/shared/hex.js +28 -0
- package/src/shared/inngest.js +35 -0
- package/src/shared/lightspeed.js +75 -0
- package/src/shared/linkedin.js +27 -0
- package/src/shared/logo-dev.js +21 -0
- package/src/shared/mapbox.js +38 -0
- package/src/shared/mcp.js +92 -0
- package/src/shared/microsoft-fabric.js +47 -0
- package/src/shared/microsoft.js +231 -0
- package/src/shared/miro.js +27 -0
- package/src/shared/oura.js +23 -0
- package/src/shared/posthog.js +33 -0
- package/src/shared/prestashop.js +23 -0
- package/src/shared/salesforce.js +36 -0
- package/src/shared/semrush.js +24 -0
- package/src/shared/shopify.js +51 -0
- package/src/shared/siteUrl.js +12 -0
- package/src/shared/slack.js +88 -0
- package/src/shared/snowflake.js +55 -0
- package/src/shared/tiktok.js +22 -0
- package/src/shared/tokens.js +747 -0
- package/src/shared/twilio.js +41 -0
- package/src/shared/twitch.js +49 -0
- package/src/shared/wave.js +52 -0
- package/src/shared/wix.js +40 -0
- package/src/shared/wiz.js +41 -0
- package/src/shared/wordpress-com.js +30 -0
- package/src/shared/wordpress.js +69 -0
- package/src/shared/workday.js +50 -0
- package/src/shared/x-twitter.js +19 -0
- package/src/shared/xero.js +46 -0
- package/src/shared/zoho-books.js +39 -0
- package/src/shared/zoho-crm.js +43 -0
- package/src/shared/zoho-regions.js +12 -0
- package/test/ai.test.js +136 -0
- package/test/airtable.test.js +125 -0
- package/test/algolia.test.js +207 -0
- package/test/amazon-redshift.test.js +272 -0
- package/test/amplitude.test.js +239 -0
- package/test/apify.test.js +93 -0
- package/test/apollo-io.test.js +125 -0
- package/test/asana.test.js +99 -0
- package/test/ashby.test.js +97 -0
- package/test/atlassian.test.js +249 -0
- package/test/attention.test.js +79 -0
- package/test/aws.test.js +405 -0
- package/test/brevo.test.js +71 -0
- package/test/calendly.test.js +136 -0
- package/test/canva.test.js +258 -0
- package/test/chargebee.test.js +195 -0
- package/test/clickhouse.test.js +278 -0
- package/test/confidence.test.js +205 -0
- package/test/databricks.test.js +253 -0
- package/test/dbt-semantic-layer.test.js +221 -0
- package/test/designMcp.test.js +336 -0
- package/test/elevenlabs.test.js +52 -0
- package/test/firebase-cloud-messaging.test.js +242 -0
- package/test/firecrawl.test.js +66 -0
- package/test/gemini-enterprise.test.js +218 -0
- package/test/github-api.test.js +167 -0
- package/test/gitlab-api.test.js +160 -0
- package/test/gong.test.js +192 -0
- package/test/google-ads.test.js +350 -0
- package/test/google-maps-platform.test.js +191 -0
- package/test/google.test.js +710 -0
- package/test/granola.test.js +240 -0
- package/test/hex.test.js +253 -0
- package/test/heygen.test.js +121 -0
- package/test/hubspot.test.js +103 -0
- package/test/inngest.test.js +219 -0
- package/test/lightspeed.test.js +250 -0
- package/test/linear.test.js +146 -0
- package/test/linkedin.test.js +243 -0
- package/test/logo-dev.test.js +79 -0
- package/test/mailgun.test.js +107 -0
- package/test/mapbox.test.js +181 -0
- package/test/mcp.test.js +227 -0
- package/test/microsoft-fabric.test.js +258 -0
- package/test/microsoft.test.js +554 -0
- package/test/n8nOAuth.test.js +188 -0
- package/test/notionOAuth.test.js +183 -0
- package/test/oura.test.js +211 -0
- package/test/paddleCatalogue.test.js +51 -0
- package/test/paddlePermissions.test.js +101 -0
- package/test/perplexityComposition.test.js +39 -0
- package/test/pipedriveOAuth.test.js +140 -0
- package/test/posthog.test.js +200 -0
- package/test/prestashop.test.js +207 -0
- package/test/resend.test.js +163 -0
- package/test/salesforce.test.js +276 -0
- package/test/semrush.test.js +479 -0
- package/test/sentry.test.js +35 -0
- package/test/shopify.test.js +247 -0
- package/test/slack.test.js +320 -0
- package/test/snowflake.test.js +391 -0
- package/test/tally.test.js +75 -0
- package/test/telegram.test.js +58 -0
- package/test/tiktok.test.js +243 -0
- package/test/tokens.test.js +1079 -0
- package/test/twilio.test.js +221 -0
- package/test/twitch.test.js +573 -0
- package/test/wave.test.js +678 -0
- package/test/wix.test.js +1099 -0
- package/test/wiz.test.js +185 -0
- package/test/wordpress-com.test.js +469 -0
- package/test/wordpress.test.js +660 -0
- package/test/workday.test.js +314 -0
- package/test/x-twitter.test.js +256 -0
- package/test/xero.test.js +700 -0
- package/test/zoho-books.test.js +253 -0
- package/test/zoho-crm.test.js +223 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { twilioDefinition } from "../shared/twilio.js";
|
|
2
|
+
import { jsonOperation } from "./jsonOperation.js";
|
|
3
|
+
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
4
|
+
import { createSchema } from "json-rest-schema";
|
|
5
|
+
import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
6
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const origins = {
|
|
9
|
+
us1: "https://api.twilio.com", ie1: "https://api.dublin.ie1.twilio.com", au1: "https://api.sydney.au1.twilio.com"
|
|
10
|
+
};
|
|
11
|
+
const phone = { type: "string", required: true, pattern: "^\\+[1-9][0-9]{1,14}$" };
|
|
12
|
+
const sid = prefix => ({ type: "string", required: true, pattern: `^${prefix}[0-9a-fA-F]{32}$` });
|
|
13
|
+
const callbackUrl = { type: "string", maxLength: 2048, validator: value => {
|
|
14
|
+
try { const url = new URL(value); return url.protocol === "https:" && !url.username && !url.password && !url.hash || "Use an HTTPS URL without credentials or a fragment."; }
|
|
15
|
+
catch { return "Use an absolute HTTPS URL."; }
|
|
16
|
+
} };
|
|
17
|
+
function resourceOperation(resource, method, fields, checkInput) {
|
|
18
|
+
const schema = createSchema(fields);
|
|
19
|
+
return {
|
|
20
|
+
scopes: [],
|
|
21
|
+
request(input, settings) {
|
|
22
|
+
const { resourceSid, ...values } = validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 });
|
|
23
|
+
checkInput?.(values, settings);
|
|
24
|
+
const url = `${origins[settings.region]}/2010-04-01/Accounts/${settings.accountSid}/${resource}${resourceSid ? `/${resourceSid}` : ""}.json`;
|
|
25
|
+
if (method === "GET") return { method, url };
|
|
26
|
+
return { method, url, headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(values).toString() };
|
|
27
|
+
},
|
|
28
|
+
validateResult: result => new RegExp(`^${resource === "Calls" ? "CA" : "SM"}[0-9a-fA-F]{32}$`, "u").test(result?.sid) &&
|
|
29
|
+
/^AC[0-9a-fA-F]{32}$/u.test(result?.account_sid) && typeof result.status === "string"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const twilioProvider = Object.freeze({
|
|
33
|
+
...twilioDefinition,
|
|
34
|
+
apiOrigins: (settings) => [origins[settings.region]],
|
|
35
|
+
apiKey: { headers: (secret, settings) => ({ Authorization: `Basic ${Buffer.from(`${settings.apiKeySid}:${secret}`).toString("base64")}` }) },
|
|
36
|
+
checkOperation: "calls.list",
|
|
37
|
+
operations: {
|
|
38
|
+
"messages.send": resourceOperation("Messages", "POST", {
|
|
39
|
+
To: phone, From: { ...phone, required: false }, MessagingServiceSid: { ...sid("MG"), required: false },
|
|
40
|
+
Body: { type: "string", required: true, noTrim: true, minLength: 1, maxLength: 1600 }, StatusCallback: callbackUrl
|
|
41
|
+
}, values => {
|
|
42
|
+
if (Boolean(values.From) === Boolean(values.MessagingServiceSid)) throw new ConnectorError("connector_input_invalid", "Choose one sender number or Messaging Service SID.", { statusCode: 422 });
|
|
43
|
+
}),
|
|
44
|
+
"messages.get": resourceOperation("Messages", "GET", { resourceSid: sid("SM") }),
|
|
45
|
+
"calls.create": resourceOperation("Calls", "POST", {
|
|
46
|
+
To: phone, From: phone, Url: callbackUrl,
|
|
47
|
+
Twiml: { type: "string", noTrim: true, minLength: 1, maxLength: 4000 },
|
|
48
|
+
StatusCallback: callbackUrl, Timeout: { type: "integer", min: 5, max: 600, defaultTo: 60 }
|
|
49
|
+
}, values => {
|
|
50
|
+
if (Boolean(values.Url) === Boolean(values.Twiml)) throw new ConnectorError("connector_input_invalid", "Supply either a call-control URL or inline TwiML.", { statusCode: 422 });
|
|
51
|
+
}),
|
|
52
|
+
"calls.get": resourceOperation("Calls", "GET", { resourceSid: sid("CA") }),
|
|
53
|
+
"calls.list": jsonOperation((settings) => `${origins[settings.region]}/2010-04-01/Accounts/${settings.accountSid}/Calls.json`, {
|
|
54
|
+
PageSize: { type: "integer", min: 1, max: 1000, defaultTo: 50 },
|
|
55
|
+
Page: { type: "integer", min: 0, max: 2147483647, defaultTo: 0 },
|
|
56
|
+
PageToken: { type: "string", minLength: 1, maxLength: 4096 },
|
|
57
|
+
To: { type: "string", minLength: 1, maxLength: 256 },
|
|
58
|
+
From: { type: "string", minLength: 1, maxLength: 256 },
|
|
59
|
+
Status: { type: "string", enum: ["queued", "ringing", "in-progress", "canceled", "completed", "failed", "busy", "no-answer"] }
|
|
60
|
+
}, (result) => Array.isArray(result?.calls) && Number.isInteger(result.page) && result.page >= 0 &&
|
|
61
|
+
Number.isInteger(result.page_size) && result.page_size > 0 &&
|
|
62
|
+
(result.next_page_uri === null || typeof result.next_page_uri === "string"))
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// POST form callbacks only. The caller supplies the exact public URL configured
|
|
66
|
+
// at Twilio, never a URL reconstructed from untrusted forwarded headers.
|
|
67
|
+
function verifyTwilioRequest({ rawBody, signature, authToken, url, accountSid, contentType }) {
|
|
68
|
+
const reject = () => { throw new ConnectorError("connector_webhook_invalid", "Invalid Twilio callback.", { statusCode: 401 }); };
|
|
69
|
+
if (!(rawBody instanceof Uint8Array) || rawBody.byteLength > 1048576 ||
|
|
70
|
+
typeof signature !== "string" || !/^[A-Za-z0-9+/]{27}=$/u.test(signature) ||
|
|
71
|
+
typeof authToken !== "string" || !authToken ||
|
|
72
|
+
typeof accountSid !== "string" || !/^AC[0-9a-fA-F]{32}$/u.test(accountSid) ||
|
|
73
|
+
typeof url !== "string" || url.length > 4096 ||
|
|
74
|
+
typeof contentType !== "string" || contentType.split(";", 1)[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") reject();
|
|
75
|
+
let form;
|
|
76
|
+
try {
|
|
77
|
+
const parsedUrl = new URL(url);
|
|
78
|
+
if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password || parsedUrl.hash) reject();
|
|
79
|
+
form = new URLSearchParams(new TextDecoder("utf-8", { fatal: true }).decode(rawBody));
|
|
80
|
+
} catch { reject(); }
|
|
81
|
+
const names = [...form.keys()];
|
|
82
|
+
// Repeated fields are ambiguous to framework parsers; never silently choose one.
|
|
83
|
+
if (names.length > 256 || new Set(names).size !== names.length) reject();
|
|
84
|
+
const signed = url + names.sort().map(name => name + form.get(name)).join("");
|
|
85
|
+
const expected = createHmac("sha1", authToken).update(signed).digest();
|
|
86
|
+
if (!timingSafeEqual(expected, Buffer.from(signature, "base64"))) reject();
|
|
87
|
+
if (form.get("AccountSid") !== accountSid) reject();
|
|
88
|
+
return Object.fromEntries(form);
|
|
89
|
+
}
|
|
90
|
+
export { twilioProvider, verifyTwilioRequest };
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
2
|
+
import { twitchDefinition } from "../shared/twitch.js";
|
|
3
|
+
import { jsonOperation } from "./jsonOperation.js";
|
|
4
|
+
import { createSchema } from "json-rest-schema";
|
|
5
|
+
|
|
6
|
+
const validationUrl = "https://id.twitch.tv/oauth2/validate";
|
|
7
|
+
const userId = (value) => typeof value === "string" && /^[1-9]\d*$/u.test(value);
|
|
8
|
+
const scopeList = (value) => Array.isArray(value) && value.every((scope) => typeof scope === "string" && /^[^\s]+$/u.test(scope));
|
|
9
|
+
const validToken = (value) => typeof value?.client_id === "string" && value.client_id.length > 0 &&
|
|
10
|
+
userId(value.user_id) && typeof value.login === "string" && value.login.length > 0 &&
|
|
11
|
+
scopeList(value.scopes) && Number.isSafeInteger(value.expires_in) && value.expires_in > 0;
|
|
12
|
+
const idField = { type: "string", validator: value => userId(value) || "Enter a numeric Twitch ID." };
|
|
13
|
+
const pageFields = { first: { type: "integer", min: 1, max: 100, defaultTo: 20 }, after: { type: "string", minLength: 1, maxLength: 4096 } };
|
|
14
|
+
const opaqueId = { type: "string", required: true, minLength: 1, maxLength: 100 };
|
|
15
|
+
const eventTypes = {
|
|
16
|
+
"stream.online": ["1", []], "stream.offline": ["1", []], "channel.update": ["2", []],
|
|
17
|
+
"channel.subscribe": ["1", ["channel:read:subscriptions"]],
|
|
18
|
+
"channel.cheer": ["1", ["bits:read"]],
|
|
19
|
+
...Object.fromEntries(["begin", "progress", "end"].map(phase => [`channel.poll.${phase}`, ["1", ["channel:read:polls", "channel:manage:polls"]]])),
|
|
20
|
+
...Object.fromEntries(["begin", "progress", "lock", "end"].map(phase => [`channel.prediction.${phase}`, ["1", ["channel:read:predictions", "channel:manage:predictions"]]])),
|
|
21
|
+
...Object.fromEntries(["add", "update"].map(phase => [`channel.channel_points_custom_reward_redemption.${phase}`, ["1", ["channel:read:redemptions", "channel:manage:redemptions"]]]))
|
|
22
|
+
};
|
|
23
|
+
const eventSubscriptions = value => Array.isArray(value?.data) && value.data.every(item => typeof item?.id === "string" &&
|
|
24
|
+
typeof item.type === "string" && typeof item.status === "string" && item.transport?.method === "websocket");
|
|
25
|
+
const choiceList = max => ({ type: "array", required: true,
|
|
26
|
+
items: { type: "object", schema: createSchema({ title: { type: "string", required: true, minLength: 1, maxLength: 25 } }) },
|
|
27
|
+
validator: values => values.length >= 2 && values.length <= max || `Use between two and ${max} choices.` });
|
|
28
|
+
const interactionResult = value => Array.isArray(value?.data) && value.data.every(item =>
|
|
29
|
+
typeof item?.id === "string" && userId(item.broadcaster_id) && typeof item.status === "string");
|
|
30
|
+
function helixOperation(path, fields, scopes, validateResult, method = "GET", checkInput) {
|
|
31
|
+
const operation = jsonOperation(`https://api.twitch.tv/helix/${path}`, fields, validateResult, method === "GET" ? "GET" : "POST");
|
|
32
|
+
return { ...operation, scopes, request(input) {
|
|
33
|
+
const request = operation.request(input);
|
|
34
|
+
checkInput?.(request.body);
|
|
35
|
+
return { ...request, method };
|
|
36
|
+
} };
|
|
37
|
+
}
|
|
38
|
+
const page = (value, record) => Array.isArray(value?.data) && value.data.every(record) &&
|
|
39
|
+
value.pagination && typeof value.pagination === "object" && !Array.isArray(value.pagination) &&
|
|
40
|
+
(value.pagination.cursor === undefined || typeof value.pagination.cursor === "string");
|
|
41
|
+
function analyticsOperation(kind, identity) {
|
|
42
|
+
const date = { type: "string", validator: value => /^\d{4}-\d{2}-\d{2}T00:00:00Z$/u.test(value) &&
|
|
43
|
+
Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value.replace("Z", ".000Z") || "Enter a real UTC date at midnight, YYYY-MM-DDT00:00:00Z." };
|
|
44
|
+
const operation = helixOperation(`analytics/${kind}`, {
|
|
45
|
+
...pageFields, [identity]: { ...opaqueId, required: false },
|
|
46
|
+
type: { type: "string", enum: ["overview_v2"], defaultTo: "overview_v2" },
|
|
47
|
+
started_at: date, ended_at: date
|
|
48
|
+
}, [`analytics:read:${kind}`], value => page(value, item => {
|
|
49
|
+
if (typeof item?.[identity] !== "string" || !item[identity] || item.type !== "overview_v2" ||
|
|
50
|
+
typeof item.date_range?.started_at !== "string" || typeof item.date_range?.ended_at !== "string") return false;
|
|
51
|
+
try { const url = new URL(item.URL); return url.protocol === "https:" && !url.username && !url.password; } catch { return false; }
|
|
52
|
+
}));
|
|
53
|
+
return { ...operation, request(input) {
|
|
54
|
+
const request = operation.request(input);
|
|
55
|
+
const query = new URL(request.url).searchParams;
|
|
56
|
+
const start = query.get("started_at"), end = query.get("ended_at");
|
|
57
|
+
if (Boolean(start) !== Boolean(end) || (start && start > end)) {
|
|
58
|
+
throw new ConnectorError("connector_input_invalid", "Supply both report dates, with the end on or after the start.", { statusCode: 422 });
|
|
59
|
+
}
|
|
60
|
+
return request;
|
|
61
|
+
} };
|
|
62
|
+
}
|
|
63
|
+
const broadcastUpdate = jsonOperation("https://api.twitch.tv/helix/channels", {
|
|
64
|
+
title: { type: "string", noTrim: true, minLength: 1, maxLength: 140 },
|
|
65
|
+
game_id: { type: "string", pattern: "^[0-9]*$", maxLength: 30 },
|
|
66
|
+
broadcaster_language: { type: "string", pattern: "^([a-z]{2}|other)$" },
|
|
67
|
+
tags: { type: "array", items: { type: "string", minLength: 1, maxLength: 25, validator: value => /^[\p{L}\p{N}]+$/u.test(value) || "Use letters and numbers in tags." }, validator: values => values.length <= 10 || "Use at most ten tags." },
|
|
68
|
+
is_branded_content: { type: "boolean" }
|
|
69
|
+
}, value => value === null, "POST");
|
|
70
|
+
const clipCreation = jsonOperation("https://api.twitch.tv/helix/clips", {
|
|
71
|
+
broadcaster_id: { ...idField, required: true }
|
|
72
|
+
}, value => Array.isArray(value?.data) && value.data.length === 1 && typeof value.data[0]?.id === "string" &&
|
|
73
|
+
value.data[0].id.length > 0 && typeof value.data[0].edit_url === "string" &&
|
|
74
|
+
/^https:\/\/(?:www\.twitch\.tv|clips\.twitch\.tv)\//u.test(value.data[0].edit_url));
|
|
75
|
+
const rewardFields = {
|
|
76
|
+
title: { type: "string", minLength: 1, maxLength: 45 }, cost: { type: "integer", min: 1, max: Number.MAX_SAFE_INTEGER },
|
|
77
|
+
prompt: { type: "string", maxLength: 200 }, is_enabled: { type: "boolean" },
|
|
78
|
+
is_user_input_required: { type: "boolean" }, background_color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
|
|
79
|
+
is_max_per_stream_enabled: { type: "boolean" }, max_per_stream: { type: "integer", min: 1, max: Number.MAX_SAFE_INTEGER },
|
|
80
|
+
is_max_per_user_per_stream_enabled: { type: "boolean" }, max_per_user_per_stream: { type: "integer", min: 1, max: Number.MAX_SAFE_INTEGER },
|
|
81
|
+
is_global_cooldown_enabled: { type: "boolean" }, global_cooldown_seconds: { type: "integer", min: 1, max: 604800 },
|
|
82
|
+
should_redemptions_skip_request_queue: { type: "boolean" }
|
|
83
|
+
};
|
|
84
|
+
const rewardResult = value => Array.isArray(value?.data) && value.data.every(item =>
|
|
85
|
+
typeof item?.id === "string" && userId(item.broadcaster_id) && typeof item.title === "string" && Number.isSafeInteger(item.cost) && item.cost > 0);
|
|
86
|
+
const scheduleTime = { type: "string", maxLength: 40, validator: value =>
|
|
87
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u.test(value) && Number.isFinite(Date.parse(value)) &&
|
|
88
|
+
new Date(value).toISOString().slice(0, 19) === value.slice(0, 19) || "Enter a real UTC RFC3339 timestamp." };
|
|
89
|
+
const scheduleFields = {
|
|
90
|
+
start_time: scheduleTime, duration: { type: "integer", min: 30, max: 1380 },
|
|
91
|
+
timezone: { type: "string", maxLength: 100, validator: value => {
|
|
92
|
+
try { new Intl.DateTimeFormat("en", { timeZone: value }); return true; } catch { return "Enter an IANA timezone such as Australia/Perth."; }
|
|
93
|
+
} },
|
|
94
|
+
title: { type: "string", maxLength: 140 }, category_id: idField
|
|
95
|
+
};
|
|
96
|
+
const scheduleResult = value => userId(value?.data?.broadcaster_id) && Array.isArray(value.data.segments) &&
|
|
97
|
+
value.data.segments.every(item => typeof item?.id === "string" && typeof item.start_time === "string" && typeof item.end_time === "string");
|
|
98
|
+
const vacationSettings = jsonOperation("https://api.twitch.tv/helix/schedule/settings", {
|
|
99
|
+
is_vacation_enabled: { type: "boolean", required: true }, vacation_start_time: scheduleTime,
|
|
100
|
+
vacation_end_time: scheduleTime, timezone: scheduleFields.timezone
|
|
101
|
+
}, value => value === null);
|
|
102
|
+
function scheduleWrite(fields, method) {
|
|
103
|
+
const operation = helixOperation("schedule/segment", fields, ["channel:manage:schedule"], method === "DELETE" ? value => value === null : scheduleResult, method);
|
|
104
|
+
return { ...operation, request(input) {
|
|
105
|
+
const request = operation.request(input);
|
|
106
|
+
const url = new URL(request.url);
|
|
107
|
+
if (request.body.id) { url.searchParams.set("id", request.body.id); delete request.body.id; }
|
|
108
|
+
if (method === "PATCH" && !Object.keys(request.body).length) throw new ConnectorError("connector_input_invalid", "Supply at least one schedule change.", { statusCode: 422 });
|
|
109
|
+
if (request.body.duration !== undefined) request.body.duration = String(request.body.duration);
|
|
110
|
+
return { ...request, url: url.href, body: method === "DELETE" ? undefined : request.body };
|
|
111
|
+
} };
|
|
112
|
+
}
|
|
113
|
+
function rewardWrite(path, fields, method, queryNames, validateResult) {
|
|
114
|
+
const operation = helixOperation(`channel_points/custom_rewards${path}`, fields, ["channel:manage:redemptions"], validateResult, method);
|
|
115
|
+
return { ...operation, request(input) {
|
|
116
|
+
const request = operation.request(input);
|
|
117
|
+
const url = new URL(request.url);
|
|
118
|
+
for (const [enabled, amount] of [["is_max_per_stream_enabled", "max_per_stream"],
|
|
119
|
+
["is_max_per_user_per_stream_enabled", "max_per_user_per_stream"], ["is_global_cooldown_enabled", "global_cooldown_seconds"]]) {
|
|
120
|
+
if (request.body[enabled] === true && request.body[amount] === undefined) {
|
|
121
|
+
throw new ConnectorError("connector_input_invalid", `Supply ${amount} when enabling this reward limit.`, { statusCode: 422 });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const name of queryNames) { url.searchParams.set(name, request.body[name]); delete request.body[name]; }
|
|
125
|
+
if (method === "PATCH" && Object.keys(request.body).length === 0) throw new ConnectorError("connector_input_invalid", "Supply at least one reward change.", { statusCode: 422 });
|
|
126
|
+
return { ...request, url: url.href, body: method === "DELETE" ? undefined : request.body };
|
|
127
|
+
} };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function normalizeTokenResponse(response) {
|
|
131
|
+
const failure = {
|
|
132
|
+
401: ["connector_reconnect_required", "Connect this Twitch account again."],
|
|
133
|
+
403: ["connector_permission_denied", "Twitch denied the token request."],
|
|
134
|
+
429: ["connector_rate_limited", "Twitch's request limit was reached."]
|
|
135
|
+
}[response.status];
|
|
136
|
+
if (failure) throw new ConnectorError(...failure, { statusCode: response.status });
|
|
137
|
+
let value;
|
|
138
|
+
try { value = await response.clone().json(); } catch {
|
|
139
|
+
if (!response.ok) return response;
|
|
140
|
+
throw new ConnectorError("connector_response_invalid", "Twitch returned an invalid token response.", { statusCode: 502 });
|
|
141
|
+
}
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
if (response.status === 400 && value?.message === "Invalid refresh token") {
|
|
144
|
+
throw new ConnectorError("connector_reconnect_required", "Connect this Twitch account again.", { statusCode: 401 });
|
|
145
|
+
}
|
|
146
|
+
return response;
|
|
147
|
+
}
|
|
148
|
+
if (typeof value?.scope === "string" && (value.scope === "" || /^[^\s]+(?: [^\s]+)*$/u.test(value.scope))) return response;
|
|
149
|
+
if (!scopeList(value?.scope)) {
|
|
150
|
+
throw new ConnectorError("connector_response_invalid", "Twitch returned an invalid permission grant.", { statusCode: 502 });
|
|
151
|
+
}
|
|
152
|
+
return Response.json({ ...value, scope: value.scope.join(" ") }, { status: response.status });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const twitchProvider = Object.freeze({
|
|
156
|
+
...twitchDefinition,
|
|
157
|
+
oauth: {
|
|
158
|
+
issuer: "https://id.twitch.tv/oauth2",
|
|
159
|
+
authorization_endpoint: "https://id.twitch.tv/oauth2/authorize",
|
|
160
|
+
token_endpoint: "https://id.twitch.tv/oauth2/token"
|
|
161
|
+
},
|
|
162
|
+
normalizeTokenResponse,
|
|
163
|
+
oauthHeaders: ({ clientId }) => ({ "Client-Id": clientId }),
|
|
164
|
+
apiOrigins: ["https://id.twitch.tv", "https://api.twitch.tv"],
|
|
165
|
+
checkOperation: "token.validate",
|
|
166
|
+
grantedScopesFromVerification: (value) => value.scopes,
|
|
167
|
+
async exchange(address, options, { request }) {
|
|
168
|
+
const url = new URL(address);
|
|
169
|
+
const permissions = {
|
|
170
|
+
"GET /helix/users": "user:read:email", "GET /helix/channels/followed": "user:read:follows",
|
|
171
|
+
"GET /helix/streams": null, "GET /helix/channels": null, "GET /helix/search/channels": null,
|
|
172
|
+
"PATCH /helix/channels": "channel:manage:broadcast",
|
|
173
|
+
"GET /helix/polls": ["channel:read:polls", "channel:manage:polls"],
|
|
174
|
+
"POST /helix/polls": "channel:manage:polls", "PATCH /helix/polls": "channel:manage:polls",
|
|
175
|
+
"GET /helix/predictions": ["channel:read:predictions", "channel:manage:predictions"],
|
|
176
|
+
"POST /helix/predictions": "channel:manage:predictions", "PATCH /helix/predictions": "channel:manage:predictions",
|
|
177
|
+
"POST /helix/chat/messages": "user:write:chat",
|
|
178
|
+
"GET /helix/subscriptions": "channel:read:subscriptions",
|
|
179
|
+
"GET /helix/subscriptions/user": "user:read:subscriptions",
|
|
180
|
+
"GET /helix/channels/vips": "channel:read:vips", "GET /helix/channels/editors": "channel:read:editors",
|
|
181
|
+
"GET /helix/moderation/banned": "moderation:read", "GET /helix/moderation/moderators": "moderation:read",
|
|
182
|
+
"GET /helix/channels/followers": "moderator:read:followers", "GET /helix/chat/chatters": "moderator:read:chatters",
|
|
183
|
+
"GET /helix/hypetrain/status": "channel:read:hype_train",
|
|
184
|
+
"GET /helix/bits/leaderboard": "bits:read",
|
|
185
|
+
"GET /helix/analytics/extensions": "analytics:read:extensions",
|
|
186
|
+
"GET /helix/analytics/games": "analytics:read:games",
|
|
187
|
+
"POST /helix/clips": "clips:edit", "GET /helix/clips": null,
|
|
188
|
+
"GET /helix/channel_points/custom_rewards": ["channel:read:redemptions", "channel:manage:redemptions"],
|
|
189
|
+
"POST /helix/channel_points/custom_rewards": "channel:manage:redemptions",
|
|
190
|
+
"PATCH /helix/channel_points/custom_rewards": "channel:manage:redemptions",
|
|
191
|
+
"DELETE /helix/channel_points/custom_rewards": "channel:manage:redemptions",
|
|
192
|
+
"GET /helix/channel_points/custom_rewards/redemptions": ["channel:read:redemptions", "channel:manage:redemptions"],
|
|
193
|
+
"PATCH /helix/channel_points/custom_rewards/redemptions": "channel:manage:redemptions",
|
|
194
|
+
"GET /helix/schedule": null, "POST /helix/schedule/segment": "channel:manage:schedule",
|
|
195
|
+
"PATCH /helix/schedule/segment": "channel:manage:schedule", "DELETE /helix/schedule/segment": "channel:manage:schedule",
|
|
196
|
+
"PATCH /helix/schedule/settings": "channel:manage:schedule",
|
|
197
|
+
"POST /helix/eventsub/subscriptions": null, "GET /helix/eventsub/subscriptions": null,
|
|
198
|
+
"DELETE /helix/eventsub/subscriptions": null
|
|
199
|
+
};
|
|
200
|
+
const operation = `${options.method} ${url.pathname}`;
|
|
201
|
+
if (!(url.href === validationUrl && options.method === "GET") &&
|
|
202
|
+
(url.origin !== "https://api.twitch.tv" || !Object.hasOwn(permissions, operation))) {
|
|
203
|
+
throw new ConnectorError("connector_destination_invalid", "This Twitch operation has an invalid destination.");
|
|
204
|
+
}
|
|
205
|
+
const validation = await request(validationUrl, { ...options, method: "GET", body: undefined });
|
|
206
|
+
if (!validToken(validation)) {
|
|
207
|
+
throw new ConnectorError("connector_response_invalid", "Twitch returned an invalid user-token validation.", { statusCode: 502 });
|
|
208
|
+
}
|
|
209
|
+
if (validation.client_id !== new Headers(options.headers).get("Client-Id")) {
|
|
210
|
+
throw new ConnectorError("connector_reconnect_required", "The Twitch token belongs to a different application.", { statusCode: 401 });
|
|
211
|
+
}
|
|
212
|
+
if (url.href === validationUrl) return validation;
|
|
213
|
+
const requiredScope = permissions[operation];
|
|
214
|
+
if (requiredScope && ![requiredScope].flat().some(scope => validation.scopes.includes(scope))) {
|
|
215
|
+
throw new ConnectorError("connector_reconnect_required", "Connect Twitch again with the permission needed for this operation.", { statusCode: 401 });
|
|
216
|
+
}
|
|
217
|
+
if (operation === "POST /helix/eventsub/subscriptions") {
|
|
218
|
+
const [version, scopes] = eventTypes[options.body.type];
|
|
219
|
+
if (scopes.length && !scopes.some(scope => validation.scopes.includes(scope))) {
|
|
220
|
+
throw new ConnectorError("connector_reconnect_required", "Connect Twitch again with permission for this event.", { statusCode: 401 });
|
|
221
|
+
}
|
|
222
|
+
options = { ...options, body: { type: options.body.type, version,
|
|
223
|
+
condition: { broadcaster_user_id: validation.user_id },
|
|
224
|
+
transport: { method: "websocket", session_id: options.body.session_id } } };
|
|
225
|
+
}
|
|
226
|
+
if (url.pathname === "/helix/channels/followed") url.searchParams.set("user_id", validation.user_id);
|
|
227
|
+
if (operation === "PATCH /helix/channels") url.searchParams.set("broadcaster_id", validation.user_id);
|
|
228
|
+
if (["/helix/polls", "/helix/predictions"].includes(url.pathname)) {
|
|
229
|
+
if (options.method === "GET") url.searchParams.set("broadcaster_id", validation.user_id);
|
|
230
|
+
else options = { ...options, body: { ...options.body, broadcaster_id: validation.user_id } };
|
|
231
|
+
}
|
|
232
|
+
if (operation === "POST /helix/chat/messages") options = { ...options, body: { ...options.body, sender_id: validation.user_id } };
|
|
233
|
+
if (url.pathname.startsWith("/helix/channel_points/custom_rewards")) url.searchParams.set("broadcaster_id", validation.user_id);
|
|
234
|
+
if (["/helix/schedule/segment", "/helix/schedule/settings"].includes(url.pathname)) url.searchParams.set("broadcaster_id", validation.user_id);
|
|
235
|
+
if (["/helix/subscriptions", "/helix/channels/vips", "/helix/channels/editors", "/helix/moderation/banned", "/helix/moderation/moderators", "/helix/hypetrain/status"].includes(url.pathname)) url.searchParams.set("broadcaster_id", validation.user_id);
|
|
236
|
+
if (url.pathname === "/helix/subscriptions/user") url.searchParams.set("user_id", validation.user_id);
|
|
237
|
+
if (url.pathname === "/helix/chat/chatters") url.searchParams.set("moderator_id", validation.user_id);
|
|
238
|
+
if (url.pathname === "/helix/channels/followers" && !url.searchParams.has("broadcaster_id")) url.searchParams.set("broadcaster_id", validation.user_id);
|
|
239
|
+
const result = await request(url.href, options);
|
|
240
|
+
if (url.pathname === "/helix/users" && result?.data?.[0]?.id !== validation.user_id) {
|
|
241
|
+
throw new ConnectorError("connector_response_invalid", "Twitch returned a profile for a different user.", { statusCode: 502 });
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
},
|
|
245
|
+
operations: {
|
|
246
|
+
"events.subscribe": helixOperation("eventsub/subscriptions", {
|
|
247
|
+
type: { type: "string", required: true, enum: Object.keys(eventTypes) }, session_id: opaqueId
|
|
248
|
+
}, [], eventSubscriptions, "POST"),
|
|
249
|
+
"events.list": helixOperation("eventsub/subscriptions", { after: pageFields.after,
|
|
250
|
+
type: { type: "string", enum: Object.keys(eventTypes) }
|
|
251
|
+
}, [], value => eventSubscriptions(value) && page(value, () => true)),
|
|
252
|
+
"events.delete": { ...helixOperation("eventsub/subscriptions", { id: opaqueId }, [], value => value === null), request(input) {
|
|
253
|
+
const operation = jsonOperation("https://api.twitch.tv/helix/eventsub/subscriptions", { id: opaqueId }, value => value === null);
|
|
254
|
+
return { ...operation.request(input), method: "DELETE" };
|
|
255
|
+
} },
|
|
256
|
+
"schedule.vacation": { ...vacationSettings, scopes: ["channel:manage:schedule"], request(input) {
|
|
257
|
+
const request = vacationSettings.request(input);
|
|
258
|
+
const query = new URL(request.url).searchParams;
|
|
259
|
+
if (query.get("is_vacation_enabled") === "true" && (!query.has("timezone") || !query.has("vacation_start_time") || !query.has("vacation_end_time") ||
|
|
260
|
+
Date.parse(query.get("vacation_end_time")) <= Date.parse(query.get("vacation_start_time")))) {
|
|
261
|
+
throw new ConnectorError("connector_input_invalid", "Enabling vacation requires a timezone and an end after the start.", { statusCode: 422 });
|
|
262
|
+
}
|
|
263
|
+
return { ...request, method: "PATCH" };
|
|
264
|
+
} },
|
|
265
|
+
"schedule.get": helixOperation("schedule", { broadcaster_id: { ...idField, required: true }, id: { ...opaqueId, required: false }, start_time: scheduleTime,
|
|
266
|
+
...pageFields, first: { type: "integer", min: 1, max: 25, defaultTo: 20 }
|
|
267
|
+
}, [], scheduleResult),
|
|
268
|
+
"schedule.create": scheduleWrite({ ...scheduleFields, start_time: { ...scheduleFields.start_time, required: true },
|
|
269
|
+
timezone: { ...scheduleFields.timezone, required: true }, duration: { ...scheduleFields.duration, required: true },
|
|
270
|
+
is_recurring: { type: "boolean", required: true }
|
|
271
|
+
}, "POST"),
|
|
272
|
+
"schedule.update": scheduleWrite({ id: opaqueId, ...scheduleFields, is_canceled: { type: "boolean" } }, "PATCH"),
|
|
273
|
+
"schedule.delete": scheduleWrite({ id: opaqueId }, "DELETE"),
|
|
274
|
+
"rewards.list": helixOperation("channel_points/custom_rewards", { id: { ...opaqueId, required: false }, only_manageable_rewards: { type: "boolean", defaultTo: false } }, ["channel:read:redemptions", "channel:manage:redemptions"], rewardResult),
|
|
275
|
+
"rewards.create": rewardWrite("", { ...rewardFields, title: { ...rewardFields.title, required: true }, cost: { ...rewardFields.cost, required: true } }, "POST", [], rewardResult),
|
|
276
|
+
"rewards.update": rewardWrite("", { id: opaqueId, ...rewardFields, is_paused: { type: "boolean" } }, "PATCH", ["id"], rewardResult),
|
|
277
|
+
"rewards.delete": rewardWrite("", { id: opaqueId }, "DELETE", ["id"], value => value === null),
|
|
278
|
+
"redemptions.list": helixOperation("channel_points/custom_rewards/redemptions", {
|
|
279
|
+
reward_id: opaqueId, id: { ...opaqueId, required: false },
|
|
280
|
+
status: { type: "string", enum: ["UNFULFILLED", "FULFILLED", "CANCELED"], defaultTo: "UNFULFILLED" },
|
|
281
|
+
sort: { type: "string", enum: ["OLDEST", "NEWEST"], defaultTo: "OLDEST" },
|
|
282
|
+
...pageFields, first: { type: "integer", min: 1, max: 50, defaultTo: 20 }
|
|
283
|
+
}, ["channel:read:redemptions", "channel:manage:redemptions"], value => page(value, item =>
|
|
284
|
+
typeof item?.id === "string" && userId(item.broadcaster_id) && userId(item.user_id) && ["UNFULFILLED", "FULFILLED", "CANCELED"].includes(item.status))),
|
|
285
|
+
"redemptions.update": rewardWrite("/redemptions", { reward_id: opaqueId, id: opaqueId,
|
|
286
|
+
status: { type: "string", required: true, enum: ["FULFILLED", "CANCELED"] }
|
|
287
|
+
}, "PATCH", ["reward_id", "id"], interactionResult),
|
|
288
|
+
"clips.create": { ...clipCreation, scopes: ["clips:edit"], request(input) {
|
|
289
|
+
return { ...clipCreation.request(input), method: "POST" };
|
|
290
|
+
} },
|
|
291
|
+
"clips.get": helixOperation("clips", { id: opaqueId }, [], value => Array.isArray(value?.data) && value.data.every(item =>
|
|
292
|
+
typeof item?.id === "string" && userId(item.broadcaster_id) && typeof item.url === "string" && typeof item.title === "string")),
|
|
293
|
+
"analytics.extensions": analyticsOperation("extensions", "extension_id"),
|
|
294
|
+
"analytics.games": analyticsOperation("games", "game_id"),
|
|
295
|
+
"bits.leaderboard": helixOperation("bits/leaderboard", {
|
|
296
|
+
count: { type: "integer", min: 1, max: 100, defaultTo: 10 },
|
|
297
|
+
period: { type: "string", enum: ["day", "week", "month", "year", "all"], defaultTo: "all" },
|
|
298
|
+
started_at: { type: "string", maxLength: 40, validator: value =>
|
|
299
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) && Number.isFinite(Date.parse(value)) || "Enter an RFC3339 timestamp." },
|
|
300
|
+
user_id: idField
|
|
301
|
+
}, ["bits:read"], value => Array.isArray(value?.data) && value.data.every(item =>
|
|
302
|
+
userId(item?.user_id) && Number.isSafeInteger(item.rank) && item.rank > 0 && Number.isSafeInteger(item.score) && item.score >= 0) &&
|
|
303
|
+
Number.isSafeInteger(value.total) && value.total >= 0 && typeof value.date_range?.started_at === "string" && typeof value.date_range?.ended_at === "string"),
|
|
304
|
+
"subscriptions.list": helixOperation("subscriptions", { ...pageFields, user_id: idField }, ["channel:read:subscriptions"], value => page(value, item => userId(item?.user_id) && typeof item.tier === "string")),
|
|
305
|
+
"subscriptions.check": helixOperation("subscriptions/user", { broadcaster_id: { ...idField, required: true } }, ["user:read:subscriptions"], value => Array.isArray(value?.data) && value.data.every(item => userId(item?.broadcaster_id) && typeof item.tier === "string")),
|
|
306
|
+
"vips.list": helixOperation("channels/vips", { ...pageFields, user_id: idField }, ["channel:read:vips"], value => page(value, item => userId(item?.user_id))),
|
|
307
|
+
"editors.list": helixOperation("channels/editors", {}, ["channel:read:editors"], value => Array.isArray(value?.data) && value.data.every(item => userId(item?.user_id))),
|
|
308
|
+
"bannedUsers.list": helixOperation("moderation/banned", { ...pageFields, user_id: idField }, ["moderation:read"], value => page(value, item => userId(item?.user_id))),
|
|
309
|
+
"moderators.list": helixOperation("moderation/moderators", { ...pageFields, user_id: idField }, ["moderation:read"], value => page(value, item => userId(item?.user_id))),
|
|
310
|
+
"followers.list": helixOperation("channels/followers", { ...pageFields, broadcaster_id: idField, user_id: idField }, ["moderator:read:followers"], value => page(value, item => userId(item?.user_id)) && Number.isSafeInteger(value.total) && value.total >= 0),
|
|
311
|
+
"chatters.list": helixOperation("chat/chatters", { ...pageFields, broadcaster_id: { ...idField, required: true } }, ["moderator:read:chatters"], value => page(value, item => userId(item?.user_id)) && Number.isSafeInteger(value.total) && value.total >= 0),
|
|
312
|
+
"hypeTrain.status": helixOperation("hypetrain/status", {}, ["channel:read:hype_train"], value => Array.isArray(value?.data) && value.data.length === 1 &&
|
|
313
|
+
(value.data[0]?.current === null || typeof value.data[0]?.current?.id === "string")),
|
|
314
|
+
"polls.list": helixOperation("polls", { ...pageFields, first: { type: "integer", min: 1, max: 20, defaultTo: 20 }, id: { ...opaqueId, required: false } }, ["channel:read:polls", "channel:manage:polls"], interactionResult),
|
|
315
|
+
"polls.create": helixOperation("polls", {
|
|
316
|
+
title: { type: "string", required: true, minLength: 1, maxLength: 60 }, choices: choiceList(5),
|
|
317
|
+
duration: { type: "integer", required: true, min: 15, max: 1800 }
|
|
318
|
+
}, ["channel:manage:polls"], interactionResult, "POST"),
|
|
319
|
+
"polls.end": helixOperation("polls", { id: opaqueId, status: { type: "string", required: true, enum: ["TERMINATED", "ARCHIVED"] } }, ["channel:manage:polls"], interactionResult, "PATCH"),
|
|
320
|
+
"predictions.list": helixOperation("predictions", { ...pageFields, first: { type: "integer", min: 1, max: 20, defaultTo: 20 }, id: { ...opaqueId, required: false } }, ["channel:read:predictions", "channel:manage:predictions"], interactionResult),
|
|
321
|
+
"predictions.create": helixOperation("predictions", {
|
|
322
|
+
title: { type: "string", required: true, minLength: 1, maxLength: 45 }, outcomes: choiceList(10),
|
|
323
|
+
prediction_window: { type: "integer", required: true, min: 30, max: 1800 }
|
|
324
|
+
}, ["channel:manage:predictions"], interactionResult, "POST"),
|
|
325
|
+
"predictions.end": helixOperation("predictions", { id: opaqueId,
|
|
326
|
+
status: { type: "string", required: true, enum: ["LOCKED", "CANCELED", "RESOLVED"] }, winning_outcome_id: { ...opaqueId, required: false }
|
|
327
|
+
}, ["channel:manage:predictions"], interactionResult, "PATCH", values => {
|
|
328
|
+
if ((values.status === "RESOLVED") !== Boolean(values.winning_outcome_id)) throw new ConnectorError("connector_input_invalid", "Supply a winning outcome only when resolving the prediction.", { statusCode: 422 });
|
|
329
|
+
}),
|
|
330
|
+
"chat.send": helixOperation("chat/messages", { broadcaster_id: { ...idField, required: true },
|
|
331
|
+
message: { type: "string", required: true, noTrim: true, minLength: 1, maxLength: 500 }, reply_parent_message_id: { ...opaqueId, required: false }
|
|
332
|
+
}, ["user:write:chat"], value => Array.isArray(value?.data) && value.data.length === 1 &&
|
|
333
|
+
typeof value.data[0]?.message_id === "string" && typeof value.data[0].is_sent === "boolean", "POST"),
|
|
334
|
+
"streams.list": jsonOperation("https://api.twitch.tv/helix/streams", {
|
|
335
|
+
...pageFields, user_id: idField, game_id: idField,
|
|
336
|
+
user_login: { type: "string", minLength: 1, maxLength: 25, pattern: "^[A-Za-z0-9_]+$" },
|
|
337
|
+
language: { type: "string", pattern: "^([a-z]{2}|other)$" }, type: { type: "string", enum: ["all", "live"], defaultTo: "all" }
|
|
338
|
+
}, value => page(value, stream => userId(stream?.id) && userId(stream.user_id) &&
|
|
339
|
+
typeof stream.title === "string" && Number.isSafeInteger(stream.viewer_count) && stream.viewer_count >= 0)),
|
|
340
|
+
"channels.read": jsonOperation("https://api.twitch.tv/helix/channels", { broadcaster_id: { ...idField, required: true } },
|
|
341
|
+
value => Array.isArray(value?.data) && value.data.every(channel => userId(channel?.broadcaster_id) && typeof channel.title === "string")),
|
|
342
|
+
"channels.search": jsonOperation("https://api.twitch.tv/helix/search/channels", {
|
|
343
|
+
...pageFields, query: { type: "string", required: true, minLength: 1, maxLength: 100 }, live_only: { type: "boolean", defaultTo: false }
|
|
344
|
+
}, value => page(value, channel => userId(channel?.id) && typeof channel.display_name === "string" && typeof channel.is_live === "boolean")),
|
|
345
|
+
"broadcast.update": {
|
|
346
|
+
scopes: ["channel:manage:broadcast"], validateResult: broadcastUpdate.validateResult,
|
|
347
|
+
request(input) {
|
|
348
|
+
const request = broadcastUpdate.request(input);
|
|
349
|
+
if (!Object.keys(request.body).length) throw new ConnectorError("connector_input_invalid", "Supply at least one broadcast change.", { statusCode: 422 });
|
|
350
|
+
return { ...request, method: "PATCH" };
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
"token.validate": jsonOperation(validationUrl, {}, validToken),
|
|
354
|
+
"profile.read": {
|
|
355
|
+
...jsonOperation("https://api.twitch.tv/helix/users", {}, (value) => Array.isArray(value?.data) &&
|
|
356
|
+
value.data.length === 1 && userId(value.data[0]?.id) && typeof value.data[0].login === "string"),
|
|
357
|
+
scopes: ["user:read:email"]
|
|
358
|
+
},
|
|
359
|
+
"channels.followed": {
|
|
360
|
+
...jsonOperation("https://api.twitch.tv/helix/channels/followed", {
|
|
361
|
+
first: { type: "integer", min: 1, max: 100, defaultTo: 20 },
|
|
362
|
+
after: { type: "string", minLength: 1, maxLength: 4096 },
|
|
363
|
+
broadcaster_id: { type: "string", validator: (value) => userId(value) || "Enter a Twitch broadcaster ID." }
|
|
364
|
+
}, (value) => Array.isArray(value?.data) && value.data.every((channel) => userId(channel?.broadcaster_id)) &&
|
|
365
|
+
Number.isSafeInteger(value.total) && value.total >= 0 && value.pagination !== null &&
|
|
366
|
+
typeof value.pagination === "object" && !Array.isArray(value.pagination) &&
|
|
367
|
+
(value.pagination.cursor === undefined || (typeof value.pagination.cursor === "string" && value.pagination.cursor.length > 0))),
|
|
368
|
+
scopes: ["user:read:follows"]
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
function parseTwitchEventSubMessage(raw, { broadcasterId, sessionId, subscriptionIds = [] } = {}) {
|
|
374
|
+
const invalid = () => new ConnectorError("connector_event_invalid", "Twitch returned an invalid or unexpected EventSub message.", { statusCode: 422 });
|
|
375
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw, "utf8") > 1048576 || !userId(broadcasterId)) throw invalid();
|
|
376
|
+
let message;
|
|
377
|
+
try { message = JSON.parse(raw); } catch { throw invalid(); }
|
|
378
|
+
const metadata = message?.metadata, payload = message?.payload;
|
|
379
|
+
if (!metadata || typeof metadata.message_id !== "string" || !metadata.message_id || metadata.message_id.length > 256 ||
|
|
380
|
+
typeof metadata.message_timestamp !== "string" || !Number.isFinite(Date.parse(metadata.message_timestamp)) ||
|
|
381
|
+
!payload || typeof payload !== "object" || Array.isArray(payload)) throw invalid();
|
|
382
|
+
const type = metadata.message_type;
|
|
383
|
+
if (type === "session_welcome" || type === "session_reconnect") {
|
|
384
|
+
const session = payload.session;
|
|
385
|
+
if (!session || typeof session.id !== "string" || !session.id || session.id.length > 256) throw invalid();
|
|
386
|
+
if (type === "session_welcome") {
|
|
387
|
+
if (session.status !== "connected" || !Number.isSafeInteger(session.keepalive_timeout_seconds) || session.keepalive_timeout_seconds < 1) throw invalid();
|
|
388
|
+
} else {
|
|
389
|
+
if (session.id !== sessionId || session.status !== "reconnecting") throw invalid();
|
|
390
|
+
let url;
|
|
391
|
+
try { url = new URL(session.reconnect_url); } catch { throw invalid(); }
|
|
392
|
+
if (url.protocol !== "wss:" || url.hostname !== "eventsub.wss.twitch.tv" || url.port || url.username || url.password || url.hash) throw invalid();
|
|
393
|
+
}
|
|
394
|
+
} else if (type === "notification" || type === "revocation") {
|
|
395
|
+
const subscription = payload.subscription;
|
|
396
|
+
if (!subscription || !Array.isArray(subscriptionIds) || !subscriptionIds.includes(subscription.id) ||
|
|
397
|
+
!sessionId || subscription.transport?.method !== "websocket" || subscription.transport.session_id !== sessionId ||
|
|
398
|
+
subscription.condition?.broadcaster_user_id !== broadcasterId || !Object.hasOwn(eventTypes, subscription.type) ||
|
|
399
|
+
subscription.version !== eventTypes[subscription.type][0] || metadata.subscription_type !== subscription.type ||
|
|
400
|
+
metadata.subscription_version !== subscription.version) throw invalid();
|
|
401
|
+
if (type === "notification") {
|
|
402
|
+
if (subscription.status !== "enabled" || !payload.event || typeof payload.event !== "object" || Array.isArray(payload.event) ||
|
|
403
|
+
payload.event.broadcaster_user_id !== broadcasterId) throw invalid();
|
|
404
|
+
} else if (!["authorization_revoked", "user_removed", "version_removed"].includes(subscription.status)) throw invalid();
|
|
405
|
+
} else if (type !== "session_keepalive") throw invalid();
|
|
406
|
+
return message;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export { twitchProvider, parseTwitchEventSubMessage };
|