feat(#2050): set matrix avatar in the daemon over the live client

This commit is contained in:
damocles 2026-06-27 19:35:21 +02:00 committed by mara
commit c99fa714d9
8 changed files with 118 additions and 210 deletions

View file

@ -61,6 +61,15 @@ let
# cannot drift). Matches the daemon's own built-in default
# (`paths::DEFAULT_HOMESERVER`).
matrixUrlDefault = "http://localhost:8008";
# Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a
# 512x512 PNG so the matrix daemon can upload it as each account's avatar
# over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
# Replaces the old `matrix-avatar-sync` curl oneshot. Only forced when an
# icon is configured — the `HIVE_ICON_PNG` daemon-env entry is gated on
# `hyperhive.icon != null`, so this binding stays lazy when no icon is set.
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out
'';
in
{
# Shared scaffolding for every hyperhive harness container.
@ -406,7 +415,7 @@ in
daemon auto-skips when `<state>/matrix-token` is missing,
and a `systemd.paths` watcher restarts it the moment
hive-c0re provisions the token (same path-trigger shape
as `matrix-avatar-sync`).
as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms,
list_room_members, read_room) to claude via an auto-injected
@ -1433,8 +1442,8 @@ in
};
# Path-trigger sibling: re-fires forge-avatar-sync the moment
# `<state>/forge-token` appears. Mirrors the matrix-avatar-sync
# pattern — on first agent deployment the container boots before
# `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
# token-watcher pattern — on first agent deployment the container boots before
# hive-c0re has provisioned the forge-token, so the service fires
# too early and exits with "no forge-token found". Without this path
# unit, RemainAfterExit=true would prevent systemd from ever
@ -1553,6 +1562,12 @@ in
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
) config.hyperhive.matrixAccounts
);
}
# Rasterized agent icon path for the daemon's avatar sync. Only set
# when an icon is configured; absent → the daemon skips avatar setting
# (hive-matrix-mcp::client::sync_avatar returns early on unset env).
// lib.optionalAttrs (config.hyperhive.icon != null) {
HIVE_ICON_PNG = "${iconPng}";
};
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
@ -1642,166 +1657,6 @@ in
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
};
# Path-trigger sibling: re-fires matrix-avatar-sync the moment a
# token appears - both the hive-internal `matrix-token` and any
# dashboard-provisioned extra `matrix-token-<name>` (so an external
# account logged in after boot gets its avatar without a restart).
# Same first-boot-ordering pattern as hive-matrix-daemon above.
systemd.paths.matrix-avatar-sync = {
description = "trigger matrix-avatar-sync when a matrix token appears";
wantedBy = [ "multi-user.target" ];
# Single `matrix-token*` glob (same as hive-matrix-daemon above) so
# both the hive-internal `matrix-token` and any dashboard-provisioned
# `matrix-token-<name>` re-fire the sync. A list value here is
# unverified for `systemd.paths.*.pathConfig`, and the wildcard
# covers both cases anyway.
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
};
# One-shot: hyperhive.icon → matrix profile avatar (two-step media
# upload + set avatar_url). Shape contract:
# docs/conventions.md::Best-effort oneshot services. Protocol +
# why RemainAfterExit = false:
# docs/persistence.md::matrix-avatar-sync.
systemd.services.matrix-avatar-sync = {
description = "sync agent icon to matrix profile avatar (best-effort)";
wantedBy = [ "multi-user.target" ];
# No `after = [ "tea-login.service" ]` — matrix has no
# equivalent prerequisite; we just need the homeserver up.
serviceConfig = {
Type = "oneshot";
# RemainAfterExit = false so the .path trigger can re-fire
# the unit (see docs/persistence.md::matrix-avatar-sync).
RemainAfterExit = false;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "matrix-avatar-sync";
};
path = [
pkgs.curl
pkgs.coreutils
pkgs.jq
pkgs.librsvg
];
script = ''
ICON=/etc/hyperhive/icon.svg
if [ ! -f "$ICON" ]; then
echo "matrix-avatar-sync: no icon configured; skipping"
exit 0
fi
# Hash-based idempotency: skip the upload if the icon hasn't
# changed since the last successful sync. Every upload mints a
# new mxc:// URI which triggers a profile state event in every
# joined room — uploading the same bytes again produces timeline
# spam without changing the visible avatar. Hash files live in
# $HYPERHIVE_STATE_DIR (survives restart, wiped on purge so
# purge + re-provision gets a fresh upload). One hash file per
# account (the mxc:// URI is homeserver-scoped, so each account
# uploads to its own homeserver independently). Delete to force
# a re-upload.
CURRENT_HASH=$(sha256sum "$ICON" | cut -d' ' -f1)
# Rasterize SVG → PNG ONCE; the same bytes are reused for every
# account (matrix media accepts any image type but we already
# standardise on PNG for the forge sync).
PNG=$(mktemp --suffix=.png)
if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then
echo "matrix-avatar-sync: rsvg-convert failed; skipping"
rm -f "$PNG"
exit 0
fi
# sync_one <token-file> <matrix-url> <hash-suffix> <label>
# Two-step media upload + avatar_url set for a single account.
# Best-effort: every failure path logs + returns 0 so one bad
# account never aborts the others.
sync_one() {
token_file=$1
matrix_url=$2
hash_suffix=$3
label=$4
if [ ! -f "$token_file" ]; then
return 0
fi
hash_file="$HYPERHIVE_STATE_DIR/matrix-avatar-icon-hash$hash_suffix"
if [ -f "$hash_file" ] && [ "$(cat "$hash_file" 2>/dev/null)" = "$CURRENT_HASH" ]; then
echo "matrix-avatar-sync[$label]: icon unchanged (hash matches); skipping"
return 0
fi
token=$(cat "$token_file")
# whoami → user_id. Needed to scope the avatar set call.
# Tolerant of the homeserver being unreachable (`-f` makes
# curl fail on 4xx/5xx; `|| true` swallows the exit).
user_id=$(curl -sf --max-time 5 \
-H "Authorization: Bearer $token" \
"$matrix_url/_matrix/client/v3/account/whoami" 2>/dev/null \
| jq -r '.user_id // empty' || true)
if [ -z "$user_id" ]; then
echo "matrix-avatar-sync[$label]: whoami failed or homeserver unreachable; skipping"
return 0
fi
# Step 1: upload bytes → mxc:// URI.
mxc=$(curl -sf --max-time 10 \
-X POST "$matrix_url/_matrix/media/v3/upload" \
-H "Authorization: Bearer $token" \
-H "Content-Type: image/png" \
--data-binary "@$PNG" 2>/dev/null \
| jq -r '.content_uri // empty' || true)
if [ -z "$mxc" ]; then
echo "matrix-avatar-sync[$label]: media upload failed; skipping"
return 0
fi
# Step 2: set avatar_url on the profile.
payload=$(jq -n --arg url "$mxc" '{avatar_url:$url}')
code=$(curl -s --max-time 10 \
-X PUT "$matrix_url/_matrix/client/v3/profile/$user_id/avatar_url" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload" \
-o /dev/null -w "%{http_code}" 2>/dev/null || true)
if [ "$code" = "200" ]; then
echo "matrix-avatar-sync[$label]: avatar set on $user_id"
# Persist hash so subsequent runs skip the upload when the
# icon hasn't changed.
echo "$CURRENT_HASH" > "$hash_file"
else
echo "matrix-avatar-sync[$label]: avatar PUT returned HTTP $code skipping (non-fatal)"
fi
}
# Hive-internal `main` account: bare `matrix-token`, written by
# `hive-c0re::matrix::ensure_user_for` to the agent's
# bind-mounted state dir. $HYPERHIVE_STATE_DIR is set system-wide
# by the meta flake (systemd.globalEnvironment) to
# `/agents/<name>/state`. Local tuwunel reachable on the shared
# host netns at the default matrix-spec port.
sync_one "$HYPERHIVE_STATE_DIR/matrix-token" "http://localhost:8008" "" "main"
# Dashboard-provisioned extra accounts: each is a
# `matrix-token-<name>` file plus a `matrix-account-<name>.json`
# sidecar carrying its (possibly external) homeserver. Mirrors
# hive-matrix-mcp::accounts::discover_token_accounts — a token
# without a sidecar is skipped because the homeserver is unknown.
for token_file in "$HYPERHIVE_STATE_DIR"/matrix-token-*; do
[ -f "$token_file" ] || continue
name=''${token_file##*/matrix-token-}
[ -n "$name" ] || continue
sidecar="$HYPERHIVE_STATE_DIR/matrix-account-$name.json"
if [ ! -f "$sidecar" ]; then
echo "matrix-avatar-sync[$name]: no homeserver sidecar; skipping"
continue
fi
homeserver=$(jq -r '.homeserver // empty' "$sidecar" 2>/dev/null || true)
if [ -z "$homeserver" ]; then
echo "matrix-avatar-sync[$name]: empty homeserver in sidecar; skipping"
continue
fi
sync_one "$token_file" "$homeserver" "-$name" "$name"
done
rm -f "$PNG"
'';
};
# Write declared dashboardLinks to the state dir so hive-c0re can
# read them without accessing the container's /etc/ from the host.
# Best-effort oneshot (always exit 0):