├── .gitignore ├── README.md ├── go.mod ├── go.sum └── src ├── CustomReactionHandler.go ├── Main.go ├── commands ├── AddNote.go ├── Ban.go ├── DelMessage.go ├── DelNote.go ├── GetNote.go ├── Id.go ├── Protect.go ├── Start.go └── Unban.go ├── config.json.example ├── config └── Config.go ├── constants ├── Errors.go └── Messages.go ├── db └── DataSource.go ├── handlers ├── Message.go ├── Reaction.go ├── Response.go ├── TopicClosed.go └── TopicReopened.go ├── helpers ├── IsResolvable.go ├── LogError.go ├── LogMessage.go ├── LogUserAction.go ├── NotesMessageGenerator.go ├── ParseInputNote.go ├── ParseInputUser.go ├── ParseUint.go ├── ResolveUser.go ├── SendLargeText.go └── UserActions.go ├── messages ├── GetMessage.go ├── GetMessageBySupportId.go ├── GetMessageByUserId.go └── StoreMessage.go ├── middlewares ├── CheckAdmin.go ├── CheckLanguage.go └── SyncUser.go ├── models ├── Message.go ├── Note.go └── User.go ├── notes └── UserNotes.go ├── rates └── Limiter.go ├── reactions └── ProcessUpdateReactions.go └── users ├── GetUser.go ├── GetUserById.go ├── GetUserByTopicId.go └── GetUserByUsername.go /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | .idea/ 3 | *.iml -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feedbackBot 2 | 3 | ## This bot simplifies user feedback and support management. 4 | 5 | ### Features: 6 | - Enables secure communication with users without sharing accounts. 7 | - Facilitates team communication for multiple support agents. 8 | 9 | # Building and running 10 | 11 | ## Prerequisites 12 | 13 | ### Software Requirements: 14 | - [Golang](https://golang.org/doc/install) installed 15 | - [PostgreSQL](https://www.postgresql.org/download/) server installed 16 | 17 | ### Configuration Steps: 18 | 1. Clone the repository: 19 | ```bash 20 | git clone https://github.com/ti-bone/feedbackBot.git 21 | cd feedbackBot 22 | ``` 23 | 2. Copy the example configuration file: 24 | ```bash 25 | cp src/config.json.example config.json 26 | ``` 27 | 3. Edit the `config.json` file with your configuration details. 28 | 4. Build the application: 29 | ```bash 30 | rm -rf build 31 | mkdir build 32 | cd src 33 | go build -o ../build/feedbackBot . 34 | cd ../build 35 | ``` 36 | 5. Run the PostgreSQL server as described in the [PostgreSQL documentation](https://www.postgresql.org/docs/). 37 | 6. Create new user and database and specify them under `db_dsn` field in your `config.json` file. 38 | 7. Obtain a Telegram bot token: 39 | - Follow [Telegram's instructions on creating new bot](https://core.telegram.org/bots/features#creating-a-new-bot) to create a new bot and get the token. 40 | 8. Set the Telegram bot token in the `bot_token` field inside your `config.json` file. 41 | 9. Add the bot to your Telegram group, you can set `logs_id` to `0`, until you obtained the ID. 42 | 10. Enable topics in the group: 43 | - Follow [Telegram's introduction to topics](https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups) to do so. 44 | 11. Run the application: 45 | ```bash 46 | ./feedbackBot 47 | ``` 48 | - It automatically creates the required database tables and indexes. 49 | 12. Obtain ID of the Telegram group where the bot is added: 50 | - Send `/id` command to the group chat. 51 | - Copy the ID and set it in your `config.json` file. 52 | 13. Restart the application. 53 | 14. Start the bot by sending `/start` command to the bot in DM. 54 | 15. Set your `is_admin` to `true` in your database(it's required for anyone, who needs to reply to users): 55 | - Connect to your database using `psql` or any other client. 56 | - Obtain your user ID by sending `/id` command to the bot in DM. 57 | - Run the following query: 58 | ```sql 59 | UPDATE users SET is_admin = true WHERE id = ; 60 | ``` 61 | Otherwise, you won't be able to reply to users via your group. 62 | 63 | ### Feel free to open an issue if you have any questions or suggestions. 64 | ### Pull requests are welcome! 65 | 66 | # Example Screenshots 67 | 68 | ## Support Agent's (left) Interface and User's Interface (right): 69 | 70 | | ![Support Agent's UI](https://static.bytefuck.dev/feedback-admin-side.png) | ![User's UI](https://static.bytefuck.dev/feedback-user-side.png) | 71 | |:--------------------------------------------------------------------------:|:-----------------------------------------------------------------:| 72 | 73 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module feedbackBot 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.24 7 | gorm.io/driver/postgres v1.5.0 8 | gorm.io/gorm v1.25.0 9 | ) 10 | 11 | require ( 12 | github.com/jackc/pgpassfile v1.0.0 // indirect 13 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 14 | github.com/jackc/pgx/v5 v5.5.4 // indirect 15 | github.com/jackc/puddle/v2 v2.2.1 // indirect 16 | github.com/jinzhu/inflection v1.0.0 // indirect 17 | github.com/jinzhu/now v1.1.5 // indirect 18 | golang.org/x/crypto v0.31.0 // indirect 19 | golang.org/x/sync v0.10.0 // indirect 20 | golang.org/x/text v0.21.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.24 h1:1T7RcpzlldaJ3qpZi0lNg/lBsfPCK+8n8Wc+R8EhAkU= 2 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.24/go.mod h1:kL1v4iIjlalwm3gCYGvF4NLa3hs+aKEfRkNJvj4aoDU= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 8 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 9 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 10 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 11 | github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= 12 | github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= 13 | github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 14 | github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 15 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 16 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 17 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 18 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 19 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 20 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 21 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 22 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 23 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 24 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 25 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 30 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 31 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 32 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 33 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 34 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 35 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 37 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 38 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 39 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 40 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 41 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 42 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 43 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 44 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 45 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 46 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 47 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 48 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 49 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 50 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 51 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 54 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 55 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 56 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 62 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 63 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 64 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 65 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 66 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 67 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 68 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 69 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 70 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 71 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 72 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 73 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 74 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 75 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 77 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 78 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 80 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 81 | gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= 82 | gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= 83 | gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 84 | gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU= 85 | gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 86 | -------------------------------------------------------------------------------- /src/CustomReactionHandler.go: -------------------------------------------------------------------------------- 1 | /* 2 | * CustomReactionHandler.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "github.com/PaulSonOfLars/gotgbot/v2" 11 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 12 | ) 13 | 14 | type Reaction struct { 15 | Filter bool 16 | Response func(b *gotgbot.Bot, ctx *ext.Context) error 17 | } 18 | 19 | func NewReaction(f bool, r func(b *gotgbot.Bot, ctx *ext.Context) error) Reaction { 20 | return Reaction{ 21 | Filter: f, 22 | Response: r, 23 | } 24 | } 25 | 26 | func (r Reaction) CheckUpdate(b *gotgbot.Bot, ctx *ext.Context) bool { 27 | if ctx.MessageReaction == nil { 28 | return false 29 | } 30 | return true 31 | } 32 | 33 | func (r Reaction) HandleUpdate(b *gotgbot.Bot, ctx *ext.Context) error { 34 | return r.Response(b, ctx) 35 | } 36 | 37 | func (r Reaction) Name() string { 38 | return fmt.Sprintf("reaction_%p", r.Response) 39 | } 40 | -------------------------------------------------------------------------------- /src/Main.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Main.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package main 7 | 8 | import ( 9 | "feedbackBot/src/commands" 10 | "feedbackBot/src/config" 11 | "feedbackBot/src/constants" 12 | "feedbackBot/src/db" 13 | "feedbackBot/src/handlers" 14 | "feedbackBot/src/helpers" 15 | "feedbackBot/src/middlewares" 16 | "fmt" 17 | "github.com/PaulSonOfLars/gotgbot/v2" 18 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 19 | updateHandlers "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 20 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" 21 | "log" 22 | "net/http" 23 | "time" 24 | ) 25 | 26 | func main() { 27 | config.LoadConfig("config.json") 28 | 29 | db.Init() 30 | 31 | b, err := gotgbot.NewBot(config.CurrentConfig.BotToken, &gotgbot.BotOpts{ 32 | BotClient: &gotgbot.BaseBotClient{ 33 | Client: http.Client{}, 34 | DefaultRequestOpts: &gotgbot.RequestOpts{ 35 | Timeout: 5 * time.Second, 36 | APIURL: gotgbot.DefaultAPIURL, 37 | }, 38 | }, 39 | }) 40 | 41 | if err != nil { 42 | panic("failed to create new bot: " + err.Error()) 43 | } 44 | 45 | // Create updater and dispatcher. 46 | dispatcher := ext.NewDispatcher(&ext.DispatcherOpts{ 47 | // If an error is returned by a handler, log it and continue going. 48 | Error: func(b *gotgbot.Bot, ctx *ext.Context, err error) ext.DispatcherAction { 49 | log.Println("an error occurred while handling update:", err.Error()) 50 | 51 | // Log error to chat 52 | errorText := constants.InternalError.Error() 53 | 54 | if config.CurrentConfig.DiscloseErrorInternals { 55 | errorText = err.Error() 56 | } 57 | 58 | err = helpers.LogError(errorText, b, ctx) 59 | if err != nil { 60 | log.Println("an error occurred while logging error:", err.Error()) 61 | } 62 | 63 | return ext.DispatcherActionNoop 64 | }, 65 | MaxRoutines: ext.DefaultMaxRoutines, 66 | }) 67 | 68 | // Handlers 69 | updater := ext.NewUpdater(dispatcher, nil) 70 | 71 | // Middleware for syncing user in DB for any update from a user 72 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.All, middlewares.SyncUser), -1) 73 | 74 | /* 75 | * User handlers 76 | */ 77 | 78 | // Middleware for language filtering 79 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.Private, middlewares.CheckLanguage), 0) 80 | 81 | // Command handlers 82 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("start", commands.Start), 0) 83 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("id", commands.Id), 0) 84 | 85 | // Message handlers 86 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.Private, handlers.Message), 0) 87 | dispatcher.AddHandlerToGroup(NewReaction(true, handlers.Reaction), 0) 88 | 89 | /* 90 | * Admin handlers 91 | */ 92 | 93 | // Middleware for admin checking 94 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.All, middlewares.CheckAdmin), 1) 95 | 96 | // Command handlers 97 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("ban", commands.Ban), 1) 98 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("unban", commands.Unban), 1) 99 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("protect", commands.Protect), 1) 100 | 101 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("add", commands.AddNote), 1) 102 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("del", commands.DelNote), 1) 103 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("get", commands.GetNotes), 1) 104 | 105 | dispatcher.AddHandlerToGroup(updateHandlers.NewCommand("mdel", commands.DelMessage), 1) 106 | 107 | // Topic-related handlers 108 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.TopicReopened, handlers.TopicReopened), 1) 109 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.TopicClosed, handlers.TopicClosed), 1) 110 | 111 | // Response handler 112 | dispatcher.AddHandlerToGroup(updateHandlers.NewMessage(message.Supergroup, handlers.Response), 1) 113 | 114 | err = updater.StartPolling(b, &ext.PollingOpts{ 115 | DropPendingUpdates: false, 116 | GetUpdatesOpts: &gotgbot.GetUpdatesOpts{ 117 | Timeout: 60, 118 | RequestOpts: &gotgbot.RequestOpts{ 119 | Timeout: time.Second * 70, 120 | }, 121 | AllowedUpdates: []string{"message", "edited_message", "message_reaction", "my_chat_member"}, 122 | }, 123 | }) 124 | if err != nil { 125 | panic("failed to start polling: " + err.Error()) 126 | } 127 | 128 | fmt.Printf("@%s has been started...\n", b.User.Username) 129 | helpers.LogMessage(fmt.Sprintf("#SYSTEM\n@%s has been started...", b.User.Username), b) 130 | 131 | updater.Idle() 132 | } 133 | -------------------------------------------------------------------------------- /src/commands/AddNote.go: -------------------------------------------------------------------------------- 1 | /* 2 | * AddNote.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | "feedbackBot/src/helpers" 11 | "feedbackBot/src/notes" 12 | "feedbackBot/src/users" 13 | "fmt" 14 | "github.com/PaulSonOfLars/gotgbot/v2" 15 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 16 | "strings" 17 | ) 18 | 19 | func AddNote(b *gotgbot.Bot, ctx *ext.Context) error { 20 | // Check if the user is provided 21 | user, isTopicMessage, err := helpers.ResolveUserWithSource(ctx, b) 22 | 23 | if err != nil || user == nil { 24 | return err 25 | } 26 | 27 | var noteText string 28 | 29 | switch { 30 | case !isTopicMessage && len(ctx.Args()) > 2: 31 | noteText = strings.Join(ctx.Args()[2:], " ") 32 | case isTopicMessage && len(ctx.Args()) > 1: 33 | noteText = strings.Join(ctx.Args()[1:], " ") 34 | } 35 | 36 | if len(noteText) == 0 { 37 | _, err := ctx.EffectiveMessage.Reply(b, constants.NoteTextNotSpecified.Error(), &gotgbot.SendMessageOpts{}) 38 | return err 39 | } 40 | 41 | addedBy, err := users.GetUserById(ctx.EffectiveMessage.From.Id) 42 | 43 | if err != nil { 44 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 45 | return err 46 | } 47 | 48 | noteId, err := notes.AddNote(user, noteText, addedBy) 49 | 50 | if err != nil { 51 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 52 | return err 53 | } 54 | 55 | _, err = ctx.EffectiveMessage.Reply( 56 | b, 57 | fmt.Sprintf(constants.NoteAdded, noteId, user.UserId), 58 | &gotgbot.SendMessageOpts{}, 59 | ) 60 | 61 | return err 62 | } 63 | -------------------------------------------------------------------------------- /src/commands/Ban.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Ban.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/helpers" 12 | "fmt" 13 | "github.com/PaulSonOfLars/gotgbot/v2" 14 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 15 | ) 16 | 17 | func Ban(b *gotgbot.Bot, ctx *ext.Context) error { 18 | // Resolve user 19 | user, err := helpers.ResolveUser(ctx, b) 20 | 21 | if err != nil || user == nil { 22 | return err 23 | } 24 | 25 | err = helpers.BanUser(user) 26 | 27 | if errors.Is(err, constants.UserAlreadyBanned) { 28 | _, err = ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 29 | } else if err != nil { 30 | return err 31 | } 32 | 33 | _, err = ctx.EffectiveMessage.Reply( 34 | b, 35 | fmt.Sprintf("#u%d has been banned.", user.UserId), 36 | &gotgbot.SendMessageOpts{}, 37 | ) 38 | 39 | return err 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/DelMessage.go: -------------------------------------------------------------------------------- 1 | /* 2 | * DelMessage.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/messages" 12 | "fmt" 13 | "github.com/PaulSonOfLars/gotgbot/v2" 14 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 15 | "log" 16 | "os" 17 | ) 18 | 19 | func DelMessage(b *gotgbot.Bot, ctx *ext.Context) error { 20 | if ctx.EffectiveMessage.ReplyToMessage == nil { 21 | _, err := ctx.EffectiveMessage.Reply(b, constants.NoMessageToDelete.Error(), &gotgbot.SendMessageOpts{}) 22 | return err 23 | } 24 | 25 | replyTo := ctx.EffectiveMessage.ReplyToMessage 26 | 27 | if replyTo.MessageId == replyTo.MessageThreadId { 28 | _, err := ctx.EffectiveMessage.Reply(b, constants.NoMessageToDelete.Error(), &gotgbot.SendMessageOpts{}) 29 | return err 30 | } 31 | 32 | message, err := messages.GetMessageBySupportId(replyTo.MessageId, ctx.EffectiveChat.Id) 33 | 34 | if err != nil { 35 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 36 | return err 37 | } 38 | 39 | _, err = b.DeleteMessage(message.UserId, message.UserMessageId, &gotgbot.DeleteMessageOpts{}) 40 | 41 | // If delete failed due to message not found - output error message and return 42 | var tgErr *gotgbot.TelegramError 43 | 44 | if errors.As(err, &tgErr) { 45 | if tgErr.Description == "Bad Request: message to delete not found" { 46 | _, err = ctx.EffectiveMessage.Reply(b, constants.MessageAlreadyDeleted.Error(), &gotgbot.SendMessageOpts{}) 47 | return err 48 | } 49 | } 50 | 51 | if err != nil { 52 | log.SetOutput(os.Stderr) 53 | log.Println(err.Error()) 54 | 55 | _, err := ctx.EffectiveMessage.Reply(b, constants.InternalError.Error(), &gotgbot.SendMessageOpts{}) 56 | return err 57 | } 58 | 59 | _, err = ctx.EffectiveMessage.Reply( 60 | b, 61 | fmt.Sprintf( 62 | constants.MessageDeleted, 63 | message.UserMessageId, message.UserId, 64 | ), 65 | &gotgbot.SendMessageOpts{}, 66 | ) 67 | 68 | return err 69 | } 70 | -------------------------------------------------------------------------------- /src/commands/DelNote.go: -------------------------------------------------------------------------------- 1 | /* 2 | * DelNote.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | "feedbackBot/src/helpers" 11 | "feedbackBot/src/notes" 12 | "fmt" 13 | "github.com/PaulSonOfLars/gotgbot/v2" 14 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 15 | ) 16 | 17 | func DelNote(b *gotgbot.Bot, ctx *ext.Context) error { 18 | args := ctx.Args() 19 | 20 | if len(args) <= 1 { 21 | _, err := ctx.EffectiveMessage.Reply(b, constants.NoteIdInvalid.Error(), &gotgbot.SendMessageOpts{}) 22 | return err 23 | } 24 | 25 | noteId, err := helpers.ParseNoteId(args[1]) 26 | 27 | if err != nil { 28 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 29 | return err 30 | } 31 | 32 | err = notes.DeleteNoteById(noteId) 33 | 34 | if err != nil { 35 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 36 | return err 37 | } 38 | 39 | _, err = ctx.EffectiveMessage.Reply(b, fmt.Sprintf(constants.NoteDeleted, noteId), &gotgbot.SendMessageOpts{}) 40 | 41 | return err 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/GetNote.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetNote.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "feedbackBot/src/helpers" 10 | "feedbackBot/src/notes" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | ) 14 | 15 | func GetNotes(b *gotgbot.Bot, ctx *ext.Context) error { 16 | user, err := helpers.ResolveUser(ctx, b) 17 | 18 | if err != nil || user == nil { 19 | return err 20 | } 21 | 22 | userNotes, err := notes.GetNotes(user) 23 | 24 | if err != nil { 25 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 26 | return err 27 | } 28 | 29 | message := helpers.GenerateNotesMessage(userNotes) 30 | 31 | return helpers.SendLargeText(ctx, b, message, &gotgbot.SendMessageOpts{ParseMode: "HTML"}) 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/Id.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Id.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "feedbackBot/src/rates" 10 | "fmt" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | ) 14 | 15 | func Id(b *gotgbot.Bot, ctx *ext.Context) error { 16 | var err error 17 | // Check if chat is not rate-limited 18 | if rates.Check(ctx.EffectiveChat.Id, 60) { 19 | // Send message with ID 20 | _, err = ctx.EffectiveMessage.Reply( 21 | b, 22 | fmt.Sprintf("Chat ID: %d", ctx.EffectiveChat.Id), 23 | &gotgbot.SendMessageOpts{ParseMode: "HTML"}, 24 | ) 25 | } 26 | 27 | return err 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/Protect.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Protect.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | "feedbackBot/src/db" 11 | "feedbackBot/src/helpers" 12 | "fmt" 13 | "github.com/PaulSonOfLars/gotgbot/v2" 14 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 15 | "log" 16 | "os" 17 | ) 18 | 19 | func Protect(b *gotgbot.Bot, ctx *ext.Context) error { 20 | // Resolve user 21 | user, err := helpers.ResolveUser(ctx, b) 22 | 23 | if err != nil || user == nil { 24 | return err 25 | } 26 | 27 | var result string 28 | 29 | // Set the result text depending on user's is_protected field 30 | if user.IsProtected { 31 | result = constants.Disabled 32 | } else { 33 | result = constants.Enabled 34 | } 35 | 36 | // Update user's is_protected field in the database 37 | res := db.Connection.Model(&user).Update("is_protected", !user.IsProtected) 38 | err = res.Error 39 | 40 | if err != nil { 41 | log.SetOutput(os.Stderr) 42 | log.Println(err) 43 | 44 | _, err := ctx.EffectiveMessage.Reply(b, constants.InternalError.Error(), &gotgbot.SendMessageOpts{}) 45 | return err 46 | } 47 | 48 | // Send the result text to the user 49 | _, err = ctx.EffectiveMessage.Reply(b, fmt.Sprintf(constants.Protected, result, user.UserId), &gotgbot.SendMessageOpts{}) 50 | 51 | return err 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/Start.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Start.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "feedbackBot/src/rates" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | ) 14 | 15 | func Start(b *gotgbot.Bot, ctx *ext.Context) error { 16 | var err error 17 | 18 | // Check if user is not rate-limited and welcome message is enabled 19 | if rates.Check(ctx.EffectiveChat.Id, 10) && config.CurrentConfig.Welcome.Enabled { 20 | // Send welcome message 21 | _, err = ctx.EffectiveMessage.Reply( 22 | b, 23 | config.CurrentConfig.Welcome.Message, 24 | &gotgbot.SendMessageOpts{ParseMode: "HTML"}, 25 | ) 26 | } 27 | 28 | return err 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/Unban.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Unban.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package commands 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/helpers" 12 | "fmt" 13 | "github.com/PaulSonOfLars/gotgbot/v2" 14 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 15 | ) 16 | 17 | func Unban(b *gotgbot.Bot, ctx *ext.Context) error { 18 | user, err := helpers.ResolveUser(ctx, b) 19 | 20 | if err != nil || user == nil { 21 | return err 22 | } 23 | 24 | err = helpers.UnbanUser(user) 25 | 26 | if err != nil && errors.Is(err, constants.UserNotBanned) { 27 | _, err = ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 28 | } else if err != nil { 29 | return err 30 | } 31 | 32 | _, err = ctx.EffectiveMessage.Reply(b, fmt.Sprintf("Ban lifted from #u%d.", user.UserId), &gotgbot.SendMessageOpts{}) 33 | return err 34 | } 35 | -------------------------------------------------------------------------------- /src/config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "bot_token": "0:AA", 3 | "logs_id": -100, 4 | "logs_topic_id": 0, 5 | "db_dsn": "host=10.10.10.2 port=5432 dbname=feedbackBot user=feedbackBot password=feedbackBot", 6 | "welcome": { 7 | "enabled": true, 8 | "message": "Hello! Write a message and I'll send it to my owners." 9 | }, 10 | "is_protected_default": false 11 | "language_filter": { 12 | "enabled": true, 13 | "forbidden_languages": ["en", "ru"], 14 | "message": "Sorry, but I can't assist you because of your Telegram's language.", 15 | "error_rate_limit": 60 16 | }, 17 | "disclose_error_internals": false, 18 | } 19 | -------------------------------------------------------------------------------- /src/config/Config.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Config.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package config 7 | 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "log" 13 | "os" 14 | ) 15 | 16 | // Configuration - describes the configuration file 17 | type Configuration struct { 18 | BotToken string `json:"bot_token"` 19 | DbDSN string `json:"db_dsn"` 20 | LogsID int64 `json:"logs_id"` 21 | LogsTopicID int64 `json:"logs_topic_id"` 22 | Welcome struct { 23 | Enabled bool `json:"enabled"` 24 | Message string `json:"message"` 25 | } `json:"welcome"` 26 | IsProtectedDefault bool `json:"is_protected_default"` 27 | LanguageFilter struct { 28 | Enabled bool `json:"enabled"` 29 | ForbiddenLanguages []string `json:"forbidden_languages"` 30 | Message string `json:"message"` 31 | ErrorRateLimit int64 `json:"error_rate_limit"` 32 | } `json:"language_filter"` 33 | DiscloseErrorInternals bool `json:"disclose_error_internals"` 34 | } 35 | 36 | // CurrentConfig - stores the current configuration 37 | var CurrentConfig Configuration 38 | 39 | // LoadConfig - loads configuration from a file and stores it in CurrentConfig 40 | func LoadConfig(filename string) { 41 | jsonFile, err := os.Open(filename) 42 | if err != nil { 43 | panic(fmt.Sprintf("error reading config: %v", err)) 44 | } 45 | 46 | defer func(jsonFile *os.File) { 47 | err := jsonFile.Close() 48 | if err != nil { 49 | panic(fmt.Sprintf("error closing config file: %v", err)) 50 | } 51 | }(jsonFile) 52 | 53 | byteValue, err := io.ReadAll(jsonFile) 54 | if err != nil { 55 | panic(fmt.Sprintf("error reading config: %v", err)) 56 | } 57 | 58 | err = json.Unmarshal(byteValue, &CurrentConfig) 59 | if err != nil { 60 | panic(fmt.Sprintf("error unmarshalling config: %v", err)) 61 | } 62 | 63 | // Check welcome message configuration 64 | if CurrentConfig.Welcome.Enabled && CurrentConfig.Welcome.Message == "" { 65 | panic("[!!!CONFIGURATION ERROR!!!] Welcome message is enabled, but not set.") 66 | } 67 | 68 | // Check language filter configuration 69 | langFilterConfig := CurrentConfig.LanguageFilter 70 | 71 | if langFilterConfig.Enabled { 72 | if langFilterConfig.Message == "" { 73 | panic("[!!!CONFIGURATION ERROR!!!] Language filter is enabled, but error message is not set.") 74 | } 75 | 76 | if len(langFilterConfig.ForbiddenLanguages) == 0 { 77 | panic("[!!!CONFIGURATION ERROR!!!] Language filter is enabled, but no languages are set.") 78 | } 79 | 80 | if langFilterConfig.ErrorRateLimit <= 0 { 81 | panic("[!!!CONFIGURATION ERROR!!!] " + 82 | "Language filter is enabled, but error rate limit whether is not set, or it is negative integer.") 83 | } 84 | } 85 | 86 | log.SetOutput(os.Stdout) 87 | log.Printf("Successfully loaded configuration from %s\n", filename) 88 | } 89 | -------------------------------------------------------------------------------- /src/constants/Errors.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Errors.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package constants 7 | 8 | import "errors" 9 | 10 | var UserAlreadyBanned = errors.New("-400: user is already banned") 11 | var UserNotBanned = errors.New("-400: user is not banned") 12 | var UserNotSpecified = errors.New("-400: user not specified") 13 | var NoteIdInvalid = errors.New("-400: invalid noteid") 14 | var NoteTextNotSpecified = errors.New("-400: invalid note text") 15 | var UserIdInvalid = errors.New("-400: invalid userid") 16 | var UserInvalid = errors.New("-400: invalid userid or username") 17 | var NoteTooLong = errors.New("-400: note too long") 18 | var NoMessageToDelete = errors.New("-400: no message to delete specified") 19 | var MessageAlreadyDeleted = errors.New("-400: this message is already deleted on user's side") 20 | 21 | var BotUserBlocked = errors.New("-403: bot was blocked by the user") 22 | 23 | var UserNotFound = errors.New("-404: user not found") 24 | var MessageNotFound = errors.New("-404: message not found") 25 | var NotesNotFound = errors.New("-404: notes for this user not found") 26 | var NoteNotFound = errors.New("-404: note with such id not found") 27 | 28 | var InternalError = errors.New("-500: internal error") 29 | -------------------------------------------------------------------------------- /src/constants/Messages.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Messages.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package constants 7 | 8 | const Protected = "Protected mode is now %s for #u%d" 9 | const Enabled = "enabled" 10 | const Disabled = "disabled" 11 | 12 | const NoteAdded = "Note added(#n%d) for #u%d" 13 | const NoteDeleted = "Note #n%d deleted" 14 | 15 | const MessageDeleted = "Message #m%d in #u%d has been deleted." 16 | -------------------------------------------------------------------------------- /src/db/DataSource.go: -------------------------------------------------------------------------------- 1 | /* 2 | * DataSource.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package db 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "feedbackBot/src/models" 11 | "fmt" 12 | "gorm.io/driver/postgres" 13 | "gorm.io/gorm" 14 | "log" 15 | "os" 16 | ) 17 | 18 | // Connection - the DB connection 19 | var Connection *gorm.DB 20 | 21 | // Init - initializes the DB connection, auto-migrates the users table and stores the connection in Connection 22 | func Init() { 23 | var err error 24 | Connection, err = gorm.Open(postgres.Open(config.CurrentConfig.DbDSN), &gorm.Config{}) 25 | if err != nil { 26 | panic(fmt.Errorf("failed to connect to the database: %w", err)) 27 | } 28 | 29 | log.SetOutput(os.Stdout) 30 | 31 | log.Println("Trying to auto-migrate users table...") 32 | err = Connection.AutoMigrate(&models.User{}) 33 | if err != nil { 34 | panic(fmt.Errorf("failed to auto-migrate users table: %w", err)) 35 | } 36 | 37 | log.Println("Auto-migrated users table.") 38 | 39 | log.Println("Trying to auto-migrate notes table...") 40 | err = Connection.AutoMigrate(&models.Note{}) 41 | if err != nil { 42 | panic(fmt.Errorf("failed to auto-migrate notes table: %w", err)) 43 | } 44 | 45 | log.Println("Auto-migrated notes table.") 46 | 47 | log.Println("Trying to auto-migrate messages table...") 48 | err = Connection.AutoMigrate(&models.Message{}) 49 | if err != nil { 50 | panic(fmt.Errorf("failed to auto-migrate messages table: %w", err)) 51 | } 52 | 53 | log.Println("Auto-migrated messages table, successfully connected to the DB.") 54 | } 55 | -------------------------------------------------------------------------------- /src/handlers/Message.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Message.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package handlers 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/config" 11 | "feedbackBot/src/db" 12 | "feedbackBot/src/helpers" 13 | "feedbackBot/src/messages" 14 | "feedbackBot/src/models" 15 | "fmt" 16 | "github.com/PaulSonOfLars/gotgbot/v2" 17 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 18 | "html" 19 | "log" 20 | "os" 21 | "time" 22 | ) 23 | 24 | func Message(b *gotgbot.Bot, ctx *ext.Context) error { 25 | if ctx.EffectiveSender.Id() == b.Id { 26 | return nil 27 | } 28 | 29 | var handleMessage func(int) error 30 | 31 | handleMessage = func(depth int) error { 32 | if depth > 5 { 33 | // Return an error when reaching the maximum depth 34 | return errors.New("maximum recursion depth reached") 35 | } 36 | 37 | var user models.User 38 | 39 | res := db.Connection.Where("user_id = ?", ctx.EffectiveUser.Id).First(&user) 40 | 41 | if res.Error != nil { 42 | // Log the error to admin topic 43 | err := helpers.LogError( 44 | fmt.Sprintf("Got DB error: %v, retrying(attempt %d)...", res.Error, depth), 45 | b, ctx, 46 | ) 47 | 48 | if err != nil { 49 | log.SetOutput(os.Stderr) 50 | log.Printf("failed to log error: %v", err.Error()) 51 | } 52 | 53 | // Wait for 2s and retry 54 | time.Sleep(2 * time.Second) 55 | 56 | // Retry 57 | return handleMessage(depth + 1) 58 | } 59 | 60 | if user.IsBanned { 61 | return nil 62 | } 63 | 64 | if user.TopicId == 0 { 65 | return handleNoTopic(b, ctx, &user, handleMessage, depth) 66 | } 67 | 68 | supportId := config.CurrentConfig.LogsID 69 | 70 | // Forward message to the user's topic 71 | response, err := b.ForwardMessage( 72 | supportId, 73 | ctx.EffectiveChat.Id, 74 | ctx.EffectiveMessage.MessageId, 75 | &gotgbot.ForwardMessageOpts{ 76 | MessageThreadId: user.TopicId, 77 | }, 78 | ) 79 | 80 | message := models.Message{ 81 | UserId: user.UserId, 82 | UserMessageId: ctx.EffectiveMessage.MessageId, 83 | SupportChatId: config.CurrentConfig.LogsID, 84 | IsOutgoing: false, 85 | } 86 | 87 | // Call to response may produce panic, because response could be nil, so we check it 88 | if response != nil { 89 | message.SupportMessageId = response.MessageId 90 | } 91 | 92 | var tgErr *gotgbot.TelegramError 93 | 94 | if errors.As(err, &tgErr) { 95 | // If thread not found - try to recreate topic 96 | if tgErr.Description == "Bad Request: message thread not found" { 97 | return handleNoTopic(b, ctx, &user, handleMessage, depth) 98 | } 99 | } 100 | 101 | // If failed, try to copy message 102 | // (can be useful if the user has SCAM flag, Telegram doesn't allow to forward messages from such users 103 | if err != nil { 104 | messageId, err := b.CopyMessage( 105 | supportId, 106 | ctx.EffectiveChat.Id, 107 | ctx.EffectiveMessage.MessageId, 108 | &gotgbot.CopyMessageOpts{ 109 | MessageThreadId: user.TopicId, 110 | }, 111 | ) 112 | 113 | message.SupportMessageId = messageId.MessageId 114 | 115 | if err != nil { 116 | return err 117 | } 118 | } 119 | 120 | // Store message info in DB 121 | return messages.StoreMessage(message) 122 | } 123 | 124 | return handleMessage(1) 125 | } 126 | 127 | func handleNoTopic( 128 | b *gotgbot.Bot, ctx *ext.Context, 129 | user *models.User, handleMessage func(int) error, 130 | currentDepth int, 131 | ) error { 132 | topic, err := b.CreateForumTopic( 133 | config.CurrentConfig.LogsID, 134 | fmt.Sprintf( 135 | "%s [%d]", 136 | ctx.EffectiveUser.FirstName, 137 | ctx.EffectiveUser.Id, 138 | ), 139 | &gotgbot.CreateForumTopicOpts{}, 140 | ) 141 | 142 | if err != nil { 143 | return err 144 | } 145 | 146 | var username string 147 | 148 | if ctx.EffectiveSender.User.Username != "" { 149 | username = "\nUsername: @" + ctx.EffectiveSender.User.Username 150 | } 151 | 152 | _, err = b.SendMessage( 153 | config.CurrentConfig.LogsID, 154 | fmt.Sprintf( 155 | "This topic with ID %d belongs to user %s %sID: %d%s", 156 | topic.MessageThreadId, 157 | html.EscapeString(ctx.EffectiveUser.FirstName), 158 | ""+html.EscapeString(ctx.EffectiveUser.LastName)+" ", 159 | ctx.EffectiveUser.Id, 160 | username, 161 | ), 162 | &gotgbot.SendMessageOpts{ 163 | ParseMode: "HTML", 164 | MessageThreadId: topic.MessageThreadId, 165 | }, 166 | ) 167 | 168 | if err != nil { 169 | // Delete topic(no need for it, because first message failed to send) 170 | _, _ = b.DeleteForumTopic(config.CurrentConfig.LogsID, topic.MessageThreadId, &gotgbot.DeleteForumTopicOpts{}) 171 | 172 | return err 173 | } 174 | 175 | // Set the topic ID to the user and write it to the DB 176 | user.TopicId = topic.MessageThreadId 177 | db.Connection.Where("user_id = ?", user.UserId).Updates(&user) 178 | 179 | return handleMessage(currentDepth + 1) 180 | } 181 | -------------------------------------------------------------------------------- /src/handlers/Reaction.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Reaction.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package handlers 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/config" 11 | "feedbackBot/src/constants" 12 | "feedbackBot/src/helpers" 13 | "feedbackBot/src/messages" 14 | "feedbackBot/src/reactions" 15 | "github.com/PaulSonOfLars/gotgbot/v2" 16 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 17 | ) 18 | 19 | func Reaction(b *gotgbot.Bot, ctx *ext.Context) error { 20 | reaction := ctx.MessageReaction 21 | 22 | if reaction.Chat.Id == config.CurrentConfig.LogsID { 23 | // If the reaction was set in the logs chat, process it as outgoing reaction to the user 24 | message, err := messages.GetMessageBySupportId(reaction.MessageId, reaction.Chat.Id) 25 | 26 | if errors.Is(err, constants.MessageNotFound) { 27 | return nil 28 | } 29 | 30 | if err != nil { 31 | return helpers.LogError(err.Error(), b, ctx) 32 | } 33 | 34 | return reactions.ProcessUpdateReactions(reaction, b, message.UserId, message.UserMessageId) 35 | } else if reaction.User != nil && reaction.Chat.Id == reaction.User.Id { 36 | // If the reaction was set by the user, process it as incoming reaction to the support 37 | message, err := messages.GetMessageByUserId(reaction.MessageId, reaction.User.Id) 38 | 39 | if errors.Is(err, constants.MessageNotFound) { 40 | return nil 41 | } 42 | 43 | if err != nil { 44 | return helpers.LogError(err.Error(), b, ctx) 45 | } 46 | 47 | return reactions.ProcessUpdateReactions(reaction, b, message.SupportChatId, message.SupportMessageId) 48 | } 49 | 50 | // Ignore others 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /src/handlers/Response.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Response.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package handlers 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/db" 12 | "feedbackBot/src/messages" 13 | "feedbackBot/src/models" 14 | "github.com/PaulSonOfLars/gotgbot/v2" 15 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 16 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" 17 | ) 18 | 19 | func Response(b *gotgbot.Bot, ctx *ext.Context) error { 20 | // Check if the message is a service message, describing topic action 21 | if message.TopicAction(ctx.EffectiveMessage) { 22 | return nil 23 | } 24 | 25 | var err error 26 | var user models.User 27 | 28 | // Get target user from the DB by topic ID 29 | db.Connection.Where("topic_id = ?", ctx.EffectiveMessage.MessageThreadId).First(&user) 30 | 31 | // If user is not found, return 32 | if user.TopicId != 0 && !user.IsBanned { 33 | id, err := b.CopyMessage( 34 | user.UserId, 35 | ctx.EffectiveChat.Id, 36 | ctx.EffectiveMessage.MessageId, 37 | &gotgbot.CopyMessageOpts{ProtectContent: user.IsProtected}, 38 | ) 39 | 40 | var tgErr *gotgbot.TelegramError 41 | 42 | if errors.As(err, &tgErr) { 43 | if tgErr.Description == "Forbidden: bot was blocked by the user" { 44 | _, err = ctx.EffectiveMessage.Reply(b, constants.BotUserBlocked.Error(), &gotgbot.SendMessageOpts{}) 45 | return err 46 | } 47 | } 48 | 49 | if err != nil { 50 | return err 51 | } 52 | 53 | // Save the message identifiers relation 54 | return messages.StoreMessage( 55 | models.Message{ 56 | UserId: user.UserId, 57 | UserMessageId: id.MessageId, 58 | SupportMessageId: ctx.EffectiveMessage.MessageId, 59 | SupportChatId: ctx.EffectiveChat.Id, 60 | IsOutgoing: true, 61 | }, 62 | ) 63 | } 64 | 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /src/handlers/TopicClosed.go: -------------------------------------------------------------------------------- 1 | /* 2 | * TopicReopened.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package handlers 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/helpers" 12 | "feedbackBot/src/users" 13 | "fmt" 14 | "github.com/PaulSonOfLars/gotgbot/v2" 15 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 16 | ) 17 | 18 | func TopicClosed(b *gotgbot.Bot, ctx *ext.Context) error { 19 | user, err := users.GetUserByTopicId(ctx.EffectiveMessage.MessageThreadId) 20 | 21 | if err != nil && errors.Is(err, constants.UserNotFound) { 22 | return nil 23 | } else if err != nil { 24 | return err 25 | } 26 | 27 | err = helpers.BanUser(user) 28 | 29 | if err != nil && errors.Is(err, constants.UserAlreadyBanned) { 30 | return nil 31 | } else if err != nil { 32 | return err 33 | } 34 | 35 | helpers.LogMessage(fmt.Sprintf("#u%d has been banned. Reason: topic with user closed.", user.UserId), b) 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /src/handlers/TopicReopened.go: -------------------------------------------------------------------------------- 1 | /* 2 | * TopicReopened.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package handlers 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/helpers" 12 | "feedbackBot/src/users" 13 | "fmt" 14 | "github.com/PaulSonOfLars/gotgbot/v2" 15 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 16 | ) 17 | 18 | func TopicReopened(b *gotgbot.Bot, ctx *ext.Context) error { 19 | user, err := users.GetUserByTopicId(ctx.EffectiveMessage.MessageThreadId) 20 | 21 | if err != nil && errors.Is(err, constants.UserNotFound) { 22 | return nil 23 | } else if err != nil { 24 | return err 25 | } 26 | 27 | err = helpers.UnbanUser(user) 28 | 29 | if err != nil && errors.Is(err, constants.UserNotBanned) { 30 | return nil 31 | } else if err != nil { 32 | return err 33 | } 34 | 35 | helpers.LogMessage(fmt.Sprintf("Ban lifted from #u%d. Reason: topic with user reopened.", user.UserId), b) 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /src/helpers/IsResolvable.go: -------------------------------------------------------------------------------- 1 | /* 2 | * IsResolvable.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "feedbackBot/src/constants" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | ) 14 | 15 | func IsResolvable(ctx *ext.Context, b *gotgbot.Bot) (bool, bool, error) { 16 | args := ctx.Args() 17 | topicId := ctx.EffectiveMessage.MessageThreadId 18 | isValidTopicMessage := topicId != 0 && topicId != config.CurrentConfig.LogsTopicID 19 | 20 | if !isValidTopicMessage && len(args) <= 1 { 21 | _, err := ctx.EffectiveMessage.Reply( 22 | b, 23 | constants.UserNotSpecified.Error()+"\nHint: you can send this command to any user-related topic.", 24 | &gotgbot.SendMessageOpts{}, 25 | ) 26 | 27 | return false, false, err 28 | } 29 | 30 | return true, isValidTopicMessage, nil 31 | } 32 | -------------------------------------------------------------------------------- /src/helpers/LogError.go: -------------------------------------------------------------------------------- 1 | /* 2 | * LogError.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "fmt" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | "html" 14 | "math" 15 | ) 16 | 17 | // LogError - logs error to the logs chat 18 | func LogError(error string, b *gotgbot.Bot, ctx *ext.Context) error { 19 | user := ctx.EffectiveUser 20 | chat := ctx.EffectiveChat 21 | 22 | logsID := config.CurrentConfig.LogsID 23 | 24 | logMessage := fmt.Sprintf( 25 | "#ERROR\nerr: %s\nUserID: %d\nFirst Name: %s", 26 | error, 27 | user.Id, 28 | html.EscapeString(user.FirstName)) 29 | 30 | if user.LastName != "" { 31 | logMessage = fmt.Sprintf("%s\nLast Name: %s", logMessage, html.EscapeString(user.LastName)) 32 | } 33 | 34 | if user.Username != "" { 35 | logMessage = fmt.Sprintf("%s\nUsername: @%s", logMessage, html.EscapeString(user.Username)) 36 | } 37 | 38 | if user.LanguageCode != "" { 39 | logMessage = fmt.Sprintf( 40 | "%s\nLanguage: %s", 41 | logMessage, 42 | html.EscapeString(user.LanguageCode), 43 | ) 44 | } 45 | 46 | if chat.Type != "private" { 47 | logMessage = fmt.Sprintf( 48 | "%s\nChat Type: %s\nChat Name: %s\nChat ID: %d\n#ch%.f", 49 | logMessage, 50 | html.EscapeString(chat.Type), 51 | html.EscapeString(chat.Title), 52 | chat.Id, 53 | math.Abs(float64(chat.Id)), 54 | ) 55 | } 56 | 57 | logMessage = fmt.Sprintf( 58 | "%s\n#u%d", 59 | logMessage, 60 | user.Id, 61 | ) 62 | 63 | _, SendToLogsErr := b.SendMessage( 64 | logsID, 65 | logMessage, 66 | &gotgbot.SendMessageOpts{ 67 | ParseMode: "html", 68 | MessageThreadId: config.CurrentConfig.LogsTopicID, 69 | }, 70 | ) 71 | 72 | if SendToLogsErr != nil { 73 | return SendToLogsErr 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /src/helpers/LogMessage.go: -------------------------------------------------------------------------------- 1 | /* 2 | * LogMessage.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "github.com/PaulSonOfLars/gotgbot/v2" 11 | "log" 12 | "os" 13 | ) 14 | 15 | // LogMessage - logs non-error message to the logs chat 16 | func LogMessage(message string, botInstance *gotgbot.Bot) { 17 | targetChatId := config.CurrentConfig.LogsID 18 | 19 | if targetChatId == 0 { 20 | log.SetOutput(os.Stdout) 21 | log.Println("logs_id is not set in config.json, skipping log event...") 22 | return 23 | } 24 | 25 | _, err := botInstance.SendMessage( 26 | targetChatId, 27 | message, 28 | &gotgbot.SendMessageOpts{ 29 | MessageThreadId: config.CurrentConfig.LogsTopicID, 30 | }, 31 | ) 32 | 33 | if err != nil { 34 | log.SetOutput(os.Stderr) 35 | log.Println(err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/helpers/LogUserAction.go: -------------------------------------------------------------------------------- 1 | /* 2 | * LogUserAction.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "fmt" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | "html" 14 | "math" 15 | ) 16 | 17 | // LogUserAction - logs user's action to the logs chat 18 | func LogUserAction(message string, b *gotgbot.Bot, ctx *ext.Context) error { 19 | user := ctx.EffectiveUser 20 | chat := ctx.EffectiveChat 21 | 22 | logsID := config.CurrentConfig.LogsID 23 | 24 | logMessage := fmt.Sprintf( 25 | "%s\nUserID: %d\nFirst Name: %s", 26 | message, 27 | user.Id, 28 | html.EscapeString(user.FirstName)) 29 | 30 | if user.LastName != "" { 31 | logMessage = fmt.Sprintf("%s\nLast Name: %s", logMessage, html.EscapeString(user.LastName)) 32 | } 33 | 34 | if user.Username != "" { 35 | logMessage = fmt.Sprintf("%s\nUsername: @%s", logMessage, html.EscapeString(user.Username)) 36 | } 37 | 38 | if user.LanguageCode != "" { 39 | logMessage = fmt.Sprintf( 40 | "%s\nLanguage: %s", 41 | logMessage, 42 | html.EscapeString(user.LanguageCode), 43 | ) 44 | } 45 | 46 | if chat.Type != "private" { 47 | logMessage = fmt.Sprintf( 48 | "%s\nChat Type: %s\nChat Name: %s\nChat ID: %d\n#ch%.f", 49 | logMessage, 50 | html.EscapeString(chat.Type), 51 | html.EscapeString(chat.Title), 52 | chat.Id, 53 | math.Abs(float64(chat.Id)), 54 | ) 55 | } 56 | 57 | logMessage = fmt.Sprintf( 58 | "%s\n#u%d", 59 | logMessage, 60 | user.Id, 61 | ) 62 | 63 | _, SendToLogsErr := b.SendMessage( 64 | logsID, 65 | logMessage, 66 | &gotgbot.SendMessageOpts{ 67 | ParseMode: "html", 68 | MessageThreadId: config.CurrentConfig.LogsTopicID, 69 | }, 70 | ) 71 | 72 | if SendToLogsErr != nil { 73 | return SendToLogsErr 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /src/helpers/NotesMessageGenerator.go: -------------------------------------------------------------------------------- 1 | /* 2 | * NotesMessageGenerator.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | "feedbackBot/src/models" 11 | "fmt" 12 | "html" 13 | ) 14 | 15 | func GenerateNotesMessage(notes []*models.Note) string { 16 | if len(notes) == 0 { 17 | return constants.InternalError.Error() 18 | } 19 | 20 | text := fmt.Sprintf("Notes for #u%d:", notes[0].UserId) 21 | for _, note := range notes { 22 | text += fmt.Sprintf( 23 | "\n#n%d(by #u%d): %s", 24 | note.ID, note.AddedById, 25 | html.EscapeString(note.Text), 26 | ) 27 | } 28 | 29 | return text 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/ParseInputNote.go: -------------------------------------------------------------------------------- 1 | /* 2 | * ParseInputNote.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | ) 11 | 12 | func ParseNoteId(input string) (uint, error) { 13 | if noteId, err := ParseUint(input); err == nil { 14 | return noteId, nil 15 | } 16 | 17 | if len(input) > 2 && input[0] == '#' && input[1] == 'n' { 18 | noteId, err := ParseUint(input[2:]) 19 | 20 | if err != nil { 21 | return 0, constants.NoteIdInvalid 22 | } 23 | 24 | return noteId, nil 25 | } 26 | 27 | return 0, constants.NoteIdInvalid 28 | } 29 | -------------------------------------------------------------------------------- /src/helpers/ParseInputUser.go: -------------------------------------------------------------------------------- 1 | /* 2 | * ParseInputUser.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | "feedbackBot/src/models" 11 | users2 "feedbackBot/src/users" 12 | "strconv" 13 | ) 14 | 15 | func ParseInputUser(input string) (*models.User, error) { 16 | if userId, err := strconv.ParseInt(input, 10, 64); err == nil { 17 | user, err := users2.GetUserById(userId) 18 | 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return user, nil 24 | } 25 | 26 | if len(input) < 2 { 27 | return nil, constants.UserInvalid 28 | } 29 | 30 | if input[0] == '#' && input[1] == 'u' { 31 | userId, err := strconv.ParseInt(input[2:], 10, 64) 32 | 33 | if err != nil { 34 | return nil, constants.UserIdInvalid 35 | } 36 | 37 | user, err := users2.GetUserById(userId) 38 | 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return user, nil 44 | } 45 | 46 | if input[0] == '@' { 47 | username := input[1:] 48 | 49 | user, err := users2.GetUserByUsername(username) 50 | 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return user, nil 56 | } 57 | 58 | return nil, constants.UserInvalid 59 | } 60 | -------------------------------------------------------------------------------- /src/helpers/ParseUint.go: -------------------------------------------------------------------------------- 1 | /* 2 | * ParseUint.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "strconv" 10 | ) 11 | 12 | func ParseUint(s string) (uint, error) { 13 | u64, err := strconv.ParseUint(s, 10, 32) 14 | if err != nil { 15 | return 0, err 16 | } 17 | 18 | return uint(u64), nil 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/ResolveUser.go: -------------------------------------------------------------------------------- 1 | /* 2 | * ResolveUser.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/models" 10 | "feedbackBot/src/users" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | ) 14 | 15 | // ResolveUser resolves a user. 16 | // Returns the resolved user and an error if any. 17 | func ResolveUser(ctx *ext.Context, b *gotgbot.Bot) (*models.User, error) { 18 | user, _, err := ResolveUserWithSource(ctx, b) 19 | return user, err 20 | } 21 | 22 | // ResolveUserWithSource resolves a user. 23 | // Returns the resolved user, whether the user is resolved from topic(source), and an error if any. 24 | func ResolveUserWithSource(ctx *ext.Context, b *gotgbot.Bot) (*models.User, bool, error) { 25 | args := ctx.Args() 26 | 27 | topicId := ctx.EffectiveMessage.MessageThreadId 28 | 29 | // IsResolvable checks args length, so it's safe to access args[0-1] 30 | isResolvable, isValidTopicMessage, err := IsResolvable(ctx, b) 31 | 32 | if !isResolvable || err != nil { 33 | return nil, false, err 34 | } 35 | 36 | var user *models.User 37 | 38 | // Try to resolve user by topic ID 39 | if isValidTopicMessage { 40 | user, err = users.GetUserByTopicId(topicId) 41 | 42 | if err != nil { 43 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 44 | return nil, false, err 45 | } 46 | } else { 47 | user, err = ParseInputUser(args[1]) 48 | 49 | if err != nil { 50 | _, err := ctx.EffectiveMessage.Reply(b, err.Error(), &gotgbot.SendMessageOpts{}) 51 | return nil, false, err 52 | } 53 | } 54 | 55 | return user, isValidTopicMessage, nil 56 | } 57 | -------------------------------------------------------------------------------- /src/helpers/SendLargeText.go: -------------------------------------------------------------------------------- 1 | /* 2 | * SendLargeText.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "github.com/PaulSonOfLars/gotgbot/v2" 10 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 11 | ) 12 | 13 | func SendLargeText( 14 | ctx *ext.Context, b *gotgbot.Bot, 15 | text string, options *gotgbot.SendMessageOpts, 16 | ) error { 17 | if len(text) > 2048 { 18 | for len(text) > 2048 { 19 | _, err := ctx.EffectiveMessage.Reply( 20 | b, 21 | text[:2048], 22 | options, 23 | ) 24 | 25 | if err != nil { 26 | return err 27 | } 28 | 29 | text = text[2048:] 30 | } 31 | } 32 | 33 | _, err := ctx.EffectiveMessage.Reply( 34 | b, 35 | text, 36 | options, 37 | ) 38 | 39 | return err 40 | } 41 | -------------------------------------------------------------------------------- /src/helpers/UserActions.go: -------------------------------------------------------------------------------- 1 | /* 2 | * UserActions.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package helpers 7 | 8 | import ( 9 | "feedbackBot/src/constants" 10 | "feedbackBot/src/db" 11 | "feedbackBot/src/models" 12 | ) 13 | 14 | func BanUser(user *models.User) error { 15 | // Get user from the DB 16 | db.Connection.Where("user_id = ?", user.UserId).First(&user) 17 | 18 | // Check if user is already banned 19 | if user.IsBanned { 20 | return constants.UserAlreadyBanned 21 | } 22 | 23 | db.Connection.Model(&user).Update("is_banned", true) 24 | 25 | return nil 26 | } 27 | 28 | func UnbanUser(user *models.User) error { 29 | // Get user from the DB 30 | db.Connection.Where("user_id = ?", user.UserId).First(&user) 31 | 32 | // Check if user is not banned 33 | if !user.IsBanned { 34 | return constants.UserNotBanned 35 | } 36 | 37 | db.Connection.Model(&user).Update("is_banned", false) 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /src/messages/GetMessage.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetMessage.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package messages 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/db" 12 | "feedbackBot/src/models" 13 | "gorm.io/gorm" 14 | "log" 15 | "os" 16 | ) 17 | 18 | // getMessage - gets info for message by a custom query, used internally in package 19 | func getMessage(query interface{}, value ...interface{}) (*models.Message, error) { 20 | var message models.Message 21 | 22 | res := db.Connection.Where(query, value...).First(&message) 23 | 24 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 25 | return nil, constants.MessageNotFound 26 | } else if res.Error != nil { 27 | log.SetOutput(os.Stderr) 28 | log.Println(res.Error) 29 | 30 | return nil, constants.InternalError 31 | } 32 | 33 | if message.UserId <= 0 { 34 | return nil, constants.MessageNotFound 35 | } 36 | 37 | return &message, nil 38 | } 39 | -------------------------------------------------------------------------------- /src/messages/GetMessageBySupportId.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetMessageBySupportId.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package messages 7 | 8 | import "feedbackBot/src/models" 9 | 10 | // GetMessageBySupportId - gets a message by its ID and chatID 11 | func GetMessageBySupportId(messageId int64, chatId int64) (*models.Message, error) { 12 | return getMessage("support_message_id = ? and support_chat_id = ?", messageId, chatId) 13 | } 14 | -------------------------------------------------------------------------------- /src/messages/GetMessageByUserId.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetMessageBySupportId.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package messages 7 | 8 | import "feedbackBot/src/models" 9 | 10 | // GetMessageByUserId - gets a message by its user-side ID and chatID 11 | func GetMessageByUserId(messageId int64, userId int64) (*models.Message, error) { 12 | return getMessage("user_message_id = ? and user_id = ?", messageId, userId) 13 | } 14 | -------------------------------------------------------------------------------- /src/messages/StoreMessage.go: -------------------------------------------------------------------------------- 1 | /* 2 | * StoreMessage.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package messages 7 | 8 | import ( 9 | "feedbackBot/src/db" 10 | "feedbackBot/src/models" 11 | ) 12 | 13 | // StoreMessage - stores a message in the database 14 | // This function stores a message in the database, it is used to store messages identifiers sent by the user and the support 15 | // More info: http://youtrack.hub/issue/GL-11 (only for employees, internal resource) 16 | func StoreMessage(message models.Message) error { 17 | err := db.Connection.Create(&message).Error 18 | 19 | return err 20 | } 21 | -------------------------------------------------------------------------------- /src/middlewares/CheckAdmin.go: -------------------------------------------------------------------------------- 1 | /* 2 | * CheckAdmin.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package middlewares 7 | 8 | import ( 9 | "feedbackBot/src/db" 10 | "feedbackBot/src/models" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | ) 14 | 15 | func CheckAdmin(_ *gotgbot.Bot, ctx *ext.Context) error { 16 | var user *models.User 17 | 18 | res := db.Connection.Where("user_id = ?", ctx.EffectiveSender.Id()).First(&user) 19 | 20 | if res.Error != nil || res.RowsAffected == 0 || !user.IsAdmin { 21 | return ext.EndGroups 22 | } 23 | 24 | return ext.ContinueGroups 25 | } 26 | -------------------------------------------------------------------------------- /src/middlewares/CheckLanguage.go: -------------------------------------------------------------------------------- 1 | /* 2 | * CheckLanguage.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package middlewares 7 | 8 | import ( 9 | "feedbackBot/src/config" 10 | "feedbackBot/src/rates" 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | "gorm.io/gorm/utils" 14 | ) 15 | 16 | func CheckLanguage(b *gotgbot.Bot, ctx *ext.Context) error { 17 | filterConfig := config.CurrentConfig.LanguageFilter 18 | 19 | if !filterConfig.Enabled { 20 | return ext.ContinueGroups // Skip this middleware 21 | } 22 | 23 | userLanguage := ctx.EffectiveUser.LanguageCode 24 | 25 | if utils.Contains(filterConfig.ForbiddenLanguages, userLanguage) { 26 | // If the filter matches, rate-limit the user for config-specified time 27 | if !rates.Check(ctx.EffectiveChat.Id, filterConfig.ErrorRateLimit) { 28 | return ext.EndGroups // Stop handling this update 29 | } 30 | 31 | _, err := ctx.EffectiveMessage.Reply( 32 | b, 33 | filterConfig.Message, 34 | &gotgbot.SendMessageOpts{ 35 | ParseMode: "HTML", 36 | }, 37 | ) 38 | 39 | if err != nil { 40 | return err // Return error if something went wrong 41 | } 42 | 43 | return ext.EndGroups // Stop handling this update 44 | } 45 | 46 | return ext.ContinueGroups 47 | } 48 | -------------------------------------------------------------------------------- /src/middlewares/SyncUser.go: -------------------------------------------------------------------------------- 1 | /* 2 | * SyncUser.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package middlewares 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/config" 11 | "feedbackBot/src/db" 12 | "feedbackBot/src/helpers" 13 | "feedbackBot/src/models" 14 | "fmt" 15 | "github.com/PaulSonOfLars/gotgbot/v2" 16 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 17 | "gorm.io/gorm" 18 | ) 19 | 20 | func SyncUser(b *gotgbot.Bot, ctx *ext.Context) error { 21 | // Work with DB in another goroutine, 22 | // because handlerGroup is waiting until the function returns any value before proceed 23 | go func() { 24 | var id = ctx.EffectiveMessage.From.Id 25 | var username = ctx.EffectiveMessage.From.Username 26 | var firstName = ctx.EffectiveMessage.From.FirstName 27 | var lastName = ctx.EffectiveMessage.From.LastName 28 | var languageCode = ctx.EffectiveMessage.From.LanguageCode 29 | 30 | var user models.User 31 | 32 | res := db.Connection.Where("user_id = ?", id).First(&user) 33 | 34 | user = models.User{ 35 | UserId: id, 36 | Username: username, 37 | FirstName: firstName, 38 | LastName: lastName, 39 | LanguageCode: languageCode, 40 | } 41 | 42 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 43 | // Set isProtected to default value(as specified in config) 44 | user.IsProtected = config.CurrentConfig.IsProtectedDefault 45 | 46 | resIns := db.Connection.Create(&user) 47 | if resIns.Error != nil { 48 | fmt.Printf("failed to insert user: %v", resIns.Error.Error()) 49 | } 50 | 51 | err := helpers.LogUserAction("#NEW_USER\nNew user in the bot.", b, ctx) 52 | if err != nil { 53 | fmt.Printf("failed to send message to the logs: %v", err.Error()) 54 | } 55 | } else { 56 | resUpd := db.Connection.Where("user_id = ?", user.UserId).Updates(&user) 57 | if resUpd.Error != nil { 58 | fmt.Printf("failed to update user: %v", resUpd.Error.Error()) 59 | } 60 | } 61 | }() 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /src/models/Message.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Message.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package models 7 | 8 | import "gorm.io/gorm" 9 | 10 | type Message struct { 11 | gorm.Model 12 | UserId int64 13 | UserMessageId int64 14 | SupportMessageId int64 15 | SupportChatId int64 16 | IsOutgoing bool 17 | } 18 | -------------------------------------------------------------------------------- /src/models/Note.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Note.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package models 7 | 8 | import "gorm.io/gorm" 9 | 10 | type Note struct { 11 | gorm.Model 12 | UserId int64 13 | AddedById int64 14 | Text string 15 | } 16 | -------------------------------------------------------------------------------- /src/models/User.go: -------------------------------------------------------------------------------- 1 | /* 2 | * User.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package models 7 | 8 | import "gorm.io/gorm" 9 | 10 | type User struct { 11 | gorm.Model 12 | UserId int64 `gorm:"unique"` 13 | FirstName string 14 | LastName string 15 | Username string 16 | LanguageCode string 17 | TopicId int64 18 | IsBanned bool 19 | IsAdmin bool 20 | IsProtected bool 21 | } 22 | -------------------------------------------------------------------------------- /src/notes/UserNotes.go: -------------------------------------------------------------------------------- 1 | /* 2 | * UserNotes.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package notes 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/db" 12 | "feedbackBot/src/models" 13 | "gorm.io/gorm" 14 | "log" 15 | "os" 16 | ) 17 | 18 | func AddNote(user *models.User, text string, addedBy *models.User) (uint, error) { 19 | if user == nil || addedBy == nil { 20 | return 0, constants.InternalError 21 | } 22 | 23 | // Check note length 24 | if len(text) > 1024 { 25 | return 0, constants.NoteTooLong 26 | } 27 | 28 | // Add note 29 | note := models.Note{ 30 | UserId: user.UserId, 31 | AddedById: addedBy.UserId, 32 | Text: text, 33 | } 34 | 35 | res := db.Connection.Create(¬e) 36 | 37 | if res.Error != nil { 38 | log.SetOutput(os.Stderr) 39 | log.Println(res.Error) 40 | 41 | return 0, constants.InternalError 42 | } 43 | 44 | return note.ID, nil 45 | } 46 | 47 | func GetNotes(user *models.User) ([]*models.Note, error) { 48 | var notes []*models.Note 49 | 50 | res := db.Connection.Where("user_id = ?", user.UserId).Find(¬es) 51 | 52 | if errors.Is(res.Error, gorm.ErrRecordNotFound) || len(notes) == 0 { 53 | return nil, constants.NotesNotFound 54 | } 55 | 56 | if res.Error != nil { 57 | log.SetOutput(os.Stderr) 58 | log.Println(res.Error) 59 | 60 | return nil, constants.InternalError 61 | } 62 | 63 | return notes, nil 64 | } 65 | 66 | func DeleteNoteById(noteId uint) error { 67 | res := db.Connection.Where("id = ?", noteId).Delete(&models.Note{}) 68 | 69 | if res.RowsAffected == 0 { 70 | return constants.NoteNotFound 71 | } 72 | 73 | if res.Error != nil { 74 | log.SetOutput(os.Stderr) 75 | log.Println(res.Error) 76 | 77 | return constants.InternalError 78 | } 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /src/rates/Limiter.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Limiter.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package rates 7 | 8 | import ( 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // chats - is a map of chat ids to the last time of request, they have made 14 | var chats = make(map[int64]int64) 15 | 16 | // chatsLock - mutex for chats map 17 | var chatsLock sync.Mutex 18 | 19 | // Check - checks if the request has made in the last N(controlled by delay parameter) seconds in this chat, 20 | // if so, returns false, else true 21 | // 22 | // chatId - id of the chat 23 | // delay - delay in seconds 24 | func Check(chatId int64, delay int64) bool { 25 | chatsLock.Lock() 26 | defer chatsLock.Unlock() 27 | 28 | lastRequest, exists := chats[chatId] 29 | 30 | if exists && (lastRequest+delay > time.Now().Unix()) { 31 | return false 32 | } 33 | 34 | chats[chatId] = time.Now().Unix() 35 | 36 | return true 37 | } 38 | -------------------------------------------------------------------------------- /src/reactions/ProcessUpdateReactions.go: -------------------------------------------------------------------------------- 1 | /* 2 | * ProcessUpdateReactions.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package reactions 7 | 8 | import ( 9 | "github.com/PaulSonOfLars/gotgbot/v2" 10 | "log" 11 | "os" 12 | ) 13 | 14 | func ProcessUpdateReactions( 15 | reaction *gotgbot.MessageReactionUpdated, b *gotgbot.Bot, 16 | chatId int64, messageId int64, 17 | ) error { 18 | // If some reactions removed - remove them from the message by calling same method once again 19 | if len(reaction.OldReaction) != 0 { 20 | _, err := b.SetMessageReaction( 21 | chatId, messageId, 22 | &gotgbot.SetMessageReactionOpts{ 23 | Reaction: reaction.OldReaction, 24 | }, 25 | ) 26 | 27 | // Errors may occur here, but they aren't critical, just log them 28 | if err != nil { 29 | log.SetOutput(os.Stderr) 30 | log.Println(err.Error()) 31 | } 32 | } 33 | 34 | // If the message is found - send the reaction to the user 35 | _, err := b.SetMessageReaction( 36 | chatId, messageId, 37 | &gotgbot.SetMessageReactionOpts{ 38 | Reaction: reaction.NewReaction, 39 | }, 40 | ) 41 | 42 | return err 43 | } 44 | -------------------------------------------------------------------------------- /src/users/GetUser.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetUser.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package users 7 | 8 | import ( 9 | "errors" 10 | "feedbackBot/src/constants" 11 | "feedbackBot/src/db" 12 | "feedbackBot/src/models" 13 | "gorm.io/gorm" 14 | "log" 15 | "os" 16 | ) 17 | 18 | // getUser - resolves a user by a query, used internally in package 19 | func getUser(query interface{}, value ...interface{}) (*models.User, error) { 20 | var user models.User 21 | 22 | res := db.Connection.Where(query, value...).First(&user) 23 | 24 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 25 | return nil, constants.UserNotFound 26 | } else if res.Error != nil { 27 | log.SetOutput(os.Stderr) 28 | log.Println(res.Error) 29 | 30 | return nil, constants.InternalError 31 | } 32 | 33 | if user.UserId <= 0 { 34 | return nil, constants.UserNotFound 35 | } 36 | 37 | return &user, nil 38 | } 39 | -------------------------------------------------------------------------------- /src/users/GetUserById.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetUserById.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package users 7 | 8 | import "feedbackBot/src/models" 9 | 10 | // GetUserById - resolves a user by their userId 11 | func GetUserById(userId int64) (*models.User, error) { 12 | return getUser("user_id = ?", userId) 13 | } 14 | -------------------------------------------------------------------------------- /src/users/GetUserByTopicId.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetUserByTopicId.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package users 7 | 8 | import "feedbackBot/src/models" 9 | 10 | // GetUserByTopicId - resolves a user by id of the topic they're currently assigned to 11 | func GetUserByTopicId(topicId int64) (*models.User, error) { 12 | return getUser("topic_id = ?", topicId) 13 | } 14 | -------------------------------------------------------------------------------- /src/users/GetUserByUsername.go: -------------------------------------------------------------------------------- 1 | /* 2 | * GetUserByUsername.go 3 | * Copyright (c) ti-bone 2023-2024 4 | */ 5 | 6 | package users 7 | 8 | import "feedbackBot/src/models" 9 | 10 | // GetUserByUsername - resolves a user by their username 11 | func GetUserByUsername(username string) (*models.User, error) { 12 | return getUser("lower(username) = lower(?)", username) 13 | } 14 | --------------------------------------------------------------------------------