From 396fc39b90e6d38a9a0698620c80ff49345c8a90 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Wed, 4 Mar 2026 00:59:10 +0000 Subject: [PATCH] bot contesta con e2ee --- agents/assistant/config.yaml | 2 +- agents/runtime.go | 50 +++++++++++++++++--- cmd/launcher/sqlite.go | 12 +++++ dev-scripts/new-agent.sh | 65 +++++++++++++++++++------- dev-scripts/register.sh | 31 +++++++++++-- dev-scripts/start.sh | 2 +- go.mod | 20 +++++--- go.sum | 26 +++++++++++ pkg/message/parse.go | 22 ++++++--- run/assistant-bot.pid | 1 + shell/matrix/client.go | 41 ++++++++++++++++ shell/matrix/listener.go | 90 ++++++++++++++++++++++++++++++++++-- 12 files changed, 316 insertions(+), 46 deletions(-) create mode 100644 cmd/launcher/sqlite.go create mode 100644 run/assistant-bot.pid diff --git a/agents/assistant/config.yaml b/agents/assistant/config.yaml index ef46cb4..d1206bc 100644 --- a/agents/assistant/config.yaml +++ b/agents/assistant/config.yaml @@ -120,7 +120,7 @@ matrix: device_id: "ASSISTANTBOT01" encryption: - enabled: false + enabled: true store_path: "./data/crypto/" trust_mode: tofu diff --git a/agents/runtime.go b/agents/runtime.go index aa8d325..b6bce92 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -4,7 +4,9 @@ package agents import ( "context" "fmt" + "io" "log/slog" + "path/filepath" "maunium.net/go/mautrix/event" @@ -28,6 +30,7 @@ type Agent struct { runner *effects.Runner listener *matrix.Listener logger *slog.Logger + cryptoStore io.Closer // non-nil when E2EE is enabled; closed on shutdown } // New assembles an Agent from its config, rules, and logger. @@ -38,6 +41,18 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* return nil, fmt.Errorf("matrix client: %w", err) } + // E2EE — initialize before the sync loop starts + var cryptoStore io.Closer + if cfg.Matrix.Encryption.Enabled { + storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db") + logger.Info("initializing e2ee", "store", storePath) + cryptoStore, err = matrixClient.InitCrypto(context.Background(), storePath, cfg.Agent.ID) + if err != nil { + return nil, fmt.Errorf("e2ee init: %w", err) + } + logger.Info("e2ee ready") + } + // SSH executor sshExec := ssh.NewExecutor(cfg.SSH) @@ -61,12 +76,13 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* runner := effects.NewRunner(matrixClient, sshExec, logger) a := &Agent{ - cfg: cfg, - rules: rules, - llm: llmFunc, - matrix: matrixClient, - runner: runner, - logger: logger, + cfg: cfg, + rules: rules, + llm: llmFunc, + matrix: matrixClient, + runner: runner, + logger: logger, + cryptoStore: cryptoStore, } // Matrix event listener @@ -77,21 +93,33 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* // Run starts the agent sync loop. Blocks until ctx is cancelled. func (a *Agent) Run(ctx context.Context) error { + if a.cryptoStore != nil { + defer a.cryptoStore.Close() + } a.logger.Info("agent starting", "id", a.cfg.Agent.ID, "name", a.cfg.Agent.Name) return a.listener.Run(ctx) } // handleEvent is called by the matrix Listener for each filtered incoming event. func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event) { + a.logger.Debug("handling event", + "sender", msgCtx.SenderID, + "is_dm", msgCtx.IsDirectMsg, + "is_mention", msgCtx.IsMention, + "command", msgCtx.Command, + ) + if a.cfg.Personality.Behavior.TypingIndicator { _ = a.matrix.SendTyping(ctx, evt.RoomID.String(), true) defer a.matrix.SendTyping(ctx, evt.RoomID.String(), false) } actions := decision.Evaluate(msgCtx, a.rules) + a.logger.Debug("rules evaluated", "matched_actions", len(actions)) // If no rules matched and the message mentions the bot or is a DM, use LLM. if len(actions) == 0 && (msgCtx.IsMention || msgCtx.IsDirectMsg) { + a.logger.Debug("no rules matched, falling back to LLM") actions = []decision.Action{{ Kind: decision.ActionKindLLM, LLM: &decision.LLMAction{ContextKey: msgCtx.RoomID}, @@ -99,6 +127,10 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, } if len(actions) == 0 { + a.logger.Debug("no actions, ignoring message", + "is_dm", msgCtx.IsDirectMsg, + "is_mention", msgCtx.IsMention, + ) return } @@ -128,6 +160,10 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, } func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext) (string, error) { + a.logger.Debug("calling LLM", + "model", a.cfg.LLM.Primary.Model, + "provider", a.cfg.LLM.Primary.Provider, + ) req := coretypes.CompletionRequest{ Model: a.cfg.LLM.Primary.Model, MaxTokens: a.cfg.LLM.Primary.MaxTokens, @@ -139,7 +175,9 @@ func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext) (str } resp, err := a.llm(ctx, req) if err != nil { + a.logger.Error("LLM call failed", "model", req.Model, "err", err) return "", err } + a.logger.Debug("LLM responded", "content_len", len(resp.Content)) return resp.Content, nil } diff --git a/cmd/launcher/sqlite.go b/cmd/launcher/sqlite.go new file mode 100644 index 0000000..20be13d --- /dev/null +++ b/cmd/launcher/sqlite.go @@ -0,0 +1,12 @@ +package main + +import ( + "database/sql" + + moderncsqlite "modernc.org/sqlite" +) + +func init() { + // mautrix dbutil opens sqlite as "sqlite3"; register the pure-Go driver under that name. + sql.Register("sqlite3", &moderncsqlite.Driver{}) +} diff --git a/dev-scripts/new-agent.sh b/dev-scripts/new-agent.sh index e0a486f..309b9a9 100755 --- a/dev-scripts/new-agent.sh +++ b/dev-scripts/new-agent.sh @@ -146,7 +146,7 @@ tools: matrix: homeserver: "${MATRIX_HOMESERVER}" user_id: "@$ID:${MATRIX_SERVER_NAME}" - access_token_env: MATRIX_TOKEN_$(echo "$ID" | tr '[:lower:]-' '[:upper:]_') + access_token_env: MATRIX_TOKEN_$(echo "$ID" | tr '[:lower:]-' '[:upper:]_' | sed 's/_BOT$//') device_id: "$(echo "$ID" | tr '[:lower:]-' '[:upper:]_')01" encryption: @@ -338,21 +338,54 @@ MD ok "Scaffold creado en $DIR/" echo "" -# ── Pasos siguientes ────────────────────────────────────────────────────── -echo -e "${YLW}Quedan 2 pasos manuales:${RST}" +# ── Actualizar cmd/launcher/main.go ─────────────────────────────────────── +LAUNCHER="cmd/launcher/main.go" + +if grep -q "\"$ID\":" "$LAUNCHER" 2>/dev/null; then + warn "$ID ya está en rulesRegistry de $LAUNCHER — saltando" +else + TAB=$'\t' + IMPORT_LINE="${TAB}${PACKAGE}agent \"github.com/enmanuel/agents/agents/$ID\"" + REGISTRY_LINE="${TAB}\"$ID\": ${PACKAGE}agent.Rules," + + # Insertar import después del último import agents/agents/* + if awk -v new_import="$IMPORT_LINE" ' + { + lines[NR] = $0 + if ($0 ~ /[a-z_]+agent "github\.com\/enmanuel\/agents\/agents\/[^"]+"/) + last_import = NR + } + END { + if (!last_import) { for (i=1;i<=NR;i++) print lines[i]; exit 1 } + for (i = 1; i <= NR; i++) { + print lines[i] + if (i == last_import) print new_import + } + } + ' "$LAUNCHER" > /tmp/_launcher_tmp; then + mv /tmp/_launcher_tmp "$LAUNCHER" + ok "Import añadido en $LAUNCHER" + else + warn "No se pudo insertar el import automáticamente — añádelo manualmente:" + echo -e " ${GRN}${IMPORT_LINE}${RST}" + fi + + # Insertar entry en rulesRegistry antes del cierre } + if awk -v new_entry="$REGISTRY_LINE" ' + /^var rulesRegistry/ { in_reg = 1 } + in_reg && /^\}/ { print new_entry; in_reg = 0 } + { print } + ' "$LAUNCHER" > /tmp/_launcher_tmp; then + mv /tmp/_launcher_tmp "$LAUNCHER" + ok "Registry entry añadida en $LAUNCHER" + else + warn "No se pudo insertar el registry entry — añádelo manualmente:" + echo -e " ${GRN}${REGISTRY_LINE}${RST}" + fi +fi + echo "" -echo -e " ${BLU}1.${RST} Añade una línea en ${BLU}cmd/launcher/main.go${RST}:" +echo -e "${YLW}Queda 1 paso:${RST} registrar el bot en Matrix y añadir su token a .env:" echo "" -echo -e ' import (' -echo -e " ${GRN}${PACKAGE}agent \"github.com/enmanuel/agents/agents/$ID\"${RST}" -echo -e ' )' -echo "" -echo -e ' var rulesRegistry = map[string]func() []decision.Rule{' -echo -e " ${GRN}\"$ID\": ${PACKAGE}agent.Rules,${RST}" -echo -e ' ...' -echo -e ' }' -echo "" -echo -e " ${BLU}2.${RST} Registra el bot en Matrix y añade el token a .env:" -echo "" -echo -e " ${DIM}./dev-scripts/register.sh $ID \"$DISPLAYNAME\"${RST}" +echo -e " ${DIM}./dev-scripts/register.sh $ID \"$DISPLAYNAME\"${RST}" echo "" diff --git a/dev-scripts/register.sh b/dev-scripts/register.sh index 57fb71c..2a35f80 100755 --- a/dev-scripts/register.sh +++ b/dev-scripts/register.sh @@ -19,7 +19,7 @@ need_arg "${1:-}" USERNAME="$1" DISPLAYNAME="${2:-$USERNAME}" -ENV_VAR="${3:-MATRIX_TOKEN_$(echo "$USERNAME" | tr '[:lower:]-' '[:upper:]_')}" +ENV_VAR="${3:-MATRIX_TOKEN_$(echo "$USERNAME" | tr '[:lower:]-' '[:upper:]_' | sed 's/_BOT$//')}" [[ -n "${MATRIX_ADMIN_TOKEN:-}" ]] || fail "MATRIX_ADMIN_TOKEN no está en .env" [[ -n "${MATRIX_HOMESERVER:-}" ]] || fail "MATRIX_HOMESERVER no está en .env" @@ -27,12 +27,33 @@ ENV_VAR="${3:-MATRIX_TOKEN_$(echo "$USERNAME" | tr '[:lower:]-' '[:upper:]_')}" info "Registrando @${USERNAME}:${MATRIX_SERVER_NAME:-$MATRIX_HOMESERVER}..." echo "" -"$GO" run ./cmd/register \ +# Ejecutar cmd/register y capturar su output completo +OUTPUT=$("$GO" run ./cmd/register \ --homeserver "$MATRIX_HOMESERVER" \ --username "$USERNAME" \ --displayname "$DISPLAYNAME" \ - --env-var "$ENV_VAR" + --env-var "$ENV_VAR" 2>&1) || fail "cmd/register falló:\n$OUTPUT" + +echo "$OUTPUT" +echo "" + +# Extraer la línea ENV_VAR=token del output +TOKEN_LINE=$(echo "$OUTPUT" | grep "^${ENV_VAR}=") +[[ -n "$TOKEN_LINE" ]] || fail "No se encontró '${ENV_VAR}=' en el output de cmd/register" + +TOKEN=$(echo "$TOKEN_LINE" | cut -d= -f2-) +[[ -n "$TOKEN" ]] || fail "Token vacío para $ENV_VAR" + +# Actualizar .env — reemplazar si ya existe, añadir si no +if grep -q "^${ENV_VAR}=" .env; then + awk -v key="$ENV_VAR" -v val="$TOKEN" \ + 'index($0, key "=") == 1 { print key "=" val; next } { print }' \ + .env > /tmp/_env_tmp && mv /tmp/_env_tmp .env + ok "$ENV_VAR actualizado en .env" +else + printf '\n%s=%s\n' "$ENV_VAR" "$TOKEN" >> .env + ok "$ENV_VAR añadido a .env" +fi echo "" -dim " Copia las líneas de arriba a tu .env y luego corre:" -dim " ./dev-scripts/start.sh $USERNAME" +dim " Arranca el bot con: ./dev-scripts/start.sh $USERNAME" diff --git a/dev-scripts/start.sh b/dev-scripts/start.sh index 85eaca6..f8cac5b 100755 --- a/dev-scripts/start.sh +++ b/dev-scripts/start.sh @@ -18,7 +18,7 @@ start_agent() { info "Iniciando $id..." # Lanza el launcher en background, desacoplado del terminal - nohup "$GO" run ./cmd/launcher -c "$cfg" \ + nohup "$GO" run -tags goolm ./cmd/launcher -c "$cfg" --log-level "${LOG_LEVEL:-info}" \ >> "$log" 2>&1 & local pid=$! diff --git a/go.mod b/go.mod index 2634c54..0a2a26e 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/enmanuel/agents -go 1.23.0 - -toolchain go1.23.5 +go 1.24.0 require ( github.com/mark3labs/mcp-go v0.44.1 @@ -17,12 +15,17 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/zerolog v1.33.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.5 // indirect @@ -33,7 +36,12 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mau.fi/util v0.8.1 // indirect - golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.21.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect ) diff --git a/go.sum b/go.sum index 16a03c1..4a26e42 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -33,9 +35,19 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -71,6 +83,8 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -78,11 +92,23 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA= maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/pkg/message/parse.go b/pkg/message/parse.go index daf3298..918eaa2 100644 --- a/pkg/message/parse.go +++ b/pkg/message/parse.go @@ -9,8 +9,9 @@ import ( // ParseOptions configures how messages are parsed. type ParseOptions struct { - CommandPrefix string // e.g. "!" - BotUserID string // for mention detection + CommandPrefix string // e.g. "!" + BotUserID string // for mention detection, e.g. "@bot:server" + MentionedUserIDs []string // pre-extracted from m.mentions event field (modern Matrix spec) } // Parse converts a raw Matrix message body into a structured MessageContext. Pure. @@ -23,11 +24,18 @@ func Parse(body, senderID, roomID string, powerLevel int, isDM bool, opts ParseO IsDirectMsg: isDM, } - // Detect mention - if opts.BotUserID != "" && strings.Contains(body, opts.BotUserID) { - ctx.IsMention = true - body = strings.ReplaceAll(body, opts.BotUserID, "") - body = strings.TrimSpace(body) + // Detect mention: check m.mentions list first (modern Matrix spec). + if opts.BotUserID != "" { + for _, uid := range opts.MentionedUserIDs { + if uid == opts.BotUserID { + ctx.IsMention = true + break + } + } + // Fallback: check if full user ID appears in the plain text body. + if !ctx.IsMention && strings.Contains(body, opts.BotUserID) { + ctx.IsMention = true + } } // Parse command diff --git a/run/assistant-bot.pid b/run/assistant-bot.pid new file mode 100644 index 0000000..4b332a6 --- /dev/null +++ b/run/assistant-bot.pid @@ -0,0 +1 @@ +1988090 diff --git a/shell/matrix/client.go b/shell/matrix/client.go index adcfb4b..631a6c6 100644 --- a/shell/matrix/client.go +++ b/shell/matrix/client.go @@ -3,10 +3,14 @@ package matrix import ( "context" + "crypto/sha256" "fmt" + "io" "os" + "path/filepath" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -39,7 +43,44 @@ func New(cfg config.MatrixCfg) (*Client, error) { return &Client{raw: raw, cfg: cfg}, nil } +// InitCrypto sets up end-to-end encryption using the mautrix cryptohelper. +// storePath is the SQLite file path for crypto material (e.g. "./data/crypto/crypto.db"). +// agentID is used to namespace the crypto state so multiple agents can share a database. +// Returns an io.Closer that must be called on agent shutdown to flush the crypto store. +func (c *Client) InitCrypto(ctx context.Context, storePath, agentID string) (io.Closer, error) { + // Resolve the actual device ID from the server — the value in config may differ + // from what the registration process assigned. + whoami, err := c.raw.Whoami(ctx) + if err != nil { + return nil, fmt.Errorf("whoami for crypto init: %w", err) + } + c.raw.DeviceID = whoami.DeviceID + + // Derive a stable pickle key from the access token. + // If the token changes (bot re-registered), delete the crypto store to reset. + sum := sha256.Sum256([]byte(c.raw.AccessToken)) + pickleKey := sum[:] + + if err := os.MkdirAll(filepath.Dir(storePath), 0700); err != nil { + return nil, fmt.Errorf("create crypto store dir: %w", err) + } + + helper, err := cryptohelper.NewCryptoHelper(c.raw, pickleKey, storePath) + if err != nil { + return nil, fmt.Errorf("create crypto helper: %w", err) + } + helper.DBAccountID = agentID + + if err := helper.Init(ctx); err != nil { + return nil, fmt.Errorf("init e2ee: %w", err) + } + + c.raw.Crypto = helper + return helper, nil +} + // SendText sends a plain-text message to a room. +// If the room has E2EE enabled and crypto is initialized, the message is encrypted automatically. func (c *Client) SendText(ctx context.Context, roomID, text string) error { _, err := c.raw.SendText(ctx, id.RoomID(roomID), text) return err diff --git a/shell/matrix/listener.go b/shell/matrix/listener.go index 773caab..a161918 100644 --- a/shell/matrix/listener.go +++ b/shell/matrix/listener.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "strings" + "sync" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" @@ -23,6 +24,8 @@ type Listener struct { cfg config.MatrixCfg handler EventHandler logger *slog.Logger + dmCache map[id.RoomID]bool + mu sync.RWMutex } // NewListener creates a Listener for the given client. @@ -32,6 +35,7 @@ func NewListener(client *Client, cfg config.MatrixCfg, handler EventHandler, log cfg: cfg, handler: handler, logger: logger, + dmCache: make(map[id.RoomID]bool), } } @@ -39,24 +43,55 @@ func NewListener(client *Client, cfg config.MatrixCfg, handler EventHandler, log func (l *Listener) Run(ctx context.Context) error { syncer := l.client.raw.Syncer.(*mautrix.DefaultSyncer) + // Auto-join rooms when invited. Without this, the bot stays in "invited" + // state and never receives m.room.message events. + syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { + if evt.GetStateKey() != l.cfg.UserID { + return + } + if evt.Content.AsMember().Membership != event.MembershipInvite { + return + } + l.logger.Info("received room invite, joining", "room", evt.RoomID, "inviter", evt.Sender) + if _, err := l.client.raw.JoinRoom(ctx, evt.RoomID.String(), "", nil); err != nil { + l.logger.Error("failed to auto-join room", "room", evt.RoomID, "err", err) + } else { + l.logger.Info("auto-joined room", "room", evt.RoomID) + } + }) + syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + l.logger.Debug("event received", "sender", evt.Sender, "room", evt.RoomID) + if !l.shouldHandle(evt) { return } body, ok := evt.Content.Raw["body"].(string) if !ok || body == "" { + l.logger.Debug("event has no body, skipping", "room", evt.RoomID) return } - // Determine power level (simplified — full impl fetches from room state) powerLevel := 0 + isDM := l.checkIsDM(ctx, evt.RoomID) - isDM := false // TODO: detect DMs via room member count + // Extract m.mentions for reliable mention detection (modern Matrix spec). + var mentionedUsers []string + if mentions, ok := evt.Content.Raw["m.mentions"].(map[string]any); ok { + if userIDs, ok := mentions["user_ids"].([]any); ok { + for _, uid := range userIDs { + if s, ok := uid.(string); ok { + mentionedUsers = append(mentionedUsers, s) + } + } + } + } opts := message.ParseOptions{ - CommandPrefix: l.cfg.Filters.CommandPrefix, - BotUserID: l.cfg.UserID, + CommandPrefix: l.cfg.Filters.CommandPrefix, + BotUserID: l.cfg.UserID, + MentionedUserIDs: mentionedUsers, } msgCtx := message.Parse( @@ -68,6 +103,15 @@ func (l *Listener) Run(ctx context.Context) error { opts, ) + l.logger.Debug("message parsed", + "sender", msgCtx.SenderID, + "room", msgCtx.RoomID, + "is_dm", msgCtx.IsDirectMsg, + "is_mention", msgCtx.IsMention, + "command", msgCtx.Command, + "content_preview", truncate(msgCtx.Content, 80), + ) + go l.handler(ctx, msgCtx, evt) }) @@ -75,23 +119,51 @@ func (l *Listener) Run(ctx context.Context) error { return l.client.raw.SyncWithContext(ctx) } +// checkIsDM returns true if the room has exactly 2 joined members. +// The result is cached so the API is only called once per room. +func (l *Listener) checkIsDM(ctx context.Context, roomID id.RoomID) bool { + l.mu.RLock() + if v, ok := l.dmCache[roomID]; ok { + l.mu.RUnlock() + return v + } + l.mu.RUnlock() + + members, err := l.client.raw.JoinedMembers(ctx, roomID) + if err != nil { + l.logger.Warn("could not fetch room members for DM check", "room", roomID, "err", err) + return false + } + + isDM := len(members.Joined) == 2 + + l.mu.Lock() + l.dmCache[roomID] = isDM + l.mu.Unlock() + + return isDM +} + // shouldHandle applies the configured filters to an event. func (l *Listener) shouldHandle(evt *event.Event) bool { f := l.cfg.Filters // Don't handle our own messages if evt.Sender == id.UserID(l.cfg.UserID) { + l.logger.Debug("ignoring own message", "room", evt.RoomID) return false } // Ignore bots if f.IgnoreBots && strings.HasSuffix(evt.Sender.String(), "-bot:"+serverName(l.cfg.UserID)) { + l.logger.Debug("ignoring bot sender", "sender", evt.Sender) return false } // Ignore specific users for _, u := range f.IgnoreUsers { if evt.Sender.String() == u { + l.logger.Debug("ignoring blocked user", "sender", evt.Sender) return false } } @@ -106,6 +178,7 @@ func (l *Listener) shouldHandle(evt *event.Event) bool { } } if !allowed { + l.logger.Debug("ignoring event: room not in listen list", "room", evt.RoomID) return false } } @@ -113,6 +186,15 @@ func (l *Listener) shouldHandle(evt *event.Event) bool { return true } +// truncate shortens s to at most n runes for log preview. +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} + func serverName(userID string) string { parts := strings.SplitN(userID, ":", 2) if len(parts) == 2 {