-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhandler.go
More file actions
682 lines (614 loc) · 23.3 KB
/
Copy pathhandler.go
File metadata and controls
682 lines (614 loc) · 23.3 KB
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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
package telegram
import (
"fmt"
"strings"
"sync"
)
// utf16Len returns the number of UTF-16 code units in s, which is what
// Telegram uses for its message/caption length limits.
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r <= 0xFFFF {
n++
} else {
n += 2
}
}
return n
}
// ─── Config ────────────────────────────────────────────────────────────────
// HandlerConfig controls which messages the Handler processes.
type HandlerConfig struct {
AllowedChats []int64 // non-empty: only these chat IDs pass; empty: no chat filter
BotUsername string // for @mention detection in groups (without @)
MaxMsgLength int // default: 4096
AllowedUsers []int64 // non-empty: only these user IDs pass; empty: no user filter
// AllowAllUsers must be true to permit access when BOTH allowlists are
// empty. Default false = fail-closed (deny everyone) so an unconfigured
// handler never silently allows all users. See ValidateConfig.
AllowAllUsers bool
}
// ─── Handler ──────────────────────────────────────────────────────────────
// Handler routes incoming Telegram updates to the appropriate callback based
// on message type. It is the bridge between the raw Telegram API and the agent.
type Handler struct {
Bot *Bot
Config HandlerConfig
log Logger
// approvers maps chatID → *TelegramApprover for inline keyboard approval
// requests. Protected by sync.Map for concurrent read/write from the
// update loop (reads) and agent goroutines (writes).
approvers sync.Map
// OnTextMessage is called when a plain text message is received.
// forwarded is true when the message was forwarded from another chat or
// user; callers should treat the text as crossing an external trust boundary.
// Returns the response text (may be empty).
// Should run asynchronously if it starts the agent loop — callers
// should dispatch to a goroutine to avoid blocking the update loop.
OnTextMessage func(chatID int64, messageID int, text string, forwarded bool, userID int64) (string, error)
// OnCallbackQuery is called when a callback query is received and
// it was NOT handled by the TelegramApprover. userID is the Telegram
// user who pressed the button, so callers can bind callbacks to the
// originating user and reject stale or cross-user button presses.
// Returns the response text (may be empty).
OnCallbackQuery func(chatID int64, callbackData string, userID int64) (string, error)
// OnCommand is called when a bot command (e.g. /start) is received.
// userID is the Telegram user who sent the command.
// Returns the response text (may be empty).
OnCommand func(chatID int64, messageID int, command string, args string, userID int64) (string, error)
// OnVoiceMessage is called when a voice message is received.
// Returns the response text (may be empty).
// fileID is the Telegram file ID of the voice message in OGG format.
// userID is the Telegram user who sent the voice message.
// Callers should use DownloadVoice to save the file locally.
OnVoiceMessage func(chatID int64, messageID int, fileID string, userID int64) (string, error)
// OnPhotoMessage is called when a photo message is received.
// Returns the response text (may be empty).
// fileIDs contains all available sizes (last = largest).
// Callers should use DownloadPhoto with the last element.
// caption is the optional text the user attached to the photo (may be empty).
// userID is the Telegram user who sent the photo message.
OnPhotoMessage func(chatID int64, messageID int, fileIDs []string, caption string, userID int64) (string, error)
// OnDocumentMessage is called when a document/file message is received.
// Returns the response text (may be empty).
// fileID is the Telegram file ID. Callers should use DownloadDocument
// and pass the document's fileName to save the file locally.
// userID is the Telegram user who sent the document message.
OnDocumentMessage func(chatID int64, messageID int, fileID string, fileName string, userID int64) (string, error)
// OnError is called when a processing error occurs.
OnError func(chatID int64, err error)
}
// SetApprover stores a TelegramApprover for the given chat ID.
// Thread-safe: safe to call from any goroutine.
func (h *Handler) SetApprover(chatID int64, a *TelegramApprover) {
h.approvers.Store(chatID, a)
}
// GetApprover retrieves the TelegramApprover for the given chat ID.
// Returns nil if no approver is registered. Thread-safe.
func (h *Handler) GetApprover(chatID int64) *TelegramApprover {
v, ok := h.approvers.Load(chatID)
if !ok {
return nil
}
a, _ := v.(*TelegramApprover)
return a
}
// DeleteApprover removes the TelegramApprover for the given chat ID.
// Thread-safe. Used when a session is reset or ends.
func (h *Handler) DeleteApprover(chatID int64) {
h.approvers.Delete(chatID)
}
// NewHandler creates a Handler with the given bot and default settings.
func NewHandler(bot *Bot) *Handler {
return &Handler{
Bot: bot,
Config: HandlerConfig{
MaxMsgLength: 4096,
},
log: NewNopLogger(),
OnTextMessage: defaultTextHandler(),
OnCallbackQuery: defaultCallbackHandler(),
OnCommand: defaultCommandHandler(),
OnVoiceMessage: defaultVoiceHandler(bot),
OnPhotoMessage: defaultPhotoHandler(bot),
OnDocumentMessage: defaultDocumentHandler(bot),
}
}
// SetLogger sets the logger for this handler. If nil, a NopLogger is used.
func (h *Handler) SetLogger(l Logger) {
if l == nil {
h.log = NewNopLogger()
return
}
h.log = l
}
// defaultTextHandler returns a default OnTextMessage callback.
func defaultTextHandler() func(int64, int, string, bool, int64) (string, error) {
return func(_ int64, _ int, _ string, _ bool, _ int64) (string, error) {
return "Not implemented yet: text", nil
}
}
// defaultCallbackHandler returns a default OnCallbackQuery callback.
func defaultCallbackHandler() func(int64, string, int64) (string, error) {
return func(_ int64, _ string, _ int64) (string, error) {
return "Not implemented yet: callback query", nil
}
}
// defaultCommandHandler returns a default OnCommand callback.
func defaultCommandHandler() func(int64, int, string, string, int64) (string, error) {
return func(_ int64, _ int, _ string, _ string, _ int64) (string, error) {
return "Not implemented yet: command", nil
}
}
// defaultVoiceHandler returns a default OnVoiceMessage callback that downloads
// the voice file and returns a MEDIA: response.
func defaultVoiceHandler(bot *Bot) func(int64, int, string, int64) (string, error) {
return func(chatID int64, _ int, fileID string, _ int64) (string, error) {
path, err := DownloadVoice(bot, chatID, fileID)
if err != nil {
return "", fmt.Errorf("telegram handler: download voice: %w", err)
}
return fmt.Sprintf("MEDIA:voice:%s", path), nil
}
}
// defaultPhotoHandler returns a default OnPhotoMessage callback that downloads
// the largest photo size and returns a MEDIA: response.
func defaultPhotoHandler(bot *Bot) func(int64, int, []string, string, int64) (string, error) {
return func(chatID int64, _ int, fileIDs []string, _ string, _ int64) (string, error) {
path, err := DownloadPhoto(bot, chatID, fileIDs)
if err != nil {
return "", fmt.Errorf("telegram handler: download photo: %w", err)
}
return fmt.Sprintf("MEDIA:photo:%s", path), nil
}
}
// defaultDocumentHandler returns a default OnDocumentMessage callback that
// downloads the document and returns a MEDIA: response.
func defaultDocumentHandler(bot *Bot) func(int64, int, string, string, int64) (string, error) {
return func(chatID int64, _ int, fileID string, fileName string, _ int64) (string, error) {
path, err := DownloadDocument(bot, chatID, fileID, fileName)
if err != nil {
return "", fmt.Errorf("telegram handler: download document: %w", err)
}
return fmt.Sprintf("MEDIA:document:%s", path), nil
}
}
// ─── Update Routing ───────────────────────────────────────────────────────
// HandleUpdate routes an incoming Telegram update to the appropriate handler.
// Recovers from panics in handler callbacks to prevent a single bad update
// from crashing the entire bot loop.
//
// SECURITY: every handler routed here must enforce isAllowed itself before
// acting (handleMessage and handleCallback both do). When adding a new update
// type, gate it the same way — callback queries once bypassed authorization
// because their handler skipped this check.
func (h *Handler) HandleUpdate(upd Update) {
defer h.recoverFromPanic("HandleUpdate", upd.ID)
switch {
case upd.Message != nil:
h.handleMessage(upd.Message)
case upd.EditedMessage != nil:
h.handleMessage(upd.EditedMessage)
case upd.CallbackQuery != nil:
h.handleCallback(upd.CallbackQuery)
default:
h.log.Warn("ignoring unsupported update type", "update_id", upd.ID)
}
}
// recoverFromPanic catches panics in handler callbacks, logs them, and fires
// OnError if configured. Use as: defer h.recoverFromPanic("method", id).
func (h *Handler) recoverFromPanic(method string, updateID int) {
if r := recover(); r != nil {
h.log.Error("panic recovered", "method", method, "update_id", updateID, "panic", r)
if h.OnError != nil {
// Try to extract a chat ID from the panic context, but
// we don't have it here — use 0 and let the callback
// decide how to handle it.
h.OnError(0, fmt.Errorf("telegram: panic in %s (update %d): %v", method, updateID, r))
}
}
}
// handleMessage routes a single message based on content type and permissions.
func (h *Handler) handleMessage(msg *Message) {
if msg.Chat == nil || msg.From == nil {
return
}
userID := msg.From.ID
if !h.isAllowed(msg.Chat.ID, userID) {
return
}
// Enforce the configured maximum message length on text and captions.
// Telegram's limit is UTF-16 code units, not bytes, so emoji and other
// supplementary-plane characters count as 2.
if h.Config.MaxMsgLength > 0 {
if n := utf16Len(msg.Text); n > h.Config.MaxMsgLength {
h.SendResponse(msg.Chat.ID, fmt.Sprintf("❌ Message is too long (%d > %d characters). Please split or shorten it.", n, h.Config.MaxMsgLength), msg.ID)
return
}
if n := utf16Len(msg.Caption); n > h.Config.MaxMsgLength {
h.SendResponse(msg.Chat.ID, fmt.Sprintf("❌ Caption is too long (%d > %d characters). Please shorten it.", n, h.Config.MaxMsgLength), msg.ID)
return
}
}
switch {
case msg.IsCommand():
h.handleCommand(msg, userID)
case msg.Voice != nil:
if h.OnVoiceMessage != nil {
resp, err := h.OnVoiceMessage(msg.Chat.ID, msg.ID, msg.Voice.FileID, userID)
if err != nil {
h.log.Error("voice message handler failed", "chat_id", msg.Chat.ID, "error", err)
if h.OnError != nil {
h.OnError(msg.Chat.ID, err)
}
return
}
if resp != "" {
h.SendResponse(msg.Chat.ID, resp, msg.ID)
}
}
case msg.Photo != nil:
if h.OnPhotoMessage != nil {
fileIDs := make([]string, len(msg.Photo))
for i, p := range msg.Photo {
fileIDs[i] = p.FileID
}
resp, err := h.OnPhotoMessage(msg.Chat.ID, msg.ID, fileIDs, msg.Caption, userID)
if err != nil {
h.log.Error("photo message handler failed", "chat_id", msg.Chat.ID, "error", err)
if h.OnError != nil {
h.OnError(msg.Chat.ID, err)
}
return
}
if resp != "" {
h.SendResponse(msg.Chat.ID, resp, msg.ID)
}
}
case msg.Document != nil:
if h.OnDocumentMessage != nil {
resp, err := h.OnDocumentMessage(msg.Chat.ID, msg.ID, msg.Document.FileID, msg.Document.FileName, userID)
if err != nil {
h.log.Error("document message handler failed", "chat_id", msg.Chat.ID, "error", err)
if h.OnError != nil {
h.OnError(msg.Chat.ID, err)
}
return
}
if resp != "" {
h.SendResponse(msg.Chat.ID, resp, msg.ID)
}
}
case msg.Text != "":
if h.OnTextMessage != nil {
forwarded := msg.ForwardOrigin != nil || msg.ForwardFrom != nil || msg.ForwardDate != 0
resp, err := h.OnTextMessage(msg.Chat.ID, msg.ID, msg.Text, forwarded, userID)
if err != nil {
h.log.Error("text message handler failed", "chat_id", msg.Chat.ID, "error", err)
if h.OnError != nil {
h.OnError(msg.Chat.ID, err)
}
return
}
if resp != "" {
h.SendResponse(msg.Chat.ID, resp, msg.ID)
}
}
default:
// Ignore unsupported message types (stickers, locations, etc.)
}
}
// handleCommand processes a bot command message.
func (h *Handler) handleCommand(msg *Message, userID int64) {
cmd, args := extractCommand(msg.Text)
if cmd == "" {
return
}
// Check if command was targeted at a specific bot via @username.
// Only respond if the username matches our BotUsername.
if h.Config.BotUsername != "" {
parts := strings.SplitN(msg.Text, " ", 2)
cmdPart := parts[0]
if atIdx := strings.Index(cmdPart, "@"); atIdx >= 0 {
mentioned := cmdPart[atIdx+1:]
if mentioned != "" && !strings.EqualFold(mentioned, h.Config.BotUsername) {
// Command intended for a different bot — ignore.
return
}
}
}
if h.OnCommand != nil {
resp, err := h.OnCommand(msg.Chat.ID, msg.ID, cmd, args, userID)
if err != nil {
h.log.Error("command handler failed", "chat_id", msg.Chat.ID, "command", cmd, "error", err)
if h.OnError != nil {
h.OnError(msg.Chat.ID, err)
}
// Send the error message to the user so they know the command failed.
h.SendResponse(msg.Chat.ID, "❌ "+err.Error(), msg.ID)
return
}
if resp != "" {
h.SendResponse(msg.Chat.ID, resp, msg.ID)
}
}
}
// handleCallback processes a callback query from an inline keyboard.
func (h *Handler) handleCallback(cq *CallbackQuery) {
if cq.Message == nil || cq.Message.Chat == nil {
return
}
// Apply the same allowlist as messages. Callback queries (inline-button
// presses) otherwise bypass authorization — they can drive per-chat
// approval/clarify state and trigger outbound API calls. Without a From
// user, treat the user ID as 0 (matches no AllowedUsers entry).
var userID int64
if cq.From != nil {
userID = cq.From.ID
}
if !h.isAllowed(cq.Message.Chat.ID, userID) {
return
}
// Route approval callbacks to the per-chat TelegramApprover.
if a := h.GetApprover(cq.Message.Chat.ID); a != nil && a.HandleCallback(cq.Data, userID) {
// Show a toast acknowledging the user's choice.
ack := approvalToast(cq.Data)
if err := h.Bot.AnswerCallbackQuery(cq.ID, ack, false); err != nil {
h.log.Error("answer callback query (approval) failed", "chat_id", cq.Message.Chat.ID, "error", err)
if h.OnError != nil {
h.OnError(cq.Message.Chat.ID, err)
}
}
return
}
// Answer the callback query to remove the loading state on the button.
if err := h.Bot.AnswerCallbackQuery(cq.ID, "", false); err != nil {
h.log.Error("answer callback query failed", "chat_id", cq.Message.Chat.ID, "error", err)
if h.OnError != nil {
h.OnError(cq.Message.Chat.ID, err)
}
return
}
if h.OnCallbackQuery != nil {
resp, err := h.OnCallbackQuery(cq.Message.Chat.ID, cq.Data, userID)
if err != nil {
h.log.Error("callback query handler failed", "chat_id", cq.Message.Chat.ID, "data", cq.Data, "error", err)
if h.OnError != nil {
h.OnError(cq.Message.Chat.ID, err)
}
// Send the error message to the user.
h.SendResponse(cq.Message.Chat.ID, "❌ "+err.Error(), cq.Message.ID)
return
}
if resp != "" {
h.SendResponse(cq.Message.Chat.ID, resp, cq.Message.ID)
}
}
}
// ─── Response Sending ─────────────────────────────────────────────────────
// SendResponse sends a response text to the given chat.
// It handles MEDIA: prefix, chunking, MarkdownV2 formatting, and retry logic.
// If replyToMessageID is non-zero, the response is sent as a reply to that message.
func (h *Handler) SendResponse(chatID int64, text string, replyToMessageID int) {
if text == "" {
return
}
// Check for MEDIA: prefix.
if strings.HasPrefix(text, "MEDIA:") {
h.sendMedia(chatID, text, replyToMessageID)
return
}
// Split into chunks via FormatResponse.
chunks, err := FormatResponse(text)
if err != nil {
h.log.Error("format response failed", "chat_id", chatID, "error", err)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: format response: %w", err))
}
return
}
for _, chunk := range chunks {
if chunk == "" {
continue
}
h.sendChunk(chatID, chunk, replyToMessageID)
}
}
// sendMedia handles a MEDIA: prefixed response.
// Format: "MEDIA:photo:/path/to/file.jpg" or "MEDIA:voice:/path/to/file.ogg"
// If replyToMessageID is non-zero, the media is sent as a reply to that message.
func (h *Handler) sendMedia(chatID int64, text string, replyToMessageID int) {
// Strip the "MEDIA:" prefix.
rest := strings.TrimPrefix(text, "MEDIA:")
parts := strings.SplitN(rest, ":", 2)
if len(parts) < 2 {
return
}
mediaType := parts[0]
filePath := parts[1]
// Outbound media can exfiltrate arbitrary files, so it requires an
// explicit user approval before the path is resolved or uploaded. The
// Telegram bot registers a per-chat approver in production; without one
// we fail closed.
a := h.GetApprover(chatID)
if a == nil {
h.log.Error("media upload rejected: no approver configured", "chat_id", chatID, "path", filePath)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: media upload rejected: no approver configured"))
}
return
}
if err := a.PromptMedia(filePath); err != nil {
h.log.Error("media upload denied", "chat_id", chatID, "path", filePath, "error", err)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: media upload denied: %s: %w", filePath, err))
}
return
}
// Validate and resolve the media path against the allowlist, scoped to this
// chat so one chat cannot request another chat's downloaded media.
resolved, err := ResolveMediaPathForChat(filePath, chatID)
if err != nil {
h.log.Error("media file rejected", "chat_id", chatID, "path", filePath, "error", err)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: media file rejected: %s: %w", filePath, err))
}
return
}
switch mediaType {
case "photo":
var opts *SendOpts
if replyToMessageID != 0 {
opts = &SendOpts{ReplyToMessageID: replyToMessageID}
}
_, err = h.Bot.SendPhoto(chatID, resolved, "", opts)
case "voice":
var opts *SendOpts
if replyToMessageID != 0 {
opts = &SendOpts{ReplyToMessageID: replyToMessageID}
}
_, err = h.Bot.SendVoice(chatID, resolved, "", opts)
case "document":
var opts *SendOpts
if replyToMessageID != 0 {
opts = &SendOpts{ReplyToMessageID: replyToMessageID}
}
_, err = h.Bot.SendDocument(chatID, resolved, "", opts)
default:
// Unknown media type — send as a document (zip, csv, pdf, etc.)
var opts *SendOpts
if replyToMessageID != 0 {
opts = &SendOpts{ReplyToMessageID: replyToMessageID}
}
_, err = h.Bot.SendDocument(chatID, resolved, "", opts)
}
if err != nil {
h.log.Error("send media failed", "chat_id", chatID, "media_type", mediaType, "path", resolved, "error", err)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: send media: %w", err))
}
}
}
// sendChunk sends a single text chunk, retrying with plain text on failures.
// If replyToMessageID is non-zero, the chunk is sent as a reply to that message.
// When both MarkdownV2 and plain-text sends fail, a short fallback error
// message is sent so the user knows a portion of the response was lost.
func (h *Handler) sendChunk(chatID int64, chunk string, replyToMessageID int) {
// Try with MarkdownV2 first.
opts := &SendOpts{ParseMode: ParseModeMarkdownV2}
if replyToMessageID != 0 {
opts.ReplyToMessageID = replyToMessageID
}
_, err := h.Bot.SendMessage(chatID, chunk, opts)
if err == nil {
return
}
// Retry with plain text — covers parse errors, 5xx errors,
// rate limits, and other transient failures.
plainOpts := &SendOpts{}
if replyToMessageID != 0 {
plainOpts.ReplyToMessageID = replyToMessageID
}
_, err = h.Bot.SendMessage(chatID, chunk, plainOpts)
if err != nil {
h.log.Error("send message failed", "chat_id", chatID, "error", err)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: send message: %w", err))
}
// Send a fallback so the user knows part of the response was lost.
if _, fbErr := h.Bot.SendMessage(chatID, "⚠️ [part of response lost — Telegram API error]", plainOpts); fbErr != nil {
h.log.Error("send fallback also failed", "chat_id", chatID, "error", fbErr)
}
}
}
// ─── Access Control ───────────────────────────────────────────────────────
// isAllowed checks if the given chat and user are allowed to interact.
// When both allowlists are empty, access is denied unless AllowAllUsers was
// explicitly enabled (fail-closed). When a list is non-empty, the corresponding
// ID must appear in it.
func (h *Handler) isAllowed(chatID int64, userID int64) bool {
// Fail-closed: with no allowlist at all, deny unless explicitly opted in.
if len(h.Config.AllowedChats) == 0 && len(h.Config.AllowedUsers) == 0 {
return h.Config.AllowAllUsers
}
if len(h.Config.AllowedChats) > 0 {
found := false
for _, id := range h.Config.AllowedChats {
if id == chatID {
found = true
break
}
}
if !found {
return false
}
}
if len(h.Config.AllowedUsers) > 0 {
found := false
for _, id := range h.Config.AllowedUsers {
if id == userID {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// ─── Message Helpers ──────────────────────────────────────────────────────
// IsCommand reports whether the message is a bot command.
// It checks the entities for type "bot_command".
func (m *Message) IsCommand() bool {
if m == nil {
return false
}
for _, e := range m.Entities {
if e.Type == "bot_command" {
return true
}
}
// Fallback: check if text starts with "/".
return strings.HasPrefix(strings.TrimSpace(m.Text), "/")
}
// extractCommand parses a command string into the command name and arguments.
//
// "/command arg1 arg2" → ("command", "arg1 arg2")
// "/command@BotName arg1" → ("command", "arg1 arg2")
// "plain text" → ("", "plain text")
func extractCommand(text string) (cmd string, args string) {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "/") {
return "", text
}
// Split into command token and the rest.
parts := strings.SplitN(text, " ", 2)
cmdPart := parts[0]
// Strip the leading "/".
cmdPart = cmdPart[1:]
// Strip @BotUsername if present.
if atIdx := strings.Index(cmdPart, "@"); atIdx >= 0 {
cmdPart = cmdPart[:atIdx]
}
args = ""
if len(parts) > 1 {
args = parts[1]
}
return cmdPart, args
}
// approvalToast returns a toast message for an approval callback action.
// Parses the callback data prefix to determine the user's choice.
func approvalToast(data string) string {
switch {
case strings.HasPrefix(data, cbPrefixApprove):
return "✅ Approved"
case strings.HasPrefix(data, cbPrefixDeny):
return "❌ Denied"
case strings.HasPrefix(data, cbPrefixTrust):
return "🔒 Trusted for this session"
default:
return ""
}
}