From b75be010178f3797c3d74bdd89f591763ef2e0e4 Mon Sep 17 00:00:00 2001 From: weoses Date: Sun, 21 Jun 2026 23:07:36 +0200 Subject: [PATCH 1/3] simple permissions for telegram-service --- CLAUDE.md => .claude/CLAUDE.md | 1 + telegram-service/conf/Config.go | 17 +- telegram-service/config.yaml | 13 +- telegram-service/go.mod | 10 - telegram-service/go.sum | 70 ----- telegram-service/main.go | 2 +- .../service/InlineHandlerService.go | 116 +++++---- .../service/MessageHandlerService.go | 245 +++++++++--------- telegram-service/service/PermissionService.go | 80 ++++++ .../service/UserAccountService.go | 106 -------- ...1_create_tg_user_account_bindings.down.sql | 1 - ...001_create_tg_user_account_bindings.up.sql | 4 - 12 files changed, 292 insertions(+), 373 deletions(-) rename CLAUDE.md => .claude/CLAUDE.md (98%) create mode 100644 telegram-service/service/PermissionService.go delete mode 100644 telegram-service/service/UserAccountService.go delete mode 100644 telegram-service/service/migrations/000001_create_tg_user_account_bindings.down.sql delete mode 100644 telegram-service/service/migrations/000001_create_tg_user_account_bindings.up.sql diff --git a/CLAUDE.md b/.claude/CLAUDE.md similarity index 98% rename from CLAUDE.md rename to .claude/CLAUDE.md index 20aa622..7f85bd9 100644 --- a/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,6 +3,7 @@ ## Project layout - `storage-service/` — main Go service (media storage, extraction, search) +- `telegram-service/` — integration with telegram - `proto/v1/` — protobuf definitions; regenerate with `buf generate` from repo root - `gen/proto/v1/` — generated Go code, do not edit manually - `common/` — shared utilities (helper, temp data, config) diff --git a/telegram-service/conf/Config.go b/telegram-service/conf/Config.go index 92c4df9..4c1f173 100644 --- a/telegram-service/conf/Config.go +++ b/telegram-service/conf/Config.go @@ -16,10 +16,6 @@ type InlineConfig struct { PageSize int } -type PostgresConfig struct { - DSN string -} - type StorageServiceConfig struct { Uri string } @@ -32,16 +28,27 @@ type WebhookConfig struct { ExternalUrl string } +type PermissionEntryConfig struct { + AllowedUserIds []int64 `mapstructure:"AllowedUserIds"` +} + +type PermissionsConfig struct { + Create *PermissionEntryConfig `mapstructure:"Create"` + Delete *PermissionEntryConfig `mapstructure:"Delete"` + Recompute *PermissionEntryConfig `mapstructure:"Recompute"` + Search *PermissionEntryConfig `mapstructure:"Search"` +} + type Config struct { Server *commonconfig.ServerConfig `mapstructure:"server"` Log *commonconfig.LoggingConfig `mapstructure:"log"` Webhook *WebhookConfig `mapstructure:"webhook"` Telegram *TelegramConfig `mapstructure:"telegram"` - Postgres *PostgresConfig `mapstructure:"postgres"` Inline *InlineConfig `mapstructure:"inline"` StorageService *StorageServiceConfig `mapstructure:"storage-service"` UserAccount *UserAccountConfig `mapstructure:"user-account"` TempStorage *commonconfig.MediaStorageConfig `mapstructure:"temp-storage"` + Permissions *PermissionsConfig `mapstructure:"permissions"` } func NewConfig() (*Config, error) { diff --git a/telegram-service/config.yaml b/telegram-service/config.yaml index 39ca814..5bf4111 100644 --- a/telegram-service/config.yaml +++ b/telegram-service/config.yaml @@ -8,9 +8,6 @@ telegram: Token: Debug: false -postgres: - DSN: - inline: PageSize: 20 @@ -30,3 +27,13 @@ temp-storage: SecretKey: Bucket: melo-temp Secure: false + +permissions: + Create: + AllowedUserIds: [] + Delete: + AllowedUserIds: [] + Recompute: + AllowedUserIds: [] + Search: + AllowedUserIds: [] diff --git a/telegram-service/go.mod b/telegram-service/go.mod index d311121..8fe7032 100644 --- a/telegram-service/go.mod +++ b/telegram-service/go.mod @@ -4,10 +4,8 @@ go 1.25.0 require ( connectrpc.com/connect v1.19.1 - github.com/golang-migrate/migrate/v4 v4.18.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.7.2 github.com/spf13/viper v1.21.0 github.com/weoses/memelo/common v0.0.0-00010101000000-000000000000 github.com/weoses/memelo/gen v0.0.0-00010101000000-000000000000 @@ -17,12 +15,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect @@ -33,13 +25,11 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect - go.uber.org/atomic v1.9.0 // indirect go.uber.org/dig v1.18.0 // indirect go.uber.org/zap v1.26.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.20.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/telegram-service/go.sum b/telegram-service/go.sum index eade10f..5e04d9c 100644 --- a/telegram-service/go.sum +++ b/telegram-service/go.sum @@ -1,63 +1,23 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0= -github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= -github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= -github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= -github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= -github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= @@ -71,30 +31,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -111,25 +57,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= -go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= -go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= -go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= -go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= -go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= @@ -146,8 +79,6 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= @@ -157,6 +88,5 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/telegram-service/main.go b/telegram-service/main.go index 1e0ca58..43ed9f5 100644 --- a/telegram-service/main.go +++ b/telegram-service/main.go @@ -65,7 +65,7 @@ func main() { fx.Provide(tgstorage.NewTmpDataService), fx.Provide(service.NewStorageConnector), fx.Provide(fx.Annotate(service.NewTelegramFileResolverService, fx.From(new(*tgbotapi.BotAPI)))), - fx.Provide(service.NewUserAccountService), + fx.Provide(service.NewPermissionService), fx.Provide(service.NewMessageHandlerService), fx.Provide(service.NewQueryProcessorFactory), fx.Provide(service.NewInlineService), diff --git a/telegram-service/service/InlineHandlerService.go b/telegram-service/service/InlineHandlerService.go index c961f12..5c2e62c 100644 --- a/telegram-service/service/InlineHandlerService.go +++ b/telegram-service/service/InlineHandlerService.go @@ -4,11 +4,12 @@ import ( "context" "fmt" "log/slog" - - "github.com/weoses/memelo/telegram-service/util" + "strings" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/google/uuid" "github.com/weoses/memelo/telegram-service/conf" + "github.com/weoses/memelo/telegram-service/util" ) const inlineDeletePrefix = "/delete" @@ -28,11 +29,23 @@ type InlineHandlerService interface { } type InineHandlerServiceImpl struct { - userAccount UserAccountService - storage StorageConnector - config *conf.Config - log *slog.Logger - factory QueryProcessorFactory + permissionService PermissionService + storage StorageConnector + config *conf.Config + log *slog.Logger + factory QueryProcessorFactory + staticAccountId uuid.UUID +} + +func permissionForQuery(query string) string { + switch { + case strings.HasPrefix(query, inlineDeletePrefix): + return PermissionDelete + case strings.HasPrefix(query, inlineRecomputePrefix): + return PermissionRecompute + default: + return PermissionSearch + } } func (i *InineHandlerServiceImpl) ProcessChosenInlineQuery(ctx context.Context, request *tgbotapi.ChosenInlineResult) error { @@ -43,13 +56,12 @@ func (i *InineHandlerServiceImpl) ProcessChosenInlineQuery(ctx context.Context, "messageId", request.InlineMessageID) processor := i.factory.GetProcessor(request.Query) + permission := permissionForQuery(request.Query) - accountId, err := i.userAccount.MapUserToAccount(ctx, userId) - if err != nil { - return fmt.Errorf("ProcessChosenInlineQuery: MapUserToAccount failed, userId=%d: %w", userId, err) - } - - return processor.ProcessChosenQuery(ctx, accountId, request.ResultID) + _, err := InvokeWithPermission(ctx, i.permissionService, userId, permission, func() (struct{}, error) { + return struct{}{}, processor.ProcessChosenQuery(ctx, i.staticAccountId, request.ResultID) + }) + return err } // ProcessQuery implements InlineService. @@ -63,54 +75,52 @@ func (i *InineHandlerServiceImpl) ProcessQuery( "query", request.Query, "offset", request.Offset) - accountId, err := i.userAccount.MapUserToAccount(ctx, userId) - if err != nil { - return nil, err - } - processor := i.factory.GetProcessor(request.Query) - - result, err := processor.Process(ctx, accountId, request.Query, util.ParseOffset(request.Offset)) - if err != nil { - return nil, fmt.Errorf("ProcessQuery: processor failed: %w", err) - } - - i.log.InfoContext(ctx, "Process query result", - "userId", userId, - "requestId", request.ID, - "resultListSize", len(result.Results)) - - nextOffset := "" - if result.Pagination != nil { - nextOffset = util.SerializeOffset(result.Pagination) - } - - i.log.InfoContext(ctx, "Search next offset", - "userId", userId, - "requestId", request.ID, - "nextOffset", nextOffset) - - return &tgbotapi.InlineConfig{ - InlineQueryID: request.ID, - CacheTime: result.CacheTime, - IsPersonal: true, - NextOffset: nextOffset, - Results: result.Results, - }, nil + permission := permissionForQuery(request.Query) + + return InvokeWithPermission(ctx, i.permissionService, userId, permission, func() (*tgbotapi.InlineConfig, error) { + result, err := processor.Process(ctx, i.staticAccountId, request.Query, util.ParseOffset(request.Offset)) + if err != nil { + return nil, fmt.Errorf("ProcessQuery: processor failed: %w", err) + } + + i.log.InfoContext(ctx, "Process query result", + "userId", userId, + "requestId", request.ID, + "resultListSize", len(result.Results)) + + nextOffset := "" + if result.Pagination != nil { + nextOffset = util.SerializeOffset(result.Pagination) + } + + i.log.InfoContext(ctx, "Search next offset", + "userId", userId, + "requestId", request.ID, + "nextOffset", nextOffset) + + return &tgbotapi.InlineConfig{ + InlineQueryID: request.ID, + CacheTime: result.CacheTime, + IsPersonal: true, + NextOffset: nextOffset, + Results: result.Results, + }, nil + }) } func NewInlineService( - userAccount UserAccountService, + permissionService PermissionService, storage StorageConnector, config *conf.Config, factory QueryProcessorFactory, ) InlineHandlerService { - return &InineHandlerServiceImpl{ - userAccount: userAccount, - storage: storage, - config: config, - log: slog.With("service", "InlineHandlerService"), - factory: factory, + permissionService: permissionService, + storage: storage, + config: config, + log: slog.With("service", "InlineHandlerService"), + factory: factory, + staticAccountId: uuid.MustParse(config.UserAccount.StaticUuid), } } diff --git a/telegram-service/service/MessageHandlerService.go b/telegram-service/service/MessageHandlerService.go index f8382f8..44ef233 100644 --- a/telegram-service/service/MessageHandlerService.go +++ b/telegram-service/service/MessageHandlerService.go @@ -13,6 +13,7 @@ import ( "github.com/weoses/memelo/common/helper" commonservice "github.com/weoses/memelo/common/service" "github.com/weoses/memelo/common/temp" + "github.com/weoses/memelo/telegram-service/conf" "github.com/weoses/memelo/telegram-service/entity" ) @@ -30,129 +31,130 @@ type MessageHandlerResponse struct { } type MessageHandlerServiceImpl struct { - storage StorageConnector - fileResolver TelegramFileResolverService - userAccountService UserAccountService - tmpDataService commonservice.TmpDataService - slogger *slog.Logger + storage StorageConnector + fileResolver TelegramFileResolverService + permissionService PermissionService + tmpDataService commonservice.TmpDataService + slogger *slog.Logger + staticAccountId uuid.UUID } func (m MessageHandlerServiceImpl) ProcessImageMessage(ctx context.Context, message *tgbotapi.Message) (*MessageHandlerResponse, error) { - var fileId string - if len(message.Photo) >= 1 { - fileId = message.Photo[len(message.Photo)-1].FileID - } - if fileId == "" { - return nil, errors.New("messageHandlerService: message dont contain image") - } - - accountId, err := m.userAccountService.MapUserToAccount(ctx, message.Chat.ID) - if err != nil { - return nil, fmt.Errorf("messageHandlerService: MapUserToAccount failed : %w", err) - } - - result, err := m.createMediaMeme(ctx, "image", fileId, accountId) - if err != nil { - return nil, err - } - - m.slogger.InfoContext(ctx, "meme created", - "imageId", result.Id, - "duplicate", result.DuplicateStatus) - - return &MessageHandlerResponse{ - Message: fmt.Sprintf( - " Text: ```\n%s\n```\n"+ - " Tags: ```%s```\n"+ - " Caption: `%s`\n"+ - " ID: `%s` \n"+ - " Status: `%s`", - result.Text, - strings.Join(result.Tags, ", "), - result.Caption, - result.Id, - result.DuplicateStatus), - ParseMode: parseMode, - }, nil + if message.From == nil { + return nil, errors.New("messageHandlerService: message has no sender") + } + return InvokeWithPermission(ctx, m.permissionService, message.From.ID, PermissionCreate, func() (*MessageHandlerResponse, error) { + var fileId string + if len(message.Photo) >= 1 { + fileId = message.Photo[len(message.Photo)-1].FileID + } + if fileId == "" { + return nil, errors.New("messageHandlerService: message dont contain image") + } + + result, err := m.createMediaMeme(ctx, "image", fileId, m.staticAccountId) + if err != nil { + return nil, err + } + + m.slogger.InfoContext(ctx, "meme created", + "imageId", result.Id, + "duplicate", result.DuplicateStatus) + + return &MessageHandlerResponse{ + Message: fmt.Sprintf( + " Text: ```\n%s\n```\n"+ + " Tags: ```%s```\n"+ + " Caption: `%s`\n"+ + " ID: `%s` \n"+ + " Status: `%s`", + result.Text, + strings.Join(result.Tags, ", "), + result.Caption, + result.Id, + result.DuplicateStatus), + ParseMode: parseMode, + }, nil + }) } func (m MessageHandlerServiceImpl) ProcessVideoMessage(ctx context.Context, message *tgbotapi.Message) (*MessageHandlerResponse, error) { - if message.Video == nil { - return nil, errors.New("messageHandlerService: message does not contain a video") - } - - accountId, err := m.userAccountService.MapUserToAccount(ctx, message.Chat.ID) - if err != nil { - return nil, fmt.Errorf("messageHandlerService: MapUserToAccount failed : %w", err) - } - - result, err := m.createMediaMeme(ctx, "video", message.Video.FileID, accountId) - if err != nil { - return nil, err - } - - m.slogger.InfoContext(ctx, "video meme created", - "memeId", result.Id, - "duplicate", result.DuplicateStatus) - - return &MessageHandlerResponse{ - Message: fmt.Sprintf( - "\n```Text\n%s\n```\n ID: `%s` \n Status: `%s`\n Tags: ```%s```", - result.Text, - result.Id, - result.DuplicateStatus, - strings.Join(result.Tags, ", ")), - ParseMode: parseMode, - }, nil + if message.From == nil { + return nil, errors.New("messageHandlerService: message has no sender") + } + return InvokeWithPermission(ctx, m.permissionService, message.From.ID, PermissionCreate, func() (*MessageHandlerResponse, error) { + if message.Video == nil { + return nil, errors.New("messageHandlerService: message does not contain a video") + } + + result, err := m.createMediaMeme(ctx, "video", message.Video.FileID, m.staticAccountId) + if err != nil { + return nil, err + } + + m.slogger.InfoContext(ctx, "video meme created", + "memeId", result.Id, + "duplicate", result.DuplicateStatus) + + return &MessageHandlerResponse{ + Message: fmt.Sprintf( + "\n```Text\n%s\n```\n ID: `%s` \n Status: `%s`\n Tags: ```%s```", + result.Text, + result.Id, + result.DuplicateStatus, + strings.Join(result.Tags, ", ")), + ParseMode: parseMode, + }, nil + }) } func (m MessageHandlerServiceImpl) ProcessDocumentMessage(ctx context.Context, message *tgbotapi.Message) (*MessageHandlerResponse, error) { - if message.Document == nil { - return nil, errors.New("messageHandlerService: message does not contain a document") - } - - accountId, err := m.userAccountService.MapUserToAccount(ctx, message.Chat.ID) - if err != nil { - return nil, fmt.Errorf("messageHandlerService: MapUserToAccount failed : %w", err) - } - - mimeType := message.Document.MimeType - if mimeType == "" { - return nil, errors.New("messageHandlerService: message has no mime type") - } - - var typ string - if strings.HasPrefix(mimeType, "video/") { - typ = "video" - } else if strings.HasPrefix(mimeType, "image/") { - typ = "image" - } else { - return nil, errors.New("messageHandlerService: invalid mime type") - } - - result, err := m.createMediaMeme(ctx, typ, message.Document.FileID, accountId) - if err != nil { - return nil, err - } - - m.slogger.InfoContext(ctx, "document meme created", - "memeId", result.Id, - "duplicate", result.DuplicateStatus) - - return &MessageHandlerResponse{ - Message: fmt.Sprintf( - " Text: ```\n%s\n```\n"+ - " Tags: ```%s```\n"+ - " Caption: `%s`\n"+ - " ID: `%s` \n"+ - " Status: `%s`", - result.Text, - strings.Join(result.Tags, ", "), - result.Caption, - result.Id, - result.DuplicateStatus), - ParseMode: parseMode, - }, nil + if message.From == nil { + return nil, errors.New("messageHandlerService: message has no sender") + } + return InvokeWithPermission(ctx, m.permissionService, message.From.ID, PermissionCreate, func() (*MessageHandlerResponse, error) { + if message.Document == nil { + return nil, errors.New("messageHandlerService: message does not contain a document") + } + + mimeType := message.Document.MimeType + if mimeType == "" { + return nil, errors.New("messageHandlerService: message has no mime type") + } + + var typ string + if strings.HasPrefix(mimeType, "video/") { + typ = "video" + } else if strings.HasPrefix(mimeType, "image/") { + typ = "image" + } else { + return nil, errors.New("messageHandlerService: invalid mime type") + } + + result, err := m.createMediaMeme(ctx, typ, message.Document.FileID, m.staticAccountId) + if err != nil { + return nil, err + } + + m.slogger.InfoContext(ctx, "document meme created", + "memeId", result.Id, + "duplicate", result.DuplicateStatus) + + return &MessageHandlerResponse{ + Message: fmt.Sprintf( + " Text: ```\n%s\n```\n"+ + " Tags: ```%s```\n"+ + " Caption: `%s`\n"+ + " ID: `%s` \n"+ + " Status: `%s`", + result.Text, + strings.Join(result.Tags, ", "), + result.Caption, + result.Id, + result.DuplicateStatus), + ParseMode: parseMode, + }, nil + }) } func (m MessageHandlerServiceImpl) createMediaMeme(ctx context.Context, reqType string, fileId string, accountId uuid.UUID) (*entity.MemeCreateResult, error) { @@ -208,14 +210,17 @@ func (m MessageHandlerServiceImpl) downloadToS3(ctx context.Context, fileURL str func NewMessageHandlerService( storage StorageConnector, fileResolver TelegramFileResolverService, - userAccountService UserAccountService, + permissionService PermissionService, tmpDataService commonservice.TmpDataService, + cfg *conf.Config, ) MessageHandlerService { + staticAccountId := uuid.MustParse(cfg.UserAccount.StaticUuid) return &MessageHandlerServiceImpl{ - storage: storage, - fileResolver: fileResolver, - userAccountService: userAccountService, - tmpDataService: tmpDataService, - slogger: slog.With("service", "MessageHandlerService"), + storage: storage, + fileResolver: fileResolver, + permissionService: permissionService, + tmpDataService: tmpDataService, + slogger: slog.With("service", "MessageHandlerService"), + staticAccountId: staticAccountId, } } diff --git a/telegram-service/service/PermissionService.go b/telegram-service/service/PermissionService.go new file mode 100644 index 0000000..8512a01 --- /dev/null +++ b/telegram-service/service/PermissionService.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "errors" + + "github.com/weoses/memelo/telegram-service/conf" +) + +const ( + PermissionCreate = "create" + PermissionDelete = "delete" + PermissionRecompute = "recompute" + PermissionSearch = "search" +) + +var ErrForbidden = errors.New("forbidden") + +type PermissionService interface { + IsAllowed(userId int64, permission string) bool +} + +// InvokeWithPermission checks the named permission for userId before calling fn. +// Returns ErrForbidden if the user is not allowed. +func InvokeWithPermission[T any]( + _ context.Context, + svc PermissionService, + userId int64, + permission string, + fn func() (T, error), +) (T, error) { + if !svc.IsAllowed(userId, permission) { + var zero T + return zero, ErrForbidden + } + return fn() +} + +type PermissionServiceImpl struct { + permissions map[string]map[int64]struct{} +} + +func (p *PermissionServiceImpl) IsAllowed(userId int64, permission string) bool { + allowed, ok := p.permissions[permission] + if !ok || len(allowed) == 0 { + return true + } + _, ok = allowed[userId] + return ok +} + +func NewPermissionService(cfg *conf.Config) PermissionService { + perms := make(map[string]map[int64]struct{}) + + type entry struct { + name string + cfg *conf.PermissionEntryConfig + } + + if cfg.Permissions != nil { + entries := []entry{ + {PermissionCreate, cfg.Permissions.Create}, + {PermissionDelete, cfg.Permissions.Delete}, + {PermissionRecompute, cfg.Permissions.Recompute}, + {PermissionSearch, cfg.Permissions.Search}, + } + for _, e := range entries { + if e.cfg == nil { + continue + } + set := make(map[int64]struct{}, len(e.cfg.AllowedUserIds)) + for _, id := range e.cfg.AllowedUserIds { + set[id] = struct{}{} + } + perms[e.name] = set + } + } + + return &PermissionServiceImpl{permissions: perms} +} diff --git a/telegram-service/service/UserAccountService.go b/telegram-service/service/UserAccountService.go deleted file mode 100644 index f61b9c8..0000000 --- a/telegram-service/service/UserAccountService.go +++ /dev/null @@ -1,106 +0,0 @@ -package service - -import ( - "context" - "embed" - "errors" - "fmt" - "log/slog" - - "github.com/golang-migrate/migrate/v4" - migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" - "github.com/golang-migrate/migrate/v4/source/iofs" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/pgx/v5/stdlib" - "github.com/weoses/memelo/telegram-service/conf" -) - -//go:embed migrations/*.sql -var migrationsFS embed.FS - -type UserAccountService interface { - MapUserToAccount(ctx context.Context, userId int64) (uuid.UUID, error) -} - -type UserAccountServiceImpl struct { - pool *pgxpool.Pool - log *slog.Logger -} - -// MapUserToAccount implements UserAccountService. -func (u *UserAccountServiceImpl) MapUserToAccount(ctx context.Context, userId int64) (uuid.UUID, error) { - newId, _ := uuid.NewRandom() - _, err := u.pool.Exec(ctx, - `INSERT INTO tg_user_account_bindings (telegram_id, account_id) - VALUES ($1, $2) ON CONFLICT (telegram_id) DO NOTHING`, - userId, newId) - if err != nil { - return uuid.UUID{}, fmt.Errorf("insert: %w", err) - } - - var accountIdStr string - err = u.pool.QueryRow(ctx, - `SELECT account_id FROM tg_user_account_bindings WHERE telegram_id = $1`, - userId).Scan(&accountIdStr) - if err != nil { - return uuid.UUID{}, fmt.Errorf("select: %w", err) - } - return uuid.Parse(accountIdStr) -} - -type UserAccountServiceStaticImpl struct { - staticUuid uuid.UUID - log *slog.Logger -} - -// MapUserToAccount implements UserAccountService. -func (u *UserAccountServiceStaticImpl) MapUserToAccount(ctx context.Context, userId int64) (uuid.UUID, error) { - return u.staticUuid, nil -} - -func runMigrations(pool *pgxpool.Pool) error { - src, err := iofs.New(migrationsFS, "migrations") - if err != nil { - return fmt.Errorf("iofs source: %w", err) - } - db := stdlib.OpenDBFromPool(pool) - drv, err := migratepgx.WithInstance(db, &migratepgx.Config{}) - if err != nil { - return fmt.Errorf("migrate driver: %w", err) - } - m, err := migrate.NewWithInstance("iofs", src, "postgres", drv) - if err != nil { - return fmt.Errorf("migrate init: %w", err) - } - err = m.Up() - if err != nil && !errors.Is(err, migrate.ErrNoChange) { - return err - } - return nil -} - -func NewUserAccountService(config *conf.Config) (UserAccountService, error) { - logger := slog.With("service", "UserAccountService") - - if config.UserAccount.StaticUuid != "" { - return &UserAccountServiceStaticImpl{ - staticUuid: uuid.MustParse(config.UserAccount.StaticUuid), - log: logger, - }, nil - } - - pool, err := pgxpool.New(context.Background(), config.Postgres.DSN) - if err != nil { - return nil, fmt.Errorf("pgxpool.New: %w", err) - } - - if err := runMigrations(pool); err != nil { - return nil, fmt.Errorf("migrations: %w", err) - } - - return &UserAccountServiceImpl{ - pool: pool, - log: logger, - }, nil -} diff --git a/telegram-service/service/migrations/000001_create_tg_user_account_bindings.down.sql b/telegram-service/service/migrations/000001_create_tg_user_account_bindings.down.sql deleted file mode 100644 index 026f145..0000000 --- a/telegram-service/service/migrations/000001_create_tg_user_account_bindings.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS tg_user_account_bindings; diff --git a/telegram-service/service/migrations/000001_create_tg_user_account_bindings.up.sql b/telegram-service/service/migrations/000001_create_tg_user_account_bindings.up.sql deleted file mode 100644 index 1589e49..0000000 --- a/telegram-service/service/migrations/000001_create_tg_user_account_bindings.up.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS tg_user_account_bindings ( - telegram_id BIGINT PRIMARY KEY, - account_id UUID NOT NULL -); From 1096502c4a48c75e204a7c3437e08572ecdee659 Mon Sep 17 00:00:00 2001 From: weoses Date: Mon, 22 Jun 2026 01:18:12 +0200 Subject: [PATCH 2/3] /start handler, processing message, forbidden message --- .../service/MessageHandlerService.go | 5 +- telegram-service/service/Telegram.go | 69 ++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/telegram-service/service/MessageHandlerService.go b/telegram-service/service/MessageHandlerService.go index 44ef233..e597961 100644 --- a/telegram-service/service/MessageHandlerService.go +++ b/telegram-service/service/MessageHandlerService.go @@ -23,7 +23,10 @@ type MessageHandlerService interface { ProcessDocumentMessage(ctx context.Context, message *tgbotapi.Message) (*MessageHandlerResponse, error) } -const parseMode = "Markdown" +const ( + parseMode = "Markdown" + msgForbidden = "You are not allowed to perform this action." +) type MessageHandlerResponse struct { Message string diff --git a/telegram-service/service/Telegram.go b/telegram-service/service/Telegram.go index 0d4d9ba..13c0f48 100644 --- a/telegram-service/service/Telegram.go +++ b/telegram-service/service/Telegram.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" @@ -11,6 +12,17 @@ import ( "github.com/weoses/memelo/telegram-service/conf" ) +const ( + msgProcessing = "⏳ Processing..." + msgStartFmt = "Welcome to Memelo Bot!\n\n" + + "Send me an image or video to save it as a meme.\n\n" + + "Use inline mode to search your collection:\n" + + "• @%s — search by text\n" + + "• @%s /random — get a random meme\n" + + "• @%s /delete — delete memes\n" + + "• @%s /recompute — recompute meme metadata" +) + type TelegramBotService interface { Handler() http.Handler RegisterWebhook() error @@ -81,9 +93,18 @@ func (s *TelegramBotServiceImpl) dispatchUpdates(ctx context.Context, updates <- } func (s *TelegramBotServiceImpl) handleCommand(ctx context.Context, requestMessage *tgbotapi.Message) { - s.log.InfoContext(ctx, "Bot message request") - s.log.DebugContext(ctx, "Bot message request details", - "request", requestMessage) + switch requestMessage.Command() { + case "start": + botName := s.bot.Self.UserName + text := fmt.Sprintf(msgStartFmt, botName, botName, botName, botName) + msg := tgbotapi.NewMessage(requestMessage.Chat.ID, text) + msg.ReplyToMessageID = requestMessage.MessageID + if _, err := s.bot.Send(msg); err != nil { + s.log.ErrorContext(ctx, "Failed to send start message", "error", err) + } + default: + s.log.InfoContext(ctx, "Unknown command", "command", requestMessage.Command()) + } } func (s *TelegramBotServiceImpl) handleMessage(ctx context.Context, requestMessage *tgbotapi.Message) { @@ -91,6 +112,15 @@ func (s *TelegramBotServiceImpl) handleMessage(ctx context.Context, requestMessa s.log.DebugContext(ctx, "Bot message request details", "request", requestMessage) + var processingMsgID int + processingNotif := tgbotapi.NewMessage(requestMessage.Chat.ID, msgProcessing) + processingNotif.ReplyToMessageID = requestMessage.MessageID + if sent, err := s.bot.Send(processingNotif); err == nil { + processingMsgID = sent.MessageID + } else { + s.log.ErrorContext(ctx, "Failed to send processing message", "error", err) + } + var answer *MessageHandlerResponse var err error if requestMessage.Video != nil { @@ -105,15 +135,26 @@ func (s *TelegramBotServiceImpl) handleMessage(ctx context.Context, requestMessa if err != nil { s.log.ErrorContext(ctx, "Failed to process message", "error", err) - s.sendCommonErrorMessage(ctx, requestMessage, err) + errText := err.Error() + if errors.Is(err, ErrForbidden) { + errText = msgForbidden + } + if editErr := s.editMessage(requestMessage.Chat.ID, processingMsgID, errText, ""); editErr != nil { + errorResponseMessage := tgbotapi.NewMessage(requestMessage.Chat.ID, errText) + errorResponseMessage.ReplyToMessageID = requestMessage.MessageID + if _, sendErr := s.bot.Send(errorResponseMessage); sendErr != nil { + s.log.ErrorContext(ctx, "Failed to send error message", "error", sendErr) + } + } return } - err = s.sendCommonResponseMessage(ctx, requestMessage, answer) - if err != nil { - s.log.ErrorContext(ctx, "Failed to send message to bot", "error", err) - s.sendCommonErrorMessage(ctx, requestMessage, err) - return + if err = s.editMessage(requestMessage.Chat.ID, processingMsgID, answer.Message, answer.ParseMode); err != nil { + s.log.ErrorContext(ctx, "Failed to edit processing message", "error", err) + if sendErr := s.sendCommonResponseMessage(ctx, requestMessage, answer); sendErr != nil { + s.log.ErrorContext(ctx, "Failed to send message to bot", "error", sendErr) + s.sendCommonErrorMessage(ctx, requestMessage, sendErr) + } } } @@ -134,6 +175,16 @@ func (s *TelegramBotServiceImpl) sendCommonErrorMessage(ctx context.Context, req } } +func (s *TelegramBotServiceImpl) editMessage(chatID int64, msgID int, text, parseMode string) error { + if msgID == 0 { + return errors.New("no message to edit") + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ParseMode = parseMode + _, err := s.bot.Send(edit) + return err +} + func (s *TelegramBotServiceImpl) handleInlineRequest(ctx context.Context, update *tgbotapi.Update) { s.log.InfoContext(ctx, "Bot inline request:", "query", update.InlineQuery.Query) From 994456869841b119a50973c972974c972462d1cd Mon Sep 17 00:00:00 2001 From: weoses Date: Mon, 22 Jun 2026 01:37:34 +0200 Subject: [PATCH 3/3] forbidden messages for inline mode do not show help for forbidden operations --- .../service/InlineHandlerService.go | 31 +++++++- telegram-service/service/QueryProcessor.go | 74 +++++++++++++------ telegram-service/service/Telegram.go | 26 ++++--- 3 files changed, 96 insertions(+), 35 deletions(-) diff --git a/telegram-service/service/InlineHandlerService.go b/telegram-service/service/InlineHandlerService.go index 5c2e62c..c377589 100644 --- a/telegram-service/service/InlineHandlerService.go +++ b/telegram-service/service/InlineHandlerService.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -26,6 +27,8 @@ type InlineHandlerService interface { ctx context.Context, request *tgbotapi.ChosenInlineResult, ) error + + HelpLines(userId int64) []string } type InineHandlerServiceImpl struct { @@ -64,6 +67,16 @@ func (i *InineHandlerServiceImpl) ProcessChosenInlineQuery(ctx context.Context, return err } +func (i *InineHandlerServiceImpl) HelpLines(userId int64) []string { + var lines []string + for _, entry := range i.factory.Entries() { + if i.permissionService.IsAllowed(userId, entry.Permission) { + lines = append(lines, entry.Processor.HelpText()) + } + } + return lines +} + // ProcessQuery implements InlineService. func (i *InineHandlerServiceImpl) ProcessQuery( ctx context.Context, @@ -78,7 +91,7 @@ func (i *InineHandlerServiceImpl) ProcessQuery( processor := i.factory.GetProcessor(request.Query) permission := permissionForQuery(request.Query) - return InvokeWithPermission(ctx, i.permissionService, userId, permission, func() (*tgbotapi.InlineConfig, error) { + cfg, err := InvokeWithPermission(ctx, i.permissionService, userId, permission, func() (*tgbotapi.InlineConfig, error) { result, err := processor.Process(ctx, i.staticAccountId, request.Query, util.ParseOffset(request.Offset)) if err != nil { return nil, fmt.Errorf("ProcessQuery: processor failed: %w", err) @@ -107,6 +120,22 @@ func (i *InineHandlerServiceImpl) ProcessQuery( Results: result.Results, }, nil }) + if errors.Is(err, ErrForbidden) { + return &tgbotapi.InlineConfig{ + InlineQueryID: request.ID, + Results: []interface{}{ + tgbotapi.InlineQueryResultArticle{ + Type: "article", + ID: "forbidden", + Title: msgForbidden, + InputMessageContent: tgbotapi.InputTextMessageContent{ + Text: msgForbidden, + }, + }, + }, + }, nil + } + return cfg, err } func NewInlineService( diff --git a/telegram-service/service/QueryProcessor.go b/telegram-service/service/QueryProcessor.go index 545ca82..6c34e7d 100644 --- a/telegram-service/service/QueryProcessor.go +++ b/telegram-service/service/QueryProcessor.go @@ -25,10 +25,17 @@ type QueryProcessorResult struct { type QueryProcessor interface { Process(ctx context.Context, accountId uuid.UUID, query string, pagination *entity.PaginationOffset) (*QueryProcessorResult, error) ProcessChosenQuery(ctx context.Context, accountId uuid.UUID, resultId string) error + HelpText() string +} + +type QueryProcessorEntry struct { + Permission string + Processor QueryProcessor } type QueryProcessorFactory interface { GetProcessor(rawQuery string) QueryProcessor + Entries() []QueryProcessorEntry } func buildTelegramResults(ctx context.Context, log *slog.Logger, results []entity.MemeSearchResult, mark string) ([]interface{}, error) { @@ -79,13 +86,16 @@ func (p *SearchQueryProcessor) ProcessChosenQuery(_ context.Context, _ uuid.UUID // prefixSearchProcessor is a shared base for processors that strip a prefix, // search, and show results with a fixed caption mark. type prefixSearchProcessor struct { - storage StorageConnector - config *conf.Config - log *slog.Logger - prefix string - mark string + storage StorageConnector + config *conf.Config + log *slog.Logger + prefix string + mark string + helpText string } +func (p *prefixSearchProcessor) HelpText() string { return p.helpText } + func (p *prefixSearchProcessor) Process(ctx context.Context, accountId uuid.UUID, query string, pagination *entity.PaginationOffset) (*QueryProcessorResult, error) { queryString := strings.TrimSpace(strings.TrimPrefix(query, p.prefix)) @@ -136,10 +146,13 @@ func (p *DeleteQueryProcessor) ProcessChosenQuery(ctx context.Context, accountId // The query parameter is a media type specifier ("image", "video", or ""), not a search term. // Pagination is intentionally ignored — random results have no meaningful cursor. type RandomQueryProcessor struct { - storage StorageConnector - log *slog.Logger + storage StorageConnector + log *slog.Logger + helpText string } +func (p *RandomQueryProcessor) HelpText() string { return p.helpText } + func (p *RandomQueryProcessor) Process(ctx context.Context, accountId uuid.UUID, query string, _ *entity.PaginationOffset) (*QueryProcessorResult, error) { mediaType := strings.TrimSpace(strings.TrimPrefix(query, randomPrefix)) mediaType = strings.TrimPrefix(mediaType, "-type:") @@ -201,32 +214,45 @@ func (f *QueryProcessorFactoryImpl) GetProcessor(rawQuery string) QueryProcessor } } +func (f *QueryProcessorFactoryImpl) Entries() []QueryProcessorEntry { + return []QueryProcessorEntry{ + {Permission: PermissionSearch, Processor: f.searchProcessor}, + {Permission: PermissionSearch, Processor: f.randomProcessor}, + {Permission: PermissionDelete, Processor: f.deleteProcessor}, + {Permission: PermissionRecompute, Processor: f.recomputeProcessor}, + } +} + func NewQueryProcessorFactory(storage StorageConnector, config *conf.Config) QueryProcessorFactory { return &QueryProcessorFactoryImpl{ searchProcessor: &SearchQueryProcessor{prefixSearchProcessor{ - storage: storage, - config: config, - log: slog.With("service", "SearchQueryProcessor"), - prefix: "", - mark: "", + storage: storage, + config: config, + log: slog.With("service", "SearchQueryProcessor"), + prefix: "", + mark: "", + helpText: " — search by text", }}, deleteProcessor: &DeleteQueryProcessor{prefixSearchProcessor{ - storage: storage, - config: config, - log: slog.With("service", "DeleteQueryProcessor"), - prefix: inlineDeletePrefix, - mark: "Deleted", + storage: storage, + config: config, + log: slog.With("service", "DeleteQueryProcessor"), + prefix: inlineDeletePrefix, + mark: "Deleted", + helpText: "/delete — delete memes", }}, randomProcessor: &RandomQueryProcessor{ - storage: storage, - log: slog.With("service", "RandomQueryProcessor"), + storage: storage, + log: slog.With("service", "RandomQueryProcessor"), + helpText: "/random — get a random meme", }, recomputeProcessor: &RecomputeQueryProcessor{prefixSearchProcessor{ - storage: storage, - config: config, - log: slog.With("service", "RecomputeQueryProcessor"), - prefix: inlineRecomputePrefix, - mark: "Recompute", + storage: storage, + config: config, + log: slog.With("service", "RecomputeQueryProcessor"), + prefix: inlineRecomputePrefix, + mark: "Recompute", + helpText: "/recompute — recompute meme metadata", }}, } } diff --git a/telegram-service/service/Telegram.go b/telegram-service/service/Telegram.go index 13c0f48..d4e761c 100644 --- a/telegram-service/service/Telegram.go +++ b/telegram-service/service/Telegram.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "errors" - "fmt" "log/slog" "net/http" + "strings" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/weoses/memelo/telegram-service/conf" @@ -14,13 +14,9 @@ import ( const ( msgProcessing = "⏳ Processing..." - msgStartFmt = "Welcome to Memelo Bot!\n\n" + + msgStartIntro = "Welcome to Memelo Bot!\n\n" + "Send me an image or video to save it as a meme.\n\n" + - "Use inline mode to search your collection:\n" + - "• @%s — search by text\n" + - "• @%s /random — get a random meme\n" + - "• @%s /delete — delete memes\n" + - "• @%s /recompute — recompute meme metadata" + "Use inline mode to search your collection:" ) type TelegramBotService interface { @@ -95,9 +91,19 @@ func (s *TelegramBotServiceImpl) dispatchUpdates(ctx context.Context, updates <- func (s *TelegramBotServiceImpl) handleCommand(ctx context.Context, requestMessage *tgbotapi.Message) { switch requestMessage.Command() { case "start": - botName := s.bot.Self.UserName - text := fmt.Sprintf(msgStartFmt, botName, botName, botName, botName) - msg := tgbotapi.NewMessage(requestMessage.Chat.ID, text) + var userId int64 + if requestMessage.From != nil { + userId = requestMessage.From.ID + } + var sb strings.Builder + sb.WriteString(msgStartIntro) + for _, line := range s.inline.HelpLines(userId) { + sb.WriteString("\n• @") + sb.WriteString(s.bot.Self.UserName) + sb.WriteString(" ") + sb.WriteString(line) + } + msg := tgbotapi.NewMessage(requestMessage.Chat.ID, sb.String()) msg.ReplyToMessageID = requestMessage.MessageID if _, err := s.bot.Send(msg); err != nil { s.log.ErrorContext(ctx, "Failed to send start message", "error", err)