feat: add automation reactions event-trigger layer
This commit is contained in:
@@ -5,4 +5,5 @@ export { HeartbeatMonitor, parseInterval } from './heartbeat.js';
|
||||
export type { HeartbeatResult, HeartbeatDeps, CheckResult } from './heartbeat.js';
|
||||
export { buildPresetCronJobs } from './presets.js';
|
||||
export { MinioSyncScheduler } from './minioSync.js';
|
||||
export { matchReactionPrompt } from './reactions.js';
|
||||
export type { MinioSyncSchedulerDeps } from './minioSync.js';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { matchReactionPrompt } from './reactions.js';
|
||||
import type { AutomationReactionConfig } from '../config/schema.js';
|
||||
|
||||
function makeRule(overrides: Partial<AutomationReactionConfig> & Pick<AutomationReactionConfig, 'name' | 'run'>): AutomationReactionConfig {
|
||||
return {
|
||||
name: overrides.name,
|
||||
enabled: overrides.enabled ?? true,
|
||||
on: overrides.on ?? ['gmail'],
|
||||
filter: overrides.filter,
|
||||
run: overrides.run,
|
||||
};
|
||||
}
|
||||
|
||||
describe('matchReactionPrompt', () => {
|
||||
it('matches channel + contains filter and renders text template', () => {
|
||||
const rules: AutomationReactionConfig[] = [
|
||||
makeRule({
|
||||
name: 'boss-email',
|
||||
on: ['gmail'],
|
||||
filter: { contains: 'boss@company.com' },
|
||||
run: 'Summarize this email and propose next actions:\n\n{{text}}',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = matchReactionPrompt(rules, {
|
||||
channel: 'gmail',
|
||||
senderId: 'watcher',
|
||||
text: 'New email from boss@company.com: Q1 plan',
|
||||
metadata: { from: 'boss@company.com' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'boss-email',
|
||||
prompt: 'Summarize this email and propose next actions:\n\nNew email from boss@company.com: Q1 plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches metadata paths and supports metadata templating', () => {
|
||||
const rules: AutomationReactionConfig[] = [
|
||||
makeRule({
|
||||
name: 'github-push',
|
||||
on: ['webhook'],
|
||||
filter: { metadata: { 'webhookName': 'github', 'body.repository.full_name': 'acme/app' } },
|
||||
run: 'New push on {{metadata.body.repository.full_name}} from {{metadata.body.pusher.name}}',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = matchReactionPrompt(rules, {
|
||||
channel: 'webhook',
|
||||
senderId: 'github',
|
||||
text: 'raw webhook payload',
|
||||
metadata: {
|
||||
webhookName: 'github',
|
||||
body: {
|
||||
repository: { full_name: 'acme/app' },
|
||||
pusher: { name: 'will' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'github-push',
|
||||
prompt: 'New push on acme/app from will',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null on no match or invalid regex', () => {
|
||||
const rules: AutomationReactionConfig[] = [
|
||||
makeRule({
|
||||
name: 'bad',
|
||||
on: ['gmail'],
|
||||
filter: { regex: '[' },
|
||||
run: 'x',
|
||||
}),
|
||||
makeRule({
|
||||
name: 'different-channel',
|
||||
on: ['webhook'],
|
||||
run: 'x',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = matchReactionPrompt(rules, {
|
||||
channel: 'gmail',
|
||||
senderId: 'watcher',
|
||||
text: 'hello',
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user