diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 0dbf063..abb7f5b 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" + "cloud.google.com/go/firestore" "cloud.google.com/go/storage" pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal" @@ -80,8 +81,17 @@ func main() { waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc) livekitClient := livekit.NewClient() + // Initialize Firestore client (for webhook-driven updates) + gcpProject := utils.MustGetEnv("GCP_PROJECT") + firestoreClient, err := firestore.NewClient(ctx, gcpProject) + if err != nil { + slog.Error("failed to create Firestore client", "error", err) + os.Exit(1) + } + defer firestoreClient.Close() + // Initialize handler - h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, livekitClient) + h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, livekitClient, firestoreClient) // Helper to wrap handlers with auth middleware withAuth := func(hf http.HandlerFunc) http.Handler { @@ -102,6 +112,7 @@ func main() { mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode) mux.HandleFunc("POST /auth/sign-in", h.SignIn) mux.HandleFunc("POST /waitlist", h.AddToWaitlist) + mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook) // ========================================================================== // Protected routes (auth required) diff --git a/go/go.mod b/go/go.mod index fe8389d..89fd4a3 100644 --- a/go/go.mod +++ b/go/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/livekit/protocol v1.45.1 + github.com/livekit/server-sdk-go/v2 v2.16.1 github.com/redis/go-redis/v9 v9.17.2 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 @@ -41,6 +42,8 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect @@ -54,7 +57,6 @@ require ( github.com/dennwc/iters v1.2.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.0.0+incompatible // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -62,7 +64,7 @@ require ( github.com/ebitengine/purego v0.8.4 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect - github.com/fatih/color v1.15.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -72,14 +74,16 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/uuid/v5 v5.2.0 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/schema v1.3.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -90,28 +94,27 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect + github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 // indirect github.com/livekit/psrpc v0.7.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magefile/mage v1.15.0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect - github.com/moby/moby/api v1.52.0 // indirect - github.com/moby/moby/client v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runc v1.3.3 // indirect - github.com/ory/dockertest/v3 v3.12.0 // indirect github.com/pion/datachannel v1.6.0 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/ice/v4 v4.2.0 // indirect @@ -132,6 +135,10 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.64.0 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect @@ -156,6 +163,7 @@ require ( go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go/go.sum b/go/go.sum index c7fc81a..000cd72 100644 --- a/go/go.sum +++ b/go/go.sum @@ -50,6 +50,10 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -112,8 +116,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA= @@ -162,8 +166,16 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA= github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -190,19 +202,25 @@ github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkI github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= +github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA= +github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A= github.com/livekit/protocol v1.45.1 h1:4cbynsPZW32gS2z6nUWfAfr4YaTUwZSKUiLpSpjX+lQ= github.com/livekit/protocol v1.45.1/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= +github.com/livekit/server-sdk-go/v2 v2.16.1 h1:ZkIA9OdVvQ6Up1uW/RtQ0YJUgYMJ6+ywOmDg0jX7bTg= +github.com/livekit/server-sdk-go/v2 v2.16.1/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -227,6 +245,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= @@ -284,6 +304,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= +github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= @@ -383,6 +411,8 @@ golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05 golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -408,6 +438,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 0de9519..1e9e95f 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" + "strings" "time" + "cloud.google.com/go/firestore" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/human" @@ -17,27 +20,30 @@ import ( "github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/waitlist" + "github.com/livekit/protocol/webhook" ) type Handler struct { - authSvc auth.AuthService - humanSvc human.Service - networkSvc network.Service - particleSvc particle.Service - depotSvc depot.Service - waitlistSvc waitlist.Service - livekitClient livekit.Client + authSvc auth.AuthService + humanSvc human.Service + networkSvc network.Service + particleSvc particle.Service + depotSvc depot.Service + waitlistSvc waitlist.Service + livekitClient livekit.Client + firestoreClient *firestore.Client } -func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, livekitClient livekit.Client) *Handler { +func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, livekitClient livekit.Client, firestoreClient *firestore.Client) *Handler { return &Handler{ - authSvc: authSvc, - humanSvc: humanSvc, - networkSvc: networkSvc, - particleSvc: particleSvc, - depotSvc: depotSvc, - waitlistSvc: waitlistSvc, - livekitClient: livekitClient, + authSvc: authSvc, + humanSvc: humanSvc, + networkSvc: networkSvc, + particleSvc: particleSvc, + depotSvc: depotSvc, + waitlistSvc: waitlistSvc, + livekitClient: livekitClient, + firestoreClient: firestoreClient, } } @@ -115,7 +121,8 @@ type RevokeInvitationRequest struct { // LiveKit DTOs type GetLivekitTokenRequest struct { - RoomId string `json:"room_id"` + NetworkId string `json:"network_id"` + StreamId string `json:"stream_id"` } type GetLivekitTokenResponse struct { @@ -1076,14 +1083,21 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) { return } - if req.RoomId == "" { - http.Error(w, "room_id is required", http.StatusBadRequest) + if req.NetworkId == "" { + http.Error(w, "network_id is required", http.StatusBadRequest) + return + } + if req.StreamId == "" { + http.Error(w, "stream_id is required", http.StatusBadRequest) return } - token, err := h.livekitClient.GetJoinToken(req.RoomId, humanId, humanEmail) + // Compose room name encoding both network and stream IDs for webhook resolution + roomName := req.NetworkId + "/" + req.StreamId + + token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail) if err != nil { - slog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomId", req.RoomId) + slog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomName", roomName) http.Error(w, "failed to generate token", http.StatusInternalServerError) return } @@ -1092,6 +1106,72 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()}) } +// HandleLivekitWebhook processes LiveKit webhook events for huddle presence. +// It verifies the webhook signature (not user auth), then updates the stream +// particle's huddle_active_participants field in Firestore. +func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) { + event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider()) + if err != nil { + slog.Error("failed to verify livekit webhook", "error", err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + eventType := event.GetEvent() + slog.Info("received livekit webhook", "event", eventType, "room", event.GetRoom().GetName()) + + switch eventType { + case "participant_joined", "participant_left", "room_finished": + // Handle these events + default: + w.WriteHeader(http.StatusOK) + return + } + + // Parse room name to extract networkId and streamId + roomName := event.GetRoom().GetName() + parts := strings.SplitN(roomName, "/", 2) + if len(parts) != 2 { + slog.Error("invalid room name format", "room", roomName) + http.Error(w, "invalid room name", http.StatusBadRequest) + return + } + networkId, streamId := parts[0], parts[1] + docPath := fmt.Sprintf("networks/%s/children/%s", networkId, streamId) + docRef := h.firestoreClient.Doc(docPath) + ctx := r.Context() + + var participantIds []string + + if eventType == "room_finished" { + // Room is done — clear the participants + participantIds = []string{} + } else { + // Use ListParticipants for authoritative state (avoids drift from missed webhooks) + participants, err := h.livekitClient.ListParticipants(ctx, roomName) + if err != nil { + slog.Error("failed to list participants", "error", err, "room", roomName) + // Return 200 so LiveKit doesn't retry + w.WriteHeader(http.StatusOK) + return + } + + participantIds = make([]string, 0, len(participants)) + for _, p := range participants { + participantIds = append(participantIds, p.Identity) + } + } + + _, err = docRef.Update(ctx, []firestore.Update{ + {Path: "huddle_active_participants", Value: participantIds}, + }) + if err != nil { + slog.Error("failed to update huddle participants in firestore", "error", err, "path", docPath) + } + + w.WriteHeader(http.StatusOK) +} + func extractBearerToken(r *http.Request) string { authHeader := r.Header.Get("Authorization") if authHeader == "" { diff --git a/go/internal/livekit/client.go b/go/internal/livekit/client.go index dc57846..8cce5eb 100644 --- a/go/internal/livekit/client.go +++ b/go/internal/livekit/client.go @@ -1,29 +1,47 @@ package livekit import ( + "context" "time" "github.com/flowy-live/llink/internal/utils" "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" ) type Client interface { - // Name will show up in the participant data + // GetJoinToken generates a JWT for a participant to join a room. + // Name will show up in the participant data. GetJoinToken(roomId string, humanId string, name string) (string, error) ServerUrl() string + // ListParticipants returns the current participants in a room. + ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) + // KeyProvider returns the key provider for verifying webhook signatures. + KeyProvider() auth.KeyProvider } type clientImpl struct { - apiSecret string - apiKey string - hostUrl string + apiSecret string + apiKey string + hostUrl string + roomService *lksdk.RoomServiceClient + keyProvider auth.KeyProvider } func NewClient() Client { + apiKey := utils.MustGetEnv("LIVEKIT_API_KEY") + apiSecret := utils.MustGetEnv("LIVEKIT_API_SECRET") + hostUrl := utils.MustGetEnv("LIVEKIT_URL") + + roomService := lksdk.NewRoomServiceClient(hostUrl, apiKey, apiSecret) + return &clientImpl{ - apiSecret: utils.MustGetEnv("LIVEKIT_API_SECRET"), - apiKey: utils.MustGetEnv("LIVEKIT_API_KEY"), - hostUrl: utils.MustGetEnv("LIVEKIT_URL"), + apiSecret: apiSecret, + apiKey: apiKey, + hostUrl: hostUrl, + roomService: roomService, + keyProvider: auth.NewSimpleKeyProvider(apiKey, apiSecret), } } @@ -44,3 +62,17 @@ func (c *clientImpl) GetJoinToken(room, humanId, name string) (string, error) { return at.ToJWT() } + +func (c *clientImpl) ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) { + resp, err := c.roomService.ListParticipants(ctx, &livekit.ListParticipantsRequest{ + Room: roomName, + }) + if err != nil { + return nil, err + } + return resp.Participants, nil +} + +func (c *clientImpl) KeyProvider() auth.KeyProvider { + return c.keyProvider +} diff --git a/go/internal/particle/firestore_types.go b/go/internal/particle/firestore_types.go index dbae226..82d5869 100644 --- a/go/internal/particle/firestore_types.go +++ b/go/internal/particle/firestore_types.go @@ -46,8 +46,9 @@ type FirestoreStreamParticle struct { CreatedByHumanId string `firestore:"created_by_human_id"` Type string `firestore:"type"` // Properties FirestoreStreamParticleProperties `firestore:"properties"` - CreatedAt time.Time `firestore:"created_at,serverTimestamp"` - LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"` - VisibleTo []string `firestore:"visible_to"` - UpdatedAt *time.Time `firestore:"updated_at,omitempty"` + CreatedAt time.Time `firestore:"created_at,serverTimestamp"` + LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"` + VisibleTo []string `firestore:"visible_to"` + UpdatedAt *time.Time `firestore:"updated_at,omitempty"` + HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"` } diff --git a/go/k8s/dev/orion.yaml b/go/k8s/dev/orion.yaml index 731c4bf..fe5a099 100644 --- a/go/k8s/dev/orion.yaml +++ b/go/k8s/dev/orion.yaml @@ -29,6 +29,8 @@ spec: memory: "52Mi" cpu: 50m env: + - name: "GCP_PROJECT" + value: "flowy-dev-440017" - name: "PORT" value: "8080" - name: "AERO_ADDR" diff --git a/js/src/api/client.ts b/js/src/api/client.ts index a8ab07a..bac9f8f 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -215,8 +215,8 @@ class ApiClient { // --- LiveKit --- - async getLivekitToken(roomId: string) { - return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { room_id: roomId }); + async getLivekitToken(networkId: string, streamId: string) { + return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId }); } } diff --git a/js/src/api/types.ts b/js/src/api/types.ts index df346e5..3e7ef23 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -184,6 +184,8 @@ export const ParticleSchema = z.discriminatedUnion("type", [ // Timestamp of the most recent child particle // used for sorting streams by recent activity without needing to query subcollections last_child_created_at: z.coerce.date().optional(), + // Array of humanIds currently in the huddle (updated via LiveKit webhooks) + huddle_active_participants: z.array(z.string()).optional(), }), ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema, diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 0a1dbec..0f6d2cb 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -10,6 +10,7 @@ import { CircleCheck, StickyNote, Timer, + Phone, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -101,6 +102,10 @@ function StreamRow({ network?.message_retention_hours ?? 24, ); + const hasActiveHuddle = + particle.huddle_active_participants && particle.huddle_active_participants.length > 0; + const huddleCount = particle.huddle_active_participants?.length ?? 0; + const isDM = particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith("human:")); @@ -162,6 +167,7 @@ function StreamRow({ className={cn( "flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent", isSelected && "bg-accent", + hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent", )} > {shortcutKey && ( @@ -188,7 +194,13 @@ function StreamRow({ > {particle.properties.name}

