-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtbb.go
303 lines (259 loc) · 7.6 KB
/
tbb.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
// Package tbb provides a base for creating custom Telegram bots.
// It can be used to spin up a custom bot in one minute which is capable of
// handling bot users via an implemented sqlite database,
// but can easily be switched to use mysql or postgres instead.
package tbb
import (
"context"
"crypto/md5"
"errors"
"fmt"
"github.com/NicoNex/echotron/v3"
timezone "github.com/evanoberholster/timezoneLookup/v2"
"github.com/gabriel-vasile/mimetype"
"gorm.io/gorm"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
type TBot struct {
db *DB
dsp *echotron.Dispatcher
ctx context.Context
cfg *Config
logger *slog.Logger
cmdReg CommandRegistry
hFn UpdateHandlerFn
api echotron.API // Telegram api
tzc *timezone.Timezonecache
srv *http.Server
}
type Option func(*TBot)
// New creates a new Telegram bot based on the given configuration.
// It uses functional options for configuration.
func New(opts ...Option) *TBot {
tbot := &TBot{
ctx: context.Background(),
cmdReg: CommandRegistry{},
hFn: func() UpdateHandler { return &DefaultUpdateHandler{} },
logger: nil,
tzc: loadTimezoneCache(),
}
// Loop through each option
for _, opt := range opts {
opt(tbot)
}
if tbot.cfg == nil {
panic("tbot config is missing")
}
if tbot.logger == nil {
tbot.logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: getLogLevel(tbot.cfg.LogLevel),
}))
}
tbot.db = NewDB(tbot.cfg, &gorm.Config{FullSaveAssociations: true})
tbot.api = echotron.NewAPI(tbot.cfg.Telegram.BotToken)
tbot.dsp = echotron.NewDispatcher(tbot.cfg.Telegram.BotToken, tbot.buildBot(tbot.hFn))
if tbot.srv != nil {
tbot.dsp.SetHTTPServer(tbot.srv)
}
// Initialize database tables
if err := tbot.db.AutoMigrate(&User{}, &UserInfo{}, &UserPhoto{}); err != nil {
panic(err)
}
return tbot
}
// WithConfig is the only required option because it provides the config for the tbot to function properly.
func WithConfig(cfg *Config) Option {
return func(app *TBot) {
app.cfg = cfg
}
}
// WithCommands is used for providing and registering custom bot commands.
// Bot commands always start with a / like /start and a Handler, which implements the CommandHandler interface.
// If you want a command to be available in the command list on Telegram,
// the provided Command must contain a Description.
func WithCommands(commands []Command) Option {
return func(app *TBot) {
app.cmdReg = buildCommandRegistry(commands)
}
}
// WithHandlerFunc option can be used to override the default UpdateHandlerFn for custom echotron.Update message handling.
func WithHandlerFunc(hFn UpdateHandlerFn) Option {
return func(app *TBot) {
app.hFn = hFn
}
}
// WithLogger option can be used to override the default logger with a custom one.
func WithLogger(l *slog.Logger) Option {
return func(app *TBot) {
app.logger = l
}
}
// WithServer option can be used add a custom http.Server to the dispatcher
func WithServer(s *http.Server) Option {
return func(app *TBot) {
app.srv = s
}
}
// Start starts the Telegram bot server in poll mode
func (tb *TBot) Start() {
var err error
if err = tb.SetBotCommands(tb.buildTelegramCommands()); err != nil {
tb.logger.Error("Cannot set bot commands!")
panic(err)
}
if tb.srv == nil {
tb.logger.Info("Start dispatcher")
tb.logger.Error(tb.dsp.Poll().Error())
return
}
// If we have a custom web server, we run the polling in a separate go routine.
go func() {
tb.logger.Info("Start dispatcher")
tb.logger.Error(tb.dsp.Poll().Error())
}()
go shutdownServerOnSignal(tb.srv)
tb.logger.Info("Start server")
err = tb.srv.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
tb.logger.Error(err.Error())
return
}
tb.logger.Info("Server closed")
}
// StartWithWebhook starts the Telegram bot server with a given webhook url.
func (tb *TBot) StartWithWebhook(webhookURL string) {
var err error
if err = tb.SetBotCommands(tb.buildTelegramCommands()); err != nil {
tb.logger.Error("Cannot set bot commands!")
panic(err)
}
if webhookURL == "" {
panic("webhook url is empty")
}
tb.logger.Info(fmt.Sprintf("Start dispatcher and server with webhook: %q", webhookURL))
if tb.srv != nil {
go shutdownServerOnSignal(tb.srv)
}
err = tb.dsp.ListenWebhook(webhookURL)
if !errors.Is(err, http.ErrServerClosed) {
tb.logger.Error(err.Error())
return
}
tb.logger.Info("Server closed")
}
// API returns the reference to the echotron.API.
func (tb *TBot) API() echotron.API {
return tb.api
}
// Config returns the config
func (tb *TBot) Config() *Config {
return tb.cfg
}
// DB returns the database handle for the bot so that the database can easily be adjusted and extended.
func (tb *TBot) DB() *DB {
return tb.db
}
// Dispatcher returns the echotron.Dispatcher.
func (tb *TBot) Dispatcher() *echotron.Dispatcher {
return tb.dsp
}
// Server returns the http.Server.
func (tb *TBot) Server() *http.Server {
return tb.srv
}
// DownloadFile downloads a file from Telegram by a given fileID
func (tb *TBot) DownloadFile(fileID string) (*File, error) {
fileIDRes, err := tb.API().GetFile(fileID)
if err != nil {
return nil, err
}
tb.logger.Debug(fileIDRes.Description)
fileData, err := tb.API().DownloadFile(fileIDRes.Result.FilePath)
if err != nil {
return nil, err
}
mime := mimetype.Detect(fileData)
f := &File{
UniqueID: fileIDRes.Result.FileUniqueID,
Extension: mime.Extension(),
MimeType: mime.String(),
Hash: fmt.Sprintf("%x", md5.Sum(fileData)),
Size: fileIDRes.Result.FileSize,
Data: fileData,
}
return f, nil
}
// SetBotCommands registers the given command list for your Telegram bot.
// Will delete registered bot commands if parameter bc is nil.
func (tb *TBot) SetBotCommands(bc []echotron.BotCommand) error {
if bc == nil {
_, err := tb.api.DeleteMyCommands(nil)
return err
}
_, err := tb.api.SetMyCommands(nil, bc...)
return err
}
func (tb *TBot) newBot(chatID int64, l *slog.Logger, hFn UpdateHandlerFn) *Bot {
b := &Bot{
tbot: tb,
chatID: chatID,
logger: l.WithGroup("Bot"),
}
if b.chatID == 0 {
panic("missing chat ID")
}
var err error
b.user, err = tb.DB().FindUserByChatID(b.chatID)
if err != nil {
b.logger.Warn(err.Error())
b.logger.Info(fmt.Sprintf("Creating new user with ChatID=%d", b.chatID))
b.user = &User{ChatID: b.chatID, UserInfo: &UserInfo{}, UserPhoto: &UserPhoto{}}
}
// Create tb new UpdateHandler and set Bot reference back on handler
b.handler = hFn()
b.handler.SetBot(b)
// Set the self-destruction timer
b.dTimer = time.AfterFunc(time.Duration(tb.cfg.BotSessionTimeout)*time.Minute, b.destruct)
b.logger.Debug(fmt.Sprintf("New Bot instance started with ChatID=%d", b.chatID))
return b
}
func (tb *TBot) buildBot(h UpdateHandlerFn) echotron.NewBotFn {
return func(chatId int64) echotron.Bot {
return tb.newBot(chatId, tb.logger, h)
}
}
func (tb *TBot) getRegistryCommand(name string) *Command {
c, ok := tb.cmdReg[name]
if !ok {
return nil
}
return &c
}
func (tb *TBot) buildTelegramCommands() []echotron.BotCommand {
var bc []echotron.BotCommand
for _, c := range tb.cmdReg {
if c.Name != "" && c.Description != "" {
bc = append(bc, echotron.BotCommand{
Command: c.Name,
Description: c.Description,
})
}
}
return bc
}
// shutdownServerOnSignal gracefully shuts down server on SIGINT or SIGTERM
func shutdownServerOnSignal(srv *http.Server) {
termChan := make(chan os.Signal, 1) // Channel for terminating the tbot via os.Interrupt signal
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
<-termChan
// Perform some cleanup...
if err := srv.Shutdown(context.Background()); err != nil {
panic(err)
}
}