this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feature: experimental rhai scripting support

Signed-off-by: Nick Gerakines <12125+ngerakines@users.noreply.github.com>

+343 -1
+5
Cargo.toml
··· 7 7 lto = true 8 8 strip = true 9 9 10 + [features] 11 + default = [] 12 + rhai = ["dep:rhai"] 13 + 10 14 [dependencies] 11 15 anyhow = "1.0.88" 12 16 async-trait = "0.1.82" ··· 38 42 tracing = { version = "0.1.40", features = ["async-await", "log", "valuable"] } 39 43 zstd = "0.13.2" 40 44 reqwest = { version = "0.12.9", features = ["json", "zstd", "rustls-tls"] } 45 + rhai = { version = "1.20.0", features = ["serde", "std", "sync"], optional = true}
+2 -1
Dockerfile
··· 11 11 12 12 ARG GIT_HASH 13 13 ENV GIT_HASH=$GIT_HASH 14 + ARG CARGO_FEATURES 14 15 15 16 RUN --mount=type=bind,source=src,target=src \ 16 17 --mount=type=bind,source=migrations,target=migrations \ ··· 21 22 --mount=type=cache,target=/usr/local/cargo/registry/ \ 22 23 <<EOF 23 24 set -e 24 - cargo build --locked --release --bin supercell --target-dir . 25 + cargo build --locked --release --bin supercell --target-dir . --features "$CARGO_FEATURES" 25 26 EOF 26 27 27 28 FROM debian:bookworm-slim
+72
docs/playbook-rhai.md
··· 1 + # Playbook: Rhai 2 + 3 + The experimental rhai matcher uses the [rhai](https://rhai.rs/) scripting language to evaluate expressions. 4 + 5 + ## Builds 6 + 7 + To use this feature, the `rhai` feature flag must be enabled at build time. 8 + 9 + Locally: 10 + 11 + ```shell 12 + cargo run --features rhai 13 + ``` 14 + 15 + Container: 16 + 17 + ```shell 18 + docker build --build-arg=CARGO_FEATURES=rhai . 19 + ``` 20 + 21 + ## Scripting 22 + 23 + Rhai matchers evaluate a script that returns a `MatcherResult` object. The script must return an object that matches this type. 24 + 25 + The `new_matcher_result()` function is available to create a new `MatcherResult` object. 26 + 27 + ```rhai 28 + let result = new_matcher_result(); 29 + 30 + // do some stuff ... 31 + 32 + result 33 + ``` 34 + 35 + ## Usage 36 + 37 + The feed matcher type `rhai` is used with a `source` attribute that points to an rhai script file. 38 + 39 + Rhai scripts are evaluated with scope that includes the following variables: 40 + 41 + * `event` - The event to match against. 42 + 43 + An example config file: 44 + 45 + ```yaml 46 + feeds: 47 + - uri: "at://did:plc:decafbad/app.bsky.feed.generator/Dcuz0bZP1" 48 + name: "rhai'ya doing" 49 + description: "This feed uses the rhai matcher to match against a complex expression." 50 + matchers: 51 + - source: "/opt/supercell/rhaiyadoin.rhai" 52 + type: rhai 53 + ``` 54 + 55 + An example rhai script: 56 + 57 + ```rhai 58 + let result = new_matcher_result(); 59 + 60 + let rtype = event?.commit?.record["$type"]; 61 + 62 + if rtype != "app.bsky.feed.post" { 63 + return result; 64 + } 65 + 66 + let root_uri = event?.commit?.record?.reply?.root?.uri; 67 + 68 + result.matched = root_uri.starts_with("at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/"); 69 + 70 + result 71 + ``` 72 +
+3
src/config.rs
··· 37 37 38 38 #[serde(rename = "sequence")] 39 39 Sequence { path: String, values: Vec<String> }, 40 + 41 + #[serde(rename = "rhai")] 42 + Rhai { script: String }, 40 43 } 41 44 42 45 #[derive(Clone)]
+155
src/matcher.rs
··· 1 1 use anyhow::{Context, Result}; 2 + 3 + #[cfg(not(feature = "rhai"))] 4 + use anyhow::anyhow; 5 + 2 6 use serde_json_path::JsonPath; 3 7 4 8 use crate::config; ··· 7 11 fn matches(&self, value: &serde_json::Value) -> bool; 8 12 } 9 13 14 + #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] 15 + pub struct MatcherResult { 16 + pub matched: bool, 17 + pub aturi: String, 18 + pub score: i64, 19 + } 20 + 21 + impl MatcherResult { 22 + fn get_matched(&mut self) -> bool { 23 + self.matched 24 + } 25 + 26 + fn set_matched(&mut self, value: bool) { 27 + self.matched = value; 28 + } 29 + 30 + fn get_aturi(&mut self) -> String { 31 + self.aturi.clone() 32 + } 33 + 34 + fn set_aturi(&mut self, value: String) { 35 + self.aturi = value; 36 + } 37 + 38 + fn get_score(&mut self) -> i64 { 39 + self.score 40 + } 41 + 42 + fn set_score(&mut self, value: i64) { 43 + self.score = value; 44 + } 45 + } 46 + 10 47 pub struct FeedMatcher { 11 48 pub(crate) feed: String, 12 49 pub(crate) aturi: Option<serde_json_path::JsonPath>, ··· 41 78 } 42 79 config::Matcher::Sequence { path, values } => { 43 80 matchers.push(Box::new(SequenceMatcher::new(values, path)?) as Box<dyn Matcher>); 81 + } 82 + 83 + #[cfg(feature = "rhai")] 84 + config::Matcher::Rhai { script } => { 85 + matchers 86 + .push(Box::new(rhai::RhaiMatcher::new(script)?) as Box<dyn Matcher>); 87 + } 88 + 89 + #[cfg(not(feature = "rhai"))] 90 + config::Matcher::Rhai { .. } => { 91 + return Err(anyhow!("rhai not enabled in this build")) 44 92 } 45 93 } 46 94 } ··· 190 238 } 191 239 } 192 240 241 + #[cfg(feature = "rhai")] 242 + pub mod rhai { 243 + 244 + use super::{Matcher, MatcherResult}; 245 + use anyhow::{Context, Result}; 246 + 247 + use rhai::{serde::to_dynamic, Engine, Scope, AST}; 248 + use std::{path::PathBuf, str::FromStr}; 249 + 250 + pub struct RhaiMatcher { 251 + source: String, 252 + engine: Engine, 253 + ast: AST, 254 + } 255 + 256 + impl RhaiMatcher { 257 + pub(crate) fn new(source: &str) -> Result<Self> { 258 + let mut engine = Engine::new(); 259 + engine 260 + .register_type_with_name::<MatcherResult>("MatcherResult") 261 + .register_get_set( 262 + "matched", 263 + MatcherResult::get_matched, 264 + MatcherResult::set_matched, 265 + ) 266 + .register_get_set("score", MatcherResult::get_score, MatcherResult::set_score) 267 + .register_get_set("aturi", MatcherResult::get_aturi, MatcherResult::set_aturi) 268 + .register_fn("new_matcher_result", MatcherResult::default); 269 + let ast = engine 270 + .compile_file(PathBuf::from_str(source)?) 271 + .context("cannot compile script")?; 272 + Ok(Self { 273 + source: source.to_string(), 274 + engine, 275 + ast, 276 + }) 277 + } 278 + } 279 + 280 + impl Matcher for RhaiMatcher { 281 + fn matches(&self, value: &serde_json::Value) -> bool { 282 + let mut scope = Scope::new(); 283 + let value_map = to_dynamic(value); 284 + if let Err(err) = value_map { 285 + println!("error: {:?}", err); 286 + tracing::error!(source = ?self.source, error = ?err, "error converting value to dynamic"); 287 + return false; 288 + } 289 + let value_map = value_map.unwrap(); 290 + scope.push("event", value_map); 291 + 292 + let result = self 293 + .engine 294 + .eval_ast_with_scope::<MatcherResult>(&mut scope, &self.ast); 295 + 296 + if let Err(err) = result { 297 + println!("error: {:?}", err); 298 + tracing::error!(source = ?self.source, error = ?err, "error evaluating script"); 299 + return false; 300 + } 301 + 302 + let result = result.unwrap(); 303 + 304 + result.matched 305 + } 306 + } 307 + } 308 + 193 309 #[cfg(test)] 194 310 mod tests { 311 + 195 312 use super::*; 196 313 197 314 #[test] ··· 361 478 assert_eq!(matcher.matches(&value), false); 362 479 } 363 480 } 481 + 482 + #[cfg(all(test, feature = "rhai"))] 483 + mod rhaitests { 484 + 485 + use anyhow::{anyhow, Result}; 486 + use super::rhai::*; 487 + use super::*; 488 + use std::path::PathBuf; 489 + 490 + #[cfg(feature = "rhai")] 491 + #[test] 492 + fn rhai_matcher() -> Result<()> { 493 + 494 + let testdata = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"); 495 + 496 + let tests = vec![ 497 + ("post1.json", [("rhai_match_everything.rhai", true),("rhai_match_type.rhai", true),("rhai_match_poster.rhai", true), ("rhai_match_reply_root.rhai", false)]), 498 + ("post2.json", [("rhai_match_everything.rhai", true),("rhai_match_type.rhai", true),("rhai_match_poster.rhai", true), ("rhai_match_reply_root.rhai", true)]) 499 + ]; 500 + 501 + for (input_json, matcher_tests) in tests { 502 + let input_json_path = testdata.join(input_json); 503 + let json_content = std::fs::read(input_json_path).map_err(|err| { 504 + anyhow::Error::new(err).context(anyhow!("reading input_json failed")) 505 + })?; 506 + let value: serde_json::Value = serde_json::from_slice(&json_content).context("parsing input_json failed")?; 507 + 508 + for (matcher_file_name, expected) in matcher_tests { 509 + let matcher_path = testdata.join(matcher_file_name); 510 + let matcher = RhaiMatcher::new(&matcher_path.to_string_lossy()).context("could not construct matcher")?; 511 + assert_eq!(matcher.matches(&value), expected); 512 + } 513 + 514 + } 515 + 516 + Ok(()) 517 + } 518 + }
+28
testdata/post1.json
··· 1 + { 2 + "did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2", 3 + "time_us": 1730491093829414, 4 + "kind": "commit", 5 + "commit": { 6 + "rev": "3laadb7behk25", 7 + "operation": "create", 8 + "collection": "app.bsky.feed.post", 9 + "rkey": "3laadb7behk25", 10 + "record": { 11 + "$type": "app.bsky.feed.post", 12 + "createdAt": "2024-11-05T22:56:04.560Z", 13 + "langs": ["en"], 14 + "text": "Hello! I'm pleased to announce github.com/astrenoxcoop..., a configurable feed generator. This is the system behind @smokesignal.events feeds.", 15 + "facets": [ 16 + { 17 + "features": [{"$type": "app.bsky.richtext.facet#link", "uri": "https://github.com/astrenoxcoop/supercell"}], 18 + "index": { "byteEnd": 57, "byteStart": 31 } 19 + }, 20 + { 21 + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:tgudj2fjm77pzkuawquqhsxm"}], 22 + "index": { "byteEnd": 135, "byteStart": 116 } 23 + } 24 + ] 25 + }, 26 + "cid": "bafyreiew6g2hjfd7c7gxnabw2uljfrcqasa335vf7g6r7r4b4laq6li2mq" 27 + } 28 + }
+32
testdata/post2.json
··· 1 + { 2 + "did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2", 3 + "time_us": 1730491094829414, 4 + "kind": "commit", 5 + "commit": { 6 + "rev": "3laadftr72k25", 7 + "operation": "create", 8 + "collection": "app.bsky.feed.post", 9 + "rkey": "3laadftr72k25", 10 + "record": { 11 + "$type": "app.bsky.feed.post", 12 + "createdAt": "2024-11-05T22:58:40.268Z", 13 + "langs": ["en"], 14 + "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.", 15 + "reply": { 16 + "root": {"cid": "bafyreiew6g2hjfd7c7gxnabw2uljfrcqasa335vf7g6r7r4b4laq6li2mq", "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25"}, 17 + "parent": {"cid": "bafyreiew6g2hjfd7c7gxnabw2uljfrcqasa335vf7g6r7r4b4laq6li2mq", "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25"} 18 + }, 19 + "facets": [ 20 + { 21 + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:mo344na6tbuu6nd22w4uwcty"}], 22 + "index": { "byteEnd": 88, "byteStart": 74 } 23 + }, 24 + { 25 + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:fjr24tyxkpi3xqenws7anfmj"}], 26 + "index": { "byteEnd": 139, "byteStart": 114 } 27 + } 28 + ] 29 + }, 30 + "cid": "bafyreihyobboz2p5pgm2plenk5fqw56ujuak42ztqnsvcaibgmicxjzvri" 31 + } 32 + }
+5
testdata/rhai_match_everything.rhai
··· 1 + 2 + let result = new_matcher_result(); 3 + result.matched = true; 4 + 5 + result
+13
testdata/rhai_match_poster.rhai
··· 1 + 2 + let result = new_matcher_result(); 3 + 4 + let rtype = event?.commit?.record["$type"]; 5 + 6 + switch rtype { 7 + "app.bsky.feed.post" => { 8 + result.matched = event.did == "did:plc:cbkjy5n7bk3ax2wplmtjofq2"; 9 + } 10 + _ => { } 11 + } 12 + 13 + result
+14
testdata/rhai_match_reply_root.rhai
··· 1 + 2 + let result = new_matcher_result(); 3 + 4 + let rtype = event?.commit?.record["$type"]; 5 + 6 + if rtype != "app.bsky.feed.post" { 7 + return result; 8 + } 9 + 10 + let root_uri = event?.commit?.record?.reply?.root?.uri; 11 + 12 + result.matched = root_uri.starts_with("at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/"); 13 + 14 + result
+14
testdata/rhai_match_type.rhai
··· 1 + 2 + let result = new_matcher_result(); 3 + 4 + let rtype = event?.commit?.record["$type"]; 5 + 6 + switch rtype { 7 + "app.bsky.feed.post" => { 8 + result.matched = true; 9 + } 10 + // noop 11 + _ => { } 12 + } 13 + 14 + result