feat(api): add libredesk webhook adapter (#3488)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 03:54:32 +02:00
1 parent 105bd49c9e
commit 1f4f3318dc
16 files changed
+1695 -3

No files matched your search

+163
View File
@@ -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": {
@@ -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;
@@ -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(),
@@ -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<void> {
await this.webhookService.executeLibredeskWebhook(params);
}
async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise<void> {
await this.webhookService.executeWebhook({
webhookId: params.webhookId,
@@ -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<void> {
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,
@@ -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}`},
@@ -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<string, unknown> = {}) {
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: '[email protected]',
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<string, unknown> = {}) {
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: '[email protected]',
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<string, unknown> = {}) {
return {
id: 5,
first_name: 'Support',
last_name: 'agent',
email: '[email protected]',
avatar_url: null,
availability_status: 'online',
type: 'agent',
last_active_at: '2026-10-15T09:20:02.77631Z',
...overrides,
};
}
function createMessage(overrides: Record<string, unknown> = {}) {
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:
'<div dir="ltr"><p>Hello, I cannot sign in to my account since this morning.</p><p>The page says my password is wrong.</p></div>',
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: ['[email protected]'], subject: 'Cannot sign in', to: ['[email protected]']},
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: '[email protected]',
avatar_url: null,
availability_status: 'offline',
type: 'contact',
last_active_at: null,
},
...overrides,
};
}
function createOutgoingMessage(overrides: Record<string, unknown> = {}) {
return createMessage({
id: 318,
uuid: '0e4d7c2b-9a1f-4b3e-8d6c-5f2a1b0e9d87',
type: 'outgoing',
status: 'pending',
content: '<p>Thanks for reaching out. Please try again.</p>',
text_content: 'Thanks for reaching out. Please try again.',
sender_id: 5,
sender_type: 'agent',
meta: {to: ['[email protected]']},
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<string> {
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<string> {
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('Libredesk transformer', () => {
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('[email protected]');
expect(text).not.toContain('5550100');
expect(text).not.toContain('[email protected]');
});
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 <t:${Math.floor(Date.parse('2026-10-16T09:40:05Z') / 1000)}:f>`,
);
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:
'<p>The account was locked. <span data-type="agent" data-id="6" data-label="Second agent">@Second agent</span> can you confirm?</p>',
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: '<p><img src="cid:ldsk-1"></p>', 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: '<script>alert(1)</script><p>Visible</p>', 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> <t:0:R>';
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> \\<t:0:R>',
);
});
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: '<script>alert(1)</script> <img src=x onerror=alert(1)>'}),
);
expect(embed?.description).toBe('\\<script>alert(1)\\</script> \\<img src=x onerror=alert(1)>');
});
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 <t:\d+:f>$/, ''))).toBeNull();
}
}
});
});
});
@@ -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;
@@ -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<WebhookResponse>(harness, s.owner.token)
.post(`/channels/${text.id}/webhooks`)
@@ -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: `<p>${textContent}</p>`,
text_content: textContent,
content_type: 'html',
private: false,
sender_id: 17,
sender_type: 'contact',
meta: {from: ['[email protected]'], to: ['[email protected]'], subject: 'Cannot sign in'},
attachments: [],
author: {
id: 17,
first_name: 'Example contact',
last_name: '',
email: '[email protected]',
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<Array<MessageResponse>> {
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);
});
});
});
@@ -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<LibredeskPayload, 'inbox_name' | 'reference_number' | 'subject'>;
type LibredeskPerson = NonNullable<LibredeskPayload['author']>;
type EmbedField = NonNullable<RichEmbedRequest['fields']>[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 ? `<t:${seconds}:f>` : undefined;
}
function tagList(tags: ReadonlyArray<string>): 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<EmbedField> = [];
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);
}