123 lines
2.8 KiB
TypeScript
123 lines
2.8 KiB
TypeScript
import type { AutomationReactionConfig } from '../config/schema.js';
|
|
|
|
export interface ReactionEvent {
|
|
channel: string;
|
|
senderId: string;
|
|
text: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ReactionMatchResult {
|
|
name: string;
|
|
prompt: string;
|
|
}
|
|
|
|
function getNestedValue(record: Record<string, unknown>, path: string): unknown {
|
|
const segments = path.split('.').filter(Boolean);
|
|
let current: unknown = record;
|
|
for (const segment of segments) {
|
|
if (!current || typeof current !== 'object') {
|
|
return undefined;
|
|
}
|
|
current = (current as Record<string, unknown>)[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function renderTemplate(template: string, event: ReactionEvent): string {
|
|
return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_full, rawKey: string) => {
|
|
const key = rawKey.trim();
|
|
if (key === 'text') {
|
|
return event.text;
|
|
}
|
|
if (key === 'channel') {
|
|
return event.channel;
|
|
}
|
|
if (key === 'sender_id') {
|
|
return event.senderId;
|
|
}
|
|
if (key.startsWith('metadata.')) {
|
|
const value = getNestedValue(event.metadata ?? {}, key.slice('metadata.'.length));
|
|
if (value === undefined || value === null) {
|
|
return '';
|
|
}
|
|
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
}
|
|
return '';
|
|
});
|
|
}
|
|
|
|
function metadataMatches(
|
|
required: Record<string, string>,
|
|
metadata?: Record<string, unknown>,
|
|
): boolean {
|
|
if (!metadata) {
|
|
return false;
|
|
}
|
|
for (const [path, expected] of Object.entries(required)) {
|
|
const actual = getNestedValue(metadata, path);
|
|
if (actual === undefined || actual === null) {
|
|
return false;
|
|
}
|
|
if (String(actual) !== expected) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function ruleMatches(rule: AutomationReactionConfig, event: ReactionEvent): boolean {
|
|
if (!rule.enabled) {
|
|
return false;
|
|
}
|
|
if (rule.on.length > 0 && !rule.on.includes(event.channel)) {
|
|
return false;
|
|
}
|
|
|
|
const filter = rule.filter;
|
|
if (!filter) {
|
|
return true;
|
|
}
|
|
|
|
if (filter.contains) {
|
|
if (!event.text.toLowerCase().includes(filter.contains.toLowerCase())) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (filter.regex) {
|
|
let regex: RegExp;
|
|
try {
|
|
regex = new RegExp(filter.regex, 'i');
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!regex.test(event.text)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (filter.metadata && !metadataMatches(filter.metadata, event.metadata)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Find the first matching reaction rule and render its run template. */
|
|
export function matchReactionPrompt(
|
|
reactions: AutomationReactionConfig[],
|
|
event: ReactionEvent,
|
|
): ReactionMatchResult | null {
|
|
for (const rule of reactions) {
|
|
if (!ruleMatches(rule, event)) {
|
|
continue;
|
|
}
|
|
return {
|
|
name: rule.name,
|
|
prompt: renderTemplate(rule.run, event),
|
|
};
|
|
}
|
|
return null;
|
|
}
|