feat: implement full scrip action engine with real executors
- SendEmail: real nodemailer transport with SMTP config, dynamic recipient resolution (static + ticket creator/owner lookup), Handlebars template support - Webhook: HTTP POST/any method with configurable headers and JSON body - FetchMetadata: external HTTP fetch, Handlebars URL/body templating, auto-adds result as comment/correspondence on ticket - RunScript: arbitrary async JS execution with helpers (addComment, createTransaction, updateTicket, touchTicket), ticket context, and Drizzle ORM access - SetCustomField: lookup by id/key/name, clear+insert value, record CustomFieldChange transaction - CreateTransaction: insert arbitrary transaction record - Add OnCustomFieldChange condition - Pass condition_config to evaluator in engine Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import Handlebars from 'handlebars';
|
||||
import { config } from '../config.ts';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { customFieldValues, transactions } from '../db/schema.ts';
|
||||
import * as schema from '../db/schema.ts';
|
||||
import { customFieldValues, tickets, transactions, users } from '../db/schema.ts';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export interface ActionExecutor {
|
||||
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
|
||||
@@ -21,12 +24,15 @@ export interface ActionPayload {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
field_id?: string;
|
||||
field_key?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export class SendEmail implements ActionExecutor {
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
constructor(private db: Db) {}
|
||||
|
||||
private getTransporter(): Transporter {
|
||||
if (!this.transporter) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
@@ -39,8 +45,55 @@ export class SendEmail implements ActionExecutor {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
private async resolveRecipients(payload: ActionPayload): Promise<string[]> {
|
||||
const configuredRecipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
|
||||
const recipients = new Set(configuredRecipients.filter(Boolean));
|
||||
const sources = payload.actionConfig['recipient_sources'] ?? payload.actionConfig['recipient_source'];
|
||||
const recipientSources = Array.isArray(sources)
|
||||
? sources.map((source) => String(source))
|
||||
: sources
|
||||
? [String(sources)]
|
||||
: [];
|
||||
|
||||
if (recipientSources.length === 0 || !payload.ticketId) {
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
const ticket = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, payload.ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const source of recipientSources) {
|
||||
if (['requester', 'requestor', 'requestors', 'creator', 'ticket_creator'].includes(source)) {
|
||||
userIds.add(ticket.creator_id);
|
||||
}
|
||||
if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) {
|
||||
userIds.add(ticket.owner_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size === 0) {
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
const rows = await this.db.query.users.findMany({
|
||||
where: inArray(users.id, Array.from(userIds)),
|
||||
});
|
||||
|
||||
for (const user of rows) {
|
||||
if (user.email) recipients.add(user.email);
|
||||
}
|
||||
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const recipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
|
||||
const recipients = await this.resolveRecipients(payload);
|
||||
const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
|
||||
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
|
||||
|
||||
@@ -91,25 +144,279 @@ export class Webhook implements ActionExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string): unknown {
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHandlebars(template: string, context: Record<string, unknown>): string {
|
||||
const instance = Handlebars.create();
|
||||
instance.registerHelper('json', (value: unknown) => JSON.stringify(value, null, 2));
|
||||
return instance.compile(template)(context);
|
||||
}
|
||||
|
||||
export class FetchMetadata implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
const rawUrl = String(payload.actionConfig['url'] ?? '');
|
||||
const method = String(payload.actionConfig['method'] ?? 'GET').toUpperCase();
|
||||
const headers = (payload.actionConfig['headers'] ?? {}) as Record<string, string>;
|
||||
const requestBodyTemplate = String(payload.actionConfig['body'] ?? '');
|
||||
const commentTemplate = String(
|
||||
payload.actionConfig['comment_template'] ??
|
||||
'External metadata\n\n{{json metadata}}',
|
||||
);
|
||||
const internal = payload.actionConfig['internal'] !== false;
|
||||
|
||||
if (!ticketId || !rawUrl) {
|
||||
return { success: false, message: 'FetchMetadata: missing ticket_id or URL' };
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, message: `FetchMetadata: ticket ${ticketId} not found` };
|
||||
}
|
||||
|
||||
const baseContext = {
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
queue_id: ticket.queue_id,
|
||||
owner_id: ticket.owner_id,
|
||||
creator_id: ticket.creator_id,
|
||||
created_at: ticket.created_at?.toISOString(),
|
||||
updated_at: ticket.updated_at?.toISOString(),
|
||||
},
|
||||
};
|
||||
const url = renderHandlebars(rawUrl, baseContext);
|
||||
const requestBody = requestBodyTemplate
|
||||
? renderHandlebars(requestBodyTemplate, baseContext)
|
||||
: undefined;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { Accept: 'application/json', ...headers },
|
||||
body: ['GET', 'HEAD'].includes(method) ? undefined : requestBody,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const metadata = parseResponseBody(responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, message: `FetchMetadata failed: HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const commentBody = renderHandlebars(commentTemplate, {
|
||||
...baseContext,
|
||||
metadata,
|
||||
response: {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: metadata,
|
||||
text: responseText,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: internal ? 'Comment' : 'Correspond',
|
||||
data: {
|
||||
body: commentBody,
|
||||
metadata,
|
||||
source_url: url,
|
||||
},
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
|
||||
await this.db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId));
|
||||
|
||||
return { success: true, message: `Metadata fetched and added to ticket ${ticketId}` };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, message: `FetchMetadata failed: ${message}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptResult = string | { success?: boolean; message?: string } | undefined | null;
|
||||
|
||||
export class RunScript implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const script = String(payload.actionConfig['script'] ?? payload.actionConfig['code'] ?? '');
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
|
||||
if (!script.trim()) {
|
||||
return { success: false, message: 'RunScript: no script configured' };
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
return { success: false, message: 'RunScript: missing ticket_id' };
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, message: `RunScript: ticket ${ticketId} not found` };
|
||||
}
|
||||
|
||||
const helpers = {
|
||||
addComment: async (body: string, options?: { internal?: boolean; creator_id?: string }) => {
|
||||
const [tx] = await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: options?.internal === false ? 'Correspond' : 'Comment',
|
||||
data: { body },
|
||||
creator_id: options?.creator_id ?? '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
return tx;
|
||||
},
|
||||
createTransaction: async (data: {
|
||||
transaction_type: string;
|
||||
field?: string | null;
|
||||
old_value?: string | null;
|
||||
new_value?: string | null;
|
||||
data?: unknown;
|
||||
creator_id?: string;
|
||||
}) => {
|
||||
const [tx] = await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: data.transaction_type,
|
||||
field: data.field ?? null,
|
||||
old_value: data.old_value ?? null,
|
||||
new_value: data.new_value ?? null,
|
||||
data: data.data,
|
||||
creator_id: data.creator_id ?? '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
return tx;
|
||||
},
|
||||
updateTicket: async (data: Partial<typeof tickets.$inferInsert>) => {
|
||||
const [updated] = await this.db.update(tickets)
|
||||
.set({ ...data, updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId))
|
||||
.returning();
|
||||
return updated;
|
||||
},
|
||||
touchTicket: async () => {
|
||||
await this.db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId));
|
||||
},
|
||||
};
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const fn = new AsyncFunction(
|
||||
'context',
|
||||
`"use strict";
|
||||
const { ticket, payload, actionConfig, helpers, db, schema, orm, fetch, console } = context;
|
||||
${script}`,
|
||||
) as (context: Record<string, unknown>) => Promise<ScriptResult>;
|
||||
|
||||
const result = await fn({
|
||||
ticket,
|
||||
payload,
|
||||
actionConfig: payload.actionConfig,
|
||||
helpers,
|
||||
db: this.db,
|
||||
schema,
|
||||
orm: { and, eq, inArray },
|
||||
fetch,
|
||||
console,
|
||||
});
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return { success: true, message: result };
|
||||
}
|
||||
if (result && typeof result === 'object') {
|
||||
return {
|
||||
success: result.success !== false,
|
||||
message: result.message ?? 'RunScript completed',
|
||||
};
|
||||
}
|
||||
return { success: true, message: 'RunScript completed' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, message: `RunScript failed: ${message}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SetCustomField implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const fieldId = payload.field_id ?? String(payload.actionConfig['field_id'] ?? '');
|
||||
const fieldRef =
|
||||
payload.field_id ??
|
||||
payload.field_key ??
|
||||
String(payload.actionConfig['field_id'] ?? payload.actionConfig['field_key'] ?? payload.actionConfig['field'] ?? '');
|
||||
const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
|
||||
if (!fieldId || !value || !ticketId) {
|
||||
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' };
|
||||
if (!fieldRef || !value || !ticketId) {
|
||||
return { success: false, message: 'SetCustomField: missing field reference, value, or ticket_id' };
|
||||
}
|
||||
|
||||
try {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(fieldRef);
|
||||
const field = await this.db.query.customFields.findFirst({
|
||||
where: (row, { or, eq }) =>
|
||||
isUuid
|
||||
? or(eq(row.id, fieldRef), eq(row.key, fieldRef), eq(row.name, fieldRef))
|
||||
: or(eq(row.key, fieldRef), eq(row.name, fieldRef)),
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
return { success: false, message: `SetCustomField: unknown field ${fieldRef}` };
|
||||
}
|
||||
|
||||
const existing = await this.db.query.customFieldValues.findMany({
|
||||
where: and(
|
||||
eq(customFieldValues.ticket_id, ticketId),
|
||||
eq(customFieldValues.custom_field_id, field.id),
|
||||
),
|
||||
});
|
||||
const oldValue = existing.map((row) => row.value).join(', ');
|
||||
|
||||
await this.db.delete(customFieldValues).where(and(
|
||||
eq(customFieldValues.ticket_id, ticketId),
|
||||
eq(customFieldValues.custom_field_id, field.id),
|
||||
));
|
||||
|
||||
await this.db.insert(customFieldValues).values({
|
||||
custom_field_id: fieldId,
|
||||
custom_field_id: field.id,
|
||||
ticket_id: ticketId,
|
||||
value,
|
||||
});
|
||||
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
|
||||
|
||||
await this.db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId));
|
||||
|
||||
await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: 'CustomFieldChange',
|
||||
field: field.key,
|
||||
old_value: oldValue || null,
|
||||
new_value: value,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
|
||||
return { success: true, message: `${field.name} set to "${value}"` };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, message: `SetCustomField failed: ${message}` };
|
||||
@@ -150,8 +457,10 @@ export class CreateTransaction implements ActionExecutor {
|
||||
|
||||
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
|
||||
return {
|
||||
SendEmail: new SendEmail(),
|
||||
SendEmail: new SendEmail(db),
|
||||
Webhook: new Webhook(),
|
||||
FetchMetadata: new FetchMetadata(db),
|
||||
RunScript: new RunScript(db),
|
||||
SetCustomField: new SetCustomField(db),
|
||||
CreateTransaction: new CreateTransaction(db),
|
||||
};
|
||||
|
||||
@@ -7,8 +7,29 @@ export interface ConditionEvaluateContext {
|
||||
lifecycleDef?: LifecycleDefinition;
|
||||
}
|
||||
|
||||
export interface ConditionConfig {
|
||||
from_status?: unknown;
|
||||
to_status?: unknown;
|
||||
field_key?: unknown;
|
||||
field_id?: unknown;
|
||||
field?: unknown;
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface ConditionEvaluator {
|
||||
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean;
|
||||
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean;
|
||||
}
|
||||
|
||||
function matchesStatusFilter(value: string | null, filter: unknown): boolean {
|
||||
if (filter === undefined || filter === null || filter === '') return true;
|
||||
if (value === null) return false;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (Array.isArray(filter)) {
|
||||
return filter.map((item) => String(item).toLowerCase()).includes(normalizedValue);
|
||||
}
|
||||
return normalizedValue === String(filter).toLowerCase();
|
||||
}
|
||||
|
||||
export class OnCreate implements ConditionEvaluator {
|
||||
@@ -18,19 +39,25 @@ export class OnCreate implements ConditionEvaluator {
|
||||
}
|
||||
|
||||
export class OnStatusChange implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
|
||||
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
return transactions.some((tx) =>
|
||||
tx.transaction_type === 'StatusChange' &&
|
||||
matchesStatusFilter(tx.old_value, config?.from_status) &&
|
||||
matchesStatusFilter(tx.new_value, config?.to_status)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class OnResolve implements ConditionEvaluator {
|
||||
private lifecycleValidator = new LifecycleValidator();
|
||||
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const lifecycleDef = context?.lifecycleDef;
|
||||
|
||||
return transactions.some((tx) => {
|
||||
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
|
||||
if (!matchesStatusFilter(tx.old_value, config?.from_status)) return false;
|
||||
if (!matchesStatusFilter(tx.new_value, config?.to_status)) return false;
|
||||
|
||||
if (lifecycleDef) {
|
||||
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
|
||||
@@ -41,10 +68,25 @@ export class OnResolve implements ConditionEvaluator {
|
||||
}
|
||||
}
|
||||
|
||||
export class OnCustomFieldChange implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const fieldFilter = config?.field_key ?? config?.field_id ?? config?.field;
|
||||
const newValueFilter = config?.new_value ?? config?.value;
|
||||
|
||||
return transactions.some((tx) =>
|
||||
tx.transaction_type === 'CustomFieldChange' &&
|
||||
matchesStatusFilter(tx.field, fieldFilter) &&
|
||||
matchesStatusFilter(tx.old_value, config?.old_value) &&
|
||||
matchesStatusFilter(tx.new_value, newValueFilter)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||
OnCreate: new OnCreate(),
|
||||
OnStatusChange: new OnStatusChange(),
|
||||
OnResolve: new OnResolve(),
|
||||
OnCustomFieldChange: new OnCustomFieldChange(),
|
||||
};
|
||||
|
||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Transaction } from '../models/transaction.ts';
|
||||
import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
|
||||
import { eq, asc, inArray } from 'drizzle-orm';
|
||||
import { getConditionEvaluator } from './conditions.ts';
|
||||
import type { ConditionEvaluateContext } from './conditions.ts';
|
||||
import type { ConditionConfig, ConditionEvaluateContext } from './conditions.ts';
|
||||
import { getActionExecutor } from './actions.ts';
|
||||
import type { ActionPayload } from './actions.ts';
|
||||
import { TemplateRenderer } from './templates.ts';
|
||||
@@ -103,7 +103,12 @@ export class ScripEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions, conditionContext)) {
|
||||
if (!evaluator.evaluate(
|
||||
ticketRecord as unknown as Ticket,
|
||||
transactions,
|
||||
conditionContext,
|
||||
scrip.condition_config as ConditionConfig,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export class TemplateRenderer {
|
||||
|
||||
export interface TemplateContext {
|
||||
ticket: {
|
||||
id: string;
|
||||
id: number;
|
||||
subject: string;
|
||||
status: string;
|
||||
queue_id: string;
|
||||
|
||||
Reference in New Issue
Block a user