Attention Required: notification rules with effects for Omarchy

Rules watch every notification for words and apps and answer with an
effect: nudge, flash, banner, airplane, confetti, blink, sound, focus,
or a command. Silenced notifications that match a rule are let through.
Settings popup behind a bar bell, rules kept in one JSON file.
This commit is contained in:
2026-09-08 16:55:43 +01:00
commit 401b46c886
26 changed files with 3727 additions and 0 deletions
Executable
BIN
View File
Binary file not shown.
Executable
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# Streams every notification sent on this desktop, one JSON object per line:
# {"source": "popup" | "silenced", "key", "app", "summary", "body",
# "appIcon", "urgency", "replacesId", "timestamp"}
#
# The source of truth is the session bus: every notification is a Notify
# call on org.freedesktop.Notifications, watched with `busctl monitor`. That
# sees everything, including what Omarchy's notification service drops on
# the floor while Do Not Disturb is on (a bare notify-send, for one), so a
# rule can still let it through. "silenced" means Do Not Disturb was on when
# it arrived.
#
# Without busctl, the files Omarchy's notification service writes under
# ~/.local/state/omarchy/notifications/ are watched instead: one per popup,
# moved into history/ when it leaves the screen; a silenced one is written
# straight into history/.
set -uo pipefail
command -v jq >/dev/null 2>&1 || { echo "jq is not installed" >&2; exit 1; }
dnd_state() {
local state
state=$(omarchy-shell notifications isDnd 2>/dev/null || true)
[[ $state == on ]] && echo silenced || echo popup
}
# ----------------------------------------------------------------- D-Bus
if [[ ${AR_SOURCE:-bus} == bus ]] && command -v busctl >/dev/null 2>&1; then
exec 3< <(busctl --user monitor --json=short \
--match "type='method_call',interface='org.freedesktop.Notifications',member='Notify'" 2>/dev/null)
monitor=$!
trap 'kill "$monitor" 2>/dev/null' EXIT INT TERM
# Anything on the bus can send a notification, of any size. A message
# over 256 KB is dropped unread, and what is passed on is cut to what a
# rule could ever need: no field longer than a few thousand characters.
while IFS= read -r line <&3; do
[[ $line == *'"member":"Notify"'* ]] || continue
(( ${#line} > 262144 )) && continue
source=$(dnd_state)
jq -c --arg source "$source" '
def clip($n): if type == "string" then .[0:$n] else "" end;
select(.member == "Notify" and (.payload.data | type) == "array" and (.payload.data | length) >= 5)
| .payload.data as $d
| (if ($d[6] | type) == "object" then $d[6] else {} end) as $hints
| (if ($d[1] | type) == "number" then $d[1] else 0 end) as $replaces
| {
source: $source,
key: (if $replaces != 0 then "\($d[0] | clip(200)):replaces:\($replaces)" else "\(."timestamp-realtime" // 0)-\(.cookie // 0)" end),
app: ($d[0] | clip(200)),
summary: ($d[3] | clip(2000)),
body: ($d[4] | clip(8000)),
appIcon: (if ($d[2] | clip(1000)) != "" then ($d[2] | clip(1000)) else ($hints["image-path"].data // "" | clip(1000)) end),
urgency: ($hints.urgency.data // 1),
replacesId: $replaces,
timestamp: ((."timestamp-realtime" // 0) / 1000 | floor)
}' <<<"$line" 2>/dev/null
done
exit 0
fi
# ----------------------------------------------------------------- files
state_home=${XDG_STATE_HOME:-$HOME/.local/state}
popup_dir=${AR_SRC_DIR:-$state_home/omarchy/notifications}
history_dir=$popup_dir/history
mkdir -p "$popup_dir" "$history_dir" || exit 1
emit() {
local source=$1 path=$2 name key
name=$(basename "$path")
[[ $name == *.json ]] || return 0
key=${name%.json}
[[ -s $path ]] || return 0
# The service writes small files; anything bigger is not one of them.
(( $(stat -c %s "$path" 2>/dev/null || echo 0) > 262144 )) && return 0
jq -c --arg source "$source" --arg key "$key" '
def clip($n): if type == "string" then .[0:$n] else "" end;
{ source: $source, key: $key, app: (.app | clip(200)), summary: (.summary | clip(2000)),
body: (.body | clip(8000)), appIcon: (.appIcon | clip(1000)),
urgency: (if (.urgency | type) == "number" then .urgency else 1 end), timestamp: (.timestamp // 0) }' "$path" 2>/dev/null
}
if command -v inotifywait >/dev/null 2>&1; then
echo "busctl not found; watching the notification files instead" >&2
inotifywait -m -q -e close_write -e moved_to --format '%e|%w|%f' "$popup_dir" "$history_dir" |
while IFS='|' read -r events dir file; do
dir=${dir%/}
if [[ $dir == "$history_dir" ]]; then
[[ $events == *MOVED_TO* ]] && continue
emit silenced "$dir/$file"
else
emit popup "$dir/$file"
fi
done
exit 0
fi
echo "neither busctl nor inotifywait found; polling the notification files every half second" >&2
declare -A seen
for f in "$popup_dir"/*.json "$history_dir"/*.json; do
[[ -e $f ]] && seen[$f]=$(stat -c %Y "$f" 2>/dev/null || echo 0)
done
while sleep 0.5; do
for f in "$popup_dir"/*.json; do
[[ -e $f ]] || continue
m=$(stat -c %Y "$f" 2>/dev/null || echo 0)
[[ ${seen[$f]:-} == "$m" ]] && continue
seen[$f]=$m
emit popup "$f"
done
for f in "$history_dir"/*.json; do
[[ -e $f ]] || continue
[[ -n ${seen[$f]:-} ]] && continue
seen[$f]=$(stat -c %Y "$f" 2>/dev/null || echo 0)
[[ -n ${seen[$popup_dir/$(basename "$f")]:-} ]] && continue
emit silenced "$f"
done
done
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
#
# attention-required: rules that turn a notification into an effect.
#
# attention-required list the rules, as a table
# attention-required add NAME [options] add a rule
# --words a,b,c words to look for (any of them; /regex/ allowed)
# --apps x,y only notifications from these apps (substring of the app name; default: any app)
# --effects e1,e2 nudge | flash | sound | command | your own (default: nudge)
# --all every word must be present, not any
# --cooldown SECONDS at most one firing per rule in this many seconds (default 3)
# attention-required remove NAME
# attention-required enable NAME | disable NAME
# attention-required test EFFECT run one effect now, e.g. `test nudge`
# or `test '{"type":"flash","color":"urgent"}'`
# attention-required simulate APP SUMMARY [BODY] run a made-up notification past the rules
# attention-required settings open the settings popup (also: click the bell in the bar)
# attention-required set EFFECT OPTION VALUE change a default, e.g. `set nudge intensity 6`
# attention-required toggle | on | off pause or resume every effect (also: right-click the bell)
# attention-required status what the service has seen and fired
# attention-required reload re-read rules.json (it is also watched)
# attention-required edit open rules.json in $EDITOR
# attention-required path print where rules.json is
# attention-required export [FILE] copy the rules to FILE (or print them) to keep or share
# attention-required import FILE replace the rules with FILE's (the old file is kept as a .bak)
set -euo pipefail
config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/attention-required
rules=$config_dir/rules.json
here=$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)
die() { echo "attention-required: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; }
need jq
ipc() {
if command -v omarchy-shell >/dev/null 2>&1; then
omarchy-shell attention-required "$@"
else
quickshell ipc -p "${OMARCHY_PATH:-/usr/share/omarchy}/shell" call attention-required "$@"
fi
}
ipc_panel() {
if command -v omarchy-shell >/dev/null 2>&1; then
omarchy-shell alanfortlink.attention-required "$@"
else
quickshell ipc -p "${OMARCHY_PATH:-/usr/share/omarchy}/shell" call alanfortlink.attention-required "$@"
fi
}
ensure_rules() {
mkdir -p "$config_dir/effects"
[[ -e $rules ]] || cp "$here/rules.example.json" "$rules"
}
# Written in place rather than moved into place, so the shell's file watch
# sees the change without a restart.
write_rules() {
local tmp
tmp=$(mktemp "$config_dir/.rules.XXXXXX")
cat > "$tmp"
jq . "$tmp" >/dev/null || { rm -f "$tmp"; die "refusing to write invalid JSON"; }
cat "$tmp" > "$rules"
rm -f "$tmp"
}
split_list() { jq -cn --arg s "$1" '$s | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))'; }
have_rule() { jq -e --arg n "$1" '.rules // [] | any(.name == $n)' "$rules" >/dev/null; }
cmd_list() {
ensure_rules
jq -r '
(.rules // []) | if length == 0 then "no rules" else
(["NAME", "ON", "WORDS", "APPS", "EFFECTS", "COOLDOWN"] | @tsv),
(.[] | [
.name,
(if .enabled == false then "off" else "on" end),
((.words // []) | join(", ")),
((.apps // []) | if length == 0 then "any" else join(", ") end),
((.effects // ["nudge"]) | map(if type == "string" then . else .type end) | join(", ")),
(.cooldown // 3 | tostring)
] | @tsv) end' "$rules" | column -t -s $'\t'
}
cmd_add() {
local name=${1:-}; shift || true
[[ -n $name ]] || die "usage: add NAME --words a,b [--apps x,y] [--effects nudge,flash] [--all] [--cooldown N]"
local words='[]' apps='[]' effects='["nudge"]' match=any cooldown=3
while (($#)); do
case $1 in
--words) words=$(split_list "${2:-}"); shift 2 ;;
--apps) apps=$(split_list "${2:-}"); shift 2 ;;
--effects) effects=$(split_list "${2:-}"); shift 2 ;;
--all) match=all; shift ;;
--cooldown) cooldown=${2:-3}; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
ensure_rules
have_rule "$name" && die "a rule named '$name' exists; remove it first"
jq --arg name "$name" --argjson words "$words" --argjson apps "$apps" --argjson effects "$effects" \
--arg match "$match" --argjson cooldown "$cooldown" '
.rules = ((.rules // []) + [{name: $name, words: $words, apps: $apps, effects: $effects, match: $match, cooldown: $cooldown}])
| .version = (.version // 1)' "$rules" | write_rules
echo "added '$name'"
ipc reload >/dev/null 2>&1 || true
}
cmd_remove() {
local name=${1:-}
[[ -n $name ]] || die "usage: remove NAME"
ensure_rules
have_rule "$name" || die "no rule named '$name'"
jq --arg n "$name" '.rules = ((.rules // []) | map(select(.name != $n)))' "$rules" | write_rules
echo "removed '$name'"
ipc reload >/dev/null 2>&1 || true
}
cmd_toggle() {
local enabled=$1 name=${2:-}
[[ -n $name ]] || die "usage: enable|disable NAME"
ensure_rules
have_rule "$name" || die "no rule named '$name'"
jq --arg n "$name" --argjson e "$enabled" '.rules = ((.rules // []) | map(if .name == $n then .enabled = $e else . end))' "$rules" | write_rules
echo "'$name' is now $([[ $enabled == true ]] && echo on || echo off)"
ipc reload >/dev/null 2>&1 || true
}
case ${1:-help} in
list|ls) cmd_list ;;
add) shift; cmd_add "$@" ;;
remove|rm) shift; cmd_remove "$@" ;;
enable) shift; cmd_toggle true "$@" ;;
disable) shift; cmd_toggle false "$@" ;;
test) [[ -n ${2:-} ]] || die "usage: test EFFECT"; ipc test "$2" ;;
simulate|sim) [[ -n ${3:-} ]] || die "usage: simulate APP SUMMARY [BODY]"; ipc simulate "$2" "$3" "${4:-}" ;;
settings) ipc_panel toggle ;;
set) [[ -n ${4:-} ]] || die "usage: set <effect> <option> <value> e.g. set nudge intensity 6"; ipc setDefault "$2" "$3" "$4" ;;
toggle) ipc toggle ;;
on|resume) ipc setEnabled true ;;
off|pause) ipc setEnabled false ;;
status) ipc state | jq . ;;
rules) ipc rules | jq . ;;
reload) ipc reload ;;
edit) ensure_rules; "${EDITOR:-nvim}" "$rules" ;;
path) echo "$rules" ;;
export)
ensure_rules
if [[ -n ${2:-} ]]; then cp "$rules" "$2" && echo "saved to $2"; else cat "$rules"; fi ;;
import)
[[ -n ${2:-} && -r $2 ]] || die "usage: import FILE"
jq -e '(.rules | type) == "array"' "$2" >/dev/null 2>&1 || die "$2 is not a rules file (needs a \"rules\" list)"
ensure_rules
backup="$rules.bak.$(date +%s)"
cp "$rules" "$backup"
jq '.version = (.version // 1)' "$2" | write_rules
echo "imported $2 ($(jq '.rules | length' "$rules") rules); the previous file is $backup"
ipc reload >/dev/null 2>&1 || true ;;
help|-h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' ;;
*) die "unknown command '$1' (try: attention-required help)" ;;
esac