mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(api): add libredesk webhook adapter (#3488)
This commit is contained in:
1 parent
105bd49c9e
commit
1f4f3318dc
16 files changed
+1695
-3
No files matched your search
Generated
+163
@@ -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}": {
|
"/webhooks/{webhook_id}/{token}/messages/{message_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "get_webhook_message",
|
"operationId": "get_webhook_message",
|
||||||
@@ -23675,6 +23751,93 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"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": {
|
"InstatusWebhook": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -52,4 +52,8 @@ export const WebhookRateLimitConfigs = {
|
|||||||
bucket: 'webhook:instatus::webhook_id',
|
bucket: 'webhook:instatus::webhook_id',
|
||||||
config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true},
|
config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true},
|
||||||
} as RouteRateLimitConfig,
|
} as RouteRateLimitConfig,
|
||||||
|
WEBHOOK_LIBREDESK: {
|
||||||
|
bucket: 'webhook:libredesk::webhook_id',
|
||||||
|
config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true},
|
||||||
|
} as RouteRateLimitConfig,
|
||||||
} as const;
|
} 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 {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||||
import {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
|
import {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
|
||||||
import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
|
import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
|
||||||
|
import {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
|
||||||
import {
|
import {
|
||||||
SlackWebhookRequest,
|
SlackWebhookRequest,
|
||||||
WebhookCreateRequest,
|
WebhookCreateRequest,
|
||||||
@@ -579,6 +580,34 @@ export function WebhookController(app: HonoApp) {
|
|||||||
return ctx.body(null, 204);
|
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) => {
|
app.post('/webhooks/livekit', async (ctx) => {
|
||||||
const response = await ctx.get('webhookRequestService').handleLiveKitWebhook({
|
const response = await ctx.get('webhookRequestService').handleLiveKitWebhook({
|
||||||
body: await ctx.req.text(),
|
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 {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||||
import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
|
import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
|
||||||
import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
|
import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
|
||||||
|
import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
|
||||||
import type {
|
import type {
|
||||||
SlackWebhookRequest,
|
SlackWebhookRequest,
|
||||||
WebhookCreateRequest,
|
WebhookCreateRequest,
|
||||||
@@ -165,6 +166,14 @@ interface WebhookExecuteInstatusParams {
|
|||||||
requestCache: RequestCache;
|
requestCache: RequestCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebhookExecuteLibredeskParams {
|
||||||
|
webhookId: WebhookID;
|
||||||
|
token: WebhookToken;
|
||||||
|
threadId?: string;
|
||||||
|
data: LibredeskWebhook;
|
||||||
|
requestCache: RequestCache;
|
||||||
|
}
|
||||||
|
|
||||||
interface LiveKitWebhookParams {
|
interface LiveKitWebhookParams {
|
||||||
body: string;
|
body: string;
|
||||||
authHeader?: string;
|
authHeader?: string;
|
||||||
@@ -351,6 +360,10 @@ export class WebhookRequestService {
|
|||||||
await this.webhookService.executeInstatusWebhook(params);
|
await this.webhookService.executeInstatusWebhook(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async executeLibredeskWebhook(params: WebhookExecuteLibredeskParams): Promise<void> {
|
||||||
|
await this.webhookService.executeLibredeskWebhook(params);
|
||||||
|
}
|
||||||
|
|
||||||
async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise<void> {
|
async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise<void> {
|
||||||
await this.webhookService.executeWebhook({
|
await this.webhookService.executeWebhook({
|
||||||
webhookId: params.webhookId,
|
webhookId: params.webhookId,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import * as RandomUtils from '@app/api/utils/RandomUtils';
|
|||||||
import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
|
import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
|
||||||
import {transform as GitHubTransform} from '@app/api/webhook/transformers/GitHubTransformer';
|
import {transform as GitHubTransform} from '@app/api/webhook/transformers/GitHubTransformer';
|
||||||
import {instatusDeliveryKey, transformInstatusWebhook} from '@app/api/webhook/transformers/InstatusTransformer';
|
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 type {WebhookRepository} from '@app/api/webhook/WebhookRepository';
|
||||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||||
import {
|
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 {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
|
||||||
import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
|
import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
|
||||||
import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
|
import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
|
||||||
|
import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
|
||||||
import {
|
import {
|
||||||
type WebhookCreateRequest,
|
type WebhookCreateRequest,
|
||||||
WebhookForumPostRequestFields,
|
WebhookForumPostRequestFields,
|
||||||
@@ -147,6 +149,11 @@ interface WebhookExecuteInstatusParams extends WebhookTokenParams {
|
|||||||
requestCache: RequestCache;
|
requestCache: RequestCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebhookExecuteLibredeskParams extends WebhookTokenParams, WebhookThreadParams {
|
||||||
|
data: LibredeskWebhook;
|
||||||
|
requestCache: RequestCache;
|
||||||
|
}
|
||||||
|
|
||||||
const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__';
|
const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__';
|
||||||
const HOSTED_WEBHOOK_AVATAR_URLS = {
|
const HOSTED_WEBHOOK_AVATAR_URLS = {
|
||||||
github: 'https://fluxer.app/static/img/app-webhook-github.2d0319169a3aee33.webp',
|
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'));
|
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({
|
async dispatchWebhooksUpdate({
|
||||||
guildId,
|
guildId,
|
||||||
channelId,
|
channelId,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe('Follower webhook management', () => {
|
|||||||
{method: 'post', path: `${base}?wait=true`, body: {content: 'hello'}},
|
{method: 'post', path: `${base}?wait=true`, body: {content: 'hello'}},
|
||||||
{method: 'post', path: `${base}/slack`, body: {text: '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}/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: 'get', path: `${base}/messages/${messageId}`},
|
||||||
{method: 'patch', path: `${base}/messages/${messageId}`, body: {content: 'edited'}},
|
{method: 'patch', path: `${base}/messages/${messageId}`, body: {content: 'edited'}},
|
||||||
{method: 'delete', path: `${base}/messages/${messageId}`},
|
{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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||||
import {afterEach, beforeEach, describe, it} from 'vitest';
|
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', () => {
|
describe('Webhook token routes and the web app origin', () => {
|
||||||
let harness: ApiTestHarness;
|
let harness: ApiTestHarness;
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ describe('webhooks on forum and media channels', () => {
|
|||||||
.body({text: 'hi'})
|
.body({text: 'hi'})
|
||||||
.expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED)
|
.expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED)
|
||||||
.execute();
|
.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 text = await createChannel(harness, s.owner.token, s.guildId, 'text');
|
||||||
const textHook = await threadsRequest<WebhookResponse>(harness, s.owner.token)
|
const textHook = await threadsRequest<WebhookResponse>(harness, s.owner.token)
|
||||||
.post(`/channels/${text.id}/webhooks`)
|
.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);
|
||||||
|
}
|
||||||
@@ -2177,6 +2177,7 @@ console.log('unauthenticated capability flag (from the middleware chain)');
|
|||||||
['POST /webhooks/{}/{}/github', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}],
|
['POST /webhooks/{}/{}/github', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}],
|
||||||
['POST /webhooks/{}/{}/slack', {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/{}/{}/instatus', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}],
|
||||||
|
['POST /webhooks/{}/{}/libredesk', {docs: false, why: WEBHOOK_TOKEN_CREDENTIAL}],
|
||||||
]);
|
]);
|
||||||
const problems: Array<string> = [];
|
const problems: Array<string> = [];
|
||||||
let compared = 0;
|
let compared = 0;
|
||||||
|
|||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Search](/http-api/search/) | Authenticated global message search |
|
||||||
| [Unfurl](/http-api/unfurl/) | Authenticated external URL metadata resolution |
|
| [Unfurl](/http-api/unfurl/) | Authenticated external URL metadata resolution |
|
||||||
| [Billing](/http-api/billing/) | Stripe checkout, gift purchase, age verification, refunds, the Stripe webhook |
|
| [Billing](/http-api/billing/) | Stripe checkout, gift purchase, age verification, refunds, the Stripe webhook |
|
||||||
|
|||||||
@@ -745,6 +745,136 @@ Fluxer normalises an incident, maintenance, or component status by uppercasing i
|
|||||||
| INPROGRESS | Rendered as `In progress`, with the maintenance colour |
|
| INPROGRESS | Rendered as `In progress`, with the maintenance colour |
|
||||||
| COMPLETED | Rendered as `Completed`, with the operational 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?<sup>1</sup> | ?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?<sup>2</sup> | ?[Libredesk person](#libredesk-person-object) object | Contact on `conversation.created`, or null |
|
||||||
|
| conversation_uuid?<sup>1</sup> | ?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?<sup>3</sup> | ?string | Status after a status change, or null |
|
||||||
|
| snooze_until?<sup>4</sup> | ?string | End of a snooze, or null |
|
||||||
|
| previous_tags?<sup>5</sup> | ?array[string] | Tags before a tag change, or null |
|
||||||
|
| new_tags?<sup>5</sup> | ?array[string] | Tags after a tag change, or null |
|
||||||
|
| assigned_to? | ?integer | Libredesk user ID of the new assignee, or null |
|
||||||
|
| type?<sup>6</sup> | ?string | Message type, or null |
|
||||||
|
| status?<sup>7</sup> | ?string | Message delivery status, or the conversation status on `conversation.created`, or null |
|
||||||
|
| text_content?<sup>8</sup> | ?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?<sup>2</sup> | ?[Libredesk person](#libredesk-person-object) object | Message author, or null |
|
||||||
|
| name? | ?string | Libredesk webhook name on `webhook.test`, or null |
|
||||||
|
|
||||||
|
<sup>1</sup> The embed footer is `Conversation` followed by this value, truncated to 128 characters, and is omitted when the value is empty
|
||||||
|
|
||||||
|
<sup>2</sup> The person's name becomes the embed author name. Fluxer never renders an email address, phone number, or custom attribute
|
||||||
|
|
||||||
|
<sup>3</sup> A status change with an empty `new_status` renders nothing
|
||||||
|
|
||||||
|
<sup>4</sup> A date after 1970 renders as a timestamp after the new status. Any other value is left out
|
||||||
|
|
||||||
|
<sup>5</sup> Only the difference renders, as an Added field and a Removed field. A tag change that adds and removes nothing renders nothing
|
||||||
|
|
||||||
|
<sup>6</sup> A message of type `activity` renders nothing
|
||||||
|
|
||||||
|
<sup>7</sup> A `message.updated` event renders only when the status is `failed`
|
||||||
|
|
||||||
|
<sup>8</sup> 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?<sup>1</sup> | ?string | Conversation UUID, or null |
|
||||||
|
| reference_number?<sup>2</sup> | ?string | Reference number, or null |
|
||||||
|
| subject?<sup>2</sup> | ?string | Subject, or null |
|
||||||
|
| inbox_name?<sup>2</sup> | ?string | Inbox name, or null |
|
||||||
|
| status_category?<sup>3</sup> | ?string | `open`, `waiting`, or `resolved`, or null |
|
||||||
|
|
||||||
|
<sup>1</sup> Only an automation event takes its embed footer from this value
|
||||||
|
|
||||||
|
<sup>2</sup> 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
|
||||||
|
|
||||||
|
<sup>3</sup> 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?<sup>1</sup> | ?boolean | Whether an automation rule sent the message, or null |
|
||||||
|
| is_csat?<sup>2</sup> | ?boolean | Whether the message is a satisfaction survey request, or null |
|
||||||
|
| continuity_email?<sup>2</sup> | ?boolean | Whether the message is a continuity email digest, or null |
|
||||||
|
|
||||||
|
<sup>1</sup> A true value titles an agent message `Automated reply`
|
||||||
|
|
||||||
|
<sup>2</sup> 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
|
## List guild webhooks
|
||||||
|
|
||||||
<RouteHeader method="GET" path="/v1/guilds/{guild_id}/webhooks" bot />
|
<RouteHeader method="GET" path="/v1/guilds/{guild_id}/webhooks" bot />
|
||||||
@@ -1578,3 +1708,60 @@ Mention parsing is disabled for the created message, so no mention in a provider
|
|||||||
### Rate limit
|
### 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.
|
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
|
||||||
|
|
||||||
|
<RouteHeader method="POST" path="/v1/webhooks/{webhook_id}/{token}/libredesk" />
|
||||||
|
|
||||||
|
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?<sup>1</sup> | snowflake | The ID of a thread of the webhook's channel to post into |
|
||||||
|
|
||||||
|
<sup>1</sup> 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.
|
||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof LibredeskWebhook>;
|
||||||
Reference in new issue
Block a user