diff --git a/README.md b/README.md index 4468a63..099fbcc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi - [Unity Cloud](https://build-api.cloud.unity3d.com/docs/1.0.0/index.html#operation-webhooks-intro) - `/unity` - [Uptime Robot](https://blog.uptimerobot.com/web-hook-alert-contacts-new-feature/) - `/uptimerobot` - [Zendesk](https://developer.zendesk.com/api-reference/webhooks/webhooks-api/webhooks/) - `/zendesk` + ## Contributing @@ -69,6 +70,12 @@ If you wish to contribute, follow our [contributing guide](CONTRIBUTING.md). If you want to create a new provider please follow the examples shown at our small [documentation](docs/CreateNewProvider.md). +To generate a registered provider, canonical fixture, and focused test in one step: + +```sh +npm run provider:new -- newprovider NewProvider "New Provider" https://docs.example.com/webhooks +``` + ## Testing Locally To build: diff --git a/biome.json b/biome.json index 585d1b4..456ef8c 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["src/**/*.ts", "test/**/*.ts"] + "includes": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.mjs"] }, "formatter": { "enabled": true, diff --git a/docs/CreateNewProvider.md b/docs/CreateNewProvider.md index 833cf7c..3724d25 100644 --- a/docs/CreateNewProvider.md +++ b/docs/CreateNewProvider.md @@ -1,159 +1,246 @@ # Creating a provider -A provider converts one third-party webhook request into a Discord payload. The HTTP routes and packaged examples execute providers through `ProviderRunner`, so every request gets a fresh provider instance, asynchronous parsing is awaited, and the result is checked by the report-only Discord validator. +A provider is a stateless definition that maps one normalized third-party webhook request into a Discord payload draft. Shared infrastructure owns request normalization, ignored-event handling, defaults, mention suppression, Discord limits, the Skyhook footer, and final validation. -## Provider state +Provider files should therefore contain only third-party semantics: validate the relevant envelope, interpret the event, and describe the notification. -A provider receives these protected values before parsing: +## Fastest path: generate the provider skeleton -- `this.body` — parsed JSON or form body. -- `this.headers` — request headers, or `null` in direct tests/examples without headers. -- `this.query` — query parameters, or `null` when absent. -- `this.payload` — the `DiscordPayload` being built. -- `this.logger` — the shared Skyhook logger. +Run the scaffold command with the public route, TypeScript export name, display name, and official HTTPS documentation URL: -Do not retain request data outside the provider instance. `ProviderRunner` constructs a fresh instance for every execution. +```sh +npm run provider:new -- newprovider NewProvider "New Provider" https://docs.example.com/webhooks +``` -## Direct providers +The command refuses to overwrite existing paths and creates or updates everything needed for a working baseline: -Use `DirectParseProvider` when every webhook uses one parsing path: +- `src/provider/NewProvider.ts` +- `examples/newprovider/newprovider.json` +- `test/newprovider/newprovider-spec.ts` +- the import and definition entry in `ProviderRegistry.ts` +- the supported-provider entry in `README.md` -```ts -import { DirectParseProvider } from './BaseProvider.ts' +Customize the generated mapper and canonical fixture, then run `npm test`. The repository-wide provider contract suite automatically checks the metadata, packaged example, mention policy, and finalized Discord payload for every registered provider. + +Provider modules should import only the public `./Provider.ts` facade. The implementation under `src/provider/core/` is internal infrastructure and can change without changing provider authoring code. -export class NewProvider extends DirectParseProvider { - public getName(): string { - return 'NewProvider' - } +## Direct providers - // Override only when the public URL path is not getName().toLowerCase(). - public getPath(): string { - return 'newprovider' - } +Use `defineProvider` when the request has one mapping path: - public async parseData(): Promise { - if (this.body == null || typeof this.body !== 'object') { - this.nullifyPayload() +```ts +import { scalarText } from '../util/WebhookValue.ts' +import { defineProvider } from './Provider.ts' + +export const NewProvider = defineProvider({ + path: 'newprovider', + name: 'New Provider', + example: { body: 'newprovider/newprovider.json' }, + defaults: { + username: 'New Provider', + avatarUrl: 'https://example.com/avatar.png', + embedColor: 0x123456, + }, + map({ body }, output) { + const summary = scalarText(body.summary) + if (summary == null) { + output.ignore() return } - this.setEmbedColor(0x123456) - this.addEmbed({ + output.addEmbed({ title: 'New webhook event', - description: String(this.body.summary ?? ''), + description: summary, }) - } -} + }, +}) ``` -`parseData()` may perform asynchronous work. It must return a `Promise`; do not start detached work after it returns. Bound every outbound request with an `AbortSignal` timeout. `addEmbed()` applies the shared Skyhook footer and configured color. +`map` may be synchronous or asynchronous. Do not start detached work that outlives the returned promise. -Call `this.nullifyPayload()` for malformed, ignored, or unsupported input. Parsing then returns `null`, later lifecycle hooks are skipped safely, and the live endpoint responds with the existing unsupported-event text and HTTP 200. +## Event providers -## Event-based providers +Use `defineEventProvider` when a body property or header selects one of several event mappings: -Use `TypeParseProvider` when a header or body property selects an event handler: +```ts +import { defineEventProvider } from './Provider.ts' + +export const NewProvider = defineEventProvider({ + path: 'newprovider', + name: 'New Provider', + example: { + body: 'newprovider/newprovider.json', + headers: 'newprovider/newprovider.headers.json', + }, + event: ({ headers }) => headers.get('x-new-provider-event'), + handlers: { + 'build.finished': ({ body }, output) => { + output.addEmbed({ + title: 'Build finished', + description: String(body.summary ?? ''), + }) + }, + deployment_failed: (_request, output) => { + output.addEmbed({ title: 'Deployment failed' }) + }, + }, +}) +``` + +Handler keys are the **exact raw third-party event names**. They are not camel-cased or converted, and there is no reflective method dispatch or separate event allowlist. A missing selector or unknown event returns `null` automatically. Use `fallback` only when the provider deliberately supports future event names generically. + +For a simple body selector, `event` may be the property name: ```ts -import { TypeParseProvider } from './BaseProvider.ts' +event: 'event_type' +``` -export class NewProvider extends TypeParseProvider { - public getName(): string { - return 'NewProvider' - } +## Request and output API - public getType(): string | null { - return this.headers?.['x-new-provider-event'] ?? null - } +Every mapper receives: - public knownTypes(): string[] { - return ['buildComplete', 'deploymentFailed'] - } +- `body` — a non-null, non-array JSON object. Invalid root values are ignored before mapping. +- `headers` — a fresh, case-insensitive `Headers` object. +- `query` — a fresh `URLSearchParams` object. +- `http` — the provider's policy-bound JSON HTTP capability. +- `output.payload` — the Discord payload draft. +- `output.addEmbed(embed)` — appends an embed and applies the current default color. +- `output.setEmbedColor(color)` — changes the default color for subsequently added embeds. +- `output.ignore()` — marks the webhook as ignored and causes execution to return `null`. +- `output.logger` — the shared logger for sanitized diagnostics. - public async buildComplete(): Promise { - this.addEmbed({ title: 'Build completed' }) - } +Definitions and metadata are immutable, while every execution receives a fresh `ProviderOutput`. Do not retain request or output values in module-level state. - public async deploymentFailed(): Promise { - this.addEmbed({ title: 'Deployment failed' }) - } -} -``` +Returning normally finalizes the draft. Throwing indicates a parsing failure and follows the endpoint's sanitized error path. Ignoring an unsupported event remains a successful HTTP 200 path. -`getType()` is normalized with `TypeParseProvider.formatType()`: colons, dots, underscores, hyphens, and spaces become a camel-cased handler name. For example, `build.complete` becomes `buildComplete`. +A mapper must either add message content, add at least one embed, or call `output.ignore()`. Returning an empty draft throws a provider implementation error instead of sending an unusable Discord request. -Every callable event handler must be explicitly listed in `knownTypes()`. This list is the dispatch allowlist; unlisted events and listed events without a matching function are ignored. Keep labels, URLs, colors, and third-party interpretation inside the provider rather than in shared infrastructure. +## Safe webhook values -## Discord text and limits +Use shared semantic helpers instead of adding provider-local coercion functions: -Use the semantic helpers instead of introducing provider-local copies: +- `src/util/WebhookValue.ts` + - `isRecord` + - `scalarText` and `firstScalar` + - `safeId` + - `safeIntegerText` + - `canonicalizeIso8601Timestamp` and `firstIso8601Timestamp` + - `trustedHttpsUrl` and `isAllowedHostname` +- `src/util/DiscordText.ts` + - text cleanup and bounded truncation + - literal Discord Markdown escaping + - identifier humanization +- `src/util/DiscordEmbed.ts` + - Discord limits + - bounded literal fields + - shared Skyhook footer constants -- `src/util/DiscordText.ts` — text cleanup, bounded truncation, literal Markdown escaping, and identifier humanization. -- `src/util/DiscordEmbed.ts` — Discord limits, the shared Skyhook footer, and bounded literal embed fields. -- `src/util/WebhookValue.ts` — record checks, scalar/identifier normalization, and strict ISO timestamp handling. +Do not stringify arbitrary objects into notifications. Distinguish untrusted literal text from Markdown intentionally constructed by the provider: escape the former, but preserve trusted provider-generated formatting such as links. -Current shared limits are: +Use `trustedHttpsUrl` for links derived from webhook input. Declare the exact trusted hostnames and opt into subdomains only when the third-party service requires them. -- Message content: 2,000 characters. -- Embeds per message: 10. -- Text across all embeds in one message: 6,000 characters. -- Embed title: 256; description: 4,096. -- Field count: 25; field name: 256; field value: 1,024. -- Author name: 256; footer text: 2,048. +## Outbound HTTP -The 6,000-character budget applies to the combined title, description, author name, footer text, and field names/values across every embed in the message—not separately to each embed. +Providers cannot make outbound requests unless their definition explicitly declares an HTTP policy: -`ProviderRunner` calls `validateDiscordPayload()` after a non-null parse and logs structured warnings. Validation is intentionally report-only: it does not mutate, truncate, split, reject, or repair legacy output. New providers should nevertheless produce clean validation results. Choose truncation, omission, splitting, or rejection deliberately for that provider rather than relying on the runner. +```ts +export const NewProvider = defineProvider({ + // ...metadata... + http: { + allowedHosts: ['api.example.com'], + timeoutMs: 5_000, + maxResponseBytes: 128_000, + }, + async map({ body, http }, output) { + const details = await http.getJson<{ title: string }>(String(body.details_url)) + output.addEmbed({ title: details.title }) + }, +}) +``` -Escape untrusted literal Markdown, but do not escape trusted Markdown that the provider intentionally constructs, such as its own links. Do not replace provider-specific HTML conversion unless tests prove the output is equivalent. +`http.getJson` centrally enforces: -## Registering the provider and example +- HTTPS +- exact hostname allowlists +- no URL credentials or non-default ports +- a maximum 10-second timeout +- a maximum 1 MB response size +- streaming cancellation as soon as the response exceeds its byte budget +- no redirects +- successful HTTP status +- JSON parsing -Provider registration is explicit; the runtime does not scan the provider directory. +Do not call global `fetch` from a provider. Catch enrichment errors only when a safe, useful fallback notification exists; otherwise allow the error to follow the normal sanitized failure path. -1. Add the provider source under `src/provider/`. -2. Add one canonical body under `examples//`. -3. Add canonical headers and query JSON files when the provider needs them. -4. Import the provider in `src/provider/ProviderRegistry.ts`. -5. Add one `ProviderDefinition` in the intended public order: +## Central Discord finalization + +Providers create drafts rather than manually enforcing every Discord constraint. Finalization applies: + +- default username, avatar, and embed color +- `allowed_mentions: { parse: [] }` for every provider payload +- the Skyhook footer +- 2,000-character message content +- at most 10 embeds +- at most 25 fields per embed +- title, description, field, and author component limits +- the aggregate 6,000-character budget across every embed in the message +- omission of invalid optional values + +The aggregate budget includes titles, descriptions, author names, footer text, field names, and field values across the complete message—not 6,000 characters per embed. + +Providers should still bound and select data semantically. Central truncation is a final safety boundary, not a substitute for deciding which third-party fields are useful. + +## Metadata, registration, and examples + +Provider metadata is declared once, on the definition: ```ts -{ +export const NewProvider = defineProvider({ path: 'newprovider', - name: 'NewProvider', - provider: NewProvider, + name: 'New Provider', example: { body: 'newprovider/newprovider.json', headers: 'newprovider/newprovider.headers.json', // optional query: 'newprovider/newprovider.query.json', // optional }, -}, + map(_request, output) { + output.addEmbed({ title: 'Example' }) + }, +}) ``` -The registry rejects duplicate public paths and metadata that disagrees with `getPath()` or `getName()`. `/api/providers`, live routing, and example lookup all derive from this registry. +The scaffold command performs registration automatically. To register a provider manually: + +1. Add the provider under `src/provider/`. +2. Add one canonical body under `examples//`. +3. Add canonical headers or query JSON when required. +4. Import the definition in `src/provider/ProviderRegistry.ts`. +5. Add the definition itself to `providerDefinitions` in the intended public order. +6. Add the provider to the supported-provider list in `README.md`. + +Do not duplicate the path, name, or example mapping in the registry. The registry rejects duplicate public paths, while routes, `/api/providers`, and example loading derive from definition metadata. Keep additional edge-case payloads under `test//`; only the canonical hosted example belongs under `examples/`. ## Tests -Add or update: +Use `Tester` with the definition directly: -- `test//-spec.ts` with exact assertions for titles, descriptions, fields, author, URL, footer, and color—not only embed counts. -- `test/provider/provider-registry-spec.ts` expected metadata. -- `test/examples/examples-spec.ts` expected provider path order. +```ts +const result = await Tester.test(NewProvider, 'newprovider.json', { + 'x-new-provider-event': 'build.finished', +}) +``` + +Prefer exact assertions for titles, descriptions, fields, authors, URLs, timestamps, colors, footers, and ignored-event behavior—not only embed counts. Cover malformed roots and envelopes, unknown events, asynchronous completion, trusted URL policy, untrusted Markdown, long values, and HTTP fallbacks where relevant. -Cover asynchronous completion, malformed input, unsupported events, provider-specific fallbacks, and Discord boundary behavior where relevant. Characterize odd legacy behavior before refactoring it. +`test/provider/provider-contract-spec.ts` runs the canonical fixture for every registry definition. It catches missing fixtures, empty examples, invalid metadata, mutable definitions, unsafe mentions, and Discord limit regressions without requiring provider-specific boilerplate. -Use Node 24 (the version in `.nvmrc`) and run: +Use Node 24 and run: ```sh -. "$HOME/.nvm/nvm.sh" -nvm use 24 npm test npm run lint npx tsc --noEmit npm run build -cd web -npm test -npm run build ``` diff --git a/package.json b/package.json index d8abda0..c90530d 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "start": "node dist/index.js", "dev": "node --watch --experimental-strip-types src/index.ts", "test": "node --experimental-strip-types --test 'test/**/*-spec.ts'", - "lint": "biome check src test", - "lint:fix": "biome check --write src test", + "lint": "biome check src test scripts", + "lint:fix": "biome check --write src test scripts", + "provider:new": "node scripts/create-provider.mjs", "deploy": "gcloud run deploy skyhook --source . --region us-central1 --allow-unauthenticated --max-instances 1 --port 8080", "logs": "gcloud run services logs tail skyhook --region us-central1" }, diff --git a/scripts/create-provider.mjs b/scripts/create-provider.mjs new file mode 100644 index 0000000..af9c62c --- /dev/null +++ b/scripts/create-provider.mjs @@ -0,0 +1,184 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const IMPORT_MARKER = '// provider-scaffold: imports' +const DEFINITION_MARKER = ' // provider-scaffold: definitions' +const README_MARKER = '' +const PROVIDER_PATH_PATTERN = /^[a-z][a-z0-9-]*$/ +const EXPORT_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*$/ + +export async function createProvider({ root, path, exportName, displayName, documentationUrl }) { + validateArguments({ path, exportName, displayName, documentationUrl }) + + const projectRoot = resolve(root) + const providerFile = resolve(projectRoot, 'src/provider', `${exportName}.ts`) + const fixtureDirectory = resolve(projectRoot, 'examples', path) + const fixtureFile = resolve(fixtureDirectory, `${path}.json`) + const testDirectory = resolve(projectRoot, 'test', path) + const testFile = resolve(testDirectory, `${path}-spec.ts`) + const registryFile = resolve(projectRoot, 'src/provider/ProviderRegistry.ts') + const readmeFile = resolve(projectRoot, 'README.md') + + await assertMissing(providerFile) + await assertMissing(fixtureDirectory) + await assertMissing(testDirectory) + + const [registry, readme] = await Promise.all([readFile(registryFile, 'utf8'), readFile(readmeFile, 'utf8')]) + assertMarker(registry, IMPORT_MARKER, registryFile) + assertMarker(registry, DEFINITION_MARKER, registryFile) + assertMarker(readme, README_MARKER, readmeFile) + + const nextRegistry = insertProviderImport(registry, exportName).replace( + DEFINITION_MARKER, + ` ${exportName},\n${DEFINITION_MARKER}`, + ) + const nextReadme = readme.replace( + README_MARKER, + `- [${displayName}](${documentationUrl}) - \`/${path}\`\n${README_MARKER}`, + ) + + await Promise.all([mkdir(fixtureDirectory, { recursive: true }), mkdir(testDirectory, { recursive: true })]) + await Promise.all([ + writeFile(providerFile, providerTemplate({ path, exportName, displayName, documentationUrl }), 'utf8'), + writeFile(fixtureFile, fixtureTemplate(), 'utf8'), + writeFile(testFile, testTemplate({ path, exportName }), 'utf8'), + writeFile(registryFile, nextRegistry, 'utf8'), + writeFile(readmeFile, nextReadme, 'utf8'), + ]) + + return { providerFile, fixtureFile, testFile } +} + +function validateArguments({ path, exportName, displayName, documentationUrl }) { + if (!PROVIDER_PATH_PATTERN.test(path)) { + throw new Error( + 'Provider path must start with a lowercase letter and contain only lowercase letters, digits, or hyphens.', + ) + } + if (!EXPORT_NAME_PATTERN.test(exportName)) { + throw new Error('Provider export name must be a PascalCase JavaScript identifier.') + } + if ( + typeof displayName !== 'string' || + displayName.trim() !== displayName || + displayName.length === 0 || + displayName.length > 80 || + hasControlCharacters(displayName) + ) { + throw new Error('Provider display name must contain 1-80 printable characters without surrounding whitespace.') + } + if (typeof documentationUrl !== 'string' || /\s/.test(documentationUrl) || documentationUrl.includes('*/')) { + throw new Error('Provider documentation URL must be a valid HTTPS URL.') + } + let documentation + try { + documentation = new URL(documentationUrl) + } catch { + throw new Error('Provider documentation URL must be a valid HTTPS URL.') + } + if (documentation.protocol !== 'https:') { + throw new Error('Provider documentation URL must be a valid HTTPS URL.') + } +} + +async function assertMissing(path) { + try { + await access(path) + throw new Error(`Refusing to overwrite existing path: ${path}`) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } +} + +function assertMarker(content, marker, file) { + if (!content.includes(marker)) throw new Error(`Scaffold marker is missing from ${file}: ${marker}`) +} + +function insertProviderImport(registry, exportName) { + const newModule = `./${exportName}.ts` + const newImport = `import { ${exportName} } from '${newModule}'` + const lines = registry.split('\n') + const markerIndex = lines.indexOf(IMPORT_MARKER) + const insertAt = lines.findIndex((line, index) => { + if (index >= markerIndex || !line.startsWith('import ')) return false + const moduleName = line.match(/from '([^']+)'$/)?.[1] + return moduleName != null && moduleName.localeCompare(newModule) > 0 + }) + lines.splice(insertAt === -1 ? markerIndex : insertAt, 0, newImport) + return lines.join('\n') +} + +function providerTemplate({ path, exportName, displayName, documentationUrl }) { + return `import { firstScalar } from '../util/WebhookValue.ts' +import { defineProvider } from './Provider.ts' + +/** + * ${documentationUrl} + */ +export const ${exportName} = defineProvider({ + path: '${path}', + name: '${escapeSingleQuoted(displayName)}', + example: { body: '${path}/${path}.json' }, + map({ body }, output) { + const event = firstScalar(body.event, body.type) ?? '${escapeSingleQuoted(displayName)} notification' + output.addEmbed({ title: event }) + }, +}) +` +} + +function fixtureTemplate() { + return `${JSON.stringify({ event: 'Example event' }, null, 4)}\n` +} + +function testTemplate({ path, exportName }) { + return `import { describe, it } from 'node:test' +import { ${exportName} } from '../../src/provider/${exportName}.ts' +import { Tester } from '../Tester.ts' + +describe('/POST ${path}', () => { + it('example', () => Tester.test(${exportName}, '${path}.json')) +}) +` +} + +function escapeSingleQuoted(value) { + return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'") +} + +function hasControlCharacters(value) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) + return codePoint <= 0x1f || codePoint === 0x7f + }) +} + +async function runCli() { + const [path, exportName, displayName, documentationUrl] = process.argv.slice(2) + if ([path, exportName, displayName, documentationUrl].some((value) => value == null)) { + throw new Error( + 'Usage: npm run provider:new -- "" ', + ) + } + const result = await createProvider({ + root: process.cwd(), + path, + exportName, + displayName, + documentationUrl, + }) + console.log(`Created provider ${displayName}:`) + console.log(`- ${result.providerFile}`) + console.log(`- ${result.fixtureFile}`) + console.log(`- ${result.testFile}`) + console.log('Customize the generated mapper and fixture, then run npm test.') +} + +const isMainModule = process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href +if (isMainModule) { + runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/src/provider/AppCenter.ts b/src/provider/AppCenter.ts index 96a3842..f7d6c3c 100644 --- a/src/provider/AppCenter.ts +++ b/src/provider/AppCenter.ts @@ -1,73 +1,54 @@ -import type { Embed } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' +import { defineEventProvider } from './Provider.ts' /** * https://learn.microsoft.com/en-us/appcenter/dashboard/webhooks/ */ -export class AppCenter extends TypeParseProvider { - private embed: Embed - - constructor() { - super() - this.setEmbedColor(0xcb2e62) - this.embed = {} - } - - public getName(): string { - return 'AppCenter' - } - - public getType(): string | null { - if (this.body.build_status) { - return 'pipeline' - } - - if (this.body.release_id) { - return 'distribute' - } - +export const AppCenter = defineEventProvider({ + path: 'appcenter', + name: 'AppCenter', + example: { body: 'appcenter/appcenter-pipeline.json' }, + defaults: { embedColor: 0xcb2e62 }, + event({ body }) { + if (body.build_status) return 'pipeline' + if (body.release_id) return 'distribute' return null - } - - public knownTypes(): string[] { - return ['pipeline', 'distribute'] - } - - private getEmojiStatus(status: string): string { - switch (status) { - case 'Canceled': - return '🚫' - case 'Failed': - return '❌' - case 'Succeeded': - return '✅' - case 'SucceededWithIssues': - return '⚠️' - default: - return '' - } - } - - public async pipeline(): Promise { - this.embed.title = 'Pipeline #' + this.body.build_id + ' on ' + this.body.app_name - this.embed.url = this.body.build_link - this.embed.description = `**Status**: ${this.body.build_status} ${this.getEmojiStatus(this.body.build_status)}` - this.addEmbed(this.embed) - } - - public async distribute(): Promise { - const information: string[] = [ - `**Version**: ${this.body.short_version} (${this.body.version})`, - `**Platform**: ${this.body.platform}`, - ] - - if (this.body.release_notes) { - information.push(`**Release notes**: ${this.body.release_notes}`) - } - - this.embed.title = 'Distribute #' + this.body.release_id + ' on ' + this.body.app_name - this.embed.url = this.body.install_link - this.embed.description = information.join('\n') - this.addEmbed(this.embed) + }, + handlers: { + pipeline({ body }, output) { + output.addEmbed({ + title: `Pipeline #${body.build_id} on ${body.app_name}`, + url: body.build_link, + description: `**Status**: ${body.build_status} ${emojiStatus(body.build_status)}`, + }) + }, + distribute({ body }, output) { + const information = [ + `**Version**: ${body.short_version} (${body.version})`, + `**Platform**: ${body.platform}`, + ] + if (body.release_notes) { + information.push(`**Release notes**: ${body.release_notes}`) + } + output.addEmbed({ + title: `Distribute #${body.release_id} on ${body.app_name}`, + url: body.install_link, + description: information.join('\n'), + }) + }, + }, +}) + +function emojiStatus(status: string): string { + switch (status) { + case 'Canceled': + return '🚫' + case 'Failed': + return '❌' + case 'Succeeded': + return '✅' + case 'SucceededWithIssues': + return '⚠️' + default: + return '' } } diff --git a/src/provider/Appveyor.ts b/src/provider/Appveyor.ts index d48aee5..05335f7 100644 --- a/src/provider/Appveyor.ts +++ b/src/provider/Appveyor.ts @@ -1,35 +1,34 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://www.appveyor.com/docs/notifications/#webhook-payload-default */ -export class AppVeyor extends DirectParseProvider { - public getName(): string { - return 'AppVeyor' - } - - public async parseData(): Promise { - this.setEmbedColor(0x00b3e0) +export const AppVeyor = defineProvider({ + path: 'appveyor', + name: 'AppVeyor', + example: { body: 'appveyor/appveyor.json' }, + defaults: { embedColor: 0x00b3e0 }, + map({ body }, output) { const embed: Embed = { - title: 'Build ' + this.body.eventData.buildVersion, - url: this.body.eventData.buildUrl, - description: this.body.eventData.commitMessage + '\n\n' + '**Status**: ' + this.body.eventData.status, + title: 'Build ' + body.eventData.buildVersion, + url: body.eventData.buildUrl, + description: body.eventData.commitMessage + '\n\n' + '**Status**: ' + body.eventData.status, author: { - name: this.body.eventData.commitAuthor, + name: body.eventData.commitAuthor, }, } - if (this.body.eventData.repositoryProvider === 'gitHub') { + if (body.eventData.repositoryProvider === 'gitHub') { embed.author!.url = - 'https://github.com/' + this.body.eventData.repositoryName + '/commit/' + this.body.eventData.commitId + 'https://github.com/' + body.eventData.repositoryName + '/commit/' + body.eventData.commitId } - if (this.body.eventData.jobs[0].artifacts.length !== 0) { + if (body.eventData.jobs[0].artifacts.length !== 0) { embed.description += '\n**Artifacts**:' - for (const artifact of this.body.eventData.jobs[0].artifacts) { + for (const artifact of body.eventData.jobs[0].artifacts) { embed.description += '\n- [' + artifact.fileName + '](' + artifact.permalink + ')' } } - this.addEmbed(embed) - } -} + output.addEmbed(embed) + }, +}) diff --git a/src/provider/AzureDevOps.ts b/src/provider/AzureDevOps.ts index 558a2dd..08c8001 100644 --- a/src/provider/AzureDevOps.ts +++ b/src/provider/AzureDevOps.ts @@ -1,204 +1,92 @@ -import type { Embed, EmbedAuthor, EmbedField } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' +import type { Embed, EmbedField } from '../model/DiscordApi.ts' +import { defineEventProvider, type ProviderMapper } from './Provider.ts' + +const minimalHandler: ProviderMapper = ({ body }, output) => { + output.addEmbed(minimalMessage(body)) +} /** * https://learn.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops */ -export class AzureDevOps extends TypeParseProvider { - private embed: Embed - - constructor() { - super() - this.setEmbedColor(0x68217a) - this.embed = {} - } - - public getName(): string { - return 'Azure DevOps' - } - - public getPath(): string { - return 'azure' - } - - public getType(): string { - return this.body.eventType - } - - public knownTypes(): string[] { - return [ - 'gitPush', - 'tfvcCheckin', - 'gitPullrequestCreated', - 'gitPullrequestMerged', - 'gitPullrequestUpdated', - 'workitemCommented', - 'workitemCreated', - 'workitemDeleted', - 'workitemRestored', - 'workitemUpdated', - 'buildComplete', - 'msVssReleaseReleaseCreatedEvent', - 'msVssReleaseReleaseAbandonedEvent', - 'msVssReleaseDeploymentApprovalCompleted', - 'msVssReleaseDeploymentApprovalPendingEvent', - 'msVssReleaseDeploymentCompletedEvent', - 'msVssReleaseDeplyomentStartedEvent', - ] - } - - // PUSH - public async gitPush(): Promise { - const fields: EmbedField[] = [] - this.body.resource.commits.forEach((commit: { commitId: string; comment: string }) => { - fields.push({ - name: 'Commit from ' + this.body.resource.pushedBy.displayName, - value: - '([`' + - commit.commitId.substring(0, 7) + - '`](' + - this.body.resource.repository.remoteUrl + - '/commit/' + - commit.commitId + - ')) ' + - commit.comment, +export const AzureDevOps = defineEventProvider({ + path: 'azure', + name: 'Azure DevOps', + example: { body: 'azure/azure.json' }, + defaults: { embedColor: 0x68217a }, + event: 'eventType', + handlers: { + 'git.push'({ body }, output) { + const fields: EmbedField[] = body.resource.commits.map((commit: { commitId: string; comment: string }) => ({ + name: `Commit from ${body.resource.pushedBy.displayName}`, + value: `([\`${commit.commitId.substring(0, 7)}\`](${body.resource.repository.remoteUrl}/commit/${commit.commitId})) ${commit.comment}`, inline: false, - }) - }) - this.embed.fields = fields - this.embed.author = { - name: this.body.resource.pushedBy.displayName, - icon_url: this.body.resource.pushedBy.imageUrl, - } - this.addMinimalMessage() - } - - // CHECK IN - public async tfvcCheckin(): Promise { - this.embed.fields = [ - { - name: 'Check in from ' + this.body.resource.checkedInBy.displayName, - value: - '([`' + - this.body.resource.changesetId + - '`](' + - this.body.resource.url + - ')) ' + - this.body.resource.comment, - inline: false, - }, - ] - this.addMinimalMessage() - } - - // PULL REQUEST - public async gitPullrequestCreated(): Promise { - this.formatPullRequest('Pull Request from ') - } - - // PULL REQUEST MERGE COMMIT - public async gitPullrequestMerged(): Promise { - this.formatPullRequest('Pull Request Merge Commit from ') - } - - // PULL REQUEST UPDATED - public async gitPullrequestUpdated(): Promise { - this.formatPullRequest('Pull Request Updated by ') - } - - private formatPullRequest(fieldLabel: string): void { - this.embed.author = this.extractCreatedByAuthor() - this.embed.fields = [ - { - name: fieldLabel + this.body.resource.createdBy.displayName, - value: - '([`' + - this.body.resource.title + - '`](' + - this.body.resource.repository.remoteUrl + - ')) ' + - this.body.resource.description, - inline: false, - }, - ] - this.addMinimalMessage() - } - - // WORK ITEM COMMENTED ON - public async workitemCommented(): Promise { - this.addMinimalMessage() - } - - // WORK ITEM CREATED - public async workitemCreated(): Promise { - this.addMinimalMessage() - } - - // WORK ITEM DELETED - public async workitemDeleted(): Promise { - this.addMinimalMessage() - } - - // WORK ITEM RESTORED - public async workitemRestored(): Promise { - this.addMinimalMessage() - } - - // WORK ITEM UPDATED - public async workitemUpdated(): Promise { - this.addMinimalMessage() - } - - // BUILD COMPLETED - public async buildComplete(): Promise { - this.addMinimalMessage() - } - - // RELEASE CREATED - public async msVssReleaseReleaseCreatedEvent(): Promise { - this.addMinimalMessage() - } - - // RELEASE ABANDONED - public async msVssReleaseReleaseAbandonedEvent(): Promise { - this.addMinimalMessage() - } - - // RELEASE DEPLOYMENT APPROVAL COMPLETED - public async msVssReleaseDeploymentApprovalCompleted(): Promise { - this.addMinimalMessage() - } - - // RELEASE DEPLOYMENT APPROVAL PENDING - public async msVssReleaseDeploymentApprovalPendingEvent(): Promise { - this.addMinimalMessage() - } - - // RELEASE DEPLOYMENT COMPLETED - public async msVssReleaseDeploymentCompletedEvent(): Promise { - this.addMinimalMessage() - } - - // RELEASE DEPLOYMENT STARTED - public async msVssReleaseDeplyomentStartedEvent(): Promise { - this.addMinimalMessage() - } - - // Because carpal tunnel... - private addMinimalMessage(): void { - this.embed.title = this.body.message.markdown as string - - if (this.embed.title.length > 256) { - this.embed.title = this.body.resource.title ?? this.body.message.markdown.substring(0, 256) - } - - this.addEmbed(this.embed) + })) + output.addEmbed( + minimalMessage(body, { + fields, + author: { + name: body.resource.pushedBy.displayName, + icon_url: body.resource.pushedBy.imageUrl, + }, + }), + ) + }, + 'tfvc.checkin'({ body }, output) { + output.addEmbed( + minimalMessage(body, { + fields: [ + { + name: `Check in from ${body.resource.checkedInBy.displayName}`, + value: `([\`${body.resource.changesetId}\`](${body.resource.url})) ${body.resource.comment}`, + inline: false, + }, + ], + }), + ) + }, + 'git.pullrequest.created': pullRequestHandler('Pull Request from '), + 'git.pullrequest.merged': pullRequestHandler('Pull Request Merge Commit from '), + 'git.pullrequest.updated': pullRequestHandler('Pull Request Updated by '), + 'workitem.commented': minimalHandler, + 'workitem.created': minimalHandler, + 'workitem.deleted': minimalHandler, + 'workitem.restored': minimalHandler, + 'workitem.updated': minimalHandler, + 'build.complete': minimalHandler, + 'ms.vss-release.release-created-event': minimalHandler, + 'ms.vss-release.release-abandoned-event': minimalHandler, + 'ms.vss-release.deployment-approval-completed': minimalHandler, + 'ms.vss-release.deployment-approval-pending-event': minimalHandler, + 'ms.vss-release.deployment-completed-event': minimalHandler, + 'ms.vss-release.deployment-started-event': minimalHandler, + 'ms.vss-release.deplyoment-started-event': minimalHandler, + }, +}) + +function pullRequestHandler(fieldLabel: string): ProviderMapper { + return ({ body }, output) => { + output.addEmbed( + minimalMessage(body, { + author: { + name: body.resource.createdBy.displayName, + icon_url: body.resource.createdBy.imageUrl, + }, + fields: [ + { + name: fieldLabel + body.resource.createdBy.displayName, + value: `([\`${body.resource.title}\`](${body.resource.repository.remoteUrl})) ${body.resource.description}`, + inline: false, + }, + ], + }), + ) } +} - private extractCreatedByAuthor(): EmbedAuthor { - return { - name: this.body.resource.createdBy.displayName, - icon_url: this.body.resource.createdBy.imageUrl, - } +function minimalMessage(body: Record, embed: Embed = {}): Embed { + const markdown = String(body.message.markdown) + return { + ...embed, + title: markdown.length > 256 ? (body.resource.title ?? markdown.substring(0, 256)) : markdown, } } diff --git a/src/provider/BaseProvider.ts b/src/provider/BaseProvider.ts deleted file mode 100644 index 12f3a4d..0000000 --- a/src/provider/BaseProvider.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { DiscordPayload, Embed } from '../model/DiscordApi.ts' -import { SKYHOOK_FOOTER } from '../util/DiscordEmbed.ts' -import { type Logger, logger } from '../util/logger.ts' - -function camelCase(s: string): string { - const parts = s.split(/[._\-\s]+/).filter((p) => p.length > 0) - if (parts.length === 0) return '' - const [first, ...rest] = parts - return ( - first.charAt(0).toLowerCase() + - first.slice(1) + - rest.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') - ) -} - -/** - * Base provider, which all other providers will subclass. You can then - * use the provided methods to format the data to Discord - */ -export abstract class BaseProvider { - protected payload: DiscordPayload - protected logger: Logger = logger - protected headers: any - protected body: any - protected query: any - // all embeds will use this color - protected embedColor: number | null - private cancelled: boolean - - constructor() { - this.payload = {} - this.embedColor = null - this.cancelled = false - } - - /** - * Override this and provide the name of the provider - */ - public abstract getName(): string - - /** - * By default, the path is always just the same as the name, all lower case. Override if that is not the case - */ - public getPath(): string { - return this.getName().toLowerCase() - } - - /** - * Parse the request and respond with a DiscordPayload - * - * @param body the request body - * @param headers the request headers - * @param query the query - */ - public async parse(body: any, headers: any = null, query: any = null): Promise { - this.body = body - this.headers = headers - this.query = query - this.cancelled = false - await this.preParse() - if (this.cancelled) return null - - await this.parseImpl() - if (this.cancelled) return null - - await this.postParse() - - return this.cancelled ? null : this.payload - } - - /** - * Nullify the payload. This will effectively cancel the operation and sent nothing to discord. - */ - protected nullifyPayload(): void { - this.cancelled = true - } - - /** - * Open method to do certain things pre parse. - */ - protected preParse(): void | Promise { - // Default - } - - /** - * Parse implementation. The parse strategy is up to the implementation. - */ - protected abstract parseImpl(): Promise - - /** - * Open method to do certain things post parse and before the payload is returned. - */ - protected postParse(): void | Promise { - // Default - } - - protected addEmbed(embed: Embed): void { - if (this.cancelled) return - - // TODO check to see if too many fields - // add the footer to all embeds added - embed.footer = { ...SKYHOOK_FOOTER } - if (this.embedColor != null) { - embed.color = this.embedColor - } - if (this.payload.embeds == null) { - this.payload.embeds = [] - } - this.payload.embeds.push(embed) - } - - protected setEmbedColor(color: number): void { - this.embedColor = color - } -} - -/** - * BaseProvider implementation that uses a direct parse strategy. - * Subclasses should implement parse logic in the parseData method. - */ -export abstract class DirectParseProvider extends BaseProvider { - public abstract parseData(): Promise - - protected async parseImpl(): Promise { - await this.parseData() - } -} - -/** - * BaseProvider implementation that uses a type based parse strategy. - * Sublasses must implement a getType method. This method will look at - * the payload data and determine its type. A function matching the returned - * type name will be executed. If none function matching the type name - * is found, nothing will be executed. - */ -export abstract class TypeParseProvider extends BaseProvider { - public abstract getType(): string | null - - public abstract knownTypes(): string[] - - /** - * Formats the type passed to make it work as a method reference. This means removing underscores - * and camel casing. - * - * @param type the event type - */ - public static formatType(type: string | null): string | null { - if (type == null) { - return null - } - type = type.replace(/:/g, '_') // needed because of BitBucket - return camelCase(type) - } - - protected async parseImpl(): Promise { - const type = TypeParseProvider.formatType(this.getType()) - if (type != null) { - if (this.knownTypes().includes(type)) { - const method: () => Promise | null = (this as any)[type] - if (method != null && typeof method === 'function') { - this.logger.info(`Calling ${type}() in ${this.constructor.name} provider.`) - await method.bind(this)() - return - } - } - } - // If we didn't succeed, dont send anything. - this.nullifyPayload() - } -} diff --git a/src/provider/Basecamp.ts b/src/provider/Basecamp.ts index 4a989a6..58743df 100644 --- a/src/provider/Basecamp.ts +++ b/src/provider/Basecamp.ts @@ -1,210 +1,151 @@ import TurndownService from 'turndown' import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' -/** - * https://github.com/basecamp/bc3-api/blob/master/sections/webhooks.md - */ -export class Basecamp extends DirectParseProvider { - private turndown: TurndownService - private colorCreated = 0x00ff00 - private colorDeleted = 0xff0000 - private colorArchived = 0x8b0000 - private colorUnarchived = 0x66cdaa - private colorEdited = 0x40e0d0 - - constructor() { - super() - this.turndown = new TurndownService() - } +const COLOR_CREATED = 0x00ff00 +const COLOR_DELETED = 0xff0000 +const COLOR_ARCHIVED = 0x8b0000 +const COLOR_UNARCHIVED = 0x66cdaa +const COLOR_EDITED = 0x40e0d0 +const turndown = new TurndownService() - public getName(): string { - return 'Basecamp' - } +interface EventFormat { + readonly color: number + readonly action: string + readonly content?: boolean + readonly fields?: readonly ('title' | 'type')[] +} - public getPath(): string { - return 'basecamp' - } +const EVENT_FORMATS: Readonly> = { + comment_trashed: { color: COLOR_DELETED, action: 'deleted comment', content: true, fields: ['title'] }, + comment_created: { color: COLOR_CREATED, action: 'added comment', content: true, fields: ['title'] }, + comment_content_changed: { color: COLOR_EDITED, action: 'changed comment', content: true, fields: ['title'] }, + comment_archived: { color: COLOR_ARCHIVED, action: 'archived comment', content: true, fields: ['title'] }, + comment_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived comment', content: true, fields: ['title'] }, + todo_created: { color: COLOR_CREATED, action: 'created todo', fields: ['title'] }, + todo_completed: { color: 0x4ca3dd, action: 'completed todo', fields: ['title'] }, + todo_archived: { color: COLOR_ARCHIVED, action: 'archived todo', fields: ['title'] }, + todo_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived todo', fields: ['title'] }, + todo_trashed: { color: COLOR_DELETED, action: 'deleted todo', fields: ['title'] }, + todolist_description_changed: { + color: COLOR_EDITED, + action: "changed todolist's description", + content: true, + fields: ['title'], + }, + todolist_created: { color: COLOR_CREATED, action: 'created todolist', content: true, fields: ['title'] }, + todolist_archived: { color: COLOR_ARCHIVED, action: 'archived todolist', fields: ['title'] }, + todolist_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived todolist', fields: ['title'] }, + todolist_trashed: { color: COLOR_DELETED, action: 'deleted todolist', fields: ['title'] }, + message_created: { color: COLOR_CREATED, action: 'published message', content: true, fields: ['title'] }, + message_active: { color: COLOR_CREATED, action: 'published message', content: true, fields: ['title'] }, + message_archived: { color: COLOR_ARCHIVED, action: 'archived message', content: true, fields: ['title'] }, + message_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived message', content: true, fields: ['title'] }, + message_trashed: { color: COLOR_DELETED, action: 'deleted message', content: true, fields: ['title'] }, + vault_created: { color: COLOR_CREATED, action: "created doc's folder", fields: ['title'] }, + vault_copied: { color: COLOR_CREATED, action: "copied doc's folder", fields: ['title'] }, + vault_inserted: { color: COLOR_CREATED, action: "added doc's folder", fields: ['title'] }, + vault_title_changed: { color: COLOR_EDITED, action: "changed folder's title", fields: ['title'] }, + vault_trashed: { color: COLOR_DELETED, action: 'deleted folder', content: true, fields: ['title'] }, + vault_archived: { color: COLOR_ARCHIVED, action: 'archived folder', content: true, fields: ['title'] }, + vault_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived folder', content: true, fields: ['title'] }, + upload_created: { color: COLOR_CREATED, action: 'uploaded file', content: true, fields: ['title'] }, + upload_active: { color: COLOR_CREATED, action: 'uploaded file', content: true, fields: ['title'] }, + upload_copied: { color: COLOR_CREATED, action: 'copied file', content: true, fields: ['title'] }, + upload_inserted: { color: COLOR_CREATED, action: 'added file', content: true, fields: ['title'] }, + upload_archived: { color: COLOR_ARCHIVED, action: 'archived file', content: true, fields: ['title'] }, + upload_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived file', content: true, fields: ['title'] }, + upload_trashed: { color: COLOR_DELETED, action: 'deleted file', content: true, fields: ['title'] }, + document_created: { color: COLOR_CREATED, action: 'created document', content: true, fields: ['title'] }, + document_active: { color: COLOR_CREATED, action: 'created document', content: true, fields: ['title'] }, + document_copied: { color: COLOR_CREATED, action: 'copied document', content: true, fields: ['title'] }, + document_inserted: { color: COLOR_CREATED, action: 'added document', content: true, fields: ['title'] }, + document_archived: { color: COLOR_ARCHIVED, action: 'archived document', content: true, fields: ['title'] }, + document_unarchived: { color: COLOR_UNARCHIVED, action: 'unarchived document', content: true, fields: ['title'] }, + document_trashed: { color: COLOR_DELETED, action: 'deleted document', content: true, fields: ['title'] }, + google_document_created: { + color: COLOR_CREATED, + action: 'created Google Document', + content: true, + fields: ['title'], + }, + google_document_active: { + color: COLOR_CREATED, + action: 'created Google Document', + content: true, + fields: ['title'], + }, + google_document_copied: { + color: COLOR_CREATED, + action: 'copied Google Document', + content: true, + fields: ['title'], + }, + google_document_inserted: { + color: COLOR_CREATED, + action: 'added Google Document', + content: true, + fields: ['title'], + }, + google_document_archived: { + color: COLOR_ARCHIVED, + action: 'archived Google Document', + content: true, + fields: ['title'], + }, + google_document_unarchived: { + color: COLOR_UNARCHIVED, + action: 'unarchived Google Document', + content: true, + fields: ['title'], + }, + google_document_trashed: { + color: COLOR_DELETED, + action: 'deleted Google Document', + content: true, + fields: ['title'], + }, +} - public async parseData(): Promise { - switch (this.body.kind) { - case 'comment_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted comment', ['title'], true) - break - case 'comment_created': - this.prepareEmbed(this.colorCreated, 'added comment', ['title'], true) - break - case 'todo_created': - this.prepareEmbed(this.colorCreated, 'created todo', ['title']) - break - case 'todo_completed': - this.prepareEmbed(0x4ca3dd, 'completed todo', ['title']) - break - case 'todo_archived': - this.prepareEmbed(this.colorArchived, 'archived todo', ['title']) - break - case 'todo_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived todo', ['title']) - break - case 'todo_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted todo', ['title']) - break - case 'comment_content_changed': - this.prepareEmbed(this.colorEdited, 'changed comment', ['title'], true) - break - case 'todolist_description_changed': - this.prepareEmbed(this.colorEdited, "changed todolist's description", ['title'], true) - break - case 'todolist_created': - this.prepareEmbed(this.colorCreated, 'created todolist', ['title'], true) - break - case 'todolist_archived': - this.prepareEmbed(this.colorArchived, 'archived todolist', ['title']) - break - case 'todolist_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived todolist', ['title']) - break - case 'todolist_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted todolist', ['title']) - break - case 'message_created': - case 'message_active': - this.prepareEmbed(this.colorCreated, 'published message', ['title'], true) - break - case 'message_archived': - this.prepareEmbed(this.colorArchived, 'archived message', ['title'], true) - break - case 'message_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived message', ['title'], true) - break - case 'message_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted message', ['title'], true) - break - case 'comment_archived': - this.prepareEmbed(this.colorArchived, 'archived comment', ['title'], true) - break - case 'comment_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived comment', ['title'], true) - break - case 'vault_created': - this.prepareEmbed(this.colorCreated, "created doc's folder", ['title']) - break - case 'vault_copied': - this.prepareEmbed(this.colorCreated, "copied doc's folder", ['title']) - break - case 'vault_inserted': - this.prepareEmbed(this.colorCreated, "added doc's folder", ['title']) - break - case 'vault_title_changed': - this.prepareEmbed(this.colorEdited, "changed folder's title", ['title']) - break - case 'vault_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted folder', ['title'], true) - break - case 'vault_archived': - this.prepareEmbed(this.colorArchived, 'archived folder', ['title'], true) - break - case 'vault_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived folder', ['title'], true) - break - case 'upload_created': - case 'upload_active': - this.prepareEmbed(this.colorCreated, 'uploaded file', ['title'], true) - break - case 'upload_copied': - this.prepareEmbed(this.colorCreated, 'copied file', ['title'], true) - break - case 'upload_inserted': - this.prepareEmbed(this.colorCreated, 'added file', ['title'], true) - break - case 'upload_archived': - this.prepareEmbed(this.colorArchived, 'archived file', ['title'], true) - break - case 'upload_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived file', ['title'], true) - break - case 'upload_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted file', ['title'], true) - break - case 'document_created': - case 'document_active': - this.prepareEmbed(this.colorCreated, 'created document', ['title'], true) - break - case 'document_copied': - this.prepareEmbed(this.colorCreated, 'copied document', ['title'], true) - break - case 'document_inserted': - this.prepareEmbed(this.colorCreated, 'added document', ['title'], true) - break - case 'document_archived': - this.prepareEmbed(this.colorArchived, 'archived document', ['title'], true) - break - case 'document_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived document', ['title'], true) - break - case 'document_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted document', ['title'], true) - break - case 'google_document_created': - case 'google_document_active': - this.prepareEmbed(this.colorCreated, 'created Google Document', ['title'], true) - break - case 'google_document_copied': - this.prepareEmbed(this.colorCreated, 'copied Google Document', ['title'], true) - break - case 'google_document_inserted': - this.prepareEmbed(this.colorCreated, 'added Google Document', ['title'], true) - break - case 'google_document_archived': - this.prepareEmbed(this.colorArchived, 'archived Google Document', ['title'], true) - break - case 'google_document_unarchived': - this.prepareEmbed(this.colorUnarchived, 'unarchived Google Document', ['title'], true) - break - case 'google_document_trashed': - this.prepareEmbed(this.colorDeleted, 'deleted Google Document', ['title'], true) - break - default: - console.log('unknown event ' + this.body.kind) - this.prepareEmbed(0xf0ff00, this.body.kind, ['title', 'type'], true) - break +/** + * https://github.com/basecamp/bc3-api/blob/master/sections/webhooks.md + */ +export const Basecamp = defineProvider({ + path: 'basecamp', + name: 'Basecamp', + example: { body: 'basecamp/basecamp.json' }, + map({ body }, output) { + const format = EVENT_FORMATS[body.kind] ?? { + color: 0xf0ff00, + action: body.kind, + content: true, + fields: ['title', 'type'] as const, } - } + output.addEmbed(createEmbed(body, format)) + }, +}) - private escapeString(str: string): string { - if (!str) { - return '' - } - return this.turndown.turndown(str) +function createEmbed(body: Record, format: EventFormat): Embed { + const embed: Embed = { + title: `${format.action} on ${body.recording.bucket.name} / ${body.recording.parent.type} : ${body.recording.parent.title}`, + url: body.recording.app_url, + color: format.color, + author: { + name: body.recording.creator.name, + icon_url: body.recording.creator.avatar_url, + }, + fields: [], } - private prepareEmbed(color: number, title: string, fields: string[] = [], content = false): Embed { - const embed: Embed = { - title: `${title} on ${this.body.recording.bucket.name} / ${this.body.recording.parent.type} : ${this.body.recording.parent.title}`, - url: this.body.recording.app_url, - color: color, - author: { - name: this.body.recording.creator.name, - icon_url: this.body.recording.creator.avatar_url, - }, - fields: [], - } - - if (content) { - embed.description = this.escapeString(this.body.recording.content).substring(0, 4096) + if (format.content) { + embed.description = turndown.turndown(body.recording.content || '').substring(0, 4096) + } + for (const field of format.fields ?? []) { + if (field === 'title') { + embed.fields!.push({ name: 'Title', value: body.recording.title, inline: true }) + } else if (field === 'type') { + embed.fields!.push({ name: 'Type', value: body.recording.type, inline: true }) } - - const body = this.body - fields.forEach((field) => { - switch (field) { - case 'title': - embed.fields!.push({ name: 'Title', value: body.recording.title, inline: true }) - break - case 'type': - embed.fields!.push({ name: 'Type', value: body.recording.type, inline: true }) - } - }) - this.addEmbed(embed) - return embed } + return embed } diff --git a/src/provider/BitBucketServer.ts b/src/provider/BitBucketServer.ts index b8aac1b..e009081 100644 --- a/src/provider/BitBucketServer.ts +++ b/src/provider/BitBucketServer.ts @@ -1,285 +1,140 @@ import type { Embed, EmbedAuthor, EmbedField } from '../model/DiscordApi.ts' -import { TypeParseProvider } from './BaseProvider.ts' - -export class BitBucketServer extends TypeParseProvider { - private embed: Embed - - constructor() { - super() - this.setEmbedColor(0x205081) - this.embed = {} - } - - public getName(): string { - return 'BitBucketServer' - } - - public getType(): string | null { - if (this.headers == null) { - return null - } - return this.headers['x-event-key'] - } - - public knownTypes(): string[] { - return [ - 'diagnosticsPing', - 'repoRefsChanged', - 'repoModified', - 'repoForked', - 'repoCommentAdded', - 'repoCommentEdited', - 'repoCommentDeleted', - 'prOpened', - 'prFromRefUpdated', - 'prModified', - 'prReviewerUpdated', - 'prReviewerApproved', - 'prReviewerUnapproved', - 'prReviewerNeedsWork', - 'prMerged', - 'prDeclined', - 'prDeleted', - 'prCommentAdded', - 'prCommentEdited', - 'prCommentDeleted', - 'mirrorRepoSynchronized', - ] - } - - public async diagnosticsPing(): Promise { - this.embed.title = 'Test Connection' - this.embed.description = 'You have successfully configured Skyhook with your BitBucket Server instance.' - this.embed.fields = [ - { - name: 'Test', - value: this.body.test, - }, - ] - - this.addEmbed(this.embed) - } - - public async repoRefsChanged(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.extractRepoRepositoryName()}] New commit` - if (typeof this.body.repository.description === 'string') { - this.embed.description = this.body.repository.description - } - this.embed.url = this.extractRepoUrl() - this.embed.fields = this.extractRepoChangesField() - this.addEmbed(this.embed) - } - - public async repoModified(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.old.name}] Repository has been updated` - this.embed.url = - this.extractBaseLink() + - '/projects/' + - this.body.new.project.key + - '/repos/' + - this.body.new.slug + - '/browse' - this.addEmbed(this.embed) - } - - public async repoForked(): Promise { - this.embed.author = this.extractAuthor() - this.embed.description = 'A new [`fork`] has been created.' - this.addEmbed(this.embed) - } - - public async repoCommentAdded(): Promise { - this.formatCommitCommentPayload('New comment on commit') - this.addEmbed(this.embed) - } - - public async repoCommentEdited(): Promise { - this.formatCommitCommentPayload('Comment edited on commit') - this.addEmbed(this.embed) - } - - public async repoCommentDeleted(): Promise { - this.formatCommitCommentPayload('Comment deleted on commit') - this.addEmbed(this.embed) - } - - public async prOpened(): Promise { - this.formatPrPayload('Pull request opened') - this.addEmbed(this.embed) - } - - public async prFromRefUpdated(): Promise { - this.formatPrPayload('Pull request updated') - this.addEmbed(this.embed) - } - - public async prModified(): Promise { - this.formatPrPayload('Pull request modified') - this.addEmbed(this.embed) - } - - public async prReviewerUpdated(): Promise { - this.formatPrPayload('New reviewers for pull request') - this.addEmbed(this.embed) - } - - public async prReviewerApproved(): Promise { - this.formatPrPayload('Pull request approved') - this.addEmbed(this.embed) - } - - public async prReviewerUnapproved(): Promise { - this.formatPrPayload('Removed approval for pull request') - this.addEmbed(this.embed) - } - - public async prReviewerNeedsWork(): Promise { - this.formatPrPayload('Pull request needs work') - this.addEmbed(this.embed) - } - - public async prMerged(): Promise { - this.formatPrPayload('Pull request merged') - this.addEmbed(this.embed) - } - - public async prDeclined(): Promise { - this.formatPrPayload('Pull request declined') - this.addEmbed(this.embed) - } - - public async prDeleted(): Promise { - this.formatPrPayload('Deleted pull request') - this.addEmbed(this.embed) - } - - public async prCommentAdded(): Promise { - this.formatCommentPayload('New comment on pull request') - this.addEmbed(this.embed) - } - - public async prCommentEdited(): Promise { - this.formatCommentPayload('Updated comment on pull request') - this.addEmbed(this.embed) - } - - public async prCommentDeleted(): Promise { - this.formatCommentPayload('Deleted comment on pull request') - this.addEmbed(this.embed) - } +import { defineEventProvider, type ProviderMapper } from './Provider.ts' + +const BITBUCKET_ICON = 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/44_Bitbucket_logo_logos-512.png' + +/** + * https://developer.atlassian.com/server/bitbucket/how-tos/webhooks/ + */ +export const BitBucketServer = defineEventProvider({ + path: 'bitbucketserver', + name: 'BitBucketServer', + example: { + body: 'bitbucketserver/bitbucketserver.json', + headers: 'bitbucketserver/bitbucketserver.headers.json', + }, + defaults: { embedColor: 0x205081 }, + event: ({ headers }) => headers.get('x-event-key'), + handlers: { + 'diagnostics:ping': ({ body }, output) => { + output.addEmbed({ + title: 'Test Connection', + description: 'You have successfully configured Skyhook with your BitBucket Server instance.', + fields: [{ name: 'Test', value: body.test }], + }) + }, + 'repo:refs_changed': embedHandler(repoRefsChanged), + 'repo:modified': embedHandler(repoModified), + 'repo:forked': embedHandler((body) => ({ + author: author(body), + description: 'A new [`fork`] has been created.', + })), + 'repo:comment:added': embedHandler((body) => commitComment(body, 'New comment on commit')), + 'repo:comment:edited': embedHandler((body) => commitComment(body, 'Comment edited on commit')), + 'repo:comment:deleted': embedHandler((body) => commitComment(body, 'Comment deleted on commit')), + 'pr:opened': embedHandler((body) => pullRequest(body, 'Pull request opened')), + 'pr:from_ref_updated': embedHandler((body) => pullRequest(body, 'Pull request updated')), + 'pr:modified': embedHandler((body) => pullRequest(body, 'Pull request modified')), + 'pr:reviewer:updated': embedHandler((body) => pullRequest(body, 'New reviewers for pull request')), + 'pr:reviewer:approved': embedHandler((body) => pullRequest(body, 'Pull request approved')), + 'pr:reviewer:unapproved': embedHandler((body) => pullRequest(body, 'Removed approval for pull request')), + 'pr:reviewer:needs_work': embedHandler((body) => pullRequest(body, 'Pull request needs work')), + 'pr:merged': embedHandler((body) => pullRequest(body, 'Pull request merged')), + 'pr:declined': embedHandler((body) => pullRequest(body, 'Pull request declined')), + 'pr:deleted': embedHandler((body) => pullRequest(body, 'Deleted pull request')), + 'pr:comment:added': embedHandler((body) => pullRequestComment(body, 'New comment on pull request')), + 'pr:comment:edited': embedHandler((body) => pullRequestComment(body, 'Updated comment on pull request')), + 'pr:comment:deleted': embedHandler((body) => pullRequestComment(body, 'Deleted comment on pull request')), + 'mirror:repo_synchronized': embedHandler((body) => ({ + title: `[${body.repository.name}] Mirror Synchronized`, + })), + }, +}) + +function embedHandler(formatter: (body: Record) => Embed): ProviderMapper { + return ({ body }, output) => output.addEmbed(formatter(body)) +} - public async mirrorRepoSynchronized(): Promise { - this.embed.title = `[${this.extractRepoRepositoryName()}] Mirror Synchronized` +function repoRefsChanged(body: Record): Embed { + return { + author: author(body), + title: `[${body.repository.name}] New commit`, + ...(typeof body.repository.description === 'string' ? { description: body.repository.description } : {}), + url: repoUrl(body), + fields: repoChangeFields(body), } +} - private formatPrPayload(title: string): void { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.extractPullRequestRepositoryName()}] ${title}: #${this.body.pullRequest.id} ${this.body.pullRequest.title}` - this.embed.description = this.body.pullRequest.description - this.embed.url = this.extractPullRequestUrl() - this.embed.fields = this.extractPullRequestFields() +function repoModified(body: Record): Embed { + return { + author: author(body), + title: `[${body.old.name}] Repository has been updated`, + url: `${baseLink(body)}/projects/${body.new.project.key}/repos/${body.new.slug}/browse`, } +} - private formatCommentPayload(title: string): void { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.extractPullRequestRepositoryName()}] ${title}: #${this.body.pullRequest.id} ${this.body.pullRequest.title}` - this.embed.description = this.body.comment.text - this.embed.url = this.extractPullRequestUrl() +function pullRequest(body: Record, title: string): Embed { + return { + author: author(body), + title: `[${body.pullRequest.toRef.repository.name}] ${title}: #${body.pullRequest.id} ${body.pullRequest.title}`, + description: body.pullRequest.description, + url: pullRequestUrl(body), + fields: pullRequestFields(body), } +} - private formatCommitCommentPayload(title: string): void { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.extractRepoRepositoryName()}] ${title} ${this.body.commit.slice(0, 10)}` - this.embed.description = this.body.comment.text - this.embed.url = this.extractCommitCommentUrl() +function pullRequestComment(body: Record, title: string): Embed { + return { + author: author(body), + title: `[${body.pullRequest.toRef.repository.name}] ${title}: #${body.pullRequest.id} ${body.pullRequest.title}`, + description: body.comment.text, + url: pullRequestUrl(body), } +} - private extractAuthor(): EmbedAuthor { - return { - name: this.body.actor.displayName, - icon_url: 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/44_Bitbucket_logo_logos-512.png', - } +function commitComment(body: Record, title: string): Embed { + return { + author: author(body), + title: `[${body.repository.name}] ${title} ${body.commit.slice(0, 10)}`, + description: body.comment.text, + url: `${baseLink(body)}/projects/${body.repository.project.key}/repos/${body.repository.slug}/commits/${body.commit}`, } +} - private extractPullRequestUrl(): string { - return ( - this.extractBaseLink() + - '/projects/' + - this.body.pullRequest.fromRef.repository.project.key + - '/repos/' + - this.body.pullRequest.fromRef.repository.slug + - '/pull-requests/' + - this.body.pullRequest.id + - '/overview' - ) - } +function author(body: Record): EmbedAuthor { + return { name: body.actor.displayName, icon_url: BITBUCKET_ICON } +} - private extractPullRequestFields(): EmbedField[] { - const fieldArray: EmbedField[] = [] +function pullRequestUrl(body: Record): string { + const repository = body.pullRequest.fromRef.repository + return `${baseLink(body)}/projects/${repository.project.key}/repos/${repository.slug}/pull-requests/${body.pullRequest.id}/overview` +} - fieldArray.push({ +function pullRequestFields(body: Record): EmbedField[] { + const fields: EmbedField[] = [ + { name: 'From --> To', - value: `**Source branch:** ${this.body.pullRequest.fromRef.displayId} \n **Destination branch:** ${this.body.pullRequest.toRef.displayId} \n **State:** ${this.body.pullRequest.state}`, - }) - - for (let i = 0; i < Math.min(this.body.pullRequest.reviewers.length, 18); i++) { - fieldArray.push({ - name: 'Reviewer', - value: this.body.pullRequest.reviewers[i].user.displayName, - }) - } - - return fieldArray - } - - private extractPullRequestRepositoryName(): string { - return this.body.pullRequest.toRef.repository.name - } - - private extractRepoRepositoryName(): string { - return this.body.repository.name - } - - private extractRepoUrl(): string { - return ( - this.extractBaseLink() + - '/projects/' + - this.body.repository.project.key + - '/repos/' + - this.body.repository.slug + - '/browse' - ) + value: `**Source branch:** ${body.pullRequest.fromRef.displayId} \n **Destination branch:** ${body.pullRequest.toRef.displayId} \n **State:** ${body.pullRequest.state}`, + }, + ] + for (const reviewer of body.pullRequest.reviewers.slice(0, 18)) { + fields.push({ name: 'Reviewer', value: reviewer.user.displayName }) } + return fields +} - private extractRepoChangesField(): EmbedField[] { - const fieldArray: EmbedField[] = [] - - for (let i = 0; i < Math.min(this.body.changes.length, 18); i++) { - fieldArray.push({ - name: 'Change', - value: `**Branch:** ${this.body.changes[i].ref.displayId} \n **Old Hash:** ${this.body.changes[i].fromHash.slice(0, 10)} \n **New Hash:** ${this.body.changes[i].toHash.slice(0, 10)} \n **Type:** ${this.body.changes[i].type}`, - }) - } - - return fieldArray - } +function repoUrl(body: Record): string { + return `${baseLink(body)}/projects/${body.repository.project.key}/repos/${body.repository.slug}/browse` +} - private extractCommitCommentUrl(): string { - return ( - this.extractBaseLink() + - '/projects/' + - this.body.repository.project.key + - '/repos/' + - this.body.repository.slug + - '/commits/' + - this.body.commit - ) - } +function repoChangeFields(body: Record): EmbedField[] { + return body.changes.slice(0, 18).map((change: Record) => ({ + name: 'Change', + value: `**Branch:** ${change.ref.displayId} \n **Old Hash:** ${change.fromHash.slice(0, 10)} \n **New Hash:** ${change.toHash.slice(0, 10)} \n **Type:** ${change.type}`, + })) +} - private extractBaseLink(): string { - const actorLink = this.body.actor.links.self[0].href - return actorLink.substring(0, actorLink.indexOf('/user')) - } +function baseLink(body: Record): string { + const actorLink = body.actor.links.self[0].href + return actorLink.substring(0, actorLink.indexOf('/user')) } diff --git a/src/provider/Bitbucket.ts b/src/provider/Bitbucket.ts index 5484da4..0ad9031 100644 --- a/src/provider/Bitbucket.ts +++ b/src/provider/Bitbucket.ts @@ -1,476 +1,287 @@ import type { Embed, EmbedAuthor, EmbedField } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' import { MarkdownUtil } from '../util/MarkdownUtil.ts' +import { defineEventProvider, type ProviderMapper } from './Provider.ts' + +const BASE_LINK = 'https://bitbucket.org/' /** - * https://confluence.atlassian.com/bitbucket/manage-webhooks-735643732.html + * https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/ */ -export class BitBucket extends TypeParseProvider { - private static _formatLargeString(str: string, limit = 256): string { - return str.length > limit ? str.substring(0, limit - 1) + '\u2026' : str - } - - private static _titleCase(str: string, ifNull = 'None'): string { - if (str == null) { - return ifNull - } - if (str.length < 1) { - return str - } - const strArray = str.toLowerCase().split(' ') - for (let i = 0; i < strArray.length; i++) { - strArray[i] = strArray[i].charAt(0).toUpperCase() + strArray[i].slice(1) - } - return strArray.join(' ') - } - - private baseLink = 'https://bitbucket.org/' - private embed: Embed - - constructor() { - super() - this.setEmbedColor(0x205081) - this.embed = {} - } - - public getName(): string { - return 'BitBucket' - } - - public getType(): string | null { - if (this.headers == null) { - return null - } - return this.headers['x-event-key'] - } +export const BitBucket = defineEventProvider({ + path: 'bitbucket', + name: 'BitBucket', + example: { + body: 'bitbucket/bitbucket.json', + headers: 'bitbucket/bitbucket.headers.json', + }, + defaults: { embedColor: 0x205081 }, + event: ({ headers }) => headers.get('x-event-key'), + handlers: { + 'repo:push': repoPush, + 'repo:fork': embedHandler(repoFork), + 'repo:updated': embedHandler(repoUpdated), + 'repo:commit_comment_created': embedHandler(repoCommitComment), + 'repo:commit_status_created': embedHandler((body) => commitStatus(body, false)), + 'repo:commit_status_updated': embedHandler((body) => commitStatus(body, true)), + 'issue:created': embedHandler(issueCreated), + 'issue:updated': embedHandler(issueUpdated), + 'issue:comment_created': embedHandler(issueCommentCreated), + 'pullrequest:created': embedHandler((body) => pullRequestWithDetails(body, 'Pull request opened')), + 'pullrequest:updated': embedHandler((body) => pullRequestWithDetails(body, 'Updated pull request')), + 'pullrequest:approved': coloredPullRequestHandler('Approved pull request', 0x2db83d), + 'pullrequest:unapproved': embedHandler((body) => pullRequest(body, 'Removed approval for pull request')), + 'pullrequest:fulfilled': embedHandler((body) => pullRequest(body, 'Merged pull request')), + 'pullrequest:rejected': embedHandler(pullRequestRejected), + 'pullrequest:comment_created': embedHandler((body) => pullRequestComment(body, 'New comment on pull request')), + 'pullrequest:comment_updated': embedHandler((body) => + pullRequestComment(body, 'Updated comment on pull request'), + ), + 'pullrequest:comment_deleted': embedHandler((body) => + pullRequestComment(body, 'Deleted comment on pull request'), + ), + 'pullrequest:changes_request_created': coloredPullRequestHandler( + 'Changes requested for pull request', + 0xffa500, + ), + 'pullrequest:changes_request_removed': embedHandler((body) => + pullRequest(body, 'Removed changes requested for pull request'), + ), + }, +}) + +function embedHandler(formatter: (body: Record) => Embed): ProviderMapper { + return ({ body }, output) => output.addEmbed(formatter(body)) +} - public knownTypes(): string[] { - return [ - 'repoPush', - 'repoFork', - 'repoUpdated', - 'repoCommitCommentCreated', - 'repoCommitStatusCreated', - 'repoCommitStatusUpdated', - 'issueCreated', - 'issueUpdated', - 'issueCommentCreated', - 'pullrequestCreated', - 'pullrequestUpdated', - 'pullrequestApproved', - 'pullrequestUnapproved', - 'pullrequestFulfilled', - 'pullrequestRejected', - 'pullrequestCommentCreated', - 'pullrequestCommentUpdated', - 'pullrequestCommentDeleted', - 'pullrequestChangesRequestCreated', - 'pullrequestChangesRequestRemoved', - ] +function coloredPullRequestHandler(title: string, color: number): ProviderMapper { + return ({ body }, output) => { + output.setEmbedColor(color) + output.addEmbed(pullRequest(body, title)) } +} - public async repoPush(): Promise { - if (this.body.push != null && this.body.push.changes != null) { - for (let i = 0; i < this.body.push.changes.length && i < 4; i++) { - const change = this.body.push.changes[i] - const embed: Embed = {} - - if (change.new == null && change.old.type === 'branch') { - // Branch Deleted - embed.title = '[' + this.body.repository.full_name + '] Branch deleted: ' + change.old.name - } else if (change.old == null && change.new.type === 'branch') { - // Branch Created - embed.title = '[' + this.body.repository.full_name + '] New branch created: ' + change.new.name - embed.url = change.new.links.html.href - } else if (change.old == null && change.new.type === 'tag') { - // Tag Created - embed.title = '[' + this.body.repository.full_name + '] New tag created: ' + change.new.name - embed.url = change.new.links.html.href - } else if (change.new == null && change.old.type === 'tag') { - // Tag Deleted - embed.title = '[' + this.body.repository.full_name + '] Tag deleted: ' + change.old.name - } else { - // Just some commits. - const branch = change.new.name - const commits = change.commits - - const fields: EmbedField[] = [] - let title = `[${this.body.repository.name}]:${branch} ` - if (commits != null) { - title += commits.length + ' commit' + (commits.length > 1 ? 's' : '') - for (let j = commits.length - 1; j >= 0; j--) { - const commit = commits[j] - const message = - commit.message.length > 256 - ? commit.message.substring(0, 255) + '\u2026' - : commit.message - const author = - typeof commit.author.user !== 'undefined' ? commit.author.user.display_name : 'Unknown' - fields.push({ - name: 'Commit from ' + author, - value: - '(' + - '[`' + - commit.hash.substring(0, 7) + - '`](' + - commit.links.html.href + - ')' + - ') ' + - message.replace(/\n/g, ' ').replace(/\r/g, ' '), - }) - } - } - embed.title = title - embed.url = change.links.html.href - embed.fields = fields +function repoPush({ body }: { body: Record }, output: { addEmbed(embed: Embed): void }): void { + if (body.push?.changes == null) return + for (const change of body.push.changes.slice(0, 4)) { + const embed: Embed = { author: author(body) } + if (change.new == null && change.old.type === 'branch') { + embed.title = `[${body.repository.full_name}] Branch deleted: ${change.old.name}` + } else if (change.old == null && change.new.type === 'branch') { + embed.title = `[${body.repository.full_name}] New branch created: ${change.new.name}` + embed.url = change.new.links.html.href + } else if (change.old == null && change.new.type === 'tag') { + embed.title = `[${body.repository.full_name}] New tag created: ${change.new.name}` + embed.url = change.new.links.html.href + } else if (change.new == null && change.old.type === 'tag') { + embed.title = `[${body.repository.full_name}] Tag deleted: ${change.old.name}` + } else { + const commits = change.commits + const fields: EmbedField[] = [] + let title = `[${body.repository.name}]:${change.new.name} ` + if (commits != null) { + title += `${commits.length} commit${commits.length > 1 ? 's' : ''}` + for (let index = commits.length - 1; index >= 0; index--) { + const commit = commits[index] + const message = formatLargeString(commit.message) + const commitAuthor = commit.author.user?.display_name ?? 'Unknown' + fields.push({ + name: `Commit from ${commitAuthor}`, + value: `([\`${commit.hash.substring(0, 7)}\`](${commit.links.html.href})) ${message.replace(/\n/g, ' ').replace(/\r/g, ' ')}`, + }) } - - embed.author = this.extractAuthor() - this.addEmbed(embed) } + embed.title = title + embed.url = change.links.html.href + embed.fields = fields } + output.addEmbed(embed) } +} - public async repoFork(): Promise { - this.embed.author = this.extractAuthor() - this.embed.description = - 'Created a [`fork`](' + - this.baseLink + - this.body.fork.full_name + - ') of [`' + - this.body.repository.name + - '`](' + - this.baseLink + - this.body.repository.full_name + - ')' - this.addEmbed(this.embed) +function repoFork(body: Record): Embed { + return { + author: author(body), + description: `Created a [\`fork\`](${BASE_LINK}${body.fork.full_name}) of [\`${body.repository.name}\`](${BASE_LINK}${body.repository.full_name})`, } +} - public async repoUpdated(): Promise { - const changes: string[] = [] - if (typeof this.body.changes.name !== 'undefined') { - changes.push('**Name:** "' + this.body.changes.name.old + '" -> "' + this.body.changes.name.new + '"') - } - if (typeof this.body.changes.website !== 'undefined') { - changes.push( - '**Website:** "' + this.body.changes.website.old + '" -> "' + this.body.changes.website.new + '"', - ) +function repoUpdated(body: Record): Embed { + const changes: string[] = [] + for (const property of ['name', 'website', 'language', 'description']) { + if (body.changes[property] !== undefined) { + const label = titleCase(property) + changes.push(`**${label}:** "${body.changes[property].old}" -> "${body.changes[property].new}"`) } - if (typeof this.body.changes.language !== 'undefined') { - changes.push( - '**Language:** "' + this.body.changes.language.old + '" -> "' + this.body.changes.language.new + '"', - ) - } - if (typeof this.body.changes.description !== 'undefined') { - changes.push( - '**Description:** "' + - this.body.changes.description.old + - '" -> "' + - this.body.changes.description.new + - '"', - ) - } - - this.embed.author = this.extractAuthor() - this.embed.url = this.baseLink + this.body.repository.full_name - this.embed.description = changes.join('\n') - this.embed.title = `[${this.body.repository.full_name}] General information updated` - - this.addEmbed(this.embed) } - - public async repoCommitCommentCreated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] New comment on commit \`${this.body.commit.hash.substring(0, 7)}\`` - this.embed.description = - this.body.comment.content.html.replace(/<.*?>/g, '').length > 1024 - ? this.body.comment.content.html.replace(/<.*?>/g, '').substring(0, 1023) + '\u2026' - : this.body.comment.content.html.replace(/<.*?>/g, '') - this.embed.url = this.baseLink + this.body.repository.full_name + '/commits/' + this.body.commit.hash - this.addEmbed(this.embed) + return { + author: author(body), + title: `[${body.repository.full_name}] General information updated`, + url: BASE_LINK + body.repository.full_name, + description: changes.join('\n'), } +} - public async repoCommitStatusCreated(): Promise { - this.embed.title = this.body.commit_status.name - this.embed.description = - '**State:** ' + this.body.commit_status.state + '\n' + this.body.commit_status.description - this.embed.url = this.body.commit_status.url - this.addEmbed(this.embed) +function repoCommitComment(body: Record): Embed { + return { + author: author(body), + title: `[${body.repository.full_name}] New comment on commit \`${body.commit.hash.substring(0, 7)}\``, + description: formatHtmlText(body.comment.content.html), + url: `${BASE_LINK}${body.repository.full_name}/commits/${body.commit.hash}`, } +} - public async repoCommitStatusUpdated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = this.body.commit_status.name - this.embed.url = this.body.commit_status.url - this.embed.description = - '**State:** ' + this.body.commit_status.state + '\n' + this.body.commit_status.description - this.addEmbed(this.embed) +function commitStatus(body: Record, includeAuthor: boolean): Embed { + return { + ...(includeAuthor ? { author: author(body) } : {}), + title: body.commit_status.name, + description: `**State:** ${body.commit_status.state}\n${body.commit_status.description}`, + url: body.commit_status.url, } +} - public async issueCreated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Issue opened: #${this.body.issue.id} ${this.body.issue.title}` - this.embed.url = this.extractIssueUrl() - - const states: string[] = [] - if (this.body.issue.assignee != null && this.body.issue.assignee.display_name != null) { - states.push( - '**Assignee:** ' + - '[`' + - this.body.issue.assignee.display_name + - '`](' + - this.body.issue.assignee.links.html.href + - ')', - ) - } - - states.push('**State:** `' + BitBucket._titleCase(this.body.issue.state) + '`') - states.push('**Kind:** `' + BitBucket._titleCase(this.body.issue.kind) + '`') - states.push('**Priority:** `' + BitBucket._titleCase(this.body.issue.priority) + '`') - - if (this.body.issue.component != null && this.body.issue.component.name != null) { - states.push('**Component:** `' + BitBucket._titleCase(this.body.issue.component.name) + '`') - } - - if (this.body.issue.milestone != null && this.body.issue.milestone.name != null) { - states.push('**Milestone:** `' + BitBucket._titleCase(this.body.issue.milestone.name) + '`') - } - - if (this.body.issue.version != null && this.body.issue.version.name != null) { - states.push('**Version:** `' + BitBucket._titleCase(this.body.issue.version.name) + '`') - } - - if (this.body.issue.content.raw) { - states.push( - '**Content:**\n' + - MarkdownUtil._formatMarkdown(BitBucket._formatLargeString(this.body.issue.content.raw), this.embed), - ) - } - - this.embed.description = states.join('\n') - - this.addEmbed(this.embed) +function issueCreated(body: Record): Embed { + const embed: Embed = { + author: author(body), + title: `[${body.repository.full_name}] Issue opened: #${body.issue.id} ${body.issue.title}`, + url: issueUrl(body), } - - public async issueUpdated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Issue updated: #${this.body.issue.id} ${this.body.issue.title}` - this.embed.url = this.extractIssueUrl() - const changes = [] - - if (typeof this.body.changes !== 'undefined') { - const states = ['old', 'new'] - - const labels = ['Assignee', 'Responsible'] - labels.forEach((label) => { - const actor = this.body.changes[label.toLowerCase()] - - if (actor == null) { - return - } - - const actorNames: { [state: string]: string } = {} - const unassigned = '`Unassigned`' - - states.forEach((state) => { - if (actor[state] != null && actor[state].username != null) { - actorNames[state] = - '[`' + actor[state].display_name + '`](' + actor[state].links.html.href + ')' - } else { - actorNames[state] = unassigned - } - }) - - if ( - !Object.keys(actorNames).length || - (actorNames.old === unassigned && actorNames.new === unassigned) - ) { - return - } - - changes.push('**' + label + ':** ' + actorNames.old + ' \uD83E\uDC6A ' + actorNames.new) - }) - - ;['Kind', 'Priority', 'Status', 'Component', 'Milestone', 'Version'].forEach((label) => { - const property = this.body.changes[label.toLowerCase()] - - if (typeof property !== 'undefined') { - changes.push( - '**' + - label + - ':** `' + - BitBucket._titleCase(property.old) + - '` \uD83E\uDC6A `' + - BitBucket._titleCase(property.new) + - '`', - ) - } - }) - - { - const label = 'Content' - const property = this.body.changes[label.toLowerCase()] - - if (typeof property !== 'undefined') { - changes.push( - '**New ' + - label + - ':** \n' + - MarkdownUtil._formatMarkdown(BitBucket._formatLargeString(property.new), this.embed), - ) - } - } - } - - this.embed.description = changes.join('\n') - - this.addEmbed(this.embed) + const states: string[] = [] + if (body.issue.assignee?.display_name != null) { + states.push(`**Assignee:** [\`${body.issue.assignee.display_name}\`](${body.issue.assignee.links.html.href})`) } - - public async issueCommentCreated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] New comment on issue #${this.body.issue.id}: ${this.body.issue.title}` - this.embed.url = this.extractIssueUrl() - this.embed.description = MarkdownUtil._formatMarkdown( - BitBucket._formatLargeString(this.body.comment.content.raw), - this.embed, - ) - this.addEmbed(this.embed) + states.push(`**State:** \`${titleCase(body.issue.state)}\``) + states.push(`**Kind:** \`${titleCase(body.issue.kind)}\``) + states.push(`**Priority:** \`${titleCase(body.issue.priority)}\``) + for (const property of ['component', 'milestone', 'version']) { + if (body.issue[property]?.name != null) { + states.push(`**${titleCase(property)}:** \`${titleCase(body.issue[property].name)}\``) + } } - - public async pullrequestCreated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Pull request opened: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.embed.description = this.body.pullrequest.description - this.embed.fields = [this.extractPullRequestField()] - this.addEmbed(this.embed) + if (body.issue.content.raw) { + states.push(`**Content:**\n${MarkdownUtil._formatMarkdown(formatLargeString(body.issue.content.raw), embed)}`) } + embed.description = states.join('\n') + return embed +} - public async pullrequestUpdated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Updated pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.embed.description = this.body.pullrequest.description - this.embed.fields = [this.extractPullRequestField()] - this.addEmbed(this.embed) +function issueUpdated(body: Record): Embed { + const embed: Embed = { + author: author(body), + title: `[${body.repository.full_name}] Issue updated: #${body.issue.id} ${body.issue.title}`, + url: issueUrl(body), } - - public async pullrequestApproved(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Approved pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.setEmbedColor(0x2db83d) - this.addEmbed(this.embed) + const changes: string[] = [] + if (body.changes !== undefined) { + for (const label of ['Assignee', 'Responsible']) { + const actor = body.changes[label.toLowerCase()] + if (actor == null) continue + const oldActor = + actor.old?.username != null + ? `[\`${actor.old.display_name}\`](${actor.old.links.html.href})` + : '`Unassigned`' + const newActor = + actor.new?.username != null + ? `[\`${actor.new.display_name}\`](${actor.new.links.html.href})` + : '`Unassigned`' + if (oldActor !== '`Unassigned`' || newActor !== '`Unassigned`') { + changes.push(`**${label}:** ${oldActor} 🡪 ${newActor}`) + } + } + for (const label of ['Kind', 'Priority', 'Status', 'Component', 'Milestone', 'Version']) { + const property = body.changes[label.toLowerCase()] + if (property !== undefined) { + changes.push(`**${label}:** \`${titleCase(property.old)}\` 🡪 \`${titleCase(property.new)}\``) + } + } + const content = body.changes.content + if (content !== undefined) { + changes.push(`**New Content:** \n${MarkdownUtil._formatMarkdown(formatLargeString(content.new), embed)}`) + } } + embed.description = changes.join('\n') + return embed +} - public async pullrequestUnapproved(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Removed approval for pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.addEmbed(this.embed) +function issueCommentCreated(body: Record): Embed { + const embed: Embed = { + author: author(body), + title: `[${body.repository.full_name}] New comment on issue #${body.issue.id}: ${body.issue.title}`, + url: issueUrl(body), } + embed.description = MarkdownUtil._formatMarkdown(formatLargeString(body.comment.content.raw), embed) + return embed +} - public async pullrequestFulfilled(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Merged pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.addEmbed(this.embed) +function pullRequestWithDetails(body: Record, title: string): Embed { + return { + ...pullRequest(body, title), + description: body.pullrequest.description, + fields: [pullRequestField(body)], } +} - public async pullrequestRejected(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Rejected pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.embed.description = - typeof this.body.pullrequest.reason !== 'undefined' - ? this.body.pullrequest.reason.replace(/<.*?>/g, '').length > 1024 - ? this.body.pullrequest.reason.replace(/<.*?>/g, '').substring(0, 1023) + '\u2026' - : this.body.pullrequest.reason.replace(/<.*?>/g, '') - : '' - this.addEmbed(this.embed) +function pullRequest(body: Record, title: string): Embed { + return { + author: author(body), + title: `[${body.repository.full_name}] ${title}: #${body.pullrequest.id} ${body.pullrequest.title}`, + url: pullRequestUrl(body), } +} - public async pullrequestCommentCreated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] New comment on pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.embed.description = - this.body.comment.content.html.replace(/<.*?>/g, '').length > 1024 - ? this.body.comment.content.html.replace(/<.*?>/g, '').substring(0, 1023) + '\u2026' - : this.body.comment.content.html.replace(/<.*?>/g, '') - this.addEmbed(this.embed) +function pullRequestRejected(body: Record): Embed { + return { + ...pullRequest(body, 'Rejected pull request'), + description: body.pullrequest.reason === undefined ? '' : formatHtmlText(body.pullrequest.reason), } +} - public async pullrequestCommentUpdated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Updated comment on pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.embed.description = - this.body.comment.content.html.replace(/<.*?>/g, '').length > 1024 - ? this.body.comment.content.html.replace(/<.*?>/g, '').substring(0, 1023) + '\u2026' - : this.body.comment.content.html.replace(/<.*?>/g, '') - this.addEmbed(this.embed) +function pullRequestComment(body: Record, title: string): Embed { + return { + ...pullRequest(body, title), + description: formatHtmlText(body.comment.content.html), } +} - public async pullrequestCommentDeleted(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Deleted comment on pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.description = - this.body.comment.content.html.replace(/<.*?>/g, '').length > 1024 - ? this.body.comment.content.html.replace(/<.*?>/g, '').substring(0, 1023) + '\u2026' - : this.body.comment.content.html.replace(/<.*?>/g, '') - this.embed.url = this.extractPullRequestUrl() - this.addEmbed(this.embed) +function author(body: Record): EmbedAuthor { + const result: EmbedAuthor = { name: body.actor.display_name } + if (body.actor.links === undefined) { + result.icon_url = 'http://i0.wp.com/avatar-cdn.atlassian.com/default/96.png' + result.url = '' + } else { + result.icon_url = body.actor.links.avatar.href + result.url = BASE_LINK + body.actor.username } + return result +} - public async pullrequestChangesRequestCreated(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Changes requested for pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.setEmbedColor(0xffa500) - this.addEmbed(this.embed) - } +function pullRequestUrl(body: Record): string { + return `${BASE_LINK}${body.repository.full_name}/pull-requests/${body.pullrequest.id}` +} - public async pullrequestChangesRequestRemoved(): Promise { - this.embed.author = this.extractAuthor() - this.embed.title = `[${this.body.repository.full_name}] Removed changes requested for pull request: #${this.body.pullrequest.id} ${this.body.pullrequest.title}` - this.embed.url = this.extractPullRequestUrl() - this.addEmbed(this.embed) +function pullRequestField(body: Record): EmbedField { + return { + name: body.pullrequest.title, + value: `**Destination branch:** ${body.pullrequest.destination.branch.name}\n**State:** ${body.pullrequest.state}\n`, } +} - private extractAuthor(): EmbedAuthor { - const author: EmbedAuthor = { - name: this.body.actor.display_name, - } - if (this.body.actor.links === undefined) { - author.icon_url = 'http://i0.wp.com/avatar-cdn.atlassian.com/default/96.png' - author.url = '' - } else { - author.icon_url = this.body.actor.links.avatar.href - author.url = this.baseLink + this.body.actor.username - } - return author - } +function issueUrl(body: Record): string { + return `${BASE_LINK}${body.repository.full_name}/issues/${body.issue.id}` +} - private extractPullRequestUrl(): string { - return this.baseLink + this.body.repository.full_name + '/pull-requests/' + this.body.pullrequest.id - } +function formatLargeString(value: string, limit = 256): string { + return value.length > limit ? value.substring(0, limit - 1) + '\u2026' : value +} - private extractPullRequestField(): EmbedField { - return { - name: this.body.pullrequest.title, - value: - '**Destination branch:** ' + - this.body.pullrequest.destination.branch.name + - '\n' + - '**State:** ' + - this.body.pullrequest.state + - '\n', - } - } +function formatHtmlText(html: string): string { + return formatLargeString(html.replace(/<.*?>/g, ''), 1024) +} - private extractIssueUrl(): string { - return this.baseLink + this.body.repository.full_name + '/issues/' + this.body.issue.id - } +function titleCase(value: string | null, ifNull = 'None'): string { + if (value == null) return ifNull + return value + .toLowerCase() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') } diff --git a/src/provider/Buildkite.ts b/src/provider/Buildkite.ts index 5e64104..08a1eb5 100644 --- a/src/provider/Buildkite.ts +++ b/src/provider/Buildkite.ts @@ -6,8 +6,15 @@ import { SKYHOOK_FOOTER_TEXT, } from '../util/DiscordEmbed.ts' import { cleanText, escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts' -import { canonicalizeIso8601Timestamp, isRecord } from '../util/WebhookValue.ts' -import { DirectParseProvider } from './BaseProvider.ts' +import { + firstScalar, + firstIso8601Timestamp as firstTimestamp, + isRecord, + safeIntegerText, + scalarText, + trustedHttpsUrl, +} from '../util/WebhookValue.ts' +import { defineProvider } from './Provider.ts' const BUILDKITE_GREEN = 0x14cc80 const BUILDKITE_BLUE = 0x2196f3 @@ -32,75 +39,51 @@ interface ParsedEvent { * * @see https://buildkite.com/docs/apis/webhooks */ -export class Buildkite extends DirectParseProvider { - public constructor() { - super() - this.setEmbedColor(BUILDKITE_GREEN) - this.payload.username = 'Buildkite' - this.payload.allowed_mentions = { parse: [] } - } - - public getName(): string { - return 'Buildkite' - } - - public getPath(): string { - return 'buildkite' - } - - public async parseData(): Promise { - if (!isRecord(this.body)) { - this.nullifyPayload() - return - } - - const event = boundedEvent(this.body.event) - const headerValue = getHeaderValue(this.headers, 'x-buildkite-event') +export const Buildkite = defineProvider({ + path: 'buildkite', + name: 'Buildkite', + example: { + body: 'buildkite/buildkite.json', + headers: 'buildkite/buildkite.headers.json', + }, + defaults: { + username: 'Buildkite', + embedColor: BUILDKITE_GREEN, + }, + map({ body, headers }, output) { + const event = boundedEvent(body.event) + const headerValue = getHeaderValue(headers, 'x-buildkite-event') const headerEvent = headerValue === undefined ? undefined : boundedEvent(headerValue) if (event == null || (headerValue !== undefined && headerEvent !== event)) { - this.nullifyPayload() + output.ignore() return } - const parsed = this.parseEvent(event) + const parsed = parseEvent(event, body) if (parsed == null) { - this.nullifyPayload() + output.ignore() return } - const author = senderAuthor(this.body.sender) + const author = senderAuthor(body.sender) if (author != null) { parsed.embed.author = author } parsed.embed.fields = fitEscapedFieldsWithinAggregateLimit(parsed.embed) - this.setEmbedColor(statusColor(parsed.status)) - this.addEmbed(parsed.embed) - } - - private parseEvent(event: string): ParsedEvent | null { - if (event.startsWith('build.')) { - return parseBuildEvent(event, this.body) - } - if (event.startsWith('job.')) { - return parseJobEvent(event, this.body) - } - if (event.startsWith('agent.')) { - return parseAgentEvent(event, this.body) - } - if (event === 'cluster_token.registration_blocked') { - return parseBlockedRegistrationEvent(this.body) - } - if (event === 'ping') { - return parsePingEvent(this.body) - } - if (event.startsWith('package.')) { - return parsePackageEvent(event, this.body) - } - if (event.startsWith('workflow.')) { - return parseWorkflowEvent(event, this.body) - } - return parseGenericEvent(event, this.body) - } + output.setEmbedColor(statusColor(parsed.status)) + output.addEmbed(parsed.embed) + }, +}) + +function parseEvent(event: string, body: Record): ParsedEvent | null { + if (event.startsWith('build.')) return parseBuildEvent(event, body) + if (event.startsWith('job.')) return parseJobEvent(event, body) + if (event.startsWith('agent.')) return parseAgentEvent(event, body) + if (event === 'cluster_token.registration_blocked') return parseBlockedRegistrationEvent(body) + if (event === 'ping') return parsePingEvent(body) + if (event.startsWith('package.')) return parsePackageEvent(event, body) + if (event.startsWith('workflow.')) return parseWorkflowEvent(event, body) + return parseGenericEvent(event, body) } function parseBuildEvent(event: string, body: Record): ParsedEvent | null { @@ -379,16 +362,6 @@ function agentTimestamp(action: string, agent: Record): string | nu return firstTimestamp(timestampByAction[action], agent.created_at) } -function firstTimestamp(...values: unknown[]): string | null { - for (const value of values) { - const timestamp = canonicalizeIso8601Timestamp(value) - if (timestamp != null) { - return timestamp - } - } - return null -} - function setTrustedUrl(embed: Embed, ...values: unknown[]): void { for (const value of values) { const url = trustedBuildkiteUrl(value) @@ -400,21 +373,11 @@ function setTrustedUrl(embed: Embed, ...values: unknown[]): void { } function trustedBuildkiteUrl(value: unknown): string | null { - if (typeof value !== 'string' || value.length === 0 || value.length > MAX_URL_CHARACTERS) { - return null - } - try { - const url = new URL(value) - if ( - url.protocol !== 'https:' || - (url.hostname !== 'buildkite.com' && !url.hostname.endsWith('.buildkite.com')) - ) { - return null - } - return url.href.length <= MAX_URL_CHARACTERS ? url.href : null - } catch { - return null - } + return trustedHttpsUrl(value, { + allowedHosts: ['buildkite.com'], + allowSubdomains: true, + maxLength: MAX_URL_CHARACTERS, + }) } function getHeaderValue(headers: unknown, name: string): unknown | undefined { @@ -450,36 +413,8 @@ function boundedText(value: unknown, maxLength: number, singleLine: boolean): st return text.length > 0 && text.length <= maxLength ? text : null } -function scalarText(value: unknown): string | null { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - return null - } - if ( - typeof value === 'number' && - (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) - ) { - return null - } - const text = cleanText(String(value), false) - return text.length === 0 ? null : text -} - -function firstScalar(...values: unknown[]): string | null { - for (const value of values) { - const result = scalarText(value) - if (result != null) { - return result - } - } - return null -} - -function safeIntegerText(value: unknown): string | null { - return Number.isSafeInteger(value) ? String(value) : null -} - function positiveIntegerText(value: unknown): string | null { - return Number.isSafeInteger(value) && Number(value) > 0 ? String(value) : null + return safeIntegerText(value, true) } function literal(value: string, maxLength: number, singleLine: boolean): string { diff --git a/src/provider/CircleCi.ts b/src/provider/CircleCi.ts index 7b8f2ec..288a4dd 100644 --- a/src/provider/CircleCi.ts +++ b/src/provider/CircleCi.ts @@ -1,24 +1,22 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://circleci.com/docs/2.0/webhooks */ -export class CircleCi extends DirectParseProvider { - public getName(): string { - return 'CircleCi' - } - - public async parseData(): Promise { - this.setEmbedColor(0x343433) - - const sha = this.body.pipeline.vcs.revision - const project = this.body.project.name - const subject = this.body.pipeline.vcs.commit.subject - const committer = this.body.pipeline.vcs.commit.author.name - const status = this.body.workflow.status - const url = this.body.workflow.url - const number = this.body.pipeline.number +export const CircleCi = defineProvider({ + path: 'circleci', + name: 'CircleCi', + example: { body: 'circleci/circleci.json' }, + defaults: { embedColor: 0x343433 }, + map({ body }, output) { + const sha = body.pipeline.vcs.revision + const project = body.project.name + const subject = body.pipeline.vcs.commit.subject + const committer = body.pipeline.vcs.commit.author.name + const status = body.workflow.status + const url = body.workflow.url + const number = body.pipeline.number let description = '' if (sha != null) { description += `[${sha.slice(0, 7)}]` @@ -34,13 +32,12 @@ export class CircleCi extends DirectParseProvider { } const embed: Embed = { title: `Pipeline #${number}`, - url: url, - description: description, + url, + description, author: { name: committer, }, } - - this.addEmbed(embed) - } -} + output.addEmbed(embed) + }, +}) diff --git a/src/provider/Codacy.ts b/src/provider/Codacy.ts index ff80b8b..475a56f 100644 --- a/src/provider/Codacy.ts +++ b/src/provider/Codacy.ts @@ -1,37 +1,35 @@ import type { Embed, EmbedField } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://support.codacy.com/hc/en-us/articles/207280359-WebHook-Notifications */ -export class Codacy extends DirectParseProvider { - public getName(): string { - return 'Codacy' - } - - public async parseData(): Promise { - this.setEmbedColor(0x242c33) - const embed: Embed = {} - embed.title = 'New Commit' - embed.url = this.body.commit.data.urls.delta +export const Codacy = defineProvider({ + path: 'codacy', + name: 'Codacy', + example: { body: 'codacy/codacy.json' }, + defaults: { embedColor: 0x242c33 }, + map({ body }, output) { + const embed: Embed = { + title: 'New Commit', + url: body.commit.data.urls.delta, + } const fields: EmbedField[] = [] - // Results undefined with PR. - if (this.body.commit.results != null) { + // Results are undefined for pull requests. + if (body.commit.results != null) { fields.push({ name: 'Fixed Issues', - value: String(this.body.commit.results.fixed_count || 0), + value: String(body.commit.results.fixed_count || 0), inline: true, }) - fields.push({ name: 'New Issues', - value: String(this.body.commit.results.new_count || 0), + value: String(body.commit.results.new_count || 0), inline: true, }) } embed.fields = fields - - this.addEmbed(embed) - } -} + output.addEmbed(embed) + }, +}) diff --git a/src/provider/Confluence.ts b/src/provider/Confluence.ts index 6e9dcfa..298c8a9 100644 --- a/src/provider/Confluence.ts +++ b/src/provider/Confluence.ts @@ -1,305 +1,143 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** - * https://developer.atlassian.com/server/jira/platform/webhooks/ + * https://developer.atlassian.com/server/confluence/webhooks/ */ -export class Confluence extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0x1e45a8) - } - - public getName(): string { - return 'Confluence' - } - - public getPath(): string { - return 'confluence' - } - - public async parseData(): Promise { - if (this.body.eventType == null) { - this.nullifyPayload() +export const Confluence = defineProvider({ + path: 'confluence', + name: 'Confluence', + example: { body: 'confluence/confluence_page.json' }, + defaults: { embedColor: 0x1e45a8 }, + map({ body }, output) { + const event = typeof body.eventType === 'string' ? body.eventType : null + if (event == null) { + output.ignore() return } - // extract variables from Confluence that don't depend on the event - const user = this.body.userDisplayName || { displayName: 'Anonymous' } - const event = this.body.eventType - let embed: Embed - - if (event.startsWith('attachment_')) { - embed = this.attachmentEvent(event, user) - this.addEmbed(embed) - } else if (event.startsWith('blog_')) { - embed = this.blogEvent(event, user) - this.addEmbed(embed) - } else if (event.startsWith('comment_')) { - embed = this.commentEvent(event, user) - this.addEmbed(embed) - } else if (event.startsWith('group_')) { - embed = this.groupEvent(event) - this.addEmbed(embed) - } else if (event.startsWith('label_')) { - embed = this.labelEvent(event, user) - this.addEmbed(embed) - } else if (event.startsWith('page_')) { - embed = this.pageEvent(event, user) - this.addEmbed(embed) - } else if (event.startsWith('space_')) { - embed = this.spaceEvent(event, user) - this.addEmbed(embed) - } else if (event.startsWith('user_')) { - embed = this.userEvent(event) - this.addEmbed(embed) - } else { - // This is to nullify the payload for currently unsupported events - this.nullifyPayload() + const user = typeof body.userDisplayName === 'string' ? body.userDisplayName : 'Anonymous' + const embed = formatEvent(event, body, user) + if (embed == null) { + output.ignore() return } - } - - private attachmentEvent(event: string, user: string): Embed { - /** - * Event Triggered when... - attachment_created a file is attached to a page or blog post - attachment_removed a file is deleted (sent to the trash) from the attachments page(not triggered when a version is deleted from the file history) - attachment_restored a file is restored from the trash - attachment_trashed a file is purged from the trash - attachment_updated a new file version of is uploaded directly or by editing the file - - */ - - const title = this.setEventTitle(event) - const action = this.setActionTitle(event) - const content_title = this.body.attachedTo.title - const space = this.body.attachedTo.spaceName - const content_type = this.body.attachedTo.contentType - const url = this.body.attachedTo.self - let description: string - - if (event.startsWith('attachment_removed')) { - description = user + ' ' + action + ' from ' + content_type + ' ' + content_title + ' in ' + space - } else if (event.startsWith('attachment_created') || event.startsWith('attachment_updated')) { - description = user + ' ' + action + ' on ' + content_type + ' ' + content_title + ' in ' + space - } else { - description = user + ' ' + action - } - - const embed: Embed = { - title: title, - url: url, - description: description, - } + output.addEmbed(embed) + }, +}) + +function formatEvent(event: string, body: Record, user: string): Embed | null { + if (event.startsWith('attachment_')) return attachmentEvent(event, body, user) + if (event.startsWith('blog_')) return blogEvent(event, body, user) + if (event.startsWith('comment_')) return commentEvent(event, body, user) + if (event.startsWith('group_')) return groupEvent(event, body) + if (event.startsWith('label_')) return labelEvent(event, body, user) + if (event.startsWith('page_')) return pageEvent(event, body, user) + if (event.startsWith('space_')) return spaceEvent(event, body, user) + if (event.startsWith('user_')) return userEvent(event, body) + return null +} - return embed +function attachmentEvent(event: string, body: Record, user: string): Embed { + const action = actionTitle(event) + const contentTitle = body.attachedTo.title + const space = body.attachedTo.spaceName + const contentType = body.attachedTo.contentType + let description: string + + if (event.startsWith('attachment_removed')) { + description = `${user} ${action} from ${contentType} ${contentTitle} in ${space}` + } else if (event.startsWith('attachment_created') || event.startsWith('attachment_updated')) { + description = `${user} ${action} on ${contentType} ${contentTitle} in ${space}` + } else { + description = `${user} ${action}` } - private blogEvent(event: string, user: string): Embed { - /** - * Event Triggered when... - blog_created a blog post is published - blog_removed a blog post is deleted (sent to the trash) - blog_restored a blog post is restored from the trash - blog_trashed a blog post is purged from the trash - blog_updated a blog post is edited - */ - const title = this.setEventTitle(event) - const action = this.setActionTitle(event) - const content_title = this.body.blog.title - const url = this.body.blog.self - - const embed: Embed = { - title: title, - url: url, - description: user + ' ' + action + ' ' + content_title, - } - - return embed + return { + title: eventTitle(event), + url: body.attachedTo.self, + description, } +} - private commentEvent(event: string, user: string): Embed { - /** - * Event Triggered when... - comment_created a page comment, inline comment or file comment is made - comment_removed a page comment, inline comment, or file comment is deleted - comment_updated a page comment, inline comment, or file comment is edited - - */ - const title = this.setEventTitle(event) - const action = this.setActionTitle(event) - const content_title = this.body.comment.parent.title - const content_type = this.body.comment.parent.contentType - const space = this.body.comment.spaceName - const url = this.body.comment.parent.self - - const embed: Embed = { - title: title, - url: url, - description: user + ' ' + action + ' on ' + content_type + ' ' + content_title + ' in ' + space, - } - - return embed +function blogEvent(event: string, body: Record, user: string): Embed { + return { + title: eventTitle(event), + url: body.blog.self, + description: `${user} ${actionTitle(event)} ${body.blog.title}`, } +} - private labelEvent(event: string, user: string): Embed { - /** - * Event Triggered when... - label_added an existing label is applied to a page, blog post, or space - label_created a label is added for the first time (did not already exist) - label_deleted a label is removed from the last page, blog post, or space, and so ceases to exist - label_removed a label is removed from a page, blog post, or space - - */ - const title = this.setEventTitle(event) - const action = this.setActionTitle(event) - const label_title = this.body.label.name - const space = this.body.labeled.spaceName - const url = this.body.label.self - let description: string | undefined - let content_title: string | undefined - let content_type: string | undefined - - if (event.startsWith('label_created') || event.startsWith('label_deleted')) { - description = user + ' ' + action + ' ' + label_title - } else if (event.startsWith('label_removed')) { - content_title = this.body.labeled.title - content_type = this.body.labeled.contentType - description = user + ' ' + action + ' from ' + content_type + ' ' + content_title + ' in ' + space - } else if (event.startsWith('label_added')) { - content_title = this.body.labeled.title - content_type = this.body.labeled.contentType - description = user + ' ' + action + ' to ' + content_type + ' ' + content_title + ' in ' + space - } - - const embed: Embed = { - title: title, - url: url, - description: description, - } - - return embed +function commentEvent(event: string, body: Record, user: string): Embed { + const parent = body.comment.parent + return { + title: eventTitle(event), + url: parent.self, + description: `${user} ${actionTitle(event)} on ${parent.contentType} ${parent.title} in ${body.comment.spaceName}`, } +} - private pageEvent(event: string, user: string): Embed { - /** - * Event Triggered when... - page_children_reordered the default ordering of pages is changed to alphabetical in the Space Tools > Reorder pages tab(is not triggered when you drag a page, or move a page, to change the page order) - page_created a page is published for the first time, including pages created from a template or blueprint - page_moved a page is moved to a different position in the page tree, to a different parent page, or to another space - page_removed a page is deleted (sent to the trash) - page_restored a page is restored from the trash - page_trashed a page is purged from the trash - page_updated a page is edited (triggered at the point the unpublished changes are published) - - */ - const title = this.setEventTitle(event) - const action = this.setActionTitle(event) - const content_title = this.body.page.title - const url = this.body.page.self - - const embed: Embed = { - title: title, - url: url, - description: user + ' ' + action + ' ' + content_title, - } - - return embed +function labelEvent(event: string, body: Record, user: string): Embed { + const action = actionTitle(event) + const label = body.label.name + const labeled = body.labeled + let description: string | undefined + + if (event.startsWith('label_created') || event.startsWith('label_deleted')) { + description = `${user} ${action} ${label}` + } else if (event.startsWith('label_removed')) { + description = `${user} ${action} from ${labeled.contentType} ${labeled.title} in ${labeled.spaceName}` + } else if (event.startsWith('label_added')) { + description = `${user} ${action} to ${labeled.contentType} ${labeled.title} in ${labeled.spaceName}` } - private spaceEvent(event: string, user: string): Embed { - /** - * Event Triggered when... - space_created a new space is created - space_logo_updated a new file is uploaded for use as the logo of a space - space_permissions_updated space permissions are changed in the Space Tools > Permissions tab(is not triggered when you edit space permissions using Inspect Permissions) - space_removed a space is deleted - space_updated the space details (title, description, status) is updated in the Space Tools > Overview tab - - */ - - const title = this.setEventTitle(event) - const action = this.setActionTitle(event) - const content_title = this.body.space.title - const url = this.body.space.self - - const embed: Embed = { - title: title, - url: url, - description: user + ' ' + action + ' ' + content_title, - } - - return embed + return { + title: eventTitle(event), + url: body.label.self, + description, } +} - private userEvent(event: string): Embed { - /** - * Event Triggered when... - user_created a new user account is created - user_deactivated a user account is disabled - user_followed someone follows a user - user_reactivated a disabled user account is enabled - user_removed a user account is deleted - */ - - const title = this.setEventTitle(event) - const content_title = this.body.userProfile.fullName - - const embed: Embed = { - title: title, - description: title + ' ' + content_title, - } +function pageEvent(event: string, body: Record, user: string): Embed { + return { + title: eventTitle(event), + url: body.page.self, + description: `${user} ${actionTitle(event)} ${body.page.title}`, + } +} - return embed +function spaceEvent(event: string, body: Record, user: string): Embed { + return { + title: eventTitle(event), + url: body.space.self, + description: `${user} ${actionTitle(event)} ${body.space.title}`, } +} - private groupEvent(event: string): Embed { - /** - * Event Triggered when... - group_created a new group is created - group_removed a group is deleted - */ +function userEvent(event: string, body: Record): Embed { + const title = eventTitle(event) + return { + title, + description: `${title} ${body.userProfile.fullName}`, + } +} - const title = this.setEventTitle(event) - const content_title = this.body.groupName +function groupEvent(event: string, body: Record): Embed { + const title = eventTitle(event) + return { + title, + description: `${title} ${body.groupName}`, + } +} - const embed: Embed = { - title: title, - description: title + ' ' + content_title, - } +function capitalizeFirstLetter(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1) +} - return embed - } +function eventTitle(event: string): string { + return event.split('_').reverse().map(capitalizeFirstLetter).join(' ') +} - //Utility Method for Capitalizing Words - private capitalizeFirstLetter(text: string): string { - return text.charAt(0).toUpperCase() + text.slice(1) - } - //Utility Method for Setting Event Title - private setEventTitle(event: string): string { - const event_words = event.split('_') - if (event_words.length === 3) { - return ( - this.capitalizeFirstLetter(event.split('_')[2]) + - ' ' + - this.capitalizeFirstLetter(event.split('_')[1]) + - ' ' + - this.capitalizeFirstLetter(event.split('_')[0]) - ) - } else { - return ( - this.capitalizeFirstLetter(event.split('_')[1]) + ' ' + this.capitalizeFirstLetter(event.split('_')[0]) - ) - } - } - //Utility Method for Setting Event Title - private setActionTitle(event: string): string { - const event_words = event.split('_') - if (event_words.length === 3) { - return event.split('_')[2] + ' ' + event.split('_')[1] + ' ' + event.split('_')[0] - } else { - return event.split('_')[1] + ' ' + event.split('_')[0] - } - } +function actionTitle(event: string): string { + return event.split('_').reverse().join(' ') } diff --git a/src/provider/DockerHub.ts b/src/provider/DockerHub.ts index dac1fba..360b679 100644 --- a/src/provider/DockerHub.ts +++ b/src/provider/DockerHub.ts @@ -1,19 +1,18 @@ -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://docs.docker.com/docker-hub/webhooks/ */ -export class DockerHub extends DirectParseProvider { - public getName(): string { - return 'DockerHub' - } - - public async parseData(): Promise { - this.setEmbedColor(0x0db7ed) - this.addEmbed({ - title: '🐳 Repository: ' + this.body.repository.repo_name, - description: `${this.body.push_data.pusher} pushed for tag: **${this.body.push_data.tag}**`, - url: this.body.repository.repo_url, +export const DockerHub = defineProvider({ + path: 'dockerhub', + name: 'DockerHub', + example: { body: 'dockerhub/dockerhub.json' }, + defaults: { embedColor: 0x0db7ed }, + map({ body }, output) { + output.addEmbed({ + title: '🐳 Repository: ' + body.repository.repo_name, + description: `${body.push_data.pusher} pushed for tag: **${body.push_data.tag}**`, + url: body.repository.repo_url, }) - } -} + }, +}) diff --git a/src/provider/GitLab.ts b/src/provider/GitLab.ts index 43656b6..12056db 100644 --- a/src/provider/GitLab.ts +++ b/src/provider/GitLab.ts @@ -1,5 +1,5 @@ import type { Embed, EmbedAuthor, EmbedField } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' +import { defineEventProvider } from './Provider.ts' interface Project { name: string @@ -10,283 +10,212 @@ interface Project { } /** - * https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/project/integrations/webhooks.md + * https://docs.gitlab.com/user/project/integrations/webhook_events/ */ -export class GitLab extends TypeParseProvider { - private static _formatAvatarURL(url: string): string { - if (!/^https?:\/\/|^\/\//i.test(url)) { - return 'https://gitlab.com' + url - } - return url - } - - private embed: Embed - - constructor() { - super() - this.setEmbedColor(0xfca326) - this.embed = {} - } - - public getName(): string { - return 'GitLab' - } - - public getType(): string | null { - return this.body.object_kind - } - - public knownTypes(): string[] { - return ['push', 'tagPush', 'issue', 'note', 'mergeRequest', 'wikiPage', 'pipeline', 'build'] - } - - public async push(): Promise { - const project = this.projectFromBody() +export const GitLab = defineEventProvider({ + path: 'gitlab', + name: 'GitLab', + example: { body: 'gitlab/gitlab.json' }, + defaults: { embedColor: 0xfca326 }, + event: 'object_kind', + handlers: { + push({ body }, output) { + output.addEmbed(pushEvent(body)) + }, + tag_push({ body }, output) { + output.addEmbed(tagPushEvent(body)) + }, + issue({ body }, output) { + output.addEmbed(changeEvent(body, 'issue', ISSUE_ACTIONS)) + }, + note({ body }, output) { + output.addEmbed(noteEvent(body)) + }, + merge_request({ body }, output) { + output.addEmbed(changeEvent(body, 'merge request', MERGE_REQUEST_ACTIONS)) + }, + wiki_page({ body }, output) { + output.addEmbed(wikiPageEvent(body)) + }, + pipeline({ body }, output) { + output.addEmbed({ + title: `Pipeline #${body.object_attributes.id} on ${body.project.name}`, + url: `${body.project.web_url}/pipelines/${body.object_attributes.id}`, + author: authorFromBody(body), + description: `**Status**: ${body.object_attributes.status}`, + }) + }, + build({ body }, output) { + const project = body.project || body.repository + output.addEmbed({ + title: `Build #${body.build_id} on ${project.name}`, + url: `${project.homepage}/builds/${body.build_id}`, + author: authorFromBody(body), + description: `**Status**: ${body.build_status}`, + }) + }, + }, +}) + +const ISSUE_ACTIONS: Record = { + open: 'Opened', + close: 'Closed', + reopen: 'Reopened', + update: 'Updated', +} - if (project.totalCommitsCount > 0) { - const fields: EmbedField[] = [] +const MERGE_REQUEST_ACTIONS: Record = { + open: 'Opened', + close: 'Closed', + reopen: 'Reopened', + update: 'Updated', + merge: 'Merged', + approved: 'Approved', + unapproved: 'Unapproved', +} - for (const commit of project.commits) { - const message = - commit.message.length > 256 ? commit.message.substring(0, 255) + '\u2026' : commit.message - fields.push({ - name: 'Commit from ' + commit.author.name, - value: - '(' + - '[`' + - commit.id.substring(0, 7) + - '`](' + - commit.url + - ')' + - ') ' + - (message == null ? '' : message.replace(/\n/g, ' ').replace(/\r/g, ' ')), - inline: false, - }) - } +function pushEvent(body: Record): Embed { + const project = projectFromBody(body) + const embed: Embed = { author: authorFromPush(body) } - this.embed.title = - '[' + - project.name + - ':' + - project.branch + - '] ' + - project.totalCommitsCount + - ' commit' + - (project.totalCommitsCount > 1 ? 's' : '') - this.embed.url = project.url + '/tree/' + project.branch - this.embed.fields = fields - } else { - if (this.body.after !== '0000000000000000000000000000000000000000') { - this.embed.title = '[' + project.name + ':' + project.branch + '] New branch created: ' + project.branch - this.embed.url = project.url + '/tree/' + project.branch - } else { - this.embed.title = '[' + project.name + ':' + project.branch + '] Branch deleted: ' + project.branch - this.embed.url = project.url + if (project.totalCommitsCount > 0) { + const fields: EmbedField[] = project.commits.map((commit) => { + const message = commit.message.length > 256 ? commit.message.substring(0, 255) + '\u2026' : commit.message + return { + name: `Commit from ${commit.author.name}`, + value: `([\`${commit.id.substring(0, 7)}\`](${commit.url})) ${message == null ? '' : message.replace(/\n/g, ' ').replace(/\r/g, ' ')}`, + inline: false, } - } - - this.embed.author = this.authorFromBodyPush() - this.addEmbed(this.embed) - } - - public async tagPush(): Promise { - const tmpTag = this.body.ref.split('/') - tmpTag.shift() - tmpTag.shift() - const tag = tmpTag.join('/') - - const project = this.projectFromBody() + }) + embed.title = `[${project.name}:${project.branch}] ${project.totalCommitsCount} commit${project.totalCommitsCount > 1 ? 's' : ''}` + embed.url = `${project.url}/tree/${project.branch}` + embed.fields = fields + } else if (body.after !== '0000000000000000000000000000000000000000') { + embed.title = `[${project.name}:${project.branch}] New branch created: ${project.branch}` + embed.url = `${project.url}/tree/${project.branch}` + } else { + embed.title = `[${project.name}:${project.branch}] Branch deleted: ${project.branch}` + embed.url = project.url + } + return embed +} - this.embed.url = project.url + '/tags/' + tag - this.embed.author = this.authorFromBodyPush() - this.embed.description = - this.body.message != null - ? this.body.message.length > 1024 - ? this.body.message.substring(0, 1023) + '\u2026' - : this.body.message - : '' - if (this.body.after !== '0000000000000000000000000000000000000000') { - this.embed.title = `Pushed tag "${tag}" to ${project.name}` - } else { - this.embed.title = `Deleted tag "${tag}" to ${project.name}` - } - this.addEmbed(this.embed) +function tagPushEvent(body: Record): Embed { + const tag = body.ref.split('/').slice(2).join('/') + const project = projectFromBody(body) + return { + title: + body.after !== '0000000000000000000000000000000000000000' + ? `Pushed tag "${tag}" to ${project.name}` + : `Deleted tag "${tag}" to ${project.name}`, + url: `${project.url}/tags/${tag}`, + author: authorFromPush(body), + description: + body.message == null + ? '' + : body.message.length > 1024 + ? body.message.substring(0, 1023) + '\u2026' + : body.message, } +} - public async issue(): Promise { - const actions: Record = { - open: 'Opened', - close: 'Closed', - reopen: 'Reopened', - update: 'Updated', - } - - this.embed.title = - actions[this.body.object_attributes.action] + - ' issue #' + - this.body.object_attributes.iid + - ' on ' + - this.body.project.name - this.embed.url = this.body.object_attributes.url - this.embed.author = this.authorFromBody() - if (this.body.object_attributes.description !== '') { - this.embed.fields = [ - { - name: this.body.object_attributes.title, - value: - this.body.object_attributes.description.length > 1024 - ? this.body.object_attributes.description.substring(0, 1023) + '\u2026' - : this.body.object_attributes.description, - }, - ] - } else { - this.embed.description = `**${this.body.object_attributes.title}**` - } - this.addEmbed(this.embed) - } +function changeEvent(body: Record, resource: string, actions: Record): Embed { + const attributes = body.object_attributes + const embed: Embed = { + title: `${actions[attributes.action]} ${resource} #${attributes.iid} on ${body.project.name}`, + url: attributes.url, + author: authorFromBody(body), + } + if (attributes.description !== '') { + embed.fields = [ + { + name: attributes.title, + value: + attributes.description.length > 1024 + ? attributes.description.substring(0, 1023) + '\u2026' + : attributes.description, + }, + ] + } else { + embed.description = `**${attributes.title}**` + } + return embed +} - public async note(): Promise { - let type: string - switch (this.body.object_attributes.noteable_type) { - case 'Commit': - type = 'commit (' + this.body.commit.id.substring(0, 7) + ')' - break - case 'MergeRequest': - type = 'merge request #' + this.body.merge_request.iid - break - case 'Issue': - type = 'issue #' + this.body.issue.iid - break - case 'Snippet': - type = 'snippet #' + this.body.snippet.id - break - default: - type = 'unknown' - break - } - this.embed.title = 'Wrote a comment on ' + type + ' on ' + this.body.project.name - this.embed.url = this.body.object_attributes.url - this.embed.author = this.authorFromBody() - this.embed.description = - this.body.object_attributes.note.length > 2048 - ? this.body.object_attributes.note.substring(0, 2047) + '\u2026' - : this.body.object_attributes.note - this.addEmbed(this.embed) +function noteEvent(body: Record): Embed { + let type: string + switch (body.object_attributes.noteable_type) { + case 'Commit': + type = `commit (${body.commit.id.substring(0, 7)})` + break + case 'MergeRequest': + type = `merge request #${body.merge_request.iid}` + break + case 'Issue': + type = `issue #${body.issue.iid}` + break + case 'Snippet': + type = `snippet #${body.snippet.id}` + break + default: + type = 'unknown' + } + const note = body.object_attributes.note + return { + title: `Wrote a comment on ${type} on ${body.project.name}`, + url: body.object_attributes.url, + author: authorFromBody(body), + description: note.length > 2048 ? note.substring(0, 2047) + '\u2026' : note, } +} - public async mergeRequest(): Promise { - const actions: Record = { - open: 'Opened', - close: 'Closed', - reopen: 'Reopened', - update: 'Updated', - merge: 'Merged', - approved: 'Approved', - unapproved: 'Unapproved', - } - this.embed.title = - actions[this.body.object_attributes.action] + - ' merge request #' + - this.body.object_attributes.iid + - ' on ' + - this.body.project.name - this.embed.url = this.body.object_attributes.url - this.embed.author = this.authorFromBody() - if (this.body.object_attributes.description !== '') { - this.embed.fields = [ - { - name: this.body.object_attributes.title, - value: - this.body.object_attributes.description.length > 1024 - ? this.body.object_attributes.description.substring(0, 1023) + '\u2026' - : this.body.object_attributes.description, - }, - ] - } else { - this.embed.description = `**${this.body.object_attributes.title}**` - } - this.addEmbed(this.embed) +function wikiPageEvent(body: Record): Embed { + const actions: Record = { create: 'Created', delete: 'Deleted', update: 'Updated' } + const attributes = body.object_attributes + return { + title: `${actions[attributes.action]} wiki page ${attributes.title} on ${body.project.name}`, + url: attributes.url, + author: authorFromBody(body), + description: + attributes.message == null + ? '' + : attributes.message.length > 2048 + ? attributes.message.substring(0, 2047) + '\u2026' + : attributes.message, } +} - public async wikiPage(): Promise { - const actions: Record = { - create: 'Created', - delete: 'Deleted', - update: 'Updated', - } - - this.embed.title = - actions[this.body.object_attributes.action] + - ' wiki page ' + - this.body.object_attributes.title + - ' on ' + - this.body.project.name - this.embed.url = this.body.object_attributes.url - this.embed.author = this.authorFromBody() - this.embed.description = - this.body.object_attributes.message != null - ? this.body.object_attributes.message.length > 2048 - ? this.body.object_attributes.message.substring(0, 2047) + '\u2026' - : this.body.object_attributes.message - : '' - this.addEmbed(this.embed) - } +function formatAvatarUrl(url: string): string { + return /^https?:\/\/|^\/\//i.test(url) ? url : `https://gitlab.com${url}` +} - public async pipeline(): Promise { - this.embed.title = 'Pipeline #' + this.body.object_attributes.id + ' on ' + this.body.project.name - this.embed.url = this.body.project.web_url + '/pipelines/' + this.body.object_attributes.id - this.embed.author = this.authorFromBody() - this.embed.description = '**Status**: ' + this.body.object_attributes.status - this.addEmbed(this.embed) - } +function authorFromBody(body: Record): EmbedAuthor { + return { name: body.user.name, icon_url: formatAvatarUrl(body.user.avatar_url) } +} - public async build(): Promise { - // The build event uses the deprecated repository field. - const realProj = this.body.project || this.body.repository - this.embed.title = 'Build #' + this.body.build_id + ' on ' + realProj.name - this.embed.url = realProj.homepage + '/builds/' + this.body.build_id - this.embed.author = this.authorFromBody() - this.embed.description = '**Status**: ' + this.body.build_status - this.addEmbed(this.embed) - } +function authorFromPush(body: Record): EmbedAuthor { + return { name: body.user_name, icon_url: formatAvatarUrl(body.user_avatar) } +} - private authorFromBody(): EmbedAuthor { +function projectFromBody(body: Record): Project { + const branch = body.ref.split('/').slice(2).join('/') + if (body.project != null) { return { - name: this.body.user.name, - icon_url: GitLab._formatAvatarURL(this.body.user.avatar_url), + name: body.project.name, + url: body.project.web_url, + branch, + commits: body.commits || [], + totalCommitsCount: body.total_commits_count || 0, } } - - private authorFromBodyPush(): EmbedAuthor { + if (body.repository != null) { return { - name: this.body.user_name, - icon_url: GitLab._formatAvatarURL(this.body.user_avatar), + name: body.repository.name, + url: body.repository.homepage, + branch, + commits: body.commits || [], + totalCommitsCount: body.total_commits_count || 0, } } - - private projectFromBody(): Project { - const branch = this.body.ref.split('/') - branch.shift() - branch.shift() - - if (this.body.project != null) { - return { - name: this.body.project.name, - url: this.body.project.web_url, - branch: branch.join('/'), - commits: this.body.commits || [], - totalCommitsCount: this.body.total_commits_count || 0, - } - } else if (this.body.repository != null) { - return { - name: this.body.repository.name, - url: this.body.repository.homepage, - branch: branch.join('/'), - commits: this.body.commits || [], - totalCommitsCount: this.body.total_commits_count || 0, - } - } - - throw new Error("Failed to resolve project from body! Did GitLab's webhook format change?") - } + throw new Error("Failed to resolve project from body! Did GitLab's webhook format change?") } diff --git a/src/provider/Heroku.ts b/src/provider/Heroku.ts index 8522cef..c465a3d 100644 --- a/src/provider/Heroku.ts +++ b/src/provider/Heroku.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' function gravatarUrl(email: string, size = 100): string { const hash = createHash('md5').update(email.trim().toLowerCase()).digest('hex') @@ -9,44 +9,41 @@ function gravatarUrl(email: string, size = 100): string { /** * https://devcenter.heroku.com/articles/app-webhooks */ -export class Heroku extends DirectParseProvider { - public getName(): string { - return 'Heroku' - } - - public async parseData(): Promise { - this.setEmbedColor(0xc9c3e6) - const action: string = this.actionAsPastTense(this.body.action) - const type: string = this.typeAsReadable(this.body.webhook_metadata.event.include) - const authorName: string = this.body.actor.email - let name = this.body.data.name - if (name == null) { - name = this.body.data.app.name - } +export const Heroku = defineProvider({ + path: 'heroku', + name: 'Heroku', + example: { body: 'heroku/heroku.json' }, + defaults: { embedColor: 0xc9c3e6 }, + map({ body }, output) { + const action = actionAsPastTense(body.action) + const type = typeAsReadable(body.webhook_metadata.event.include) + const authorName = body.actor.email + const name = body.data.name ?? body.data.app.name - this.addEmbed({ + output.addEmbed({ title: `${authorName} ${action} ${type}. App: ${name}`, - url: this.body.data.web_url, + url: body.data.web_url, author: { name: authorName, - icon_url: gravatarUrl(this.body.actor.email), + icon_url: gravatarUrl(body.actor.email), }, }) - } + }, +}) - private actionAsPastTense(action: string): string { - switch (action) { - case 'create': - return 'created' - case 'destroy': - return 'destroyed' - case 'update': - return 'updated' - } - return 'unknown' +function actionAsPastTense(action: string): string { + switch (action) { + case 'create': + return 'created' + case 'destroy': + return 'destroyed' + case 'update': + return 'updated' + default: + return 'unknown' } +} - private typeAsReadable(type: string): string { - return type.split('api:')[1] - } +function typeAsReadable(type: string): string { + return type.split('api:')[1] } diff --git a/src/provider/HuggingFace.ts b/src/provider/HuggingFace.ts index a14b117..a507172 100644 --- a/src/provider/HuggingFace.ts +++ b/src/provider/HuggingFace.ts @@ -1,6 +1,6 @@ import type { Embed, EmbedField } from '../model/DiscordApi.ts' import { DISCORD_EMBED_LIMITS, DISCORD_MESSAGE_LIMITS, SKYHOOK_FOOTER_TEXT } from '../util/DiscordEmbed.ts' -import { DirectParseProvider } from './BaseProvider.ts' +import { defineProvider, type ProviderOutput } from './Provider.ts' const CONTENT_SUMMARY_RESERVE = 64 @@ -65,62 +65,62 @@ interface HuggingFacePayload { /** * https://huggingface.co/docs/hub/main/webhooks */ -export class HuggingFace extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0xffd21e) - this.payload.username = 'Hugging Face' - this.payload.allowed_mentions = { parse: [] } - } - - public getName(): string { - return 'Hugging Face' - } - - public getPath(): string { - return 'huggingface' - } - - public async parseData(): Promise { - const body = this.body as HuggingFacePayload +export const HuggingFace = defineProvider({ + path: 'huggingface', + name: 'Hugging Face', + example: { body: 'huggingface/huggingface.json' }, + defaults: { + username: 'Hugging Face', + embedColor: 0xffd21e, + }, + map({ body }, output) { + new HuggingFaceMapper().map(body as unknown as HuggingFacePayload, output) + }, +}) + +class HuggingFaceMapper { + public map(body: HuggingFacePayload, output: ProviderOutput): void { const scope = body.event.scope if (scope === 'discussion.comment' || scope.startsWith('discussion.comment.')) { - this.addEmbed(this.formatComment(body)) + output.addEmbed(this.formatComment(body)) } else if (scope === 'discussion' || scope.startsWith('discussion.')) { - this.addEmbed(this.formatDiscussion(body)) + output.addEmbed(this.formatDiscussion(body)) } else if (scope === 'repo.config' || scope.startsWith('repo.config.')) { - this.addEmbed(this.formatRepoConfig(body)) + output.addEmbed(this.formatRepoConfig(body)) } else if (scope === 'repo.content' || scope.startsWith('repo.content.')) { - this.addEmbed(this.formatRepoContent(body)) + output.addEmbed(this.formatRepoContent(body)) } else if (scope === 'repo' || scope.startsWith('repo.')) { - this.addEmbed(this.formatRepo(body)) + output.addEmbed(this.formatRepo(body)) } else { - this.addEmbed(this.formatUnknownScope(body)) + output.addEmbed(this.formatUnknownScope(body)) } } private formatRepo(body: HuggingFacePayload): Embed { - const action = HuggingFace.actionLabel(body.event.action) + const action = HuggingFaceMapper.actionLabel(body.event.action) const embed: Embed = { url: body.repo.url.web } if (body.event.action === 'move' && body.movedTo != null) { - embed.title = HuggingFace.truncate( + embed.title = HuggingFaceMapper.truncate( `Moved ${body.repo.type} ${body.repo.name} to ${body.movedTo.name}`, DISCORD_EMBED_LIMITS.title, ) - embed.description = `**New name:** \`${HuggingFace.escapeInlineCode(body.movedTo.name)}\`` + embed.description = `**New name:** \`${HuggingFaceMapper.escapeInlineCode(body.movedTo.name)}\`` return embed } - embed.title = HuggingFace.truncate(`${action} ${body.repo.type} ${body.repo.name}`, DISCORD_EMBED_LIMITS.title) + embed.title = HuggingFaceMapper.truncate( + `${action} ${body.repo.type} ${body.repo.name}`, + DISCORD_EMBED_LIMITS.title, + ) embed.description = `**Visibility:** ${body.repo.private ? 'Private' : 'Public'}` return embed } private formatRepoContent(body: HuggingFacePayload): Embed { const embed: Embed = { - title: HuggingFace.truncate( + title: HuggingFaceMapper.truncate( `Updated content in ${body.repo.type} ${body.repo.name}`, DISCORD_EMBED_LIMITS.title, ), @@ -158,7 +158,7 @@ export class HuggingFace extends DirectParseProvider { } private formatUpdatedRef(repo: HuggingFaceRepo, updatedRef: HuggingFaceUpdatedRef): EmbedField { - const { kind, name } = HuggingFace.describeRef(updatedRef.ref) + const { kind, name } = HuggingFaceMapper.describeRef(updatedRef.ref) const change = updatedRef.oldSha == null ? 'Created' : updatedRef.newSha == null ? 'Deleted' : 'Updated' const shas = [ updatedRef.oldSha == null ? null : this.commitLink(repo, updatedRef.oldSha), @@ -166,14 +166,14 @@ export class HuggingFace extends DirectParseProvider { ].filter((sha): sha is string => sha != null) return { - name: HuggingFace.truncate(`${change} ${kind} ${name}`, DISCORD_EMBED_LIMITS.fieldName), - value: HuggingFace.truncate(shas.join(' → '), DISCORD_EMBED_LIMITS.fieldValue), + name: HuggingFaceMapper.truncate(`${change} ${kind} ${name}`, DISCORD_EMBED_LIMITS.fieldName), + value: HuggingFaceMapper.truncate(shas.join(' → '), DISCORD_EMBED_LIMITS.fieldValue), } } private formatRepoConfig(body: HuggingFacePayload): Embed { return { - title: HuggingFace.truncate( + title: HuggingFaceMapper.truncate( `Updated settings for ${body.repo.type} ${body.repo.name}`, DISCORD_EMBED_LIMITS.title, ), @@ -186,18 +186,18 @@ export class HuggingFace extends DirectParseProvider { } private formatDiscussion(body: HuggingFacePayload): Embed { - const discussion = HuggingFace.requireDiscussion(body) + const discussion = HuggingFaceMapper.requireDiscussion(body) const kind = discussion.isPullRequest ? 'pull request' : 'discussion' const embed: Embed = { - title: HuggingFace.truncate( - `${HuggingFace.actionLabel(body.event.action)} ${kind} #${discussion.num} on ${body.repo.name}: ${discussion.title}`, + title: HuggingFaceMapper.truncate( + `${HuggingFaceMapper.actionLabel(body.event.action)} ${kind} #${discussion.num} on ${body.repo.name}: ${discussion.title}`, DISCORD_EMBED_LIMITS.title, ), url: discussion.url.web, fields: [ { name: 'Status', - value: HuggingFace.titleCase(discussion.status), + value: HuggingFaceMapper.titleCase(discussion.status), inline: true, }, ], @@ -206,8 +206,8 @@ export class HuggingFace extends DirectParseProvider { if (discussion.changes?.base != null) { embed.fields!.push({ name: 'Base', - value: HuggingFace.truncate( - HuggingFace.stripRefPrefix(discussion.changes.base), + value: HuggingFaceMapper.truncate( + HuggingFaceMapper.stripRefPrefix(discussion.changes.base), DISCORD_EMBED_LIMITS.fieldValue, ), inline: true, @@ -216,19 +216,19 @@ export class HuggingFace extends DirectParseProvider { if (body.comment?.hidden === true) { embed.description = 'This comment was hidden.' } else if (body.comment?.content != null) { - embed.description = HuggingFace.truncate(body.comment.content, DISCORD_EMBED_LIMITS.description) + embed.description = HuggingFaceMapper.truncate(body.comment.content, DISCORD_EMBED_LIMITS.description) } return embed } private formatComment(body: HuggingFacePayload): Embed { - const discussion = HuggingFace.requireDiscussion(body) - const comment = HuggingFace.requireComment(body) + const discussion = HuggingFaceMapper.requireDiscussion(body) + const comment = HuggingFaceMapper.requireComment(body) const kind = discussion.isPullRequest ? 'pull request' : 'discussion' - const action = body.event.action === 'create' ? 'New' : HuggingFace.actionLabel(body.event.action) + const action = body.event.action === 'create' ? 'New' : HuggingFaceMapper.actionLabel(body.event.action) return { - title: HuggingFace.truncate( + title: HuggingFaceMapper.truncate( `${action} comment on ${kind} #${discussion.num} in ${body.repo.name}: ${discussion.title}`, DISCORD_EMBED_LIMITS.title, ), @@ -236,14 +236,14 @@ export class HuggingFace extends DirectParseProvider { description: comment.hidden || comment.content == null ? 'This comment was hidden.' - : HuggingFace.truncate(comment.content, DISCORD_EMBED_LIMITS.description), + : HuggingFaceMapper.truncate(comment.content, DISCORD_EMBED_LIMITS.description), } } private formatUnknownScope(body: HuggingFacePayload): Embed { return { - title: HuggingFace.truncate( - `${HuggingFace.actionLabel(body.event.action)} ${body.event.scope} for ${body.repo.type} ${body.repo.name}`, + title: HuggingFaceMapper.truncate( + `${HuggingFaceMapper.actionLabel(body.event.action)} ${body.event.scope} for ${body.repo.type} ${body.repo.name}`, DISCORD_EMBED_LIMITS.title, ), url: body.repo.url.web, @@ -277,7 +277,7 @@ export class HuggingFace extends DirectParseProvider { move: 'Moved', update: 'Updated', } - return labels[action] ?? HuggingFace.titleCase(action) + return labels[action] ?? HuggingFaceMapper.titleCase(action) } private static describeRef(ref: string): { kind: string; name: string } { @@ -287,7 +287,7 @@ export class HuggingFace extends DirectParseProvider { if (ref.startsWith('refs/tags/')) { return { kind: 'tag', name: ref.slice('refs/tags/'.length) } } - return { kind: 'reference', name: HuggingFace.stripRefPrefix(ref) } + return { kind: 'reference', name: HuggingFaceMapper.stripRefPrefix(ref) } } private static stripRefPrefix(ref: string): string { diff --git a/src/provider/Instana.ts b/src/provider/Instana.ts index 02303fc..0a31c0d 100644 --- a/src/provider/Instana.ts +++ b/src/provider/Instana.ts @@ -1,5 +1,5 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', @@ -15,98 +15,71 @@ const InstanaEventType = { /** * https://www.instana.com/docs/ecosystem/webhook/ */ -export class Instana extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0x54c0de) - } - - private getEventType(): string { - return this.body.issue.state || InstanaEventType.CHANGE_EVENT - } - - private addField( - embed: Embed, - inline: boolean, - name: string, - fieldValue: string | number, - isValueDate: boolean, - ): void { - if (!fieldValue) { - return - } - - embed.fields!.push({ - name, - value: isValueDate ? dateFormatter.format(new Date(fieldValue as number)) : (fieldValue as string), - inline, - }) - } - - private parseOpenIncident(embed: Embed): Embed { - embed.title = 'Issue Opened' - embed.url = this.body.issue.link - this.addField(embed, false, 'Id', this.body.issue.id, false) - this.addField(embed, false, 'Description', this.body.issue.text, false) - this.addField(embed, false, 'Suggestion', this.body.issue.suggestion, false) - this.addField(embed, false, 'Start Time', this.body.issue.start, true) - this.addField(embed, false, 'End Time', this.body.issue.end, true) - return embed - } - - private parseClosedIncident(embed: Embed): Embed { - embed.title = 'Issue Closed' - this.addField(embed, false, 'Id', this.body.issue.id, false) - this.addField(embed, false, 'Start Time', this.body.issue.start, true) - this.addField(embed, false, 'End Time', this.body.issue.end, true) - return embed - } - - private parseChangeEvent(embed: Embed): Embed { - embed.url = this.body.issue.link - embed.title = this.body.issue.text - this.addField(embed, false, 'Id', this.body.issue.id, false) - this.addField(embed, false, 'Description', this.body.issue.description, false) - this.addField(embed, false, 'Start Time', this.body.issue.start, true) - this.addField(embed, false, 'End Time', this.body.issue.end, true) - this.addField(embed, false, 'Type', this.body.issue.type, false) - return embed - } - - private parseUnrecognizedType(embed: Embed): Embed { - embed.title = 'Unrecognized Webhook Type' - embed.url = this.body.issue.link - return embed - } - - public getName(): string { - return 'Instana' - } - - public getPath(): string { - return 'instana' - } - - public async parseData(): Promise { - const embed: Embed = {} - embed.fields = [] - switch (this.getEventType()) { - case InstanaEventType.OPEN: { - this.addEmbed(this.parseOpenIncident(embed)) +export const Instana = defineProvider({ + path: 'instana', + name: 'Instana', + example: { body: 'instana/instana.json' }, + defaults: { embedColor: 0x54c0de }, + map({ body }, output) { + const embed: Embed = { fields: [] } + const eventType = body.issue.state || InstanaEventType.CHANGE_EVENT + switch (eventType) { + case InstanaEventType.OPEN: + formatOpenIncident(body, embed) break - } - case InstanaEventType.CLOSED: { - this.addEmbed(this.parseClosedIncident(embed)) + case InstanaEventType.CLOSED: + formatClosedIncident(body, embed) break - } - case InstanaEventType.CHANGE_EVENT: { - this.addEmbed(this.parseChangeEvent(embed)) + case InstanaEventType.CHANGE_EVENT: + formatChangeEvent(body, embed) break - } - default: { - this.addEmbed(this.parseUnrecognizedType(embed)) + default: + embed.title = 'Unrecognized Webhook Type' + embed.url = body.issue.link break - } } - } + output.addEmbed(embed) + }, +}) + +function addField( + embed: Embed, + inline: boolean, + name: string, + fieldValue: string | number, + isValueDate: boolean, +): void { + if (!fieldValue) return + embed.fields!.push({ + name, + value: isValueDate ? dateFormatter.format(new Date(fieldValue as number)) : (fieldValue as string), + inline, + }) +} + +function formatOpenIncident(body: Record, embed: Embed): void { + embed.title = 'Issue Opened' + embed.url = body.issue.link + addField(embed, false, 'Id', body.issue.id, false) + addField(embed, false, 'Description', body.issue.text, false) + addField(embed, false, 'Suggestion', body.issue.suggestion, false) + addField(embed, false, 'Start Time', body.issue.start, true) + addField(embed, false, 'End Time', body.issue.end, true) +} + +function formatClosedIncident(body: Record, embed: Embed): void { + embed.title = 'Issue Closed' + addField(embed, false, 'Id', body.issue.id, false) + addField(embed, false, 'Start Time', body.issue.start, true) + addField(embed, false, 'End Time', body.issue.end, true) +} + +function formatChangeEvent(body: Record, embed: Embed): void { + embed.url = body.issue.link + embed.title = body.issue.text + addField(embed, false, 'Id', body.issue.id, false) + addField(embed, false, 'Description', body.issue.description, false) + addField(embed, false, 'Start Time', body.issue.start, true) + addField(embed, false, 'End Time', body.issue.end, true) + addField(embed, false, 'Type', body.issue.type, false) } diff --git a/src/provider/Jenkins.ts b/src/provider/Jenkins.ts index 40f6da0..ce4e645 100644 --- a/src/provider/Jenkins.ts +++ b/src/provider/Jenkins.ts @@ -1,44 +1,35 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://plugins.jenkins.io/notification */ -export class Jenkins extends DirectParseProvider { - private static capitalize(str: string): string { - const tmp = str.toLowerCase() - return tmp.charAt(0).toUpperCase() + tmp.slice(1) - } - - public getName(): string { - return 'Jenkins-CI' - } - - public getPath(): string { - return 'jenkins' - } - - public async parseData(): Promise { - this.setEmbedColor(0xf0d6b7) - const phase = this.body.build.phase +export const Jenkins = defineProvider({ + path: 'jenkins', + name: 'Jenkins-CI', + example: { body: 'jenkins/jenkins.json' }, + defaults: { embedColor: 0xf0d6b7 }, + map({ body }, output) { + const phase = body.build.phase const embed: Embed = { - title: 'Project ' + this.body.name, - url: this.body.build.full_url, + title: 'Project ' + body.name, + url: body.build.full_url, } switch (phase) { case 'STARTED': - embed.description = 'Started build #' + this.body.build.number + embed.description = 'Started build #' + body.build.number break case 'COMPLETED': case 'FINALIZED': embed.description = - Jenkins.capitalize(phase) + - ' build #' + - this.body.build.number + - ' with status: ' + - this.body.build.status + capitalize(phase) + ' build #' + body.build.number + ' with status: ' + body.build.status break } - this.addEmbed(embed) - } + output.addEmbed(embed) + }, +}) + +function capitalize(value: string): string { + const lowerCase = value.toLowerCase() + return lowerCase.charAt(0).toUpperCase() + lowerCase.slice(1) } diff --git a/src/provider/Jira.ts b/src/provider/Jira.ts index 96805bb..2befb19 100644 --- a/src/provider/Jira.ts +++ b/src/provider/Jira.ts @@ -1,69 +1,55 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://developer.atlassian.com/server/jira/platform/webhooks/ */ -export class Jira extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0x1e45a8) - } - - public getName(): string { - return 'Jira' - } - - public getPath(): string { - return 'jira' - } - - public async parseData(): Promise { - if (this.body.webhookEvent == null) { - this.nullifyPayload() +export const Jira = defineProvider({ + path: 'jira', + name: 'Jira', + example: { body: 'jira/jira-issue.json' }, + defaults: { embedColor: 0x1e45a8 }, + map({ body }, output) { + if (body.webhookEvent == null) { + output.ignore() return } let isIssue: boolean - if (this.body.webhookEvent.startsWith('jira:issue_')) { + if (body.webhookEvent.startsWith('jira:issue_')) { isIssue = true - } else if (this.body.webhookEvent.startsWith('comment_')) { + } else if (body.webhookEvent.startsWith('comment_')) { isIssue = false - if (this.body.issue == null) { - // What's the point of notifying a new comment if ONLY comment information is sent? - // Do we care that a comment was made if we cant tell what was commented on? - // This solution will silence errors until someone makes sense of Atlassian's decisions.. - this.nullifyPayload() + if (body.issue == null) { + output.ignore() return } } else { + output.ignore() return } - // extract variable from Jira - const issueHasAsignee = this.body?.issue?.fields?.assignee != null - const issue = this.body.issue - const user = this.body.user || { displayName: 'Anonymous' } - const action = this.body.webhookEvent.split('_')[1] - - // create the embed + const issueHasAssignee = body.issue?.fields?.assignee != null + const issue = body.issue + const user = body.user || { displayName: 'Anonymous' } + const action = body.webhookEvent.split('_')[1] const embed: Embed = { title: `${issue.key} - ${issue.fields.summary}`, - url: this.createBrowseUrl(issue), + url: createBrowseUrl(issue), } if (isIssue) { - embed.description = `${user.displayName} ${action} issue: ${embed.title}${issueHasAsignee ? ` (${issue.fields.assignee.displayName})` : ''} ` + embed.description = `${user.displayName} ${action} issue: ${embed.title}${issueHasAssignee ? ` (${issue.fields.assignee.displayName})` : ''} ` } else { - const comment = this.body.comment + const comment = body.comment embed.description = `${comment.updateAuthor.displayName} ${action} comment: ${comment.body}` } - this.addEmbed(embed) - } - - private createBrowseUrl(issue: { self: string; key: string }): string { - const url: URL = new URL(issue.self) - const path: string | RegExpMatchArray = url.pathname.match(/.+?(?=\/rest\/api)/) ?? '' - url.pathname = `${path}/browse/${issue.key}` - return url.toString() - } + output.addEmbed(embed) + }, +}) + +function createBrowseUrl(issue: { self: string; key: string }): string { + const url = new URL(issue.self) + const path = url.pathname.match(/.+?(?=\/rest\/api)/) ?? '' + url.pathname = `${path}/browse/${issue.key}` + return url.toString() } diff --git a/src/provider/Linear.ts b/src/provider/Linear.ts index 2f99ee8..f60cd0a 100644 --- a/src/provider/Linear.ts +++ b/src/provider/Linear.ts @@ -1,8 +1,14 @@ import type { Embed, EmbedField } from '../model/DiscordApi.ts' import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts' import { cleanText, escapeDiscordMarkdownLiteral, truncateText } from '../util/DiscordText.ts' -import { canonicalizeIso8601Timestamp, isRecord } from '../util/WebhookValue.ts' -import { DirectParseProvider } from './BaseProvider.ts' +import { + canonicalizeIso8601Timestamp, + firstScalar, + isRecord, + scalarText, + trustedHttpsUrl, +} from '../util/WebhookValue.ts' +import { defineProvider } from './Provider.ts' const MAX_URL_CHARACTERS = 2048 const DATA_CHANGE_ACTIONS = new Set(['create', 'update', 'remove']) @@ -24,61 +30,47 @@ const PRIORITY_LABELS: Record = { * * @see https://linear.app/developers/webhooks */ -export class Linear extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0x5e6ad2) - this.payload.username = 'Linear' - this.payload.allowed_mentions = { parse: [] } - } - - public getName(): string { - return 'Linear' - } - - public getPath(): string { - return 'linear' - } - - public async parseData(): Promise { - if (!isRecord(this.body)) { - this.nullifyPayload() - return - } - - const action = boundedToken(this.body.action, 64) - const type = boundedToken(this.body.type, 100) - const timestamp = canonicalizeIso8601Timestamp(this.body.createdAt) +export const Linear = defineProvider({ + path: 'linear', + name: 'Linear', + example: { + body: 'linear/linear.json', + headers: 'linear/linear.headers.json', + }, + defaults: { + username: 'Linear', + embedColor: 0x5e6ad2, + }, + map({ body, headers }, output) { + const action = boundedToken(body.action, 64) + const type = boundedToken(body.type, 100) + const timestamp = canonicalizeIso8601Timestamp(body.createdAt) if ( action == null || type == null || timestamp == null || - !Number.isSafeInteger(this.body.webhookTimestamp) || - this.body.webhookTimestamp <= 0 || - !isBoundedString(this.body.webhookId, 128) + !Number.isSafeInteger(body.webhookTimestamp) || + body.webhookTimestamp <= 0 || + !isBoundedString(body.webhookId, 128) ) { - this.nullifyPayload() + output.ignore() return } - const data = isRecord(this.body.data) - ? this.body.data - : isRecord(this.body.issueData) - ? this.body.issueData - : {} - if (DATA_CHANGE_ACTIONS.has(action) && !isRecord(this.body.data)) { - this.nullifyPayload() + const data = isRecord(body.data) ? body.data : isRecord(body.issueData) ? body.issueData : {} + if (DATA_CHANGE_ACTIONS.has(action) && !isRecord(body.data)) { + output.ignore() return } - const headerEvent = getHeader(this.headers, 'linear-event') + const headerEvent = getHeader(headers, 'linear-event') if (headerEvent != null && headerEvent !== type) { - this.nullifyPayload() + output.ignore() return } const embed: Embed = { - title: escapeAndTruncate(this.createTitle(type, action, data), DISCORD_EMBED_LIMITS.title, true), + title: escapeAndTruncate(createTitle(type, action, data), DISCORD_EMBED_LIMITS.title, true), timestamp, } @@ -87,12 +79,12 @@ export class Linear extends DirectParseProvider { embed.description = escapeAndTruncate(description, DISCORD_EMBED_LIMITS.description, false) } - const url = trustedLinearUrl(this.body.url) + const url = trustedLinearUrl(body.url) if (url != null) { embed.url = url } - const actor = isRecord(this.body.actor) ? this.body.actor : null + const actor = isRecord(body.actor) ? body.actor : null const actorName = scalarText(actor?.name) if (actorName != null) { embed.author = { @@ -104,49 +96,49 @@ export class Linear extends DirectParseProvider { } } - embed.fields = fitLiteralEmbedFields(embed, this.createFields(data)) - this.addEmbed(embed) - } - - private createTitle(type: string, action: string, data: Record): string { - const resource = resourceTypeLabel(type) - const actionLabel = actionPhrase(action, type) - const identifier = scalarText(data.identifier) - const name = scalarText(data.name) - const title = scalarText(data.title) - const subject = identifier != null && title != null ? `${identifier} — ${title}` : (identifier ?? name ?? title) - - return `${resource} ${actionLabel}${subject == null ? '' : `: ${subject}`}` - } - - private createFields(data: Record): EmbedField[] { - const fields: EmbedField[] = [] - - addField(fields, 'State', nestedName(data.state) ?? scalarText(data.stateName)) - addField(fields, 'Assignee', nestedName(data.assignee)) - addField(fields, 'Priority', priorityLabel(data.priority)) - addField(fields, 'Team', nestedName(data.team)) - addField(fields, 'Project', nestedName(data.project)) - addField(fields, 'Cycle', nestedName(data.cycle)) - addField(fields, 'Customer', nestedName(data.customer)) - addField(fields, 'Labels', listNames(data.labels), false) - addField(fields, 'OAuth client ID', scalarText(this.body.oauthClientId)) - - if (isRecord(this.body.updatedFrom)) { - const changedFields = [ - ...new Set( - Object.keys(this.body.updatedFrom) - .filter((key) => !IGNORED_UPDATED_FIELDS.has(key)) - .map((key) => changedFieldLabel(key)), - ), - ] - if (changedFields.length > 0) { - fields.push({ name: 'Updated fields', value: changedFields.join(', '), inline: false }) - } + embed.fields = fitLiteralEmbedFields(embed, createFields(body, data)) + output.addEmbed(embed) + }, +}) + +function createTitle(type: string, action: string, data: Record): string { + const resource = resourceTypeLabel(type) + const actionLabel = actionPhrase(action, type) + const identifier = scalarText(data.identifier) + const name = scalarText(data.name) + const title = scalarText(data.title) + const subject = identifier != null && title != null ? `${identifier} — ${title}` : (identifier ?? name ?? title) + + return `${resource} ${actionLabel}${subject == null ? '' : `: ${subject}`}` +} + +function createFields(body: Record, data: Record): EmbedField[] { + const fields: EmbedField[] = [] + + addField(fields, 'State', nestedName(data.state) ?? scalarText(data.stateName)) + addField(fields, 'Assignee', nestedName(data.assignee)) + addField(fields, 'Priority', priorityLabel(data.priority)) + addField(fields, 'Team', nestedName(data.team)) + addField(fields, 'Project', nestedName(data.project)) + addField(fields, 'Cycle', nestedName(data.cycle)) + addField(fields, 'Customer', nestedName(data.customer)) + addField(fields, 'Labels', listNames(data.labels), false) + addField(fields, 'OAuth client ID', scalarText(body.oauthClientId)) + + if (isRecord(body.updatedFrom)) { + const changedFields = [ + ...new Set( + Object.keys(body.updatedFrom) + .filter((key) => !IGNORED_UPDATED_FIELDS.has(key)) + .map((key) => changedFieldLabel(key)), + ), + ] + if (changedFields.length > 0) { + fields.push({ name: 'Updated fields', value: changedFields.join(', '), inline: false }) } - - return fields } + + return fields } function addField(fields: EmbedField[], name: string, value: string | null, inline = true): void { @@ -217,16 +209,6 @@ function priorityLabel(value: unknown): string | null { return scalarText(value) } -function firstScalar(...values: unknown[]): string | null { - for (const value of values) { - const text = scalarText(value) - if (text != null) { - return text - } - } - return null -} - function getHeader(headers: unknown, name: string): string | null { if (headers instanceof Headers) { return scalarText(headers.get(name)) @@ -243,19 +225,10 @@ function getHeader(headers: unknown, name: string): string | null { } function trustedLinearUrl(value: unknown): string | null { - if (!isBoundedString(value, MAX_URL_CHARACTERS)) { - return null - } - try { - const url = new URL(value) - if (url.protocol !== 'https:' || (url.hostname !== 'linear.app' && url.hostname !== 'www.linear.app')) { - return null - } - const normalizedUrl = url.href - return normalizedUrl.length <= MAX_URL_CHARACTERS ? normalizedUrl : null - } catch { - return null - } + return trustedHttpsUrl(value, { + allowedHosts: ['linear.app', 'www.linear.app'], + maxLength: MAX_URL_CHARACTERS, + }) } function boundedToken(value: unknown, maxLength: number): string | null { @@ -266,20 +239,6 @@ function isBoundedString(value: unknown, maxLength: number): value is string { return typeof value === 'string' && value.length > 0 && value.length <= maxLength } -function scalarText(value: unknown): string | null { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - return null - } - if ( - typeof value === 'number' && - (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) - ) { - return null - } - const text = cleanText(String(value), false) - return text.length > 0 ? text : null -} - function escapeAndTruncate(value: string, maxLength: number, singleLine: boolean): string { return truncateText(escapeDiscordMarkdownLiteral(value), maxLength, singleLine) } diff --git a/src/provider/NewRelic.ts b/src/provider/NewRelic.ts index edbb653..41dd8a7 100644 --- a/src/provider/NewRelic.ts +++ b/src/provider/NewRelic.ts @@ -1,22 +1,17 @@ -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://docs.newrelic.com/docs/alerts/new-relic-alerts/managing-notification-channels/customize-your-webhook-payload */ -export class NewRelic extends DirectParseProvider { - public getName(): string { - return 'New Relic' - } - - public getPath(): string { - return 'newrelic' - } - - public async parseData(): Promise { - this.addEmbed({ - title: `${this.body.condition_name} ${this.body.current_state}`, - url: this.body.incident_url, - description: this.body.details, +export const NewRelic = defineProvider({ + path: 'newrelic', + name: 'New Relic', + example: { body: 'newrelic/newrelic.json' }, + map({ body }, output) { + output.addEmbed({ + title: `${body.condition_name} ${body.current_state}`, + url: body.incident_url, + description: body.details, }) - } -} + }, +}) diff --git a/src/provider/Patreon.ts b/src/provider/Patreon.ts index f93af04..051b518 100644 --- a/src/provider/Patreon.ts +++ b/src/provider/Patreon.ts @@ -1,5 +1,5 @@ import type { Embed } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' +import { defineEventProvider } from './Provider.ts' const PatreonAction = { CREATE: 'CREATE', @@ -8,230 +8,155 @@ const PatreonAction = { } as const type PatreonAction = (typeof PatreonAction)[keyof typeof PatreonAction] +const boldRegex = /(.*?)<\/strong>/ +const italicRegex = /(.*?)<\/em>/ +const underlineRegex = /(.*?)<\/u>/ +const anchorRegex = /(.*?)<\/a>/ +const ulRegex = /
    (.*?)<\/ul>/ +const liRegex = /
  • (.*?)<\/li>/ +const imageRegex = // + /** * https://docs.patreon.com/#webhooks */ -export class Patreon extends TypeParseProvider { - // HTML Regular Expressions - private static boldRegex = /(.*?)<\/strong>/ - private static italicRegex = /(.*?)<\/em>/ - private static underlineRegex = /(.*?)<\/u>/ - private static anchorRegex = /(.*?)<\/a>/ - private static ulRegex = /
      (.*?)<\/ul>/ - private static liRegex = /
    • (.*?)<\/li>/ - private static imageRegex = // - - private static _formatHTML(html: string, baseLink: string): string { - const newLineRegex = /
      /g - // Match lists - while (Patreon.ulRegex.test(html)) { - const match = Patreon.ulRegex.exec(html)! - html = html.replace(Patreon.ulRegex, match[1]) - let str = match[1] - while (Patreon.liRegex.test(str)) { - const match2 = Patreon.liRegex.exec(match[1])! - str = str.replace(match2[0], '') - html = html.replace(Patreon.liRegex, '\uFEFF\u00A0\u00A0\u00A0\u00A0\u2022 ' + match2[1] + '\n') - } - } - // Match bold - while (Patreon.boldRegex.test(html)) { - const match = Patreon.boldRegex.exec(html)! - html = html.replace(Patreon.boldRegex, '**' + match[1] + '**') - } - // Match Italic - while (Patreon.italicRegex.test(html)) { - const match = Patreon.italicRegex.exec(html)! - html = html.replace(Patreon.italicRegex, '_' + match[1] + '_') - } - // Replace Underlined - while (Patreon.underlineRegex.test(html)) { - const match = Patreon.underlineRegex.exec(html)! - html = html.replace(Patreon.underlineRegex, '__' + match[1] + '__') - } - // Replace Anchors - while (Patreon.anchorRegex.test(html)) { - const match = Patreon.anchorRegex.exec(html)! - const url = match[1].startsWith('#') ? baseLink + match[1] : match[1] - html = html.replace(Patreon.anchorRegex, '[' + match[2] + '](' + url + ')') - } - // Replace Images - while (Patreon.imageRegex.test(html)) { - const match = Patreon.imageRegex.exec(html)! - html = html.replace(Patreon.imageRegex, '[View Image..](' + match[1] + ')') - } - // Replace all br tags - html = html.replace(newLineRegex, '\n') - return html - } - - constructor() { - super() - this.setEmbedColor(0xf96854) - } - - public getName(): string { - return 'Patreon' - } - - public getType(): string | null { - return this.headers['x-patreon-event'] +export const Patreon = defineEventProvider({ + path: 'patreon', + name: 'Patreon', + example: { + body: 'patreon/patreon-member-create.json', + headers: 'patreon/patreon.headers.json', + }, + defaults: { embedColor: 0xf96854 }, + event: ({ headers }) => headers.get('x-patreon-event'), + handlers: { + 'members:create': apiV2Handler(PatreonAction.CREATE), + 'members:update': apiV2Handler(PatreonAction.UPDATE), + 'members:delete': apiV2Handler(PatreonAction.DELETE), + 'members:pledge:create': apiV2Handler(PatreonAction.CREATE), + 'members:pledge:update': apiV2Handler(PatreonAction.UPDATE), + 'members:pledge:delete': apiV2Handler(PatreonAction.DELETE), + 'pledges:create': apiV1Handler(PatreonAction.CREATE), + 'pledges:update': apiV1Handler(PatreonAction.UPDATE), + 'pledges:delete': apiV1Handler(PatreonAction.DELETE), + }, +}) + +function apiV2Handler(type: PatreonAction) { + return ({ body }: { body: Record }, output: { addEmbed(embed: Embed): void }): void => { + output.addEmbed(handleApiV2(body, type)) } +} - public knownTypes(): string[] { - return [ - 'membersCreate', - 'membersUpdate', - 'membersDelete', - 'membersPledgeCreate', - 'membersPledgeUpdate', - 'membersPledgeDelete', - 'pledgesCreate', - 'pledgesUpdate', - 'pledgesDelete', - ] +function apiV1Handler(type: PatreonAction) { + return ({ body }: { body: Record }, output: { addEmbed(embed: Embed): void }): void => { + output.addEmbed(handleApiV1(body, type)) } +} - private _handleAPIV2(type: PatreonAction): void { - const embed: Embed = {} - const campaignId = this.body.data.relationships.campaign?.data?.id - const patronId = this.body.data.relationships.user?.data?.id - - // TODO Test endpoint may be returning incomplete data. - // Does not provide a way to get the reward without keying off of amount_cents. - // Keep an eye on data.relationships.currently_entitled_tiers - // For now, find closest tier that is below or at the cents value. - const rewards = (this.body.included as any[]).filter( - (val) => - val.type === 'reward' && - val.attributes.published && - val.attributes.amount_cents <= this.body.data.attributes.pledge_amount_cents, - ) - const reward = - rewards.length > 0 - ? rewards.reduce((a, b) => { - const max = Math.max(a.attributes.amount_cents, b.attributes.amount_cents) - return max === a.attributes.amount_cents ? a : b - }) - : null - - for (const entry of this.body.included) { - if (entry.type === 'campaign' && entry.id === campaignId) { - const dollarAmount = (this.body.data.attributes.pledge_amount_cents / 100).toFixed(2) - if (type === PatreonAction.DELETE) { - embed.title = `Canceled $${dollarAmount} Pledge` - } else { - embed.title = `Pledged $${dollarAmount}` - } - embed.url = entry.attributes.url - } else if (entry.type === 'user' && entry.id === patronId) { - embed.author = { - name: entry.attributes.full_name, - icon_url: entry.attributes.thumb_url, - url: entry.attributes.url, - } +function handleApiV2(body: Record, type: PatreonAction): Embed { + const embed: Embed = {} + const campaignId = body.data.relationships.campaign?.data?.id + const patronId = body.data.relationships.user?.data?.id + const rewards = (body.included as any[]).filter( + (value) => + value.type === 'reward' && + value.attributes.published && + value.attributes.amount_cents <= body.data.attributes.pledge_amount_cents, + ) + const reward = + rewards.length === 0 + ? null + : rewards.reduce((a, b) => (a.attributes.amount_cents >= b.attributes.amount_cents ? a : b)) + + for (const entry of body.included) { + if (entry.type === 'campaign' && entry.id === campaignId) { + const dollarAmount = (body.data.attributes.pledge_amount_cents / 100).toFixed(2) + embed.title = + type === PatreonAction.DELETE ? `Canceled $${dollarAmount} Pledge` : `Pledged $${dollarAmount}` + embed.url = entry.attributes.url + } else if (entry.type === 'user' && entry.id === patronId) { + embed.author = { + name: entry.attributes.full_name, + icon_url: entry.attributes.thumb_url, + url: entry.attributes.url, } } - - if (reward != null && type !== PatreonAction.DELETE) { - embed.fields = [ - { - name: 'Unlocked Tier', - value: `[${reward.attributes.title} ($${(reward.attributes.amount_cents / 100).toFixed(2)}+/mo)](https://www.patreon.com${reward.attributes.url})\n${Patreon._formatHTML(reward.attributes.description, embed.url!)}`, - inline: false, - }, - ] - } - this.addEmbed(embed) } - private async membersCreate(): Promise { - this._handleAPIV2(PatreonAction.CREATE) - } + addRewardField(embed, reward, type) + return embed +} - private async membersUpdate(): Promise { - this._handleAPIV2(PatreonAction.UPDATE) +function handleApiV1(body: Record, type: PatreonAction): Embed { + const embed: Embed = {} + const campaignId = body.data.relationships.campaign?.data?.id + const patronId = body.data.relationships.patron?.data?.id + const rewardId = body.data.relationships.reward?.data?.id + let reward: any = null + + for (const entry of body.included) { + if (entry.id === campaignId) { + const dollarAmount = (body.data.attributes.amount_cents / 100).toFixed(2) + embed.title = + type === PatreonAction.DELETE ? `Canceled $${dollarAmount} Pledge` : `Pledged $${dollarAmount}` + embed.url = entry.attributes.url + } else if (entry.id === patronId) { + embed.author = { + name: entry.attributes.full_name, + icon_url: entry.attributes.thumb_url, + url: entry.attributes.url, + } + } else if (entry.id === rewardId) { + reward = entry + } } - private async membersDelete(): Promise { - this._handleAPIV2(PatreonAction.DELETE) - } + addRewardField(embed, reward, type) + return embed +} - private async membersPledgeCreate(): Promise { - this._handleAPIV2(PatreonAction.CREATE) - } +function addRewardField(embed: Embed, reward: any, type: PatreonAction): void { + if (reward == null || type === PatreonAction.DELETE) return + embed.fields = [ + { + name: 'Unlocked Tier', + value: `[${reward.attributes.title} ($${(reward.attributes.amount_cents / 100).toFixed(2)}+/mo)](https://www.patreon.com${reward.attributes.url})\n${formatHtml(reward.attributes.description, embed.url!)}`, + inline: false, + }, + ] +} - private async membersPledgeUpdate(): Promise { - this._handleAPIV2(PatreonAction.UPDATE) +function formatHtml(html: string, baseLink: string): string { + while (ulRegex.test(html)) { + const match = ulRegex.exec(html)! + html = html.replace(ulRegex, match[1]) + let list = match[1] + while (liRegex.test(list)) { + const item = liRegex.exec(match[1])! + list = list.replace(item[0], '') + html = html.replace(liRegex, '\uFEFF\u00A0\u00A0\u00A0\u00A0\u2022 ' + item[1] + '\n') + } } - - private async membersPledgeDelete(): Promise { - this._handleAPIV2(PatreonAction.DELETE) + while (boldRegex.test(html)) { + const match = boldRegex.exec(html)! + html = html.replace(boldRegex, '**' + match[1] + '**') } - - /** - * @deprecated Patreon v1 API - */ - private _createUpdateCommon(type: PatreonAction): void { - const embed: Embed = {} - const campaignId = this.body.data.relationships.campaign?.data?.id - const patronId = this.body.data.relationships.patron?.data?.id - const rewardId = this.body.data.relationships.reward?.data?.id - - const incl = this.body.included - // This is deprecated, don't care. - let reward: any = null - - // This is deprecated, don't care. - incl.forEach((attr: any) => { - if (attr.id === campaignId) { - const dollarAmount = (this.body.data.attributes.amount_cents / 100).toFixed(2) - if (type === PatreonAction.DELETE) { - embed.title = `Canceled $${dollarAmount} Pledge` - } else { - embed.title = `Pledged $${dollarAmount}` - } - embed.url = attr.attributes.url - } else if (attr.id === patronId) { - embed.author = { - name: attr.attributes.full_name, - icon_url: attr.attributes.thumb_url, - url: attr.attributes.url, - } - } else if (attr.id === rewardId) { - reward = attr - } - }) - if (reward != null && type !== PatreonAction.DELETE) { - embed.fields = [ - { - name: 'Unlocked Tier', - value: `[${reward.attributes.title} ($${(reward.attributes.amount_cents / 100).toFixed(2)}+/mo)](https://www.patreon.com${reward.attributes.url})\n${Patreon._formatHTML(reward.attributes.description, embed.url!)}`, - inline: false, - }, - ] - } - this.addEmbed(embed) + while (italicRegex.test(html)) { + const match = italicRegex.exec(html)! + html = html.replace(italicRegex, '_' + match[1] + '_') } - - /** - * @deprecated Patreon v1 API - */ - private async pledgesCreate(): Promise { - this._createUpdateCommon(PatreonAction.CREATE) + while (underlineRegex.test(html)) { + const match = underlineRegex.exec(html)! + html = html.replace(underlineRegex, '__' + match[1] + '__') } - - /** - * @deprecated Patreon v1 API - */ - private async pledgesUpdate(): Promise { - this._createUpdateCommon(PatreonAction.UPDATE) + while (anchorRegex.test(html)) { + const match = anchorRegex.exec(html)! + const url = match[1].startsWith('#') ? baseLink + match[1] : match[1] + html = html.replace(anchorRegex, `[${match[2]}](${url})`) } - - /** - * @deprecated Patreon v1 API - */ - private async pledgesDelete(): Promise { - this._createUpdateCommon(PatreonAction.DELETE) + while (imageRegex.test(html)) { + const match = imageRegex.exec(html)! + html = html.replace(imageRegex, `[View Image..](${match[1]})`) } + return html.replace(/
      /g, '\n') } diff --git a/src/provider/Pingdom.ts b/src/provider/Pingdom.ts index 0287ba0..f26905a 100644 --- a/src/provider/Pingdom.ts +++ b/src/provider/Pingdom.ts @@ -1,20 +1,19 @@ -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://www.pingdom.com/resources/webhooks */ -export class Pingdom extends DirectParseProvider { - public getName(): string { - return 'Pingdom' - } - - public async parseData(): Promise { - if (this.body.current_state !== this.body.previous_state) { - this.setEmbedColor(this.body.current_state === 'UP' ? 0x4caf50 : 0xd32f2f) - this.addEmbed({ - title: this.body.check_name + ' - State changed', - description: 'State change from ' + this.body.previous_state + ' to ' + this.body.current_state, +export const Pingdom = defineProvider({ + path: 'pingdom', + name: 'Pingdom', + example: { body: 'pingdom/pingdom.json' }, + map({ body }, output) { + if (body.current_state !== body.previous_state) { + output.setEmbedColor(body.current_state === 'UP' ? 0x4caf50 : 0xd32f2f) + output.addEmbed({ + title: body.check_name + ' - State changed', + description: 'State change from ' + body.previous_state + ' to ' + body.current_state, }) } - } -} + }, +}) diff --git a/src/provider/Provider.ts b/src/provider/Provider.ts new file mode 100644 index 0000000..f208e9e --- /dev/null +++ b/src/provider/Provider.ts @@ -0,0 +1,15 @@ +export { finalizeDiscordPayload } from './core/DiscordPayloadFinalizer.ts' +export { defineEventProvider, defineProvider } from './core/ProviderDefinition.ts' +export { executeProvider } from './core/ProviderExecution.ts' +export { ProviderOutput } from './core/ProviderOutput.ts' +export type { + EventProviderOptions, + ProviderDefaults, + ProviderDefinition, + ProviderExampleFiles, + ProviderHttp, + ProviderHttpPolicy, + ProviderMapper, + ProviderRequest, + ProviderRunInput, +} from './core/ProviderTypes.ts' diff --git a/src/provider/ProviderRegistry.ts b/src/provider/ProviderRegistry.ts index 955f455..6472b22 100644 --- a/src/provider/ProviderRegistry.ts +++ b/src/provider/ProviderRegistry.ts @@ -2,7 +2,6 @@ import { AppCenter } from './AppCenter.ts' import { AppVeyor } from './Appveyor.ts' import { AzureDevOps } from './AzureDevOps.ts' import { Basecamp } from './Basecamp.ts' -import type { BaseProvider } from './BaseProvider.ts' import { BitBucketServer } from './BitBucketServer.ts' import { BitBucket } from './Bitbucket.ts' import { Buildkite } from './Buildkite.ts' @@ -20,6 +19,7 @@ import { Linear } from './Linear.ts' import { NewRelic } from './NewRelic.ts' import { Patreon } from './Patreon.ts' import { Pingdom } from './Pingdom.ts' +import type { ProviderDefinition } from './Provider.ts' import { Rollbar } from './Rollbar.ts' import { Shopify } from './Shopify.ts' import { Square } from './Square.ts' @@ -29,20 +29,9 @@ import { Unity } from './Unity.ts' import { UptimeRobot } from './UptimeRobot.ts' import { Zendesk } from './Zendesk.ts' -export type ProviderConstructor = new () => BaseProvider +// provider-scaffold: imports -export interface ProviderExampleFiles { - readonly body: string - readonly headers?: string - readonly query?: string -} - -export interface ProviderDefinition { - readonly path: string - readonly name: string - readonly provider: ProviderConstructor - readonly example: ProviderExampleFiles -} +export type { ProviderDefinition } from './Provider.ts' export interface ProviderInfo { readonly name: string @@ -56,34 +45,17 @@ export class ProviderRegistry { public constructor(definitions: readonly ProviderDefinition[]) { const definitionsByPath = new Map() - const registeredDefinitions: ProviderDefinition[] = [] - for (const input of definitions) { - if (definitionsByPath.has(input.path)) { - throw new Error(`Duplicate provider path "${input.path}".`) + for (const definition of definitions) { + if (definitionsByPath.has(definition.path)) { + throw new Error(`Duplicate provider path "${definition.path}".`) } - - const provider = new input.provider() - const providerPath = provider.getPath() - if (providerPath !== input.path) { - throw new Error(`Provider path "${input.path}" does not match "${providerPath}".`) - } - const providerName = provider.getName() - if (providerName !== input.name) { - throw new Error(`Provider name "${input.name}" does not match "${providerName}".`) - } - - const definition = Object.freeze({ - ...input, - example: Object.freeze({ ...input.example }), - }) definitionsByPath.set(definition.path, definition) - registeredDefinitions.push(definition) } this.definitionsByPath = definitionsByPath - this.definitions = Object.freeze(registeredDefinitions) - this.providerInfos = Object.freeze(registeredDefinitions.map(({ name, path }) => Object.freeze({ name, path }))) + this.definitions = Object.freeze([...definitions]) + this.providerInfos = Object.freeze(definitions.map(({ name, path }) => Object.freeze({ name, path }))) } public get(path: string): ProviderDefinition | undefined { @@ -96,198 +68,36 @@ export class ProviderRegistry { } const providerDefinitions: readonly ProviderDefinition[] = [ - { - path: 'appcenter', - name: 'AppCenter', - provider: AppCenter, - example: { body: 'appcenter/appcenter-pipeline.json' }, - }, - { - path: 'appveyor', - name: 'AppVeyor', - provider: AppVeyor, - example: { body: 'appveyor/appveyor.json' }, - }, - { - path: 'basecamp', - name: 'Basecamp', - provider: Basecamp, - example: { body: 'basecamp/basecamp.json' }, - }, - { - path: 'bitbucket', - name: 'BitBucket', - provider: BitBucket, - example: { - body: 'bitbucket/bitbucket.json', - headers: 'bitbucket/bitbucket.headers.json', - }, - }, - { - path: 'bitbucketserver', - name: 'BitBucketServer', - provider: BitBucketServer, - example: { - body: 'bitbucketserver/bitbucketserver.json', - headers: 'bitbucketserver/bitbucketserver.headers.json', - }, - }, - { - path: 'buildkite', - name: 'Buildkite', - provider: Buildkite, - example: { - body: 'buildkite/buildkite.json', - headers: 'buildkite/buildkite.headers.json', - }, - }, - { - path: 'circleci', - name: 'CircleCi', - provider: CircleCi, - example: { body: 'circleci/circleci.json' }, - }, - { - path: 'codacy', - name: 'Codacy', - provider: Codacy, - example: { body: 'codacy/codacy.json' }, - }, - { - path: 'confluence', - name: 'Confluence', - provider: Confluence, - example: { body: 'confluence/confluence_page.json' }, - }, - { - path: 'dockerhub', - name: 'DockerHub', - provider: DockerHub, - example: { body: 'dockerhub/dockerhub.json' }, - }, - { - path: 'gitlab', - name: 'GitLab', - provider: GitLab, - example: { body: 'gitlab/gitlab.json' }, - }, - { - path: 'heroku', - name: 'Heroku', - provider: Heroku, - example: { body: 'heroku/heroku.json' }, - }, - { - path: 'huggingface', - name: 'Hugging Face', - provider: HuggingFace, - example: { body: 'huggingface/huggingface.json' }, - }, - { - path: 'instana', - name: 'Instana', - provider: Instana, - example: { body: 'instana/instana.json' }, - }, - { - path: 'jenkins', - name: 'Jenkins-CI', - provider: Jenkins, - example: { body: 'jenkins/jenkins.json' }, - }, - { - path: 'jira', - name: 'Jira', - provider: Jira, - example: { body: 'jira/jira-issue.json' }, - }, - { - path: 'linear', - name: 'Linear', - provider: Linear, - example: { - body: 'linear/linear.json', - headers: 'linear/linear.headers.json', - }, - }, - { - path: 'newrelic', - name: 'New Relic', - provider: NewRelic, - example: { body: 'newrelic/newrelic.json' }, - }, - { - path: 'patreon', - name: 'Patreon', - provider: Patreon, - example: { - body: 'patreon/patreon-member-create.json', - headers: 'patreon/patreon.headers.json', - }, - }, - { - path: 'pingdom', - name: 'Pingdom', - provider: Pingdom, - example: { body: 'pingdom/pingdom.json' }, - }, - { - path: 'rollbar', - name: 'Rollbar', - provider: Rollbar, - example: { body: 'rollbar/rollbar.json' }, - }, - { - path: 'shopify', - name: 'Shopify', - provider: Shopify, - example: { - body: 'shopify/shopify.json', - headers: 'shopify/shopify.headers.json', - }, - }, - { - path: 'square', - name: 'Square', - provider: Square, - example: { body: 'square/square.json' }, - }, - { - path: 'travis', - name: 'Travis', - provider: Travis, - example: { body: 'travis/travis.json' }, - }, - { - path: 'trello', - name: 'Trello', - provider: Trello, - example: { body: 'trello/trello.json' }, - }, - { - path: 'unity', - name: 'Unity Cloud', - provider: Unity, - example: { body: 'unity/unity.json' }, - }, - { - path: 'uptimerobot', - name: 'Uptime Robot', - provider: UptimeRobot, - example: { body: 'uptimerobot/uptimerobot.json' }, - }, - { - path: 'azure', - name: 'Azure DevOps', - provider: AzureDevOps, - example: { body: 'azure/azure.json' }, - }, - { - path: 'zendesk', - name: 'Zendesk', - provider: Zendesk, - example: { body: 'zendesk/zendesk.json' }, - }, + AppCenter, + AppVeyor, + Basecamp, + BitBucket, + BitBucketServer, + Buildkite, + CircleCi, + Codacy, + Confluence, + DockerHub, + GitLab, + Heroku, + HuggingFace, + Instana, + Jenkins, + Jira, + Linear, + NewRelic, + Patreon, + Pingdom, + Rollbar, + Shopify, + Square, + Travis, + Trello, + Unity, + UptimeRobot, + AzureDevOps, + Zendesk, + // provider-scaffold: definitions ] export const providerRegistry = new ProviderRegistry(providerDefinitions) diff --git a/src/provider/ProviderRunner.ts b/src/provider/ProviderRunner.ts index 49ee504..ae724f7 100644 --- a/src/provider/ProviderRunner.ts +++ b/src/provider/ProviderRunner.ts @@ -2,13 +2,10 @@ import type { DiscordPayload } from '../model/DiscordApi.ts' import { loadProviderExample } from '../ProviderExamples.ts' import { validateDiscordPayload } from '../util/DiscordPayloadValidator.ts' import { type Logger, logger } from '../util/logger.ts' +import { executeProvider, type ProviderRunInput } from './Provider.ts' import { type ProviderRegistry, providerRegistry } from './ProviderRegistry.ts' -export interface ProviderRunInput { - readonly body: any - readonly headers?: any - readonly query?: any -} +export type { ProviderRunInput } from './Provider.ts' type WarningLogger = Pick @@ -27,8 +24,7 @@ export class ProviderRunner { throw new Error(`Unknown provider ${providerPath}`) } - const provider = new definition.provider() - const payload = await provider.parse(input.body, input.headers ?? null, input.query ?? null) + const payload = await executeProvider(definition, input) if (payload != null) { const issues = validateDiscordPayload(payload) if (issues.length > 0) { diff --git a/src/provider/Rollbar.ts b/src/provider/Rollbar.ts index e969413..14a12b8 100644 --- a/src/provider/Rollbar.ts +++ b/src/provider/Rollbar.ts @@ -1,81 +1,47 @@ import type { Embed } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' +import { defineEventProvider } from './Provider.ts' /** * https://docs.rollbar.com/docs/webhooks */ -export class Rollbar extends TypeParseProvider { - private embed: Embed - - constructor() { - super() - this.setEmbedColor(0x3884cb) - this.embed = {} - } - - public getName(): string { - return 'Rollbar' - } - - public getType(): string | null { - return this.body.event_name - } - - public knownTypes(): string[] { - return [ - 'expRepeatItem', - 'deploy', - 'itemVelocity', - 'newItem', - 'occurrence', - 'reactivatedItem', - 'reopenedItem', - 'resolvedItem', - ] - } - - public async expRepeatItem(): Promise { - this.embed.title = `${this.body.data.occurrence} occurrence of issue` - this.embed.description = this.body.data.item.title - } - - public async deploy(): Promise { - const deploy = this.body.data.deploy - this.embed.title = `New Deploy to ${deploy.environment}` - this.embed.description = deploy.comment - } - - public async itemVelocity(): Promise { - this.embed.title = 'Velocity increase of issue' - this.embed.description = this.body.data.item.title - } - - public async newItem(): Promise { - this.embed.title = 'Velocity increase of issue' - this.embed.description = this.body.data.item.title - } - - public async occurrence(): Promise { - this.embed.title = 'New issue' - this.embed.description = this.body.data.item.title - } - - public async reactivatedItem(): Promise { - this.embed.title = 'Issue reactivated' - this.embed.description = this.body.data.item.title - } - - public async reopenedItem(): Promise { - this.embed.title = 'Issue reopened' - this.embed.description = this.body.data.item.title - } - - public async resolvedItem(): Promise { - this.embed.title = 'Issue resolved' - this.embed.description = this.body.data.item.title - } - - public postParse(): void { - this.addEmbed(this.embed) - } +export const Rollbar = defineEventProvider({ + path: 'rollbar', + name: 'Rollbar', + example: { body: 'rollbar/rollbar.json' }, + defaults: { embedColor: 0x3884cb }, + event: 'event_name', + handlers: { + exp_repeat_item({ body }, output) { + output.addEmbed(issueEmbed(`${body.data.occurrence} occurrence of issue`, body)) + }, + deploy({ body }, output) { + const deploy = body.data.deploy + output.addEmbed({ + title: `New Deploy to ${deploy.environment}`, + description: deploy.comment, + }) + }, + item_velocity({ body }, output) { + output.addEmbed(issueEmbed('Velocity increase of issue', body)) + }, + new_item({ body }, output) { + output.addEmbed(issueEmbed('Velocity increase of issue', body)) + }, + occurrence({ body }, output) { + output.addEmbed(issueEmbed('New issue', body)) + }, + reactivated_item({ body }, output) { + output.addEmbed(issueEmbed('Issue reactivated', body)) + }, + reopened_item({ body }, output) { + output.addEmbed(issueEmbed('Issue reopened', body)) + }, + resolved_item({ body }, output) { + output.addEmbed(issueEmbed('Issue resolved', body)) + }, + }, +}) + +function issueEmbed(title: string, body: Record): Embed { + return { title, description: body.data.item.title } } diff --git a/src/provider/Shopify.ts b/src/provider/Shopify.ts index b8c5a80..8fdd721 100644 --- a/src/provider/Shopify.ts +++ b/src/provider/Shopify.ts @@ -1,8 +1,8 @@ import type { Embed, EmbedField } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts' -import { cleanText, humanizeWords, truncateText } from '../util/DiscordText.ts' -import { canonicalizeIso8601Timestamp, isRecord } from '../util/WebhookValue.ts' +import { humanizeWords, truncateText } from '../util/DiscordText.ts' +import { canonicalizeIso8601Timestamp, isRecord, safeId, scalarText } from '../util/WebhookValue.ts' +import { defineProvider, type ProviderOutput } from './Provider.ts' const ACTION_LABELS: Record = { activate: 'activated', @@ -71,24 +71,32 @@ const GENERIC_FIELD_KEYS = [ * @see https://shopify.dev/docs/api/webhooks/latest * @see https://shopify.dev/docs/apps/build/webhooks/delivery-structure */ -export class Shopify extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0x95bf47) +export const Shopify = defineProvider({ + path: 'shopify', + name: 'Shopify', + example: { + body: 'shopify/shopify.json', + headers: 'shopify/shopify.headers.json', + }, + defaults: { embedColor: 0x95bf47 }, + map({ body, headers }, output) { + new ShopifyMapper(body, headers).map(output) + }, +}) + +class ShopifyMapper { + private readonly body: Record + private readonly headers: Headers + + public constructor(body: Record, headers: Headers) { + this.body = body + this.headers = headers } - public getName(): string { - return 'Shopify' - } - - public getPath(): string { - return 'shopify' - } - - public async parseData(): Promise { + public map(output: ProviderOutput): void { const topic = this.getHeader('x-shopify-topic').trim().toLowerCase() - if (topic.length === 0 || !isRecord(this.body)) { - this.nullifyPayload() + if (topic.length === 0) { + output.ignore() return } @@ -126,23 +134,11 @@ export class Shopify extends DirectParseProvider { const fields = this.createFields(resourcePart, actionPart) embed.fields = fitLiteralEmbedFields(embed, fields) - this.payload.allowed_mentions = { parse: [] } - this.addEmbed(embed) + output.addEmbed(embed) } private getHeader(name: string): string { - if (this.headers == null) { - return '' - } - if (typeof this.headers.get === 'function') { - return String(this.headers.get(name) ?? '') - } - for (const [key, value] of Object.entries(this.headers)) { - if (key.toLowerCase() === name) { - return String(value ?? '') - } - } - return '' + return this.headers.get(name) ?? '' } private getShopDomain(): string | null { @@ -350,26 +346,7 @@ function humanizeResource(resource: string): string { return humanizeWords(words.join('_')) } -function safeId(value: unknown): string | null { - if (typeof value === 'string') { - const cleaned = cleanText(value, true) - return cleaned.length > 0 ? cleaned : null - } - if (typeof value === 'number' && Number.isSafeInteger(value)) { - return String(value) - } - return null -} - function safeAdminId(value: unknown): string | null { const id = safeId(value) return id != null && /^\d{1,32}$/.test(id) ? id : null } - -function scalarText(value: unknown): string | null { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - return null - } - const text = cleanText(String(value), false) - return text.length > 0 ? text : null -} diff --git a/src/provider/Square.ts b/src/provider/Square.ts index e77ecfd..8dbbc49 100644 --- a/src/provider/Square.ts +++ b/src/provider/Square.ts @@ -1,8 +1,8 @@ import type { Embed, EmbedField } from '../model/DiscordApi.ts' import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts' import { cleanText, escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts' -import { canonicalizeIso8601Timestamp, isRecord } from '../util/WebhookValue.ts' -import { DirectParseProvider } from './BaseProvider.ts' +import { canonicalizeIso8601Timestamp, firstScalar, isRecord, scalarText } from '../util/WebhookValue.ts' +import { defineProvider } from './Provider.ts' const EVENT_TYPE_PATTERN = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$/ const DATA_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/ @@ -18,33 +18,20 @@ const DATA_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/ * @see https://developer.squareup.com/docs/webhooks/overview * @see https://developer.squareup.com/docs/webhooks/step2subscribe#format-of-an-event-notification */ -export class Square extends DirectParseProvider { - public constructor() { - super() - this.setEmbedColor(0x006aff) - this.payload.username = 'Square' - this.payload.allowed_mentions = { parse: [] } - } - - public getName(): string { - return 'Square' - } - - public getPath(): string { - return 'square' - } - - public async parseData(): Promise { - if (!isRecord(this.body)) { - this.nullifyPayload() - return - } - - const eventType = boundedEventType(this.body.type) - const eventId = boundedText(this.body.event_id, 128) - const merchantId = boundedText(this.body.merchant_id, 128) - const timestamp = canonicalizeIso8601Timestamp(this.body.created_at) - const data = isRecord(this.body.data) ? this.body.data : null +export const Square = defineProvider({ + path: 'square', + name: 'Square', + example: { body: 'square/square.json' }, + defaults: { + username: 'Square', + embedColor: 0x006aff, + }, + map({ body }, output) { + const eventType = boundedEventType(body.type) + const eventId = boundedText(body.event_id, 128) + const merchantId = boundedText(body.merchant_id, 128) + const timestamp = canonicalizeIso8601Timestamp(body.created_at) + const data = isRecord(body.data) ? body.data : null const dataType = boundedDataType(data?.type) const hasDataId = data != null && Object.hasOwn(data, 'id') const dataId = hasDataId ? boundedText(data.id, 512) : null @@ -57,7 +44,7 @@ export class Square extends DirectParseProvider { dataType == null || (hasDataId && dataId == null) ) { - this.nullifyPayload() + output.ignore() return } @@ -67,10 +54,10 @@ export class Square extends DirectParseProvider { title: truncateText(escapeDiscordMarkdownLiteral(title), DISCORD_EMBED_LIMITS.title, true), timestamp, } - embed.fields = fitLiteralEmbedFields(embed, createFields(this.body, object, dataType, dataId)) - this.addEmbed(embed) - } -} + embed.fields = fitLiteralEmbedFields(embed, createFields(body, object, dataType, dataId)) + output.addEmbed(embed) + }, +}) function createTitle(eventType: string, object: Record | null, dataType: string): string { const parts = eventType.split('.') @@ -182,30 +169,6 @@ function boundedText(value: unknown, maxLength: number): string | null { return text.length > 0 && text.length <= maxLength ? text : null } -function firstScalar(...values: unknown[]): string | null { - for (const value of values) { - const text = scalarText(value) - if (text != null) { - return text - } - } - return null -} - -function scalarText(value: unknown): string | null { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - return null - } - if ( - typeof value === 'number' && - (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) - ) { - return null - } - const text = cleanText(String(value), false) - return text.length > 0 ? text : null -} - function humanizeSquareWords(value: string): string { return humanizeWords(value).replace(/^Oauth\b/, 'OAuth') } diff --git a/src/provider/Travis.ts b/src/provider/Travis.ts index a3f72a3..1a6b9da 100644 --- a/src/provider/Travis.ts +++ b/src/provider/Travis.ts @@ -1,34 +1,31 @@ import type { Embed } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' + +// States: https://github.com/travis-ci/travis-api/blob/master/lib/travis/model/build/states.rb#L25 +const STATUS_COLORS: Readonly> = { + passed: 0x39aa56, + failed: 0xdb4545, + errored: 0xdb4545, + canceled: 0x9d9d9d, +} /** * https://docs.travis-ci.com/user/notifications/#Configuring-webhook-notifications */ -export class Travis extends DirectParseProvider { - // States https://github.com/travis-ci/travis-api/blob/master/lib/travis/model/build/states.rb#L25 - private static STATUS_COLORS: Record = { - passed: 0x39aa56, - failed: 0xdb4545, - errored: 0xdb4545, - canceled: 0x9d9d9d, - } - - public getName(): string { - return 'Travis' - } - - public async parseData(): Promise { - this.setEmbedColor(0x39aa56) +export const Travis = defineProvider({ + path: 'travis', + name: 'Travis', + example: { body: 'travis/travis.json' }, + defaults: { embedColor: 0x39aa56 }, + map({ body }, output) { const embed: Embed = {} - let targetBody = this.body - if (this.body.payload != null && typeof this.body.payload === 'string') { - // Travis now sends data inside of a string payload property. + let targetBody = body + if (typeof body.payload === 'string') { try { - targetBody = JSON.parse(this.body.payload) + targetBody = JSON.parse(body.payload) } catch (error) { - this.logger.info('Malformed payload JSON from travis.') - this.logger.error(error) - targetBody = this.body + output.logger.info('Malformed payload JSON from travis.') + output.logger.error(error) } } @@ -38,13 +35,14 @@ export class Travis extends DirectParseProvider { embed.description = `[\`${targetBody.commit.substring(0, 7)}\`](${targetBody.compare_url}) ${msg.length > 50 ? msg.substring(0, 47) + '...' : msg}` if (targetBody.state != null) { - if (Travis.STATUS_COLORS[targetBody.state] != null) { - this.setEmbedColor(Travis.STATUS_COLORS[targetBody.state]) + const statusColor = STATUS_COLORS[targetBody.state] + if (statusColor != null) { + output.setEmbedColor(statusColor) } else { - this.logger.warn('Unknown Travis build state: ' + targetBody.state) + output.logger.warn('Unknown Travis build state: ' + targetBody.state) } } - this.addEmbed(embed) - } -} + output.addEmbed(embed) + }, +}) diff --git a/src/provider/Trello.ts b/src/provider/Trello.ts index ef42114..63ddaeb 100644 --- a/src/provider/Trello.ts +++ b/src/provider/Trello.ts @@ -1,7 +1,7 @@ import { URL } from 'node:url' import type { Embed, EmbedAuthor, EmbedField } from '../model/DiscordApi.ts' -import { TypeParseProvider } from '../provider/BaseProvider.ts' import { MarkdownUtil } from '../util/MarkdownUtil.ts' +import { defineEventProvider, type ProviderHttp, type ProviderMapper, type ProviderOutput } from './Provider.ts' type Card = any type Board = any @@ -9,10 +9,95 @@ type Attachment = any const PLUGIN_MANIFEST_TIMEOUT_MS = 10_000 +export const Trello = defineEventProvider({ + path: 'trello', + name: 'Trello', + example: { body: 'trello/trello.json' }, + defaults: { embedColor: 0x026aa7 }, + http: { + allowedHosts: ['trello.com', 'www.trello.com', 'api.trello.com'], + timeoutMs: PLUGIN_MANIFEST_TIMEOUT_MS, + maxResponseBytes: 256_000, + }, + event: ({ body }) => (typeof body.action?.type === 'string' ? body.action.type : null), + handlers: { + addAttachmentToCard: trelloHandler((mapper) => mapper.addAttachmentToCard()), + addBoardsPinnedToMember: trelloHandler((mapper) => mapper.addBoardsPinnedToMember()), + addChecklistToCard: trelloHandler((mapper) => mapper.addChecklistToCard()), + addLabelToCard: trelloHandler((mapper) => mapper.addLabelToCard()), + addMemberToCard: trelloHandler((mapper) => mapper.addMemberToCard()), + addMemberToBoard: trelloHandler((mapper) => mapper.addMemberToBoard()), + addMemberToOrganization: trelloHandler((mapper) => mapper.addMemberToOrganization()), + addToOrganizationBoard: trelloHandler((mapper) => mapper.addToOrganizationBoard()), + commentCard: trelloHandler((mapper) => mapper.commentCard()), + convertToCardFromCheckItem: trelloHandler((mapper) => mapper.convertToCardFromCheckItem()), + copyBoard: trelloHandler((mapper) => mapper.copyBoard()), + copyCard: trelloHandler((mapper) => mapper.copyCard()), + copyChecklist: trelloHandler((mapper) => mapper.copyChecklist()), + createLabel: trelloHandler((mapper) => mapper.createLabel()), + copyCommentCard: trelloHandler((mapper) => mapper.copyCommentCard()), + createBoard: trelloHandler((mapper) => mapper.createBoard()), + createBoardInvitation: trelloHandler((mapper) => mapper.createBoardInvitation()), + createBoardPreference: trelloHandler((mapper) => mapper.createBoardPreference()), + createCard: trelloHandler((mapper) => mapper.createCard()), + createCheckItem: trelloHandler((mapper) => mapper.createCheckItem()), + createChecklist: trelloHandler((mapper) => mapper.createChecklist()), + createList: trelloHandler((mapper) => mapper.createList()), + createOrganization: trelloHandler((mapper) => mapper.createOrganization()), + createOrganizationInvitation: trelloHandler((mapper) => mapper.createOrganizationInvitation()), + deleteAttachmentFromCard: trelloHandler((mapper) => mapper.deleteAttachmentFromCard()), + deleteBoardInvitation: trelloHandler((mapper) => mapper.deleteBoardInvitation()), + deleteCard: trelloHandler((mapper) => mapper.deleteCard()), + deleteCheckItem: trelloHandler((mapper) => mapper.deleteCheckItem()), + deleteLabel: trelloHandler((mapper) => mapper.deleteLabel()), + deleteOrganizationInvitation: trelloHandler((mapper) => mapper.deleteOrganizationInvitation()), + disablePlugin: trelloHandler((mapper) => mapper.disablePlugin()), + disable_plugin: trelloHandler((mapper) => mapper.disablePlugin()), + disablePowerUp: trelloHandler((mapper) => mapper.disablePowerUp()), + emailCard: trelloHandler((mapper) => mapper.emailCard()), + enablePlugin: trelloHandler((mapper) => mapper.enablePlugin()), + enable_plugin: trelloHandler((mapper) => mapper.enablePlugin()), + enablePowerUp: trelloHandler((mapper) => mapper.enablePowerUp()), + makeAdminOfBoard: trelloHandler((mapper) => mapper.makeAdminOfBoard()), + makeAdminOfOrganization: trelloHandler((mapper) => mapper.makeAdminOfOrganization()), + makeNormalMemberOfBoard: trelloHandler((mapper) => mapper.makeNormalMemberOfBoard()), + makeNormalMemberOfOrganization: trelloHandler((mapper) => mapper.makeNormalMemberOfOrganization()), + makeObserverOfBoard: trelloHandler((mapper) => mapper.makeObserverOfBoard()), + memberJoinedTrello: trelloHandler((mapper) => mapper.memberJoinedTrello()), + moveCardFromBoard: trelloHandler((mapper) => mapper.moveCardFromBoard()), + moveCardToBoard: trelloHandler((mapper) => mapper.moveCardToBoard()), + moveListFromBoard: trelloHandler((mapper) => mapper.moveListFromBoard()), + moveListToBoard: trelloHandler((mapper) => mapper.moveListToBoard()), + removeBoardsPinnedFromMember: trelloHandler((mapper) => mapper.removeBoardsPinnedFromMember()), + removeChecklistFromCard: trelloHandler((mapper) => mapper.removeChecklistFromCard()), + removeFromOrganizationBoard: trelloHandler((mapper) => mapper.removeFromOrganizationBoard()), + removeLabelFromCard: trelloHandler((mapper) => mapper.removeLabelFromCard()), + removeMemberFromCard: trelloHandler((mapper) => mapper.removeMemberFromCard()), + removeMemberFromBoard: trelloHandler((mapper) => mapper.removeMemberFromBoard()), + removeMemberFromOrganization: trelloHandler((mapper) => mapper.removeMemberFromOrganization()), + unconfirmedBoardInvitation: trelloHandler((mapper) => mapper.unconfirmedBoardInvitation()), + unconfirmedOrganizationInvitation: trelloHandler((mapper) => mapper.unconfirmedOrganizationInvitation()), + updateBoard: trelloHandler((mapper) => mapper.updateBoard()), + updateCard: trelloHandler((mapper) => mapper.updateCard()), + updateCheckItem: trelloHandler((mapper) => mapper.updateCheckItem()), + updateCheckItemStateOnCard: trelloHandler((mapper) => mapper.updateCheckItemStateOnCard()), + updateChecklist: trelloHandler((mapper) => mapper.updateChecklist()), + updateLabel: trelloHandler((mapper) => mapper.updateLabel()), + updateList: trelloHandler((mapper) => mapper.updateList()), + updateMember: trelloHandler((mapper) => mapper.updateMember()), + updateOrganization: trelloHandler((mapper) => mapper.updateOrganization()), + voteOnCard: trelloHandler((mapper) => mapper.voteOnCard()), + }, +}) + +function trelloHandler(run: (mapper: TrelloMapper) => Promise): ProviderMapper { + return ({ body, http }, output) => run(new TrelloMapper(body, output, http)) +} + /** * https://developers.trello.com/apis/webhooks */ -export class Trello extends TypeParseProvider { +class TrelloMapper { private static baseLink = 'https://trello.com/' private static baseAvatarUrl = 'https://trello-avatars.s3.amazonaws.com/' private static defTrelloColors: Record = { @@ -35,7 +120,7 @@ export class Trello extends TypeParseProvider { private static _addMemberThumbnail(avatarHash: string, embed: Embed): void { if (avatarHash != null && avatarHash !== 'null') { embed.thumbnail = { - url: Trello.baseAvatarUrl + avatarHash + '/170.png', + url: TrelloMapper.baseAvatarUrl + avatarHash + '/170.png', } } } @@ -46,82 +131,16 @@ export class Trello extends TypeParseProvider { private action: any private model: any + private readonly body: Record + private readonly output: ProviderOutput + private readonly http: ProviderHttp - public getName(): string { - return 'Trello' - } - - public getType(): string | null { - return this.body.action.type - } - - public knownTypes(): string[] { - return [ - 'addAttachmentToCard', - 'addBoardsPinnedToMember', - 'addChecklistToCard', - 'addLabelToCard', - 'addMemberToCard', - 'addMemberToBoard', - 'addMemberToOrganization', - 'addToOrganizationBoard', - 'commentCard', - 'convertToCardFromCheckItem', - 'copyBoard', - 'copyCard', - 'copyChecklist', - 'createLabel', - 'copyCommentCard', - 'createBoard', - 'createBoardInvitation', - 'createBoardPreference', - 'createCard', - 'createCheckItem', - 'createChecklist', - 'createList', - 'createOrganization', - 'createOrganizationInvitation', - 'deleteAttachmentFromCard', - 'deleteBoardInvitation', - 'deleteCard', - 'deleteCheckItem', - 'deleteLabel', - 'deleteOrganizationInvitation', - 'disablePlugin', - 'disablePowerUp', - 'emailCard', - 'enablePlugin', - 'enablePowerUp', - 'makeAdminOfBoard', - 'makeAdminOfOrganization', - 'makeNormalMemberOfBoard', - 'makeNormalMemberOfOrganization', - 'makeObserverOfBoard', - 'memberJoinedTrello', - 'moveCardFromBoard', - 'moveCardToBoard', - 'moveListFromBoard', - 'moveListToBoard', - 'removeBoardsPinnedFromMember', - 'removeChecklistFromCard', - 'removeFromOrganizationBoard', - 'removeLabelFromCard', - 'removeMemberFromCard', - 'removeMemberFromBoard', - 'removeMemberFromOrganization', - 'unconfirmedBoardInvitation', - 'unconfirmedOrganizationInvitation', - 'updateBoard', - 'updateCard', - 'updateCheckItem', - 'updateCheckItemStateOnCard', - 'updateChecklist', - 'updateLabel', - 'updateList', - 'updateMember', - 'updateOrganization', - 'voteOnCard', - ] + public constructor(body: Record, output: ProviderOutput, http: ProviderHttp) { + this.body = body + this.output = output + this.http = http + this.action = body.action + this.model = body.model } // Webhook Type Responses @@ -131,13 +150,12 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Added Attachment to "' + this.action.data.card.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) this._formatAttachment(this.action.data.attachment, embed) - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async addBoardsPinnedToMember(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async addChecklistToCard(): Promise { @@ -145,7 +163,7 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Added Checklist to "' + this.action.data.card.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) embed.description = '`' + this.action.data.checklist.name + '` has been created.' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async addLabelToCard(): Promise { @@ -153,7 +171,7 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Added Label to "' + this.action.data.card.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) this._formatLabel(this.action.data.text, this.action.data.value, embed) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async addMemberToCard(): Promise { @@ -162,18 +180,18 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Joined "' + this.action.data.card.name + '"' } else { embed.title = '[' + this.action.data.board.name + '] Added User to "' + this.action.data.card.name + '"' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.description = this.action.member.fullName + ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' } embed.url = this._resolveFullCardURL(this.action.data.card) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async addMemberToBoard(): Promise { @@ -182,18 +200,18 @@ export class Trello extends TypeParseProvider { embed.title = 'Joined Board "' + this.action.data.board.name + '"' } else { embed.title = 'Added User to Board "' + this.action.data.board.name + '"' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.description = this.action.member.fullName + ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' } embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async addMemberToOrganization(): Promise { @@ -202,18 +220,18 @@ export class Trello extends TypeParseProvider { embed.title = 'Joined Organization "' + this.action.data.organization.name + '"' } else { embed.title = 'Added User to Organization "' + this.action.data.organization.name + '"' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.description = this.action.member.fullName + ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' } embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async addToOrganizationBoard(): Promise { @@ -226,15 +244,15 @@ export class Trello extends TypeParseProvider { this._resolveFullBoardURL(this.action.data.board) + ') has been created.' embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async commentCard(): Promise { const embed = this._preparePayload() embed.title = '[' + this.action.data.board.name + '] Commented on Card "' + this.action.data.card.name + '"' embed.url = this._resolveFullCommentURL(this.action.data.card, this.action.id) - embed.description = Trello._formatLargeString(this.action.data.text) - this.addEmbed(embed) + embed.description = TrelloMapper._formatLargeString(this.action.data.text) + this.output.addEmbed(embed) } public async convertToCardFromCheckItem(): Promise { @@ -249,7 +267,7 @@ export class Trello extends TypeParseProvider { '`](' + this._resolveFullCardURL(this.action.data.cardSource) + ') has been converted to a card.' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async copyBoard(): Promise { @@ -262,7 +280,7 @@ export class Trello extends TypeParseProvider { '` has been copied from [another board](' + this._resolveBoardURL(this.action.data.boardSource.id) + ').' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async copyCard(): Promise { @@ -279,7 +297,7 @@ export class Trello extends TypeParseProvider { this._resolveFullCardURL(this.action.data.card) + ')' embed.url = this._resolveFullCardURL(this.action.data.card) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async copyChecklist(): Promise { @@ -288,7 +306,7 @@ export class Trello extends TypeParseProvider { embed.description = '`' + this.action.data.checklistSource.name + '` \uD83E\uDC6A `' + this.action.data.checklist.name + '`' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async createLabel(): Promise { @@ -296,32 +314,29 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Created Label' this._formatLabel(this.action.data.label.name, this.action.data.label.color, embed) embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async copyCommentCard(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async createBoard(): Promise { const embed = this._preparePayload() - embed.title = 'Created Board "' + Trello._formatLargeString(this.action.data.board.name) + '"' + embed.title = 'Created Board "' + TrelloMapper._formatLargeString(this.action.data.board.name) + '"' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } // Won't Trigger? public async createBoardInvitation(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } // How to Trigger? public async createBoardPreference(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async createCard(): Promise { @@ -330,7 +345,7 @@ export class Trello extends TypeParseProvider { embed.url = this._resolveFullCardURL(this.action.data.card) embed.description = '`' + this.action.data.card.name + '` has been created in list `' + this.action.data.list.name + '`.' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async createCheckItem(): Promise { @@ -343,13 +358,12 @@ export class Trello extends TypeParseProvider { '` was added to checklist `' + this.action.data.checklist.name + '`.' - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async createChecklist(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async createList(): Promise { @@ -357,7 +371,7 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Created List' embed.description = '`' + this.action.data.list.name + '` has been created.' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async createOrganization(): Promise { @@ -365,13 +379,12 @@ export class Trello extends TypeParseProvider { embed.title = 'Created Organization' embed.description = '`' + this.action.data.organization.name + '` has been created.' embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } // Won't Trigger? public async createOrganizationInvitation(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async deleteAttachmentFromCard(): Promise { @@ -380,13 +393,12 @@ export class Trello extends TypeParseProvider { '[' + this.action.data.board.name + '] Removed Attachment from "' + this.action.data.card.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) this._formatAttachment(this.action.data.attachment, embed) - this.addEmbed(embed) + this.output.addEmbed(embed) } // Won't Trigger? public async deleteBoardInvitation(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async deleteCard(): Promise { @@ -394,7 +406,7 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Deleted Card' embed.description = 'A card was deleted from list `' + this.action.data.list.name + '`.' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async deleteCheckItem(): Promise { @@ -408,20 +420,19 @@ export class Trello extends TypeParseProvider { '` was removed from checklist `' + this.action.data.checklist.name + '`.' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async deleteLabel(): Promise { const embed = this._preparePayload() embed.title = '[' + this.action.data.board.name + '] Deleted Label' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } // Won't Trigger? public async deleteOrganizationInvitation(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async disablePlugin(): Promise { @@ -430,14 +441,12 @@ export class Trello extends TypeParseProvider { // How to Trigger? public async disablePowerUp(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } // How to Trigger? public async emailCard(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async enablePlugin(): Promise { @@ -449,11 +458,7 @@ export class Trello extends TypeParseProvider { embed.url = this._resolveFullBoardURL(this.action.data.board) const url = this.action.data.plugin.url try { - const response = await fetch(url, { signal: AbortSignal.timeout(PLUGIN_MANIFEST_TIMEOUT_MS) }) - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`) - } - const manifest = await response.json() + const manifest = await this.http.getJson(url) const desc = MarkdownUtil._formatMarkdown(manifest.details, embed) embed.title = `[${this.action.data.board.name}] ${action} Plugin ${mark}` embed.fields = [ @@ -468,16 +473,15 @@ export class Trello extends TypeParseProvider { } } catch (err) { const diagnostics = err instanceof Error ? (err.stack ?? err.message) : String(err) - this.logger.warn(`[Trello Provider] Error while retrieving plugin manifest: ${diagnostics}`) + this.output.logger.warn(`[Trello Provider] Error while retrieving plugin manifest: ${diagnostics}`) embed.title = `[${this.action.data.board.name}] ${action} Plugin "${this.action.data.plugin.name}"` } - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async enablePowerUp(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async makeAdminOfBoard(): Promise { @@ -488,12 +492,12 @@ export class Trello extends TypeParseProvider { ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async makeAdminOfOrganization(): Promise { @@ -504,12 +508,12 @@ export class Trello extends TypeParseProvider { ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async makeNormalMemberOfBoard(): Promise { @@ -520,12 +524,12 @@ export class Trello extends TypeParseProvider { ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async makeNormalMemberOfOrganization(): Promise { @@ -536,12 +540,12 @@ export class Trello extends TypeParseProvider { ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } // Unable to test, business class+ feature. @@ -553,18 +557,17 @@ export class Trello extends TypeParseProvider { ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async memberJoinedTrello(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async moveCardFromBoard(): Promise { @@ -581,7 +584,7 @@ export class Trello extends TypeParseProvider { this._resolveBoardURL(this.action.data.boardTarget.id) + ').' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async moveCardToBoard(): Promise { @@ -598,7 +601,7 @@ export class Trello extends TypeParseProvider { this._resolveBoardURL(this.action.data.boardSource.id) + ').' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async moveListFromBoard(): Promise { @@ -611,7 +614,7 @@ export class Trello extends TypeParseProvider { this._resolveBoardURL(this.action.data.boardTarget.id) + ').' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async moveListToBoard(): Promise { @@ -624,14 +627,14 @@ export class Trello extends TypeParseProvider { this._resolveBoardURL(this.action.data.boardSource.id) + ').' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async removeBoardsPinnedFromMember(): Promise { const embed = this._preparePayload() - this.addEmbed(embed) + this.output.addEmbed(embed) } public async removeChecklistFromCard(): Promise { @@ -640,7 +643,7 @@ export class Trello extends TypeParseProvider { '[' + this.action.data.board.name + '] Removed Checklist from "' + this.action.data.card.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) embed.description = '`' + this.action.data.checklist.name + '` has been removed.' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async removeFromOrganizationBoard(): Promise { @@ -648,7 +651,7 @@ export class Trello extends TypeParseProvider { embed.title = 'Removed Board from "' + this.action.data.organization.name + '"' embed.description = '`' + this.action.data.board.name + '` has been deleted.' embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async removeLabelFromCard(): Promise { @@ -656,7 +659,7 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Removed Label from "' + this.action.data.card.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) this._formatLabel(this.action.data.text, this.action.data.value, embed) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async removeMemberFromCard(): Promise { @@ -665,18 +668,18 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Left "' + this.action.data.card.name + '"' } else { embed.title = '[' + this.action.data.board.name + '] Removed User from "' + this.action.data.card.name + '"' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.description = this.action.member.fullName + ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' } embed.url = this._resolveFullCardURL(this.action.data.card) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async removeMemberFromBoard(): Promise { @@ -690,13 +693,13 @@ export class Trello extends TypeParseProvider { ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) } embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async removeMemberFromOrganization(): Promise { @@ -705,30 +708,28 @@ export class Trello extends TypeParseProvider { embed.title = 'Left Organization "' + this.action.data.organization.name + '"' } else { embed.title = 'Removed User from Organization "' + this.action.data.organization.name + '"' - Trello._addMemberThumbnail(this.action.member.avatarHash, embed) + TrelloMapper._addMemberThumbnail(this.action.member.avatarHash, embed) embed.description = this.action.member.fullName + ' ([`' + this.action.member.username + '`](' + - Trello.baseLink + + TrelloMapper.baseLink + this.action.member.username + '))' } embed.url = this._resolveGenericURL(this.action.data.organization.id) - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async unconfirmedBoardInvitation(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } // How to trigger? public async unconfirmedOrganizationInvitation(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async updateBoard(): Promise { @@ -798,10 +799,11 @@ export class Trello extends TypeParseProvider { } } else if (old.prefs.background != null) { const val = - Trello.defTrelloColors[this.action.data.board.prefs.background] == null + TrelloMapper.defTrelloColors[this.action.data.board.prefs.background] == null ? 'image' : this.action.data.board.prefs.background - const oldVal = Trello.defTrelloColors[old.prefs.background] == null ? 'image' : old.prefs.background + const oldVal = + TrelloMapper.defTrelloColors[old.prefs.background] == null ? 'image' : old.prefs.background field = { name: 'Background', value: '`' + oldVal + '` \uD83E\uDC6A `' + val + '`', @@ -813,7 +815,7 @@ export class Trello extends TypeParseProvider { if (field != null) { embed.fields = [field] } - this.addEmbed(embed) + this.output.addEmbed(embed) } public async updateCard(): Promise { @@ -829,19 +831,19 @@ export class Trello extends TypeParseProvider { } else if (old.desc != null) { if (!old.desc) { embed.title = embed.title + 'Added Description to Card "' + this.action.data.card.name + '"' - embed.description = Trello._formatLargeString( + embed.description = TrelloMapper._formatLargeString( MarkdownUtil._formatMarkdown(this.action.data.card.desc, embed), ) } else if (!this.action.data.card.desc) { embed.title = embed.title + 'Removed Description from Card "' + this.action.data.card.name + '"' field = { name: 'Old Value', - value: Trello._formatLargeString(MarkdownUtil._formatMarkdown(old.desc, embed)), + value: TrelloMapper._formatLargeString(MarkdownUtil._formatMarkdown(old.desc, embed)), inline: false, } } else { embed.title = embed.title + 'Updated Description of Card "' + this.action.data.card.name + '"' - embed.description = Trello._formatLargeString( + embed.description = TrelloMapper._formatLargeString( MarkdownUtil._formatMarkdown(this.action.data.card.desc, embed), ) } @@ -882,7 +884,7 @@ export class Trello extends TypeParseProvider { if (field != null) { embed.fields = [field] } - this.addEmbed(embed) + this.output.addEmbed(embed) } public async updateCheckItem(): Promise { @@ -891,7 +893,7 @@ export class Trello extends TypeParseProvider { '[' + this.action.data.board.name + '] Renamed Item in Checklist "' + this.action.data.checklist.name + '"' embed.url = this._resolveFullCardURL(this.action.data.card) embed.description = '`' + this.action.data.old.name + '` \uD83E\uDC6A `' + this.action.data.checkItem.name + '`' - this.addEmbed(embed) + this.output.addEmbed(embed) } public async updateCheckItemStateOnCard(): Promise { @@ -913,7 +915,7 @@ export class Trello extends TypeParseProvider { this.action.data.checkItem.state + '`.' embed.url = this._resolveFullCardURL(this.action.data.card) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async updateChecklist(): Promise { @@ -921,12 +923,12 @@ export class Trello extends TypeParseProvider { embed.title = '[' + this.action.data.board.name + '] Renamed Checklist' embed.description = '`' + - Trello._formatLargeString(this.action.data.old.name) + + TrelloMapper._formatLargeString(this.action.data.old.name) + '` \uD83E\uDC6A `' + - Trello._formatLargeString(this.action.data.checklist.name) + + TrelloMapper._formatLargeString(this.action.data.checklist.name) + '`' embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } public async updateLabel(): Promise { @@ -988,7 +990,7 @@ export class Trello extends TypeParseProvider { if (field != null) { embed.fields = [field] } - this.addEmbed(embed) + this.output.addEmbed(embed) } public async updateList(): Promise { @@ -1005,13 +1007,12 @@ export class Trello extends TypeParseProvider { embed.description = '`' + this.action.data.old.name + '` \uD83E\uDC6A `' + this.action.data.list.name + '`' } embed.url = this._resolveFullBoardURL(this.action.data.board) - this.addEmbed(embed) + this.output.addEmbed(embed) } // How to Trigger? public async updateMember(): Promise { - this.nullifyPayload() - console.log('Not implemented') + this.output.ignore() } public async updateOrganization(): Promise { @@ -1059,22 +1060,22 @@ export class Trello extends TypeParseProvider { } else if (old.desc != null) { if (!old.desc) { embed.title = embed.title + 'Added Description to Organization' - embed.description = Trello._formatLargeString( + embed.description = TrelloMapper._formatLargeString( MarkdownUtil._formatMarkdown(organization.desc, embed), ) } else if (!organization.desc) { embed.title = embed.title + 'Removed Description from Organization' field = { name: 'Old Value', - value: Trello._formatLargeString(MarkdownUtil._formatMarkdown(old.desc, embed)), + value: TrelloMapper._formatLargeString(MarkdownUtil._formatMarkdown(old.desc, embed)), inline: false, } } else { embed.title = embed.title + 'Changed Description of Organization' embed.description = - Trello._formatLargeString(MarkdownUtil._formatMarkdown(old.desc, embed)) + + TrelloMapper._formatLargeString(MarkdownUtil._formatMarkdown(old.desc, embed)) + '\n`\uD83E\uDC6B`\n' + - Trello._formatLargeString(MarkdownUtil._formatMarkdown(organization.desc, embed)) + TrelloMapper._formatLargeString(MarkdownUtil._formatMarkdown(organization.desc, embed)) } } } else { @@ -1085,7 +1086,7 @@ export class Trello extends TypeParseProvider { if (field != null) { embed.fields = [field] } - this.addEmbed(embed) + this.output.addEmbed(embed) } public async voteOnCard(): Promise { @@ -1098,12 +1099,12 @@ export class Trello extends TypeParseProvider { '[' + this.action.data.board.name + '] Removed Vote on Card "' + this.action.data.card.name + '"' } embed.url = this._resolveFullCardURL(this.action.data.card) - this.addEmbed(embed) + this.output.addEmbed(embed) } private _resolveFullCardURL(card: Card): string { return ( - Trello.baseLink + + TrelloMapper.baseLink + 'c/' + card.shortLink + '/' + @@ -1114,7 +1115,7 @@ export class Trello extends TypeParseProvider { } private _resolveFullBoardURL(board: Board): string { - return Trello.baseLink + 'b/' + board.shortLink + '/' + board.name.replace(/\s/g, '-').toLowerCase() + return TrelloMapper.baseLink + 'b/' + board.shortLink + '/' + board.name.replace(/\s/g, '-').toLowerCase() } private _resolveFullCommentURL(card: Card, commentId: string): string { @@ -1122,11 +1123,11 @@ export class Trello extends TypeParseProvider { } private _resolveCardURL(id: string): string { - return Trello.baseLink + 'c/' + id + return TrelloMapper.baseLink + 'c/' + id } private _resolveBoardURL(id: string): string { - return Trello.baseLink + 'b/' + id + return TrelloMapper.baseLink + 'b/' + id } private _resolveCommentURL(cardID: string, commentId: string): string { @@ -1134,7 +1135,7 @@ export class Trello extends TypeParseProvider { } private _resolveGenericURL(id: string): string { - return Trello.baseLink + id + return TrelloMapper.baseLink + id } private _formatAttachment(attachment: Attachment, embed: Embed): void { @@ -1158,10 +1159,10 @@ export class Trello extends TypeParseProvider { } private _formatLabel(text: string, value: string, embed: Embed): void { - if (value && Trello.defTrelloColors[value] != null) { - embed.color = Trello.defTrelloColors[value] + if (value && TrelloMapper.defTrelloColors[value] != null) { + embed.color = TrelloMapper.defTrelloColors[value] } else { - embed.color = Trello.defTrelloColors.nocolor + embed.color = TrelloMapper.defTrelloColors.nocolor } if (text) { embed.description = '`' + text + '`' @@ -1172,19 +1173,12 @@ export class Trello extends TypeParseProvider { const memberCreator = this.body.action.memberCreator return { name: memberCreator.fullName, - icon_url: Trello.baseAvatarUrl + memberCreator.avatarHash + '/170.png', - url: Trello.baseLink + memberCreator.username, + icon_url: TrelloMapper.baseAvatarUrl + memberCreator.avatarHash + '/170.png', + url: TrelloMapper.baseLink + memberCreator.username, } } private _preparePayload(): Embed { - this.action = this.body.action - this.model = this.body.model - - // Testing code. - // console.info(this.body.action.type); - // console.info(this.action.data); - const embed: Embed = { author: this._resolveUser(), } @@ -1193,11 +1187,11 @@ export class Trello extends TypeParseProvider { if ( this.model.prefs != null && this.model.prefs.background != null && - Trello.defTrelloColors[this.model.prefs.background] != null + TrelloMapper.defTrelloColors[this.model.prefs.background] != null ) { - embed.color = Trello.defTrelloColors[this.model.prefs.background] + embed.color = TrelloMapper.defTrelloColors[this.model.prefs.background] } else { - embed.color = Trello.defTrelloColors.trello + embed.color = TrelloMapper.defTrelloColors.trello } return embed diff --git a/src/provider/Unity.ts b/src/provider/Unity.ts index 5d673e4..13272df 100644 --- a/src/provider/Unity.ts +++ b/src/provider/Unity.ts @@ -1,35 +1,24 @@ -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://build-api.cloud.unity3d.com/docs/1.0.0/index.html#operation-webhooks-intro */ -export class Unity extends DirectParseProvider { - public getName(): string { - return 'Unity Cloud' - } - - public getPath(): string { - return 'unity' - } - - public async parseData(): Promise { - this.setEmbedColor(0x222c37) - const projectName = this.body.projectName - const projectVersion = this.body.buildNumber - let share = null - try { - share = this.body.links.artifacts[0].files.href - } catch (_err) { - // Artifact not present - } - const type = this.body.buildStatus +export const Unity = defineProvider({ + path: 'unity', + name: 'Unity Cloud', + example: { body: 'unity/unity.json' }, + defaults: { embedColor: 0x222c37 }, + map({ body }, output) { + const projectName = body.projectName + const projectVersion = body.buildNumber + const download = body.links?.artifacts?.[0]?.files?.href ?? '' + const type = body.buildStatus let content = 'No download available.' - let download = '' - this.payload.username = projectName + ' Buildserver' + output.payload.username = projectName + ' Buildserver' + switch (type) { case 'success': - if (share) { - download = share.href + if (download.length > 0) { content = '[`Download it here`](' + download + ')' } content = '**New build**\n' + content @@ -44,10 +33,11 @@ export class Unity extends DirectParseProvider { content = '**Build failed**\n' + 'Latest version is still #' + (projectVersion - 1) + '\n' break } - this.addEmbed({ + + output.addEmbed({ title: '[' + projectName + '] ' + ' version #' + projectVersion, url: download, description: content, }) - } -} + }, +}) diff --git a/src/provider/UptimeRobot.ts b/src/provider/UptimeRobot.ts index bbdc2cc..bfbd532 100644 --- a/src/provider/UptimeRobot.ts +++ b/src/provider/UptimeRobot.ts @@ -1,36 +1,19 @@ -import { DirectParseProvider } from '../provider/BaseProvider.ts' +import { defineProvider } from './Provider.ts' /** * https://blog.uptimerobot.com/web-hook-alert-contacts-new-feature/ * Example: * http://www.domain.com/?monitorID=95987545252&monitorURL=http://test.com&monitorFriendlyName=TestWebsite&alertType=*0&alertDetails=ConnectionTimeout&monitorAlertContacts=457;2;john@doe.com */ -export class UptimeRobot extends DirectParseProvider { - public getName(): string { - return 'Uptime Robot' - } - - public getPath(): string { - return 'uptimerobot' - } - - public async parseData(): Promise { - let title = this.query.monitorFriendlyName - if (title == null) { - title = this.body.monitorFriendlyName - } - let url = this.query.monitorURL - if (url == null) { - url = this.body.monitorURL - } - let description = this.query.alertDetails - if (description == null) { - description = this.body.alertDetails - } - this.addEmbed({ - title: title, - url: url, - description: description, +export const UptimeRobot = defineProvider({ + path: 'uptimerobot', + name: 'Uptime Robot', + example: { body: 'uptimerobot/uptimerobot.json' }, + map({ body, query }, output) { + output.addEmbed({ + title: query.get('monitorFriendlyName') ?? body.monitorFriendlyName, + url: query.get('monitorURL') ?? body.monitorURL, + description: query.get('alertDetails') ?? body.alertDetails, }) - } -} + }, +}) diff --git a/src/provider/Zendesk.ts b/src/provider/Zendesk.ts index c4f4349..a783249 100644 --- a/src/provider/Zendesk.ts +++ b/src/provider/Zendesk.ts @@ -1,8 +1,8 @@ import type { Embed, EmbedField } from '../model/DiscordApi.ts' -import { DirectParseProvider } from '../provider/BaseProvider.ts' import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts' import { cleanText, escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts' -import { canonicalizeIso8601Timestamp, isRecord } from '../util/WebhookValue.ts' +import { canonicalizeIso8601Timestamp, safeId as extractSafeId, isRecord, scalarText } from '../util/WebhookValue.ts' +import { defineProvider, type ProviderOutput } from './Provider.ts' const EVENT_TYPE_PREFIX = 'zen:event-type' const SUBJECT_DOMAIN_ALIASES: Record = { @@ -20,29 +20,32 @@ const SUBJECT_DOMAIN_ALIASES: Record = { * @see https://developer.zendesk.com/api-reference/webhooks/webhooks-api/webhooks/ * @see https://developer.zendesk.com/api-reference/webhooks/event-types/webhook-event-types/ */ -export class Zendesk extends DirectParseProvider { - constructor() { - super() - this.setEmbedColor(0x03363d) +export const Zendesk = defineProvider({ + path: 'zendesk', + name: 'Zendesk', + example: { body: 'zendesk/zendesk.json' }, + defaults: { embedColor: 0x03363d }, + map({ body }, output) { + new ZendeskMapper(body).map(output) + }, +}) + +class ZendeskMapper { + private readonly body: Record + + public constructor(body: Record) { + this.body = body } - public getName(): string { - return 'Zendesk' - } - - public getPath(): string { - return 'zendesk' - } - - public async parseData(): Promise { + public map(output: ProviderOutput): void { if (!isRecord(this.body) || !isRecord(this.body.detail) || !isRecord(this.body.event)) { - this.nullifyPayload() + output.ignore() return } const timestamp = validateEnvelope(this.body) if (timestamp == null) { - this.nullifyPayload() + output.ignore() return } @@ -51,26 +54,26 @@ export class Zendesk extends DirectParseProvider { eventType?.startsWith(`${EVENT_TYPE_PREFIX}:`) === true || eventType?.startsWith(`${EVENT_TYPE_PREFIX}/`) === true if (eventType == null || !hasKnownDelimiter) { - this.nullifyPayload() + output.ignore() return } const eventName = eventType.slice(EVENT_TYPE_PREFIX.length + 1) const separator = eventName.indexOf('.') if (separator <= 0 || separator === eventName.length - 1) { - this.nullifyPayload() + output.ignore() return } const resource = eventName.slice(0, separator) const action = eventName.slice(separator + 1) if (!/^[a-z0-9_]+$/.test(resource) || !/^[a-z0-9_]+$/.test(action)) { - this.nullifyPayload() + output.ignore() return } const subjectDomain = this.body.subject.split(':', 3)[1] if (subjectDomain !== resource && !SUBJECT_DOMAIN_ALIASES[resource]?.includes(subjectDomain)) { - this.nullifyPayload() + output.ignore() return } @@ -107,8 +110,7 @@ export class Zendesk extends DirectParseProvider { const fields = [...this.createEventFields(event), ...this.createResourceFields(resource, detail)] embed.fields = fitLiteralEmbedFields(embed, fields) - this.payload.allowed_mentions = { parse: [] } - this.addEmbed(embed) + output.addEmbed(embed) } private getResourceLabel(detail: Record, event: Record): string | null { @@ -286,28 +288,7 @@ function humanizeListItem(value: string): string { } function safeId(value: unknown): string | null { - if (typeof value === 'string') { - const cleaned = cleanText(value, true) - return cleaned.length > 0 && cleaned.length <= 128 ? cleaned : null - } - if (typeof value === 'number' && Number.isSafeInteger(value)) { - return String(value) - } - return null -} - -function scalarText(value: unknown): string | null { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - return null - } - if ( - typeof value === 'number' && - (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) - ) { - return null - } - const text = cleanText(String(value), false) - return text.length > 0 ? text : null + return extractSafeId(value, 128) } function escapeAndTruncate(value: string, maxLength: number, singleLine: boolean): string { diff --git a/src/provider/core/DiscordPayloadFinalizer.ts b/src/provider/core/DiscordPayloadFinalizer.ts new file mode 100644 index 0000000..837d201 --- /dev/null +++ b/src/provider/core/DiscordPayloadFinalizer.ts @@ -0,0 +1,166 @@ +import type { DiscordPayload, Embed, EmbedAuthor } from '../../model/DiscordApi.ts' +import { + DISCORD_EMBED_LIMITS, + DISCORD_MESSAGE_LIMITS, + SKYHOOK_FOOTER, + SKYHOOK_FOOTER_TEXT, +} from '../../util/DiscordEmbed.ts' +import { truncateText } from '../../util/DiscordText.ts' +import { canonicalizeIso8601Timestamp, isRecord } from '../../util/WebhookValue.ts' +import type { ProviderDefaults } from './ProviderTypes.ts' + +const DISCORD_WEBHOOK_USERNAME_LIMIT = 80 +const DISCORD_URL_LIMIT = 2048 + +export function finalizeDiscordPayload(payload: DiscordPayload, defaults: ProviderDefaults = {}): DiscordPayload { + const result: DiscordPayload = {} + + if (typeof payload.content === 'string') { + const content = truncateText(payload.content, DISCORD_MESSAGE_LIMITS.content, false) + if (content.length > 0) result.content = content + } + + const username = payload.username ?? defaults.username + if (typeof username === 'string') { + const bounded = truncateText(username, DISCORD_WEBHOOK_USERNAME_LIMIT, true) + if (bounded.length > 0) result.username = bounded + } + + const avatarUrl = normalizeWebUrl(payload.avatar_url ?? defaults.avatarUrl) + if (avatarUrl != null) result.avatar_url = avatarUrl + + if (typeof payload.tts === 'boolean') result.tts = payload.tts + result.allowed_mentions = { parse: [] } + + if (Array.isArray(payload.embeds) && payload.embeds.length > 0) { + const embeds = finalizeEmbeds(payload.embeds, defaults.embedColor) + if (embeds.length > 0) result.embeds = embeds + } + + return result +} + +function finalizeEmbeds(embeds: readonly Embed[], defaultColor: number | undefined): Embed[] { + const candidates = embeds.filter(isRecord).slice(0, DISCORD_MESSAGE_LIMITS.embeds) as Embed[] + let remainingCharacters = DISCORD_MESSAGE_LIMITS.embedCharacters + const finalized: Embed[] = [] + + for (let index = 0; index < candidates.length; index += 1) { + const remainingFooterCount = candidates.length - index + const reservedFooterCharacters = remainingFooterCount * SKYHOOK_FOOTER_TEXT.length + if (remainingCharacters < reservedFooterCharacters) break + + const input = candidates[index] + let availableCharacters = remainingCharacters - reservedFooterCharacters + const embed: Embed = { footer: { ...SKYHOOK_FOOTER } } + + const color = validDiscordColor(input.color) + ? input.color + : validDiscordColor(defaultColor) + ? defaultColor + : undefined + if (color != null) embed.color = color + + const url = normalizeWebUrl(input.url) + if (url != null) embed.url = url + + const timestamp = canonicalizeIso8601Timestamp(input.timestamp) + if (timestamp != null) embed.timestamp = timestamp + + const imageUrl = normalizeWebUrl(input.image?.url) + if (imageUrl != null) embed.image = { url: imageUrl } + + const thumbnailUrl = normalizeWebUrl(input.thumbnail?.url) + if (thumbnailUrl != null) embed.thumbnail = { url: thumbnailUrl } + + const title = boundedEmbedText(input.title, DISCORD_EMBED_LIMITS.title, true, availableCharacters) + if (title != null) { + embed.title = title + availableCharacters -= title.length + } + + const description = boundedEmbedText( + input.description, + DISCORD_EMBED_LIMITS.description, + false, + availableCharacters, + ) + if (description != null) { + embed.description = description + availableCharacters -= description.length + } + + const author = finalizeAuthor(input.author, availableCharacters) + if (author != null) { + embed.author = author + availableCharacters -= author.name.length + } + + if (Array.isArray(input.fields)) { + const fields = [] + for (const candidate of input.fields.slice(0, DISCORD_EMBED_LIMITS.fields)) { + if (!isRecord(candidate)) continue + const name = boundedEmbedText(candidate.name, DISCORD_EMBED_LIMITS.fieldName, true, availableCharacters) + if (name == null) continue + const value = boundedEmbedText( + candidate.value, + DISCORD_EMBED_LIMITS.fieldValue, + false, + availableCharacters - name.length, + ) + if (value == null) continue + fields.push({ + name, + value, + ...(typeof candidate.inline === 'boolean' ? { inline: candidate.inline } : {}), + }) + availableCharacters -= name.length + value.length + } + embed.fields = fields + } + + const usedNonFooterCharacters = remainingCharacters - reservedFooterCharacters - availableCharacters + remainingCharacters -= usedNonFooterCharacters + SKYHOOK_FOOTER_TEXT.length + finalized.push(embed) + } + + return finalized +} + +function finalizeAuthor(value: unknown, availableCharacters: number): EmbedAuthor | null { + if (!isRecord(value)) return null + const name = boundedEmbedText(value.name, DISCORD_EMBED_LIMITS.authorName, true, availableCharacters) + if (name == null) return null + + const author: EmbedAuthor = { name } + const url = normalizeWebUrl(value.url) + if (url != null) author.url = url + const iconUrl = normalizeWebUrl(value.icon_url) + if (iconUrl != null) author.icon_url = iconUrl + return author +} + +function boundedEmbedText( + value: unknown, + propertyLimit: number, + singleLine: boolean, + availableCharacters: number, +): string | null { + if (typeof value !== 'string' || availableCharacters <= 0) return null + const bounded = truncateText(value, Math.min(propertyLimit, availableCharacters), singleLine) + return bounded.length === 0 ? null : bounded +} + +function validDiscordColor(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 0xffffff +} + +function normalizeWebUrl(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > DISCORD_URL_LIMIT) return null + try { + const url = new URL(value) + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null + } catch { + return null + } +} diff --git a/src/provider/core/ProviderDefinition.ts b/src/provider/core/ProviderDefinition.ts new file mode 100644 index 0000000..4464c93 --- /dev/null +++ b/src/provider/core/ProviderDefinition.ts @@ -0,0 +1,132 @@ +import type { + EventProviderOptions, + ProviderDefinition, + ProviderExampleFiles, + ProviderHttpPolicy, +} from './ProviderTypes.ts' + +const PROVIDER_PATH_PATTERN = /^[a-z][a-z0-9-]*$/ +const EXAMPLE_PATH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*\.json$/ +const HTTP_HOST_PATTERN = /^(?:\[[0-9a-f:]+\]|[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/ +const MAX_EVENT_NAME_LENGTH = 256 + +export function defineProvider(definition: ProviderDefinition): ProviderDefinition { + validateDefinition(definition) + return Object.freeze({ + ...definition, + example: Object.freeze({ ...definition.example }), + defaults: definition.defaults == null ? undefined : Object.freeze({ ...definition.defaults }), + http: freezeHttpPolicy(definition.http), + }) +} + +export function defineEventProvider(options: EventProviderOptions): ProviderDefinition { + const { event, handlers, fallback, ...provider } = options + if ((typeof event !== 'string' || event.length === 0) && typeof event !== 'function') { + throw new Error('Event provider selector must be a non-empty body key or a selector function.') + } + if (typeof fallback !== 'undefined' && typeof fallback !== 'function') { + throw new Error('Event provider fallback must be a function.') + } + + for (const [eventName, handler] of Object.entries(handlers)) { + if (eventName.length === 0 || eventName.length > MAX_EVENT_NAME_LENGTH) { + throw new Error(`Event provider handler key must contain 1-${MAX_EVENT_NAME_LENGTH} characters.`) + } + if (typeof handler !== 'function') { + throw new Error(`Event provider handler for "${eventName}" must be a function.`) + } + } + + const frozenHandlers = Object.freeze({ ...handlers }) + return defineProvider({ + ...provider, + async map(request, output): Promise { + const selected = typeof event === 'string' ? request.body[event] : event(request) + if (typeof selected !== 'string' || selected.length === 0 || selected.length > MAX_EVENT_NAME_LENGTH) { + output.ignore() + return + } + const handler = frozenHandlers[selected] ?? fallback + if (handler == null) { + output.ignore() + return + } + await handler(request, output) + }, + }) +} + +function validateDefinition(definition: ProviderDefinition): void { + if (!PROVIDER_PATH_PATTERN.test(definition.path)) { + throw new Error( + 'Provider path must start with a lowercase letter and contain only lowercase letters, digits, or hyphens.', + ) + } + if ( + definition.name.trim() !== definition.name || + definition.name.length === 0 || + definition.name.length > 80 || + hasControlCharacters(definition.name) + ) { + throw new Error('Provider name must contain 1-80 printable characters without surrounding whitespace.') + } + validateExamplePath(definition.example.body, 'body') + if (definition.example.headers != null) validateExamplePath(definition.example.headers, 'headers') + if (definition.example.query != null) validateExamplePath(definition.example.query, 'query') + if (typeof definition.map !== 'function') { + throw new Error('Provider map must be a function.') + } + + const { defaults } = definition + if (defaults?.embedColor != null && !isDiscordColor(defaults.embedColor)) { + throw new Error('Provider default embed color must be an integer between 0 and 0xffffff.') + } + validateHttpPolicy(definition.http) +} + +function validateExamplePath(path: string, kind: keyof ProviderExampleFiles): void { + if (path.trim().length === 0 || !EXAMPLE_PATH_PATTERN.test(path) || path.includes('..')) { + throw new Error(`Provider example ${kind} must be a relative JSON fixture path.`) + } +} + +function validateHttpPolicy(policy: ProviderHttpPolicy | undefined): void { + if (policy == null) return + if (policy.allowedHosts.length === 0) { + throw new Error('Provider HTTP policy must declare at least one allowed host.') + } + for (const host of policy.allowedHosts) { + if (host !== host.toLowerCase() || !HTTP_HOST_PATTERN.test(host) || host.includes('..')) { + throw new Error(`Provider HTTP host is invalid: ${host}`) + } + } + if (policy.timeoutMs != null && (!Number.isSafeInteger(policy.timeoutMs) || policy.timeoutMs <= 0)) { + throw new Error('Provider HTTP timeout must be a positive safe integer.') + } + if ( + policy.maxResponseBytes != null && + (!Number.isSafeInteger(policy.maxResponseBytes) || policy.maxResponseBytes <= 0) + ) { + throw new Error('Provider HTTP response size must be a positive safe integer.') + } +} + +function freezeHttpPolicy(policy: ProviderHttpPolicy | undefined): ProviderHttpPolicy | undefined { + if (policy == null) return undefined + return Object.freeze({ + ...policy, + allowedHosts: Object.freeze([...policy.allowedHosts]), + }) +} + +function isDiscordColor(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0 && value <= 0xffffff +} + +function hasControlCharacters(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0)! + return codePoint <= 0x1f || codePoint === 0x7f + }) +} diff --git a/src/provider/core/ProviderExecution.ts b/src/provider/core/ProviderExecution.ts new file mode 100644 index 0000000..547f7ce --- /dev/null +++ b/src/provider/core/ProviderExecution.ts @@ -0,0 +1,53 @@ +import { isRecord } from '../../util/WebhookValue.ts' +import { createProviderHttp } from './ProviderHttp.ts' +import { ProviderOutput } from './ProviderOutput.ts' +import type { ProviderDefinition, ProviderRequest, ProviderRunInput } from './ProviderTypes.ts' + +export async function executeProvider( + definition: ProviderDefinition, + input: ProviderRunInput, +): Promise> { + if (!isRecord(input.body)) return null + + const request: ProviderRequest = { + body: input.body, + headers: normalizeHeaders(input.headers), + query: normalizeQuery(input.query), + http: createProviderHttp(definition.http), + } + const output = new ProviderOutput(definition.defaults) + await definition.map(request, output) + return output.finish() +} + +function normalizeHeaders(value: unknown): Headers { + if (value instanceof Headers) return new Headers(value) + + const headers = new Headers() + if (!isRecord(value)) return headers + for (const [name, headerValue] of Object.entries(value)) { + if (headerValue == null) continue + if (Array.isArray(headerValue)) { + for (const item of headerValue) headers.append(name, String(item)) + } else { + headers.set(name, String(headerValue)) + } + } + return headers +} + +function normalizeQuery(value: unknown): URLSearchParams { + if (value instanceof URLSearchParams) return new URLSearchParams(value) + + const query = new URLSearchParams() + if (!isRecord(value)) return query + for (const [name, queryValue] of Object.entries(value)) { + if (queryValue == null) continue + if (Array.isArray(queryValue)) { + for (const item of queryValue) query.append(name, String(item)) + } else { + query.set(name, String(queryValue)) + } + } + return query +} diff --git a/src/provider/core/ProviderHttp.ts b/src/provider/core/ProviderHttp.ts new file mode 100644 index 0000000..6a370ab --- /dev/null +++ b/src/provider/core/ProviderHttp.ts @@ -0,0 +1,87 @@ +import { isAllowedHostname } from '../../util/WebhookValue.ts' +import type { ProviderHttp, ProviderHttpPolicy } from './ProviderTypes.ts' + +const DEFAULT_TIMEOUT_MS = 5_000 +const MAX_TIMEOUT_MS = 10_000 +const DEFAULT_RESPONSE_BYTES = 256_000 +const MAX_RESPONSE_BYTES = 1_000_000 + +export function createProviderHttp(policy: ProviderHttpPolicy | undefined): ProviderHttp { + return Object.freeze({ + async getJson(url: string): Promise { + if (policy == null) throw new Error('This provider is not permitted to make HTTP requests.') + + const target = parseTarget(url, policy) + const timeoutMs = Math.min(policy.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS) + const maxResponseBytes = Math.min(policy.maxResponseBytes ?? DEFAULT_RESPONSE_BYTES, MAX_RESPONSE_BYTES) + const response = await fetch(target, { + headers: { accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) { + throw new Error(`Provider HTTP request failed with status ${response.status}.`) + } + + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) { + await response.body?.cancel() + throw sizeLimitError() + } + + return JSON.parse(await readBoundedText(response, maxResponseBytes)) as T + }, + }) +} + +function parseTarget(url: string, policy: ProviderHttpPolicy): URL { + let target: URL + try { + target = new URL(url) + } catch { + throw new Error('Provider HTTP URL is invalid.') + } + if (target.protocol !== 'https:') throw new Error('Provider HTTP requests must use HTTPS.') + if (target.username.length > 0 || target.password.length > 0) { + throw new Error('Provider HTTP URLs must not contain credentials.') + } + if (target.port.length > 0) throw new Error('Provider HTTP URLs must use the default HTTPS port.') + if (!isAllowedHostname(target.hostname, policy.allowedHosts)) { + throw new Error(`Provider HTTP host is not allowed: ${target.hostname}`) + } + return target +} + +async function readBoundedText(response: Response, maxResponseBytes: number): Promise { + if (response.body == null) return '' + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let totalBytes = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > maxResponseBytes) { + await reader.cancel() + throw sizeLimitError() + } + chunks.push(value) + } + } finally { + reader.releaseLock() + } + + const body = new Uint8Array(totalBytes) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(body) +} + +function sizeLimitError(): Error { + return new Error('Provider HTTP response exceeds the configured size limit.') +} diff --git a/src/provider/core/ProviderOutput.ts b/src/provider/core/ProviderOutput.ts new file mode 100644 index 0000000..ed0981c --- /dev/null +++ b/src/provider/core/ProviderOutput.ts @@ -0,0 +1,61 @@ +import type { DiscordPayload, Embed } from '../../model/DiscordApi.ts' +import { type Logger, logger } from '../../util/logger.ts' +import { finalizeDiscordPayload } from './DiscordPayloadFinalizer.ts' +import type { ProviderDefaults } from './ProviderTypes.ts' + +export class ProviderOutput { + public readonly payload: DiscordPayload = {} + public readonly logger: Logger + private readonly defaults: ProviderDefaults + private currentEmbedColor: number | undefined + private ignored = false + + public constructor(defaults: ProviderDefaults = {}, outputLogger: Logger = logger) { + this.defaults = defaults + this.logger = outputLogger + this.currentEmbedColor = defaults.embedColor + } + + public addEmbed(embed: Embed): void { + if (this.ignored) return + if (this.payload.embeds == null) this.payload.embeds = [] + this.payload.embeds.push({ + ...embed, + color: embed.color ?? this.currentEmbedColor, + }) + } + + public setEmbedColor(color: number): void { + this.currentEmbedColor = color + } + + public ignore(): void { + this.ignored = true + } + + public finish(): DiscordPayload | null { + if (this.ignored) return null + const payload = finalizeDiscordPayload(this.payload, this.defaults) + if (!hasMessage(payload)) { + throw new Error( + 'Provider produced no Discord message content. Call output.ignore() for unsupported events.', + ) + } + return payload + } +} + +function hasMessage(payload: DiscordPayload): boolean { + if (typeof payload.content === 'string' && payload.content.length > 0) return true + return ( + payload.embeds?.some( + (embed) => + embed.title != null || + embed.description != null || + embed.author != null || + (embed.fields?.length ?? 0) > 0 || + embed.image != null || + embed.thumbnail != null, + ) ?? false + ) +} diff --git a/src/provider/core/ProviderTypes.ts b/src/provider/core/ProviderTypes.ts new file mode 100644 index 0000000..474ed51 --- /dev/null +++ b/src/provider/core/ProviderTypes.ts @@ -0,0 +1,53 @@ +import type { ProviderOutput } from './ProviderOutput.ts' + +export interface ProviderExampleFiles { + readonly body: string + readonly headers?: string + readonly query?: string +} + +export interface ProviderDefaults { + readonly username?: string + readonly avatarUrl?: string + readonly embedColor?: number +} + +export interface ProviderHttpPolicy { + readonly allowedHosts: readonly string[] + readonly timeoutMs?: number + readonly maxResponseBytes?: number +} + +export interface ProviderHttp { + getJson(url: string): Promise +} + +export interface ProviderRequest { + readonly body: Record + readonly headers: Headers + readonly query: URLSearchParams + readonly http: ProviderHttp +} + +export interface ProviderRunInput { + readonly body: unknown + readonly headers?: unknown + readonly query?: unknown +} + +export type ProviderMapper = (request: ProviderRequest, output: ProviderOutput) => void | Promise + +export interface ProviderDefinition { + readonly path: string + readonly name: string + readonly example: ProviderExampleFiles + readonly defaults?: ProviderDefaults + readonly http?: ProviderHttpPolicy + readonly map: ProviderMapper +} + +export interface EventProviderOptions extends Omit { + readonly event: string | ((request: ProviderRequest) => string | null) + readonly handlers: Readonly> + readonly fallback?: ProviderMapper +} diff --git a/src/util/WebhookValue.ts b/src/util/WebhookValue.ts index 6a9f18f..0f2d0d5 100644 --- a/src/util/WebhookValue.ts +++ b/src/util/WebhookValue.ts @@ -1,9 +1,89 @@ +import { cleanText } from './DiscordText.ts' + const ISO_8601_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/ export function isRecord(value: unknown): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value) } +export function scalarText(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return null + } + if ( + typeof value === 'number' && + (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) + ) { + return null + } + const text = cleanText(String(value), false) + return text.length === 0 ? null : text +} + +export function firstScalar(...values: unknown[]): string | null { + for (const value of values) { + const text = scalarText(value) + if (text != null) return text + } + return null +} + +export function safeId(value: unknown, maxLength = Number.POSITIVE_INFINITY): string | null { + if (typeof value === 'number') { + return Number.isSafeInteger(value) ? String(value) : null + } + if (typeof value !== 'string') { + return null + } + const id = cleanText(value, true) + return id.length > 0 && id.length <= maxLength ? id : null +} + +export function safeIntegerText(value: unknown, positive = false): string | null { + return Number.isSafeInteger(value) && (!positive || Number(value) > 0) ? String(value) : null +} + +export function firstIso8601Timestamp(...values: unknown[]): string | null { + for (const value of values) { + const timestamp = canonicalizeIso8601Timestamp(value) + if (timestamp != null) return timestamp + } + return null +} + +export interface TrustedUrlPolicy { + readonly allowedHosts: readonly string[] + readonly allowSubdomains?: boolean + readonly maxLength?: number +} + +export function trustedHttpsUrl(value: unknown, policy: TrustedUrlPolicy): string | null { + const maxLength = policy.maxLength ?? 2_048 + if (typeof value !== 'string' || value.length === 0 || value.length > maxLength) { + return null + } + try { + const url = new URL(value) + if ( + url.protocol !== 'https:' || + !isAllowedHostname(url.hostname, policy.allowedHosts, policy.allowSubdomains === true) + ) { + return null + } + return url.href.length <= maxLength ? url.href : null + } catch { + return null + } +} + +export function isAllowedHostname(hostname: string, allowedHosts: readonly string[], allowSubdomains = false): boolean { + const normalized = hostname.toLowerCase() + return allowedHosts.some((allowedHost) => { + const allowed = allowedHost.toLowerCase() + return normalized === allowed || (allowSubdomains && normalized.endsWith(`.${allowed}`)) + }) +} + export function canonicalizeIso8601Timestamp(value: unknown): string | null { if (typeof value !== 'string') { return null diff --git a/test/Tester.ts b/test/Tester.ts index d69f947..6229f76 100644 --- a/test/Tester.ts +++ b/test/Tester.ts @@ -1,41 +1,30 @@ import { existsSync, readFileSync } from 'node:fs' import type { DiscordPayload } from '../src/model/DiscordApi.ts' -import type { BaseProvider } from '../src/provider/BaseProvider.ts' +import { executeProvider, type ProviderDefinition } from '../src/provider/Provider.ts' -/** - * Helps with testing things - */ +/** Helps execute provider definitions against fixtures. */ class Tester { public static async test( - provider: BaseProvider, + provider: ProviderDefinition, jsonFileName: string | null = null, - headers: any = null, - query: any = null, + headers: unknown = null, + query: unknown = null, ): Promise { - let requestBody = null - if (jsonFileName != null) { - requestBody = JSON.parse(Tester.readTestFile(provider, jsonFileName)) - } - return Tester.testWithBody(provider, requestBody, headers, query) + const body = jsonFileName == null ? null : JSON.parse(Tester.readTestFile(provider, jsonFileName)) + return Tester.testWithBody(provider, body, headers, query) } public static async testWithBody( - provider: BaseProvider, + provider: ProviderDefinition, body: Record | null = null, - headers: any = null, - query: any = null, + headers: unknown = null, + query: unknown = null, ): Promise { - try { - const res = await provider.parse(body, headers, query) - return Promise.resolve(res) - } catch (err) { - console.error(err) - return Promise.reject(err) - } + return executeProvider(provider, { body, headers, query }) } - public static readTestFile(provider: BaseProvider, fileName: string): string { - const providerPath = provider.getPath().toLowerCase() + public static readTestFile(provider: ProviderDefinition, fileName: string): string { + const providerPath = provider.path.toLowerCase() const examplePath = `./examples/${providerPath}/${fileName}` const filePath = existsSync(examplePath) ? examplePath : `./test/${providerPath}/${fileName}` return readFileSync(filePath, 'utf-8') diff --git a/test/appcenter/appcenter-spec.ts b/test/appcenter/appcenter-spec.ts index 6b0bca1..5a5c398 100644 --- a/test/appcenter/appcenter-spec.ts +++ b/test/appcenter/appcenter-spec.ts @@ -5,14 +5,14 @@ import { Tester } from '../Tester.ts' describe('/POST appcenter', () => { it('push (event pipeline)', async () => { - const res = await Tester.test(new AppCenter(), 'appcenter-pipeline.json', null) + const res = await Tester.test(AppCenter, 'appcenter-pipeline.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('push (event distribute)', async () => { - const res = await Tester.test(new AppCenter(), 'appcenter-distribute.json', null) + const res = await Tester.test(AppCenter, 'appcenter-distribute.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/appveyor/appveyor-spec.ts b/test/appveyor/appveyor-spec.ts index c9fa298..624d5a5 100644 --- a/test/appveyor/appveyor-spec.ts +++ b/test/appveyor/appveyor-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST appveyor', () => { it('build', async () => { - const res = await Tester.test(new AppVeyor(), 'appveyor.json', null) + const res = await Tester.test(AppVeyor, 'appveyor.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/azure/azure-spec.ts b/test/azure/azure-spec.ts index ccfad39..b1285f5 100644 --- a/test/azure/azure-spec.ts +++ b/test/azure/azure-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST azure', () => { it('git.push', async () => { - const res = await Tester.test(new AzureDevOps(), 'azure.json', null) + const res = await Tester.test(AzureDevOps, 'azure.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) @@ -19,7 +19,7 @@ describe('/POST azure', () => { for (const [eventType, fieldName] of pullRequestCases) { it(`preserves the ${eventType} pull-request payload`, async () => { - const res = await Tester.testWithBody(new AzureDevOps(), { + const res = await Tester.testWithBody(AzureDevOps, { eventType, message: { markdown: '**Pull request event**' }, resource: { @@ -34,6 +34,7 @@ describe('/POST azure', () => { }) assert.deepEqual(res, { + allowed_mentions: { parse: [] }, embeds: [ { author: { diff --git a/test/basecamp/basecamp-spec.ts b/test/basecamp/basecamp-spec.ts index bd38f7b..8b84a94 100644 --- a/test/basecamp/basecamp-spec.ts +++ b/test/basecamp/basecamp-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST basecamp', () => { it('general', async () => { - const res = await Tester.test(new Basecamp(), 'basecamp.json', null) + const res = await Tester.test(Basecamp, 'basecamp.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds) && res!.embeds.length > 0) }) diff --git a/test/bitbucket/bitbucket-spec.ts b/test/bitbucket/bitbucket-spec.ts index c11855e..f94c3c5 100644 --- a/test/bitbucket/bitbucket-spec.ts +++ b/test/bitbucket/bitbucket-spec.ts @@ -8,7 +8,7 @@ describe('/POST bitbucket', () => { const headers = { 'x-event-key': 'repo:push', } - const res = await Tester.test(new BitBucket(), 'bitbucket.json', headers) + const res = await Tester.test(BitBucket, 'bitbucket.json', headers) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) @@ -18,7 +18,7 @@ describe('/POST bitbucket', () => { const headers = { 'x-event-key': 'repo:push', } - const res = await Tester.test(new BitBucket(), 'bitbucket-tag.json', headers) + const res = await Tester.test(BitBucket, 'bitbucket-tag.json', headers) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) @@ -28,7 +28,7 @@ describe('/POST bitbucket', () => { const headers = { 'x-event-key': 'repo:push', } - const res = await Tester.test(new BitBucket(), 'bitbucket-anonymous.json', headers) + const res = await Tester.test(BitBucket, 'bitbucket-anonymous.json', headers) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/bitbucketserver/bitbucketserver-spec.ts b/test/bitbucketserver/bitbucketserver-spec.ts index 3a71f98..12b93db 100644 --- a/test/bitbucketserver/bitbucketserver-spec.ts +++ b/test/bitbucketserver/bitbucketserver-spec.ts @@ -9,7 +9,7 @@ describe('/POST bitbucketserver', () => { 'x-event-key': 'repo:refs_changed', } - const res = await Tester.test(new BitBucketServer(), 'bitbucketserver.json', headers) + const res = await Tester.test(BitBucketServer, 'bitbucketserver.json', headers) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) @@ -40,8 +40,8 @@ describe('/POST bitbucketserver', () => { 'x-event-key': 'repo:refs_changed', } - const res = await Tester.test(new BitBucketServer(), 'bitbucketserver.json', headers) + const res = await Tester.test(BitBucketServer, 'bitbucketserver.json', headers) assert.notStrictEqual(res, null) - assert.ok(res!.embeds![0]?.fields?.length <= 18) + assert.ok((res!.embeds![0]?.fields?.length ?? 0) <= 18) }) }) diff --git a/test/buildkite/buildkite-spec.ts b/test/buildkite/buildkite-spec.ts index 1efc8b2..5fb7093 100644 --- a/test/buildkite/buildkite-spec.ts +++ b/test/buildkite/buildkite-spec.ts @@ -34,15 +34,15 @@ const build = { describe('/POST buildkite', () => { it('exposes provider metadata', () => { - const provider = new Buildkite() + const provider = Buildkite - assert.equal(provider.getName(), 'Buildkite') - assert.equal(provider.getPath(), 'buildkite') + assert.equal(provider.name, 'Buildkite') + assert.equal(provider.path, 'buildkite') }) it('formats the canonical build fixture used by example delivery', async () => { const example = loadProviderExample('buildkite') - const result = await Tester.testWithBody(new Buildkite(), example.body, example.headers, example.query) + const result = await Tester.testWithBody(Buildkite, example.body, example.headers, example.query) assert.notEqual(result, null) assert.equal(result!.username, 'Buildkite') @@ -65,7 +65,7 @@ describe('/POST buildkite', () => { it('formats build lifecycle events and blocked builds', async () => { const running = await Tester.testWithBody( - new Buildkite(), + Buildkite, { event: 'build.running', build, pipeline, sender }, { 'X-Buildkite-Event': 'build.running' }, ) @@ -73,7 +73,7 @@ describe('/POST buildkite', () => { assert.equal(running!.embeds![0].title, 'My Pipeline build \\#27 running') assert.equal(running!.embeds![0].timestamp, '2026-07-27T14:20:05.000Z') - const failing = await Tester.testWithBody(new Buildkite(), { + const failing = await Tester.testWithBody(Buildkite, { event: 'build.failing', build: { ...build, state: 'passed', failing_at: '2026-07-27T14:24:00Z' }, pipeline, @@ -83,7 +83,7 @@ describe('/POST buildkite', () => { assert.equal(failing!.embeds![0].title, 'My Pipeline build \\#27 failing') assert.equal(failing!.embeds![0].color, 0xe53935) - const blocked = await Tester.testWithBody(new Buildkite(), { + const blocked = await Tester.testWithBody(Buildkite, { event: 'build.finished', build: { ...build, state: 'blocked', blocked: true, finished_at: '2026-07-27T14:25:00Z' }, pipeline, @@ -95,7 +95,7 @@ describe('/POST buildkite', () => { }) it('formats job events with build and execution details', async () => { - const result = await Tester.testWithBody(new Buildkite(), { + const result = await Tester.testWithBody(Buildkite, { event: 'job.finished', job: { id: 'b63254c0-3271-4a98-8270-7cfbd6c2f14e', @@ -139,7 +139,7 @@ describe('/POST buildkite', () => { } for (const [event, state] of Object.entries(buildEvents)) { const result = await Tester.testWithBody( - new Buildkite(), + Buildkite, { event, build: { ...build, state, finished_at: '2026-07-27T14:23:00Z' }, pipeline, sender }, { 'x-buildkite-event': event }, ) @@ -155,7 +155,7 @@ describe('/POST buildkite', () => { } for (const [event, state] of Object.entries(jobEvents)) { const result = await Tester.testWithBody( - new Buildkite(), + Buildkite, { event, job: { @@ -182,7 +182,7 @@ describe('/POST buildkite', () => { } for (const [event, connectionState] of Object.entries(agentEvents)) { const result = await Tester.testWithBody( - new Buildkite(), + Buildkite, { event, agent: { name: 'runner-1', connection_state: connectionState }, @@ -196,7 +196,7 @@ describe('/POST buildkite', () => { }) it('formats agent, blocked registration, ping, package, and the documented Test Engine alarm event', async () => { - const agentResult = await Tester.testWithBody(new Buildkite(), { + const agentResult = await Tester.testWithBody(Buildkite, { event: 'agent.blocked', agent: { name: 'runner_1', @@ -219,7 +219,7 @@ describe('/POST buildkite', () => { { name: 'Blocked IP', value: '203.0.113.10', inline: true }, ]) - const tokenResult = await Tester.testWithBody(new Buildkite(), { + const tokenResult = await Tester.testWithBody(Buildkite, { event: 'cluster_token.registration_blocked', blocked_ip: '203.0.113.11', cluster_token: { description: 'Production **agents**' }, @@ -231,7 +231,7 @@ describe('/POST buildkite', () => { { name: 'Blocked IP', value: '203.0.113.11', inline: true }, ]) - const pingResult = await Tester.testWithBody(new Buildkite(), { + const pingResult = await Tester.testWithBody(Buildkite, { event: 'ping', service: { provider: 'webhook' }, organization: { name: 'Acme Inc', slug: 'acme-inc' }, @@ -240,7 +240,7 @@ describe('/POST buildkite', () => { assert.equal(pingResult!.embeds![0].title, 'Buildkite webhook settings updated') assert.deepEqual(pingResult!.embeds![0].fields, [{ name: 'Organization', value: 'Acme Inc', inline: true }]) - const packageResult = await Tester.testWithBody(new Buildkite(), { + const packageResult = await Tester.testWithBody(Buildkite, { event: 'package.created', package: { name: 'banana', @@ -256,7 +256,7 @@ describe('/POST buildkite', () => { { name: 'Organization', value: 'acme-inc', inline: true }, ]) - const workflowResult = await Tester.testWithBody(new Buildkite(), { + const workflowResult = await Tester.testWithBody(Buildkite, { event: 'workflow.alarm', subject: { type: 'test', @@ -277,7 +277,7 @@ describe('/POST buildkite', () => { it('accepts future event families generically and only links trusted Buildkite URLs', async () => { const result = await Tester.testWithBody( - new Buildkite(), + Buildkite, { event: 'pipeline.archived', web_url: 'https://evil.example/phishing', @@ -290,7 +290,7 @@ describe('/POST buildkite', () => { assert.equal(result!.embeds![0].url, undefined) assert.deepEqual(result!.embeds![0].author, { name: 'Future \\*\\*sender\\*\\*' }) - const allowed = await Tester.testWithBody(new Buildkite(), { + const allowed = await Tester.testWithBody(Buildkite, { event: 'pipeline.archived', web_url: 'https://api.buildkite.com/v2/organizations/acme-inc/pipelines/my-pipeline', sender: 'Webhook creator', @@ -306,7 +306,7 @@ describe('/POST buildkite', () => { 'https://buildkite.com.evil.example/acme-inc/my-pipeline', 'https://buildkite.com@evil.example/acme-inc/my-pipeline', ]) { - const unsafe = await Tester.testWithBody(new Buildkite(), { + const unsafe = await Tester.testWithBody(Buildkite, { event: 'pipeline.archived', web_url: webUrl, }) @@ -327,12 +327,12 @@ describe('/POST buildkite', () => { { event: 'package.created', sender }, { event: 'workflow.alarm' }, ]) { - assert.equal(await Tester.testWithBody(new Buildkite(), body), null) + assert.equal(await Tester.testWithBody(Buildkite, body), null) } assert.equal( await Tester.testWithBody( - new Buildkite(), + Buildkite, { event: 'build.running', build, pipeline, sender }, { 'x-buildkite-event': 'build.finished' }, ), @@ -340,7 +340,7 @@ describe('/POST buildkite', () => { ) assert.equal( await Tester.testWithBody( - new Buildkite(), + Buildkite, { event: 'build.running', build, pipeline, sender }, { 'x-buildkite-event': '' }, ), @@ -350,7 +350,7 @@ describe('/POST buildkite', () => { it('stays within Discord limits for long untrusted values', async () => { const longText = '@everyone [click](https://evil.example) ' + 'x'.repeat(7000) - const result = await Tester.testWithBody(new Buildkite(), { + const result = await Tester.testWithBody(Buildkite, { event: 'job.promised_exit_status', promised_exit_status_reason: longText, job: { diff --git a/test/circleci/circleci-spec.ts b/test/circleci/circleci-spec.ts index 75fbccd..4274ae6 100644 --- a/test/circleci/circleci-spec.ts +++ b/test/circleci/circleci-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST circleci', () => { it('build', async () => { - const res = await Tester.test(new CircleCi(), 'circleci.json', null) + const res = await Tester.test(CircleCi, 'circleci.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/codacy/codacy-spec.ts b/test/codacy/codacy-spec.ts index e6d14a6..618f655 100644 --- a/test/codacy/codacy-spec.ts +++ b/test/codacy/codacy-spec.ts @@ -5,9 +5,10 @@ import { Tester } from '../Tester.ts' describe('/POST codacy', () => { it('formats a commit exactly', async () => { - const res = await Tester.test(new Codacy(), 'codacy.json', null) + const res = await Tester.test(Codacy, 'codacy.json', null) assert.deepStrictEqual(res, { + allowed_mentions: { parse: [] }, embeds: [ { title: 'New Commit', @@ -27,7 +28,7 @@ describe('/POST codacy', () => { }) it('emits string field names and values', async () => { - const res = await Tester.test(new Codacy(), 'codacy.json', null) + const res = await Tester.test(Codacy, 'codacy.json', null) assert.notStrictEqual(res, null) for (const embed of res!.embeds ?? []) { diff --git a/test/confluence/confluence-spec.ts b/test/confluence/confluence-spec.ts index 05f5232..d1ff5e8 100644 --- a/test/confluence/confluence-spec.ts +++ b/test/confluence/confluence-spec.ts @@ -5,43 +5,43 @@ import { Tester } from '../Tester.ts' describe('/POST confluence', () => { it('page_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_page.json', null) + const res = await Tester.test(Confluence, 'confluence_page.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('attachment_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_attachment.json', null) + const res = await Tester.test(Confluence, 'confluence_attachment.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('comment_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_comment.json', null) + const res = await Tester.test(Confluence, 'confluence_comment.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('label_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_label.json', null) + const res = await Tester.test(Confluence, 'confluence_label.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('space_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_space.json', null) + const res = await Tester.test(Confluence, 'confluence_space.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('blog_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_blog.json', null) + const res = await Tester.test(Confluence, 'confluence_blog.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('user_', async () => { - const res = await Tester.test(new Confluence(), 'confluence_user.json', null) + const res = await Tester.test(Confluence, 'confluence_user.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/dockerhub/dockerhub-spec.ts b/test/dockerhub/dockerhub-spec.ts index 8a4704a..31f0818 100644 --- a/test/dockerhub/dockerhub-spec.ts +++ b/test/dockerhub/dockerhub-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST dockerhub', () => { it('build', async () => { - const res = await Tester.test(new DockerHub(), 'dockerhub.json', null) + const res = await Tester.test(DockerHub, 'dockerhub.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/examples/examples-spec.ts b/test/examples/examples-spec.ts index 534c002..af8eb9f 100644 --- a/test/examples/examples-spec.ts +++ b/test/examples/examples-spec.ts @@ -1,22 +1,17 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { loadProviderExample, providerExamplePaths } from '../../src/ProviderExamples.ts' -import { DirectParseProvider } from '../../src/provider/BaseProvider.ts' +import { defineProvider } from '../../src/provider/Provider.ts' import { ProviderRegistry, providerRegistry } from '../../src/provider/ProviderRegistry.ts' -class ExampleAliasProvider extends DirectParseProvider { - public getName(): string { - return 'Example Alias' - } - - public getPath(): string { - return 'example-alias' - } - - public async parseData(): Promise { - this.payload.content = 'example' - } -} +const exampleAliasProvider = defineProvider({ + path: 'example-alias', + name: 'Example Alias', + example: { body: 'gitlab/gitlab.json' }, + map(_request, output) { + output.payload.content = 'example' + }, +}) const expectedProviderPaths = [ 'appcenter', @@ -66,14 +61,7 @@ describe('provider examples', () => { }) it('resolves example files from the supplied registry', () => { - const registry = new ProviderRegistry([ - { - path: 'example-alias', - name: 'Example Alias', - provider: ExampleAliasProvider, - example: { body: 'gitlab/gitlab.json' }, - }, - ]) + const registry = new ProviderRegistry([exampleAliasProvider]) const example = loadProviderExample('example-alias', registry) diff --git a/test/gitlab/gitlab-spec.ts b/test/gitlab/gitlab-spec.ts index 04a1eb7..3cb7677 100644 --- a/test/gitlab/gitlab-spec.ts +++ b/test/gitlab/gitlab-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST gitlab', () => { it('push', async () => { - const res = await Tester.test(new GitLab(), 'gitlab.json', null) + const res = await Tester.test(GitLab, 'gitlab.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/heroku/heroku-spec.ts b/test/heroku/heroku-spec.ts index 95faed5..bf2d718 100644 --- a/test/heroku/heroku-spec.ts +++ b/test/heroku/heroku-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST heroku', () => { it('deploy', async () => { - const res = await Tester.test(new Heroku(), 'heroku.json', null) + const res = await Tester.test(Heroku, 'heroku.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/huggingface/huggingface-spec.ts b/test/huggingface/huggingface-spec.ts index d63ed93..99ce696 100644 --- a/test/huggingface/huggingface-spec.ts +++ b/test/huggingface/huggingface-spec.ts @@ -20,14 +20,14 @@ const webhook = { id: 'webhook-id', version: 3 } describe('/POST huggingface', () => { it('uses the public provider name and URL path', () => { - const provider = new HuggingFace() + const provider = HuggingFace - assert.strictEqual(provider.getName(), 'Hugging Face') - assert.strictEqual(provider.getPath(), 'huggingface') + assert.strictEqual(provider.name, 'Hugging Face') + assert.strictEqual(provider.path, 'huggingface') }) it('formats the documented pull request payload', async () => { - const res = await Tester.test(new HuggingFace(), 'huggingface.json') + const res = await Tester.test(HuggingFace, 'huggingface.json') const embed = res?.embeds?.[0] assert.strictEqual(res?.username, 'Hugging Face') @@ -42,7 +42,7 @@ describe('/POST huggingface', () => { }) it('summarizes updated branches and tags', async () => { - const res = await Tester.testWithBody(new HuggingFace(), { + const res = await Tester.testWithBody(HuggingFace, { event: { action: 'update', scope: 'repo.content' }, repo, updatedRefs: [ @@ -88,7 +88,7 @@ describe('/POST huggingface', () => { newSha: '575db8b7a51b6f85eb06eee540738584589f131c', })) - const res = await Tester.testWithBody(new HuggingFace(), { + const res = await Tester.testWithBody(HuggingFace, { event: { action: 'update', scope: 'repo.content' }, repo: longRepo, updatedRefs, @@ -106,7 +106,7 @@ describe('/POST huggingface', () => { }) it('formats repository configuration changes, including future narrowed scopes', async () => { - const res = await Tester.testWithBody(new HuggingFace(), { + const res = await Tester.testWithBody(HuggingFace, { event: { action: 'update', scope: 'repo.config.dois' }, repo: { ...repo, private: true }, updatedConfig: { private: true }, @@ -119,7 +119,7 @@ describe('/POST huggingface', () => { }) it('keeps long pull request base refs within Discord field limits', async () => { - const res = await Tester.testWithBody(new HuggingFace(), { + const res = await Tester.testWithBody(HuggingFace, { event: { action: 'update', scope: 'discussion' }, repo, discussion: { @@ -141,7 +141,7 @@ describe('/POST huggingface', () => { }) it('formats hidden discussion comments without exposing missing content', async () => { - const res = await Tester.testWithBody(new HuggingFace(), { + const res = await Tester.testWithBody(HuggingFace, { event: { action: 'update', scope: 'discussion.comment' }, repo, discussion: { @@ -172,7 +172,7 @@ describe('/POST huggingface', () => { }) it('describes repository moves', async () => { - const res = await Tester.testWithBody(new HuggingFace(), { + const res = await Tester.testWithBody(HuggingFace, { event: { action: 'move', scope: 'repo' }, repo: { ...repo, name: 'commit451/old-name' }, movedTo: { name: 'commit451/new-name', owner: { id: 'owner-id' } }, diff --git a/test/instana/instana-spec.ts b/test/instana/instana-spec.ts index 8d949cb..6bd2d7c 100644 --- a/test/instana/instana-spec.ts +++ b/test/instana/instana-spec.ts @@ -5,28 +5,28 @@ import { Tester } from '../Tester.ts' describe('/POST instana', () => { it('general', async () => { - const res = await Tester.test(new Instana(), 'instana.json', null) + const res = await Tester.test(Instana, 'instana.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('open incident', async () => { - const res = await Tester.test(new Instana(), 'instana-open-incident.json', null) + const res = await Tester.test(Instana, 'instana-open-incident.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('close incident', async () => { - const res = await Tester.test(new Instana(), 'instana-close-incident.json', null) + const res = await Tester.test(Instana, 'instana-close-incident.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('change event', async () => { - const res = await Tester.test(new Instana(), 'instana-change-event.json', null) + const res = await Tester.test(Instana, 'instana-change-event.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/jenkins/jenkins-spec.ts b/test/jenkins/jenkins-spec.ts index b0383c9..8764828 100644 --- a/test/jenkins/jenkins-spec.ts +++ b/test/jenkins/jenkins-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST jenkins', () => { it('deploy', async () => { - const res = await Tester.test(new Jenkins(), 'jenkins.json', null) + const res = await Tester.test(Jenkins, 'jenkins.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/jira/jira-spec.ts b/test/jira/jira-spec.ts index 1e823d0..5f99acc 100644 --- a/test/jira/jira-spec.ts +++ b/test/jira/jira-spec.ts @@ -5,41 +5,41 @@ import { Tester } from '../Tester.ts' describe('/POST jira', () => { it('issue_updated', async () => { - const res = await Tester.test(new Jira(), 'jira-issue.json', null) + const res = await Tester.test(Jira, 'jira-issue.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('comment_added', async () => { - const res = await Tester.test(new Jira(), 'jira-comment.json', null) + const res = await Tester.test(Jira, 'jira-comment.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) }) it('custom_no_event', async () => { - const res = await Tester.test(new Jira(), 'jira-custom-no-event.json', null) + const res = await Tester.test(Jira, 'jira-custom-no-event.json', null) assert.ok(!(res instanceof Error)) assert.strictEqual(res, null) }) it('comment_only', async () => { - const res = await Tester.test(new Jira(), 'jira-comment-only.json', null) + const res = await Tester.test(Jira, 'jira-comment-only.json', null) assert.ok(!(res instanceof Error)) assert.strictEqual(res, null) }) it('browse_url', async () => { - const provider: Jira = new Jira() + const provider = Jira const requestBody = JSON.parse(Tester.readTestFile(provider, 'jira-issue.json')) - let res = await Tester.testWithBody(new Jira(), requestBody, null) + let res = await Tester.testWithBody(Jira, requestBody, null) assert.notStrictEqual(res, null) assert.strictEqual(res!.embeds![0]?.url, 'https://jira.atlassian.com/browse/JRA-20002') requestBody.issue.self = 'https://jira.atlassian.com/our/path/rest/api/2/issue/99291' - res = await Tester.testWithBody(new Jira(), requestBody, null) + res = await Tester.testWithBody(Jira, requestBody, null) assert.notStrictEqual(res, null) assert.strictEqual(res!.embeds![0]?.url, 'https://jira.atlassian.com/our/path/browse/JRA-20002') }) diff --git a/test/linear/linear-spec.ts b/test/linear/linear-spec.ts index cf818d5..b84e231 100644 --- a/test/linear/linear-spec.ts +++ b/test/linear/linear-spec.ts @@ -13,15 +13,15 @@ const envelope = { describe('/POST linear', () => { it('exposes provider metadata', () => { - const provider = new Linear() + const provider = Linear - assert.equal(provider.getName(), 'Linear') - assert.equal(provider.getPath(), 'linear') + assert.equal(provider.name, 'Linear') + assert.equal(provider.path, 'linear') }) it('formats the documented comment payload used by example delivery', async () => { const example = loadProviderExample('linear') - const result = await Tester.testWithBody(new Linear(), example.body, example.headers, example.query) + const result = await Tester.testWithBody(Linear, example.body, example.headers, example.query) assert.notEqual(result, null) assert.deepEqual(result!.allowed_mentions, { parse: [] }) assert.equal(result!.username, 'Linear') @@ -44,7 +44,7 @@ describe('/POST linear', () => { }) it('summarizes issue updates with useful current values and changed fields', async () => { - const result = await Tester.testWithBody(new Linear(), { + const result = await Tester.testWithBody(Linear, { ...envelope, action: 'update', actor: { @@ -90,7 +90,7 @@ describe('/POST linear', () => { }) it('handles current and future data-change resource types generically', async () => { - const result = await Tester.testWithBody(new Linear(), { + const result = await Tester.testWithBody(Linear, { ...envelope, action: 'create', data: { @@ -108,7 +108,7 @@ describe('/POST linear', () => { assert.equal(embed.description, 'The rollout is at 50% with \\[details\\]\\(https://example.com\\).') assert.deepEqual(embed.fields, [{ name: 'Project', value: 'Mobile', inline: true }]) - const removedResult = await Tester.testWithBody(new Linear(), { + const removedResult = await Tester.testWithBody(Linear, { ...envelope, action: 'remove', data: { identifier: 'ENG-41', title: 'Retired issue' }, @@ -119,7 +119,7 @@ describe('/POST linear', () => { }) it('formats Issue SLA and OAuth app convenience events', async () => { - const slaResult = await Tester.testWithBody(new Linear(), { + const slaResult = await Tester.testWithBody(Linear, { ...envelope, action: 'breached', issueData: { @@ -138,7 +138,7 @@ describe('/POST linear', () => { ['set', 'set'], ['highRisk', 'at high risk'], ] as const) { - const result = await Tester.testWithBody(new Linear(), { + const result = await Tester.testWithBody(Linear, { ...envelope, action, issueData: { identifier: 'SUP-7', title: 'Customer cannot check out' }, @@ -148,7 +148,7 @@ describe('/POST linear', () => { assert.equal(result!.embeds![0].title, `Issue SLA ${phrase}: SUP-7 — Customer cannot check out`) } - const revokedResult = await Tester.testWithBody(new Linear(), { + const revokedResult = await Tester.testWithBody(Linear, { ...envelope, action: 'revoked', oauthClientId: 'oauth-client-id', @@ -172,12 +172,12 @@ describe('/POST linear', () => { { ...envelope, action: 'create', data: {}, type: '' }, { ...envelope, action: 'create', data: {}, type: 'Issue', webhookTimestamp: Number.NaN }, ]) { - assert.equal(await Tester.testWithBody(new Linear(), body), null) + assert.equal(await Tester.testWithBody(Linear, body), null) } assert.equal( await Tester.testWithBody( - new Linear(), + Linear, { ...envelope, action: 'create', data: {}, type: 'Issue' }, { 'linear-event': 'Comment' }, ), @@ -186,7 +186,7 @@ describe('/POST linear', () => { }) it('only links trusted Linear URLs', async () => { - const result = await Tester.testWithBody(new Linear(), { + const result = await Tester.testWithBody(Linear, { ...envelope, action: 'create', actor: { name: 'Unsafe actor', url: 'https://evil.example/profile' }, @@ -199,7 +199,7 @@ describe('/POST linear', () => { assert.deepEqual(result!.embeds![0].author, { name: 'Unsafe actor' }) const oversizedUrl = `https://linear.app/${'😀'.repeat(500)}` - const oversizedResult = await Tester.testWithBody(new Linear(), { + const oversizedResult = await Tester.testWithBody(Linear, { ...envelope, action: 'create', actor: { name: 'Actor', url: oversizedUrl }, @@ -217,7 +217,7 @@ describe('/POST linear', () => { const updatedFrom = Object.fromEntries( Array.from({ length: 100 }, (_, index) => [`veryLongChangedField${index}${longText}`, 'old']), ) - const result = await Tester.testWithBody(new Linear(), { + const result = await Tester.testWithBody(Linear, { ...envelope, action: 'update', actor: { name: longText }, diff --git a/test/newrelic/newrelic-spec.ts b/test/newrelic/newrelic-spec.ts index 9630773..03790ef 100644 --- a/test/newrelic/newrelic-spec.ts +++ b/test/newrelic/newrelic-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST newrelic', () => { it('deploy', async () => { - const res = await Tester.test(new NewRelic(), 'newrelic.json', null) + const res = await Tester.test(NewRelic, 'newrelic.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/patreon/patreon-spec.ts b/test/patreon/patreon-spec.ts index 0e9cb27..45518de 100644 --- a/test/patreon/patreon-spec.ts +++ b/test/patreon/patreon-spec.ts @@ -8,7 +8,7 @@ describe('/POST patreon', () => { const headers = { 'x-patreon-event': 'pledges:create', } - const res = await Tester.test(new Patreon(), 'patreon-pledge-create.json', headers) + const res = await Tester.test(Patreon, 'patreon-pledge-create.json', headers) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) @@ -23,20 +23,20 @@ describe('/POST patreon', () => { assert.deepEqual(res!.embeds[0].fields, [ { name: 'Unlocked Tier', - value: '[Patron on Discord ($1.00+/mo)](https://www.patreon.com/join/thecampaign/checkout?rid=5050505)\n    • Earn the **Patron** rank on our [Discord Server](https://discord.gg/abcdefg).\n', + value: '[Patron on Discord ($1.00+/mo)](https://www.patreon.com/join/thecampaign/checkout?rid=5050505)\n    • Earn the **Patron** rank on our [Discord Server](https://discord.gg/abcdefg).', inline: false, }, ]) }) it('members:create preserves the current v2 output', async () => { - const deprecatedPayload = await Tester.test(new Patreon(), 'patreon-pledge-create.json', { + const deprecatedPayload = await Tester.test(Patreon, 'patreon-pledge-create.json', { 'x-patreon-event': 'pledges:create', }) const headers = { 'x-patreon-event': 'members:create', } - const res = await Tester.test(new Patreon(), 'patreon-member-create.json', headers) + const res = await Tester.test(Patreon, 'patreon-member-create.json', headers) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/pingdom/pingdom-spec.ts b/test/pingdom/pingdom-spec.ts index 8acada0..d3dd20b 100644 --- a/test/pingdom/pingdom-spec.ts +++ b/test/pingdom/pingdom-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST pingdom', () => { it('check', async () => { - const res = await Tester.test(new Pingdom(), 'pingdom.json', null) + const res = await Tester.test(Pingdom, 'pingdom.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/provider/base-provider-spec.ts b/test/provider/base-provider-spec.ts deleted file mode 100644 index 46180eb..0000000 --- a/test/provider/base-provider-spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import { DirectParseProvider } from '../../src/provider/BaseProvider.ts' -import { Rollbar } from '../../src/provider/Rollbar.ts' - -const delay = (milliseconds: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, milliseconds) - }) - -class DelayedProvider extends DirectParseProvider { - public completed = false - - public getName(): string { - return 'Delayed' - } - - public async parseData(): Promise { - await delay(10) - this.completed = true - this.payload.content = 'complete' - } -} - -class LifecycleProvider extends DirectParseProvider { - public readonly lifecycle: string[] = [] - - public getName(): string { - return 'Lifecycle' - } - - protected async preParse(): Promise { - await delay(5) - this.lifecycle.push('preParse') - } - - public async parseData(): Promise { - this.lifecycle.push('parseData') - } - - protected async postParse(): Promise { - await delay(5) - this.lifecycle.push('postParse') - } -} - -class NullifyingProvider extends DirectParseProvider { - public postParseCalled = false - - public getName(): string { - return 'Nullifying' - } - - public async parseData(): Promise { - this.nullifyPayload() - } - - protected postParse(): void { - this.postParseCalled = true - } - - public addEmbedAfterCancellation(): number { - this.addEmbed({ title: 'must not be added' }) - return this.payload.embeds?.length ?? 0 - } -} - -describe('BaseProvider parsing lifecycle', () => { - it('waits for delayed direct parsing before resolving', async () => { - const provider = new DelayedProvider() - - const result = await provider.parse(null) - - assert.strictEqual(provider.completed, true) - assert.strictEqual(result?.content, 'complete') - }) - - it('awaits preParse, parseData, and postParse in lifecycle order', async () => { - const provider = new LifecycleProvider() - - await provider.parse(null) - - assert.deepStrictEqual(provider.lifecycle, ['preParse', 'parseData', 'postParse']) - }) - - it('returns null, skips postParse, and prevents embeds after nullification', async () => { - const provider = new NullifyingProvider() - - const result = await provider.parse(null) - - assert.strictEqual(result, null) - assert.doesNotThrow(() => { - assert.strictEqual(provider.addEmbedAfterCancellation(), 0) - }) - assert.strictEqual(provider.postParseCalled, false) - }) - - it('returns null without throwing for an unknown Rollbar event', async () => { - const result = await new Rollbar().parse({ event_name: 'unknown_event' }) - - assert.strictEqual(result, null) - }) -}) diff --git a/test/provider/provider-contract-spec.ts b/test/provider/provider-contract-spec.ts new file mode 100644 index 0000000..7946afa --- /dev/null +++ b/test/provider/provider-contract-spec.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { providerRegistry } from '../../src/provider/ProviderRegistry.ts' +import { ProviderRunner } from '../../src/provider/ProviderRunner.ts' +import { validateDiscordPayload } from '../../src/util/DiscordPayloadValidator.ts' + +const runner = new ProviderRunner(providerRegistry) + +describe('provider contracts', () => { + for (const definition of providerRegistry.definitions) { + it(`${definition.path} has valid metadata and produces a valid packaged example`, async () => { + assert.match(definition.path, /^[a-z][a-z0-9-]*$/) + assert.notEqual(definition.name.trim(), '') + assert.notEqual(definition.example.body.trim(), '') + assert.equal(Object.isFrozen(definition), true) + assert.equal(Object.isFrozen(definition.example), true) + + const payload = await runner.runExample(definition.path) + assert.ok(payload, `${definition.path} ignored its packaged example`) + assert.ok( + (typeof payload.content === 'string' && payload.content.length > 0) || + (Array.isArray(payload.embeds) && payload.embeds.length > 0), + `${definition.path} produced an empty packaged example`, + ) + assert.deepEqual(payload.allowed_mentions, { parse: [] }) + assert.deepEqual(validateDiscordPayload(payload), []) + }) + } +}) diff --git a/test/provider/provider-definition-spec.ts b/test/provider/provider-definition-spec.ts new file mode 100644 index 0000000..80b3860 --- /dev/null +++ b/test/provider/provider-definition-spec.ts @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { defineEventProvider, defineProvider } from '../../src/provider/Provider.ts' +import { ProviderRegistry } from '../../src/provider/ProviderRegistry.ts' +import { ProviderRunner } from '../../src/provider/ProviderRunner.ts' +import { SKYHOOK_FOOTER } from '../../src/util/DiscordEmbed.ts' +import { validateDiscordPayload } from '../../src/util/DiscordPayloadValidator.ts' + +const example = { body: 'dockerhub/dockerhub.json' } + +describe('functional provider definitions', () => { + it('rejects invalid provider metadata at definition time', () => { + const map = (): void => undefined + + assert.throws(() => defineProvider({ path: 'Bad Path', name: 'Bad', example, map }), /provider path/i) + assert.throws(() => defineProvider({ path: 'bad', name: ' ', example, map }), /provider name/i) + assert.throws( + () => defineProvider({ path: 'bad', name: 'Bad', example: { body: ' ' }, map }), + /example body/i, + ) + assert.throws( + () => + defineProvider({ + path: 'bad', + name: 'Bad', + example, + http: { allowedHosts: ['HTTPS://example.com/path'] }, + map, + }), + /HTTP host/i, + ) + }) + + it('normalizes request input and applies provider defaults centrally', async () => { + const provider = defineProvider({ + path: 'functional', + name: 'Functional', + example, + defaults: { + username: 'Functional Bot', + embedColor: 0x123456, + }, + async map({ body, headers, query }, output) { + assert.equal(headers.get('x-event'), 'created') + assert.equal(query.get('source'), 'test') + output.payload.content = String(body.value) + output.payload.allowed_mentions = { parse: ['everyone'] } + output.addEmbed({ title: 'Created' }) + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + const result = await runner.run('functional', { + body: { value: 'complete' }, + headers: { 'X-Event': 'created' }, + query: { source: 'test' }, + }) + + assert.equal(result?.content, 'complete') + assert.equal(result?.username, 'Functional Bot') + assert.deepEqual(result?.allowed_mentions, { parse: [] }) + assert.equal(result?.embeds?.[0].color, 0x123456) + assert.deepEqual(result?.embeds?.[0].footer, SKYHOOK_FOOTER) + }) + + it('uses a fresh output draft for every execution and awaits asynchronous maps', async () => { + const provider = defineProvider({ + path: 'fresh', + name: 'Fresh', + example, + async map({ body }, output) { + await new Promise((resolve) => setTimeout(resolve, 5)) + output.addEmbed({ title: String(body.value) }) + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + const first = await runner.run('fresh', { body: { value: 'first' } }) + const second = await runner.run('fresh', { body: { value: 'second' } }) + + assert.deepEqual( + first?.embeds?.map(({ title }) => title), + ['first'], + ) + assert.deepEqual( + second?.embeds?.map(({ title }) => title), + ['second'], + ) + }) + + it('returns null for malformed roots and providers that explicitly ignore an input', async () => { + let calls = 0 + const provider = defineProvider({ + path: 'ignore', + name: 'Ignore', + example, + map(_request, output) { + calls += 1 + output.ignore() + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + assert.equal(await runner.run('ignore', { body: null }), null) + assert.equal(await runner.run('ignore', { body: { ignored: true } }), null) + assert.equal(calls, 1) + }) + + it('rejects an accidental empty draft instead of sending an unusable payload', async () => { + const provider = defineProvider({ + path: 'empty', + name: 'Empty', + example, + map(_request, output) { + output.addEmbed({}) + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + await assert.rejects(runner.run('empty', { body: {} }), /produced no Discord message/i) + }) + + it('dispatches event providers through raw event keys without reflection or a separate allowlist', async () => { + const provider = defineEventProvider({ + path: 'events', + name: 'Events', + example, + event: ({ headers }) => headers.get('x-event'), + handlers: { + 'build.finished': (_request, output) => { + output.payload.content = 'finished' + }, + build_finished: (_request, output) => { + output.payload.content = 'underscored' + }, + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + assert.equal( + (await runner.run('events', { body: {}, headers: { 'x-event': 'build.finished' } }))?.content, + 'finished', + ) + assert.equal( + (await runner.run('events', { body: {}, headers: { 'x-event': 'build_finished' } }))?.content, + 'underscored', + ) + assert.equal(await runner.run('events', { body: {}, headers: { 'x-event': 'unknown' } }), null) + }) + + it('ignores non-string and oversized event selectors rather than coercing them', async () => { + const provider = defineEventProvider({ + path: 'strict-events', + name: 'Strict Events', + example, + event: 'event', + handlers: { + '[object Object]': (_request, output) => { + output.payload.content = 'coerced object' + }, + valid: (_request, output) => { + output.payload.content = 'valid' + }, + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + assert.equal(await runner.run('strict-events', { body: { event: {} } }), null) + assert.equal(await runner.run('strict-events', { body: { event: 'x'.repeat(257) } }), null) + assert.equal((await runner.run('strict-events', { body: { event: 'valid' } }))?.content, 'valid') + }) + + it('finalizes oversized drafts into validator-clean Discord payloads', async () => { + const provider = defineProvider({ + path: 'bounded', + name: 'Bounded', + example, + map(_request, output) { + output.payload.content = 'x'.repeat(3000) + for (let embedIndex = 0; embedIndex < 12; embedIndex += 1) { + output.addEmbed({ + title: 't'.repeat(400), + description: 'd'.repeat(5000), + fields: Array.from({ length: 30 }, (_, fieldIndex) => ({ + name: `Field ${fieldIndex}`, + value: 'v'.repeat(1500), + })), + }) + } + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + const result = await runner.run('bounded', { body: {} }) + + assert.equal(result?.content?.length, 2000) + assert.ok((result?.embeds?.length ?? 0) <= 10) + assert.ok((result?.embeds?.[0].fields?.length ?? 0) <= 25) + assert.deepEqual(validateDiscordPayload(result), []) + }) + + it('drops malformed Discord components while preserving a usable notification', async () => { + const provider = defineProvider({ + path: 'sanitized', + name: 'Sanitized', + example, + map(_request, output) { + output.payload.username = 'u'.repeat(100) + output.payload.avatar_url = 'javascript:alert(1)' + output.addEmbed({ + title: 'Safe title', + url: 'javascript:alert(1)', + timestamp: 'not-a-timestamp', + color: -1, + author: { name: 'Author', url: 'javascript:alert(1)' }, + image: { url: 'file:///private/image.png' }, + thumbnail: { url: 'https://cdn.example.com/image.png' }, + fields: [null, { name: 'Valid', value: 'Value' }] as never, + }) + }, + }) + const runner = new ProviderRunner(new ProviderRegistry([provider])) + + const result = await runner.run('sanitized', { body: {} }) + const embed = result?.embeds?.[0] + + assert.equal(result?.username?.length, 80) + assert.equal(result?.avatar_url, undefined) + assert.equal(embed?.url, undefined) + assert.equal(embed?.timestamp, undefined) + assert.equal(embed?.color, undefined) + assert.deepEqual(embed?.author, { name: 'Author' }) + assert.equal(embed?.image, undefined) + assert.deepEqual(embed?.thumbnail, { url: 'https://cdn.example.com/image.png' }) + assert.deepEqual(embed?.fields, [{ name: 'Valid', value: 'Value' }]) + assert.deepEqual(validateDiscordPayload(result), []) + }) +}) diff --git a/test/provider/provider-http-spec.ts b/test/provider/provider-http-spec.ts new file mode 100644 index 0000000..0873f93 --- /dev/null +++ b/test/provider/provider-http-spec.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { afterEach, describe, it, mock } from 'node:test' +import { defineProvider, type ProviderHttpPolicy } from '../../src/provider/Provider.ts' +import { ProviderRegistry } from '../../src/provider/ProviderRegistry.ts' +import { ProviderRunner } from '../../src/provider/ProviderRunner.ts' + +const example = { body: 'dockerhub/dockerhub.json' } + +afterEach(() => mock.restoreAll()) + +function createRunner(policy?: ProviderHttpPolicy): ProviderRunner { + const provider = defineProvider({ + path: 'http', + name: 'HTTP', + example, + http: policy, + async map({ body, http }, output) { + const result = await http.getJson<{ value: string }>(String(body.url)) + output.payload.content = result.value + }, + }) + return new ProviderRunner(new ProviderRegistry([provider])) +} + +describe('provider HTTP capability', () => { + it('permits bounded JSON GETs to explicitly trusted HTTPS hosts', async () => { + let requestInit: RequestInit | undefined + mock.method(globalThis, 'fetch', async (_input: string | URL | Request, init?: RequestInit) => { + requestInit = init + return new Response(JSON.stringify({ value: 'loaded' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + const runner = createRunner({ allowedHosts: ['api.example.com'], timeoutMs: 100, maxResponseBytes: 100 }) + + const payload = await runner.run('http', { body: { url: 'https://api.example.com/value' } }) + + assert.equal(payload?.content, 'loaded') + assert.equal(requestInit?.redirect, 'error') + assert.equal(requestInit?.headers && new Headers(requestInit.headers).get('accept'), 'application/json') + assert.ok(requestInit?.signal instanceof AbortSignal) + }) + + it('denies networking unless the definition explicitly opts in', async () => { + const runner = createRunner() + + await assert.rejects( + runner.run('http', { body: { url: 'https://api.example.com/value' } }), + /not permitted to make HTTP requests/, + ) + }) + + it('rejects insecure schemes, credentials, ports, and untrusted hosts before fetching', async () => { + const fetchMock = mock.method(globalThis, 'fetch', async () => new Response('{}')) + const runner = createRunner({ allowedHosts: ['api.example.com'] }) + + await assert.rejects(runner.run('http', { body: { url: 'http://api.example.com/value' } }), /must use HTTPS/) + await assert.rejects( + runner.run('http', { body: { url: 'https://user:secret@api.example.com/value' } }), + /must not contain credentials/, + ) + await assert.rejects( + runner.run('http', { body: { url: 'https://api.example.com:8443/value' } }), + /default HTTPS port/, + ) + await assert.rejects( + runner.run('http', { body: { url: 'https://api.example.com.evil/value' } }), + /host is not allowed/, + ) + assert.equal(fetchMock.mock.callCount(), 0) + }) + + it('rejects unsuccessful and oversized responses', async () => { + const runner = createRunner({ allowedHosts: ['api.example.com'], maxResponseBytes: 16 }) + let call = 0 + mock.method(globalThis, 'fetch', async () => { + call += 1 + if (call === 1) return new Response('{}', { status: 503 }) + if (call === 2) return new Response('{}', { status: 200, headers: { 'content-length': '17' } }) + return new Response(JSON.stringify({ value: 'more than sixteen bytes' }), { status: 200 }) + }) + + await assert.rejects(runner.run('http', { body: { url: 'https://api.example.com/value' } }), /status 503/) + await assert.rejects(runner.run('http', { body: { url: 'https://api.example.com/value' } }), /size limit/) + await assert.rejects(runner.run('http', { body: { url: 'https://api.example.com/value' } }), /size limit/) + }) + + it('rejects invalid JSON rather than returning untrusted text', async () => { + mock.method(globalThis, 'fetch', async () => new Response('not-json', { status: 200 })) + const runner = createRunner({ allowedHosts: ['api.example.com'] }) + + await assert.rejects(runner.run('http', { body: { url: 'https://api.example.com/value' } }), SyntaxError) + }) +}) diff --git a/test/provider/provider-registry-spec.ts b/test/provider/provider-registry-spec.ts index f52fd46..e2bfc54 100644 --- a/test/provider/provider-registry-spec.ts +++ b/test/provider/provider-registry-spec.ts @@ -1,28 +1,17 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { DirectParseProvider } from '../../src/provider/BaseProvider.ts' +import { defineProvider } from '../../src/provider/Provider.ts' import { type ProviderDefinition, ProviderRegistry, providerRegistry } from '../../src/provider/ProviderRegistry.ts' -class DuplicatePathProvider extends DirectParseProvider { - public getName(): string { - return 'Duplicate Path' - } - - public getPath(): string { - return 'duplicate' - } - - public async parseData(): Promise { - this.payload.content = 'duplicate' - } -} - -const duplicateDefinition = (): ProviderDefinition => ({ - path: 'duplicate', - name: 'Duplicate Path', - provider: DuplicatePathProvider, - example: { body: 'gitlab/gitlab.json' }, -}) +const duplicateDefinition = (): ProviderDefinition => + defineProvider({ + path: 'duplicate', + name: 'Duplicate Path', + example: { body: 'gitlab/gitlab.json' }, + map: ({ body }, output) => { + output.payload.content = String(body.value ?? 'duplicate') + }, + }) const expectedMetadata = [ { path: 'appcenter', name: 'AppCenter', example: { body: 'appcenter/appcenter-pipeline.json' } }, @@ -91,17 +80,6 @@ describe('ProviderRegistry', () => { ) }) - it('rejects registry metadata that disagrees with the provider contract', () => { - assert.throws( - () => new ProviderRegistry([{ ...duplicateDefinition(), path: 'wrong-path' }]), - /Provider path "wrong-path" does not match "duplicate"/, - ) - assert.throws( - () => new ProviderRegistry([{ ...duplicateDefinition(), name: 'Wrong Name' }]), - /Provider name "Wrong Name" does not match "Duplicate Path"/, - ) - }) - it('preserves provider order, public metadata, and example file mappings', () => { assert.deepEqual( providerRegistry.definitions.map(({ path, name, example }) => ({ path, name, example })), diff --git a/test/provider/provider-runner-spec.ts b/test/provider/provider-runner-spec.ts index 71dca13..f61f7e4 100644 --- a/test/provider/provider-runner-spec.ts +++ b/test/provider/provider-runner-spec.ts @@ -1,9 +1,8 @@ import assert from 'node:assert/strict' import { beforeEach, describe, it } from 'node:test' -import type { DiscordPayload } from '../../src/model/DiscordApi.ts' import { loadProviderExample } from '../../src/ProviderExamples.ts' -import { DirectParseProvider } from '../../src/provider/BaseProvider.ts' -import { type ProviderDefinition, ProviderRegistry } from '../../src/provider/ProviderRegistry.ts' +import { defineProvider, type ProviderOutput } from '../../src/provider/Provider.ts' +import { ProviderRegistry } from '../../src/provider/ProviderRegistry.ts' import { ProviderRunner } from '../../src/provider/ProviderRunner.ts' const delay = (milliseconds: number): Promise => @@ -11,83 +10,53 @@ const delay = (milliseconds: number): Promise => setTimeout(resolve, milliseconds) }) -class RunnerProvider extends DirectParseProvider { - public static constructions = 0 - public static completions = 0 - private readonly instanceNumber = ++RunnerProvider.constructions +let completions = 0 +const outputs = new Set() - public getName(): string { - return 'Runner' - } - - public getPath(): string { - return 'runner' - } - - public async parseData(): Promise { - await delay(5) - RunnerProvider.completions += 1 - if (this.body.ignore === true) { - this.nullifyPayload() - return - } - this.payload.content = `instance-${this.instanceNumber}:${this.body.value ?? 'complete'}` - } -} - -class InvalidPayloadProvider extends DirectParseProvider { - public getName(): string { - return 'Invalid Payload' - } - - public getPath(): string { - return 'invalid' - } - - public async parseData(): Promise { - this.payload.content = 'x'.repeat(2001) - } -} - -const runnerDefinition: ProviderDefinition = { +const runnerDefinition = defineProvider({ path: 'runner', name: 'Runner', - provider: RunnerProvider, example: { body: 'gitlab/gitlab.json' }, -} + async map({ body }, output) { + outputs.add(output) + await delay(5) + completions += 1 + if (body.ignore === true) { + output.ignore() + return + } + output.payload.content = String(body.value ?? 'complete') + }, +}) function createRunnerRegistry(): ProviderRegistry { - const registry = new ProviderRegistry([runnerDefinition]) - // Registry construction validates metadata with a temporary provider. Request - // construction counts start after that startup-only contract check. - RunnerProvider.constructions = 0 - return registry + return new ProviderRegistry([runnerDefinition]) } beforeEach(() => { - RunnerProvider.constructions = 0 - RunnerProvider.completions = 0 + completions = 0 + outputs.clear() }) describe('ProviderRunner', () => { - it('awaits asynchronous provider parsing before returning', async () => { + it('awaits asynchronous provider mapping before returning', async () => { const runner = new ProviderRunner(createRunnerRegistry()) const payload = await runner.run('runner', { body: { value: 'finished' } }) - assert.equal(RunnerProvider.completions, 1) - assert.equal(payload?.content, 'instance-1:finished') + assert.equal(completions, 1) + assert.equal(payload?.content, 'finished') }) - it('constructs a fresh provider for every execution', async () => { + it('uses fresh output state for every execution', async () => { const runner = new ProviderRunner(createRunnerRegistry()) const first = await runner.run('runner', { body: { value: 'first' } }) const second = await runner.run('runner', { body: { value: 'second' } }) - assert.equal(first?.content, 'instance-1:first') - assert.equal(second?.content, 'instance-2:second') - assert.equal(RunnerProvider.constructions, 2) + assert.equal(first?.content, 'first') + assert.equal(second?.content, 'second') + assert.equal(outputs.size, 2) }) it('preserves null for ignored or unsupported events', async () => { @@ -106,30 +75,28 @@ describe('ProviderRunner', () => { const livePayload = await runner.run('runner', example) const examplePayload = await runner.runExample('runner') - assert.equal(livePayload?.content, 'instance-1:complete') - assert.equal(examplePayload?.content, 'instance-2:complete') + assert.equal(livePayload?.content, 'complete') + assert.equal(examplePayload?.content, 'complete') }) - it('reports validation issues as structured warnings without mutating or rejecting output', async () => { - const definition: ProviderDefinition = { - path: 'invalid', - name: 'Invalid Payload', - provider: InvalidPayloadProvider, + it('returns a centrally finalized Discord payload', async () => { + const definition = defineProvider({ + path: 'oversized', + name: 'Oversized', example: { body: 'gitlab/gitlab.json' }, - } + map(_request, output) { + output.payload.content = 'x'.repeat(2_001) + }, + }) const warnings: unknown[] = [] const runner = new ProviderRunner(new ProviderRegistry([definition]), { warn: (warning: unknown) => warnings.push(warning), }) - const expected: DiscordPayload = { content: 'x'.repeat(2001) } - const payload = await runner.run('invalid', { body: {} }) + const payload = await runner.run('oversized', { body: {} }) - assert.deepEqual(payload, expected) - assert.equal(warnings.length, 1) - const warning = JSON.parse(String(warnings[0])) - assert.equal(warning.event, 'discord_payload_validation_warning') - assert.equal(warning.provider, 'invalid') - assert.deepEqual(warning.issues, [{ code: 'content-length', path: 'content', actual: 2001, limit: 2000 }]) + assert.equal(payload?.content?.length, 2_000) + assert.deepEqual(payload?.allowed_mentions, { parse: [] }) + assert.deepEqual(warnings, []) }) }) diff --git a/test/provider/provider-scaffold-spec.ts b/test/provider/provider-scaffold-spec.ts new file mode 100644 index 0000000..b9f8bb6 --- /dev/null +++ b/test/provider/provider-scaffold-spec.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, it } from 'node:test' +import { createProvider } from '../../scripts/create-provider.mjs' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('provider scaffold', () => { + it('creates a provider, fixture, test, registry entry, and README entry', async () => { + const root = await scaffoldProject() + + const result = await createProvider({ + root, + path: 'example-cloud', + exportName: 'ExampleCloud', + displayName: 'Example Cloud', + documentationUrl: 'https://docs.example.com/webhooks', + }) + + const [provider, fixture, test, registry, readme] = await Promise.all([ + readFile(result.providerFile, 'utf8'), + readFile(result.fixtureFile, 'utf8'), + readFile(result.testFile, 'utf8'), + readFile(join(root, 'src/provider/ProviderRegistry.ts'), 'utf8'), + readFile(join(root, 'README.md'), 'utf8'), + ]) + assert.match(provider, /export const ExampleCloud = defineProvider/) + assert.match(provider, /example-cloud\/example-cloud\.json/) + assert.match(result.fixtureFile, /examples\/example-cloud\/example-cloud\.json$/) + assert.match(result.testFile, /test\/example-cloud\/example-cloud-spec\.ts$/) + assert.deepEqual(JSON.parse(fixture), { event: 'Example event' }) + assert.match(test, /Tester\.test\(ExampleCloud, 'example-cloud\.json'\)/) + assert.match(registry, /import \{ ExampleCloud \} from '\.\/ExampleCloud\.ts'/) + assert.match(registry, / {4}ExampleCloud,/) + assert.match(readme, /\[Example Cloud\]\(https:\/\/docs\.example\.com\/webhooks\) - `\/example-cloud`/) + }) + + it('refuses invalid names and existing provider paths', async () => { + const root = await scaffoldProject() + const valid = { + root, + path: 'example', + exportName: 'Example', + displayName: 'Example', + documentationUrl: 'https://docs.example.com/webhooks', + } + + await assert.rejects(createProvider({ ...valid, path: '../escape' }), /provider path/i) + await assert.rejects(createProvider({ ...valid, displayName: 'Bad\nName' }), /display name/i) + await assert.rejects( + createProvider({ ...valid, documentationUrl: 'https://example.com/*/' }), + /documentation URL/i, + ) + await createProvider(valid) + await assert.rejects(createProvider(valid), /refusing to overwrite/i) + }) +}) + +async function scaffoldProject(): Promise { + const root = await mkdtemp(join(tmpdir(), 'skyhook-provider-scaffold-')) + temporaryDirectories.push(root) + await mkdir(join(root, 'src/provider'), { recursive: true }) + await writeFile( + join(root, 'src/provider/ProviderRegistry.ts'), + '// provider-scaffold: imports\nconst providers = [\n // provider-scaffold: definitions\n]\n', + 'utf8', + ) + await writeFile(join(root, 'README.md'), '\n', 'utf8') + return root +} diff --git a/test/rollbar/rollbar-spec.ts b/test/rollbar/rollbar-spec.ts index 7f762ec..81c2bfe 100644 --- a/test/rollbar/rollbar-spec.ts +++ b/test/rollbar/rollbar-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST rollbar', () => { it('new item', async () => { - const res = await Tester.test(new Rollbar(), 'rollbar.json', null) + const res = await Tester.test(Rollbar, 'rollbar.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/shopify/shopify-spec.ts b/test/shopify/shopify-spec.ts index 51d984d..7e28ba4 100644 --- a/test/shopify/shopify-spec.ts +++ b/test/shopify/shopify-spec.ts @@ -13,13 +13,13 @@ const headers = (topic: string, overrides: Record = {}) => ({ describe('/POST shopify', () => { it('exposes provider metadata', () => { - const provider = new Shopify() - assert.strictEqual(provider.getName(), 'Shopify') - assert.strictEqual(provider.getPath(), 'shopify') + const provider = Shopify + assert.strictEqual(provider.name, 'Shopify') + assert.strictEqual(provider.path, 'shopify') }) it('formats an order creation from the documented payload', async () => { - const res = await Tester.test(new Shopify(), 'shopify.json', headers('orders/create')) + const res = await Tester.test(Shopify, 'shopify.json', headers('orders/create')) assert.notStrictEqual(res, null) assert.deepStrictEqual(res!.allowed_mentions, { parse: [] }) assert.strictEqual(res!.embeds?.length, 1) @@ -47,7 +47,7 @@ describe('/POST shopify', () => { }) it('formats a product update from the documented payload', async () => { - const res = await Tester.test(new Shopify(), 'shopify-product.json', headers('products/update')) + const res = await Tester.test(Shopify, 'shopify-product.json', headers('products/update')) assert.notStrictEqual(res, null) const embed = res!.embeds![0] @@ -63,7 +63,7 @@ describe('/POST shopify', () => { it('handles any current or future topic with a safe generic summary', async () => { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { inventory_item_id: 808950810, location_id: 487838322, @@ -90,7 +90,7 @@ describe('/POST shopify', () => { it('formats app uninstall events without exposing contact data', async () => { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { id: 548380009, name: 'Super Toys', @@ -109,13 +109,13 @@ describe('/POST shopify', () => { }) it('ignores malformed requests without a Shopify topic', async () => { - const res = await Tester.testWithBody(new Shopify(), { id: 1 }, headers('not-used', { 'x-shopify-topic': '' })) + const res = await Tester.testWithBody(Shopify, { id: 1 }, headers('not-used', { 'x-shopify-topic': '' })) assert.strictEqual(res, null) }) it('does not trust an invalid shop domain for links', async () => { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { admin_graphql_api_id: 'gid://shopify/Product/123', title: 'Unsafe product', @@ -129,7 +129,7 @@ describe('/POST shopify', () => { it('rejects oversized shop domains and resource IDs before building Discord links', async () => { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { admin_graphql_api_id: `gid://shopify/Product/${'1'.repeat(3000)}`, title: 'Oversized identifiers', @@ -145,7 +145,7 @@ describe('/POST shopify', () => { it('does not display rounded 64-bit Shopify IDs', async () => { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { inventory_item_id: Number('820982911946154508'), location_id: '487838322', @@ -163,7 +163,7 @@ describe('/POST shopify', () => { it('does not forward malformed or oversized timestamps to Discord', async () => { for (const timestamp of ['0', '2026-02-31T00:00:00.000Z', '2026-07-23T15:30:00.12345678901234567890Z']) { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { available: 75 }, headers('inventory_levels/update', { 'x-shopify-triggered-at': timestamp }), ) @@ -174,7 +174,7 @@ describe('/POST shopify', () => { it('escapes untrusted Discord Markdown in embed fields', async () => { const res = await Tester.testWithBody( - new Shopify(), + Shopify, { title: 'Safe title', status: '**active**', @@ -196,7 +196,7 @@ describe('/POST shopify', () => { it('stays inside Discord embed limits for long untrusted values', async () => { const longText = '@everyone ' + 'x'.repeat(7000) const res = await Tester.testWithBody( - new Shopify(), + Shopify, { admin_graphql_api_id: 'gid://shopify/Product/123', title: longText, diff --git a/test/square/square-spec.ts b/test/square/square-spec.ts index 6889c53..978515b 100644 --- a/test/square/square-spec.ts +++ b/test/square/square-spec.ts @@ -12,15 +12,15 @@ const envelope = { describe('/POST square', () => { it('exposes provider metadata', () => { - const provider = new Square() + const provider = Square - assert.equal(provider.getName(), 'Square') - assert.equal(provider.getPath(), 'square') + assert.equal(provider.name, 'Square') + assert.equal(provider.path, 'square') }) it('formats the documented customer payload used by example delivery', async () => { const example = loadProviderExample('square') - const result = await Tester.testWithBody(new Square(), example.body, example.headers, example.query) + const result = await Tester.testWithBody(Square, example.body, example.headers, example.query) assert.notEqual(result, null) assert.equal(result!.username, 'Square') assert.deepEqual(result!.allowed_mentions, { parse: [] }) @@ -35,7 +35,7 @@ describe('/POST square', () => { }) it('summarizes payment events with status, money, and identifiers', async () => { - const result = await Tester.testWithBody(new Square(), { + const result = await Tester.testWithBody(Square, { ...envelope, location_id: 'location_123', type: 'payment.updated', @@ -67,7 +67,7 @@ describe('/POST square', () => { }) it('accepts the documented mixed-case Location data type', async () => { - const result = await Tester.testWithBody(new Square(), { + const result = await Tester.testWithBody(Square, { ...envelope, location_id: 'location_123', type: 'location.created', @@ -92,7 +92,7 @@ describe('/POST square', () => { }) it('handles deleted objects and future event families generically', async () => { - const deleted = await Tester.testWithBody(new Square(), { + const deleted = await Tester.testWithBody(Square, { ...envelope, type: 'customer.deleted', data: { @@ -105,7 +105,7 @@ describe('/POST square', () => { assert.equal(deleted!.embeds![0].title, 'Customer deleted') assert.deepEqual(deleted!.embeds![0].fields, [{ name: 'Customer ID', value: 'customer\\_456', inline: true }]) - const future = await Tester.testWithBody(new Square(), { + const future = await Tester.testWithBody(Square, { ...envelope, type: 'inventory.adjustment.flagged', data: { @@ -132,7 +132,7 @@ describe('/POST square', () => { }) it('accepts documented event data that has no affected object ID', async () => { - const catalog = await Tester.testWithBody(new Square(), { + const catalog = await Tester.testWithBody(Square, { ...envelope, type: 'catalog.version.updated', data: { @@ -148,7 +148,7 @@ describe('/POST square', () => { assert.equal(catalog!.embeds![0].title, 'Catalog version updated') assert.deepEqual(catalog!.embeds![0].fields, []) - const revoked = await Tester.testWithBody(new Square(), { + const revoked = await Tester.testWithBody(Square, { ...envelope, type: 'oauth.authorization.revoked', data: { @@ -184,13 +184,13 @@ describe('/POST square', () => { { ...valid, data: { type: 'customer', id: '' } }, { ...valid, data: { type: 'customer', id: null } }, ]) { - assert.equal(await Tester.testWithBody(new Square(), body), null) + assert.equal(await Tester.testWithBody(Square, body), null) } }) it('stays within Discord limits for long untrusted object values', async () => { const longText = '@everyone [click](https://evil.example) ' + 'x'.repeat(7000) - const result = await Tester.testWithBody(new Square(), { + const result = await Tester.testWithBody(Square, { ...envelope, type: 'catalog.version.updated', data: { diff --git a/test/travis/travis-spec.ts b/test/travis/travis-spec.ts index ef7bd32..aa9424f 100644 --- a/test/travis/travis-spec.ts +++ b/test/travis/travis-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST travis', () => { it('build', async () => { - const res = await Tester.test(new Travis(), 'travis.json', {}) + const res = await Tester.test(Travis, 'travis.json', {}) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/trello/trello-spec.ts b/test/trello/trello-spec.ts index a65d5c1..9958588 100644 --- a/test/trello/trello-spec.ts +++ b/test/trello/trello-spec.ts @@ -7,7 +7,7 @@ afterEach(() => mock.restoreAll()) describe('/POST trello', () => { it('commentCard', async () => { - const res = await Tester.test(new Trello(), 'trello.json', null) + const res = await Tester.test(Trello, 'trello.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) @@ -46,7 +46,7 @@ describe('/POST trello', () => { ) }) - const res = await Tester.testWithBody(new Trello(), { + const res = await Tester.testWithBody(Trello, { action: { type: eventType, memberCreator: { @@ -58,7 +58,7 @@ describe('/POST trello', () => { board: { name: 'Example Board', shortLink: 'board' }, plugin: { name: 'Calendar fallback', - url: 'https://plugins.example/manifest.json', + url: 'https://trello.com/manifest.json', }, }, }, @@ -76,7 +76,7 @@ describe('/POST trello', () => { url: 'https://trello.com/b/board/example-board', title: `[Example Board] ${action} Plugin ${mark}`, fields: [{ name: 'Calendar', value: 'Plugin details', inline: false }], - image: { url: 'https://plugins.example/calendar.png' }, + image: { url: 'https://trello.com/calendar.png' }, footer: { text: 'Powered by skyhookapi.com', icon_url: 'https://skyhookapi.com/images/skyhook-tiny.png', diff --git a/test/unity/unity-spec.ts b/test/unity/unity-spec.ts index 6f7ea67..317778e 100644 --- a/test/unity/unity-spec.ts +++ b/test/unity/unity-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST unity', () => { it('build', async () => { - const res = await Tester.test(new Unity(), 'unity.json', null) + const res = await Tester.test(Unity, 'unity.json', null) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/uptimerobot/uptimeRobot-spec.ts b/test/uptimerobot/uptimeRobot-spec.ts index f6211aa..24f8f5b 100644 --- a/test/uptimerobot/uptimeRobot-spec.ts +++ b/test/uptimerobot/uptimeRobot-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST uptimerobot', () => { it('monitor', async () => { - const res = await Tester.test(new UptimeRobot(), 'uptimerobot.json', null, {}) + const res = await Tester.test(UptimeRobot, 'uptimerobot.json', null, {}) assert.notStrictEqual(res, null) assert.ok(Array.isArray(res!.embeds)) assert.strictEqual(res!.embeds.length, 1) diff --git a/test/util/webhook-value-spec.ts b/test/util/webhook-value-spec.ts index e230eb9..dccafd5 100644 --- a/test/util/webhook-value-spec.ts +++ b/test/util/webhook-value-spec.ts @@ -1,6 +1,16 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { canonicalizeIso8601Timestamp, isRecord } from '../../src/util/WebhookValue.ts' +import { + canonicalizeIso8601Timestamp, + firstIso8601Timestamp, + firstScalar, + isAllowedHostname, + isRecord, + safeId, + safeIntegerText, + scalarText, + trustedHttpsUrl, +} from '../../src/util/WebhookValue.ts' describe('WebhookValue', () => { describe('isRecord', () => { @@ -56,4 +66,53 @@ describe('WebhookValue', () => { } }) }) + + describe('safe scalar extraction', () => { + it('accepts useful scalar values while rejecting ambiguous numeric values', () => { + assert.equal(scalarText(' value\n'), 'value') + assert.equal(scalarText(true), 'true') + assert.equal(scalarText(42.5), '42.5') + assert.equal(scalarText(Number.POSITIVE_INFINITY), null) + assert.equal(scalarText(Number.MAX_SAFE_INTEGER + 1), null) + assert.equal(scalarText({ value: 'no coercion' }), null) + assert.equal(firstScalar(null, {}, 'chosen'), 'chosen') + }) + + it('extracts bounded IDs and safe integers', () => { + assert.equal(safeId(' abc-123 ', 16), 'abc-123') + assert.equal(safeId('too-long', 4), null) + assert.equal(safeId(42), '42') + assert.equal(safeId(1.5), null) + assert.equal(safeIntegerText(0), '0') + assert.equal(safeIntegerText(0, true), null) + assert.equal(safeIntegerText(42, true), '42') + }) + + it('returns the first valid ISO timestamp', () => { + assert.equal(firstIso8601Timestamp('invalid', '2025-01-10T17:27:48Z'), '2025-01-10T17:27:48.000Z') + }) + }) + + describe('trusted URL policy', () => { + it('requires HTTPS and an explicitly allowed hostname', () => { + const policy = { allowedHosts: ['example.com'], maxLength: 100 } + assert.equal(trustedHttpsUrl('https://example.com/path', policy), 'https://example.com/path') + assert.equal(trustedHttpsUrl('http://example.com/path', policy), null) + assert.equal(trustedHttpsUrl('https://evil.example/path', policy), null) + assert.equal(trustedHttpsUrl('https://example.com.evil/path', policy), null) + }) + + it('allows true subdomains only when requested', () => { + assert.equal(isAllowedHostname('api.example.com', ['example.com']), false) + assert.equal(isAllowedHostname('api.example.com', ['example.com'], true), true) + assert.equal(isAllowedHostname('notexample.com', ['example.com'], true), false) + assert.equal( + trustedHttpsUrl('https://api.example.com/path', { + allowedHosts: ['example.com'], + allowSubdomains: true, + }), + 'https://api.example.com/path', + ) + }) + }) }) diff --git a/test/zendesk/zendesk-spec.ts b/test/zendesk/zendesk-spec.ts index 00c8d1d..fdc292f 100644 --- a/test/zendesk/zendesk-spec.ts +++ b/test/zendesk/zendesk-spec.ts @@ -5,7 +5,7 @@ import { Tester } from '../Tester.ts' describe('/POST zendesk', () => { it('formats the documented ticket-created event payload', async () => { - const result = await Tester.test(new Zendesk(), 'zendesk.json') + const result = await Tester.test(Zendesk, 'zendesk.json') assert.notEqual(result, null) assert.deepEqual(result!.allowed_mentions, { parse: [] }) assert.equal(result!.embeds?.length, 1) @@ -25,7 +25,7 @@ describe('/POST zendesk', () => { }) it('summarizes ticket field changes', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 17839070, detail: { id: '1244', @@ -60,7 +60,7 @@ describe('/POST zendesk', () => { }) it('formats ticket comments with author and visibility', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 18501212, detail: { id: '342757', @@ -97,7 +97,7 @@ describe('/POST zendesk', () => { }) it('handles standard event domains and future event types generically', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 123456, detail: { created_at: '2025-01-08T10:12:07Z', @@ -119,7 +119,7 @@ describe('/POST zendesk', () => { { name: 'Group ID', value: '42', inline: true }, ]) - const futureResult = await Tester.testWithBody(new Zendesk(), { + const futureResult = await Tester.testWithBody(Zendesk, { account_id: 123456, detail: { id: 'abc' }, event: { current: true, previous: false }, @@ -138,7 +138,7 @@ describe('/POST zendesk', () => { }) it('accepts Zendesk event types that use the documented slash delimiter', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 12514403, detail: { id: '6596848315901', role: 'END_USER' }, event: { identity: { id: '42', type: 'email' } }, @@ -157,7 +157,7 @@ describe('/POST zendesk', () => { }) it('formats messaging events with the message author and body', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 123456, detail: { id: '9876' }, event: { @@ -181,7 +181,7 @@ describe('/POST zendesk', () => { }) it('summarizes agent availability state changes', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 2, detail: { account_id: '2', agent_id: '10011', version: '3' }, event: { @@ -205,7 +205,7 @@ describe('/POST zendesk', () => { }) it('accepts the documented account subject for omnichannel configuration events', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 2, detail: { account_id: 2 }, event: { current_value: false, previous_value: true }, @@ -223,7 +223,7 @@ describe('/POST zendesk', () => { }) it('summarizes standard list and custom-field changes without dumping nested metadata', async () => { - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 123456, detail: { id: '5158', status: 'OPEN', subject: 'Tagged ticket' }, event: { @@ -270,7 +270,7 @@ describe('/POST zendesk', () => { { type: 'zen:event-type:ticket.created', detail: null, event: {} }, { ticket: { id: 123, subject: 'Custom trigger payload' } }, ]) { - const result = await Tester.testWithBody(new Zendesk(), body) + const result = await Tester.testWithBody(Zendesk, body) assert.equal(result, null) } }) @@ -292,11 +292,11 @@ describe('/POST zendesk', () => { type: 'zen:event-type:organization.created', zendesk_event_version: '2022-06-20', } - const result = await Tester.testWithBody(new Zendesk(), body) + const result = await Tester.testWithBody(Zendesk, body) assert.notEqual(result, null) assert.deepEqual(result!.embeds![0].fields, []) - const invalidTimestampResult = await Tester.testWithBody(new Zendesk(), { + const invalidTimestampResult = await Tester.testWithBody(Zendesk, { ...body, time: '2025-02-31T00:00:00.000Z', }) @@ -305,7 +305,7 @@ describe('/POST zendesk', () => { it('stays within Discord embed limits for untrusted payload text', async () => { const longText = '@everyone [click](https://evil.example) ' + 'x'.repeat(7000) - const result = await Tester.testWithBody(new Zendesk(), { + const result = await Tester.testWithBody(Zendesk, { account_id: 123456, detail: { description: longText,