diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-04-27 00:02:20 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-27 00:02:20 +0100 |
| commit | 136f126296af65f50da598d084d1485c0e40437a (patch) | |
| tree | 2725c7932ebbcb9b48b5af98eb9b72329a400260 /packages/trpc/lib/ruleEngine.ts | |
| parent | ca47be7fe7be128f459c37614a04902a873fe289 (diff) | |
| download | karakeep-136f126296af65f50da598d084d1485c0e40437a.tar.zst | |
feat: Implement generic rule engine (#1318)
* Add schema for the new rule engine
* Add rule engine backend logic
* Implement the worker logic and event firing
* Implement the UI changesfor the rule engine
* Ensure that when a referenced list or tag are deleted, the corresponding event/action is
* Dont show smart lists in rule engine events
* Add privacy validations for attached tag and list ids
* Move the rules logic into a models
Diffstat (limited to 'packages/trpc/lib/ruleEngine.ts')
| -rw-r--r-- | packages/trpc/lib/ruleEngine.ts | 231 |
1 files changed, 231 insertions, 0 deletions
diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts new file mode 100644 index 00000000..0bef8cdc --- /dev/null +++ b/packages/trpc/lib/ruleEngine.ts @@ -0,0 +1,231 @@ +import deepEql from "deep-equal"; +import { and, eq } from "drizzle-orm"; + +import { bookmarks, tagsOnBookmarks } from "@karakeep/db/schema"; +import { LinkCrawlerQueue } from "@karakeep/shared/queues"; +import { + RuleEngineAction, + RuleEngineCondition, + RuleEngineEvent, + RuleEngineRule, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from ".."; +import { List } from "../models/lists"; +import { RuleEngineRuleModel } from "../models/rules"; + +async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) { + return await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + link: { + columns: { + url: true, + }, + }, + text: true, + asset: true, + tagsOnBookmarks: true, + rssFeeds: { + columns: { + rssFeedId: true, + }, + }, + user: { + columns: {}, + with: { + rules: { + with: { + actions: true, + }, + }, + }, + }, + }, + }); +} + +type ReturnedBookmark = NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>; + +export interface RuleEngineEvaluationResult { + type: "success" | "failure"; + ruleId: string; + message: string; +} + +export class RuleEngine { + private constructor( + private ctx: AuthedContext, + private bookmark: Omit<ReturnedBookmark, "user">, + private rules: RuleEngineRule[], + ) {} + + static async forBookmark(ctx: AuthedContext, bookmarkId: string) { + const [bookmark, rules] = await Promise.all([ + fetchBookmark(ctx.db, bookmarkId), + RuleEngineRuleModel.getAll(ctx), + ]); + if (!bookmark) { + throw new Error(`Bookmark ${bookmarkId} not found`); + } + return new RuleEngine( + ctx, + bookmark, + rules.map((r) => r.rule), + ); + } + + doesBookmarkMatchConditions(condition: RuleEngineCondition): boolean { + switch (condition.type) { + case "alwaysTrue": { + return true; + } + case "urlContains": { + return (this.bookmark.link?.url ?? "").includes(condition.str); + } + case "importedFromFeed": { + return this.bookmark.rssFeeds.some( + (f) => f.rssFeedId === condition.feedId, + ); + } + case "bookmarkTypeIs": { + return this.bookmark.type === condition.bookmarkType; + } + case "hasTag": { + return this.bookmark.tagsOnBookmarks.some( + (t) => t.tagId === condition.tagId, + ); + } + case "isFavourited": { + return this.bookmark.favourited; + } + case "isArchived": { + return this.bookmark.archived; + } + case "and": { + return condition.conditions.every((c) => + this.doesBookmarkMatchConditions(c), + ); + } + case "or": { + return condition.conditions.some((c) => + this.doesBookmarkMatchConditions(c), + ); + } + default: { + const _exhaustiveCheck: never = condition; + return false; + } + } + } + + async evaluateRule( + rule: RuleEngineRule, + event: RuleEngineEvent, + ): Promise<RuleEngineEvaluationResult[]> { + if (!rule.enabled) { + return []; + } + if (!deepEql(rule.event, event, { strict: true })) { + return []; + } + if (!this.doesBookmarkMatchConditions(rule.condition)) { + return []; + } + const results = await Promise.allSettled( + rule.actions.map((action) => this.executeAction(action)), + ); + return results.map((result) => { + if (result.status === "fulfilled") { + return { + type: "success", + ruleId: rule.id, + message: result.value, + }; + } else { + return { + type: "failure", + ruleId: rule.id, + message: (result.reason as Error).message, + }; + } + }); + } + + async executeAction(action: RuleEngineAction): Promise<string> { + switch (action.type) { + case "addTag": { + await this.ctx.db + .insert(tagsOnBookmarks) + .values([ + { + attachedBy: "human", + bookmarkId: this.bookmark.id, + tagId: action.tagId, + }, + ]) + .onConflictDoNothing(); + return `Added tag ${action.tagId}`; + } + case "removeTag": { + await this.ctx.db + .delete(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.tagId, action.tagId), + eq(tagsOnBookmarks.bookmarkId, this.bookmark.id), + ), + ); + return `Removed tag ${action.tagId}`; + } + case "addToList": { + const list = await List.fromId(this.ctx, action.listId); + await list.addBookmark(this.bookmark.id); + return `Added to list ${action.listId}`; + } + case "removeFromList": { + const list = await List.fromId(this.ctx, action.listId); + await list.removeBookmark(this.bookmark.id); + return `Removed from list ${action.listId}`; + } + case "downloadFullPageArchive": { + await LinkCrawlerQueue.enqueue({ + bookmarkId: this.bookmark.id, + archiveFullPage: true, + runInference: false, + }); + return `Enqueued full page archive`; + } + case "favouriteBookmark": { + await this.ctx.db + .update(bookmarks) + .set({ + favourited: true, + }) + .where(eq(bookmarks.id, this.bookmark.id)); + return `Marked as favourited`; + } + case "archiveBookmark": { + await this.ctx.db + .update(bookmarks) + .set({ + archived: true, + }) + .where(eq(bookmarks.id, this.bookmark.id)); + return `Marked as archived`; + } + default: { + const _exhaustiveCheck: never = action; + return ""; + } + } + } + + async onEvent(event: RuleEngineEvent): Promise<RuleEngineEvaluationResult[]> { + const results = await Promise.all( + this.rules.map((rule) => this.evaluateRule(rule, event)), + ); + + return results.flat(); + } +} |
