Words that count, besides your name

A message highlighted and notified only if it carried this account's own
nick, tagged the room, or was a DM. Every other client here lets you add
words - a project, a surname, a name you also answer to - and without it
the choice was your nick or nothing.

The words live in the daemon because this is where the decision is made:
one flag feeds the stored column the mentions inbox reads, the event the
client styles from, and the notification. Words kept in a window could
colour a line and could never notify anybody.

Two lists, in highlights.toml beside voice.toml and dcc.toml: a global one
and one per account, added together at match time, since a word that means
you on one network is somebody's ordinary vocabulary on another. A side
table rather than a sixth field on five account structs - display_name is
stored that way and costs a five-branch fan-out twice over - so removing an
account now has to forget its words, which removeAccount does.

Whole words, case-insensitively, by the same boundary test the local rename
already uses: "cat" must not match "catalogue" or a keyword lights up half
a channel and is turned off within the hour.

Verified live: a message carrying a keyword arrived highlighted and a
message carrying it inside a longer word did not.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 18:01:08 -04:00
co-authored by Claude Opus 5
parent c6c6a07177
commit 9577b117c5
11 changed files with 363 additions and 1 deletions
+15
View File
@@ -778,6 +778,9 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
// Filled in by Runtime::list_accounts, which is the only place that
// can see a live connection.
current_nick: String::new(),
// Filled in by Runtime::list_accounts, which can see the store these
// live in; the constructors here only ever see one account's config.
highlight_keywords: Vec::new(),
ssl: a.ssl,
has_password: a.password.as_deref().is_some_and(|p| !p.is_empty()),
avatar_url: None,
@@ -807,6 +810,9 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
// Filled in by Runtime::list_accounts, which can see the store these
// live in; the constructors here only ever see one account's config.
highlight_keywords: Vec::new(),
ssl: true,
has_password: !a.token.is_empty(),
avatar_url: a.avatar_url.clone(),
@@ -835,6 +841,9 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
// Filled in by Runtime::list_accounts, which can see the store these
// live in; the constructors here only ever see one account's config.
highlight_keywords: Vec::new(),
ssl: true,
has_password: !a.password.is_empty(),
avatar_url: None,
@@ -872,6 +881,9 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
// Filled in by Runtime::list_accounts, which can see the store these
// live in; the constructors here only ever see one account's config.
highlight_keywords: Vec::new(),
ssl: true,
has_password: a.token.as_deref().is_some_and(|t| !t.is_empty()),
avatar_url: None,
@@ -904,6 +916,9 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
// Filled in by Runtime::list_accounts, which can see the store these
// live in; the constructors here only ever see one account's config.
highlight_keywords: Vec::new(),
ssl: true,
has_password: !a.password.is_empty(),
avatar_url: None,
+1
View File
@@ -1920,6 +1920,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(dir.join("highlights.toml"))),
}
}
+1
View File
@@ -254,6 +254,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config = crate::accounts::MatrixAccountConfig {
homeserver_url: homeserver.to_string(),
+5
View File
@@ -3773,6 +3773,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config = MatrixAccountConfig {
@@ -3888,6 +3889,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config = MatrixAccountConfig {
@@ -3997,6 +3999,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config_a = MatrixAccountConfig {
@@ -4151,6 +4154,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config = MatrixAccountConfig {
@@ -4304,6 +4308,7 @@ mod tests {
voice: std::sync::Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: std::sync::Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: std::sync::Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config = MatrixAccountConfig {
+1
View File
@@ -536,6 +536,7 @@ mod tests {
voice: Arc::new(crate::backend::discord_voice::VoiceState::new()),
voice_prefs: Arc::new(crate::backend::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
dcc_prefs: Arc::new(crate::backend::irc_dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
highlights: Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
};
let config = MatrixAccountConfig {
homeserver_url: homeserver.to_string(),
+183
View File
@@ -0,0 +1,183 @@
//! The words that make a message worth being told about, besides your name.
//!
//! Kept here rather than in the client because this is where the decision is
//! made: `Runtime::record_message` marks a message highlighted, and that one
//! flag feeds the stored column the mentions inbox reads, the event the client
//! styles from, and the notification. A keyword held in a window's own
//! preferences could colour a line and could never notify anybody, which is
//! the whole point of having one.
//!
//! Two lists. The global one is for words that mean the same wherever they are
//! said - a surname, a project - and the per-account ones for words that only
//! matter on one network, which is most of them: a name that reaches you on
//! IRC is somebody else's ordinary vocabulary on Discord.
//!
//! A side table keyed by account id rather than a field on each account,
//! because there are five account structs and `display_name` already shows
//! what that costs - a five-branch fan-out in the setter and another in the
//! getter. The price of this shape is that removing an account has to remove
//! its words too; `removeAccount` does that.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
/// The file's contents, at `~/.config/nobilis/highlights.toml`.
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct HighlightPrefs {
/// Words that count on every account.
#[serde(default)]
pub global: Vec<String>,
/// Words that count on one account, by account id.
#[serde(default)]
pub accounts: BTreeMap<String, Vec<String>>,
}
pub struct HighlightStore {
path: PathBuf,
inner: Mutex<HighlightPrefs>,
}
impl HighlightStore {
pub fn open(path: PathBuf) -> Self {
let inner = std::fs::read_to_string(&path)
.ok()
.and_then(|t| toml::from_str(&t).ok())
// A file that will not parse must not stop the daemon starting.
// No keywords is a working configuration - it is what everybody
// has until they add one.
.unwrap_or_default();
Self { path, inner: Mutex::new(inner) }
}
/// The words that count on every account.
pub fn global(&self) -> Vec<String> {
self.inner.lock().unwrap().global.clone()
}
/// The words this one account has of its own.
pub fn account(&self, account_id: &str) -> Vec<String> {
self.inner.lock().unwrap().accounts.get(account_id).cloned().unwrap_or_default()
}
/// Everything that counts in a conversation on this account: the global
/// words and its own, together, since a message only has to match one.
pub fn for_account(&self, account_id: &str) -> Vec<String> {
let prefs = self.inner.lock().unwrap();
let mut out = prefs.global.clone();
if let Some(own) = prefs.accounts.get(account_id) {
out.extend(own.iter().cloned());
}
out
}
pub fn set_global(&self, words: Vec<String>) {
self.update(|prefs| prefs.global = tidy(words));
}
/// Sets one account's words, and forgets the account entirely when the
/// list is emptied - an empty list and no list mean the same thing, and
/// only one of them leaves a row behind.
pub fn set_account(&self, account_id: &str, words: Vec<String>) {
self.update(|prefs| {
let words = tidy(words);
if words.is_empty() {
prefs.accounts.remove(account_id);
} else {
prefs.accounts.insert(account_id.to_string(), words);
}
});
}
/// Drops an account's words, for an account that is being removed.
pub fn forget(&self, account_id: &str) {
self.update(|prefs| {
prefs.accounts.remove(account_id);
});
}
/// Applies a change and writes the file out, through a temporary file so
/// an interrupted write cannot leave a half-written one behind.
fn update(&self, edit: impl FnOnce(&mut HighlightPrefs)) {
let mut prefs = self.inner.lock().unwrap();
edit(&mut prefs);
let Ok(text) = toml::to_string_pretty(&*prefs) else { return };
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = self.path.with_extension("toml.tmp");
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
/// What a typed list actually contains once the typing is taken out: no blank
/// entries, no surrounding spaces, nothing said twice.
fn tidy(words: Vec<String>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for word in words {
let word = word.trim().to_string();
if word.is_empty() || out.iter().any(|w| w.eq_ignore_ascii_case(&word)) {
continue;
}
out.push(word);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_typed_list_loses_its_blanks_and_its_repeats() {
let words = tidy(vec![" moho ".into(), "".into(), "MOHO".into(), "nobilis".into(), " ".into()]);
assert_eq!(words, vec!["moho".to_string(), "nobilis".to_string()]);
}
#[test]
fn an_accounts_words_are_its_own_plus_everybodys() {
let dir = std::env::temp_dir().join(format!("moho-highlights-{}", std::process::id()));
let store = HighlightStore::open(dir.join("highlights.toml"));
store.set_global(vec!["salastil".into()]);
store.set_account("irc:coreirc", vec!["moho".into()]);
let mut both = store.for_account("irc:coreirc");
both.sort();
assert_eq!(both, vec!["moho".to_string(), "salastil".to_string()]);
// Another account gets the global words and nobody else's.
assert_eq!(store.for_account("discord:1"), vec!["salastil".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn emptying_a_list_removes_it_rather_than_storing_nothing() {
let dir = std::env::temp_dir().join(format!("moho-highlights-empty-{}", std::process::id()));
let store = HighlightStore::open(dir.join("highlights.toml"));
store.set_account("irc:coreirc", vec!["moho".into()]);
store.set_account("irc:coreirc", vec![]);
assert!(store.inner.lock().unwrap().accounts.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// The words have to survive a restart, which is the only thing the file
/// is for.
#[test]
fn what_was_written_is_read_back() {
let dir = std::env::temp_dir().join(format!("moho-highlights-rw-{}", std::process::id()));
let path = dir.join("highlights.toml");
{
let store = HighlightStore::open(path.clone());
store.set_global(vec!["salastil".into()]);
store.set_account("kick:me", vec!["raid".into(), "gifted".into()]);
}
let reopened = HighlightStore::open(path);
assert_eq!(reopened.global(), vec!["salastil".to_string()]);
assert_eq!(reopened.account("kick:me"), vec!["raid".to_string(), "gifted".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
}
+2
View File
@@ -1,6 +1,7 @@
mod accounts;
mod backend;
mod events;
mod highlights;
mod ipc;
mod model;
mod net;
@@ -210,6 +211,7 @@ async fn main() -> Result<()> {
voice: Arc::new(backend::discord_voice::VoiceState::new()),
voice_prefs: Arc::new(backend::audio::VoicePrefsStore::open(opts.data_dir.join("voice.toml"))),
dcc_prefs: Arc::new(backend::irc_dcc::DccPrefsStore::open(opts.data_dir.join("dcc.toml"))),
highlights: Arc::new(highlights::HighlightStore::open(opts.data_dir.join("highlights.toml"))),
};
// Reconnect every saved account, same as
+5
View File
@@ -33,6 +33,11 @@ pub struct Account {
/// file on the machine the daemon is running on rather than this one.
#[serde(rename = "hasSaslCertificate")]
pub has_sasl_certificate: bool,
/// The words that make a message here worth being told about, besides
/// this account's own name. Reported with the account so the field that
/// edits them can be filled in without a second question.
#[serde(rename = "highlightKeywords", default)]
pub highlight_keywords: Vec<String>,
/// The nick this account is actually using right now.
///
/// Not the same as the configured one: a session that landed on the alt
+36
View File
@@ -384,6 +384,11 @@ pub async fn dispatch(
// Its guilds would otherwise stay in the rail with nothing
// under them.
state.runtime.clear_buffer_groups_for_account(id);
// Its highlight words live in their own file rather than on
// the account, so removing the account has to say so - or
// they would come back with an account added under the same
// name later.
state.highlights.forget(id);
match state.accounts.remove(id) {
Ok(true) => (Some(ok_node()), None),
Ok(false) => (None, Some("no such account".to_string())),
@@ -456,6 +461,37 @@ pub async fn dispatch(
Some(id) => account_mutation_result(state.accounts.set_nickserv_password(id, p_str(params, "password", ""))),
},
// The words that count besides your name.
//
// Global ones and one account's own are the same call with and
// without an account: they are the same kind of thing, and a message
// only has to match one of them.
"getHighlightKeywords" => (
Some(serde_json::json!({ "global": state.highlights.global() })),
None,
),
"setHighlightKeywords" => {
let Some(words) = params.get("keywords").and_then(|v| v.as_array()) else {
return (None, Some("setHighlightKeywords requires \"keywords\"".to_string()));
};
let words: Vec<String> = words.iter().filter_map(|w| w.as_str().map(str::to_string)).collect();
match p_str_opt(params, "accountId").filter(|id| !id.is_empty()) {
Some(account_id) => {
if state.runtime.list_accounts(state).iter().all(|a| a.id != account_id) {
return (None, Some("no such account".to_string()));
}
// No event: the account carries its own words, and the
// client re-reads the accounts after a change it made -
// which is how every other per-account setting here
// finds its way back onto the screen.
state.highlights.set_account(account_id, words);
}
None => state.highlights.set_global(words),
}
(Some(serde_json::json!({ "global": state.highlights.global() })), None)
}
"setAccountDisplayName" => match p_str_opt(params, "accountId") {
None => (None, Some("no such account".to_string())),
Some(id) => {
+110 -1
View File
@@ -665,6 +665,48 @@ fn mentions_the_room(service: &str, body: &str) -> bool {
words.iter().any(|word| lower.contains(word))
}
/// Whether a message contains one of the words somebody asked to be told
/// about.
///
/// Whole words, case-insensitively: "cat" does not match "catalogue", and a
/// keyword that matched inside longer words would light up half a busy
/// channel and be turned off within the hour. The boundary test is the same
/// one `rename_own_mentions` uses below, because a name is exactly the sort
/// of thing people put in this list and it has to behave the same way in both
/// places.
///
/// A keyword of several words matches as the phrase, with a boundary at each
/// end - "release day" is a thing worth being told about and is two words.
fn matches_keyword(body: &str, keywords: &[String]) -> bool {
if keywords.is_empty() || body.is_empty() {
return false;
}
// The same "not a boundary" test as the rename below: nick punctuation
// counts as part of a word, or `some_word` would match `some`.
let joined = |c: char| c.is_alphanumeric() || "_-[]{}\\^|`".contains(c);
let lower_body = body.to_lowercase();
keywords.iter().any(|keyword| {
let keyword = keyword.trim().to_lowercase();
if keyword.is_empty() {
return false;
}
let mut at = 0;
while let Some(hit) = lower_body[at..].find(&keyword) {
let start = at + hit;
let end = start + keyword.len();
let before = lower_body[..start].chars().next_back();
let after = lower_body[end..].chars().next();
if !before.is_some_and(joined) && !after.is_some_and(joined) {
return true;
}
// Inside a longer word. Carry on past this one rather than
// giving up: the same word may appear again on its own.
at = end;
}
false
})
}
/// A mention of you, written the way you asked to be called.
///
/// The rename is a local one - no service is told about it - so a message
@@ -846,6 +888,10 @@ impl Runtime {
if let Some(nick) = self.irc_current_nick(&account.id).or_else(|| self.own_identity(&account.id)) {
account.current_nick = nick;
}
// This account's own words, not the global ones: the field this
// fills edits one account's list, and showing everybody's words
// in it would mean saving them onto this account as well.
account.highlight_keywords = state.highlights.account(&account.id);
}
out
}
@@ -3028,7 +3074,11 @@ impl Runtime {
// addressed. Only where the service acts on the word: "@everyone"
// on IRC is somebody typing a phrase, and highlighting it would be
// this client inventing a mention the network does not have.
|| (from != own_nick && mentions_the_room(model::service_of(account_id), body));
|| (from != own_nick && mentions_the_room(model::service_of(account_id), body))
// And the words somebody asked to be told about, which is the
// same question asked about a different list - your name is
// simply the one word everybody has.
|| (from != own_nick && matches_keyword(body, &state.highlights.for_account(account_id)));
let is_own = !own_nick.is_empty() && from == own_nick;
// Everything above this line reads the real nick, and everything
// below it reads what you asked to be called. The order is the whole
@@ -3443,6 +3493,65 @@ mod invite_tests {
}
}
#[cfg(test)]
mod keyword_tests {
use super::matches_keyword;
fn words(list: &[&str]) -> Vec<String> {
list.iter().map(|w| w.to_string()).collect()
}
#[test]
fn a_word_on_its_own_counts() {
assert!(matches_keyword("has anyone tried moho yet", &words(&["moho"])));
assert!(matches_keyword("moho", &words(&["moho"])));
assert!(matches_keyword("what about moho?", &words(&["moho"])));
}
/// The rule that decides whether this is usable at all: a keyword that
/// matched inside longer words would light up half a busy channel.
#[test]
fn a_word_inside_another_word_does_not() {
assert!(!matches_keyword("the catalogue is out", &words(&["cat"])));
assert!(!matches_keyword("mohogany table", &words(&["moho"])));
assert!(!matches_keyword("some_word", &words(&["some"])));
}
/// And the same word said properly later in the message still counts.
#[test]
fn one_bad_match_does_not_hide_a_good_one() {
assert!(matches_keyword("the catalogue, and also the cat", &words(&["cat"])));
}
#[test]
fn case_is_not_part_of_the_question() {
assert!(matches_keyword("MOHO shipped", &words(&["moho"])));
assert!(matches_keyword("moho shipped", &words(&["MoHo"])));
}
#[test]
fn a_keyword_can_be_a_phrase() {
assert!(matches_keyword("is it release day yet", &words(&["release day"])));
assert!(!matches_keyword("release something", &words(&["release day"])));
}
#[test]
fn nothing_asked_for_never_matches() {
assert!(!matches_keyword("anything at all", &[]));
assert!(!matches_keyword("anything at all", &words(&["", " "])));
assert!(!matches_keyword("", &words(&["moho"])));
}
/// Punctuation is not part of a word, so a keyword still counts when it
/// is the thing being asked about.
#[test]
fn punctuation_around_a_word_is_not_part_of_it() {
assert!(matches_keyword("(moho)", &words(&["moho"])));
assert!(matches_keyword("moho: any news", &words(&["moho"])));
assert!(matches_keyword("@moho", &words(&["moho"])));
}
}
#[cfg(test)]
mod rename_tests {
use super::*;
+4
View File
@@ -31,4 +31,8 @@ pub struct AppState {
/// so a deliberate mute is still in force after a restart.
pub voice_prefs: Arc<crate::backend::audio::VoicePrefsStore>,
pub dcc_prefs: Arc<crate::backend::irc_dcc::DccPrefsStore>,
/// The words that make a message worth being told about, besides your
/// name. Here rather than in a window because this is where a message is
/// decided to be a highlight - see highlights.rs.
pub highlights: Arc<crate::highlights::HighlightStore>,
}