diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index eb0f4d500..98804ae81 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -22857,6 +22857,82 @@ } } }, + "/webhooks/{webhook_id}/{token}/libredesk": { + "post": { + "operationId": "execute_libredesk_webhook", + "summary": "Execute Libredesk webhook", + "tags": ["Webhooks"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Receives and processes Libredesk help desk webhook events, formatting them as messages in the configured channel.", + "parameters": [ + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the webhook", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the webhook" + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": {"description": "The webhook token", "type": "string"}, + "description": "The webhook token" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "description": "ID of a thread in the webhook channel to target instead of the channel itself", + "x-fluxer-experiment": "channel_threads" + }, + {"type": "null"} + ] + } + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LibredeskWebhook"}}} + } + } + }, "/webhooks/{webhook_id}/{token}/messages/{message_id}": { "get": { "operationId": "get_webhook_message", @@ -23675,6 +23751,93 @@ ], "additionalProperties": false }, + "LibredeskWebhook": { + "type": "object", + "properties": { + "event": {"type": "string"}, + "payload": { + "type": "object", + "properties": { + "uuid": {"type": ["string", "null"]}, + "reference_number": {"type": ["string", "null"]}, + "subject": {"type": ["string", "null"]}, + "inbox_name": {"type": ["string", "null"]}, + "contact": { + "anyOf": [ + { + "type": "object", + "properties": { + "first_name": {"type": ["string", "null"]}, + "last_name": {"type": ["string", "null"]} + } + }, + {"type": "null"} + ] + }, + "conversation_uuid": {"type": ["string", "null"]}, + "conversation": { + "anyOf": [ + { + "type": "object", + "properties": { + "uuid": {"type": ["string", "null"]}, + "reference_number": {"type": ["string", "null"]}, + "subject": {"type": ["string", "null"]}, + "inbox_name": {"type": ["string", "null"]}, + "status_category": {"type": ["string", "null"]} + } + }, + {"type": "null"} + ] + }, + "previous_status": {"type": ["string", "null"]}, + "new_status": {"type": ["string", "null"]}, + "snooze_until": {"type": ["string", "null"]}, + "previous_tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}, + "new_tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}, + "assigned_to": {"anyOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, {"type": "null"}]}, + "type": {"type": ["string", "null"]}, + "status": {"type": ["string", "null"]}, + "text_content": {"type": ["string", "null"]}, + "private": {"type": ["boolean", "null"]}, + "sender_type": {"type": ["string", "null"]}, + "meta": { + "anyOf": [ + { + "type": "object", + "properties": { + "is_automated": {"type": ["boolean", "null"]}, + "is_csat": {"type": ["boolean", "null"]}, + "continuity_email": {"type": ["boolean", "null"]} + } + }, + {"type": "null"} + ] + }, + "attachments": { + "anyOf": [ + {"type": "array", "items": {"type": "object", "properties": {"name": {"type": ["string", "null"]}}}}, + {"type": "null"} + ] + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "first_name": {"type": ["string", "null"]}, + "last_name": {"type": ["string", "null"]} + } + }, + {"type": "null"} + ] + }, + "name": {"type": ["string", "null"]} + } + } + }, + "required": ["event", "payload"] + }, "InstatusWebhook": { "type": "object", "properties": { diff --git a/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts index c5842f828..0c896b286 100644 --- a/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts @@ -52,4 +52,8 @@ export const WebhookRateLimitConfigs = { bucket: 'webhook:instatus::webhook_id', config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true}, } as RouteRateLimitConfig, + WEBHOOK_LIBREDESK: { + bucket: 'webhook:libredesk::webhook_id', + config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true}, + } as RouteRateLimitConfig, } as const; diff --git a/fluxer_api/src/api/webhook/WebhookController.ts b/fluxer_api/src/api/webhook/WebhookController.ts index d82bca388..8782dcf53 100644 --- a/fluxer_api/src/api/webhook/WebhookController.ts +++ b/fluxer_api/src/api/webhook/WebhookController.ts @@ -37,6 +37,7 @@ import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRe import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; +import {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas'; import { SlackWebhookRequest, WebhookCreateRequest, @@ -579,6 +580,34 @@ export function WebhookController(app: HonoApp) { return ctx.body(null, 204); }, ); + app.post( + '/webhooks/:webhook_id/:token/libredesk', + RateLimitMiddleware(RateLimitConfigs.WEBHOOK_LIBREDESK), + BlockAppOriginMiddleware, + OpenAPI({ + operationId: 'execute_libredesk_webhook', + summary: 'Execute Libredesk webhook', + description: + 'Receives and processes Libredesk help desk webhook events, formatting them as messages in the configured channel.', + responseSchema: null, + statusCode: 204, + tags: ['Webhooks'], + }), + Validator('param', WebhookIdTokenParam), + Validator('query', WebhookMessageQueryRequest), + Validator('json', LibredeskWebhook), + async (ctx) => { + const {webhook_id: webhookId, token} = ctx.req.valid('param'); + await ctx.get('webhookRequestService').executeLibredeskWebhook({ + webhookId: createWebhookID(webhookId), + token: createWebhookToken(token), + threadId: ctx.req.query('thread_id'), + data: ctx.req.valid('json'), + requestCache: ctx.get('requestCache'), + }); + return ctx.body(null, 204); + }, + ); app.post('/webhooks/livekit', async (ctx) => { const response = await ctx.get('webhookRequestService').handleLiveKitWebhook({ body: await ctx.req.text(), diff --git a/fluxer_api/src/api/webhook/WebhookRequestService.ts b/fluxer_api/src/api/webhook/WebhookRequestService.ts index 69772c3d0..4ac9aba95 100644 --- a/fluxer_api/src/api/webhook/WebhookRequestService.ts +++ b/fluxer_api/src/api/webhook/WebhookRequestService.ts @@ -26,6 +26,7 @@ import type {WebhookExecuteMessageData, WebhookService} from '@app/api/webhook/W import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; +import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas'; import type { SlackWebhookRequest, WebhookCreateRequest, @@ -165,6 +166,14 @@ interface WebhookExecuteInstatusParams { requestCache: RequestCache; } +interface WebhookExecuteLibredeskParams { + webhookId: WebhookID; + token: WebhookToken; + threadId?: string; + data: LibredeskWebhook; + requestCache: RequestCache; +} + interface LiveKitWebhookParams { body: string; authHeader?: string; @@ -351,6 +360,10 @@ export class WebhookRequestService { await this.webhookService.executeInstatusWebhook(params); } + async executeLibredeskWebhook(params: WebhookExecuteLibredeskParams): Promise { + await this.webhookService.executeLibredeskWebhook(params); + } + async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise { await this.webhookService.executeWebhook({ webhookId: params.webhookId, diff --git a/fluxer_api/src/api/webhook/WebhookService.ts b/fluxer_api/src/api/webhook/WebhookService.ts index 76f06c12b..2c3b1adb9 100644 --- a/fluxer_api/src/api/webhook/WebhookService.ts +++ b/fluxer_api/src/api/webhook/WebhookService.ts @@ -45,6 +45,7 @@ import * as RandomUtils from '@app/api/utils/RandomUtils'; import {inputValidationErrorFromZodIssues} from '@app/api/Validator'; import {transform as GitHubTransform} from '@app/api/webhook/transformers/GitHubTransformer'; import {instatusDeliveryKey, transformInstatusWebhook} from '@app/api/webhook/transformers/InstatusTransformer'; +import {transformLibredeskWebhook} from '@app/api/webhook/transformers/LibredeskTransformer'; import type {WebhookRepository} from '@app/api/webhook/WebhookRepository'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import { @@ -74,6 +75,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas'; import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; +import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas'; import { type WebhookCreateRequest, WebhookForumPostRequestFields, @@ -147,6 +149,11 @@ interface WebhookExecuteInstatusParams extends WebhookTokenParams { requestCache: RequestCache; } +interface WebhookExecuteLibredeskParams extends WebhookTokenParams, WebhookThreadParams { + data: LibredeskWebhook; + requestCache: RequestCache; +} + const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__'; const HOSTED_WEBHOOK_AVATAR_URLS = { github: 'https://fluxer.app/static/img/app-webhook-github.2d0319169a3aee33.webp', @@ -603,6 +610,22 @@ export class WebhookService { if (delivery) await this.cacheService.set(`instatus:${webhookId}:${delivery}`, 1, seconds('1 day')); } + async executeLibredeskWebhook(params: WebhookExecuteLibredeskParams): Promise { + const {webhookId, token, threadId, data, requestCache} = params; + const {webhook, channel} = await this.getTokenAuthenticatedWebhookChannel({webhookId, token}); + if (channel.isThreadOnly() && threadId === undefined) throw new WebhookServiceForumUnsupportedError(); + const thread = await this.resolveWebhookThread(webhook, threadId); + const embed = transformLibredeskWebhook(data); + if (!embed) return; + await this.channelService.messages.send.sendWebhookMessage({ + webhook, + thread, + data: {embeds: [embed], allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS}, + username: 'Libredesk', + requestCache, + }); + } + async dispatchWebhooksUpdate({ guildId, channelId, diff --git a/fluxer_api/src/api/webhook/tests/FollowerWebhookManagement.test.ts b/fluxer_api/src/api/webhook/tests/FollowerWebhookManagement.test.ts index be4c58c30..4e15f2228 100644 --- a/fluxer_api/src/api/webhook/tests/FollowerWebhookManagement.test.ts +++ b/fluxer_api/src/api/webhook/tests/FollowerWebhookManagement.test.ts @@ -92,6 +92,7 @@ describe('Follower webhook management', () => { {method: 'post', path: `${base}?wait=true`, body: {content: 'hello'}}, {method: 'post', path: `${base}/slack`, body: {text: 'hello'}}, {method: 'post', path: `${base}/instatus`, body: {meta: {unsubscribe: '', documentation: ''}, page: {id: 'x'}}}, + {method: 'post', path: `${base}/libredesk`, body: {event: 'webhook.test', payload: {id: 1, name: 'x'}}}, {method: 'get', path: `${base}/messages/${messageId}`}, {method: 'patch', path: `${base}/messages/${messageId}`, body: {content: 'edited'}}, {method: 'delete', path: `${base}/messages/${messageId}`}, diff --git a/fluxer_api/src/api/webhook/tests/LibredeskTransformer.test.ts b/fluxer_api/src/api/webhook/tests/LibredeskTransformer.test.ts new file mode 100644 index 000000000..775aaf2b9 --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/LibredeskTransformer.test.ts @@ -0,0 +1,731 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {transformLibredeskWebhook} from '@app/api/webhook/transformers/LibredeskTransformer'; +import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; +import {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas'; +import {describe, expect, it} from 'vitest'; + +const CONVERSATION_UUID = '3f1c2a7e-8b4d-4e6a-9c1f-5d2e7b8a9c01'; +const EMBED_TOTAL_MAX_LENGTH = 6000; +const COMBINING_ACUTE = String.fromCodePoint(0x301); +const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e); +const LINE_SEPARATOR = String.fromCodePoint(0x2028); + +function createContact(overrides: Record = {}) { + return { + id: 17, + created_at: '2026-10-15T09:12:30.118204Z', + updated_at: '2026-10-15T09:12:30.118204Z', + first_name: 'Example contact', + last_name: '', + email: 'contact@example.com', + type: 'contact', + availability_status: 'offline', + avatar_url: null, + phone_number: null, + phone_number_country_code: null, + country: null, + custom_attributes: {}, + enabled: true, + last_active_at: null, + last_login_at: null, + external_user_id: null, + ...overrides, + }; +} + +function createConversation(overrides: Record = {}) { + return { + id: 42, + created_at: '2026-10-15T09:12:30.154871Z', + updated_at: '2026-10-15T09:12:30.154871Z', + uuid: CONVERSATION_UUID, + contact_id: 17, + inbox_id: 1, + closed_at: null, + resolved_at: null, + reference_number: '1042', + priority: null, + priority_id: null, + status: 'Open', + status_category: 'open', + status_id: 1, + first_reply_at: null, + last_reply_at: null, + assigned_user_id: null, + assigned_team_id: null, + waiting_since: '2026-10-15T09:12:30.201377Z', + snoozed_until: null, + subject: 'Cannot sign in', + inbox_mail: 'support@example.com', + inbox_reply_to: '', + inbox_name: 'Support', + inbox_channel: 'email', + tags: [], + meta: {}, + custom_attributes: {}, + last_message_at: '2026-10-15T09:12:30.101552Z', + last_message: 'Hello, I cannot sign in to my account since this morning.', + last_message_sender: 'contact', + last_interaction: 'Hello, I cannot sign in to my account since this morning.', + last_interaction_at: '2026-10-15T09:12:30.101552Z', + last_interaction_sender: 'contact', + contact: createContact(), + sla_policy_id: null, + sla_policy_name: null, + applied_sla_id: null, + next_sla_deadline_at: null, + first_response_deadline_at: null, + resolution_deadline_at: null, + next_response_deadline_at: null, + next_response_met_at: null, + csat_rating: null, + csat_feedback: null, + csat_responded_at: null, + previous_conversations: null, + ...overrides, + }; +} + +function createAgentAuthor(overrides: Record = {}) { + return { + id: 5, + first_name: 'Support', + last_name: 'agent', + email: 'agent@example.com', + avatar_url: null, + availability_status: 'online', + type: 'agent', + last_active_at: '2026-10-15T09:20:02.77631Z', + ...overrides, + }; +} + +function createMessage(overrides: Record = {}) { + return { + id: 311, + created_at: '2026-10-15T09:12:30.101552Z', + updated_at: '2026-10-15T09:12:30.101552Z', + uuid: 'c8a1f2e3-4b5d-4c6e-8f7a-9b0c1d2e3f40', + type: 'incoming', + status: 'received', + conversation_id: 42, + conversation_uuid: CONVERSATION_UUID, + content: + '

Hello, I cannot sign in to my account since this morning.

The page says my password is wrong.

', + text_content: 'Hello, I cannot sign in to my account since this morning.\n\nThe page says my password is wrong.', + content_type: 'html', + private: false, + sender_id: 17, + sender_type: 'contact', + meta: {bcc: [], cc: [], from: ['contact@example.com'], subject: 'Cannot sign in', to: ['support@example.com']}, + attachments: [ + { + name: 'screenshot.png', + size: 48213, + content: null, + content_id: '', + content_type: 'image/png', + disposition: 'attachment', + uuid: '7b2e9d14-3c5a-4f8e-a1b6-0d9c8e7f6a52', + url: 'https://desk.example.com/uploads/7b2e9d14-3c5a-4f8e-a1b6-0d9c8e7f6a52?sig=example&exp=1792143150', + thumbnail_url: 'https://desk.example.com/uploads/thumb_7b2e9d14-3c5a-4f8e-a1b6-0d9c8e7f6a52?sig=example', + }, + ], + author: { + id: 17, + first_name: 'Example contact', + last_name: '', + email: 'contact@example.com', + avatar_url: null, + availability_status: 'offline', + type: 'contact', + last_active_at: null, + }, + ...overrides, + }; +} + +function createOutgoingMessage(overrides: Record = {}) { + return createMessage({ + id: 318, + uuid: '0e4d7c2b-9a1f-4b3e-8d6c-5f2a1b0e9d87', + type: 'outgoing', + status: 'pending', + content: '

Thanks for reaching out. Please try again.

', + text_content: 'Thanks for reaching out. Please try again.', + sender_id: 5, + sender_type: 'agent', + meta: {to: ['contact@example.com']}, + attachments: [], + author: createAgentAuthor(), + ...overrides, + }); +} + +function render(event: string, payload: unknown, timestamp = '2026-10-15T09:12:30Z'): RichEmbedRequest | null { + return transformLibredeskWebhook(LibredeskWebhook.parse({event, payload, timestamp})); +} + +function renderedText(embed: RichEmbedRequest): Array { + return [ + embed.title, + embed.description, + embed.author?.name, + embed.footer?.text, + ...(embed.fields ?? []).flatMap((field) => [field.name, field.value]), + ].filter((value): value is string => typeof value === 'string'); +} + +function embedLength(embed: RichEmbedRequest): number { + return renderedText(embed).reduce((total, value) => total + value.length, 0); +} + +function markdownSlots(embed: RichEmbedRequest): Array { + return [embed.title, embed.description, ...(embed.fields ?? []).map((field) => field.value)].filter( + (value): value is string => typeof value === 'string', + ); +} + +function unescapedTrigger(value: string): RegExpMatchArray | null { + return value.match(/(? { + describe('conversation events', () => { + it('renders conversation.created with the inbox, reference, subject and contact name', () => { + expect(render('conversation.created', createConversation())).toEqual({ + title: '\\[Support\\] New conversation: #1042 Cannot sign in', + color: 0x3b82f6, + author: {name: 'Example contact'}, + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + }); + + it('never renders the contact email or phone number', () => { + const embed = render( + 'conversation.created', + createConversation({contact: createContact({phone_number: '5550100', country: 'Example country'})}), + ); + const text = renderedText(embed!).join('\n'); + expect(text).not.toContain('contact@example.com'); + expect(text).not.toContain('5550100'); + expect(text).not.toContain('support@example.com'); + }); + + it('renders conversation.status_changed with both statuses', () => { + const embed = render('conversation.status_changed', { + actor_id: 5, + conversation: createConversation({status: 'Resolved', status_category: 'resolved'}), + conversation_uuid: CONVERSATION_UUID, + new_status: 'Resolved', + previous_status: 'Open', + snooze_until: '', + }); + expect(embed).toEqual({ + title: '\\[Support\\] Status changed: #1042 Cannot sign in', + color: 0x3ecf8e, + description: 'Open to Resolved', + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + }); + + it('renders the snooze end as a timestamp', () => { + const embed = render('conversation.status_changed', { + actor_id: 5, + conversation: createConversation({status: 'Snoozed', status_category: 'waiting'}), + conversation_uuid: CONVERSATION_UUID, + new_status: 'Snoozed', + previous_status: 'Open', + snooze_until: '2026-10-16T09:40:05Z', + }); + expect(embed?.description).toBe( + `Open to Snoozed until `, + ); + expect(embed?.color).toBe(0xf5a623); + }); + + it('drops a snooze end outside the timestamp range', () => { + for (const snooze_until of ['1970-01-01T00:00:00Z', '1969-12-31T00:00:00Z', '-271821-04-20T00:00:00Z', 'soon']) { + const embed = render('conversation.status_changed', { + conversation: createConversation({status: 'Snoozed', status_category: 'waiting'}), + conversation_uuid: CONVERSATION_UUID, + new_status: 'Snoozed', + previous_status: 'Open', + snooze_until, + }); + expect(embed?.description).toBe('Open to Snoozed'); + } + }); + + it('skips a status change without a new status', () => { + expect(render('conversation.status_changed', {conversation_uuid: CONVERSATION_UUID, new_status: ''})).toBeNull(); + }); + + it('renders conversation.tags_changed as the added and removed tags', () => { + const embed = render('conversation.tags_changed', { + actor_id: 5, + conversation: createConversation({tags: ['account', 'login']}), + conversation_uuid: CONVERSATION_UUID, + new_tags: ['account', 'login'], + previous_tags: ['account', 'billing'], + }); + expect(embed?.title).toBe('\\[Support\\] Tags changed: #1042 Cannot sign in'); + expect(embed?.fields).toEqual([ + {name: 'Added', value: 'login', inline: true}, + {name: 'Removed', value: 'billing', inline: true}, + ]); + }); + + it('skips a tags change that changed nothing', () => { + expect( + render('conversation.tags_changed', { + conversation: createConversation(), + conversation_uuid: CONVERSATION_UUID, + new_tags: ['account'], + previous_tags: ['account'], + }), + ).toBeNull(); + }); + + it('renders conversation.assigned with the agent ID', () => { + const embed = render('conversation.assigned', { + actor_id: 6, + assigned_to: 5, + conversation: createConversation({assigned_user_id: 5}), + conversation_uuid: CONVERSATION_UUID, + }); + expect(embed?.title).toBe('\\[Support\\] Conversation assigned: #1042 Cannot sign in'); + expect(embed?.fields).toEqual([{name: 'Agent ID', value: '5', inline: true}]); + }); + + it('renders conversation.unassigned', () => { + const embed = render('conversation.unassigned', { + actor_id: 5, + conversation: createConversation({assigned_team_id: 2}), + conversation_uuid: CONVERSATION_UUID, + }); + expect(embed?.title).toBe('\\[Support\\] Conversation unassigned: #1042 Cannot sign in'); + expect(embed?.footer).toEqual({text: `Conversation ${CONVERSATION_UUID}`}); + }); + + it('falls back to the top-level uuid when Libredesk could not load the conversation', () => { + const embed = render('conversation.unassigned', { + actor_id: 5, + conversation: createConversation({ + id: 0, + uuid: '', + reference_number: '', + subject: null, + inbox_name: '', + inbox_channel: '', + status: null, + status_category: null, + tags: null, + meta: null, + custom_attributes: null, + contact: createContact({id: 0, first_name: '', email: null, custom_attributes: null}), + }), + conversation_uuid: CONVERSATION_UUID, + }); + expect(embed).toEqual({ + title: 'Conversation unassigned', + color: 0x5c6bc0, + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + }); + + it('renders a live chat conversation without a subject', () => { + const embed = render( + 'conversation.created', + createConversation({subject: null, inbox_name: 'Live chat', inbox_channel: 'livechat'}), + ); + expect(embed?.title).toBe('\\[Live chat\\] New conversation: #1042'); + }); + }); + + describe('message events', () => { + it('renders an incoming message with the sender name, excerpt and attachment names', () => { + expect(render('message.created', createMessage())).toEqual({ + title: 'New message', + color: 0xf97316, + description: 'Hello, I cannot sign in to my account since this morning. The page says my password is wrong.', + author: {name: 'Example contact'}, + fields: [{name: 'Attachments', value: 'screenshot.png', inline: false}], + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + }); + + it('renders an agent reply', () => { + const embed = render('message.created', createOutgoingMessage()); + expect(embed?.title).toBe('New reply'); + expect(embed?.author).toEqual({name: 'Support agent'}); + expect(embed?.description).toBe('Thanks for reaching out. Please try again.'); + expect(embed?.fields).toBeUndefined(); + }); + + it('labels a private note and keeps the mention text inert', () => { + const embed = render( + 'message.created', + createOutgoingMessage({ + status: 'sent', + private: true, + meta: {}, + content: + '

The account was locked. @Second agent can you confirm?

', + text_content: 'The account was locked. @Second agent can you confirm?', + }), + ); + expect(embed?.title).toBe('New private note'); + expect(embed?.color).toBe(0xfcbd1f); + expect(embed?.description).toBe('The account was locked. \\@Second agent can you confirm?'); + }); + + it('labels an automated reply from the system user', () => { + const embed = render( + 'message.created', + createOutgoingMessage({ + sender_id: 1, + meta: {is_automated: true}, + text_content: 'We have received your request.', + author: createAgentAuthor({id: 1, first_name: 'System', last_name: '', email: 'System', type: 'agent'}), + }), + ); + expect(embed?.title).toBe('Automated reply'); + expect(embed?.author).toEqual({name: 'System'}); + }); + + it('skips activity lines, satisfaction surveys and continuity digests', () => { + expect( + render( + 'message.created', + createOutgoingMessage({ + type: 'activity', + status: 'sent', + private: true, + content_type: 'text', + text_content: 'Support agent self-assigned this conversation', + meta: {activity_type: 'self_assign'}, + }), + ), + ).toBeNull(); + expect( + render( + 'message.created', + createOutgoingMessage({ + meta: {is_csat: true, is_automated: true, csat_uuid: 'b0f5c2d4-0000-4000-8000-000000000000'}, + text_content: 'Rate us at https://desk.example.com/csat/b0f5c2d4-0000-4000-8000-000000000000', + }), + ), + ).toBeNull(); + expect(render('message.created', createOutgoingMessage({meta: {continuity_email: true}}))).toBeNull(); + }); + + it('names the sender generically when Libredesk sends an empty author', () => { + const embed = render( + 'message.created', + createMessage({author: {id: 0, first_name: '', last_name: '', email: null, type: ''}, attachments: null}), + ); + expect(embed?.author).toEqual({name: 'Contact'}); + expect(embed?.fields).toBeUndefined(); + }); + + it('renders an image-only message without a description', () => { + const embed = render( + 'message.created', + createMessage({content: '

', text_content: ''}), + ); + expect(embed?.description).toBeUndefined(); + expect(embed?.fields).toEqual([{name: 'Attachments', value: 'screenshot.png', inline: false}]); + }); + + it('renders message.updated only for a failed send', () => { + const failed = render('message.updated', createOutgoingMessage({status: 'failed'})); + expect(failed).toEqual({ + title: 'Reply failed to send', + color: 0xe23c39, + description: 'Thanks for reaching out. Please try again.', + author: {name: 'Support agent'}, + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + expect(render('message.updated', createOutgoingMessage({status: 'sent'}))).toBeNull(); + expect(render('message.updated', createOutgoingMessage({status: 'pending'}))).toBeNull(); + }); + }); + + describe('test and custom events', () => { + it('renders webhook.test', () => { + expect(render('webhook.test', {id: 3, name: 'Support feed'})).toEqual({ + title: 'Test delivery received', + color: 0x5c6bc0, + description: 'Support feed', + }); + }); + + it('renders an automation event that carries a conversation', () => { + const embed = render('priority_escalated', { + actor_id: 1, + conversation: createConversation({priority: 'High'}), + }); + expect(embed?.title).toBe('\\[Support\\] priority_escalated: #1042 Cannot sign in'); + expect(embed?.footer).toEqual({text: `Conversation ${CONVERSATION_UUID}`}); + }); + + it('ignores an unknown event without a conversation', () => { + expect(render('inbox.renamed', {id: 1, name: 'Support'})).toBeNull(); + }); + + it('renders an automation event named like a built-in event as an automation event', () => { + const payload = {actor_id: 1, conversation: createConversation()}; + for (const event of [ + 'conversation.created', + 'conversation.status_changed', + 'conversation.tags_changed', + 'conversation.assigned', + 'conversation.unassigned', + 'message.created', + 'message.updated', + 'webhook.test', + ]) { + expect(render(event, payload)).toEqual({ + title: `\\[Support\\] ${event}: #1042 Cannot sign in`, + color: 0x5c6bc0, + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + } + }); + + it('accepts an automation event name of any length and truncates it', () => { + const embed = render('x'.repeat(300), {conversation: createConversation()}); + expect(embed?.title).toBe(`\\[Support\\] ${'x'.repeat(29)}...: #1042 Cannot sign in`); + }); + }); + + describe('validation', () => { + it('accepts null and missing optional fields', () => { + expect(render('conversation.created', {uuid: CONVERSATION_UUID})).toEqual({ + title: 'New conversation', + color: 0x3b82f6, + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + expect(render('message.created', {conversation_uuid: CONVERSATION_UUID})).toEqual({ + title: 'New reply', + color: 0x3b82f6, + author: {name: 'Agent'}, + footer: {text: `Conversation ${CONVERSATION_UUID}`}, + }); + expect(render('conversation.created', {})).toBeNull(); + expect(render('message.created', {})).toBeNull(); + expect(render('conversation.tags_changed', {conversation: null, new_tags: null, previous_tags: null})).toBeNull(); + }); + + it('rejects a body without an event or a payload object', () => { + expect(LibredeskWebhook.safeParse({payload: {}}).success).toBe(false); + expect(LibredeskWebhook.safeParse({event: 'webhook.test'}).success).toBe(false); + expect(LibredeskWebhook.safeParse({event: 'webhook.test', payload: 'text'}).success).toBe(false); + expect(LibredeskWebhook.safeParse({event: 'message.created', payload: {text_content: 42}}).success).toBe(false); + }); + + it('never reads the raw HTML content', () => { + const embed = render( + 'message.created', + createMessage({content: '

Visible

', text_content: 'Visible'}), + ); + expect(embed?.description).toBe('Visible'); + }); + }); + + describe('sanitisation', () => { + it('neutralises mass mentions and mention syntax in every markdown slot', () => { + const hostile = '@everyone @here <@123456789012345678> <@&123456789012345678> <#123456789012345678> '; + const embed = render('message.created', createMessage({text_content: hostile, attachments: [{name: hostile}]})); + const conversation = render( + 'conversation.created', + createConversation({subject: hostile, inbox_name: '@here', reference_number: '<@1>'}), + ); + for (const value of [...markdownSlots(embed!), ...markdownSlots(conversation!)]) { + expect(unescapedTrigger(value)).toBeNull(); + } + expect(embed?.description).toBe( + '\\@everyone \\@here \\<\\@123456789012345678> \\<\\@&123456789012345678> \\<#123456789012345678> \\', + ); + }); + + it('keeps an escaped backslash from re-enabling a mention', () => { + const embed = render('message.created', createMessage({text_content: '\\@everyone \\<@1>'})); + expect(embed?.description).toBe('\\\\\\@everyone \\\\\\<\\@1>'); + expect(unescapedTrigger(embed!.description!)).toBeNull(); + }); + + it('disarms masked links but keeps plain urls readable', () => { + const embed = render( + 'message.created', + createMessage({ + text_content: '[Reset password](https://phishing.example.com) or visit https://example.com/help', + }), + ); + expect(embed?.description).toBe( + '\\[Reset password\\](https://phishing.example.com) or visit https://example.com/help', + ); + }); + + it('percent-encodes markdown triggers inside urls so links keep their target', () => { + const embed = render( + 'message.created', + createMessage({ + text_content: + 'See https://social.example/@user/1 and https://docs.example/a[1]?q=a\\b or https://x.example/<@123> then @here', + }), + ); + expect(embed?.description).toBe( + 'See https://social.example/%40user/1 and https://docs.example/a%5B1%5D?q=a%5Cb or https://x.example/%3C%40123> then \\@here', + ); + expect(unescapedTrigger(embed!.description!)).toBeNull(); + }); + + it('keeps a url that the parser would reject inert', () => { + const embed = render( + 'message.created', + createMessage({text_content: 'https://**x.example/@everyone[click](https://phishing.example.com)'}), + ); + expect(embed?.description).toBe('https://**x.example/%40everyone%5Bclick%5D(https://phishing.example.com)'); + }); + + it('renders HTML in the plain text as literal text', () => { + const embed = render( + 'message.created', + createMessage({text_content: ' '}), + ); + expect(embed?.description).toBe('\\ \\'); + }); + + it('collapses whitespace and strips control and bidi characters', () => { + const embed = render( + 'message.created', + createMessage({ + text_content: `Line one\n\n\tLine${String.fromCodePoint(0)} two${RIGHT_TO_LEFT_OVERRIDE} reversed${LINE_SEPARATOR}end `, + }), + ); + expect(embed?.description).toBe('Line one Line two reversed end'); + }); + + it('leaves plain-text slots unescaped', () => { + const embed = render('message.created', createMessage({author: {first_name: '@everyone', last_name: '<@1>'}})); + expect(embed?.author).toEqual({name: '@everyone <@1>'}); + }); + + it('clamps a very long subject and body within the embed limits', () => { + const long = 'a'.repeat(100_000); + const created = render( + 'conversation.created', + createConversation({subject: long, inbox_name: long, reference_number: long}), + ); + expect(created!.title!.length).toBeLessThanOrEqual(256); + expect(created!.title!.endsWith('...')).toBe(true); + const message = render('message.created', createMessage({text_content: long, attachments: [{name: long}]})); + expect(message!.description!.length).toBeLessThanOrEqual(1000); + expect(message!.description!.endsWith('...')).toBe(true); + expect(message!.fields![0]!.value.length).toBeLessThanOrEqual(256); + }); + + it('keeps the worst case title within 256 units', () => { + const triggers = '@'.repeat(10_000); + const embed = render('priority_escalated', { + conversation: createConversation({subject: triggers, inbox_name: triggers, reference_number: triggers}), + }); + expect(embed!.title!.length).toBeLessThanOrEqual(256); + expect(unescapedTrigger(embed!.title!)).toBeNull(); + }); + + it('never splits an escape when truncating', () => { + for (let offset = 0; offset < 4; offset++) { + const embed = render( + 'message.created', + createMessage({text_content: `${'x'.repeat(offset)}${'@'.repeat(2000)}`}), + ); + const description = embed!.description!; + expect(description.length).toBeLessThanOrEqual(1000); + expect(description.endsWith('...')).toBe(true); + expect(unescapedTrigger(description)).toBeNull(); + expect(description.slice(0, -3).endsWith('\\')).toBe(false); + } + }); + + it('bounds a single grapheme built from thousands of combining marks', () => { + const bomb = `a${COMBINING_ACUTE.repeat(20_000)}`; + const embed = render( + 'message.created', + createMessage({ + text_content: bomb, + author: {first_name: bomb, last_name: bomb}, + attachments: [{name: bomb}], + conversation_uuid: bomb, + }), + ); + expect(embed!.description).toBe('...'); + expect(embed!.author!.name).toBe('...'); + expect(embedLength(embed!)).toBeLessThanOrEqual(EMBED_TOTAL_MAX_LENGTH); + }); + + it('never emits a lone surrogate', () => { + const lone = render('message.created', createMessage({text_content: 'Hello \uD800 there'})); + expect(lone!.description).toBe('Hello \uFFFD there'); + const split = render('message.created', createMessage({text_content: `x${'\u{1F600}'.repeat(3000)}`})); + expect(split!.description!.isWellFormed()).toBe(true); + expect(split!.description!.length).toBeLessThanOrEqual(1000); + }); + + it('stays within the total embed budget for every event when every string is hostile', () => { + const hostile = `${'<@&1> @everyone [x](https://example.com) '.repeat(500)}${COMBINING_ACUTE.repeat(500)}`; + const tags = Array.from({length: 200}, (_, index) => `${hostile}${index}`); + const conversation = createConversation({ + subject: hostile, + inbox_name: hostile, + reference_number: hostile, + contact: createContact({first_name: hostile, last_name: hostile}), + }); + const payloads: Array<[string, unknown]> = [ + ['conversation.created', conversation], + [ + 'conversation.status_changed', + { + conversation, + conversation_uuid: hostile, + previous_status: hostile, + new_status: hostile, + snooze_until: '2026-10-16T09:40:05Z', + }, + ], + ['conversation.tags_changed', {conversation, conversation_uuid: hostile, previous_tags: [], new_tags: tags}], + ['conversation.assigned', {conversation, conversation_uuid: hostile, assigned_to: Number.MAX_SAFE_INTEGER}], + ['conversation.unassigned', {conversation, conversation_uuid: hostile}], + [ + 'message.created', + createMessage({ + text_content: hostile, + author: {first_name: hostile, last_name: hostile}, + attachments: tags.map((name) => ({name})), + conversation_uuid: hostile, + }), + ], + [ + 'message.updated', + createOutgoingMessage({status: 'failed', text_content: hostile, conversation_uuid: hostile}), + ], + ['webhook.test', {id: 3, name: hostile}], + [hostile.slice(0, 256).trim(), {conversation}], + ]; + for (const [event, payload] of payloads) { + const embed = render(event, payload); + expect(embed).not.toBeNull(); + expect(embedLength(embed!)).toBeLessThanOrEqual(EMBED_TOTAL_MAX_LENGTH); + expect(embed!.title!.length).toBeLessThanOrEqual(256); + expect((embed!.description ?? '').length).toBeLessThanOrEqual(4096); + for (const field of embed!.fields ?? []) expect(field.value.length).toBeLessThanOrEqual(1024); + for (const value of markdownSlots(embed!)) { + expect(unescapedTrigger(value.replace(/ until $/, ''))).toBeNull(); + } + } + }); + }); +}); diff --git a/fluxer_api/src/api/webhook/tests/WebhookAppOrigin.test.ts b/fluxer_api/src/api/webhook/tests/WebhookAppOrigin.test.ts index e919419d4..3d93ee2e6 100644 --- a/fluxer_api/src/api/webhook/tests/WebhookAppOrigin.test.ts +++ b/fluxer_api/src/api/webhook/tests/WebhookAppOrigin.test.ts @@ -10,7 +10,7 @@ import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {afterEach, beforeEach, describe, it} from 'vitest'; -const TOKEN_ROUTE_SUFFIXES = ['', '/github', '/slack', '/instatus']; +const TOKEN_ROUTE_SUFFIXES = ['', '/github', '/slack', '/instatus', '/libredesk']; describe('Webhook token routes and the web app origin', () => { let harness: ApiTestHarness; diff --git a/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts b/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts index 125d641fb..3f9dd7b53 100644 --- a/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts +++ b/fluxer_api/src/api/webhook/tests/WebhookForumThreads.test.ts @@ -137,6 +137,11 @@ describe('webhooks on forum and media channels', () => { .body({text: 'hi'}) .expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED) .execute(); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${s.webhook.id}/${s.webhook.token}/libredesk`) + .body({event: 'webhook.test', payload: {id: 1, name: 'x'}}) + .expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED) + .execute(); const text = await createChannel(harness, s.owner.token, s.guildId, 'text'); const textHook = await threadsRequest(harness, s.owner.token) .post(`/channels/${text.id}/webhooks`) diff --git a/fluxer_api/src/api/webhook/tests/WebhookLibredesk.test.ts b/fluxer_api/src/api/webhook/tests/WebhookLibredesk.test.ts new file mode 100644 index 000000000..88f8a08df --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/WebhookLibredesk.test.ts @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createGuild} from '@app/api/guild/tests/GuildTestUtils'; +import {getMessages} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import {createWebhook, deleteWebhook} from '@app/api/webhook/tests/WebhookTestUtils'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +const CONVERSATION_UUID = '3f1c2a7e-8b4d-4e6a-9c1f-5d2e7b8a9c01'; + +function createContactMessage(textContent: string) { + return { + id: 311, + created_at: '2026-10-15T09:12:30.101552Z', + updated_at: '2026-10-15T09:12:30.101552Z', + uuid: 'c8a1f2e3-4b5d-4c6e-8f7a-9b0c1d2e3f40', + type: 'incoming', + status: 'received', + conversation_id: 42, + conversation_uuid: CONVERSATION_UUID, + content: `

${textContent}

`, + text_content: textContent, + content_type: 'html', + private: false, + sender_id: 17, + sender_type: 'contact', + meta: {from: ['contact@example.com'], to: ['support@example.com'], subject: 'Cannot sign in'}, + attachments: [], + author: { + id: 17, + first_name: 'Example contact', + last_name: '', + email: 'contact@example.com', + avatar_url: null, + availability_status: 'offline', + type: 'contact', + last_active_at: null, + }, + }; +} + +async function setup(harness: ApiTestHarness, name: string) { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, `${name} Guild`); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, `${name} Webhook`); + return {owner, channelId, webhook}; +} + +async function webhookMessages( + harness: ApiTestHarness, + token: string, + channelId: string, + webhookId: string, +): Promise> { + const messages = await getMessages(harness, token, channelId); + return messages.filter((message) => message.webhook_id === webhookId); +} + +describe('Webhook Libredesk integration', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + describe('POST /webhooks/:webhook_id/:token/libredesk', () => { + it('posts a test delivery under the Libredesk name and ignores the signature header', async () => { + const {owner, channelId, webhook} = await setup(harness, 'Libredesk Test Delivery'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`) + .header('User-Agent', 'Libredesk-Webhook/unknown-version') + .header('X-Libredesk-Signature', 'sha256=0000000000000000000000000000000000000000000000000000000000000000') + .body({event: 'webhook.test', payload: {id: 3, name: 'Support feed'}, timestamp: '2026-10-15T09:00:00Z'}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + const messages = await webhookMessages(harness, owner.token, channelId, webhook.id); + expect(messages).toHaveLength(1); + expect(messages[0]!.author.username).toBe('Libredesk'); + expect(messages[0]!.embeds?.[0]?.title).toBe('Test delivery received'); + expect(messages[0]!.embeds?.[0]?.description).toBe('Support feed'); + await deleteWebhook(harness, webhook.id, owner.token); + }); + + it('posts a contact message without resolving any mention', async () => { + const {owner, channelId, webhook} = await setup(harness, 'Libredesk Mentions'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`) + .body({ + event: 'message.created', + payload: createContactMessage(`@everyone @here <@${owner.userId}> please help`), + timestamp: '2026-10-15T09:12:30Z', + }) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + const messages = await webhookMessages(harness, owner.token, channelId, webhook.id); + expect(messages).toHaveLength(1); + const message = messages[0]!; + expect(message.content).toBe(''); + expect(message.mention_everyone).toBe(false); + expect(message.mentions ?? []).toHaveLength(0); + expect(message.mention_roles ?? []).toHaveLength(0); + expect(message.embeds?.[0]?.title).toBe('New message'); + expect(message.embeds?.[0]?.description).toBe(`\\@everyone \\@here \\<\\@${owner.userId}> please help`); + await deleteWebhook(harness, webhook.id, owner.token); + }); + + it('posts every delivery because Libredesk sends no delivery identifier', async () => { + const {owner, channelId, webhook} = await setup(harness, 'Libredesk Repeat'); + const body = { + event: 'message.created', + payload: createContactMessage('Still cannot sign in.'), + timestamp: '2026-10-15T09:12:30Z', + }; + for (let attempt = 0; attempt < 2; attempt++) { + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`) + .body(body) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + } + expect(await webhookMessages(harness, owner.token, channelId, webhook.id)).toHaveLength(2); + await deleteWebhook(harness, webhook.id, owner.token); + }); + + it('acknowledges unknown events and activity lines without posting', async () => { + const {owner, channelId, webhook} = await setup(harness, 'Libredesk Ignored'); + const bodies = [ + {event: 'inbox.renamed', payload: {id: 1, name: 'Support'}}, + { + event: 'message.created', + payload: { + ...createContactMessage('Support agent self-assigned this conversation'), + type: 'activity', + private: true, + sender_type: 'agent', + meta: {activity_type: 'self_assign'}, + }, + }, + {event: 'message.updated', payload: {...createContactMessage('Sent'), status: 'sent'}}, + ]; + for (const body of bodies) { + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`) + .body(body) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + } + expect(await webhookMessages(harness, owner.token, channelId, webhook.id)).toHaveLength(0); + await deleteWebhook(harness, webhook.id, owner.token); + }); + + it('posts an automation event with a name over 256 characters', async () => { + const {owner, channelId, webhook} = await setup(harness, 'Libredesk Long Event'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`) + .body({ + event: 'x'.repeat(300), + payload: { + actor_id: 1, + conversation: { + uuid: CONVERSATION_UUID, + reference_number: '1042', + subject: 'Cannot sign in', + inbox_name: 'Support', + }, + }, + }) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + const messages = await webhookMessages(harness, owner.token, channelId, webhook.id); + expect(messages).toHaveLength(1); + expect(messages[0]!.embeds?.[0]?.title).toBe(`\\[Support\\] ${'x'.repeat(29)}...: #1042 Cannot sign in`); + await deleteWebhook(harness, webhook.id, owner.token); + }); + + it('rejects a body without an event', async () => { + const {owner, webhook} = await setup(harness, 'Libredesk Invalid Body'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`) + .body({payload: {id: 3, name: 'Support feed'}}) + .expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY) + .execute(); + await deleteWebhook(harness, webhook.id, owner.token); + }); + + it('rejects a libredesk webhook with an invalid token', async () => { + const {owner, webhook} = await setup(harness, 'Libredesk Invalid Token'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/invalid_token/libredesk`) + .body({event: 'webhook.test', payload: {id: 3, name: 'Support feed'}}) + .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBHOOK) + .execute(); + await deleteWebhook(harness, webhook.id, owner.token); + }); + }); +}); diff --git a/fluxer_api/src/api/webhook/transformers/LibredeskTransformer.ts b/fluxer_api/src/api/webhook/transformers/LibredeskTransformer.ts new file mode 100644 index 000000000..0493f4f81 --- /dev/null +++ b/fluxer_api/src/api/webhook/transformers/LibredeskTransformer.ts @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; +import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas'; + +type LibredeskPayload = LibredeskWebhook['payload']; +type LibredeskHeadline = Pick; +type LibredeskPerson = NonNullable; +type EmbedField = NonNullable[number]; + +const COLOUR_NEW = 0x3b82f6 as const; +const COLOUR_RESOLVED = 0x3ecf8e as const; +const COLOUR_WAITING = 0xf5a623 as const; +const COLOUR_CONTACT = 0xf97316 as const; +const COLOUR_NOTE = 0xfcbd1f as const; +const COLOUR_FAILED = 0xe23c39 as const; +const COLOUR_NEUTRAL = 0x5c6bc0 as const; + +const INBOX_MAX_LENGTH = 48; +const REFERENCE_MAX_LENGTH = 32; +const EVENT_MAX_LENGTH = 32; +const SUBJECT_MAX_LENGTH = 128; +const STATUS_MAX_LENGTH = 64; +const NAME_MAX_LENGTH = 128; +const EXCERPT_MAX_LENGTH = 1000; +const FIELD_VALUE_MAX_LENGTH = 256; +const FOOTER_MAX_LENGTH = 128; +const ELLIPSIS = '...'; + +const graphemeSegmenter = new Intl.Segmenter('en', {granularity: 'grapheme'}); +const WHITESPACE_REGEX = /[\s\p{Cc}]+/gu; +const BIDI_CONTROL_REGEX = /\p{Bidi_Control}/gu; +const MARKDOWN_TRIGGER_REGEX = /[\\<@[\]]/g; +const URL_REGEX = /https?:\/\/\S+/giu; +const MAX_TIMESTAMP_SECONDS = 8_640_000_000_000; + +function escapeMarkdown(segment: string, inUrl: boolean): string { + return inUrl + ? segment.replace(MARKDOWN_TRIGGER_REGEX, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`) + : segment.replace(MARKDOWN_TRIGGER_REGEX, '\\$&'); +} + +function urlRanges(text: string): Array<[number, number]> { + return Array.from(text.matchAll(URL_REGEX), (match) => [match.index, match.index + match[0].length]); +} + +function clampText(value: string | null | undefined, maxLength: number, markdown: boolean): string | undefined { + if (typeof value !== 'string') return undefined; + const window = maxLength * 4; + let truncated = value.length > window; + const source = (truncated ? value.slice(0, window) : value).toWellFormed(); + const text = source.replace(BIDI_CONTROL_REGEX, '').replace(WHITESPACE_REGEX, ' ').trim(); + if (text.length === 0) return undefined; + const budget = maxLength - ELLIPSIS.length; + const urls = markdown ? urlRanges(text) : []; + let range = 0; + let output = ''; + let cut = 0; + for (const {segment, index} of graphemeSegmenter.segment(text)) { + while (range < urls.length && urls[range]![1] <= index) range++; + const inUrl = range < urls.length && urls[range]![0] < index + segment.length; + const piece = markdown ? escapeMarkdown(segment, inUrl) : segment; + if (output.length + piece.length > maxLength) { + truncated = true; + break; + } + output += piece; + if (output.length <= budget) cut = output.length; + } + return truncated ? `${output.slice(0, cut).trimEnd()}${ELLIPSIS}` : output; +} + +function markdownText(value: string | null | undefined, maxLength: number): string | undefined { + return clampText(value, maxLength, true); +} + +function plainText(value: string | null | undefined, maxLength: number): string | undefined { + return clampText(value, maxLength, false); +} + +function personName(person: LibredeskPerson | null | undefined): string | undefined { + const parts = [person?.first_name, person?.last_name].filter((part) => part && part.trim().length > 0); + return plainText(parts.join(' '), NAME_MAX_LENGTH); +} + +function conversationTitle(action: string, conversation: LibredeskHeadline | null | undefined): string { + const inbox = markdownText(conversation?.inbox_name, INBOX_MAX_LENGTH); + const reference = markdownText(conversation?.reference_number, REFERENCE_MAX_LENGTH); + const subject = markdownText(conversation?.subject, SUBJECT_MAX_LENGTH); + const headline = [reference ? `#${reference}` : undefined, subject].filter(Boolean).join(' '); + const label = headline ? `${action}: ${headline}` : action; + return inbox ? `\\[${inbox}\\] ${label}` : label; +} + +function conversationFooter(uuid: string | null | undefined): RichEmbedRequest['footer'] { + const text = plainText(uuid, FOOTER_MAX_LENGTH); + return text ? {text: `Conversation ${text}`} : undefined; +} + +function statusColour(category: string | null | undefined): number { + switch (category) { + case 'open': + return COLOUR_NEW; + case 'waiting': + return COLOUR_WAITING; + case 'resolved': + return COLOUR_RESOLVED; + default: + return COLOUR_NEUTRAL; + } +} + +function snoozeMarkup(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const seconds = Math.floor(new Date(value).getTime() / 1000); + return seconds >= 1 && seconds <= MAX_TIMESTAMP_SECONDS ? `` : undefined; +} + +function tagList(tags: ReadonlyArray): string | undefined { + return markdownText(tags.join(', '), FIELD_VALUE_MAX_LENGTH); +} + +function transformConversationCreated(payload: LibredeskPayload): RichEmbedRequest { + const name = personName(payload.contact); + return { + title: conversationTitle('New conversation', payload), + color: COLOUR_NEW, + author: name ? {name} : undefined, + footer: conversationFooter(payload.uuid), + }; +} + +function transformStatusChanged(payload: LibredeskPayload): RichEmbedRequest | null { + const next = markdownText(payload.new_status, STATUS_MAX_LENGTH); + if (!next) return null; + const previous = markdownText(payload.previous_status, STATUS_MAX_LENGTH); + const until = snoozeMarkup(payload.snooze_until); + const change = previous ? `${previous} to ${next}` : next; + return { + title: conversationTitle('Status changed', payload.conversation), + color: statusColour(payload.conversation?.status_category), + description: until ? `${change} until ${until}` : change, + footer: conversationFooter(payload.conversation_uuid), + }; +} + +function transformTagsChanged(payload: LibredeskPayload): RichEmbedRequest | null { + const previous = new Set(payload.previous_tags ?? []); + const next = new Set(payload.new_tags ?? []); + const added = tagList([...next].filter((tag) => !previous.has(tag))); + const removed = tagList([...previous].filter((tag) => !next.has(tag))); + if (!added && !removed) return null; + const fields: Array = []; + if (added) fields.push({name: 'Added', value: added, inline: true}); + if (removed) fields.push({name: 'Removed', value: removed, inline: true}); + return { + title: conversationTitle('Tags changed', payload.conversation), + color: COLOUR_NEUTRAL, + fields, + footer: conversationFooter(payload.conversation_uuid), + }; +} + +function transformAssigned(payload: LibredeskPayload): RichEmbedRequest { + const assignee = payload.assigned_to; + return { + title: conversationTitle('Conversation assigned', payload.conversation), + color: COLOUR_NEUTRAL, + fields: assignee != null ? [{name: 'Agent ID', value: String(assignee), inline: true}] : undefined, + footer: conversationFooter(payload.conversation_uuid), + }; +} + +function transformUnassigned(payload: LibredeskPayload): RichEmbedRequest { + return { + title: conversationTitle('Conversation unassigned', payload.conversation), + color: COLOUR_NEUTRAL, + footer: conversationFooter(payload.conversation_uuid), + }; +} + +function isHiddenMessage(payload: LibredeskPayload): boolean { + return payload.type === 'activity' || payload.meta?.is_csat === true || payload.meta?.continuity_email === true; +} + +function attachmentsField(payload: LibredeskPayload): EmbedField | undefined { + const names = (payload.attachments ?? []).map((attachment) => attachment.name?.trim()).filter(Boolean); + const value = markdownText(names.join(', '), FIELD_VALUE_MAX_LENGTH); + return value ? {name: 'Attachments', value, inline: false} : undefined; +} + +function messageHeading(payload: LibredeskPayload): {title: string; color: number; fallbackName: string} { + if (payload.sender_type === 'contact') return {title: 'New message', color: COLOUR_CONTACT, fallbackName: 'Contact'}; + if (payload.private === true) return {title: 'New private note', color: COLOUR_NOTE, fallbackName: 'Agent'}; + if (payload.meta?.is_automated === true) + return {title: 'Automated reply', color: COLOUR_NEUTRAL, fallbackName: 'Agent'}; + return {title: 'New reply', color: COLOUR_NEW, fallbackName: 'Agent'}; +} + +function transformMessageCreated(payload: LibredeskPayload): RichEmbedRequest | null { + if (isHiddenMessage(payload)) return null; + const {title, color, fallbackName} = messageHeading(payload); + const attachments = attachmentsField(payload); + return { + title, + color, + description: markdownText(payload.text_content, EXCERPT_MAX_LENGTH), + author: {name: personName(payload.author) ?? fallbackName}, + fields: attachments ? [attachments] : undefined, + footer: conversationFooter(payload.conversation_uuid), + }; +} + +function transformMessageUpdated(payload: LibredeskPayload): RichEmbedRequest | null { + if (payload.status !== 'failed' || isHiddenMessage(payload)) return null; + return { + title: 'Reply failed to send', + color: COLOUR_FAILED, + description: markdownText(payload.text_content, EXCERPT_MAX_LENGTH), + author: {name: personName(payload.author) ?? 'Agent'}, + footer: conversationFooter(payload.conversation_uuid), + }; +} + +function transformTest(payload: LibredeskPayload): RichEmbedRequest { + return { + title: 'Test delivery received', + color: COLOUR_NEUTRAL, + description: markdownText(payload.name, NAME_MAX_LENGTH), + }; +} + +function transformCustomEvent(event: string, payload: LibredeskPayload): RichEmbedRequest | null { + const conversation = payload.conversation; + if (!conversation) return null; + const name = markdownText(event, EVENT_MAX_LENGTH) ?? 'Event'; + return { + title: conversationTitle(name, conversation), + color: COLOUR_NEUTRAL, + footer: conversationFooter(conversation.uuid), + }; +} + +export function transformLibredeskWebhook(body: LibredeskWebhook): RichEmbedRequest | null { + const {event, payload} = body; + const hasConversationUuid = payload.conversation_uuid != null; + switch (event) { + case 'conversation.created': + if (payload.uuid != null) return transformConversationCreated(payload); + break; + case 'conversation.status_changed': + if (hasConversationUuid) return transformStatusChanged(payload); + break; + case 'conversation.tags_changed': + if (hasConversationUuid) return transformTagsChanged(payload); + break; + case 'conversation.assigned': + if (hasConversationUuid) return transformAssigned(payload); + break; + case 'conversation.unassigned': + if (hasConversationUuid) return transformUnassigned(payload); + break; + case 'message.created': + if (hasConversationUuid) return transformMessageCreated(payload); + break; + case 'message.updated': + if (hasConversationUuid) return transformMessageUpdated(payload); + break; + case 'webhook.test': + if (payload.name != null) return transformTest(payload); + break; + } + return transformCustomEvent(event, payload); +} diff --git a/fluxer_docs/scripts/VerifyDocsCoverage.ts b/fluxer_docs/scripts/VerifyDocsCoverage.ts index ad0120a33..7b1081ab6 100644 --- a/fluxer_docs/scripts/VerifyDocsCoverage.ts +++ b/fluxer_docs/scripts/VerifyDocsCoverage.ts @@ -2177,6 +2177,7 @@ console.log('unauthenticated capability flag (from the middleware chain)'); ['POST /webhooks/{}/{}/github', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}], ['POST /webhooks/{}/{}/slack', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}], ['POST /webhooks/{}/{}/instatus', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}], + ['POST /webhooks/{}/{}/libredesk', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}], ]); const problems: Array = []; let compared = 0; diff --git a/fluxer_docs/src/content/docs/http-api/index.md b/fluxer_docs/src/content/docs/http-api/index.md index 06fe8f155..109fc0ebd 100644 --- a/fluxer_docs/src/content/docs/http-api/index.md +++ b/fluxer_docs/src/content/docs/http-api/index.md @@ -305,7 +305,7 @@ Fluxer produces at most one entry for each distinct pair of `path` and `code`, s | [Roles and permissions](/http-api/permissions/) | Permission flags, computation order, role objects and lifecycle | | [Discovery](/http-api/discovery/) | Public guild listings, categories, the listing application lifecycle, joining without an invite | | [Invites](/http-api/invites/) | Invite lookup, creation, acceptance, deletion, code generation | -| [Webhooks](/http-api/webhooks/) | Webhook management, message execution, GitHub, Slack, and Instatus callbacks | +| [Webhooks](/http-api/webhooks/) | Webhook management, message execution, GitHub, Slack, Instatus, and Libredesk callbacks | | [Search](/http-api/search/) | Authenticated global message search | | [Unfurl](/http-api/unfurl/) | Authenticated external URL metadata resolution | | [Billing](/http-api/billing/) | Stripe checkout, gift purchase, age verification, refunds, the Stripe webhook | diff --git a/fluxer_docs/src/content/docs/http-api/webhooks.mdx b/fluxer_docs/src/content/docs/http-api/webhooks.mdx index 2c2edf499..17c8b86e3 100644 --- a/fluxer_docs/src/content/docs/http-api/webhooks.mdx +++ b/fluxer_docs/src/content/docs/http-api/webhooks.mdx @@ -745,6 +745,136 @@ Fluxer normalises an incident, maintenance, or component status by uppercasing i | INPROGRESS | Rendered as `In progress`, with the maintenance colour | | COMPLETED | Rendered as `Completed`, with the operational colour | +## Libredesk callback objects + +These objects define the body [Execute Libredesk webhook](#execute-libredesk-webhook) accepts. Libredesk sends more members than these tables list, and Fluxer ignores every member they do not list. + +### Libredesk callback object + +#### Structure + +| Field | Type | Description | +| --- | --- | --- | +| event | string | [Libredesk event type](#libredesk-event-types) | +| payload | [Libredesk payload](#libredesk-payload-object) object | Event data | + +### Libredesk payload object + +Libredesk sends a conversation, a message, or a conversation wrapped with change details, depending on the event. Every member is optional, and each event reads only the members it renders. + +#### Structure + +| Field | Type | Description | +| --- | --- | --- | +| uuid?1 | ?string | Conversation UUID on `conversation.created`, or null | +| reference_number? | ?string | Conversation reference number on `conversation.created`, or null | +| subject? | ?string | Conversation subject on `conversation.created`, or null | +| inbox_name? | ?string | Inbox name on `conversation.created`, or null | +| contact?2 | ?[Libredesk person](#libredesk-person-object) object | Contact on `conversation.created`, or null | +| conversation_uuid?1 | ?string | Conversation UUID on the other built-in events, or null | +| conversation? | ?[Libredesk conversation](#libredesk-conversation-object) object | Conversation after the change, or null | +| previous_status? | ?string | Status before a status change, or null | +| new_status?3 | ?string | Status after a status change, or null | +| snooze_until?4 | ?string | End of a snooze, or null | +| previous_tags?5 | ?array[string] | Tags before a tag change, or null | +| new_tags?5 | ?array[string] | Tags after a tag change, or null | +| assigned_to? | ?integer | Libredesk user ID of the new assignee, or null | +| type?6 | ?string | Message type, or null | +| status?7 | ?string | Message delivery status, or the conversation status on `conversation.created`, or null | +| text_content?8 | ?string | Plain text of the message, or null | +| private? | ?boolean | Whether the message is a private note, or null | +| sender_type? | ?string | `contact` or `agent`, or null | +| meta? | ?[Libredesk message metadata](#libredesk-message-metadata-object) object | Message metadata, or null | +| attachments? | ?array[[Libredesk attachment](#libredesk-attachment-object) object] | Message attachments, or null | +| author?2 | ?[Libredesk person](#libredesk-person-object) object | Message author, or null | +| name? | ?string | Libredesk webhook name on `webhook.test`, or null | + +1 The embed footer is `Conversation` followed by this value, truncated to 128 characters, and is omitted when the value is empty + +2 The person's name becomes the embed author name. Fluxer never renders an email address, phone number, or custom attribute + +3 A status change with an empty `new_status` renders nothing + +4 A date after 1970 renders as a timestamp after the new status. Any other value is left out + +5 Only the difference renders, as an Added field and a Removed field. A tag change that adds and removes nothing renders nothing + +6 A message of type `activity` renders nothing + +7 A `message.updated` event renders only when the status is `failed` + +8 Whitespace collapses to single spaces, and the excerpt is truncated to 1,000 characters. Fluxer never reads the HTML `content` member + +### Libredesk conversation object + +#### Structure + +| Field | Type | Description | +| --- | --- | --- | +| uuid?1 | ?string | Conversation UUID, or null | +| reference_number?2 | ?string | Reference number, or null | +| subject?2 | ?string | Subject, or null | +| inbox_name?2 | ?string | Inbox name, or null | +| status_category?3 | ?string | `open`, `waiting`, or `resolved`, or null | + +1 Only an automation event takes its embed footer from this value + +2 A conversation event title is the inbox name in square brackets, the event label, then `#` with the reference number and the subject. The inbox name is truncated to 48 characters, the reference number to 32, and the subject to 128. An empty part is left out + +3 The value selects the colour of a status change embed + +### Libredesk person object + +#### Structure + +| Field | Type | Description | +| --- | --- | --- | +| first_name? | ?string | First name, or null | +| last_name? | ?string | Last name, or null | + +The non-empty names, joined by a space and truncated to 128 characters, form the rendered name. A message whose author has no name renders `Contact` or `Agent` instead. + +### Libredesk message metadata object + +#### Structure + +| Field | Type | Description | +| --- | --- | --- | +| is_automated?1 | ?boolean | Whether an automation rule sent the message, or null | +| is_csat?2 | ?boolean | Whether the message is a satisfaction survey request, or null | +| continuity_email?2 | ?boolean | Whether the message is a continuity email digest, or null | + +1 A true value titles an agent message `Automated reply` + +2 A true value renders nothing, so a survey link or a digest of earlier messages never reaches the channel + +### Libredesk attachment object + +#### Structure + +| Field | Type | Description | +| --- | --- | --- | +| name? | ?string | File name, or null | + +The non-empty names form an Attachments field, truncated to 256 characters. Attachment URLs are never rendered. + +## Libredesk event types + +The `event` member selects the rendering. A built-in event renders as below only when its payload has that event's shape: `uuid` for `conversation.created`, `name` for `webhook.test`, and `conversation_uuid` for the others. Any other payload renders as an automation event. + +- `conversation.created`: new conversation, with the contact name as the embed author +- `conversation.status_changed`: status change, rendered as the previous and new status +- `conversation.tags_changed`: tag change, rendered as the added and removed tags +- `conversation.assigned`: agent assignment, rendered with the agent's Libredesk user ID +- `conversation.unassigned`: agent removal +- `message.created`: new contact message, agent reply, private note, or automated reply, with the sender as the embed author +- `message.updated`: outgoing message that failed to send +- `webhook.test`: test delivery from the Libredesk webhook settings, rendered with the Libredesk webhook name + +A contact message is titled `New message`, a private note `New private note`, an automated agent message `Automated reply`, and any other agent message `New reply`. Activity lines, satisfaction survey requests, and continuity digests render nothing. + +A Libredesk automation rule can send any event name. Such an event renders the event name, truncated to 32 characters, as the title label when its payload carries a conversation, and renders nothing otherwise. + ## List guild webhooks @@ -1578,3 +1708,60 @@ Mention parsing is disabled for the created message, so no mention in a provider ### Rate limit 200 requests per minute for each caller identity and webhook ID, on the `webhook:instatus::webhook_id` bucket, which is exempt from the global limit. + +## Execute Libredesk webhook + + + +Accepts a Libredesk callback and creates one formatted message when the event renders. The route always returns 204 with an empty body, whether or not a message was created. + +A rendered event emits a [Message Create](/gateway/events/#message-create). + +To connect a Libredesk help desk, add a webhook in its admin settings with this route's full URL and choose the events to send. Libredesk posts to the URL unchanged, so a `thread_id` query parameter in it applies to every delivery. The Libredesk secret is optional. Fluxer ignores the `X-Libredesk-Signature` header, because the token in the path is the credential. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| webhook_id | snowflake | The ID of the webhook to execute | +| token | string | Webhook execution token (1-256 characters) | + +### Query parameters + +| Field | Type | Description | +| --- | --- | --- | +| thread_id?1 | snowflake | The ID of a thread of the webhook's channel to post into | + +1 A value that names no thread of the webhook's channel returns 404 `UNKNOWN_CHANNEL`. A webhook whose channel is a forum or media channel needs it, and returns 400 `WEBHOOK_SERVICE_FORUM_UNSUPPORTED` without it + +### JSON body + +The body is a [Libredesk callback](#libredesk-callback-object) object. A body the schema rejects returns 400 `INVALID_FORM_BODY`. + +:::note[No deduplication] +Libredesk sends each event once and includes no delivery identifier, so Fluxer processes every callback. Two identical callbacks create two messages. +::: + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | empty | Callback was accepted or was not renderable | +| 400 | [error response](/http-api/#error-response) | The webhook's channel is a forum or media channel and `thread_id` is absent, returning `WEBHOOK_SERVICE_FORUM_UNSUPPORTED` | +| 400 | [error response](/http-api/#error-response) | `thread_id` names a locked thread, returning `THREAD_LOCKED` | +| 400 | [error response](/http-api/#error-response) | The guild has 1,000 active threads and `thread_id` names an archived thread, returning `MAX_ACTIVE_THREADS` | +| 403 | [error response](/http-api/#error-response) | Rendered embed text is blocked, returning `CONTENT_BLOCKED` | +| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` | +| 404 | [error response](/http-api/#error-response) | The target channel is missing or cannot hold a webhook, or `thread_id` names no thread of it, returning `UNKNOWN_CHANNEL` | + +### Side effects + +A renderable event creates one message with exactly one embed, authored under the fixed name `Libredesk` with the webhook's own avatar. Sessions that can read the channel receive [Message Create](/gateway/events/#message-create). + +Mention parsing is disabled for the created message. Fluxer also escapes every `\`, `<`, `@`, `[`, and `]` in Libredesk text it places in the embed title, description, or a field value, so no mention, timestamp, or masked link from a ticket renders. Inside a URL that starts with `http://` or `https://`, it percent-encodes those characters instead, so the link keeps its target. The embed author name and footer are plain text. Fluxer removes bidirectional control characters from all of them. + +An event that renders nothing creates nothing and emits no Dispatch. + +### Rate limit + +200 requests per minute for each caller identity and webhook ID, on the `webhook:libredesk::webhook_id` bucket, which is exempt from the global limit. diff --git a/fluxer_docs/src/content/docs/topics/rate-limits.md b/fluxer_docs/src/content/docs/topics/rate-limits.md index 0f465521e..deb10b241 100644 --- a/fluxer_docs/src/content/docs/topics/rate-limits.md +++ b/fluxer_docs/src/content/docs/topics/rate-limits.md @@ -16,7 +16,7 @@ A request that resolves no account is keyed by the client IP address, exactly fo A request also counts against the global bucket, unless its route bucket is declared exempt. The global bucket is keyed by the same identity, so a request that resolves no account consumes the global allowance of its client IP address. -These buckets are exempt, and each is the only bucket its route declares: `webhook:execute::webhook_id`, `webhook:message_get::webhook_id`, `webhook:message_edit::webhook_id`, `webhook:message_delete::webhook_id`, `webhook:github::webhook_id`, `webhook:instatus::webhook_id`, and `stripe:webhook`. Those routes draw on no global allowance. The `user:group_dm:create` and `user:group_dm:recipient:add` buckets are exempt as well. Each is a second bucket on a route whose first bucket is not exempt, so both routes still draw on the global allowance. +These buckets are exempt, and each is the only bucket its route declares: `webhook:execute::webhook_id`, `webhook:message_get::webhook_id`, `webhook:message_edit::webhook_id`, `webhook:message_delete::webhook_id`, `webhook:github::webhook_id`, `webhook:instatus::webhook_id`, `webhook:libredesk::webhook_id`, and `stripe:webhook`. Those routes draw on no global allowance. The `user:group_dm:create` and `user:group_dm:recipient:add` buckets are exempt as well. Each is a second bucket on a route whose first bucket is not exempt, so both routes still draw on the global allowance. Every HTTP API and Admin API operation declares a bucket. A caller that sends no credential on a [Bluesky client document](/http-api/connections/#get-bluesky-client-metadata) is keyed by the client IP address. diff --git a/packages/schema/src/domains/webhook/LibredeskWebhookSchemas.ts b/packages/schema/src/domains/webhook/LibredeskWebhookSchemas.ts new file mode 100644 index 000000000..8253f561b --- /dev/null +++ b/packages/schema/src/domains/webhook/LibredeskWebhookSchemas.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createUnboundedStringType, NonNegativeSafeIntegerType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +const LibredeskPerson = z.object({ + first_name: createUnboundedStringType().nullish(), + last_name: createUnboundedStringType().nullish(), +}); + +const LibredeskConversationSummary = z.object({ + uuid: createUnboundedStringType().nullish(), + reference_number: createUnboundedStringType().nullish(), + subject: createUnboundedStringType().nullish(), + inbox_name: createUnboundedStringType().nullish(), +}); + +const LibredeskConversation = LibredeskConversationSummary.extend({ + status_category: createUnboundedStringType().nullish(), +}); + +const LibredeskMessageMeta = z.object({ + is_automated: z.boolean().nullish(), + is_csat: z.boolean().nullish(), + continuity_email: z.boolean().nullish(), +}); + +const LibredeskAttachment = z.object({ + name: createUnboundedStringType().nullish(), +}); + +const LibredeskPayload = LibredeskConversationSummary.extend({ + contact: LibredeskPerson.nullish(), + conversation_uuid: createUnboundedStringType().nullish(), + conversation: LibredeskConversation.nullish(), + previous_status: createUnboundedStringType().nullish(), + new_status: createUnboundedStringType().nullish(), + snooze_until: createUnboundedStringType().nullish(), + previous_tags: z.array(createUnboundedStringType()).nullish(), + new_tags: z.array(createUnboundedStringType()).nullish(), + assigned_to: NonNegativeSafeIntegerType.nullish(), + type: createUnboundedStringType().nullish(), + status: createUnboundedStringType().nullish(), + text_content: createUnboundedStringType().nullish(), + private: z.boolean().nullish(), + sender_type: createUnboundedStringType().nullish(), + meta: LibredeskMessageMeta.nullish(), + attachments: z.array(LibredeskAttachment).nullish(), + author: LibredeskPerson.nullish(), + name: createUnboundedStringType().nullish(), +}); + +export const LibredeskWebhook = z.object({ + event: createUnboundedStringType(), + payload: LibredeskPayload, +}); + +export type LibredeskWebhook = z.infer;