mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
import type { TriggerType } from "./workflowFormTypes";
|
|
|
|
export const CONDITION_OPERATORS = [
|
|
"contains",
|
|
"not_contains",
|
|
"starts_with",
|
|
"ends_with",
|
|
"equals",
|
|
"not_equals",
|
|
"is_not_empty",
|
|
"is_empty",
|
|
] as const;
|
|
|
|
export type ConditionOperator = (typeof CONDITION_OPERATORS)[number];
|
|
|
|
const EXACT_MATCH_OPERATORS = [
|
|
"equals",
|
|
"not_equals",
|
|
] as const satisfies readonly ConditionOperator[];
|
|
|
|
/** IDs and pubkeys are opaque identifiers, so partial string matching is not meaningful. */
|
|
export function conditionOperatorsForField(
|
|
field: string,
|
|
): readonly ConditionOperator[] {
|
|
return field === "trigger_author" || field.endsWith("_id")
|
|
? EXACT_MATCH_OPERATORS
|
|
: CONDITION_OPERATORS;
|
|
}
|
|
|
|
export function defaultConditionOperatorForField(
|
|
field: string,
|
|
): ConditionOperator {
|
|
return conditionOperatorsForField(field)[0];
|
|
}
|
|
|
|
export type ConditionField = {
|
|
label: string;
|
|
value: string;
|
|
};
|
|
|
|
export type ParsedConditionExpression = {
|
|
field: string;
|
|
operator: ConditionOperator;
|
|
value: string;
|
|
webhookField: string;
|
|
};
|
|
|
|
export const CUSTOM_CONDITION_FIELD = "custom";
|
|
|
|
const COMMON_FIELDS: ConditionField[] = [
|
|
{ label: "Author pubkey", value: "trigger_author" },
|
|
{ label: "Channel ID", value: "trigger_channel_id" },
|
|
{ label: "Message ID", value: "trigger_message_id" },
|
|
];
|
|
|
|
const FIELDS_BY_TRIGGER: Record<TriggerType, ConditionField[]> = {
|
|
message_posted: [
|
|
{ label: "Message text", value: "trigger_text" },
|
|
...COMMON_FIELDS,
|
|
],
|
|
diff_posted: [
|
|
{ label: "Diff text", value: "trigger_text" },
|
|
...COMMON_FIELDS,
|
|
],
|
|
reaction_added: [
|
|
{ label: "Reaction emoji", value: "trigger_emoji" },
|
|
...COMMON_FIELDS,
|
|
],
|
|
webhook: [
|
|
{ label: "Webhook field…", value: "webhook_field" },
|
|
{ label: "Channel ID", value: "trigger_channel_id" },
|
|
],
|
|
schedule: [
|
|
{ label: "Channel ID", value: "trigger_channel_id" },
|
|
{ label: "Scheduled timestamp", value: "trigger_timestamp" },
|
|
],
|
|
};
|
|
|
|
export function conditionFieldsForTrigger(
|
|
triggerType: TriggerType,
|
|
): ConditionField[] {
|
|
return FIELDS_BY_TRIGGER[triggerType];
|
|
}
|
|
|
|
export function conditionOperatorNeedsValue(
|
|
operator: ConditionOperator,
|
|
): boolean {
|
|
return operator !== "is_not_empty" && operator !== "is_empty";
|
|
}
|
|
|
|
function escapeEvalexprString(value: string): string {
|
|
// evalexpr v11 only recognizes escaped quotes and backslashes. Other
|
|
// backslash escapes (including \n and \t) are rejected by its parser.
|
|
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
}
|
|
|
|
export function normalizeWebhookField(field: string): string | null {
|
|
const trimmed = field.trim();
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return null;
|
|
if (trimmed.startsWith("trigger_") || trimmed.startsWith("steps_")) {
|
|
return null;
|
|
}
|
|
return `trigger_${trimmed}`;
|
|
}
|
|
|
|
export function buildConditionExpression({
|
|
field,
|
|
operator,
|
|
value,
|
|
webhookField,
|
|
}: {
|
|
field: string;
|
|
operator: ConditionOperator;
|
|
value: string;
|
|
webhookField?: string;
|
|
}): string | null {
|
|
const variable =
|
|
field === "webhook_field"
|
|
? normalizeWebhookField(webhookField ?? "")
|
|
: field;
|
|
if (!variable) return null;
|
|
|
|
if (operator === "is_not_empty") return `str_len(${variable}) > 0`;
|
|
if (operator === "is_empty") return `str_len(${variable}) == 0`;
|
|
if (!value.trim()) return null;
|
|
|
|
const quotedValue = `"${escapeEvalexprString(value)}"`;
|
|
switch (operator) {
|
|
case "contains":
|
|
return `str_contains(${variable}, ${quotedValue})`;
|
|
case "not_contains":
|
|
return `!str_contains(${variable}, ${quotedValue})`;
|
|
case "starts_with":
|
|
return `str_starts_with(${variable}, ${quotedValue})`;
|
|
case "ends_with":
|
|
return `str_ends_with(${variable}, ${quotedValue})`;
|
|
case "equals":
|
|
return `${variable} == ${quotedValue}`;
|
|
case "not_equals":
|
|
return `${variable} != ${quotedValue}`;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function unescapeEvalexprString(value: string): string {
|
|
return value.replaceAll(/\\(["\\])/g, "$1");
|
|
}
|
|
|
|
function parsedField(
|
|
variable: string,
|
|
triggerType: TriggerType,
|
|
): Pick<ParsedConditionExpression, "field" | "webhookField"> | null {
|
|
const fields = conditionFieldsForTrigger(triggerType);
|
|
if (fields.some((field) => field.value === variable)) {
|
|
return { field: variable, webhookField: "" };
|
|
}
|
|
|
|
if (
|
|
fields.some((field) => field.value === "webhook_field") &&
|
|
variable.startsWith("trigger_")
|
|
) {
|
|
const webhookField = variable.slice("trigger_".length);
|
|
if (normalizeWebhookField(webhookField) === variable) {
|
|
return { field: "webhook_field", webhookField };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Parse expressions emitted by the condition editor back into form state. */
|
|
export function parseConditionExpression(
|
|
expression: string,
|
|
triggerType: TriggerType,
|
|
): ParsedConditionExpression | null {
|
|
const trimmed = expression.trim();
|
|
const emptyMatch = /^str_len\(([A-Za-z_][A-Za-z0-9_]*)\) (>|==) 0$/.exec(
|
|
trimmed,
|
|
);
|
|
if (emptyMatch) {
|
|
const field = parsedField(emptyMatch[1], triggerType);
|
|
if (!field) return null;
|
|
return {
|
|
...field,
|
|
operator: emptyMatch[2] === ">" ? "is_not_empty" : "is_empty",
|
|
value: "",
|
|
};
|
|
}
|
|
|
|
const stringLiteral = '"((?:\\\\["\\\\]|[^"\\\\])*)"';
|
|
const functionMatch = new RegExp(
|
|
`^(!)?str_(contains|starts_with|ends_with)\\(([A-Za-z_][A-Za-z0-9_]*), ${stringLiteral}\\)$`,
|
|
).exec(trimmed);
|
|
if (functionMatch) {
|
|
const field = parsedField(functionMatch[3], triggerType);
|
|
if (!field) return null;
|
|
const operator = functionMatch[1]
|
|
? "not_contains"
|
|
: functionMatch[2] === "starts_with"
|
|
? "starts_with"
|
|
: functionMatch[2] === "ends_with"
|
|
? "ends_with"
|
|
: "contains";
|
|
return {
|
|
...field,
|
|
operator,
|
|
value: unescapeEvalexprString(functionMatch[4]),
|
|
};
|
|
}
|
|
|
|
const equalityMatch = new RegExp(
|
|
`^([A-Za-z_][A-Za-z0-9_]*) (!=|==) ${stringLiteral}$`,
|
|
).exec(trimmed);
|
|
if (equalityMatch) {
|
|
const field = parsedField(equalityMatch[1], triggerType);
|
|
if (!field) return null;
|
|
return {
|
|
...field,
|
|
operator: equalityMatch[2] === "==" ? "equals" : "not_equals",
|
|
value: unescapeEvalexprString(equalityMatch[3]),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|