bot contesta con e2ee

This commit is contained in:
2026-03-04 00:59:10 +00:00
parent bd8e1432e5
commit 396fc39b90
12 changed files with 316 additions and 46 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ matrix:
device_id: "ASSISTANTBOT01"
encryption:
enabled: false
enabled: true
store_path: "./data/crypto/"
trust_mode: tofu
+44 -6
View File
@@ -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
}
+12
View File
@@ -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{})
}
+49 -16
View File
@@ -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 ""
+26 -5
View File
@@ -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"
+1 -1
View File
@@ -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=$!
+14 -6
View File
@@ -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
)
+26
View File
@@ -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=
+15 -7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
1988090
+41
View File
@@ -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
+86 -4
View File
@@ -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 {