mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
The lazy store embedded appservice.StateStore, but the client's store is also type-asserted to crypto.StateStore -- a wider interface -- and NewCryptoHelper refuses to start when that assertion fails. Embedding the narrower interface dropped GetEncryptionEvent and FindSharedRooms from the concrete type, so the bot died on startup with "the client state store must implement crypto.StateStore". Embed both, assert both at compile time, and resolve GetEncryptionEvent lazily like IsEncrypted since the crypto machine reads megolm rotation settings through it.
298 lines
12 KiB
Go
298 lines
12 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/appservice"
|
|
"maunium.net/go/mautrix/crypto/cryptohelper"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// presenceHeartbeatInterval is how often the appservice re-asserts its online
|
|
// presence. In masdevice mode the /sync long-poll refreshed presence implicitly;
|
|
// appservice mode has no /sync, so we push presence explicitly. Synapse ties
|
|
// "online" to recent activity and force-offlines a non-syncing user ~30s after its
|
|
// last update (SYNC_ONLINE_TIMEOUT). A slower tick makes the bot FLAP (green right
|
|
// after each PUT, then offline until the next) — empirically observed at 60s. So we
|
|
// tick well under 30s to hold it continuously green. A hard crash stops the
|
|
// heartbeat and Synapse offlines the bot within ~30s on its own.
|
|
const presenceHeartbeatInterval = 20 * time.Second
|
|
|
|
// Waits applied when an inbound event arrives before its megolm session does.
|
|
// The short wait covers the common race (keys moments behind the event); only
|
|
// after it lapses do we spend an m.room_key_request and wait the long one. The
|
|
// budget bounds the whole recovery so a permanently-unreadable event can't pin a
|
|
// goroutine forever. Mirrors cryptohelper's own 3s/22s ladder.
|
|
const (
|
|
initialSessionWait = 3 * time.Second
|
|
extendedSessionWait = 22 * time.Second
|
|
sessionRecoveryBudget = initialSessionWait + extendedSessionWait + 30*time.Second
|
|
)
|
|
|
|
// cryptoToDeviceTypes are the to-device event types the crypto machine must see
|
|
// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode
|
|
// the cryptohelper gets these automatically; under the appservice transaction
|
|
// model we route each one to mach.HandleToDeviceEvent ourselves.
|
|
var cryptoToDeviceTypes = []event.Type{
|
|
event.ToDeviceEncrypted,
|
|
event.ToDeviceRoomKey,
|
|
event.ToDeviceForwardedRoomKey,
|
|
event.ToDeviceRoomKeyRequest,
|
|
event.ToDeviceRoomKeyWithheld,
|
|
event.ToDeviceOrgMatrixRoomKeyWithheld,
|
|
event.ToDeviceSecretRequest,
|
|
event.ToDeviceSecretSend,
|
|
event.ToDeviceDummy,
|
|
}
|
|
|
|
// newAppserviceSession builds the appservice-mode Session: as_token auth, a
|
|
// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor
|
|
// fed by Synapse's transaction pushes (in place of /sync). The bot is an
|
|
// appservice user — Synapse forbids AS users from /sync, so all events, plus the
|
|
// E2EE extensions (to-device / device lists / OTK counts), arrive over the
|
|
// transaction API instead.
|
|
func newAppserviceSession(cfg Config) (*Session, error) {
|
|
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
if cfg.UserID == "" {
|
|
return nil, fmt.Errorf("BOT_USER_ID is required")
|
|
}
|
|
if cfg.RegistrationPath == "" {
|
|
return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode")
|
|
}
|
|
if cfg.HomeserverDomain == "" {
|
|
return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)")
|
|
}
|
|
if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) {
|
|
return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
reg, err := appservice.LoadRegistration(cfg.RegistrationPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err)
|
|
}
|
|
// Gate for to-device delivery: handleTransaction only pumps to-device events
|
|
// when this is set (appservice/http.go). Force it on regardless of the yaml so
|
|
// E2EE key exchange can't silently break on a missing field.
|
|
reg.EphemeralEvents = true
|
|
|
|
as, err := appservice.CreateFull(appservice.CreateOpts{
|
|
Registration: reg,
|
|
HomeserverDomain: cfg.HomeserverDomain,
|
|
HomeserverURL: cfg.Homeserver,
|
|
HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create appservice: %w", err)
|
|
}
|
|
// Surface the HTTP listener + transaction logs (default is a silent Nop).
|
|
as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger().
|
|
Level(zerolog.InfoLevel)
|
|
|
|
userID := id.UserID(cfg.UserID)
|
|
if as.BotMXID() != userID {
|
|
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
|
|
}
|
|
|
|
// Resolve room state (is it encrypted, who is in it) from the server on first
|
|
// use, since there is no /sync to backfill it. Must be installed before the
|
|
// first BotClient() call: makeClient copies as.StateStore into the client, and
|
|
// caches the client.
|
|
inner, ok := as.StateStore.(innerStateStore)
|
|
if !ok {
|
|
return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore)
|
|
}
|
|
store := newLazyStateStore(inner)
|
|
as.StateStore = store
|
|
|
|
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
|
|
store.client = client
|
|
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
|
|
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
|
|
// Syncer (we drive the crypto machine from transactions, not /sync). The param
|
|
// is only emitted once DeviceID is non-empty (url.go), so setting it now is a
|
|
// no-op for the whoami/device-create calls below; CreateDeviceMSC4190 re-sets it.
|
|
client.SetAppServiceDeviceID = true
|
|
|
|
// Validate the token + identity before we start listening.
|
|
whoami, err := client.Whoami(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
|
|
}
|
|
if whoami.UserID != userID {
|
|
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
|
}
|
|
slog.Info("appservice token valid", "user_id", whoami.UserID)
|
|
|
|
// ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ----
|
|
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
|
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("init crypto helper: %w", err)
|
|
}
|
|
// MSC4190: the appservice creates/refreshes its own device via PUT /devices
|
|
// instead of the UIA-gated login. The crypto store persists the device ID, so
|
|
// restarts reuse it. LoginAs carries only the display name (never calls /login
|
|
// in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync
|
|
// handlers — we drive the crypto machine from transactions below instead.
|
|
ch.MSC4190 = true
|
|
ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName}
|
|
if err := ch.Init(ctx); err != nil {
|
|
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
|
|
}
|
|
client.Crypto = ch
|
|
|
|
// ---- Event processor: replicate the cryptohelper's /sync wiring against
|
|
// the appservice transaction channels ----
|
|
ep := appservice.NewEventProcessor(as)
|
|
mach := ch.Machine()
|
|
|
|
// Best-effort cross-signing bootstrap so the bot's device shows as verified to
|
|
// users who trust its master key. Best-effort: under MAS the key upload may be
|
|
// refused, which we log and ignore — E2EE still functions without it.
|
|
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
|
|
|
|
// Crypto plumbing that /sync would otherwise carry:
|
|
ep.OnOTK(mach.HandleOTKCounts)
|
|
ep.OnDeviceList(mach.HandleDeviceLists)
|
|
for _, t := range cryptoToDeviceTypes {
|
|
ep.On(t, mach.HandleToDeviceEvent)
|
|
}
|
|
|
|
// Keep the client's StateStore current so outgoing sends know which rooms are
|
|
// encrypted and who to share keys with (no /sync backfill here), and let the
|
|
// crypto machine track membership for key sharing. PrependHandler so state is
|
|
// updated before app handlers (auto-join/moderation) run for the same event.
|
|
ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) {
|
|
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
|
|
})
|
|
ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
|
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
|
|
mach.HandleMemberEvent(ctx, evt)
|
|
})
|
|
|
|
// recoverSession handles an event whose megolm session we don't have yet. The
|
|
// keys are often merely in flight (a to-device m.room_key racing the room
|
|
// event), so wait briefly; if they never land, ask the sender to re-share and
|
|
// wait longer. Only once that fails is the event genuinely unreadable.
|
|
//
|
|
// This duplicates cryptohelper.HandleEncrypted's wait/request ladder on
|
|
// purpose: that path is gated on a /sync token being present in the context
|
|
// (mautrix.SyncTokenContextKey), which appservice transactions never carry, so
|
|
// wiring ch.ASEventProcessor would still leave us dropping these events.
|
|
//
|
|
// Runs detached from the transaction context, which is cancelled as soon as we
|
|
// ACK the transaction — long before the keys could arrive.
|
|
recoverSession := func(evt *event.Event) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), sessionRecoveryBudget)
|
|
defer cancel()
|
|
|
|
content := evt.Content.AsEncrypted()
|
|
got := ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWait)
|
|
if !got {
|
|
ch.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
|
|
got = ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWait)
|
|
}
|
|
if !got {
|
|
slog.Warn("appservice: gave up decrypting event, no room key",
|
|
"room", evt.RoomID, "event", evt.ID, "session", content.SessionID)
|
|
return
|
|
}
|
|
|
|
decrypted, err := ch.Decrypt(ctx, evt)
|
|
if err != nil {
|
|
slog.Warn("appservice: failed to decrypt event after key request",
|
|
"room", evt.RoomID, "event", evt.ID, "err", err)
|
|
return
|
|
}
|
|
slog.Debug("appservice: recovered event after key request", "room", evt.RoomID, "event", evt.ID)
|
|
ep.Dispatch(ctx, decrypted)
|
|
}
|
|
|
|
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
|
|
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
|
|
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
|
|
decrypted, err := ch.Decrypt(ctx, evt)
|
|
if err != nil {
|
|
if errors.Is(err, cryptohelper.NoSessionFound) {
|
|
go recoverSession(evt)
|
|
return
|
|
}
|
|
slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err)
|
|
return
|
|
}
|
|
ep.Dispatch(ctx, decrypted)
|
|
})
|
|
|
|
return &Session{
|
|
Client: client,
|
|
mode: "appservice",
|
|
as: as,
|
|
ep: ep,
|
|
}, nil
|
|
}
|
|
|
|
// runAppservice starts the transaction dispatchers and the HTTP listener, then
|
|
// blocks until ctx is cancelled.
|
|
func (s *Session) runAppservice(ctx context.Context) error {
|
|
s.ep.Start(ctx)
|
|
s.as.Ready = true
|
|
|
|
errCh := make(chan struct{})
|
|
go func() {
|
|
s.as.Start() // blocks in ListenAndServe until Stop()
|
|
close(errCh)
|
|
}()
|
|
|
|
slog.Info("appservice listener started", "address", s.as.Host.Address())
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-errCh:
|
|
return fmt.Errorf("appservice HTTP listener exited unexpectedly")
|
|
}
|
|
}
|
|
|
|
// runPresenceHeartbeat keeps the bot's Matrix presence "online" while the
|
|
// appservice runs. Without /sync, nothing else refreshes presence, so it would
|
|
// otherwise freeze at its last value. On graceful shutdown it best-effort sets
|
|
// presence "offline"; on a hard crash the heartbeat simply stops and Synapse
|
|
// decays the stale "online" state on its own.
|
|
func (s *Session) runPresenceHeartbeat(ctx context.Context) {
|
|
setPresence := func(ctx context.Context, presence event.Presence) {
|
|
if err := s.Client.SetPresence(ctx, mautrix.ReqPresence{Presence: presence}); err != nil {
|
|
slog.Warn("appservice: set presence failed", "presence", presence, "err", err)
|
|
}
|
|
}
|
|
|
|
setPresence(ctx, event.PresenceOnline)
|
|
|
|
ticker := time.NewTicker(presenceHeartbeatInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// Detach from the cancelled ctx so the final PUT still goes out.
|
|
offCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
setPresence(offCtx, event.PresenceOffline)
|
|
cancel()
|
|
return
|
|
case <-ticker.C:
|
|
setPresence(ctx, event.PresenceOnline)
|
|
}
|
|
}
|
|
}
|