diff --git a/.gitignore b/.gitignore index 3fdf355..e29861c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ rustbot.service rustbot.conf weather.*.json karma.*.json +karma.*.events.jsonl *.json.tmp karma-web.service karma-web.timer diff --git a/README.md b/README.md index 6a695c5..42b04aa 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,13 @@ lists them at runtime and `help ` describes one. ## Karma web page -The live karma board is published at ****. A second -binary, `karma-web`, reads the per-network `karma.*.json` files and renders one -self-contained HTML page (no JS/CSS/font dependencies, all IRC-supplied text -HTML-escaped); a systemd timer regenerates it every minute and nginx serves it +The live karma board is published at **** β€” a hero +with live stats, a top-3 podium, a ranked ledger with per-thing trend +sparklines, a recent-activity feed, and a top-givers board. A second binary, +`karma-web`, reads the per-network `karma.*.json` totals plus the append-only +`karma.*.events.jsonl` history the bot writes on every bump, and renders one +self-contained HTML page (no CSS/JS/font dependencies, all IRC-supplied text +HTML-escaped). A systemd timer regenerates it every minute and nginx serves it over TLS. ```sh diff --git a/src/bin/karma-web.rs b/src/bin/karma-web.rs index abeb453..8514c54 100644 --- a/src/bin/karma-web.rs +++ b/src/bin/karma-web.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use rustbot::karma_web::{parse_board, render, Board}; +use rustbot::karma_web::{parse_board, parse_events, render, Board}; fn main() { let mut args = std::env::args().skip(1); @@ -23,7 +23,12 @@ fn main() { .and_then(|s| s.strip_suffix(".json")) { if let Ok(body) = fs::read_to_string(&p) { - boards.push(parse_board(net, &body)); + let mut board = parse_board(net, &body); + let ev = p.with_file_name(format!("karma.{net}.events.jsonl")); + if let Ok(evbody) = fs::read_to_string(&ev) { + board.events = parse_events(&evbody); + } + boards.push(board); } } } diff --git a/src/bot/modules/karma.rs b/src/bot/modules/karma.rs index eab4ca4..cfa76b6 100644 --- a/src/bot/modules/karma.rs +++ b/src/bot/modules/karma.rs @@ -1,6 +1,9 @@ use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::PathBuf; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::bot::module::{Action, Chat, Command, CommandSpec, Module}; use crate::json::{self, Json}; @@ -112,6 +115,7 @@ impl Module for Karma { } // A reason is only attributed when the message has a single bump. let reason = extract_reason(chat.text); + let t = now(); let mut announces = Vec::new(); for (thing, delta) in &changes { let r = if changes.len() == 1 { @@ -119,6 +123,8 @@ impl Module for Karma { } else { None }; + self.store + .log_event(&format_event(t, thing, *delta, chat.sender, r)); let new = self.store.bump(thing, *delta, r, chat.sender); if *delta > 0 && MILESTONES.contains(&new) { announces.push(Action::reply(format!( @@ -221,6 +227,7 @@ struct Entry { struct Store { map: HashMap, path: PathBuf, + events_path: PathBuf, } impl Store { @@ -238,7 +245,22 @@ impl Store { m }) .unwrap_or_default(); - Store { map, path } + let events_path = events_path_for(&path); + Store { + map, + path, + events_path, + } + } + + fn log_event(&self, line: &str) { + if let Ok(mut f) = OpenOptions::new() + .create(true) + .append(true) + .open(&self.events_path) + { + let _ = f.write_all(line.as_bytes()); + } } fn save(&self) -> std::io::Result<()> { @@ -323,3 +345,28 @@ fn entry_from_json(v: &Json) -> Entry { .map(str::to_string), } } + +fn events_path_for(path: &Path) -> PathBuf { + let p = path.to_string_lossy(); + let base = p.strip_suffix(".json").unwrap_or(&p); + PathBuf::from(format!("{base}.events.jsonl")) +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub fn format_event(t: u64, thing: &str, delta: i64, by: &str, why: Option<&str>) -> String { + let why_field = match why { + Some(w) if !w.is_empty() => format!(",\"why\":\"{}\"", json::escape(w)), + _ => String::new(), + }; + format!( + "{{\"t\":{t},\"o\":\"{}\",\"d\":{delta},\"by\":\"{}\"{why_field}}}\n", + json::escape(thing), + json::escape(by) + ) +} diff --git a/src/karma_web.rs b/src/karma_web.rs index 30c9d6a..85813ea 100644 --- a/src/karma_web.rs +++ b/src/karma_web.rs @@ -1,3 +1,5 @@ +use std::collections::{HashMap, HashSet}; + use crate::json::Json; pub struct Row { @@ -7,9 +9,18 @@ pub struct Row { pub by: Option, } +pub struct Event { + pub t: u64, + pub thing: String, + pub delta: i64, + pub by: Option, + pub why: Option, +} + pub struct Board { pub network: String, pub rows: Vec, + pub events: Vec, } pub fn parse_board(network: &str, body: &str) -> Board { @@ -33,9 +44,41 @@ pub fn parse_board(network: &str, body: &str) -> Board { Board { network: network.to_string(), rows, + events: Vec::new(), } } +pub fn parse_events(body: &str) -> Vec { + let mut out = Vec::new(); + for line in body.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Some(j) = Json::parse(line) else { continue }; + let thing = match j.get("o").and_then(Json::as_str) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + out.push(Event { + t: j.get("t").and_then(Json::as_f64).unwrap_or(0.0) as u64, + thing, + delta: j.get("d").and_then(Json::as_f64).unwrap_or(0.0) as i64, + by: j + .get("by") + .and_then(Json::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string), + why: j + .get("why") + .and_then(Json::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string), + }); + } + out +} + fn fields(v: &Json) -> (i64, Option, Option) { if let Some(n) = v.as_f64() { return (n as i64, None, None); @@ -69,70 +112,364 @@ fn esc(s: &str) -> String { o } -pub fn render(boards: &[Board], updated_epoch: u64) -> String { - let total: usize = boards.iter().map(|b| b.rows.len()).sum(); - let mut s = String::from(HEAD); - if total == 0 { - s.push_str(EMPTY); +fn hue(s: &str) -> u32 { + let mut h: u32 = 2166136261; + for b in s.to_lowercase().bytes() { + h ^= b as u32; + h = h.wrapping_mul(16777619); + } + h % 360 +} + +fn initials(s: &str) -> String { + let up: String = s + .chars() + .filter(|c| c.is_alphanumeric()) + .take(2) + .collect::() + .to_uppercase(); + if up.is_empty() { + "?".to_string() } else { - s.push_str(SEARCH); - for b in boards { - if b.rows.is_empty() { - continue; - } - s.push_str(&format!( - "

