From 5f81c72fe88c6ae64ade6bec06cc431d31851aab Mon Sep 17 00:00:00 2001 From: Nick Gerakines <12125+ngerakines@users.noreply.github.com> Date: Fri, 08 Nov 2024 22:20:23 +0000 Subject: [PATCH] feature: experimental rhai scripting support Signed-off-by: Nick Gerakines <12125+ngerakines@users.noreply.github.com> --- Cargo.toml | 5 +++++ Dockerfile | 3 ++- docs/playbook-rhai.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 3 +++ src/matcher.rs | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ testdata/post1.json | 28 ++++++++++++++++++++++++++++ testdata/post2.json | 32 ++++++++++++++++++++++++++++++++ testdata/rhai_match_everything.rhai | 5 +++++ testdata/rhai_match_poster.rhai | 13 +++++++++++++ testdata/rhai_match_reply_root.rhai | 14 ++++++++++++++ testdata/rhai_match_type.rhai | 14 ++++++++++++++ 11 file(s) changed, 343 insertion(s)(+), 1 deletion(s)(-) diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,10 @@ [profile.release] lto = true strip = true +[features] +default = [] +rhai = ["dep:rhai"] + [dependencies] anyhow = "1.0.88" async-trait = "0.1.82" @@ -38,3 +42,4 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono", "json"] } tracing = { version = "0.1.40", features = ["async-await", "log", "valuable"] } zstd = "0.13.2" reqwest = { version = "0.12.9", features = ["json", "zstd", "rustls-tls"] } +rhai = { version = "1.20.0", features = ["serde", "std", "sync"], optional = true} diff --git a/Dockerfile b/Dockerfile --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ WORKDIR /app/ ARG GIT_HASH ENV GIT_HASH=$GIT_HASH +ARG CARGO_FEATURES RUN --mount=type=bind,source=src,target=src \ --mount=type=bind,source=migrations,target=migrations \ @@ -21,7 +22,7 @@ --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/registry/ \ < }, + + #[serde(rename = "rhai")] + Rhai { script: String }, } #[derive(Clone)] diff --git a/src/matcher.rs b/src/matcher.rs --- a/src/matcher.rs +++ b/src/matcher.rs @@ -1,4 +1,8 @@ use anyhow::{Context, Result}; + +#[cfg(not(feature = "rhai"))] +use anyhow::anyhow; + use serde_json_path::JsonPath; use crate::config; @@ -7,6 +11,39 @@ pub trait Matcher: Sync + Send { fn matches(&self, value: &serde_json::Value) -> bool; } +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct MatcherResult { + pub matched: bool, + pub aturi: String, + pub score: i64, +} + +impl MatcherResult { + fn get_matched(&mut self) -> bool { + self.matched + } + + fn set_matched(&mut self, value: bool) { + self.matched = value; + } + + fn get_aturi(&mut self) -> String { + self.aturi.clone() + } + + fn set_aturi(&mut self, value: String) { + self.aturi = value; + } + + fn get_score(&mut self) -> i64 { + self.score + } + + fn set_score(&mut self, value: i64) { + self.score = value; + } +} + pub struct FeedMatcher { pub(crate) feed: String, pub(crate) aturi: Option, @@ -41,6 +78,17 @@ .push(Box::new(PrefixMatcher::new(value, path)?) as Box); } config::Matcher::Sequence { path, values } => { matchers.push(Box::new(SequenceMatcher::new(values, path)?) as Box); + } + + #[cfg(feature = "rhai")] + config::Matcher::Rhai { script } => { + matchers + .push(Box::new(rhai::RhaiMatcher::new(script)?) as Box); + } + + #[cfg(not(feature = "rhai"))] + config::Matcher::Rhai { .. } => { + return Err(anyhow!("rhai not enabled in this build")) } } } @@ -190,8 +238,77 @@ false } } +#[cfg(feature = "rhai")] +pub mod rhai { + + use super::{Matcher, MatcherResult}; + use anyhow::{Context, Result}; + + use rhai::{serde::to_dynamic, Engine, Scope, AST}; + use std::{path::PathBuf, str::FromStr}; + + pub struct RhaiMatcher { + source: String, + engine: Engine, + ast: AST, + } + + impl RhaiMatcher { + pub(crate) fn new(source: &str) -> Result { + let mut engine = Engine::new(); + engine + .register_type_with_name::("MatcherResult") + .register_get_set( + "matched", + MatcherResult::get_matched, + MatcherResult::set_matched, + ) + .register_get_set("score", MatcherResult::get_score, MatcherResult::set_score) + .register_get_set("aturi", MatcherResult::get_aturi, MatcherResult::set_aturi) + .register_fn("new_matcher_result", MatcherResult::default); + let ast = engine + .compile_file(PathBuf::from_str(source)?) + .context("cannot compile script")?; + Ok(Self { + source: source.to_string(), + engine, + ast, + }) + } + } + + impl Matcher for RhaiMatcher { + fn matches(&self, value: &serde_json::Value) -> bool { + let mut scope = Scope::new(); + let value_map = to_dynamic(value); + if let Err(err) = value_map { + println!("error: {:?}", err); + tracing::error!(source = ?self.source, error = ?err, "error converting value to dynamic"); + return false; + } + let value_map = value_map.unwrap(); + scope.push("event", value_map); + + let result = self + .engine + .eval_ast_with_scope::(&mut scope, &self.ast); + + if let Err(err) = result { + println!("error: {:?}", err); + tracing::error!(source = ?self.source, error = ?err, "error evaluating script"); + return false; + } + + let result = result.unwrap(); + + result.matched + } + } +} + #[cfg(test)] mod tests { + use super::*; #[test] @@ -361,3 +478,41 @@ .expect("matcher is valid"); assert_eq!(matcher.matches(&value), false); } } + +#[cfg(all(test, feature = "rhai"))] +mod rhaitests { + + use anyhow::{anyhow, Result}; + use super::rhai::*; + use super::*; + use std::path::PathBuf; + + #[cfg(feature = "rhai")] + #[test] + fn rhai_matcher() -> Result<()> { + + let testdata = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"); + + let tests = vec![ + ("post1.json", [("rhai_match_everything.rhai", true),("rhai_match_type.rhai", true),("rhai_match_poster.rhai", true), ("rhai_match_reply_root.rhai", false)]), + ("post2.json", [("rhai_match_everything.rhai", true),("rhai_match_type.rhai", true),("rhai_match_poster.rhai", true), ("rhai_match_reply_root.rhai", true)]) + ]; + + for (input_json, matcher_tests) in tests { + let input_json_path = testdata.join(input_json); + let json_content = std::fs::read(input_json_path).map_err(|err| { + anyhow::Error::new(err).context(anyhow!("reading input_json failed")) + })?; + let value: serde_json::Value = serde_json::from_slice(&json_content).context("parsing input_json failed")?; + + for (matcher_file_name, expected) in matcher_tests { + let matcher_path = testdata.join(matcher_file_name); + let matcher = RhaiMatcher::new(&matcher_path.to_string_lossy()).context("could not construct matcher")?; + assert_eq!(matcher.matches(&value), expected); + } + + } + + Ok(()) + } +} diff --git a/testdata/post1.json b/testdata/post1.json new file mode 100644 --- /dev/null +++ b/testdata/post1.json @@ -0,0 +1,28 @@ +{ + "did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2", + "time_us": 1730491093829414, + "kind": "commit", + "commit": { + "rev": "3laadb7behk25", + "operation": "create", + "collection": "app.bsky.feed.post", + "rkey": "3laadb7behk25", + "record": { + "$type": "app.bsky.feed.post", + "createdAt": "2024-11-05T22:56:04.560Z", + "langs": ["en"], + "text": "Hello! I'm pleased to announce github.com/astrenoxcoop..., a configurable feed generator. This is the system behind @smokesignal.events feeds.", + "facets": [ + { + "features": [{"$type": "app.bsky.richtext.facet#link", "uri": "https://github.com/astrenoxcoop/supercell"}], + "index": { "byteEnd": 57, "byteStart": 31 } + }, + { + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:tgudj2fjm77pzkuawquqhsxm"}], + "index": { "byteEnd": 135, "byteStart": 116 } + } + ] + }, + "cid": "bafyreiew6g2hjfd7c7gxnabw2uljfrcqasa335vf7g6r7r4b4laq6li2mq" + } +} diff --git a/testdata/post2.json b/testdata/post2.json new file mode 100644 --- /dev/null +++ b/testdata/post2.json @@ -0,0 +1,32 @@ +{ + "did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2", + "time_us": 1730491094829414, + "kind": "commit", + "commit": { + "rev": "3laadftr72k25", + "operation": "create", + "collection": "app.bsky.feed.post", + "rkey": "3laadftr72k25", + "record": { + "$type": "app.bsky.feed.post", + "createdAt": "2024-11-05T22:58:40.268Z", + "langs": ["en"], + "text": "This is also the first major public release of open source software under @astrenox.coop, a cooperative formed by @emilymobes.astrenox.coop and me. This is open source under the permissive MIT license. Contributes and suggestions are welcome.", + "reply": { + "root": {"cid": "bafyreiew6g2hjfd7c7gxnabw2uljfrcqasa335vf7g6r7r4b4laq6li2mq", "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25"}, + "parent": {"cid": "bafyreiew6g2hjfd7c7gxnabw2uljfrcqasa335vf7g6r7r4b4laq6li2mq", "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25"} + }, + "facets": [ + { + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:mo344na6tbuu6nd22w4uwcty"}], + "index": { "byteEnd": 88, "byteStart": 74 } + }, + { + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:fjr24tyxkpi3xqenws7anfmj"}], + "index": { "byteEnd": 139, "byteStart": 114 } + } + ] + }, + "cid": "bafyreihyobboz2p5pgm2plenk5fqw56ujuak42ztqnsvcaibgmicxjzvri" + } +} diff --git a/testdata/rhai_match_everything.rhai b/testdata/rhai_match_everything.rhai new file mode 100644 --- /dev/null +++ b/testdata/rhai_match_everything.rhai @@ -0,0 +1,5 @@ + +let result = new_matcher_result(); +result.matched = true; + +result diff --git a/testdata/rhai_match_poster.rhai b/testdata/rhai_match_poster.rhai new file mode 100644 --- /dev/null +++ b/testdata/rhai_match_poster.rhai @@ -0,0 +1,13 @@ + +let result = new_matcher_result(); + +let rtype = event?.commit?.record["$type"]; + +switch rtype { + "app.bsky.feed.post" => { + result.matched = event.did == "did:plc:cbkjy5n7bk3ax2wplmtjofq2"; + } + _ => { } +} + +result diff --git a/testdata/rhai_match_reply_root.rhai b/testdata/rhai_match_reply_root.rhai new file mode 100644 --- /dev/null +++ b/testdata/rhai_match_reply_root.rhai @@ -0,0 +1,14 @@ + +let result = new_matcher_result(); + +let rtype = event?.commit?.record["$type"]; + +if rtype != "app.bsky.feed.post" { + return result; +} + +let root_uri = event?.commit?.record?.reply?.root?.uri; + +result.matched = root_uri.starts_with("at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/"); + +result diff --git a/testdata/rhai_match_type.rhai b/testdata/rhai_match_type.rhai new file mode 100644 --- /dev/null +++ b/testdata/rhai_match_type.rhai @@ -0,0 +1,14 @@ + +let result = new_matcher_result(); + +let rtype = event?.commit?.record["$type"]; + +switch rtype { + "app.bsky.feed.post" => { + result.matched = true; + } + // noop + _ => { } +} + +result -- tangled.sh