diff --git a/Makefile b/Makefile index 411cd9dc5..41aa9f51c 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COM # Go variables GO?=CGO_ENABLED=0 go WEB_GO?=$(GO) -GOFLAGS?=-v -tags stdjson +GOFLAGS?=-v -tags goolm,stdjson # Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). # diff --git a/go.mod b/go.mod index bce41d0d3..e9ef37e98 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/mattn/go-sqlite3 v1.14.34 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 @@ -31,6 +32,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 + go.mau.fi/util v0.9.7 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.41.0 @@ -77,7 +79,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.7 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/text v0.35.0 // indirect modernc.org/libc v1.67.6 // indirect diff --git a/pkg/channels/matrix/init.go b/pkg/channels/matrix/init.go index 6677f855e..4d6ad45a7 100644 --- a/pkg/channels/matrix/init.go +++ b/pkg/channels/matrix/init.go @@ -1,6 +1,8 @@ package matrix import ( + "path/filepath" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -8,6 +10,11 @@ import ( func init() { channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMatrixChannel(cfg.Channels.Matrix, b) + matrixCfg := cfg.Channels.Matrix + cryptoDatabasePath := matrixCfg.CryptoDatabasePath + if cryptoDatabasePath == "" { + cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix") + } + return NewMatrixChannel(matrixCfg, b, cryptoDatabasePath) }) } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 98c607d0b..07f9c80fa 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -2,6 +2,7 @@ package matrix import ( "context" + "database/sql" "fmt" "html" "io" @@ -17,9 +18,12 @@ import ( "github.com/gomarkdown/markdown" mdhtml "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + _ "modernc.org/sqlite" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -30,6 +34,9 @@ import ( ) const ( + sqliteDriver = "sqlite" + dbName = "store.db" + typingRefreshInterval = 20 * time.Second typingServerTTL = 30 * time.Second roomKindCacheTTL = 5 * time.Minute @@ -181,9 +188,12 @@ type MatrixChannel struct { roomKindCache *roomKindCache localpartMentionR *regexp.Regexp + + cryptoHelper *cryptohelper.CryptoHelper + cryptoDbPath string } -func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) { +func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus, cryptoDatabasePath string) (*MatrixChannel, error) { homeserver := strings.TrimSpace(cfg.Homeserver) userID := strings.TrimSpace(cfg.UserID) accessToken := strings.TrimSpace(cfg.AccessToken()) @@ -230,6 +240,7 @@ func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*Mat roomKindCache: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL), localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, + cryptoDbPath: cryptoDatabasePath, }, nil } @@ -239,7 +250,17 @@ func (c *MatrixChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) c.startTime = time.Now() + // Initialize crypto helper if database and passphrase are configured + if c.cryptoDbPath != "" && c.config.CryptoPassphrase != "" { + if err := c.initCrypto(ctx); err != nil { + logger.WarnCF("matrix", "Failed to initialize crypto, continuing without encryption support", map[string]any{ + "error": err.Error(), + }) + } + } + c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent) + c.syncer.OnEventType(event.EventEncrypted, c.handleMessageEvent) c.syncer.OnEventType(event.StateMember, c.handleMemberEvent) c.SetRunning(true) @@ -266,10 +287,84 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { } c.stopTypingSessions(ctx) + // Close crypto helper if initialized + if c.cryptoHelper != nil { + c.cryptoHelper.Close() + c.cryptoHelper = nil + c.client.Crypto = nil + } + logger.InfoC("matrix", "Matrix channel stopped") return nil } +func (c *MatrixChannel) initCrypto(ctx context.Context) error { + logger.InfoC("matrix", "Initializing crypto helper") + + // Ensure the crypto database directory exists + if err := os.MkdirAll(c.cryptoDbPath, 0o700); err != nil { + return fmt.Errorf("create crypto database directory: %w", err) + } + + // Create database with sqlite driver (modernc.org/sqlite) + dbPath := filepath.Join(c.cryptoDbPath, dbName) + connStr := "file:" + dbPath + "?_foreign_keys=on" + + db, err := sql.Open(sqliteDriver, connStr) + if err != nil { + return fmt.Errorf("open crypto database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + // Execute PRAGMA statements + // This is equivalent to the "sqlite3-fk-wal" dialect used by cryptohelper + pragmaStmts := []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + } + for _, pragma := range pragmaStmts { + if _, err = db.ExecContext(ctx, pragma); err != nil { + _ = db.Close() + return fmt.Errorf("execute %s: %w", pragma, err) + } + } + + // Wrap with dbutil for dialect support + wrappedDB, err := dbutil.NewWithDB(db, sqliteDriver) + if err != nil { + _ = db.Close() + return fmt.Errorf("wrap database: %w", err) + } + + cryptoHelper, err := cryptohelper.NewCryptoHelper(c.client, []byte(c.config.CryptoPassphrase), wrappedDB) + if err != nil { + return fmt.Errorf("create crypto helper: %w", err) + } + + if c.client.DeviceID == "" { + resp, err := c.client.Whoami(ctx) + if err != nil { + _ = db.Close() + return fmt.Errorf("get device ID via whoami: %w", err) + } + c.client.DeviceID = resp.DeviceID + } + + if err = cryptoHelper.Init(ctx); err != nil { + cryptoHelper.Close() + return fmt.Errorf("init crypto helper: %w", err) + } + + c.client.Crypto = cryptoHelper + c.cryptoHelper = cryptoHelper + + logger.InfoC("matrix", "Crypto helper initialized successfully") + return nil +} + func markdownToHTML(md string) string { p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) @@ -548,9 +643,26 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event return } - msgEvt := evt.Content.AsMessage() - if msgEvt == nil { - return + var msgEvt *event.MessageEventContent + switch evt.Type { + case event.EventMessage: + // When crypto is enabled, events marked WasEncrypted=true are + // re-dispatched by c.cryptoHelper after decryption and will be + // processed again in the EventEncrypted branch. Skip to avoid duplication. + if c.client.Crypto != nil && evt.Mautrix.WasEncrypted { + return + } + + msgEvt = evt.Content.AsMessage() + if msgEvt == nil || msgEvt.MsgType == "" { + return + } + case event.EventEncrypted: + var ok bool + msgEvt, ok = c.decryptEvent(ctx, evt) + if !ok { + return + } } // Ignore edits. @@ -642,6 +754,36 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event ) } +// decryptEvent decrypts an encrypted event and returns the decrypted message event content. +// It returns the decrypted content and a boolean indicating whether decryption was successful. +func (c *MatrixChannel) decryptEvent(ctx context.Context, evt *event.Event) (*event.MessageEventContent, bool) { + if c.client.Crypto == nil { + logger.DebugCF("matrix", "Received encrypted message but crypto is not enabled", map[string]any{ + "room_id": evt.RoomID.String(), + }) + return nil, false + } + + decrypted, err := c.client.Crypto.Decrypt(ctx, evt) + if err != nil { + logger.WarnCF("matrix", "Failed to decrypt message", map[string]any{ + "room_id": evt.RoomID.String(), + "error": err.Error(), + }) + return nil, false + } + + if decrypted.Type != event.EventMessage { + logger.DebugCF("matrix", "Decrypted event is not a message event", map[string]any{ + "room_id": evt.RoomID.String(), + "type": decrypted.Type.String(), + }) + return nil, false + } + + return decrypted.Content.AsMessage(), true +} + func (c *MatrixChannel) extractInboundContent( ctx context.Context, msgEvt *event.MessageEventContent, diff --git a/pkg/config/config.go b/pkg/config/config.go index 84e1ab61a..52fe2400a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -603,6 +603,8 @@ type MatrixConfig struct { Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` secDirty bool + CryptoDatabasePath string `json:"crypto_database_path,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_DATABASE_PATH"` + CryptoPassphrase string `json:"crypto_passphrase,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_PASSPHRASE"` } // AccessToken returns the Matrix access token