{}

\ - {} {}
    ", - esc(&b.network), - b.rows.len(), - if b.rows.len() == 1 { - "entry" - } else { - "entries" - } - )); - for (i, r) in b.rows.iter().enumerate() { - s.push_str(&row(i + 1, r)); - } - s.push_str("
"); + up + } +} + +fn avatar(name: &str, size: &str) -> String { + format!( + "{}", + hue(name), + esc(&initials(name)) + ) +} + +fn signed(n: i64) -> String { + if n > 0 { + format!("+{n}") + } else { + n.to_string() + } +} + +fn klass(n: i64) -> &'static str { + match n { + n if n > 0 => "up", + n if n < 0 => "down", + _ => "zero", + } +} + +/// Cumulative karma over the logged events for `thing`, anchored so the last +/// point equals the current stored count. Empty if there is nothing to plot. +fn series_for(thing: &str, count: i64, events: &[Event]) -> Vec { + let key = thing.to_lowercase(); + let deltas: Vec = events + .iter() + .filter(|e| e.thing.to_lowercase() == key) + .map(|e| e.delta) + .collect(); + if deltas.is_empty() { + return Vec::new(); + } + let sum: i64 = deltas.iter().sum(); + let mut v = count - sum; + let mut series = Vec::with_capacity(deltas.len() + 1); + series.push(v); + for d in deltas { + v += d; + series.push(v); + } + series +} + +fn sparkline(series: &[i64]) -> String { + if series.len() < 2 { + return String::new(); + } + let (w, h, pad) = (104.0_f64, 30.0_f64, 4.0_f64); + let min = *series.iter().min().unwrap() as f64; + let max = *series.iter().max().unwrap() as f64; + let span = if (max - min).abs() < f64::EPSILON { + 1.0 + } else { + max - min + }; + let n = series.len() as f64; + let at = |i: usize, v: i64| -> (f64, f64) { + let x = pad + (w - 2.0 * pad) * (i as f64) / (n - 1.0); + let y = pad + (h - 2.0 * pad) * (1.0 - (v as f64 - min) / span); + (x, y) + }; + let mut pts = String::new(); + for (i, v) in series.iter().enumerate() { + if i > 0 { + pts.push(' '); + } + let (x, y) = at(i, *v); + pts.push_str(&format!("{x:.1},{y:.1}")); + } + let (lx, ly) = at(series.len() - 1, *series.last().unwrap()); + let cls = if series.last() >= series.first() { + "up" + } else { + "down" + }; + format!( + "\ + " + ) +} + +struct Giver { + name: String, + gives: i64, + points: i64, +} + +fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec { + let mut m: HashMap = HashMap::new(); + for (_, e) in all { + if let Some(by) = &e.by { + let ent = m.entry(by.to_lowercase()).or_insert((by.clone(), 0, 0)); + ent.1 += 1; + ent.2 += e.delta; } } - s.push_str(&SCRIPT.replace("__EPOCH__", &updated_epoch.to_string())); + let mut v: Vec = m + .into_values() + .map(|(name, gives, points)| Giver { + name, + gives, + points, + }) + .collect(); + v.sort_by(|a, b| { + b.gives + .cmp(&a.gives) + .then(b.points.cmp(&a.points)) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + v.truncate(n); + v +} + +pub fn render(boards: &[Board], updated_epoch: u64) -> String { + let things: usize = boards.iter().map(|b| b.rows.len()).sum(); + let total_points: i64 = boards.iter().flat_map(|b| &b.rows).map(|r| r.count).sum(); + + let mut feed: Vec<(&str, &Event)> = boards + .iter() + .flat_map(|b| b.events.iter().map(move |e| (b.network.as_str(), e))) + .collect(); + feed.sort_by_key(|(_, e)| std::cmp::Reverse(e.t)); + let givers_set: HashSet = feed + .iter() + .filter_map(|(_, e)| e.by.as_ref().map(|s| s.to_lowercase())) + .collect(); + let gifts = feed.len(); + + let mut s = String::from(HEAD); + let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1; + + // hero + s.push_str("
rubot@devtronic
"); + s.push_str("

