bot contesta con e2ee
This commit is contained in:
@@ -120,7 +120,7 @@ matrix:
|
||||
device_id: "ASSISTANTBOT01"
|
||||
|
||||
encryption:
|
||||
enabled: false
|
||||
enabled: true
|
||||
store_path: "./data/crypto/"
|
||||
trust_mode: tofu
|
||||
|
||||
|
||||
+44
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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=$!
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1988090
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user