-
+
+ {hasActiveHuddle && ( + + + {huddleCount} + + )} {expiringSoon && ( )} diff --git a/js/src/features/particles/stream-card.tsx b/js/src/features/particles/stream-card.tsx index a00132c..7f6b417 100644 --- a/js/src/features/particles/stream-card.tsx +++ b/js/src/features/particles/stream-card.tsx @@ -1,5 +1,5 @@ import { forwardRef, useMemo } from "react"; -import { Timer } from "lucide-react"; +import { Timer, Phone } from "lucide-react"; import { cn, getInitials } from "@/lib/utils"; import { useLiveLatestChild } from "@/hooks/use-particle"; import { useAuthStore } from "@/stores/auth-store"; @@ -34,6 +34,10 @@ export const StreamCard = forwardRef(function S network?.message_retention_hours ?? 24, ); + const hasActiveHuddle = + particle.huddle_active_participants && particle.huddle_active_participants.length > 0; + const huddleCount = particle.huddle_active_participants?.length ?? 0; + const isDM = particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith("human:")); @@ -94,10 +98,14 @@ export const StreamCard = forwardRef(function S "cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20", isUnseen && "ring-2 ring-primary", isSelected && "ring-2 ring-ring", + hasActiveHuddle && "ring-2 ring-red-500/70", )} > {/* Preview area */}
+ {hasActiveHuddle && ( +
+ )} {shortcutKey && ( {shortcutKey} @@ -141,6 +149,12 @@ export const StreamCard = forwardRef(function S {particle.properties.name}
+ {hasActiveHuddle && ( + + + {huddleCount} + + )} {expiringSoon && ( )} diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 466a884..0450868 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -20,6 +20,7 @@ import { WindowControls } from "@/components/window-controls"; import { RelativeTimestamp } from "@/components/relative-timestamp"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; +import { getInitials } from "@/lib/utils"; function getParticleDisplayName(particle: Particle): string { switch (particle.type) { @@ -173,8 +174,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { break; case "h": { e.preventDefault(); - const roomId = streamParticle.id; - apiClient.getLivekitToken(roomId).then(({ token, server_url }) => { + apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { window.electronWindow.openHuddle({ token, serverUrl: server_url }); }); break; @@ -330,6 +330,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) { const navigate = useNavigate(); + const network = useNetwork(networkId); + + const huddleParticipants = streamParticle.huddle_active_participants ?? []; + const hasActiveHuddle = huddleParticipants.length > 0; + + const handleJoinHuddle = () => { + apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { + window.electronWindow.openHuddle({ token, serverUrl: server_url }); + }); + }; return (
@@ -356,6 +366,37 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa + {hasActiveHuddle && ( + + )} +