karma_

"); + s.push_str( + "

reputation earned one nick++ at a time, across the channels.

", + ); + s.push_str("
"); + s.push_str(&stat(&things.to_string(), "tracked")); + s.push_str(&stat(&signed(total_points), "net karma")); + s.push_str(&stat(&gifts.to_string(), "gifts logged")); + s.push_str(&stat(&givers_set.len().to_string(), "givers")); + s.push_str("
"); + + if things == 0 { + s.push_str(EMPTY); + s.push_str(&footer(updated_epoch)); + s.push_str(SCRIPT); + s.push_str(TAIL); + return s; + } + + // per-network board(s): champion podium + the rest as a ledger + for b in boards { + if b.rows.is_empty() { + continue; + } + s.push_str("
"); + if multi { + s.push_str(&format!( + "
network{}
", + esc(&b.network) + )); + } + s.push_str(&podium(b)); + s.push_str(&ledger(b)); + s.push_str("
"); + } + + // activity + givers band + s.push_str("
"); + s.push_str(&activity(&feed, multi)); + s.push_str(&givers_panel(&feed)); + s.push_str("
"); + + s.push_str(&footer(updated_epoch)); + s.push_str(SCRIPT); + s.push_str(TAIL); s } -fn row(rank: usize, r: &Row) -> String { - let medal = match rank { - 1 => "πŸ₯‡".to_string(), - 2 => "πŸ₯ˆ".to_string(), - 3 => "πŸ₯‰".to_string(), - n => n.to_string(), - }; - let top = if rank <= 3 { " top" } else { "" }; - let (klass, sign) = match r.count { - n if n > 0 => ("pos", "+"), - n if n < 0 => ("neg", ""), - _ => ("zero", ""), - }; - let mut who = format!("
{}
", esc(&r.thing)); - if let Some(reason) = &r.reason { - match &r.by { - Some(by) => who.push_str(&format!( - "
\u{201C}{}\u{201D} \u{2014} {}
", - esc(reason), - esc(by) - )), - None => who.push_str(&format!( - "
\u{201C}{}\u{201D}
", - esc(reason) - )), - } - } +fn stat(num: &str, label: &str) -> String { format!( - "
  • {medal}\ -
    {who}
    \ - {sign}{}
  • ", - r.count + "
    {}{}
    ", + esc(num), + esc(label) + ) +} + +fn podium(b: &Board) -> String { + let top = b.rows.iter().take(3).collect::>(); + let mut out = String::from("
      "); + for (i, r) in top.iter().enumerate() { + let rank = i + 1; + let crown = match rank { + 1 => "πŸ‘‘", + 2 => "πŸ₯ˆ", + 3 => "πŸ₯‰", + _ => "", + }; + let spark = sparkline(&series_for(&r.thing, r.count, &b.events)); + let reason = match (&r.reason, &r.by) { + (Some(why), Some(by)) => format!( + "
      \u{201C}{}\u{201D} \u{2014} {}
      ", + esc(why), + esc(by) + ), + (Some(why), None) => format!("
      \u{201C}{}\u{201D}
      ", esc(why)), + _ => String::new(), + }; + out.push_str(&format!( + "
    1. {crown}
      {}\ +
      {}
      {}
      {spark}{reason}
    2. ", + avatar(&r.thing, "lg"), + esc(&r.thing), + klass(r.count), + signed(r.count), + )); + } + out.push_str("
    "); + out +} + +fn ledger(b: &Board) -> String { + if b.rows.len() <= 3 { + return String::new(); + } + let mut out = String::from("
    the ledger
      "); + for (i, r) in b.rows.iter().enumerate().skip(3) { + let rank = i + 1; + let spark = sparkline(&series_for(&r.thing, r.count, &b.events)); + let reason = match (&r.reason, &r.by) { + (Some(why), Some(by)) => format!( + "\u{201C}{}\u{201D} \u{2014} {}", + esc(why), + esc(by) + ), + (Some(why), None) => format!("\u{201C}{}\u{201D}", esc(why)), + _ => String::new(), + }; + out.push_str(&format!( + "
    1. {rank}{}\ +
      {}
      {reason}
      \ + {spark}{}
    2. ", + avatar(&r.thing, "sm"), + esc(&r.thing), + klass(r.count), + signed(r.count), + )); + } + out.push_str("
    "); + out +} + +fn activity(feed: &[(&str, &Event)], multi: bool) -> String { + let mut out = String::from( + "
    \ + recent activity
    ", + ); + if feed.is_empty() { + out.push_str( + "
    Nothing yet. The next nick++ shows up here.
    ", + ); + return out; + } + out.push_str("
      "); + for (net, e) in feed.iter().take(40) { + let actor = e.by.clone().unwrap_or_else(|| "someone".to_string()); + let dchip = if e.delta >= 0 { "++" } else { "--" }; + let why = match &e.why { + Some(w) => format!("\u{201C}{}\u{201D}", esc(w)), + None => String::new(), + }; + let netlabel = if multi { + format!("{}", esc(net)) + } else { + String::new() + }; + out.push_str(&format!( + "
    • {}
      \ + {} gave {} \ + {dchip}{netlabel}
      {why}\ +
    • ", + klass(e.delta), + avatar(&actor, "sm"), + esc(&actor), + esc(&e.thing), + klass(e.delta), + e.t, + )); + } + out.push_str("
    "); + out +} + +fn givers_panel(feed: &[(&str, &Event)]) -> String { + let givers = top_givers(feed, 8); + let mut out = + String::from("
    top givers
    "); + if givers.is_empty() { + out.push_str( + "
    No one has handed out karma yet. Be the first.
    ", + ); + return out; + } + out.push_str("
      "); + for (i, g) in givers.iter().enumerate() { + out.push_str(&format!( + "
    1. {}{}\ +
      {}
      \ + {} gift{}
      \ + {}
    2. ", + i + 1, + avatar(&g.name, "sm"), + esc(&g.name), + g.gives, + if g.gives == 1 { "" } else { "s" }, + klass(g.points), + signed(g.points), + )); + } + out.push_str("
    "); + out +} + +fn footer(epoch: u64) -> String { + format!( + "
    give karma by saying nick++ in the channel \ + · built by rubot \ + · updated
    " ) } @@ -141,70 +478,154 @@ const HEAD: &str = r##" -karma β€” devtronic +karma Β· devtronic -
    -
    -

    ✨ karma

    -

    community karma, tracked by rubot

    -
    +
    "##; -const SEARCH: &str = r##""##; +const EMPTY: &str = r##"
    No karma yet.
    Say nick++ in the channel and this board comes alive.
    "##; -const EMPTY: &str = r##"
    no karma yet β€” say nick++ in the channel to give some.
    "##; - -const SCRIPT: &str = r##"
    say nick++ in the channel to give karma · rubot · updated
    -
    - - - "##; + +const TAIL: &str = "\n"; diff --git a/tests/karma.rs b/tests/karma.rs index 22ced9a..b3692b7 100644 --- a/tests/karma.rs +++ b/tests/karma.rs @@ -1,5 +1,5 @@ use rustbot::bot::module::{Action, Chat, Command, Module}; -use rustbot::bot::modules::karma::{extract_reason, parse_changes, Karma}; +use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma}; fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { Chat { @@ -166,3 +166,45 @@ fn milestone_announced_at_ten() { assert!(msg.contains("rust reached 10 karma"), "{msg}"); let _ = std::fs::remove_file(&path); } + +#[test] +fn format_event_is_one_json_line() { + let line = format_event(1700000000, "rust", 1, "alice", Some("clean PR")); + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1); + assert!(line.contains("\"t\":1700000000"), "{line}"); + assert!(line.contains("\"o\":\"rust\""), "{line}"); + assert!(line.contains("\"d\":1"), "{line}"); + assert!(line.contains("\"by\":\"alice\""), "{line}"); + assert!(line.contains("\"why\":\"clean PR\""), "{line}"); + // no reason -> no why field + let bare = format_event(1, "docs", -1, "bob", None); + assert!(!bare.contains("why"), "{bare}"); + // quotes in a reason are escaped + let q = format_event(1, "x", 1, "y", Some("he said \"hi\"")); + assert!(q.contains("\\\"hi\\\""), "{q}"); +} + +#[test] +fn on_message_appends_to_the_event_log() { + let path = std::env::temp_dir().join("rubot-test-karma-ev.json"); + let log = std::env::temp_dir().join("rubot-test-karma-ev.events.jsonl"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&log); + let mut k = Karma::with_path(path.clone()); + k.on_message(&chat("alice", "rust++ for the clean PR")); + k.on_message(&chat("bob", "rust-- python++")); + let body = std::fs::read_to_string(&log).expect("event log written"); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines.len(), 3, "one line per bump: {body}"); + assert!(lines[0].contains("\"o\":\"rust\"") && lines[0].contains("\"by\":\"alice\"")); + assert!( + lines[0].contains("\"why\":\"the clean PR\""), + "{}", + lines[0] + ); + // the two-bump message attributes no reason + assert!(!lines[1].contains("why") && !lines[2].contains("why")); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&log); +} diff --git a/tests/karma_web.rs b/tests/karma_web.rs index c9446d5..6412986 100644 --- a/tests/karma_web.rs +++ b/tests/karma_web.rs @@ -1,4 +1,10 @@ -use rustbot::karma_web::{parse_board, render}; +use rustbot::karma_web::{parse_board, parse_events, render}; + +fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board { + let mut b = parse_board(net, store); + b.events = parse_events(log); + b +} #[test] fn parses_flat_number_format() { @@ -6,7 +12,6 @@ fn parses_flat_number_format() { assert_eq!(b.rows.len(), 1); assert_eq!(b.rows[0].thing, "romaka"); assert_eq!(b.rows[0].count, 2); - assert!(b.rows[0].reason.is_none()); } #[test] @@ -25,36 +30,81 @@ fn sorts_descending_by_count() { } #[test] -fn renders_scores_and_reason() { +fn parses_event_log_lines() { + let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\ + {\"t\":200,\"o\":\"rust\",\"d\":-1,\"by\":\"bob\"}\n\ + \n\ + garbage line\n"; + let ev = parse_events(log); + assert_eq!(ev.len(), 2); + assert_eq!(ev[0].thing, "rust"); + assert_eq!(ev[0].delta, 1); + assert_eq!(ev[0].by.as_deref(), Some("alice")); + assert_eq!(ev[0].why.as_deref(), Some("clean PR")); + assert_eq!(ev[1].delta, -1); + assert!(ev[1].why.is_none()); +} + +#[test] +fn renders_hero_podium_and_scores() { let b = parse_board( "libera", r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#, ); let html = render(&[b], 0); - assert!(html.contains("libera"), "{html}"); - assert!(html.contains(">rust<"), "{html}"); - assert!(html.contains(">+5<"), "{html}"); - assert!(html.contains("clean PR"), "{html}"); - assert!(html.contains("alice"), "{html}"); + assert!( + html.contains(">karma<"), + "wordmark missing: has no karma mark" + ); + assert!(html.contains("podium"), "no podium"); + assert!(html.contains(">rust<"), "champion nick missing"); + assert!(html.contains(">+5<"), "score missing"); + assert!(html.contains("clean PR"), "reason missing"); } #[test] -fn escapes_untrusted_irc_input() { - let b = parse_board( +fn renders_activity_and_givers_from_events() { + let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\ + {\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n"; + let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log); + let html = render(&[b], 0); + assert!(html.contains("recent activity"), "no activity section"); + assert!(html.contains("top givers"), "no givers section"); + assert!(html.contains("gave"), "no feed verb"); + // alice made two gifts -> shown in givers panel + assert!( + html.contains("2 gifts"), + "giver count wrong: {}", + html.contains("gift") + ); + assert!( + html.contains("data-t=\"200\""), + "feed sorted/newest-first missing" + ); +} + +#[test] +fn empty_states_are_friendly() { + // no karma at all + let html = render(&[], 0); + assert!(html.contains("No karma yet"), "{html}"); + // karma but no event history yet + let b = parse_board("libera", r#"{"romaka":2}"#); + let html = render(&[b], 0); + assert!(html.contains("Nothing yet"), "empty activity state missing"); + assert!(html.contains("Be the first"), "empty givers state missing"); +} + +#[test] +fn escapes_untrusted_irc_input_everywhere() { + let log = "{\"t\":1,\"o\":\"