diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index d8d68e3..30a1d22 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1,2 @@
-* @aldy505 @elianiva
\ No newline at end of file
+* @teknologi-umum/backend-go
+.github @teknologi-umum/infrastructure
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 0bc2ebe..3d8c371 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -11,6 +11,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
+ - name: Setup jq
+ uses: dcarbone/install-jq-action@v2.1.0
+
- name: Checkout code
uses: actions/checkout@v4
with:
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index d9a2dd4..85bc549 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -11,6 +11,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
+ - name: Setup jq
+ uses: dcarbone/install-jq-action@v2.1.0
+
- name: Checkout code
uses: actions/checkout@v4
with:
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
old mode 100755
new mode 100644
index c8397c9..518bf5e
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,5 +2,6 @@
+
\ No newline at end of file
diff --git a/analytics/analytics.go b/analytics/analytics.go
index 736b177..0101915 100644
--- a/analytics/analytics.go
+++ b/analytics/analytics.go
@@ -3,7 +3,7 @@ package analytics
import (
"github.com/allegro/bigcache/v3"
"github.com/jmoiron/sqlx"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// Dependency is the dependency injection struct
diff --git a/analytics/join.go b/analytics/join.go
index 348421e..68f584c 100644
--- a/analytics/join.go
+++ b/analytics/join.go
@@ -11,7 +11,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// NewUser adds a newly joined user on the group into the database.
diff --git a/analytics/join_test.go b/analytics/join_test.go
index 57bd5ee..3fc7a96 100644
--- a/analytics/join_test.go
+++ b/analytics/join_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"github.com/getsentry/sentry-go"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
func TestNewUser(t *testing.T) {
diff --git a/analytics/msg.go b/analytics/msg.go
index f6f5390..0233f00 100644
--- a/analytics/msg.go
+++ b/analytics/msg.go
@@ -4,7 +4,7 @@ import (
"context"
"time"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// NewMessage handles an incoming message from the group
diff --git a/analytics/msg_test.go b/analytics/msg_test.go
index 28e794d..d9492d8 100644
--- a/analytics/msg_test.go
+++ b/analytics/msg_test.go
@@ -3,7 +3,7 @@ package analytics_test
import (
"testing"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
func TestNewMsg(t *testing.T) {
diff --git a/analytics/parser.go b/analytics/parser.go
index 5182190..f922d16 100644
--- a/analytics/parser.go
+++ b/analytics/parser.go
@@ -9,7 +9,7 @@ import (
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
type NullInt64 sql.NullInt64
diff --git a/analytics/parser_test.go b/analytics/parser_test.go
index 0aaa92d..8f34319 100644
--- a/analytics/parser_test.go
+++ b/analytics/parser_test.go
@@ -5,7 +5,7 @@ import (
"github.com/teknologi-umum/captcha/analytics"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
func TestParseGroupMember(t *testing.T) {
diff --git a/analytics/swarm.go b/analytics/swarm.go
index 4e8a0a8..ef50f79 100644
--- a/analytics/swarm.go
+++ b/analytics/swarm.go
@@ -9,7 +9,7 @@ import (
"github.com/teknologi-umum/captcha/shared"
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
func (d *Dependency) SwarmLog(user *tb.User, groupID int64, finishedCaptcha bool) {
@@ -125,7 +125,7 @@ func (d *Dependency) UpdateSwarm(user *tb.User, groupID int64, finishedCaptcha b
}
func (d *Dependency) PurgeBots(ctx context.Context, m *tb.Message) {
- admins, err := d.Bot.AdminsOf(m.Chat)
+ admins, err := d.Bot.AdminsOf(ctx, m.Chat)
if err != nil {
shared.HandleError(ctx, fmt.Errorf("get admins: %w", err))
return
@@ -186,7 +186,7 @@ func (d *Dependency) PurgeBots(ctx context.Context, m *tb.Message) {
return
}
- err = d.Bot.Ban(m.Chat, &tb.ChatMember{
+ err = d.Bot.Ban(ctx, m.Chat, &tb.ChatMember{
RestrictedUntil: tb.Forever(),
User: &tb.User{
ID: userID,
@@ -237,7 +237,7 @@ func (d *Dependency) PurgeBots(ctx context.Context, m *tb.Message) {
return
}
- _, err = d.Bot.Send(m.Chat, fmt.Sprintf("%d bots have been banned", len(userIDs)))
+ _, err = d.Bot.Send(ctx, m.Chat, fmt.Sprintf("%d bots have been banned", len(userIDs)))
if err != nil {
shared.HandleError(ctx, fmt.Errorf("send: %w", err))
return
diff --git a/ascii/ascii.go b/ascii/ascii.go
index 8b7fbf8..92d59ee 100644
--- a/ascii/ascii.go
+++ b/ascii/ascii.go
@@ -7,7 +7,7 @@ import (
"github.com/teknologi-umum/captcha/shared"
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// Dependencies contains dependency injection struct
@@ -25,6 +25,7 @@ func (d *Dependencies) Ascii(ctx context.Context, m *tb.Message) {
gen := utils.GenerateAscii(m.Payload)
_, err := d.Bot.Send(
+ ctx,
m.Chat,
"
"+gen+"
",
&tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true},
@@ -32,6 +33,7 @@ func (d *Dependencies) Ascii(ctx context.Context, m *tb.Message) {
if err != nil {
if errors.Is(err, tb.ErrEmptyMessage) {
_, err := d.Bot.Send(
+ ctx,
m.Chat,
"That text is not supported yet",
&tb.SendOptions{
diff --git a/captcha/additional.go b/captcha/additional.go
index 8b63086..f5e076d 100644
--- a/captcha/additional.go
+++ b/captcha/additional.go
@@ -5,7 +5,7 @@ import (
"fmt"
"strconv"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// Collect AdditionalMsg that was sent because the user did something
diff --git a/captcha/answer.go b/captcha/answer.go
index 4971e0b..7c8e6b7 100644
--- a/captcha/answer.go
+++ b/captcha/answer.go
@@ -13,7 +13,7 @@ import (
"github.com/allegro/bigcache/v3"
"github.com/pkg/errors"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// WaitForAnswer is the handler for listening to incoming user message.
@@ -76,6 +76,7 @@ func (d *Dependencies) WaitForAnswer(ctx context.Context, m *tb.Message) {
if answer != captcha.Answer {
remainingTime := time.Until(captcha.Expiry)
wrongMsg, err := d.Bot.Send(
+ ctx,
m.Chat,
"Jawaban captcha salah, harap coba lagi. Kamu punya "+
strconv.Itoa(int(remainingTime.Seconds()))+
@@ -84,15 +85,10 @@ func (d *Dependencies) WaitForAnswer(ctx context.Context, m *tb.Message) {
ParseMode: tb.ModeHTML,
ReplyTo: m,
DisableWebPagePreview: true,
+ AllowWithoutReply: true,
},
)
if err != nil {
- if strings.Contains(err.Error(), "replied message not found") {
- // Don't retry to send the message if the user won't know
- // which message we're replying to.
- return
- }
-
if strings.Contains(err.Error(), "retry after") {
// If this happens, probably we're in a spam bot surge and would
// probably don't care with the user captcha after all.
@@ -153,7 +149,7 @@ func (d *Dependencies) WaitForAnswer(ctx context.Context, m *tb.Message) {
if msgID == "" {
continue
}
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: msgID,
})
@@ -168,7 +164,7 @@ func (d *Dependencies) WaitForAnswer(ctx context.Context, m *tb.Message) {
if msgID == "" {
continue
}
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: msgID,
})
@@ -179,7 +175,7 @@ func (d *Dependencies) WaitForAnswer(ctx context.Context, m *tb.Message) {
}
// Delete the question message.
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: captcha.QuestionID,
})
diff --git a/captcha/captcha.go b/captcha/captcha.go
index 2956487..e990c86 100644
--- a/captcha/captcha.go
+++ b/captcha/captcha.go
@@ -2,7 +2,7 @@ package captcha
import (
"github.com/allegro/bigcache/v3"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// Dependencies contains the dependency injection struct for
diff --git a/captcha/delete.go b/captcha/delete.go
index 4c0fb69..875d174 100644
--- a/captcha/delete.go
+++ b/captcha/delete.go
@@ -2,14 +2,14 @@ package captcha
import (
"context"
+ "errors"
"fmt"
- "strconv"
"strings"
"time"
"github.com/teknologi-umum/captcha/shared"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// deleteMessage creates a timer of one minute to delete a certain message.
@@ -17,18 +17,15 @@ func (d *Dependencies) deleteMessage(ctx context.Context, message *tb.StoredMess
c := make(chan struct{}, 1)
time.AfterFunc(time.Minute*1, func() {
for {
- err := d.Bot.Delete(message)
+ err := d.Bot.Delete(ctx, message)
if err != nil && !strings.Contains(err.Error(), "message to delete not found") {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
@@ -49,20 +46,17 @@ func (d *Dependencies) deleteMessage(ctx context.Context, message *tb.StoredMess
<-c
}
-func (d *Dependencies) deleteMessageBlocking(message *tb.StoredMessage) error {
+func (d *Dependencies) deleteMessageBlocking(ctx context.Context, message *tb.StoredMessage) error {
for {
- err := d.Bot.Delete(message)
+ err := d.Bot.Delete(ctx, message)
if err != nil && !strings.Contains(err.Error(), "message to delete not found") {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
diff --git a/captcha/join.go b/captcha/join.go
index e75891a..59b8dc2 100644
--- a/captcha/join.go
+++ b/captcha/join.go
@@ -16,7 +16,7 @@ import (
"github.com/allegro/bigcache/v3"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// Captcha struct keeps all the data needed for the captcha
@@ -72,7 +72,7 @@ func (d *Dependencies) CaptchaUserJoin(ctx context.Context, m *tb.Message) {
if err != nil {
if errors.Is(err, bigcache.ErrEntryNotFound) {
// Find and set
- admins, err = d.Bot.AdminsOf(m.Chat)
+ admins, err = d.Bot.AdminsOf(ctx, m.Chat)
if err != nil {
if !strings.Contains(err.Error(), "Gateway Timeout (504)") && !strings.Contains(err.Error(), "retry after") {
shared.HandleBotError(ctx, err, d.Bot, m)
@@ -138,25 +138,24 @@ func (d *Dependencies) CaptchaUserJoin(ctx context.Context, m *tb.Message) {
SENDMSG_RETRY:
// Send the question first.
msgQuestion, err := d.Bot.Send(
+ ctx,
m.Chat,
question,
&tb.SendOptions{
ParseMode: tb.ModeHTML,
ReplyTo: m,
DisableWebPagePreview: true,
+ AllowWithoutReply: true,
},
)
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 10 second
- retry = 10
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
goto SENDMSG_RETRY
}
@@ -165,23 +164,6 @@ SENDMSG_RETRY:
goto SENDMSG_RETRY
}
- if strings.Contains(err.Error(), "replied message not found") {
- msgQuestion, err = d.Bot.Send(
- m.Chat,
- question,
- &tb.SendOptions{
- ParseMode: tb.ModeHTML,
- DisableWebPagePreview: true,
- },
- )
- if err != nil {
- if !strings.Contains(err.Error(), "retry after") && !strings.Contains(err.Error(), "Gateway Timeout (504)") {
- shared.HandleBotError(ctx, err, d.Bot, m)
- return
- }
- }
- }
-
// err could possibly be nil at this point, so we better check it out.
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, m)
diff --git a/captcha/leave.go b/captcha/leave.go
index 89fb1ab..ac3232e 100644
--- a/captcha/leave.go
+++ b/captcha/leave.go
@@ -10,7 +10,7 @@ import (
"github.com/teknologi-umum/captcha/shared"
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// CaptchaUserLeave handles the event when a user left the group.
@@ -22,7 +22,7 @@ func (d *Dependencies) CaptchaUserLeave(ctx context.Context, m *tb.Message) {
// Check if the user is an admin or bot first.
// If they are, return.
// If they're not, continue to execute the captcha.
- admins, err := d.Bot.AdminsOf(m.Chat)
+ admins, err := d.Bot.AdminsOf(ctx, m.Chat)
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, m)
return
@@ -71,7 +71,7 @@ func (d *Dependencies) CaptchaUserLeave(ctx context.Context, m *tb.Message) {
}
// Delete the question message.
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: captcha.QuestionID,
})
@@ -85,7 +85,7 @@ func (d *Dependencies) CaptchaUserLeave(ctx context.Context, m *tb.Message) {
if msgID == "" {
continue
}
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: msgID,
})
@@ -100,7 +100,7 @@ func (d *Dependencies) CaptchaUserLeave(ctx context.Context, m *tb.Message) {
if msgID == "" {
continue
}
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: msgID,
})
diff --git a/captcha/non.go b/captcha/non.go
index 69379f0..3b2f441 100644
--- a/captcha/non.go
+++ b/captcha/non.go
@@ -11,7 +11,7 @@ import (
"github.com/teknologi-umum/captcha/shared"
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// NonTextListener is the handler for every incoming payload that
@@ -57,6 +57,7 @@ func (d *Dependencies) NonTextListener(ctx context.Context, m *tb.Message) {
// Check if the answer is a media
remainingTime := time.Until(captcha.Expiry)
wrongMsg, err := d.Bot.Send(
+ ctx,
m.Chat,
"Hai, "+
utils.SanitizeInput(m.Sender.FirstName)+
@@ -76,7 +77,7 @@ func (d *Dependencies) NonTextListener(ctx context.Context, m *tb.Message) {
return
}
- err = d.deleteMessageBlocking(&tb.StoredMessage{
+ err = d.deleteMessageBlocking(ctx, &tb.StoredMessage{
ChatID: m.Chat.ID,
MessageID: strconv.Itoa(m.ID),
})
diff --git a/captcha/wait.go b/captcha/wait.go
index a9e1b81..d853f72 100644
--- a/captcha/wait.go
+++ b/captcha/wait.go
@@ -12,7 +12,7 @@ import (
"github.com/allegro/bigcache/v3"
"github.com/pkg/errors"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// waitOrDelete will start a timer. If the timer is expired, it will kick the user from the group.
@@ -46,6 +46,7 @@ func (d *Dependencies) waitOrDelete(ctx context.Context, msgUser *tb.Message) {
KICKMSG_RETRY:
// Goodbye, user!
kickMsg, err := d.Bot.Send(
+ ctx,
msgUser.Chat,
""+
utils.SanitizeInput(msgUser.Sender.FirstName)+
@@ -56,16 +57,13 @@ func (d *Dependencies) waitOrDelete(ctx context.Context, msgUser *tb.Message) {
ParseMode: tb.ModeHTML,
})
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
goto KICKMSG_RETRY
}
@@ -82,21 +80,18 @@ func (d *Dependencies) waitOrDelete(ctx context.Context, msgUser *tb.Message) {
// Even if the keyword is Ban, it's just kicking them.
// If the RestrictedUntil value is below zero, it means
// they are banned forever.
- err = d.Bot.Ban(msgUser.Chat, &tb.ChatMember{
+ err = d.Bot.Ban(ctx, msgUser.Chat, &tb.ChatMember{
RestrictedUntil: time.Now().Add(BanDuration).Unix(),
User: msgUser.Sender,
}, true)
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
goto BAN_RETRY
}
@@ -114,7 +109,7 @@ func (d *Dependencies) waitOrDelete(ctx context.Context, msgUser *tb.Message) {
ChatID: msgUser.Chat.ID,
MessageID: captcha.QuestionID,
}
- err = d.deleteMessageBlocking(&msgToBeDeleted)
+ err = d.deleteMessageBlocking(ctx, &msgToBeDeleted)
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, msgUser)
break
@@ -125,7 +120,7 @@ func (d *Dependencies) waitOrDelete(ctx context.Context, msgUser *tb.Message) {
ChatID: msgUser.Chat.ID,
MessageID: msgID,
}
- err = d.deleteMessageBlocking(&msgToBeDeleted)
+ err = d.deleteMessageBlocking(ctx, &msgToBeDeleted)
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, msgUser)
break
diff --git a/captcha/welcome.go b/captcha/welcome.go
index d433cbf..6fb6056 100644
--- a/captcha/welcome.go
+++ b/captcha/welcome.go
@@ -2,6 +2,7 @@ package captcha
import (
"context"
+ "errors"
"fmt"
"math/rand"
"strconv"
@@ -12,7 +13,7 @@ import (
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// currentWelcomeMessages is a collection of welcome messages
@@ -76,6 +77,7 @@ func (d *Dependencies) sendWelcomeMessage(ctx context.Context, m *tb.Message) er
for {
msg, err := d.Bot.Send(
+ ctx,
m.Chat,
strings.NewReplacer(
"{user}",
@@ -94,16 +96,13 @@ func (d *Dependencies) sendWelcomeMessage(ctx context.Context, m *tb.Message) er
},
)
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
diff --git a/cmd/captcha/commands.go b/cmd/captcha/commands.go
index 885f04c..36bd73b 100644
--- a/cmd/captcha/commands.go
+++ b/cmd/captcha/commands.go
@@ -15,9 +15,9 @@ import (
"github.com/teknologi-umum/captcha/ascii"
"github.com/teknologi-umum/captcha/badwords"
"github.com/teknologi-umum/captcha/captcha"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
"github.com/teknologi-umum/captcha/shared"
"github.com/teknologi-umum/captcha/underattack"
- tb "gopkg.in/telebot.v3"
)
// Dependency contains the dependency injection struct
@@ -179,7 +179,7 @@ func (d *Dependency) BadWordHandler(c tb.Context) error {
return nil
}
- _, err = c.Bot().Send(c.Sender(), "Terimakasih telah menambahkan kata yang tidak pantas.")
+ _, err = c.Bot().Send(ctx, c.Sender(), "Terimakasih telah menambahkan kata yang tidak pantas.")
if err != nil {
shared.HandleBotError(ctx, err, c.Bot(), c.Message())
}
diff --git a/cmd/captcha/main.go b/cmd/captcha/main.go
index c185922..fded1c7 100644
--- a/cmd/captcha/main.go
+++ b/cmd/captcha/main.go
@@ -50,7 +50,7 @@ import (
"github.com/getsentry/sentry-go"
_ "github.com/joho/godotenv/autoload"
"github.com/pkg/errors"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
var version string
@@ -200,7 +200,7 @@ func main() {
log.Fatal("during init of bot client:", errors.WithStack(err))
}
defer func() {
- _, err := b.Close()
+ _, err := b.Close(context.Background())
if err != nil {
sentry.CaptureException(err)
log.Print(errors.WithStack(err))
@@ -337,7 +337,7 @@ func main() {
// This is basically just for health check.
b.Handle("/start", func(c tb.Context) error {
if c.Message().FromGroup() {
- _, err := c.Bot().Send(c.Message().Chat, "ok")
+ _, err := c.Bot().Send(context.Background(), c.Message().Chat, "ok")
if err != nil {
shared.HandleBotError(ctx, err, b, c.Message())
return nil
diff --git a/go.mod b/go.mod
index 4bbe329..a0875c3 100644
--- a/go.mod
+++ b/go.mod
@@ -7,22 +7,40 @@ require (
github.com/allegro/bigcache/v3 v3.1.0
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi/v5 v5.0.11
+ github.com/goccy/go-yaml v1.9.5
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/jmoiron/sqlx v1.3.5
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/pkg/errors v0.9.1
github.com/rs/cors v1.10.1
+ github.com/spf13/viper v1.13.0
+ github.com/stretchr/testify v1.8.2
github.com/unrolled/secure v1.13.0
go.mongodb.org/mongo-driver v1.13.1
- gopkg.in/telebot.v3 v3.2.1
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/fatih/color v1.13.0 // indirect
+ github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
github.com/klauspost/compress v1.16.0 // indirect
+ github.com/magiconair/properties v1.8.6 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
+ github.com/pelletier/go-toml v1.9.5 // indirect
+ github.com/pelletier/go-toml/v2 v2.0.5 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/spf13/afero v1.8.2 // indirect
+ github.com/spf13/cast v1.5.0 // indirect
+ github.com/spf13/jwalterweatherman v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/subosito/gotenv v1.4.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
@@ -31,6 +49,10 @@ require (
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
+ golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)
diff --git a/go.sum b/go.sum
index e9d0882..33a0155 100644
--- a/go.sum
+++ b/go.sum
@@ -17,32 +17,14 @@ cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHOb
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
-cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
-cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
-cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
-cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
-cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
-cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
-cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
-cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
-cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
-cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -58,99 +40,56 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/aldy505/asciitxt v0.0.2 h1:m5DPU0NzYBt3jNEMKhZL4o3+lPOPnHlii1EBMqnjMpo=
github.com/aldy505/asciitxt v0.0.2/go.mod h1:RFESbriJgvvuNfRYbLXQQHowPXfDHUy6+ml2xwj6PeQ=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk=
github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
-github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
-github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
-github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
+github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
+github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI=
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
+github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/goccy/go-yaml v1.9.5 h1:Eh/+3uk9kLxG4koCX6lRMAPS1OaMSAi+FJcya0INdB0=
github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
@@ -158,8 +97,6 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -174,11 +111,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -191,18 +124,11 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
-github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -213,51 +139,15 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
-github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
-github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
-github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
-github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
-github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
-github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
-github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
-github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
-github.com/hashicorp/serf v0.9.7/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
@@ -266,136 +156,78 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
-github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
-github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
-github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
-github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
-github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
+github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
+github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.13.0 h1:BWSJ/M+f+3nmdz9bxB+bWX28kkALN2ok11D0rSo8EJU=
github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
-github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/unrolled/secure v1.13.0 h1:sdr3Phw2+f8Px8HE5sd1EHdj1aV3yUwed/uZXChLFsk=
github.com/unrolled/secure v1.13.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
@@ -411,12 +243,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
-go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
-go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk=
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@@ -425,23 +252,15 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
@@ -468,7 +287,6 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@@ -479,11 +297,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -491,11 +307,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -512,22 +326,10 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
-golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -538,17 +340,6 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -559,36 +350,23 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -599,8 +377,6 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -608,40 +384,15 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -652,7 +403,6 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
@@ -675,7 +425,6 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -700,7 +449,6 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
@@ -709,20 +457,14 @@ golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
@@ -743,26 +485,6 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
-google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
-google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
-google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
-google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
-google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
-google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
-google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
-google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
-google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
-google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
-google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
-google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
-google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
-google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
-google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -793,7 +515,6 @@ google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfG
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
@@ -806,48 +527,7 @@ google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
-google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
-google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
-google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -861,24 +541,9 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
-google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -889,29 +554,17 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/telebot.v3 v3.2.1 h1:3I4LohaAyJBiivGmkfB+CiVu7QFOWkuZ4+KHgO/G3rs=
-gopkg.in/telebot.v3 v3.2.1/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -926,4 +579,3 @@ olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/internal/telebot/.github/workflows/go.yml b/internal/telebot/.github/workflows/go.yml
new file mode 100644
index 0000000..a0e99df
--- /dev/null
+++ b/internal/telebot/.github/workflows/go.yml
@@ -0,0 +1,17 @@
+name: Go
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.16
+
+ - name: Test
+ run: export TELEBOT_SECRET=${{ secrets.TELEBOT_SECRET }} && export CHAT_ID=${{ secrets.CHAT_ID }} && export USER_ID=${{ secrets.USER_ID }} && go test -v ./...
\ No newline at end of file
diff --git a/internal/telebot/.gitignore b/internal/telebot/.gitignore
new file mode 100644
index 0000000..c81da31
--- /dev/null
+++ b/internal/telebot/.gitignore
@@ -0,0 +1,34 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof
+
+.idea
+.DS_Store
+coverage.txt
+
+# Terraform artifacts
+*.zip
+.terraform*
+terraform*
+/examples/awslambdaechobot/awslambdaechobot
diff --git a/internal/telebot/LICENSE b/internal/telebot/LICENSE
new file mode 100644
index 0000000..2965b84
--- /dev/null
+++ b/internal/telebot/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 llya Kowalewski
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/internal/telebot/README.md b/internal/telebot/README.md
new file mode 100644
index 0000000..060a70d
--- /dev/null
+++ b/internal/telebot/README.md
@@ -0,0 +1,489 @@
+# Telebot
+>"I never knew creating Telegram bots could be so _sexy_!"
+
+[![GoDoc](https://godoc.org/gopkg.in/telebot.v3?status.svg)](https://godoc.org/gopkg.in/telebot.v3)
+[![GitHub Actions](https://github.com/tucnak/telebot/actions/workflows/go.yml/badge.svg)](https://github.com/tucnak/telebot/actions)
+[![codecov.io](https://codecov.io/gh/tucnak/telebot/coverage.svg?branch=v3)](https://codecov.io/gh/tucnak/telebot)
+[![Discuss on Telegram](https://img.shields.io/badge/telegram-discuss-0088cc.svg)](https://t.me/go_telebot)
+
+```bash
+go get -u gopkg.in/telebot.v3
+```
+
+* [Overview](#overview)
+* [Getting Started](#getting-started)
+ - [Context](#context)
+ - [Middleware](#middleware)
+ - [Poller](#poller)
+ - [Commands](#commands)
+ - [Files](#files)
+ - [Sendable](#sendable)
+ - [Editable](#editable)
+ - [Keyboards](#keyboards)
+ - [Inline mode](#inline-mode)
+* [Contributing](#contributing)
+* [Donate](#donate)
+* [License](#license)
+
+# Overview
+Telebot is a bot framework for [Telegram Bot API](https://core.telegram.org/bots/api).
+This package provides the best of its kind API for command routing, inline query requests and keyboards, as well
+as callbacks. Actually, I went a couple steps further, so instead of making a 1:1 API wrapper I chose to focus on
+the beauty of API and performance. Some strong sides of Telebot are:
+
+* Real concise API
+* Command routing
+* Middleware
+* Transparent File API
+* Effortless bot callbacks
+
+All the methods of Telebot API are _extremely_ easy to memorize and get used to. Also, consider Telebot a
+highload-ready solution. I'll test and benchmark the most popular actions and if necessary, optimize
+against them without sacrificing API quality.
+
+# Getting Started
+Let's take a look at the minimal Telebot setup:
+
+```go
+package main
+
+import (
+ "log"
+ "os"
+ "time"
+
+ tele "gopkg.in/telebot.v3"
+)
+
+func main() {
+ pref := tele.Settings{
+ Token: os.Getenv("TOKEN"),
+ Poller: &tele.LongPoller{Timeout: 10 * time.Second},
+ }
+
+ b, err := tele.NewBot(pref)
+ if err != nil {
+ log.Fatal(err)
+ return
+ }
+
+ b.Handle("/hello", func(c tele.Context) error {
+ return c.Send("Hello!")
+ })
+
+ b.Start()
+}
+
+```
+
+Simple, innit? Telebot's routing system takes care of delivering updates
+to their endpoints, so in order to get to handle any meaningful event,
+all you got to do is just plug your function into one of the Telebot-provided
+endpoints. You can find the full list
+[here](https://godoc.org/gopkg.in/telebot.v3#pkg-constants).
+
+There are dozens of supported endpoints (see package consts). Let me know
+if you'd like to see some endpoint or endpoint ideas implemented. This system
+is completely extensible, so I can introduce them without breaking
+backwards compatibility.
+
+## Context
+Context is a special type that wraps a huge update structure and represents
+the context of the current event. It provides several helpers, which allow
+getting, for example, the chat that this update had been sent in, no matter
+what kind of update this is.
+
+```go
+b.Handle(tele.OnText, func(c tele.Context) error {
+ // All the text messages that weren't
+ // captured by existing handlers.
+
+ var (
+ user = c.Sender()
+ text = c.Text()
+ )
+
+ // Use full-fledged bot's functions
+ // only if you need a result:
+ msg, err := b.Send(user, text)
+ if err != nil {
+ return err
+ }
+
+ // Instead, prefer a context short-hand:
+ return c.Send(text)
+})
+
+b.Handle(tele.OnChannelPost, func(c tele.Context) error {
+ // Channel posts only.
+ msg := c.Message()
+})
+
+b.Handle(tele.OnPhoto, func(c tele.Context) error {
+ // Photos only.
+ photo := c.Message().Photo
+})
+
+b.Handle(tele.OnQuery, func(c tele.Context) error {
+ // Incoming inline queries.
+ return c.Answer(...)
+})
+```
+
+## Middleware
+Telebot has a simple and recognizable way to set up middleware — chained functions with access to `Context`, called before the handler execution.
+
+Import a `middleware` package to get some basic out-of-box middleware
+implementations:
+```go
+import "gopkg.in/telebot.v3/middleware"
+```
+
+```go
+// Global-scoped middleware:
+b.Use(middleware.Logger())
+b.Use(middleware.AutoRespond())
+
+// Group-scoped middleware:
+adminOnly := b.Group()
+adminOnly.Use(middleware.Whitelist(adminIDs...))
+adminOnly.Handle("/ban", onBan)
+adminOnly.Handle("/kick", onKick)
+
+// Handler-scoped middleware:
+b.Handle(tele.OnText, onText, middleware.IgnoreVia())
+```
+
+Custom middleware example:
+```go
+// AutoResponder automatically responds to every callback update.
+func AutoResponder(next tele.HandlerFunc) tele.HandlerFunc {
+ return func(c tele.Context) error {
+ if c.Callback() != nil {
+ defer c.Respond()
+ }
+ return next(c) // continue execution chain
+ }
+}
+```
+
+## Poller
+Telebot doesn't really care how you provide it with incoming updates, as long
+as you set it up with a Poller, or call ProcessUpdate for each update:
+
+```go
+// Poller is a provider of Updates.
+//
+// All pollers must implement Poll(), which accepts bot
+// pointer and subscription channel and start polling
+// synchronously straight away.
+type Poller interface {
+ // Poll is supposed to take the bot object
+ // subscription channel and start polling
+ // for Updates immediately.
+ //
+ // Poller must listen for stop constantly and close
+ // it as soon as it's done polling.
+ Poll(b *Bot, updates chan Update, stop chan struct{})
+}
+```
+
+## Commands
+When handling commands, Telebot supports both direct (`/command`) and group-like
+syntax (`/command@botname`) and will never deliver messages addressed to some
+other bot, even if [privacy mode](https://core.telegram.org/bots#privacy-mode) is off.
+
+For simplified deep-linking, Telebot also extracts payload:
+```go
+// Command: /start
+b.Handle("/start", func(c tele.Context) error {
+ fmt.Println(c.Message().Payload) //
+})
+```
+
+For multiple arguments use:
+```go
+// Command: /tags <...>
+b.Handle("/tags", func(c tele.Context) error {
+ tags := c.Args() // list of arguments splitted by a space
+ for _, tag := range tags {
+ // iterate through passed arguments
+ }
+})
+```
+
+## Files
+>Telegram allows files up to 50 MB in size.
+
+Telebot allows to both upload (from disk or by URL) and download (from Telegram)
+files in bot's scope. Also, sending any kind of media with a File created
+from disk will upload the file to Telegram automatically:
+```go
+a := &tele.Audio{File: tele.FromDisk("file.ogg")}
+
+fmt.Println(a.OnDisk()) // true
+fmt.Println(a.InCloud()) // false
+
+// Will upload the file from disk and send it to the recipient
+b.Send(recipient, a)
+
+// Next time you'll be sending this very *Audio, Telebot won't
+// re-upload the same file but rather utilize its Telegram FileID
+b.Send(otherRecipient, a)
+
+fmt.Println(a.OnDisk()) // true
+fmt.Println(a.InCloud()) // true
+fmt.Println(a.FileID) //
+```
+
+You might want to save certain `File`s in order to avoid re-uploading. Feel free
+to marshal them into whatever format, `File` only contain public fields, so no
+data will ever be lost.
+
+## Sendable
+Send is undoubtedly the most important method in Telebot. `Send()` accepts a
+`Recipient` (could be user, group or a channel) and a `Sendable`. Other types other than
+the Telebot-provided media types (`Photo`, `Audio`, `Video`, etc.) are `Sendable`.
+If you create composite types of your own, and they satisfy the `Sendable` interface,
+Telebot will be able to send them out.
+
+```go
+// Sendable is any object that can send itself.
+//
+// This is pretty cool, since it lets bots implement
+// custom Sendables for complex kinds of media or
+// chat objects spanning across multiple messages.
+type Sendable interface {
+ Send(*Bot, Recipient, *SendOptions) (*Message, error)
+}
+```
+
+The only type at the time that doesn't fit `Send()` is `Album` and there is a reason
+for that. Albums were added not so long ago, so they are slightly quirky for backwards
+compatibilities sake. In fact, an `Album` can be sent, but never received. Instead,
+Telegram returns a `[]Message`, one for each media object in the album:
+```go
+p := &tele.Photo{File: tele.FromDisk("chicken.jpg")}
+v := &tele.Video{File: tele.FromURL("http://video.mp4")}
+
+msgs, err := b.SendAlbum(user, tele.Album{p, v})
+```
+
+### Send options
+Send options are objects and flags you can pass to `Send()`, `Edit()` and friends
+as optional arguments (following the recipient and the text/media). The most
+important one is called `SendOptions`, it lets you control _all_ the properties of
+the message supported by Telegram. The only drawback is that it's rather
+inconvenient to use at times, so `Send()` supports multiple shorthands:
+```go
+// regular send options
+b.Send(user, "text", &tele.SendOptions{
+ // ...
+})
+
+// ReplyMarkup is a part of SendOptions,
+// but often it's the only option you need
+b.Send(user, "text", &tele.ReplyMarkup{
+ // ...
+})
+
+// flags: no notification && no web link preview
+b.Send(user, "text", tele.Silent, tele.NoPreview)
+```
+
+Full list of supported option-flags you can find
+[here](https://pkg.go.dev/gopkg.in/telebot.v3#Option).
+
+## Editable
+If you want to edit some existing message, you don't really need to store the
+original `*Message` object. In fact, upon edit, Telegram only requires `chat_id`
+and `message_id`. So you don't really need the Message as a whole. Also, you
+might want to store references to certain messages in the database, so I thought
+it made sense for *any* Go struct to be editable as a Telegram message, to implement
+`Editable`:
+```go
+// Editable is an interface for all objects that
+// provide "message signature", a pair of 32-bit
+// message ID and 64-bit chat ID, both required
+// for edit operations.
+//
+// Use case: DB model struct for messages to-be
+// edited with, say two columns: msg_id,chat_id
+// could easily implement MessageSig() making
+// instances of stored messages editable.
+type Editable interface {
+ // MessageSig is a "message signature".
+ //
+ // For inline messages, return chatID = 0.
+ MessageSig() (messageID int, chatID int64)
+}
+```
+
+For example, `Message` type is Editable. Here is the implementation of `StoredMessage`
+type, provided by Telebot:
+```go
+// StoredMessage is an example struct suitable for being
+// stored in the database as-is or being embedded into
+// a larger struct, which is often the case (you might
+// want to store some metadata alongside, or might not.)
+type StoredMessage struct {
+ MessageID int `sql:"message_id" json:"message_id"`
+ ChatID int64 `sql:"chat_id" json:"chat_id"`
+}
+
+func (x StoredMessage) MessageSig() (int, int64) {
+ return x.MessageID, x.ChatID
+}
+```
+
+Why bother at all? Well, it allows you to do things like this:
+```go
+// just two integer columns in the database
+var msgs []tele.StoredMessage
+db.Find(&msgs) // gorm syntax
+
+for _, msg := range msgs {
+ bot.Edit(&msg, "Updated text")
+ // or
+ bot.Delete(&msg)
+}
+```
+
+I find it incredibly neat. Worth noting, at this point of time there exists
+another method in the Edit family, `EditCaption()` which is of a pretty
+rare use, so I didn't bother including it to `Edit()`, just like I did with
+`SendAlbum()` as it would inevitably lead to unnecessary complications.
+```go
+var m *Message
+
+// change caption of a photo, audio, etc.
+bot.EditCaption(m, "new caption")
+```
+
+## Keyboards
+Telebot supports both kinds of keyboards Telegram provides: reply and inline
+keyboards. Any button can also act as endpoints for `Handle()`.
+
+```go
+var (
+ // Universal markup builders.
+ menu = &tele.ReplyMarkup{ResizeKeyboard: true}
+ selector = &tele.ReplyMarkup{}
+
+ // Reply buttons.
+ btnHelp = menu.Text("ℹ Help")
+ btnSettings = menu.Text("⚙ Settings")
+
+ // Inline buttons.
+ //
+ // Pressing it will cause the client to
+ // send the bot a callback.
+ //
+ // Make sure Unique stays unique as per button kind
+ // since it's required for callback routing to work.
+ //
+ btnPrev = selector.Data("⬅", "prev", ...)
+ btnNext = selector.Data("➡", "next", ...)
+)
+
+menu.Reply(
+ menu.Row(btnHelp),
+ menu.Row(btnSettings),
+)
+selector.Inline(
+ selector.Row(btnPrev, btnNext),
+)
+
+b.Handle("/start", func(c tele.Context) error {
+ return c.Send("Hello!", menu)
+})
+
+// On reply button pressed (message)
+b.Handle(&btnHelp, func(c tele.Context) error {
+ return c.Edit("Here is some help: ...")
+})
+
+// On inline button pressed (callback)
+b.Handle(&btnPrev, func(c tele.Context) error {
+ return c.Respond()
+})
+```
+
+You can use markup constructor for every type of possible button:
+```go
+r := b.NewMarkup()
+
+// Reply buttons:
+r.Text("Hello!")
+r.Contact("Send phone number")
+r.Location("Send location")
+r.Poll(tele.PollQuiz)
+
+// Inline buttons:
+r.Data("Show help", "help") // data is optional
+r.Data("Delete item", "delete", item.ID)
+r.URL("Visit", "https://google.com")
+r.Query("Search", query)
+r.QueryChat("Share", query)
+r.Login("Login", &tele.Login{...})
+```
+
+## Inline mode
+So if you want to handle incoming inline queries you better plug the `tele.OnQuery`
+endpoint and then use the `Answer()` method to send a list of inline queries
+back. I think at the time of writing, Telebot supports all of the provided result
+types (but not the cached ones). This is what it looks like:
+
+```go
+b.Handle(tele.OnQuery, func(c tele.Context) error {
+ urls := []string{
+ "http://photo.jpg",
+ "http://photo2.jpg",
+ }
+
+ results := make(tele.Results, len(urls)) // []tele.Result
+ for i, url := range urls {
+ result := &tele.PhotoResult{
+ URL: url,
+ ThumbURL: url, // required for photos
+ }
+
+ results[i] = result
+ // needed to set a unique string ID for each result
+ results[i].SetResultID(strconv.Itoa(i))
+ }
+
+ return c.Answer(&tele.QueryResponse{
+ Results: results,
+ CacheTime: 60, // a minute
+ })
+})
+```
+
+There's not much to talk about really. It also supports some form of authentication
+through deep-linking. For that, use fields `SwitchPMText` and `SwitchPMParameter`
+of `QueryResponse`.
+
+# Contributing
+
+1. Fork it
+2. Clone v3: `git clone -b v3 https://github.com/tucnak/telebot`
+3. Create your feature branch: `git checkout -b v3-feature`
+4. Make changes and add them: `git add .`
+5. Commit: `git commit -m "add some feature"`
+6. Push: `git push origin v3-feature`
+7. Pull request
+
+# Donate
+
+I do coding for fun, but I also try to search for interesting solutions and
+optimize them as much as possible.
+If you feel like it's a good piece of software, I wouldn't mind a tip!
+
+Litecoin: `ltc1qskt5ltrtyg7esfjm0ftx6jnacwffhpzpqmerus`
+
+Ethereum: `0xB78A2Ac1D83a0aD0b993046F9fDEfC5e619efCAB`
+
+# License
+
+Telebot is distributed under MIT.
diff --git a/internal/telebot/admin.go b/internal/telebot/admin.go
new file mode 100644
index 0000000..922644f
--- /dev/null
+++ b/internal/telebot/admin.go
@@ -0,0 +1,314 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+ "time"
+)
+
+// Rights is a list of privileges available to chat members.
+type Rights struct {
+ // Anonymous is true, if the user's presence in the chat is hidden.
+ Anonymous bool `json:"is_anonymous"`
+
+ CanBeEdited bool `json:"can_be_edited"`
+ CanChangeInfo bool `json:"can_change_info"`
+ CanPostMessages bool `json:"can_post_messages"`
+ CanEditMessages bool `json:"can_edit_messages"`
+ CanDeleteMessages bool `json:"can_delete_messages"`
+ CanPinMessages bool `json:"can_pin_messages"`
+ CanInviteUsers bool `json:"can_invite_users"`
+ CanRestrictMembers bool `json:"can_restrict_members"`
+ CanPromoteMembers bool `json:"can_promote_members"`
+ CanSendMessages bool `json:"can_send_messages"`
+ CanSendPolls bool `json:"can_send_polls"`
+ CanSendOther bool `json:"can_send_other_messages"`
+ CanAddPreviews bool `json:"can_add_web_page_previews"`
+ CanManageVideoChats bool `json:"can_manage_video_chats"`
+ CanManageChat bool `json:"can_manage_chat"`
+ CanManageTopics bool `json:"can_manage_topics"`
+
+ CanSendMedia bool `json:"can_send_media_messages,omitempty"` // deprecated
+ CanSendAudios bool `json:"can_send_audios"`
+ CanSendDocuments bool `json:"can_send_documents"`
+ CanSendPhotos bool `json:"can_send_photos"`
+ CanSendVideos bool `json:"can_send_videos"`
+ CanSendVideoNotes bool `json:"can_send_video_notes"`
+ CanSendVoiceNotes bool `json:"can_send_voice_notes"`
+
+ // Independent defines whether the chat permissions are set independently.
+ // If not, the can_send_other_messages and can_add_web_page_previews permissions
+ // will imply the can_send_messages, can_send_audios, can_send_documents, can_send_photos,
+ // can_send_videos, can_send_video_notes, and can_send_voice_notes permissions;
+ // the can_send_polls permission will imply the can_send_messages permission.
+ //
+ // Works for Restrict and SetGroupPermissions methods only.
+ Independent bool `json:"-"`
+}
+
+// NoRights is the default Rights{}.
+func NoRights() Rights { return Rights{} }
+
+// NoRestrictions should be used when un-restricting or
+// un-promoting user.
+//
+// member.Rights = tele.NoRestrictions()
+// b.Restrict(chat, member)
+func NoRestrictions() Rights {
+ return Rights{
+ CanBeEdited: true,
+ CanChangeInfo: false,
+ CanPostMessages: false,
+ CanEditMessages: false,
+ CanDeleteMessages: false,
+ CanInviteUsers: false,
+ CanRestrictMembers: false,
+ CanPinMessages: false,
+ CanPromoteMembers: false,
+ CanSendMessages: true,
+ CanSendPolls: true,
+ CanSendOther: true,
+ CanAddPreviews: true,
+ CanManageVideoChats: false,
+ CanManageChat: false,
+ CanManageTopics: false,
+ CanSendAudios: true,
+ CanSendDocuments: true,
+ CanSendPhotos: true,
+ CanSendVideos: true,
+ CanSendVideoNotes: true,
+ CanSendVoiceNotes: true,
+ }
+}
+
+// AdminRights could be used to promote user to admin.
+func AdminRights() Rights {
+ return Rights{
+ CanBeEdited: true,
+ CanChangeInfo: true,
+ CanPostMessages: true,
+ CanEditMessages: true,
+ CanDeleteMessages: true,
+ CanInviteUsers: true,
+ CanRestrictMembers: true,
+ CanPinMessages: true,
+ CanPromoteMembers: true,
+ CanSendMessages: true,
+ CanSendPolls: true,
+ CanSendOther: true,
+ CanAddPreviews: true,
+ CanManageVideoChats: true,
+ CanManageChat: true,
+ CanManageTopics: true,
+ CanSendAudios: true,
+ CanSendDocuments: true,
+ CanSendPhotos: true,
+ CanSendVideos: true,
+ CanSendVideoNotes: true,
+ CanSendVoiceNotes: true,
+ }
+}
+
+// Forever is a ExpireUnixtime of "forever" banning.
+func Forever() int64 {
+ return time.Now().Add(367 * 24 * time.Hour).Unix()
+}
+
+// Ban will ban user from chat until `member.RestrictedUntil`.
+func (b *Bot) Ban(ctx context.Context, chat *Chat, member *ChatMember, revokeMessages ...bool) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "user_id": member.User.Recipient(),
+ "until_date": strconv.FormatInt(member.RestrictedUntil, 10),
+ }
+ if len(revokeMessages) > 0 {
+ params["revoke_messages"] = strconv.FormatBool(revokeMessages[0])
+ }
+
+ _, err := b.Raw(ctx, "kickChatMember", params)
+ return err
+}
+
+// Unban will unban user from chat, who would have thought eh?
+// forBanned does nothing if the user is not banned.
+func (b *Bot) Unban(ctx context.Context, chat *Chat, user *User, forBanned ...bool) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "user_id": user.Recipient(),
+ }
+
+ if len(forBanned) > 0 {
+ params["only_if_banned"] = strconv.FormatBool(forBanned[0])
+ }
+
+ _, err := b.Raw(ctx, "unbanChatMember", params)
+ return err
+}
+
+// Restrict lets you restrict a subset of member's rights until
+// member.RestrictedUntil, such as:
+//
+// - can send messages
+// - can send media
+// - can send other
+// - can add web page previews
+func (b *Bot) Restrict(ctx context.Context, chat *Chat, member *ChatMember) error {
+ perms, until := member.Rights, member.RestrictedUntil
+
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "user_id": member.User.Recipient(),
+ "until_date": strconv.FormatInt(until, 10),
+ "permissions": perms,
+ }
+ if perms.Independent {
+ params["use_independent_chat_permissions"] = true
+ }
+
+ _, err := b.Raw(ctx, "restrictChatMember", params)
+ return err
+}
+
+// Promote lets you update member's admin rights, such as:
+//
+// - can change info
+// - can post messages
+// - can edit messages
+// - can delete messages
+// - can invite users
+// - can restrict members
+// - can pin messages
+// - can promote members
+func (b *Bot) Promote(ctx context.Context, chat *Chat, member *ChatMember) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "user_id": member.User.Recipient(),
+ "is_anonymous": member.Anonymous,
+ }
+ embedRights(params, member.Rights)
+
+ _, err := b.Raw(ctx, "promoteChatMember", params)
+ return err
+}
+
+// AdminsOf returns a member list of chat admins.
+//
+// On success, returns an Array of ChatMember objects that
+// contains information about all chat administrators except other bots.
+//
+// If the chat is a group or a supergroup and
+// no administrators were appointed, only the creator will be returned.
+func (b *Bot) AdminsOf(ctx context.Context, chat *Chat) ([]ChatMember, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "getChatAdministrators", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []ChatMember
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// Len returns the number of members in a chat.
+func (b *Bot) Len(ctx context.Context, chat *Chat) (int, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "getChatMembersCount", params)
+ if err != nil {
+ return 0, err
+ }
+
+ var resp struct {
+ Result int
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return 0, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// SetAdminTitle sets a custom title for an administrator.
+// A title should be 0-16 characters length, emoji are not allowed.
+func (b *Bot) SetAdminTitle(ctx context.Context, chat *Chat, user *User, title string) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "user_id": user.Recipient(),
+ "custom_title": title,
+ }
+
+ _, err := b.Raw(ctx, "setChatAdministratorCustomTitle", params)
+ return err
+}
+
+// BanSenderChat will use this method to ban a channel chat in a supergroup or a channel.
+// Until the chat is unbanned, the owner of the banned chat won't be able
+// to send messages on behalf of any of their channels.
+func (b *Bot) BanSenderChat(ctx context.Context, chat *Chat, sender Recipient) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "sender_chat_id": sender.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "banChatSenderChat", params)
+ return err
+}
+
+// UnbanSenderChat will use this method to unban a previously banned channel chat in a supergroup or channel.
+// The bot must be an administrator for this to work and must have the appropriate administrator rights.
+func (b *Bot) UnbanSenderChat(ctx context.Context, chat *Chat, sender Recipient) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "sender_chat_id": sender.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "unbanChatSenderChat", params)
+ return err
+}
+
+// DefaultRights returns the current default administrator rights of the bot.
+func (b *Bot) DefaultRights(ctx context.Context, forChannels bool) (*Rights, error) {
+ params := map[string]bool{
+ "for_channels": forChannels,
+ }
+
+ data, err := b.Raw(ctx, "getMyDefaultAdministratorRights", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *Rights
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// SetDefaultRights changes the default administrator rights requested by the bot
+// when it's added as an administrator to groups or channels.
+func (b *Bot) SetDefaultRights(ctx context.Context, rights Rights, forChannels bool) error {
+ params := map[string]interface{}{
+ "rights": rights,
+ "for_channels": forChannels,
+ }
+
+ _, err := b.Raw(ctx, "setMyDefaultAdministratorRights", params)
+ return err
+}
+
+func embedRights(p map[string]interface{}, rights Rights) {
+ data, _ := json.Marshal(rights)
+ _ = json.Unmarshal(data, &p)
+}
diff --git a/internal/telebot/admin_test.go b/internal/telebot/admin_test.go
new file mode 100644
index 0000000..886e794
--- /dev/null
+++ b/internal/telebot/admin_test.go
@@ -0,0 +1,45 @@
+package telebot
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEmbedRights(t *testing.T) {
+ rights := NoRestrictions()
+ params := map[string]interface{}{
+ "chat_id": "1",
+ "user_id": "2",
+ }
+ embedRights(params, rights)
+
+ expected := map[string]interface{}{
+ "is_anonymous": false,
+ "chat_id": "1",
+ "user_id": "2",
+ "can_be_edited": true,
+ "can_send_messages": true,
+ "can_send_polls": true,
+ "can_send_other_messages": true,
+ "can_add_web_page_previews": true,
+ "can_change_info": false,
+ "can_post_messages": false,
+ "can_edit_messages": false,
+ "can_delete_messages": false,
+ "can_invite_users": false,
+ "can_restrict_members": false,
+ "can_pin_messages": false,
+ "can_promote_members": false,
+ "can_manage_video_chats": false,
+ "can_manage_chat": false,
+ "can_manage_topics": false,
+ "can_send_audios": true,
+ "can_send_documents": true,
+ "can_send_photos": true,
+ "can_send_videos": true,
+ "can_send_video_notes": true,
+ "can_send_voice_notes": true,
+ }
+ assert.Equal(t, expected, params)
+}
diff --git a/internal/telebot/api.go b/internal/telebot/api.go
new file mode 100644
index 0000000..618c5a3
--- /dev/null
+++ b/internal/telebot/api.go
@@ -0,0 +1,334 @@
+package telebot
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Raw lets you call any method of Bot API manually.
+// It also handles API errors, so you only need to unwrap
+// result field from json data.
+func (b *Bot) Raw(ctx context.Context, method string, payload interface{}) ([]byte, error) {
+ url := b.URL + "/bot" + b.Token + "/" + method
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(payload); err != nil {
+ return nil, err
+ }
+
+ // Cancel the request immediately without waiting for the timeout when bot is about to stop.
+ // This may become important if doing long polling with long timeout.
+ exit := make(chan struct{})
+ defer close(exit)
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ go func() {
+ select {
+ case <-b.stopClient:
+ cancel()
+ case <-exit:
+ }
+ }()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := b.client.Do(req)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+ resp.Close = true
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+
+ if b.verbose {
+ verbose(method, payload, data)
+ }
+
+ // returning data as well
+ return data, extractOk(data)
+}
+
+func (b *Bot) sendFiles(ctx context.Context, method string, files map[string]File, params map[string]string) ([]byte, error) {
+ rawFiles := make(map[string]interface{})
+ for name, f := range files {
+ switch {
+ case f.InCloud():
+ params[name] = f.FileID
+ case f.FileURL != "":
+ params[name] = f.FileURL
+ case f.OnDisk():
+ rawFiles[name] = f.FileLocal
+ case f.FileReader != nil:
+ rawFiles[name] = f.FileReader
+ default:
+ return nil, fmt.Errorf("telebot: file for field %s doesn't exist", name)
+ }
+ }
+
+ if len(rawFiles) == 0 {
+ return b.Raw(ctx, method, params)
+ }
+
+ pipeReader, pipeWriter := io.Pipe()
+ writer := multipart.NewWriter(pipeWriter)
+
+ go func() {
+ defer pipeWriter.Close()
+
+ for field, file := range rawFiles {
+ if err := addFileToWriter(writer, files[field].fileName, field, file); err != nil {
+ pipeWriter.CloseWithError(err)
+ return
+ }
+ }
+ for field, value := range params {
+ if err := writer.WriteField(field, value); err != nil {
+ pipeWriter.CloseWithError(err)
+ return
+ }
+ }
+ if err := writer.Close(); err != nil {
+ pipeWriter.CloseWithError(err)
+ return
+ }
+ }()
+
+ url := b.URL + "/bot" + b.Token + "/" + method
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pipeReader)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := b.client.Do(req)
+ if err != nil {
+ err = wrapError(err)
+ pipeReader.CloseWithError(err)
+ return nil, err
+ }
+ resp.Close = true
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusInternalServerError {
+ return nil, ErrInternal
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+
+ return data, extractOk(data)
+}
+
+func addFileToWriter(writer *multipart.Writer, filename, field string, file interface{}) error {
+ var reader io.Reader
+ if r, ok := file.(io.Reader); ok {
+ reader = r
+ } else if path, ok := file.(string); ok {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ reader = f
+ } else {
+ return fmt.Errorf("telebot: file for field %v should be io.ReadCloser or string", field)
+ }
+
+ part, err := writer.CreateFormFile(field, filename)
+ if err != nil {
+ return err
+ }
+
+ _, err = io.Copy(part, reader)
+ return err
+}
+
+func (b *Bot) sendText(ctx context.Context, to Recipient, text string, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "text": text,
+ }
+ b.embedSendOptions(params, opt)
+
+ data, err := b.Raw(ctx, "sendMessage", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+func (b *Bot) sendMedia(ctx context.Context, media Media, params map[string]string, files map[string]File) (*Message, error) {
+ kind := media.MediaType()
+ what := "send" + strings.Title(kind)
+
+ if kind == "videoNote" {
+ kind = "video_note"
+ }
+
+ sendFiles := map[string]File{kind: *media.MediaFile()}
+ for k, v := range files {
+ sendFiles[k] = v
+ }
+
+ data, err := b.sendFiles(ctx, what, sendFiles, params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+func (b *Bot) getMe(ctx context.Context) (*User, error) {
+ data, err := b.Raw(ctx, "getMe", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *User
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+func (b *Bot) getUpdates(ctx context.Context, offset int, limit int, timeout time.Duration, allowed []string) ([]Update, error) {
+ params := map[string]string{
+ "offset": strconv.Itoa(offset),
+ "timeout": strconv.Itoa(int(timeout / time.Second)),
+ }
+
+ data, _ := json.Marshal(allowed)
+ params["allowed_updates"] = string(data)
+
+ if limit != 0 {
+ params["limit"] = strconv.Itoa(limit)
+ }
+
+ data, err := b.Raw(ctx, "getUpdates", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []Update
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// extractOk checks given result for error. If result is ok returns nil.
+// In other cases it extracts API error. If error is not presented
+// in errors.go, it will be prefixed with `unknown` keyword.
+func extractOk(data []byte) error {
+ var e struct {
+ Ok bool `json:"ok"`
+ Code int `json:"error_code"`
+ Description string `json:"description"`
+ Parameters map[string]interface{} `json:"parameters"`
+ }
+ if json.NewDecoder(bytes.NewReader(data)).Decode(&e) != nil {
+ return nil // FIXME
+ }
+ if e.Ok {
+ return nil
+ }
+
+ err := Err(e.Description)
+ switch err {
+ case nil:
+ case ErrGroupMigrated:
+ migratedTo, ok := e.Parameters["migrate_to_chat_id"]
+ if !ok {
+ return NewError(e.Code, e.Description)
+ }
+
+ return GroupError{
+ err: err.(*Error),
+ MigratedTo: int64(migratedTo.(float64)),
+ }
+ default:
+ return err
+ }
+
+ switch e.Code {
+ case http.StatusTooManyRequests:
+ retryAfter, ok := e.Parameters["retry_after"]
+ if !ok {
+ return NewError(e.Code, e.Description)
+ }
+
+ err = FloodError{
+ err: NewError(e.Code, e.Description),
+ RetryAfter: int(retryAfter.(float64)),
+ }
+ default:
+ err = fmt.Errorf("telegram: %s (%d)", e.Description, e.Code)
+ }
+
+ return err
+}
+
+// extractMessage extracts common Message result from given data.
+// Should be called after extractOk or b.Raw() to handle possible errors.
+func extractMessage(data []byte) (*Message, error) {
+ var resp struct {
+ Result *Message
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ var resp struct {
+ Result bool
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ if resp.Result {
+ return nil, ErrTrueResult
+ }
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+func verbose(method string, payload interface{}, data []byte) {
+ body, _ := json.Marshal(payload)
+ body = bytes.ReplaceAll(body, []byte(`\"`), []byte(`"`))
+ body = bytes.ReplaceAll(body, []byte(`"{`), []byte(`{`))
+ body = bytes.ReplaceAll(body, []byte(`}"`), []byte(`}`))
+
+ indent := func(b []byte) string {
+ var buf bytes.Buffer
+ json.Indent(&buf, b, "", " ")
+ return buf.String()
+ }
+
+ log.Printf(
+ "[verbose] telebot: sent request\nMethod: %v\nParams: %v\nResponse: %v",
+ method, indent(body), indent(data),
+ )
+}
diff --git a/internal/telebot/api_test.go b/internal/telebot/api_test.go
new file mode 100644
index 0000000..f49ff6e
--- /dev/null
+++ b/internal/telebot/api_test.go
@@ -0,0 +1,123 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// testPayload implements json.Marshaler
+// to test json encoding error behaviour.
+type testPayload struct{}
+
+func (testPayload) MarshalJSON() ([]byte, error) {
+ return nil, errors.New("test error")
+}
+
+func testRawServer(w http.ResponseWriter, r *http.Request) {
+ switch {
+ // causes EOF error on ioutil.ReadAll
+ case strings.HasSuffix(r.URL.Path, "/testReadError"):
+ // tells the body is 1 byte length but actually it's 0
+ w.Header().Set("Content-Length", "1")
+
+ // returns unknown telegram error
+ case strings.HasSuffix(r.URL.Path, "/testUnknownError"):
+ data, _ := json.Marshal(struct {
+ Ok bool `json:"ok"`
+ Code int `json:"error_code"`
+ Description string `json:"description"`
+ }{
+ Ok: false,
+ Code: 400,
+ Description: "unknown error",
+ })
+
+ w.WriteHeader(400)
+ w.Write(data)
+ }
+}
+
+func TestRaw(t *testing.T) {
+ if token == "" {
+ t.Skip("TELEBOT_SECRET is required")
+ }
+
+ ctx := context.Background()
+
+ b, err := newTestBot()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = b.Raw(ctx, "BAD METHOD", nil)
+ assert.EqualError(t, err, ErrNotFound.Error())
+
+ _, err = b.Raw(ctx, "", &testPayload{})
+ assert.Error(t, err)
+
+ srv := httptest.NewServer(http.HandlerFunc(testRawServer))
+ defer srv.Close()
+
+ b.URL = srv.URL
+ b.client = srv.Client()
+
+ _, err = b.Raw(ctx, "testReadError", nil)
+ assert.EqualError(t, err, "telebot: "+io.ErrUnexpectedEOF.Error())
+
+ _, err = b.Raw(ctx, "testUnknownError", nil)
+ assert.EqualError(t, err, "telegram: unknown error (400)")
+}
+
+func TestExtractOk(t *testing.T) {
+ data := []byte(`{"ok": true, "result": {}}`)
+ require.NoError(t, extractOk(data))
+
+ data = []byte(`{
+ "ok": false,
+ "error_code": 400,
+ "description": "Bad Request: reply message not found"
+ }`)
+ assert.EqualError(t, extractOk(data), ErrNotFoundToReply.Error())
+
+ data = []byte(`{
+ "ok": false,
+ "error_code": 429,
+ "description": "Too Many Requests: retry after 8",
+ "parameters": {"retry_after": 8}
+ }`)
+ assert.Equal(t, FloodError{
+ err: NewError(429, "Too Many Requests: retry after 8"),
+ RetryAfter: 8,
+ }, extractOk(data))
+
+ data = []byte(`{
+ "ok": false,
+ "error_code": 400,
+ "description": "Bad Request: group chat was upgraded to a supergroup chat",
+ "parameters": {"migrate_to_chat_id": -100123456789}
+ }`)
+ assert.Equal(t, GroupError{
+ err: ErrGroupMigrated,
+ MigratedTo: -100123456789,
+ }, extractOk(data))
+}
+
+func TestExtractMessage(t *testing.T) {
+ data := []byte(`{"ok":true,"result":true}`)
+ _, err := extractMessage(data)
+ assert.Equal(t, ErrTrueResult, err)
+
+ data = []byte(`{"ok":true,"result":{"foo":"bar"}}`)
+ _, err = extractMessage(data)
+ require.NoError(t, err)
+}
diff --git a/internal/telebot/bot.go b/internal/telebot/bot.go
new file mode 100644
index 0000000..2881465
--- /dev/null
+++ b/internal/telebot/bot.go
@@ -0,0 +1,1154 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// NewBot does try to build a Bot with token `token`, which
+// is a secret API key assigned to particular bot.
+func NewBot(pref Settings) (*Bot, error) {
+ if pref.Updates == 0 {
+ pref.Updates = 100
+ }
+
+ client := pref.Client
+ if client == nil {
+ client = &http.Client{Timeout: time.Minute}
+ }
+
+ if pref.URL == "" {
+ pref.URL = DefaultApiURL
+ }
+ if pref.Poller == nil {
+ pref.Poller = &LongPoller{}
+ }
+ if pref.OnError == nil {
+ pref.OnError = defaultOnError
+ }
+
+ bot := &Bot{
+ Token: pref.Token,
+ URL: pref.URL,
+ Poller: pref.Poller,
+ onError: pref.OnError,
+
+ Updates: make(chan Update, pref.Updates),
+ handlers: make(map[string]HandlerFunc),
+ stop: make(chan chan struct{}),
+
+ synchronous: pref.Synchronous,
+ verbose: pref.Verbose,
+ parseMode: pref.ParseMode,
+ client: client,
+ }
+
+ if pref.Offline {
+ bot.Me = &User{}
+ } else {
+ user, err := bot.getMe(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ bot.Me = user
+ }
+
+ bot.group = bot.Group()
+ return bot, nil
+}
+
+// Bot represents a separate Telegram bot instance.
+type Bot struct {
+ Me *User
+ Token string
+ URL string
+ Updates chan Update
+ Poller Poller
+ onError func(error, Context)
+
+ group *Group
+ handlers map[string]HandlerFunc
+ synchronous bool
+ verbose bool
+ parseMode ParseMode
+ stop chan chan struct{}
+ client *http.Client
+ stopClient chan struct{}
+}
+
+// Settings represents a utility struct for passing certain
+// properties of a bot around and is required to make bots.
+type Settings struct {
+ URL string
+ Token string
+
+ // Updates channel capacity, defaulted to 100.
+ Updates int
+
+ // Poller is the provider of Updates.
+ Poller Poller
+
+ // Synchronous prevents handlers from running in parallel.
+ // It makes ProcessUpdate return after the handler is finished.
+ Synchronous bool
+
+ // Verbose forces bot to log all upcoming requests.
+ // Use for debugging purposes only.
+ Verbose bool
+
+ // ParseMode used to set default parse mode of all sent messages.
+ // It attaches to every send, edit or whatever method. You also
+ // will be able to override the default mode by passing a new one.
+ ParseMode ParseMode
+
+ // OnError is a callback function that will get called on errors
+ // resulted from the handler. It is used as post-middleware function.
+ // Notice that context can be nil.
+ OnError func(error, Context)
+
+ // HTTP Client used to make requests to telegram api
+ Client *http.Client
+
+ // Offline allows to create a bot without network for testing purposes.
+ Offline bool
+}
+
+var defaultOnError = func(err error, c Context) {
+ if c != nil {
+ log.Println(c.Update().ID, err)
+ } else {
+ log.Println(err)
+ }
+}
+
+func (b *Bot) OnError(err error, c Context) {
+ b.onError(err, c)
+}
+
+func (b *Bot) debug(err error) {
+ if b.verbose {
+ b.OnError(err, nil)
+ }
+}
+
+// Group returns a new group.
+func (b *Bot) Group() *Group {
+ return &Group{b: b}
+}
+
+// Use adds middleware to the global bot chain.
+func (b *Bot) Use(middleware ...MiddlewareFunc) {
+ b.group.Use(middleware...)
+}
+
+var (
+ cmdRx = regexp.MustCompile(`^(/\w+)(@(\w+))?(\s|$)(.+)?`)
+ cbackRx = regexp.MustCompile(`^\f([-\w]+)(\|(.+))?$`)
+)
+
+// Handle lets you set the handler for some command name or
+// one of the supported endpoints. It also applies middleware
+// if such passed to the function.
+//
+// Example:
+//
+// b.Handle("/start", func (c tele.Context) error {
+// return c.Reply("Hello!")
+// })
+//
+// b.Handle(&inlineButton, func (c tele.Context) error {
+// return c.Respond(&tele.CallbackResponse{Text: "Hello!"})
+// })
+//
+// Middleware usage:
+//
+// b.Handle("/ban", onBan, middleware.Whitelist(ids...))
+func (b *Bot) Handle(endpoint interface{}, h HandlerFunc, m ...MiddlewareFunc) {
+ if len(b.group.middleware) > 0 {
+ m = appendMiddleware(b.group.middleware, m)
+ }
+
+ handler := func(c Context) error {
+ return applyMiddleware(h, m...)(c)
+ }
+
+ switch end := endpoint.(type) {
+ case string:
+ b.handlers[end] = handler
+ case CallbackEndpoint:
+ b.handlers[end.CallbackUnique()] = handler
+ default:
+ panic("telebot: unsupported endpoint")
+ }
+}
+
+// Start brings bot into motion by consuming incoming
+// updates (see Bot.Updates channel).
+func (b *Bot) Start() {
+ if b.Poller == nil {
+ panic("telebot: can't start without a poller")
+ }
+
+ // do nothing if called twice
+ if b.stopClient != nil {
+ return
+ }
+ b.stopClient = make(chan struct{})
+
+ stop := make(chan struct{})
+ stopConfirm := make(chan struct{})
+
+ go func() {
+ b.Poller.Poll(context.Background(), b, b.Updates, stop)
+ close(stopConfirm)
+ }()
+
+ for {
+ select {
+ // handle incoming updates
+ case upd := <-b.Updates:
+ b.ProcessUpdate(upd)
+ // call to stop polling
+ case confirm := <-b.stop:
+ close(stop)
+ <-stopConfirm
+ close(confirm)
+ b.stopClient = nil
+ return
+ }
+ }
+}
+
+// Stop gracefully shuts the poller down.
+func (b *Bot) Stop() {
+ if b.stopClient != nil {
+ close(b.stopClient)
+ }
+ confirm := make(chan struct{})
+ b.stop <- confirm
+ <-confirm
+}
+
+// NewMarkup simply returns newly created markup instance.
+func (b *Bot) NewMarkup() *ReplyMarkup {
+ return &ReplyMarkup{}
+}
+
+// NewContext returns a new native context object,
+// field by the passed update.
+func (b *Bot) NewContext(u Update) Context {
+ return &nativeContext{
+ b: b,
+ u: u,
+ }
+}
+
+// Send accepts 2+ arguments, starting with destination chat, followed by
+// some Sendable (or string!) and optional send options.
+//
+// NOTE:
+//
+// Since most arguments are of type interface{}, but have pointer
+// method receivers, make sure to pass them by-pointer, NOT by-value.
+//
+// What is a send option exactly? It can be one of the following types:
+//
+// - *SendOptions (the actual object accepted by Telegram API)
+// - *ReplyMarkup (a component of SendOptions)
+// - Option (a shortcut flag for popular options)
+// - ParseMode (HTML, Markdown, etc)
+func (b *Bot) Send(ctx context.Context, to Recipient, what interface{}, opts ...interface{}) (*Message, error) {
+ if to == nil {
+ return nil, ErrBadRecipient
+ }
+
+ sendOpts := extractOptions(opts)
+
+ switch object := what.(type) {
+ case string:
+ return b.sendText(ctx, to, object, sendOpts)
+ case Sendable:
+ return object.Send(ctx, b, to, sendOpts)
+ default:
+ return nil, ErrUnsupportedWhat
+ }
+}
+
+// SendAlbum sends multiple instances of media as a single message.
+// To include the caption, make sure the first Inputtable of an album has it.
+// From all existing options, it only supports tele.Silent.
+func (b *Bot) SendAlbum(ctx context.Context, to Recipient, a Album, opts ...interface{}) ([]Message, error) {
+ if to == nil {
+ return nil, ErrBadRecipient
+ }
+
+ sendOpts := extractOptions(opts)
+ media := make([]string, len(a))
+ files := make(map[string]File)
+
+ for i, x := range a {
+ var (
+ repr string
+ data []byte
+ file = x.MediaFile()
+ )
+
+ switch {
+ case file.InCloud():
+ repr = file.FileID
+ case file.FileURL != "":
+ repr = file.FileURL
+ case file.OnDisk() || file.FileReader != nil:
+ repr = "attach://" + strconv.Itoa(i)
+ files[strconv.Itoa(i)] = *file
+ default:
+ return nil, fmt.Errorf("telebot: album entry #%d does not exist", i)
+ }
+
+ im := x.InputMedia()
+ im.Media = repr
+
+ if len(sendOpts.Entities) > 0 {
+ im.Entities = sendOpts.Entities
+ } else {
+ im.ParseMode = sendOpts.ParseMode
+ }
+
+ data, _ = json.Marshal(im)
+ media[i] = string(data)
+ }
+
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "media": "[" + strings.Join(media, ",") + "]",
+ }
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.sendFiles(ctx, "sendMediaGroup", files, params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []Message
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+
+ for attachName := range files {
+ i, _ := strconv.Atoi(attachName)
+ r := resp.Result[i]
+
+ var newID string
+ switch {
+ case r.Photo != nil:
+ newID = r.Photo.FileID
+ case r.Video != nil:
+ newID = r.Video.FileID
+ case r.Audio != nil:
+ newID = r.Audio.FileID
+ case r.Document != nil:
+ newID = r.Document.FileID
+ }
+
+ a[i].MediaFile().FileID = newID
+ }
+
+ return resp.Result, nil
+}
+
+// Reply behaves just like Send() with an exception of "reply-to" indicator.
+// This function will panic upon nil Message.
+func (b *Bot) Reply(ctx context.Context, to *Message, what interface{}, opts ...interface{}) (*Message, error) {
+ sendOpts := extractOptions(opts)
+ if sendOpts == nil {
+ sendOpts = &SendOptions{}
+ }
+
+ sendOpts.ReplyTo = to
+ return b.Send(ctx, to.Chat, what, sendOpts)
+}
+
+// Forward behaves just like Send() but of all options it only supports Silent (see Bots API).
+// This function will panic upon nil Editable.
+func (b *Bot) Forward(ctx context.Context, to Recipient, msg Editable, opts ...interface{}) (*Message, error) {
+ if to == nil {
+ return nil, ErrBadRecipient
+ }
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "from_chat_id": strconv.FormatInt(chatID, 10),
+ "message_id": msgID,
+ }
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.Raw(ctx, "forwardMessage", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Copy behaves just like Forward() but the copied message doesn't have a link to the original message (see Bots API).
+//
+// This function will panic upon nil Editable.
+func (b *Bot) Copy(ctx context.Context, to Recipient, msg Editable, options ...interface{}) (*Message, error) {
+ if to == nil {
+ return nil, ErrBadRecipient
+ }
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "from_chat_id": strconv.FormatInt(chatID, 10),
+ "message_id": msgID,
+ }
+
+ sendOpts := extractOptions(options)
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.Raw(ctx, "copyMessage", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Edit is magic, it lets you change already sent message.
+// This function will panic upon nil Editable.
+//
+// If edited message is sent by the bot, returns it,
+// otherwise returns nil and ErrTrueResult.
+//
+// Use cases:
+//
+// b.Edit(m, m.Text, newMarkup)
+// b.Edit(m, "new text", tele.ModeHTML)
+// b.Edit(m, &tele.ReplyMarkup{...})
+// b.Edit(m, &tele.Photo{File: ...})
+// b.Edit(m, tele.Location{42.1337, 69.4242})
+// b.Edit(c, "edit inline message from the callback")
+// b.Edit(r, "edit message from chosen inline result")
+func (b *Bot) Edit(ctx context.Context, msg Editable, what interface{}, opts ...interface{}) (*Message, error) {
+ var (
+ method string
+ params = make(map[string]string)
+ )
+
+ switch v := what.(type) {
+ case *ReplyMarkup:
+ return b.EditReplyMarkup(ctx, msg, v)
+ case Inputtable:
+ return b.EditMedia(ctx, msg, v, opts...)
+ case string:
+ method = "editMessageText"
+ params["text"] = v
+ case Location:
+ method = "editMessageLiveLocation"
+ params["latitude"] = fmt.Sprintf("%f", v.Lat)
+ params["longitude"] = fmt.Sprintf("%f", v.Lng)
+
+ if v.HorizontalAccuracy != nil {
+ params["horizontal_accuracy"] = fmt.Sprintf("%f", *v.HorizontalAccuracy)
+ }
+ if v.Heading != 0 {
+ params["heading"] = strconv.Itoa(v.Heading)
+ }
+ if v.AlertRadius != 0 {
+ params["proximity_alert_radius"] = strconv.Itoa(v.AlertRadius)
+ }
+ default:
+ return nil, ErrUnsupportedWhat
+ }
+
+ msgID, chatID := msg.MessageSig()
+
+ if chatID == 0 { // if inline message
+ params["inline_message_id"] = msgID
+ } else {
+ params["chat_id"] = strconv.FormatInt(chatID, 10)
+ params["message_id"] = msgID
+ }
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.Raw(ctx, method, params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// EditReplyMarkup edits reply markup of already sent message.
+// This function will panic upon nil Editable.
+// Pass nil or empty ReplyMarkup to delete it from the message.
+//
+// If edited message is sent by the bot, returns it,
+// otherwise returns nil and ErrTrueResult.
+func (b *Bot) EditReplyMarkup(ctx context.Context, msg Editable, markup *ReplyMarkup) (*Message, error) {
+ msgID, chatID := msg.MessageSig()
+ params := make(map[string]string)
+
+ if chatID == 0 { // if inline message
+ params["inline_message_id"] = msgID
+ } else {
+ params["chat_id"] = strconv.FormatInt(chatID, 10)
+ params["message_id"] = msgID
+ }
+
+ if markup == nil {
+ // will delete reply markup
+ markup = &ReplyMarkup{}
+ }
+
+ processButtons(markup.InlineKeyboard)
+ data, _ := json.Marshal(markup)
+ params["reply_markup"] = string(data)
+
+ data, err := b.Raw(ctx, "editMessageReplyMarkup", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// EditCaption edits already sent photo caption with known recipient and message id.
+// This function will panic upon nil Editable.
+//
+// If edited message is sent by the bot, returns it,
+// otherwise returns nil and ErrTrueResult.
+func (b *Bot) EditCaption(ctx context.Context, msg Editable, caption string, opts ...interface{}) (*Message, error) {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "caption": caption,
+ }
+
+ if chatID == 0 { // if inline message
+ params["inline_message_id"] = msgID
+ } else {
+ params["chat_id"] = strconv.FormatInt(chatID, 10)
+ params["message_id"] = msgID
+ }
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.Raw(ctx, "editMessageCaption", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// EditMedia edits already sent media with known recipient and message id.
+// This function will panic upon nil Editable.
+//
+// If edited message is sent by the bot, returns it,
+// otherwise returns nil and ErrTrueResult.
+//
+// Use cases:
+//
+// b.EditMedia(m, &tele.Photo{File: tele.FromDisk("chicken.jpg")})
+// b.EditMedia(m, &tele.Video{File: tele.FromURL("http://video.mp4")})
+func (b *Bot) EditMedia(ctx context.Context, msg Editable, media Inputtable, opts ...interface{}) (*Message, error) {
+ var (
+ repr string
+ file = media.MediaFile()
+ files = make(map[string]File)
+
+ thumb *Photo
+ thumbName = "thumb"
+ )
+
+ switch {
+ case file.InCloud():
+ repr = file.FileID
+ case file.FileURL != "":
+ repr = file.FileURL
+ case file.OnDisk() || file.FileReader != nil:
+ s := file.FileLocal
+ if file.FileReader != nil {
+ s = "0"
+ } else if s == thumbName {
+ thumbName = "thumb2"
+ }
+
+ repr = "attach://" + s
+ files[s] = *file
+ default:
+ return nil, fmt.Errorf("telebot: cannot edit media, it does not exist")
+ }
+
+ switch m := media.(type) {
+ case *Video:
+ thumb = m.Thumbnail
+ case *Audio:
+ thumb = m.Thumbnail
+ case *Document:
+ thumb = m.Thumbnail
+ case *Animation:
+ thumb = m.Thumbnail
+ }
+
+ msgID, chatID := msg.MessageSig()
+ params := make(map[string]string)
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ im := media.InputMedia()
+ im.Media = repr
+
+ if len(sendOpts.Entities) > 0 {
+ im.Entities = sendOpts.Entities
+ } else {
+ im.ParseMode = sendOpts.ParseMode
+ }
+
+ if thumb != nil {
+ im.Thumbnail = "attach://" + thumbName
+ files[thumbName] = *thumb.MediaFile()
+ }
+
+ data, _ := json.Marshal(im)
+ params["media"] = string(data)
+
+ if chatID == 0 { // if inline message
+ params["inline_message_id"] = msgID
+ } else {
+ params["chat_id"] = strconv.FormatInt(chatID, 10)
+ params["message_id"] = msgID
+ }
+
+ data, err := b.sendFiles(ctx, "editMessageMedia", files, params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Delete removes the message, including service messages.
+// This function will panic upon nil Editable.
+//
+// - A message can only be deleted if it was sent less than 48 hours ago.
+// - A dice message in a private chat can only be deleted if it was sent more than 24 hours ago.
+// - Bots can delete outgoing messages in private chats, groups, and supergroups.
+// - Bots can delete incoming messages in private chats.
+// - Bots granted can_post_messages permissions can delete outgoing messages in channels.
+// - If the bot is an administrator of a group, it can delete any message there.
+// - If the bot has can_delete_messages permission in a supergroup or a
+// channel, it can delete any message there.
+func (b *Bot) Delete(ctx context.Context, msg Editable) error {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "chat_id": strconv.FormatInt(chatID, 10),
+ "message_id": msgID,
+ }
+
+ _, err := b.Raw(ctx, "deleteMessage", params)
+ return err
+}
+
+// Notify updates the chat action for recipient.
+//
+// Chat action is a status message that recipient would see where
+// you typically see "Harry is typing" status message. The only
+// difference is that bots' chat actions live only for 5 seconds
+// and die just once the client receives a message from the bot.
+//
+// Currently, Telegram supports only a narrow range of possible
+// actions, these are aligned as constants of this package.
+func (b *Bot) Notify(ctx context.Context, to Recipient, action ChatAction, threadID ...int) error {
+ if to == nil {
+ return ErrBadRecipient
+ }
+
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "action": string(action),
+ }
+
+ if len(threadID) > 0 {
+ params["message_thread_id"] = strconv.Itoa(threadID[0])
+ }
+
+ _, err := b.Raw(ctx, "sendChatAction", params)
+ return err
+}
+
+// Ship replies to the shipping query, if you sent an invoice
+// requesting an address and the parameter is_flexible was specified.
+//
+// Example:
+//
+// b.Ship(query) // OK
+// b.Ship(query, opts...) // OK with options
+// b.Ship(query, "Oops!") // Error message
+func (b *Bot) Ship(ctx context.Context, query *ShippingQuery, what ...interface{}) error {
+ params := map[string]string{
+ "shipping_query_id": query.ID,
+ }
+
+ if len(what) == 0 {
+ params["ok"] = "true"
+ } else if s, ok := what[0].(string); ok {
+ params["ok"] = "false"
+ params["error_message"] = s
+ } else {
+ var opts []ShippingOption
+ for _, v := range what {
+ opt, ok := v.(ShippingOption)
+ if !ok {
+ return ErrUnsupportedWhat
+ }
+ opts = append(opts, opt)
+ }
+
+ params["ok"] = "true"
+ data, _ := json.Marshal(opts)
+ params["shipping_options"] = string(data)
+ }
+
+ _, err := b.Raw(ctx, "answerShippingQuery", params)
+ return err
+}
+
+// Accept finalizes the deal.
+func (b *Bot) Accept(ctx context.Context, query *PreCheckoutQuery, errorMessage ...string) error {
+ params := map[string]string{
+ "pre_checkout_query_id": query.ID,
+ }
+
+ if len(errorMessage) == 0 {
+ params["ok"] = "true"
+ } else {
+ params["ok"] = "False"
+ params["error_message"] = errorMessage[0]
+ }
+
+ _, err := b.Raw(ctx, "answerPreCheckoutQuery", params)
+ return err
+}
+
+// Respond sends a response for a given callback query. A callback can
+// only be responded to once, subsequent attempts to respond to the same callback
+// will result in an error.
+//
+// Example:
+//
+// b.Respond(c)
+// b.Respond(c, response)
+func (b *Bot) Respond(ctx context.Context, c *Callback, resp ...*CallbackResponse) error {
+ var r *CallbackResponse
+ if resp == nil {
+ r = &CallbackResponse{}
+ } else {
+ r = resp[0]
+ }
+
+ r.CallbackID = c.ID
+ _, err := b.Raw(ctx, "answerCallbackQuery", r)
+ return err
+}
+
+// Answer sends a response for a given inline query. A query can only
+// be responded to once, subsequent attempts to respond to the same query
+// will result in an error.
+func (b *Bot) Answer(ctx context.Context, query *Query, resp *QueryResponse) error {
+ resp.QueryID = query.ID
+
+ for _, result := range resp.Results {
+ result.Process(b)
+ }
+
+ _, err := b.Raw(ctx, "answerInlineQuery", resp)
+ return err
+}
+
+// AnswerWebApp sends a response for a query from Web App and returns
+// information about an inline message sent by a Web App on behalf of a user
+func (b *Bot) AnswerWebApp(ctx context.Context, query *Query, r Result) (*WebAppMessage, error) {
+ r.Process(b)
+
+ params := map[string]interface{}{
+ "web_app_query_id": query.ID,
+ "result": r,
+ }
+
+ data, err := b.Raw(ctx, "answerWebAppQuery", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *WebAppMessage
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+
+ return resp.Result, err
+}
+
+// FileByID returns full file object including File.FilePath, allowing you to
+// download the file from the server.
+//
+// Usually, Telegram-provided File objects miss FilePath so you might need to
+// perform an additional request to fetch them.
+func (b *Bot) FileByID(ctx context.Context, fileID string) (File, error) {
+ params := map[string]string{
+ "file_id": fileID,
+ }
+
+ data, err := b.Raw(ctx, "getFile", params)
+ if err != nil {
+ return File{}, err
+ }
+
+ var resp struct {
+ Result File
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return File{}, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// Download saves the file from Telegram servers locally.
+// Maximum file size to download is 20 MB.
+func (b *Bot) Download(ctx context.Context, file *File, localFilename string) error {
+ reader, err := b.File(ctx, file)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+
+ out, err := os.Create(localFilename)
+ if err != nil {
+ return wrapError(err)
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, reader)
+ if err != nil {
+ return wrapError(err)
+ }
+
+ file.FileLocal = localFilename
+ return nil
+}
+
+// File gets a file from Telegram servers.
+func (b *Bot) File(ctx context.Context, file *File) (io.ReadCloser, error) {
+ f, err := b.FileByID(ctx, file.FileID)
+ if err != nil {
+ return nil, err
+ }
+
+ url := b.URL + "/file/bot" + b.Token + "/" + f.FilePath
+ file.FilePath = f.FilePath // saving file path
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+
+ resp, err := b.client.Do(req)
+ if err != nil {
+ return nil, wrapError(err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return nil, fmt.Errorf("telebot: expected status 200 but got %s", resp.Status)
+ }
+
+ return resp.Body, nil
+}
+
+// StopLiveLocation stops broadcasting live message location
+// before Location.LivePeriod expires.
+//
+// It supports ReplyMarkup.
+// This function will panic upon nil Editable.
+//
+// If the message is sent by the bot, returns it,
+// otherwise returns nil and ErrTrueResult.
+func (b *Bot) StopLiveLocation(ctx context.Context, msg Editable, opts ...interface{}) (*Message, error) {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "chat_id": strconv.FormatInt(chatID, 10),
+ "message_id": msgID,
+ }
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.Raw(ctx, "stopMessageLiveLocation", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// StopPoll stops a poll which was sent by the bot and returns
+// the stopped Poll object with the final results.
+//
+// It supports ReplyMarkup.
+// This function will panic upon nil Editable.
+func (b *Bot) StopPoll(ctx context.Context, msg Editable, opts ...interface{}) (*Poll, error) {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "chat_id": strconv.FormatInt(chatID, 10),
+ "message_id": msgID,
+ }
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ data, err := b.Raw(ctx, "stopPoll", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *Poll
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// Leave makes bot leave a group, supergroup or channel.
+func (b *Bot) Leave(ctx context.Context, chat *Chat) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "leaveChat", params)
+ return err
+}
+
+// Pin pins a message in a supergroup or a channel.
+//
+// It supports Silent option.
+// This function will panic upon nil Editable.
+func (b *Bot) Pin(ctx context.Context, msg Editable, opts ...interface{}) error {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "chat_id": strconv.FormatInt(chatID, 10),
+ "message_id": msgID,
+ }
+
+ sendOpts := extractOptions(opts)
+ b.embedSendOptions(params, sendOpts)
+
+ _, err := b.Raw(ctx, "pinChatMessage", params)
+ return err
+}
+
+// Unpin unpins a message in a supergroup or a channel.
+// It supports tb.Silent option.
+func (b *Bot) Unpin(ctx context.Context, chat *Chat, messageID ...int) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+ if len(messageID) > 0 {
+ params["message_id"] = strconv.Itoa(messageID[0])
+ }
+
+ _, err := b.Raw(ctx, "unpinChatMessage", params)
+ return err
+}
+
+// UnpinAll unpins all messages in a supergroup or a channel.
+// It supports tb.Silent option.
+func (b *Bot) UnpinAll(ctx context.Context, chat *Chat) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "unpinAllChatMessages", params)
+ return err
+}
+
+// ChatByID fetches chat info of its ID.
+//
+// Including current name of the user for one-on-one conversations,
+// current username of a user, group or channel, etc.
+func (b *Bot) ChatByID(ctx context.Context, id int64) (*Chat, error) {
+ return b.ChatByUsername(ctx, strconv.FormatInt(id, 10))
+}
+
+// ChatByUsername fetches chat info by its username.
+func (b *Bot) ChatByUsername(ctx context.Context, name string) (*Chat, error) {
+ params := map[string]string{
+ "chat_id": name,
+ }
+
+ data, err := b.Raw(ctx, "getChat", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *Chat
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ if resp.Result.Type == ChatChannel && resp.Result.Username == "" {
+ resp.Result.Type = ChatChannelPrivate
+ }
+ return resp.Result, nil
+}
+
+// ProfilePhotosOf returns list of profile pictures for a user.
+func (b *Bot) ProfilePhotosOf(ctx context.Context, user *User) ([]Photo, error) {
+ params := map[string]string{
+ "user_id": user.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "getUserProfilePhotos", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result struct {
+ Count int `json:"total_count"`
+ Photos []Photo `json:"photos"`
+ }
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result.Photos, nil
+}
+
+// ChatMemberOf returns information about a member of a chat.
+func (b *Bot) ChatMemberOf(ctx context.Context, chat, user Recipient) (*ChatMember, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "user_id": user.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "getChatMember", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *ChatMember
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// MenuButton returns the current value of the bot's menu button in a private chat,
+// or the default menu button.
+func (b *Bot) MenuButton(ctx context.Context, chat *User) (*MenuButton, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "getChatMenuButton", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *MenuButton
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// SetMenuButton changes the bot's menu button in a private chat,
+// or the default menu button.
+//
+// It accepts two kinds of menu button arguments:
+//
+// - MenuButtonType for simple menu buttons (default, commands)
+// - MenuButton complete structure for web_app menu button type
+func (b *Bot) SetMenuButton(ctx context.Context, chat *User, mb interface{}) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ }
+
+ switch v := mb.(type) {
+ case MenuButtonType:
+ params["menu_button"] = MenuButton{Type: v}
+ case *MenuButton:
+ params["menu_button"] = v
+ }
+
+ _, err := b.Raw(ctx, "setChatMenuButton", params)
+ return err
+}
+
+// Logout logs out from the cloud Bot API server before launching the bot locally.
+func (b *Bot) Logout(ctx context.Context) (bool, error) {
+ data, err := b.Raw(ctx, "logOut", nil)
+ if err != nil {
+ return false, err
+ }
+
+ var resp struct {
+ Result bool `json:"result"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return false, wrapError(err)
+ }
+
+ return resp.Result, nil
+}
+
+// Close closes the bot instance before moving it from one local server to another.
+func (b *Bot) Close(ctx context.Context) (bool, error) {
+ data, err := b.Raw(ctx, "close", nil)
+ if err != nil {
+ return false, err
+ }
+
+ var resp struct {
+ Result bool `json:"result"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return false, wrapError(err)
+ }
+
+ return resp.Result, nil
+}
diff --git a/internal/telebot/bot_test.go b/internal/telebot/bot_test.go
new file mode 100644
index 0000000..53e224c
--- /dev/null
+++ b/internal/telebot/bot_test.go
@@ -0,0 +1,762 @@
+package telebot
+
+import (
+ "context"
+ "errors"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ // required to test send and edit methods
+ token = os.Getenv("TELEBOT_SECRET")
+ chatID, _ = strconv.ParseInt(os.Getenv("CHAT_ID"), 10, 64)
+ userID, _ = strconv.ParseInt(os.Getenv("USER_ID"), 10, 64)
+
+ b, _ = newTestBot() // cached bot instance to avoid getMe method flooding
+ to = &Chat{ID: chatID} // to chat recipient for send and edit methods
+ user = &User{ID: userID} // to user recipient for some special cases
+)
+
+func defaultSettings() Settings {
+ return Settings{Token: token}
+}
+
+func newTestBot() (*Bot, error) {
+ return NewBot(defaultSettings())
+}
+
+func TestNewBot(t *testing.T) {
+ var pref Settings
+ _, err := NewBot(pref)
+ assert.Error(t, err)
+
+ pref.Token = "BAD TOKEN"
+ _, err = NewBot(pref)
+ assert.Error(t, err)
+
+ pref.URL = "BAD URL"
+ _, err = NewBot(pref)
+ assert.Error(t, err)
+
+ b, err := NewBot(Settings{Offline: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assert.NotNil(t, b.Me)
+ assert.Equal(t, DefaultApiURL, b.URL)
+ assert.Equal(t, 100, cap(b.Updates))
+ assert.NotZero(t, b.client.Timeout)
+
+ pref = defaultSettings()
+ client := &http.Client{Timeout: time.Minute}
+ pref.URL = "http://api.telegram.org" // not https
+ pref.Client = client
+ pref.Poller = &LongPoller{Timeout: time.Second}
+ pref.Updates = 50
+ pref.ParseMode = ModeHTML
+ pref.Offline = true
+
+ b, err = NewBot(pref)
+ require.NoError(t, err)
+ assert.Equal(t, client, b.client)
+ assert.Equal(t, pref.URL, b.URL)
+ assert.Equal(t, pref.Poller, b.Poller)
+ assert.Equal(t, 50, cap(b.Updates))
+ assert.Equal(t, ModeHTML, b.parseMode)
+}
+
+func TestBotHandle(t *testing.T) {
+ if b == nil {
+ t.Skip("Cached bot instance is bad (probably wrong or empty TELEBOT_SECRET)")
+ }
+
+ b.Handle("/start", func(c Context) error { return nil })
+ assert.Contains(t, b.handlers, "/start")
+
+ reply := ReplyButton{Text: "reply"}
+ b.Handle(&reply, func(c Context) error { return nil })
+
+ inline := InlineButton{Unique: "inline"}
+ b.Handle(&inline, func(c Context) error { return nil })
+
+ btnReply := (&ReplyMarkup{}).Text("btnReply")
+ b.Handle(&btnReply, func(c Context) error { return nil })
+
+ btnInline := (&ReplyMarkup{}).Data("", "btnInline")
+ b.Handle(&btnInline, func(c Context) error { return nil })
+
+ assert.Contains(t, b.handlers, btnReply.CallbackUnique())
+ assert.Contains(t, b.handlers, btnInline.CallbackUnique())
+ assert.Contains(t, b.handlers, reply.CallbackUnique())
+ assert.Contains(t, b.handlers, inline.CallbackUnique())
+}
+
+func TestBotStart(t *testing.T) {
+ if token == "" {
+ t.Skip("TELEBOT_SECRET is required")
+ }
+
+ pref := defaultSettings()
+ pref.Poller = &LongPoller{}
+
+ b, err := NewBot(pref)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // remove webhook to be sure that bot can poll
+ require.NoError(t, b.RemoveWebhook(context.Background()))
+
+ go b.Start()
+ b.Stop()
+
+ tp := newTestPoller()
+ go func() {
+ tp.updates <- Update{Message: &Message{Text: "/start"}}
+ }()
+
+ b, err = NewBot(pref)
+ require.NoError(t, err)
+ b.Poller = tp
+
+ var ok bool
+ b.Handle("/start", func(c Context) error {
+ assert.Equal(t, c.Text(), "/start")
+ tp.done <- struct{}{}
+ ok = true
+ return nil
+ })
+
+ go b.Start()
+ <-tp.done
+ b.Stop()
+
+ assert.True(t, ok)
+}
+
+func TestBotProcessUpdate(t *testing.T) {
+ b, err := NewBot(Settings{Synchronous: true, Offline: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ b.Handle(OnMedia, func(c Context) error {
+ assert.NotNil(t, c.Message().Photo)
+ return nil
+ })
+ b.ProcessUpdate(Update{Message: &Message{Photo: &Photo{}}})
+
+ b.Handle("/start", func(c Context) error {
+ assert.Equal(t, "/start", c.Text())
+ return nil
+ })
+ b.Handle("hello", func(c Context) error {
+ assert.Equal(t, "hello", c.Text())
+ return nil
+ })
+ b.Handle(OnText, func(c Context) error {
+ assert.Equal(t, "text", c.Text())
+ return nil
+ })
+ b.Handle(OnPinned, func(c Context) error {
+ assert.NotNil(t, c.Message())
+ return nil
+ })
+ b.Handle(OnPhoto, func(c Context) error {
+ assert.NotNil(t, c.Message().Photo)
+ return nil
+ })
+ b.Handle(OnVoice, func(c Context) error {
+ assert.NotNil(t, c.Message().Voice)
+ return nil
+ })
+ b.Handle(OnAudio, func(c Context) error {
+ assert.NotNil(t, c.Message().Audio)
+ return nil
+ })
+ b.Handle(OnAnimation, func(c Context) error {
+ assert.NotNil(t, c.Message().Animation)
+ return nil
+ })
+ b.Handle(OnDocument, func(c Context) error {
+ assert.NotNil(t, c.Message().Document)
+ return nil
+ })
+ b.Handle(OnSticker, func(c Context) error {
+ assert.NotNil(t, c.Message().Sticker)
+ return nil
+ })
+ b.Handle(OnVideo, func(c Context) error {
+ assert.NotNil(t, c.Message().Video)
+ return nil
+ })
+ b.Handle(OnVideoNote, func(c Context) error {
+ assert.NotNil(t, c.Message().VideoNote)
+ return nil
+ })
+ b.Handle(OnContact, func(c Context) error {
+ assert.NotNil(t, c.Message().Contact)
+ return nil
+ })
+ b.Handle(OnLocation, func(c Context) error {
+ assert.NotNil(t, c.Message().Location)
+ return nil
+ })
+ b.Handle(OnVenue, func(c Context) error {
+ assert.NotNil(t, c.Message().Venue)
+ return nil
+ })
+ b.Handle(OnDice, func(c Context) error {
+ assert.NotNil(t, c.Message().Dice)
+ return nil
+ })
+ b.Handle(OnInvoice, func(c Context) error {
+ assert.NotNil(t, c.Message().Invoice)
+ return nil
+ })
+ b.Handle(OnPayment, func(c Context) error {
+ assert.NotNil(t, c.Message().Payment)
+ return nil
+ })
+ b.Handle(OnAddedToGroup, func(c Context) error {
+ assert.NotNil(t, c.Message().GroupCreated)
+ return nil
+ })
+ b.Handle(OnUserJoined, func(c Context) error {
+ assert.NotNil(t, c.Message().UserJoined)
+ return nil
+ })
+ b.Handle(OnUserLeft, func(c Context) error {
+ assert.NotNil(t, c.Message().UserLeft)
+ return nil
+ })
+ b.Handle(OnNewGroupTitle, func(c Context) error {
+ assert.Equal(t, "title", c.Message().NewGroupTitle)
+ return nil
+ })
+ b.Handle(OnNewGroupPhoto, func(c Context) error {
+ assert.NotNil(t, c.Message().NewGroupPhoto)
+ return nil
+ })
+ b.Handle(OnGroupPhotoDeleted, func(c Context) error {
+ assert.True(t, c.Message().GroupPhotoDeleted)
+ return nil
+ })
+ b.Handle(OnMigration, func(c Context) error {
+ from, to := c.Migration()
+ assert.Equal(t, int64(1), from)
+ assert.Equal(t, int64(2), to)
+ return nil
+ })
+ b.Handle(OnEdited, func(c Context) error {
+ assert.Equal(t, "edited", c.Message().Text)
+ return nil
+ })
+ b.Handle(OnChannelPost, func(c Context) error {
+ assert.Equal(t, "post", c.Message().Text)
+ return nil
+ })
+ b.Handle(OnEditedChannelPost, func(c Context) error {
+ assert.Equal(t, "edited post", c.Message().Text)
+ return nil
+ })
+ b.Handle(OnCallback, func(c Context) error {
+ if data := c.Callback().Data; data[0] != '\f' {
+ assert.Equal(t, "callback", data)
+ }
+ return nil
+ })
+ b.Handle("\funique", func(c Context) error {
+ assert.Equal(t, "callback", c.Callback().Data)
+ return nil
+ })
+ b.Handle(OnQuery, func(c Context) error {
+ assert.Equal(t, "query", c.Data())
+ return nil
+ })
+ b.Handle(OnInlineResult, func(c Context) error {
+ assert.Equal(t, "result", c.InlineResult().ResultID)
+ return nil
+ })
+ b.Handle(OnShipping, func(c Context) error {
+ assert.Equal(t, "shipping", c.ShippingQuery().ID)
+ return nil
+ })
+ b.Handle(OnCheckout, func(c Context) error {
+ assert.Equal(t, "checkout", c.PreCheckoutQuery().ID)
+ return nil
+ })
+ b.Handle(OnPoll, func(c Context) error {
+ assert.Equal(t, "poll", c.Poll().ID)
+ return nil
+ })
+ b.Handle(OnPollAnswer, func(c Context) error {
+ assert.Equal(t, "poll", c.PollAnswer().PollID)
+ return nil
+ })
+
+ b.Handle(OnWebApp, func(c Context) error {
+ assert.Equal(t, "webapp", c.Message().WebAppData.Data)
+ return nil
+ })
+
+ b.ProcessUpdate(Update{Message: &Message{Text: "/start"}})
+ b.ProcessUpdate(Update{Message: &Message{Text: "/start@other_bot"}})
+ b.ProcessUpdate(Update{Message: &Message{Text: "hello"}})
+ b.ProcessUpdate(Update{Message: &Message{Text: "text"}})
+ b.ProcessUpdate(Update{Message: &Message{PinnedMessage: &Message{}}})
+ b.ProcessUpdate(Update{Message: &Message{Photo: &Photo{}}})
+ b.ProcessUpdate(Update{Message: &Message{Voice: &Voice{}}})
+ b.ProcessUpdate(Update{Message: &Message{Audio: &Audio{}}})
+ b.ProcessUpdate(Update{Message: &Message{Animation: &Animation{}}})
+ b.ProcessUpdate(Update{Message: &Message{Document: &Document{}}})
+ b.ProcessUpdate(Update{Message: &Message{Sticker: &Sticker{}}})
+ b.ProcessUpdate(Update{Message: &Message{Video: &Video{}}})
+ b.ProcessUpdate(Update{Message: &Message{VideoNote: &VideoNote{}}})
+ b.ProcessUpdate(Update{Message: &Message{Contact: &Contact{}}})
+ b.ProcessUpdate(Update{Message: &Message{Location: &Location{}}})
+ b.ProcessUpdate(Update{Message: &Message{Venue: &Venue{}}})
+ b.ProcessUpdate(Update{Message: &Message{Invoice: &Invoice{}}})
+ b.ProcessUpdate(Update{Message: &Message{Payment: &Payment{}}})
+ b.ProcessUpdate(Update{Message: &Message{Dice: &Dice{}}})
+ b.ProcessUpdate(Update{Message: &Message{GroupCreated: true}})
+ b.ProcessUpdate(Update{Message: &Message{UserJoined: &User{ID: 1}}})
+ b.ProcessUpdate(Update{Message: &Message{UsersJoined: []User{{ID: 1}}}})
+ b.ProcessUpdate(Update{Message: &Message{UserLeft: &User{}}})
+ b.ProcessUpdate(Update{Message: &Message{NewGroupTitle: "title"}})
+ b.ProcessUpdate(Update{Message: &Message{NewGroupPhoto: &Photo{}}})
+ b.ProcessUpdate(Update{Message: &Message{GroupPhotoDeleted: true}})
+ b.ProcessUpdate(Update{Message: &Message{Chat: &Chat{ID: 1}, MigrateTo: 2}})
+ b.ProcessUpdate(Update{EditedMessage: &Message{Text: "edited"}})
+ b.ProcessUpdate(Update{ChannelPost: &Message{Text: "post"}})
+ b.ProcessUpdate(Update{ChannelPost: &Message{PinnedMessage: &Message{}}})
+ b.ProcessUpdate(Update{EditedChannelPost: &Message{Text: "edited post"}})
+ b.ProcessUpdate(Update{Callback: &Callback{MessageID: "inline", Data: "callback"}})
+ b.ProcessUpdate(Update{Callback: &Callback{Data: "callback"}})
+ b.ProcessUpdate(Update{Callback: &Callback{Data: "\funique|callback"}})
+ b.ProcessUpdate(Update{Query: &Query{Text: "query"}})
+ b.ProcessUpdate(Update{InlineResult: &InlineResult{ResultID: "result"}})
+ b.ProcessUpdate(Update{ShippingQuery: &ShippingQuery{ID: "shipping"}})
+ b.ProcessUpdate(Update{PreCheckoutQuery: &PreCheckoutQuery{ID: "checkout"}})
+ b.ProcessUpdate(Update{Poll: &Poll{ID: "poll"}})
+ b.ProcessUpdate(Update{PollAnswer: &PollAnswer{PollID: "poll"}})
+ b.ProcessUpdate(Update{Message: &Message{WebAppData: &WebAppData{Data: "webapp"}}})
+}
+
+func TestBotOnError(t *testing.T) {
+ b, err := NewBot(Settings{Synchronous: true, Offline: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var ok bool
+ b.onError = func(err error, c Context) {
+ assert.Equal(t, b, c.(*nativeContext).b)
+ assert.NotNil(t, err)
+ ok = true
+ }
+
+ b.runHandler(func(c Context) error {
+ return errors.New("not nil")
+ }, &nativeContext{b: b})
+
+ assert.True(t, ok)
+}
+
+func TestBotMiddleware(t *testing.T) {
+ t.Run("calling order", func(t *testing.T) {
+ var trace []string
+
+ handler := func(name string) HandlerFunc {
+ return func(c Context) error {
+ trace = append(trace, name)
+ return nil
+ }
+ }
+
+ middleware := func(name string) MiddlewareFunc {
+ return func(next HandlerFunc) HandlerFunc {
+ return func(c Context) error {
+ trace = append(trace, name+":in")
+ err := next(c)
+ trace = append(trace, name+":out")
+ return err
+ }
+ }
+ }
+
+ b, err := NewBot(Settings{Synchronous: true, Offline: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ b.Use(middleware("global1"), middleware("global2"))
+ b.Handle("/a", handler("/a"), middleware("handler1a"), middleware("handler2a"))
+
+ group := b.Group()
+ group.Use(middleware("group1"), middleware("group2"))
+ group.Handle("/b", handler("/b"), middleware("handler1b"))
+
+ b.ProcessUpdate(Update{
+ Message: &Message{Text: "/a"},
+ })
+ assert.Equal(t, []string{
+ "global1:in", "global2:in",
+ "handler1a:in", "handler2a:in",
+ "/a",
+ "handler2a:out", "handler1a:out",
+ "global2:out", "global1:out",
+ }, trace)
+
+ trace = trace[:0]
+
+ b.ProcessUpdate(Update{
+ Message: &Message{Text: "/b"},
+ })
+ assert.Equal(t, []string{
+ "global1:in", "global2:in",
+ "group1:in", "group2:in",
+ "handler1b:in",
+ "/b",
+ "handler1b:out",
+ "group2:out", "group1:out",
+ "global2:out", "global1:out",
+ }, trace)
+ })
+
+ fatal := func(next HandlerFunc) HandlerFunc {
+ return func(c Context) error {
+ t.Fatal("fatal middleware should not be called")
+ return nil
+ }
+ }
+
+ nop := func(next HandlerFunc) HandlerFunc {
+ return func(c Context) error {
+ return next(c)
+ }
+ }
+
+ t.Run("combining with global middleware", func(t *testing.T) {
+ b, err := NewBot(Settings{Synchronous: true, Offline: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Pre-allocate middleware slice to make sure
+ // it has extra capacity after group-level middleware is added.
+ b.group.middleware = make([]MiddlewareFunc, 0, 2)
+ b.Use(nop)
+
+ b.Handle("/a", func(c Context) error { return nil }, nop)
+ b.Handle("/b", func(c Context) error { return nil }, fatal)
+
+ b.ProcessUpdate(Update{Message: &Message{Text: "/a"}})
+ })
+
+ t.Run("combining with group middleware", func(t *testing.T) {
+ b, err := NewBot(Settings{Synchronous: true, Offline: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ g := b.Group()
+ // Pre-allocate middleware slice to make sure
+ // it has extra capacity after group-level middleware is added.
+ g.middleware = make([]MiddlewareFunc, 0, 2)
+ g.Use(nop)
+
+ g.Handle("/a", func(c Context) error { return nil }, nop)
+ g.Handle("/b", func(c Context) error { return nil }, fatal)
+
+ b.ProcessUpdate(Update{Message: &Message{Text: "/a"}})
+ })
+}
+
+func TestBot(t *testing.T) {
+ if b == nil {
+ t.Skip("Cached bot instance is bad (probably wrong or empty TELEBOT_SECRET)")
+ }
+ if chatID == 0 {
+ t.Skip("CHAT_ID is required for Bot methods test")
+ }
+
+ ctx := context.Background()
+
+ _, err := b.Send(ctx, to, nil)
+ assert.Equal(t, ErrUnsupportedWhat, err)
+ _, err = b.Edit(ctx, &Message{Chat: &Chat{}}, nil)
+ assert.Equal(t, ErrUnsupportedWhat, err)
+
+ _, err = b.Send(ctx, nil, "")
+ assert.Equal(t, ErrBadRecipient, err)
+ _, err = b.Forward(ctx, nil, nil)
+ assert.Equal(t, ErrBadRecipient, err)
+
+ photo := &Photo{
+ File: FromURL("https://telegra.ph/file/65c5237b040ebf80ec278.jpg"),
+ Caption: t.Name(),
+ }
+ var msg *Message
+
+ t.Run("Send(what=Sendable)", func(t *testing.T) {
+ msg, err = b.Send(ctx, to, photo)
+ require.NoError(t, err)
+ assert.NotNil(t, msg.Photo)
+ assert.Equal(t, photo.Caption, msg.Caption)
+ })
+
+ t.Run("SendAlbum()", func(t *testing.T) {
+ _, err = b.SendAlbum(ctx, nil, nil)
+ assert.Equal(t, ErrBadRecipient, err)
+
+ _, err = b.SendAlbum(ctx, to, nil)
+ assert.Error(t, err)
+
+ photo2 := *photo
+ photo2.Caption = ""
+
+ msgs, err := b.SendAlbum(ctx, to, Album{photo, &photo2}, ModeHTML)
+ require.NoError(t, err)
+ assert.Len(t, msgs, 2)
+ assert.NotEmpty(t, msgs[0].AlbumID)
+ })
+
+ t.Run("EditCaption()+ParseMode", func(t *testing.T) {
+ b.parseMode = ModeHTML
+
+ edited, err := b.EditCaption(ctx, msg, "new caption with html")
+ require.NoError(t, err)
+ assert.Equal(t, "new caption with html", edited.Caption)
+ assert.Equal(t, EntityBold, edited.CaptionEntities[0].Type)
+
+ sleep()
+
+ edited, err = b.EditCaption(ctx, msg, "*new caption with markdown*", ModeMarkdown)
+ require.NoError(t, err)
+ assert.Equal(t, "new caption with markdown", edited.Caption)
+ assert.Equal(t, EntityBold, edited.CaptionEntities[0].Type)
+
+ sleep()
+
+ edited, err = b.EditCaption(ctx, msg, "_new caption with markdown \\(V2\\)_", ModeMarkdownV2)
+ require.NoError(t, err)
+ assert.Equal(t, "new caption with markdown (V2)", edited.Caption)
+ assert.Equal(t, EntityItalic, edited.CaptionEntities[0].Type)
+
+ b.parseMode = ModeDefault
+ })
+
+ t.Run("Edit(what=Media)", func(t *testing.T) {
+ edited, err := b.Edit(ctx, msg, photo)
+ require.NoError(t, err)
+ assert.Equal(t, edited.Photo.UniqueID, photo.UniqueID)
+
+ resp, err := http.Get("https://telegra.ph/file/274e5eb26f348b10bd8ee.mp4")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ file, err := ioutil.TempFile("", "")
+ require.NoError(t, err)
+
+ _, err = io.Copy(file, resp.Body)
+ require.NoError(t, err)
+
+ animation := &Animation{
+ File: FromDisk(file.Name()),
+ Caption: t.Name(),
+ FileName: "animation.gif",
+ }
+
+ msg, err := b.Send(ctx, msg.Chat, animation)
+ require.NoError(t, err)
+
+ if msg.Animation != nil {
+ assert.Equal(t, msg.Animation.FileID, animation.FileID)
+ } else {
+ assert.Equal(t, msg.Document.FileID, animation.FileID)
+ }
+
+ _, err = b.Edit(ctx, edited, animation)
+ require.NoError(t, err)
+ })
+
+ t.Run("Edit(what=Animation)", func(t *testing.T) {})
+
+ t.Run("Send(what=string)", func(t *testing.T) {
+ msg, err = b.Send(ctx, to, t.Name())
+ require.NoError(t, err)
+ assert.Equal(t, t.Name(), msg.Text)
+
+ rpl, err := b.Reply(ctx, msg, t.Name())
+ require.NoError(t, err)
+ assert.Equal(t, rpl.Text, msg.Text)
+ assert.NotNil(t, rpl.ReplyTo)
+ assert.Equal(t, rpl.ReplyTo, msg)
+ assert.True(t, rpl.IsReply())
+
+ fwd, err := b.Forward(ctx, to, msg)
+ require.NoError(t, err)
+ assert.NotNil(t, msg, fwd)
+ assert.True(t, fwd.IsForwarded())
+
+ fwd.ID += 1 // nonexistent message
+ _, err = b.Forward(ctx, to, fwd)
+ assert.Equal(t, ErrNotFoundToForward, err)
+ })
+
+ t.Run("Edit(what=string)", func(t *testing.T) {
+ msg, err = b.Edit(ctx, msg, t.Name())
+ require.NoError(t, err)
+ assert.Equal(t, t.Name(), msg.Text)
+
+ _, err = b.Edit(ctx, msg, msg.Text)
+ assert.Error(t, err) // message is not modified
+ })
+
+ t.Run("Edit(what=ReplyMarkup)", func(t *testing.T) {
+ good := &ReplyMarkup{
+ InlineKeyboard: [][]InlineButton{
+ {{
+ Data: "btn",
+ Text: "Hi Telebot!",
+ }},
+ },
+ }
+ bad := &ReplyMarkup{
+ InlineKeyboard: [][]InlineButton{
+ {{
+ Data: strings.Repeat("*", 65),
+ Text: "Bad Button",
+ }},
+ },
+ }
+
+ edited, err := b.Edit(ctx, msg, good)
+ require.NoError(t, err)
+ assert.Equal(t, edited.ReplyMarkup.InlineKeyboard, good.InlineKeyboard)
+
+ edited, err = b.EditReplyMarkup(ctx, edited, nil)
+ require.NoError(t, err)
+ assert.Nil(t, edited.ReplyMarkup)
+
+ _, err = b.Edit(ctx, edited, bad)
+ assert.Equal(t, ErrBadButtonData, err)
+ })
+
+ t.Run("Edit(what=Location)", func(t *testing.T) {
+ loc := &Location{Lat: 42, Lng: 69, LivePeriod: 60}
+ edited, err := b.Send(ctx, to, loc)
+ require.NoError(t, err)
+ assert.NotNil(t, edited.Location)
+
+ loc = &Location{Lat: loc.Lng, Lng: loc.Lat}
+ edited, err = b.Edit(ctx, edited, *loc)
+ require.NoError(t, err)
+ assert.NotNil(t, edited.Location)
+ })
+
+ // Should be after the Edit tests.
+ t.Run("Delete()", func(t *testing.T) {
+ require.NoError(t, b.Delete(ctx, msg))
+ })
+
+ t.Run("Notify()", func(t *testing.T) {
+ assert.Equal(t, ErrBadRecipient, b.Notify(ctx, nil, Typing))
+ require.NoError(t, b.Notify(ctx, to, Typing))
+ })
+
+ t.Run("Answer()", func(t *testing.T) {
+ assert.Error(t, b.Answer(ctx, &Query{}, &QueryResponse{
+ Results: Results{&ArticleResult{}},
+ }))
+ })
+
+ t.Run("Respond()", func(t *testing.T) {
+ assert.Error(t, b.Respond(ctx, &Callback{}, &CallbackResponse{}))
+ })
+
+ t.Run("Payments", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ b.Accept(ctx, &PreCheckoutQuery{})
+ b.Accept(ctx, &PreCheckoutQuery{}, "error")
+ })
+ assert.NotPanics(t, func() {
+ b.Ship(ctx, &ShippingQuery{})
+ b.Ship(ctx, &ShippingQuery{}, "error")
+ b.Ship(ctx, &ShippingQuery{}, ShippingOption{}, ShippingOption{})
+ assert.Equal(t, ErrUnsupportedWhat, b.Ship(ctx, &ShippingQuery{}, 0))
+ })
+ })
+
+ t.Run("Commands", func(t *testing.T) {
+ var (
+ set1 = []Command{{
+ Text: "test1",
+ Description: "test command 1",
+ }}
+ set2 = []Command{{
+ Text: "test2",
+ Description: "test command 2",
+ }}
+ scope = CommandScope{
+ Type: CommandScopeChat,
+ ChatID: chatID,
+ }
+ )
+
+ err := b.SetCommands(ctx, set1)
+ require.NoError(t, err)
+
+ cmds, err := b.Commands(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, set1, cmds)
+
+ err = b.SetCommands(ctx, set2, "en", scope)
+ require.NoError(t, err)
+
+ cmds, err = b.Commands(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, set1, cmds)
+
+ cmds, err = b.Commands(ctx, "en", scope)
+ require.NoError(t, err)
+ assert.Equal(t, set2, cmds)
+
+ require.NoError(t, b.DeleteCommands(ctx, "en", scope))
+ require.NoError(t, b.DeleteCommands(ctx))
+ })
+
+ t.Run("InviteLink", func(t *testing.T) {
+ inviteLink, err := b.CreateInviteLink(ctx, &Chat{ID: chatID}, nil)
+ require.NoError(t, err)
+ assert.True(t, len(inviteLink.InviteLink) > 0)
+
+ sleep()
+
+ response, err := b.EditInviteLink(ctx, &Chat{ID: chatID}, &ChatInviteLink{InviteLink: inviteLink.InviteLink})
+ require.NoError(t, err)
+ assert.True(t, len(response.InviteLink) > 0)
+
+ sleep()
+
+ response, err = b.RevokeInviteLink(ctx, &Chat{ID: chatID}, inviteLink.InviteLink)
+ require.Nil(t, err)
+ assert.True(t, len(response.InviteLink) > 0)
+ })
+}
+
+func sleep() {
+ time.Sleep(time.Second)
+}
diff --git a/internal/telebot/callback.go b/internal/telebot/callback.go
new file mode 100644
index 0000000..4bce60a
--- /dev/null
+++ b/internal/telebot/callback.go
@@ -0,0 +1,89 @@
+package telebot
+
+// CallbackEndpoint is an interface any element capable
+// of responding to a callback `\f`.
+type CallbackEndpoint interface {
+ CallbackUnique() string
+}
+
+// Callback object represents a query from a callback button in an
+// inline keyboard.
+type Callback struct {
+ ID string `json:"id"`
+
+ // For message sent to channels, Sender may be empty
+ Sender *User `json:"from"`
+
+ // Message will be set if the button that originated the query
+ // was attached to a message sent by a bot.
+ Message *Message `json:"message"`
+
+ // MessageID will be set if the button was attached to a message
+ // sent via the bot in inline mode.
+ MessageID string `json:"inline_message_id"`
+
+ // Data associated with the callback button. Be aware that
+ // a bad client can send arbitrary data in this field.
+ Data string `json:"data"`
+
+ // Unique displays an unique of the button from which the
+ // callback was fired. Sets immediately before the handling,
+ // while the Data field stores only with payload.
+ Unique string `json:"-"`
+}
+
+// MessageSig satisfies Editable interface.
+func (c *Callback) MessageSig() (string, int64) {
+ if c.IsInline() {
+ return c.MessageID, 0
+ }
+ return c.Message.MessageSig()
+}
+
+// IsInline says whether message is an inline message.
+func (c *Callback) IsInline() bool {
+ return c.MessageID != ""
+}
+
+// CallbackResponse builds a response to a Callback query.
+type CallbackResponse struct {
+ // The ID of the callback to which this is a response.
+ //
+ // Note: Telebot sets this field automatically!
+ CallbackID string `json:"callback_query_id"`
+
+ // Text of the notification. If not specified, nothing will be
+ // shown to the user.
+ Text string `json:"text,omitempty"`
+
+ // (Optional) If true, an alert will be shown by the client instead
+ // of a notification at the top of the chat screen. Defaults to false.
+ ShowAlert bool `json:"show_alert,omitempty"`
+
+ // (Optional) URL that will be opened by the user's client.
+ // If you have created a Game and accepted the conditions via
+ // @BotFather, specify the URL that opens your game.
+ //
+ // Note: this will only work if the query comes from a game
+ // callback button. Otherwise, you may use deep-linking:
+ // https://telegram.me/your_bot?start=XXXX
+ URL string `json:"url,omitempty"`
+}
+
+// CallbackUnique returns ReplyButton.Text.
+func (t *ReplyButton) CallbackUnique() string {
+ return t.Text
+}
+
+// CallbackUnique returns InlineButton.Unique.
+func (t *InlineButton) CallbackUnique() string {
+ return "\f" + t.Unique
+}
+
+// CallbackUnique implements CallbackEndpoint.
+func (t *Btn) CallbackUnique() string {
+ if t.Unique != "" {
+ return "\f" + t.Unique
+ }
+ return t.Text
+}
diff --git a/internal/telebot/chat.go b/internal/telebot/chat.go
new file mode 100644
index 0000000..de55005
--- /dev/null
+++ b/internal/telebot/chat.go
@@ -0,0 +1,468 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+ "time"
+)
+
+// User object represents a Telegram user, bot.
+type User struct {
+ ID int64 `json:"id"`
+
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ IsForum bool `json:"is_forum"`
+ Username string `json:"username"`
+ LanguageCode string `json:"language_code"`
+ IsBot bool `json:"is_bot"`
+ IsPremium bool `json:"is_premium"`
+ AddedToMenu bool `json:"added_to_attachment_menu"`
+ Usernames []string `json:"active_usernames"`
+ CustomEmojiStatus string `json:"emoji_status_custom_emoji_id"`
+
+ // Returns only in getMe
+ CanJoinGroups bool `json:"can_join_groups"`
+ CanReadMessages bool `json:"can_read_all_group_messages"`
+ SupportsInline bool `json:"supports_inline_queries"`
+}
+
+// Recipient returns user ID (see Recipient interface).
+func (u *User) Recipient() string {
+ return strconv.FormatInt(u.ID, 10)
+}
+
+// Chat object represents a Telegram user, bot, group or a channel.
+type Chat struct {
+ ID int64 `json:"id"`
+
+ // See ChatType and consts.
+ Type ChatType `json:"type"`
+
+ // Won't be there for ChatPrivate.
+ Title string `json:"title"`
+
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Username string `json:"username"`
+
+ // Returns only in getChat
+ Bio string `json:"bio,omitempty"`
+ Photo *ChatPhoto `json:"photo,omitempty"`
+ Description string `json:"description,omitempty"`
+ InviteLink string `json:"invite_link,omitempty"`
+ PinnedMessage *Message `json:"pinned_message,omitempty"`
+ Permissions *Rights `json:"permissions,omitempty"`
+ SlowMode int `json:"slow_mode_delay,omitempty"`
+ StickerSet string `json:"sticker_set_name,omitempty"`
+ CanSetStickerSet bool `json:"can_set_sticker_set,omitempty"`
+ LinkedChatID int64 `json:"linked_chat_id,omitempty"`
+ ChatLocation *ChatLocation `json:"location,omitempty"`
+ Private bool `json:"has_private_forwards,omitempty"`
+ Protected bool `json:"has_protected_content,omitempty"`
+ NoVoiceAndVideo bool `json:"has_restricted_voice_and_video_messages"`
+ HiddenMembers bool `json:"has_hidden_members,omitempty"`
+ AggressiveAntiSpam bool `json:"has_aggressive_anti_spam_enabled,omitempty"`
+}
+
+// Recipient returns chat ID (see Recipient interface).
+func (c *Chat) Recipient() string {
+ return strconv.FormatInt(c.ID, 10)
+}
+
+// ChatType represents one of the possible chat types.
+type ChatType string
+
+const (
+ ChatPrivate ChatType = "private"
+ ChatGroup ChatType = "group"
+ ChatSuperGroup ChatType = "supergroup"
+ ChatChannel ChatType = "channel"
+ ChatChannelPrivate ChatType = "privatechannel"
+)
+
+// ChatLocation represents a location to which a chat is connected.
+type ChatLocation struct {
+ Location Location `json:"location,omitempty"`
+ Address string `json:"address,omitempty"`
+}
+
+// ChatPhoto object represents a chat photo.
+type ChatPhoto struct {
+ // File identifiers of small (160x160) chat photo
+ SmallFileID string `json:"small_file_id"`
+ SmallUniqueID string `json:"small_file_unique_id"`
+
+ // File identifiers of big (640x640) chat photo
+ BigFileID string `json:"big_file_id"`
+ BigUniqueID string `json:"big_file_unique_id"`
+}
+
+// ChatMember object represents information about a single chat member.
+type ChatMember struct {
+ Rights
+
+ User *User `json:"user"`
+ Role MemberStatus `json:"status"`
+ Title string `json:"custom_title"`
+ Anonymous bool `json:"is_anonymous"`
+ Member bool `json:"is_member,omitempty"`
+
+ // Date when restrictions will be lifted for the user, unix time.
+ //
+ // If user is restricted for more than 366 days or less than
+ // 30 seconds from the current time, they are considered to be
+ // restricted forever.
+ //
+ // Use tele.Forever().
+ //
+ RestrictedUntil int64 `json:"until_date,omitempty"`
+
+ JoinToSend string `json:"join_to_send_messages"`
+ JoinByRequest string `json:"join_by_request"`
+}
+
+// MemberStatus is one's chat status.
+type MemberStatus string
+
+const (
+ Creator MemberStatus = "creator"
+ Administrator MemberStatus = "administrator"
+ Member MemberStatus = "member"
+ Restricted MemberStatus = "restricted"
+ Left MemberStatus = "left"
+ Kicked MemberStatus = "kicked"
+)
+
+// ChatMemberUpdate object represents changes in the status of a chat member.
+type ChatMemberUpdate struct {
+ // Chat where the user belongs to.
+ Chat *Chat `json:"chat"`
+
+ // Sender which user the action was triggered.
+ Sender *User `json:"from"`
+
+ // Unixtime, use Date() to get time.Time.
+ Unixtime int64 `json:"date"`
+
+ // Previous information about the chat member.
+ OldChatMember *ChatMember `json:"old_chat_member"`
+
+ // New information about the chat member.
+ NewChatMember *ChatMember `json:"new_chat_member"`
+
+ // (Optional) InviteLink which was used by the user to
+ // join the chat; for joining by invite link events only.
+ InviteLink *ChatInviteLink `json:"invite_link"`
+}
+
+// Time returns the moment of the change in local time.
+func (c *ChatMemberUpdate) Time() time.Time {
+ return time.Unix(c.Unixtime, 0)
+}
+
+// ChatID represents a chat or an user integer ID, which can be used
+// as recipient in bot methods. It is very useful in cases where
+// you have special group IDs, for example in your config, and don't
+// want to wrap it into *tele.Chat every time you send messages.
+//
+// Example:
+//
+// group := tele.ChatID(-100756389456)
+// b.Send(group, "Hello!")
+//
+// type Config struct {
+// AdminGroup tele.ChatID `json:"admin_group"`
+// }
+// b.Send(conf.AdminGroup, "Hello!")
+type ChatID int64
+
+// Recipient returns chat ID (see Recipient interface).
+func (i ChatID) Recipient() string {
+ return strconv.FormatInt(int64(i), 10)
+}
+
+// ChatJoinRequest represents a join request sent to a chat.
+type ChatJoinRequest struct {
+ // Chat to which the request was sent.
+ Chat *Chat `json:"chat"`
+
+ // Sender is the user that sent the join request.
+ Sender *User `json:"from"`
+
+ // UserChatID is an ID of a private chat with the user
+ // who sent the join request. The bot can use this ID
+ // for 5 minutes to send messages until the join request
+ // is processed, assuming no other administrator contacted the user.
+ UserChatID int64 `json:"user_chat_id"`
+
+ // Unixtime, use ChatJoinRequest.Time() to get time.Time.
+ Unixtime int64 `json:"date"`
+
+ // Bio of the user, optional.
+ Bio string `json:"bio"`
+
+ // InviteLink is the chat invite link that was used by
+ //the user to send the join request, optional.
+ InviteLink *ChatInviteLink `json:"invite_link"`
+}
+
+// ChatInviteLink object represents an invite for a chat.
+type ChatInviteLink struct {
+ // The invite link.
+ InviteLink string `json:"invite_link"`
+
+ // Invite link name.
+ Name string `json:"name"`
+
+ // The creator of the link.
+ Creator *User `json:"creator"`
+
+ // If the link is primary.
+ IsPrimary bool `json:"is_primary"`
+
+ // If the link is revoked.
+ IsRevoked bool `json:"is_revoked"`
+
+ // (Optional) Point in time when the link will expire,
+ // use ExpireDate() to get time.Time.
+ ExpireUnixtime int64 `json:"expire_date,omitempty"`
+
+ // (Optional) Maximum number of users that can be members of
+ // the chat simultaneously.
+ MemberLimit int `json:"member_limit,omitempty"`
+
+ // (Optional) True, if users joining the chat via the link need to
+ // be approved by chat administrators. If True, member_limit can't be specified.
+ JoinRequest bool `json:"creates_join_request"`
+
+ // (Optional) Number of pending join requests created using this link.
+ PendingCount int `json:"pending_join_request_count"`
+}
+
+// ExpireDate returns the moment of the link expiration in local time.
+func (c *ChatInviteLink) ExpireDate() time.Time {
+ return time.Unix(c.ExpireUnixtime, 0)
+}
+
+// Time returns the moment of chat join request sending in local time.
+func (r ChatJoinRequest) Time() time.Time {
+ return time.Unix(r.Unixtime, 0)
+}
+
+// InviteLink should be used to export chat's invite link.
+func (b *Bot) InviteLink(ctx context.Context, chat *Chat) (string, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "exportChatInviteLink", params)
+ if err != nil {
+ return "", err
+ }
+
+ var resp struct {
+ Result string
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return "", wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// CreateInviteLink creates an additional invite link for a chat.
+func (b *Bot) CreateInviteLink(ctx context.Context, chat Recipient, link *ChatInviteLink) (*ChatInviteLink, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+ if link != nil {
+ params["name"] = link.Name
+
+ if link.ExpireUnixtime != 0 {
+ params["expire_date"] = strconv.FormatInt(link.ExpireUnixtime, 10)
+ }
+ if link.MemberLimit > 0 {
+ params["member_limit"] = strconv.Itoa(link.MemberLimit)
+ } else if link.JoinRequest {
+ params["creates_join_request"] = "true"
+ }
+ }
+
+ data, err := b.Raw(ctx, "createChatInviteLink", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result ChatInviteLink `json:"result"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+
+ return &resp.Result, nil
+}
+
+// EditInviteLink edits a non-primary invite link created by the bot.
+func (b *Bot) EditInviteLink(ctx context.Context, chat Recipient, link *ChatInviteLink) (*ChatInviteLink, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+ if link != nil {
+ params["invite_link"] = link.InviteLink
+ params["name"] = link.Name
+
+ if link.ExpireUnixtime != 0 {
+ params["expire_date"] = strconv.FormatInt(link.ExpireUnixtime, 10)
+ }
+ if link.MemberLimit > 0 {
+ params["member_limit"] = strconv.Itoa(link.MemberLimit)
+ } else if link.JoinRequest {
+ params["creates_join_request"] = "true"
+ }
+ }
+
+ data, err := b.Raw(ctx, "editChatInviteLink", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result ChatInviteLink `json:"result"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+
+ return &resp.Result, nil
+}
+
+// RevokeInviteLink revokes an invite link created by the bot.
+func (b *Bot) RevokeInviteLink(ctx context.Context, chat Recipient, link string) (*ChatInviteLink, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "invite_link": link,
+ }
+
+ data, err := b.Raw(ctx, "revokeChatInviteLink", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result ChatInviteLink `json:"result"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+
+ return &resp.Result, nil
+}
+
+// ApproveJoinRequest approves a chat join request.
+func (b *Bot) ApproveJoinRequest(ctx context.Context, chat Recipient, user *User) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "user_id": user.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "approveChatJoinRequest", params)
+ if err != nil {
+ return err
+ }
+
+ return extractOk(data)
+}
+
+// DeclineJoinRequest declines a chat join request.
+func (b *Bot) DeclineJoinRequest(ctx context.Context, chat Recipient, user *User) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "user_id": user.Recipient(),
+ }
+
+ data, err := b.Raw(ctx, "declineChatJoinRequest", params)
+ if err != nil {
+ return err
+ }
+
+ return extractOk(data)
+}
+
+// SetGroupTitle should be used to update group title.
+func (b *Bot) SetGroupTitle(ctx context.Context, chat *Chat, title string) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "title": title,
+ }
+
+ _, err := b.Raw(ctx, "setChatTitle", params)
+ return err
+}
+
+// SetGroupDescription should be used to update group description.
+func (b *Bot) SetGroupDescription(ctx context.Context, chat *Chat, description string) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "description": description,
+ }
+
+ _, err := b.Raw(ctx, "setChatDescription", params)
+ return err
+}
+
+// SetGroupPhoto should be used to update group photo.
+func (b *Bot) SetGroupPhoto(ctx context.Context, chat *Chat, p *Photo) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.sendFiles(ctx, "setChatPhoto", map[string]File{"photo": p.File}, params)
+ return err
+}
+
+// SetGroupStickerSet should be used to update group's group sticker set.
+func (b *Bot) SetGroupStickerSet(ctx context.Context, chat *Chat, setName string) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "sticker_set_name": setName,
+ }
+
+ _, err := b.Raw(ctx, "setChatStickerSet", params)
+ return err
+}
+
+// SetGroupPermissions sets default chat permissions for all members.
+func (b *Bot) SetGroupPermissions(ctx context.Context, chat *Chat, perms Rights) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "permissions": perms,
+ }
+ if perms.Independent {
+ params["use_independent_chat_permissions"] = true
+ }
+
+ _, err := b.Raw(ctx, "setChatPermissions", params)
+ return err
+}
+
+// DeleteGroupPhoto should be used to just remove group photo.
+func (b *Bot) DeleteGroupPhoto(ctx context.Context, chat *Chat) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "deleteChatPhoto", params)
+ return err
+}
+
+// DeleteGroupStickerSet should be used to just remove group sticker set.
+func (b *Bot) DeleteGroupStickerSet(ctx context.Context, chat *Chat) error {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "deleteChatStickerSet", params)
+ return err
+}
diff --git a/internal/telebot/chat_test.go b/internal/telebot/chat_test.go
new file mode 100644
index 0000000..c1a7f88
--- /dev/null
+++ b/internal/telebot/chat_test.go
@@ -0,0 +1,21 @@
+package telebot
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestChat(t *testing.T) {
+ user := &User{ID: 1}
+ chat := &Chat{ID: 1}
+ chatID := ChatID(1)
+
+ assert.Implements(t, (*Recipient)(nil), user)
+ assert.Implements(t, (*Recipient)(nil), chat)
+ assert.Implements(t, (*Recipient)(nil), chatID)
+
+ assert.Equal(t, "1", user.Recipient())
+ assert.Equal(t, "1", chat.Recipient())
+ assert.Equal(t, "1", chatID.Recipient())
+}
diff --git a/internal/telebot/commands.go b/internal/telebot/commands.go
new file mode 100644
index 0000000..3c9ead8
--- /dev/null
+++ b/internal/telebot/commands.go
@@ -0,0 +1,88 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+)
+
+// Command represents a bot command.
+type Command struct {
+ // Text is a text of the command, 1-32 characters.
+ // Can contain only lowercase English letters, digits and underscores.
+ Text string `json:"command"`
+
+ // Description of the command, 3-256 characters.
+ Description string `json:"description"`
+}
+
+// CommandParams controls parameters for commands-related methods (setMyCommands, deleteMyCommands and getMyCommands).
+type CommandParams struct {
+ Commands []Command `json:"commands,omitempty"`
+ Scope *CommandScope `json:"scope,omitempty"`
+ LanguageCode string `json:"language_code,omitempty"`
+}
+
+type CommandScopeType = string
+
+const (
+ CommandScopeDefault CommandScopeType = "default"
+ CommandScopeAllPrivateChats CommandScopeType = "all_private_chats"
+ CommandScopeAllGroupChats CommandScopeType = "all_group_chats"
+ CommandScopeAllChatAdmin CommandScopeType = "all_chat_administrators"
+ CommandScopeChat CommandScopeType = "chat"
+ CommandScopeChatAdmin CommandScopeType = "chat_administrators"
+ CommandScopeChatMember CommandScopeType = "chat_member"
+)
+
+// CommandScope object represents a scope to which bot commands are applied.
+type CommandScope struct {
+ Type CommandScopeType `json:"type"`
+ ChatID int64 `json:"chat_id,omitempty"`
+ UserID int64 `json:"user_id,omitempty"`
+}
+
+// Commands returns the current list of the bot's commands for the given scope and user language.
+func (b *Bot) Commands(ctx context.Context, opts ...interface{}) ([]Command, error) {
+ params := extractCommandsParams(opts...)
+ data, err := b.Raw(ctx, "getMyCommands", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []Command
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// SetCommands changes the list of the bot's commands.
+func (b *Bot) SetCommands(ctx context.Context, opts ...interface{}) error {
+ params := extractCommandsParams(opts...)
+ _, err := b.Raw(ctx, "setMyCommands", params)
+ return err
+}
+
+// DeleteCommands deletes the list of the bot's commands for the given scope and user language.
+func (b *Bot) DeleteCommands(ctx context.Context, opts ...interface{}) error {
+ params := extractCommandsParams(opts...)
+ _, err := b.Raw(ctx, "deleteMyCommands", params)
+ return err
+}
+
+// extractCommandsParams extracts parameters for commands-related methods from the given options.
+func extractCommandsParams(opts ...interface{}) (params CommandParams) {
+ for _, opt := range opts {
+ switch value := opt.(type) {
+ case []Command:
+ params.Commands = value
+ case string:
+ params.LanguageCode = value
+ case CommandScope:
+ params.Scope = &value
+ }
+ }
+ return
+}
diff --git a/internal/telebot/context.go b/internal/telebot/context.go
new file mode 100644
index 0000000..6e8006d
--- /dev/null
+++ b/internal/telebot/context.go
@@ -0,0 +1,506 @@
+package telebot
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "time"
+)
+
+// HandlerFunc represents a handler function, which is
+// used to handle actual endpoints.
+type HandlerFunc func(Context) error
+
+// Context wraps an update and represents the context of current event.
+type Context interface {
+ // Bot returns the bot instance.
+ Bot() *Bot
+
+ // Update returns the original update.
+ Update() Update
+
+ // Message returns stored message if such presented.
+ Message() *Message
+
+ // Callback returns stored callback if such presented.
+ Callback() *Callback
+
+ // Query returns stored query if such presented.
+ Query() *Query
+
+ // InlineResult returns stored inline result if such presented.
+ InlineResult() *InlineResult
+
+ // ShippingQuery returns stored shipping query if such presented.
+ ShippingQuery() *ShippingQuery
+
+ // PreCheckoutQuery returns stored pre checkout query if such presented.
+ PreCheckoutQuery() *PreCheckoutQuery
+
+ // Poll returns stored poll if such presented.
+ Poll() *Poll
+
+ // PollAnswer returns stored poll answer if such presented.
+ PollAnswer() *PollAnswer
+
+ // ChatMember returns chat member changes.
+ ChatMember() *ChatMemberUpdate
+
+ // ChatJoinRequest returns the chat join request.
+ ChatJoinRequest() *ChatJoinRequest
+
+ // Migration returns both migration from and to chat IDs.
+ Migration() (int64, int64)
+
+ // Topic returns the topic changes.
+ Topic() *Topic
+
+ // Sender returns the current recipient, depending on the context type.
+ // Returns nil if user is not presented.
+ Sender() *User
+
+ // Chat returns the current chat, depending on the context type.
+ // Returns nil if chat is not presented.
+ Chat() *Chat
+
+ // Recipient combines both Sender and Chat functions. If there is no user
+ // the chat will be returned. The native context cannot be without sender,
+ // but it is useful in the case when the context created intentionally
+ // by the NewContext constructor and have only Chat field inside.
+ Recipient() Recipient
+
+ // Text returns the message text, depending on the context type.
+ // In the case when no related data presented, returns an empty string.
+ Text() string
+
+ // Entities returns the message entities, whether it's media caption's or the text's.
+ // In the case when no entities presented, returns a nil.
+ Entities() Entities
+
+ // Data returns the current data, depending on the context type.
+ // If the context contains command, returns its arguments string.
+ // If the context contains payment, returns its payload.
+ // In the case when no related data presented, returns an empty string.
+ Data() string
+
+ // Args returns a raw slice of command or callback arguments as strings.
+ // The message arguments split by space, while the callback's ones by a "|" symbol.
+ Args() []string
+
+ // Send sends a message to the current recipient.
+ // See Send from bot.go.
+ Send(ctx context.Context, what interface{}, opts ...interface{}) error
+
+ // SendAlbum sends an album to the current recipient.
+ // See SendAlbum from bot.go.
+ SendAlbum(ctx context.Context, a Album, opts ...interface{}) error
+
+ // Reply replies to the current message.
+ // See Reply from bot.go.
+ Reply(ctx context.Context, what interface{}, opts ...interface{}) error
+
+ // Forward forwards the given message to the current recipient.
+ // See Forward from bot.go.
+ Forward(ctx context.Context, msg Editable, opts ...interface{}) error
+
+ // ForwardTo forwards the current message to the given recipient.
+ // See Forward from bot.go
+ ForwardTo(ctx context.Context, to Recipient, opts ...interface{}) error
+
+ // Edit edits the current message.
+ // See Edit from bot.go.
+ Edit(ctx context.Context, what interface{}, opts ...interface{}) error
+
+ // EditCaption edits the caption of the current message.
+ // See EditCaption from bot.go.
+ EditCaption(ctx context.Context, caption string, opts ...interface{}) error
+
+ // EditOrSend edits the current message if the update is callback,
+ // otherwise the content is sent to the chat as a separate message.
+ EditOrSend(ctx context.Context, what interface{}, opts ...interface{}) error
+
+ // EditOrReply edits the current message if the update is callback,
+ // otherwise the content is replied as a separate message.
+ EditOrReply(ctx context.Context, what interface{}, opts ...interface{}) error
+
+ // Delete removes the current message.
+ // See Delete from bot.go.
+ Delete(ctx context.Context) error
+
+ // DeleteAfter waits for the duration to elapse and then removes the
+ // message. It handles an error automatically using b.OnError callback.
+ // It returns a Timer that can be used to cancel the call using its Stop method.
+ DeleteAfter(ctx context.Context, d time.Duration) *time.Timer
+
+ // Notify updates the chat action for the current recipient.
+ // See Notify from bot.go.
+ Notify(ctx context.Context, action ChatAction) error
+
+ // Ship replies to the current shipping query.
+ // See Ship from bot.go.
+ Ship(ctx context.Context, what ...interface{}) error
+
+ // Accept finalizes the current deal.
+ // See Accept from bot.go.
+ Accept(ctx context.Context, errorMessage ...string) error
+
+ // Answer sends a response to the current inline query.
+ // See Answer from bot.go.
+ Answer(ctx context.Context, resp *QueryResponse) error
+
+ // Respond sends a response for the current callback query.
+ // See Respond from bot.go.
+ Respond(ctx context.Context, resp ...*CallbackResponse) error
+
+ // Get retrieves data from the context.
+ Get(key string) interface{}
+
+ // Set saves data in the context.
+ Set(key string, val interface{})
+}
+
+// nativeContext is a native implementation of the Context interface.
+// "context" is taken by context package, maybe there is a better name.
+type nativeContext struct {
+ b *Bot
+ u Update
+ lock sync.RWMutex
+ store map[string]interface{}
+}
+
+func (c *nativeContext) Bot() *Bot {
+ return c.b
+}
+
+func (c *nativeContext) Update() Update {
+ return c.u
+}
+
+func (c *nativeContext) Message() *Message {
+ switch {
+ case c.u.Message != nil:
+ return c.u.Message
+ case c.u.Callback != nil:
+ return c.u.Callback.Message
+ case c.u.EditedMessage != nil:
+ return c.u.EditedMessage
+ case c.u.ChannelPost != nil:
+ if c.u.ChannelPost.PinnedMessage != nil {
+ return c.u.ChannelPost.PinnedMessage
+ }
+ return c.u.ChannelPost
+ case c.u.EditedChannelPost != nil:
+ return c.u.EditedChannelPost
+ default:
+ return nil
+ }
+}
+
+func (c *nativeContext) Callback() *Callback {
+ return c.u.Callback
+}
+
+func (c *nativeContext) Query() *Query {
+ return c.u.Query
+}
+
+func (c *nativeContext) InlineResult() *InlineResult {
+ return c.u.InlineResult
+}
+
+func (c *nativeContext) ShippingQuery() *ShippingQuery {
+ return c.u.ShippingQuery
+}
+
+func (c *nativeContext) PreCheckoutQuery() *PreCheckoutQuery {
+ return c.u.PreCheckoutQuery
+}
+
+func (c *nativeContext) ChatMember() *ChatMemberUpdate {
+ switch {
+ case c.u.ChatMember != nil:
+ return c.u.ChatMember
+ case c.u.MyChatMember != nil:
+ return c.u.MyChatMember
+ default:
+ return nil
+ }
+}
+
+func (c *nativeContext) ChatJoinRequest() *ChatJoinRequest {
+ return c.u.ChatJoinRequest
+}
+
+func (c *nativeContext) Poll() *Poll {
+ return c.u.Poll
+}
+
+func (c *nativeContext) PollAnswer() *PollAnswer {
+ return c.u.PollAnswer
+}
+
+func (c *nativeContext) Migration() (int64, int64) {
+ return c.u.Message.MigrateFrom, c.u.Message.MigrateTo
+}
+
+func (c *nativeContext) Topic() *Topic {
+ m := c.u.Message
+ if m == nil {
+ return nil
+ }
+ switch {
+ case m.TopicCreated != nil:
+ return m.TopicCreated
+ case m.TopicReopened != nil:
+ return m.TopicReopened
+ case m.TopicEdited != nil:
+ return m.TopicEdited
+ }
+ return nil
+}
+
+func (c *nativeContext) Sender() *User {
+ switch {
+ case c.u.Callback != nil:
+ return c.u.Callback.Sender
+ case c.Message() != nil:
+ return c.Message().Sender
+ case c.u.Query != nil:
+ return c.u.Query.Sender
+ case c.u.InlineResult != nil:
+ return c.u.InlineResult.Sender
+ case c.u.ShippingQuery != nil:
+ return c.u.ShippingQuery.Sender
+ case c.u.PreCheckoutQuery != nil:
+ return c.u.PreCheckoutQuery.Sender
+ case c.u.PollAnswer != nil:
+ return c.u.PollAnswer.Sender
+ case c.u.MyChatMember != nil:
+ return c.u.MyChatMember.Sender
+ case c.u.ChatMember != nil:
+ return c.u.ChatMember.Sender
+ case c.u.ChatJoinRequest != nil:
+ return c.u.ChatJoinRequest.Sender
+ default:
+ return nil
+ }
+}
+
+func (c *nativeContext) Chat() *Chat {
+ switch {
+ case c.Message() != nil:
+ return c.Message().Chat
+ case c.u.MyChatMember != nil:
+ return c.u.MyChatMember.Chat
+ case c.u.ChatMember != nil:
+ return c.u.ChatMember.Chat
+ case c.u.ChatJoinRequest != nil:
+ return c.u.ChatJoinRequest.Chat
+ default:
+ return nil
+ }
+}
+
+func (c *nativeContext) Recipient() Recipient {
+ chat := c.Chat()
+ if chat != nil {
+ return chat
+ }
+ return c.Sender()
+}
+
+func (c *nativeContext) Text() string {
+ m := c.Message()
+ if m == nil {
+ return ""
+ }
+ if m.Caption != "" {
+ return m.Caption
+ }
+ return m.Text
+}
+
+func (c *nativeContext) Entities() Entities {
+ m := c.Message()
+ if m == nil {
+ return nil
+ }
+ if len(m.CaptionEntities) > 0 {
+ return m.CaptionEntities
+ }
+ return m.Entities
+}
+
+func (c *nativeContext) Data() string {
+ switch {
+ case c.u.Message != nil:
+ return c.u.Message.Payload
+ case c.u.Callback != nil:
+ return c.u.Callback.Data
+ case c.u.Query != nil:
+ return c.u.Query.Text
+ case c.u.InlineResult != nil:
+ return c.u.InlineResult.Query
+ case c.u.ShippingQuery != nil:
+ return c.u.ShippingQuery.Payload
+ case c.u.PreCheckoutQuery != nil:
+ return c.u.PreCheckoutQuery.Payload
+ default:
+ return ""
+ }
+}
+
+func (c *nativeContext) Args() []string {
+ switch {
+ case c.u.Message != nil:
+ payload := strings.Trim(c.u.Message.Payload, " ")
+ if payload != "" {
+ return strings.Split(payload, " ")
+ }
+ case c.u.Callback != nil:
+ return strings.Split(c.u.Callback.Data, "|")
+ case c.u.Query != nil:
+ return strings.Split(c.u.Query.Text, " ")
+ case c.u.InlineResult != nil:
+ return strings.Split(c.u.InlineResult.Query, " ")
+ }
+ return nil
+}
+
+func (c *nativeContext) Send(ctx context.Context, what interface{}, opts ...interface{}) error {
+ _, err := c.b.Send(ctx, c.Recipient(), what, opts...)
+ return err
+}
+
+func (c *nativeContext) SendAlbum(ctx context.Context, a Album, opts ...interface{}) error {
+ _, err := c.b.SendAlbum(ctx, c.Recipient(), a, opts...)
+ return err
+}
+
+func (c *nativeContext) Reply(ctx context.Context, what interface{}, opts ...interface{}) error {
+ msg := c.Message()
+ if msg == nil {
+ return ErrBadContext
+ }
+ _, err := c.b.Reply(ctx, msg, what, opts...)
+ return err
+}
+
+func (c *nativeContext) Forward(ctx context.Context, msg Editable, opts ...interface{}) error {
+ _, err := c.b.Forward(ctx, c.Recipient(), msg, opts...)
+ return err
+}
+
+func (c *nativeContext) ForwardTo(ctx context.Context, to Recipient, opts ...interface{}) error {
+ msg := c.Message()
+ if msg == nil {
+ return ErrBadContext
+ }
+ _, err := c.b.Forward(ctx, to, msg, opts...)
+ return err
+}
+
+func (c *nativeContext) Edit(ctx context.Context, what interface{}, opts ...interface{}) error {
+ if c.u.InlineResult != nil {
+ _, err := c.b.Edit(ctx, c.u.InlineResult, what, opts...)
+ return err
+ }
+ if c.u.Callback != nil {
+ _, err := c.b.Edit(ctx, c.u.Callback, what, opts...)
+ return err
+ }
+ return ErrBadContext
+}
+
+func (c *nativeContext) EditCaption(ctx context.Context, caption string, opts ...interface{}) error {
+ if c.u.InlineResult != nil {
+ _, err := c.b.EditCaption(ctx, c.u.InlineResult, caption, opts...)
+ return err
+ }
+ if c.u.Callback != nil {
+ _, err := c.b.EditCaption(ctx, c.u.Callback, caption, opts...)
+ return err
+ }
+ return ErrBadContext
+}
+
+func (c *nativeContext) EditOrSend(ctx context.Context, what interface{}, opts ...interface{}) error {
+ err := c.Edit(ctx, what, opts...)
+ if errors.Is(err, ErrBadContext) {
+ return c.Send(ctx, what, opts...)
+ }
+ return err
+}
+
+func (c *nativeContext) EditOrReply(ctx context.Context, what interface{}, opts ...interface{}) error {
+ err := c.Edit(ctx, what, opts...)
+ if errors.Is(err, ErrBadContext) {
+ return c.Reply(ctx, what, opts...)
+ }
+ return err
+}
+
+func (c *nativeContext) Delete(ctx context.Context) error {
+ msg := c.Message()
+ if msg == nil {
+ return ErrBadContext
+ }
+ return c.b.Delete(ctx, msg)
+}
+
+func (c *nativeContext) DeleteAfter(ctx context.Context, d time.Duration) *time.Timer {
+ return time.AfterFunc(d, func() {
+ if err := c.Delete(ctx); err != nil {
+ c.b.OnError(err, c)
+ }
+ })
+}
+
+func (c *nativeContext) Notify(ctx context.Context, action ChatAction) error {
+ return c.b.Notify(ctx, c.Recipient(), action)
+}
+
+func (c *nativeContext) Ship(ctx context.Context, what ...interface{}) error {
+ if c.u.ShippingQuery == nil {
+ return errors.New("telebot: context shipping query is nil")
+ }
+ return c.b.Ship(ctx, c.u.ShippingQuery, what...)
+}
+
+func (c *nativeContext) Accept(ctx context.Context, errorMessage ...string) error {
+ if c.u.PreCheckoutQuery == nil {
+ return errors.New("telebot: context pre checkout query is nil")
+ }
+ return c.b.Accept(ctx, c.u.PreCheckoutQuery, errorMessage...)
+}
+
+func (c *nativeContext) Respond(ctx context.Context, resp ...*CallbackResponse) error {
+ if c.u.Callback == nil {
+ return errors.New("telebot: context callback is nil")
+ }
+ return c.b.Respond(ctx, c.u.Callback, resp...)
+}
+
+func (c *nativeContext) Answer(ctx context.Context, resp *QueryResponse) error {
+ if c.u.Query == nil {
+ return errors.New("telebot: context inline query is nil")
+ }
+ return c.b.Answer(ctx, c.u.Query, resp)
+}
+
+func (c *nativeContext) Set(key string, value interface{}) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ if c.store == nil {
+ c.store = make(map[string]interface{})
+ }
+ c.store[key] = value
+}
+
+func (c *nativeContext) Get(key string) interface{} {
+ c.lock.RLock()
+ defer c.lock.RUnlock()
+ return c.store[key]
+}
diff --git a/internal/telebot/context_test.go b/internal/telebot/context_test.go
new file mode 100644
index 0000000..d372ec5
--- /dev/null
+++ b/internal/telebot/context_test.go
@@ -0,0 +1,18 @@
+package telebot
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+var _ Context = (*nativeContext)(nil)
+
+func TestContext(t *testing.T) {
+ t.Run("Get,Set", func(t *testing.T) {
+ var c Context
+ c = new(nativeContext)
+ c.Set("name", "Jon Snow")
+ assert.Equal(t, "Jon Snow", c.Get("name"))
+ })
+}
diff --git a/internal/telebot/editable.go b/internal/telebot/editable.go
new file mode 100644
index 0000000..ec1fb5b
--- /dev/null
+++ b/internal/telebot/editable.go
@@ -0,0 +1,30 @@
+package telebot
+
+// Editable is an interface for all objects that
+// provide "message signature", a pair of 32-bit
+// message ID and 64-bit chat ID, both required
+// for edit operations.
+//
+// Use case: DB model struct for messages to-be
+// edited with, say two columns: msg_id,chat_id
+// could easily implement MessageSig() making
+// instances of stored messages editable.
+type Editable interface {
+ // MessageSig is a "message signature".
+ //
+ // For inline messages, return chatID = 0.
+ MessageSig() (messageID string, chatID int64)
+}
+
+// StoredMessage is an example struct suitable for being
+// stored in the database as-is or being embedded into
+// a larger struct, which is often the case (you might
+// want to store some metadata alongside, or might not.)
+type StoredMessage struct {
+ MessageID string `sql:"message_id" json:"message_id"`
+ ChatID int64 `sql:"chat_id" json:"chat_id"`
+}
+
+func (x StoredMessage) MessageSig() (string, int64) {
+ return x.MessageID, x.ChatID
+}
diff --git a/internal/telebot/errors.go b/internal/telebot/errors.go
new file mode 100644
index 0000000..0197e19
--- /dev/null
+++ b/internal/telebot/errors.go
@@ -0,0 +1,260 @@
+package telebot
+
+import (
+ "fmt"
+ "strings"
+)
+
+type (
+ Error struct {
+ Code int
+ Description string
+ Message string
+ }
+
+ FloodError struct {
+ err *Error
+ RetryAfter int
+ }
+
+ GroupError struct {
+ err *Error
+ MigratedTo int64
+ }
+)
+
+// ʔ returns description of error.
+// A tiny shortcut to make code clearer.
+func (err *Error) ʔ() string {
+ return err.Description
+}
+
+// Error implements error interface.
+func (err *Error) Error() string {
+ msg := err.Message
+ if msg == "" {
+ split := strings.Split(err.Description, ": ")
+ if len(split) == 2 {
+ msg = split[1]
+ } else {
+ msg = err.Description
+ }
+ }
+ return fmt.Sprintf("telegram: %s (%d)", msg, err.Code)
+}
+
+// Error implements error interface.
+func (err FloodError) Error() string {
+ return err.err.Error()
+}
+
+// Error implements error interface.
+func (err GroupError) Error() string {
+ return err.err.Error()
+}
+
+// NewError returns new Error instance with given description.
+// First element of msgs is Description. The second is optional Message.
+func NewError(code int, msgs ...string) *Error {
+ err := &Error{Code: code}
+ if len(msgs) >= 1 {
+ err.Description = msgs[0]
+ }
+ if len(msgs) >= 2 {
+ err.Message = msgs[1]
+ }
+ return err
+}
+
+// General errors
+var (
+ ErrTooLarge = NewError(400, "Request Entity Too Large")
+ ErrUnauthorized = NewError(401, "Unauthorized")
+ ErrNotFound = NewError(404, "Not Found")
+ ErrInternal = NewError(500, "Internal Server Error")
+)
+
+// Bad request errors
+var (
+ ErrBadButtonData = NewError(400, "Bad Request: BUTTON_DATA_INVALID")
+ ErrBadUserID = NewError(400, "Bad Request: USER_ID_INVALID")
+ ErrBadPollOptions = NewError(400, "Bad Request: expected an Array of String as options")
+ ErrBadURLContent = NewError(400, "Bad Request: failed to get HTTP URL content")
+ ErrCantEditMessage = NewError(400, "Bad Request: message can't be edited")
+ ErrCantRemoveOwner = NewError(400, "Bad Request: can't remove chat owner")
+ ErrCantUploadFile = NewError(400, "Bad Request: can't upload file by URL")
+ ErrCantUseMediaInAlbum = NewError(400, "Bad Request: can't use the media of the specified type in the album")
+ ErrChatAboutNotModified = NewError(400, "Bad Request: chat description is not modified")
+ ErrChatNotFound = NewError(400, "Bad Request: chat not found")
+ ErrEmptyChatID = NewError(400, "Bad Request: chat_id is empty")
+ ErrEmptyMessage = NewError(400, "Bad Request: message must be non-empty")
+ ErrEmptyText = NewError(400, "Bad Request: text is empty")
+ ErrFailedImageProcess = NewError(400, "Bad Request: IMAGE_PROCESS_FAILED", "Image process failed")
+ ErrGroupMigrated = NewError(400, "Bad Request: group chat was upgraded to a supergroup chat")
+ ErrMessageNotModified = NewError(400, "Bad Request: message is not modified")
+ ErrNoRightsToDelete = NewError(400, "Bad Request: message can't be deleted")
+ ErrNoRightsToRestrict = NewError(400, "Bad Request: not enough rights to restrict/unrestrict chat member")
+ ErrNoRightsToSend = NewError(400, "Bad Request: have no rights to send a message")
+ ErrNoRightsToSendGifs = NewError(400, "Bad Request: CHAT_SEND_GIFS_FORBIDDEN", "sending GIFS is not allowed in this chat")
+ ErrNoRightsToSendPhoto = NewError(400, "Bad Request: not enough rights to send photos to the chat")
+ ErrNoRightsToSendStickers = NewError(400, "Bad Request: not enough rights to send stickers to the chat")
+ ErrNotFoundToDelete = NewError(400, "Bad Request: message to delete not found")
+ ErrNotFoundToForward = NewError(400, "Bad Request: message to forward not found")
+ ErrNotFoundToReply = NewError(400, "Bad Request: reply message not found")
+ ErrQueryTooOld = NewError(400, "Bad Request: query is too old and response timeout expired or query ID is invalid")
+ ErrSameMessageContent = NewError(400, "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message")
+ ErrStickerEmojisInvalid = NewError(400, "Bad Request: invalid sticker emojis")
+ ErrStickerSetInvalid = NewError(400, "Bad Request: STICKERSET_INVALID", "Stickerset is invalid")
+ ErrStickerSetInvalidName = NewError(400, "Bad Request: invalid sticker set name is specified")
+ ErrStickerSetNameOccupied = NewError(400, "Bad Request: sticker set name is already occupied")
+ ErrTooLongMarkup = NewError(400, "Bad Request: reply markup is too long")
+ ErrTooLongMessage = NewError(400, "Bad Request: message is too long")
+ ErrUserIsAdmin = NewError(400, "Bad Request: user is an administrator of the chat")
+ ErrWrongFileID = NewError(400, "Bad Request: wrong file identifier/HTTP URL specified")
+ ErrWrongFileIDCharacter = NewError(400, "Bad Request: wrong remote file id specified: Wrong character in the string")
+ ErrWrongFileIDLength = NewError(400, "Bad Request: wrong remote file id specified: Wrong string length")
+ ErrWrongFileIDPadding = NewError(400, "Bad Request: wrong remote file id specified: Wrong padding in the string")
+ ErrWrongFileIDSymbol = NewError(400, "Bad Request: wrong remote file id specified: can't unserialize it. Wrong last symbol")
+ ErrWrongTypeOfContent = NewError(400, "Bad Request: wrong type of the web page content")
+ ErrWrongURL = NewError(400, "Bad Request: wrong HTTP URL specified")
+ ErrForwardMessage = NewError(400, "Bad Request: administrators of the chat restricted message forwarding")
+ ErrUserAlreadyParticipant = NewError(400, "Bad Request: USER_ALREADY_PARTICIPANT", "User is already a participant")
+ ErrHideRequesterMissing = NewError(400, "Bad Request: HIDE_REQUESTER_MISSING")
+ ErrChannelsTooMuch = NewError(400, "Bad Request: CHANNELS_TOO_MUCH")
+ ErrChannelsTooMuchUser = NewError(400, "Bad Request: USER_CHANNELS_TOO_MUCH")
+)
+
+// Forbidden errors
+var (
+ ErrBlockedByUser = NewError(403, "Forbidden: bot was blocked by the user")
+ ErrKickedFromGroup = NewError(403, "Forbidden: bot was kicked from the group chat")
+ ErrKickedFromSuperGroup = NewError(403, "Forbidden: bot was kicked from the supergroup chat")
+ ErrKickedFromChannel = NewError(403, "Forbidden: bot was kicked from the channel chat")
+ ErrNotStartedByUser = NewError(403, "Forbidden: bot can't initiate conversation with a user")
+ ErrUserIsDeactivated = NewError(403, "Forbidden: user is deactivated")
+)
+
+// Err returns Error instance by given description.
+func Err(s string) error {
+ switch s {
+ case ErrTooLarge.ʔ():
+ return ErrTooLarge
+ case ErrUnauthorized.ʔ():
+ return ErrUnauthorized
+ case ErrNotFound.ʔ():
+ return ErrNotFound
+ case ErrInternal.ʔ():
+ return ErrInternal
+ case ErrBadButtonData.ʔ():
+ return ErrBadButtonData
+ case ErrBadUserID.ʔ():
+ return ErrBadUserID
+ case ErrBadPollOptions.ʔ():
+ return ErrBadPollOptions
+ case ErrBadURLContent.ʔ():
+ return ErrBadURLContent
+ case ErrCantEditMessage.ʔ():
+ return ErrCantEditMessage
+ case ErrCantRemoveOwner.ʔ():
+ return ErrCantRemoveOwner
+ case ErrCantUploadFile.ʔ():
+ return ErrCantUploadFile
+ case ErrCantUseMediaInAlbum.ʔ():
+ return ErrCantUseMediaInAlbum
+ case ErrChatAboutNotModified.ʔ():
+ return ErrChatAboutNotModified
+ case ErrChatNotFound.ʔ():
+ return ErrChatNotFound
+ case ErrEmptyChatID.ʔ():
+ return ErrEmptyChatID
+ case ErrEmptyMessage.ʔ():
+ return ErrEmptyMessage
+ case ErrEmptyText.ʔ():
+ return ErrEmptyText
+ case ErrFailedImageProcess.ʔ():
+ return ErrFailedImageProcess
+ case ErrGroupMigrated.ʔ():
+ return ErrGroupMigrated
+ case ErrMessageNotModified.ʔ():
+ return ErrMessageNotModified
+ case ErrNoRightsToDelete.ʔ():
+ return ErrNoRightsToDelete
+ case ErrNoRightsToRestrict.ʔ():
+ return ErrNoRightsToRestrict
+ case ErrNoRightsToSend.ʔ():
+ return ErrNoRightsToSend
+ case ErrNoRightsToSendGifs.ʔ():
+ return ErrNoRightsToSendGifs
+ case ErrNoRightsToSendPhoto.ʔ():
+ return ErrNoRightsToSendPhoto
+ case ErrNoRightsToSendStickers.ʔ():
+ return ErrNoRightsToSendStickers
+ case ErrNotFoundToDelete.ʔ():
+ return ErrNotFoundToDelete
+ case ErrNotFoundToForward.ʔ():
+ return ErrNotFoundToForward
+ case ErrNotFoundToReply.ʔ():
+ return ErrNotFoundToReply
+ case ErrQueryTooOld.ʔ():
+ return ErrQueryTooOld
+ case ErrSameMessageContent.ʔ():
+ return ErrSameMessageContent
+ case ErrStickerEmojisInvalid.ʔ():
+ return ErrStickerEmojisInvalid
+ case ErrStickerSetInvalid.ʔ():
+ return ErrStickerSetInvalid
+ case ErrStickerSetInvalidName.ʔ():
+ return ErrStickerSetInvalidName
+ case ErrStickerSetNameOccupied.ʔ():
+ return ErrStickerSetNameOccupied
+ case ErrTooLongMarkup.ʔ():
+ return ErrTooLongMarkup
+ case ErrTooLongMessage.ʔ():
+ return ErrTooLongMessage
+ case ErrUserIsAdmin.ʔ():
+ return ErrUserIsAdmin
+ case ErrWrongFileID.ʔ():
+ return ErrWrongFileID
+ case ErrWrongFileIDCharacter.ʔ():
+ return ErrWrongFileIDCharacter
+ case ErrWrongFileIDLength.ʔ():
+ return ErrWrongFileIDLength
+ case ErrWrongFileIDPadding.ʔ():
+ return ErrWrongFileIDPadding
+ case ErrWrongFileIDSymbol.ʔ():
+ return ErrWrongFileIDSymbol
+ case ErrWrongTypeOfContent.ʔ():
+ return ErrWrongTypeOfContent
+ case ErrWrongURL.ʔ():
+ return ErrWrongURL
+ case ErrBlockedByUser.ʔ():
+ return ErrBlockedByUser
+ case ErrKickedFromGroup.ʔ():
+ return ErrKickedFromGroup
+ case ErrKickedFromSuperGroup.ʔ():
+ return ErrKickedFromSuperGroup
+ case ErrKickedFromChannel.ʔ():
+ return ErrKickedFromChannel
+ case ErrNotStartedByUser.ʔ():
+ return ErrNotStartedByUser
+ case ErrUserIsDeactivated.ʔ():
+ return ErrUserIsDeactivated
+ case ErrForwardMessage.ʔ():
+ return ErrForwardMessage
+ case ErrUserAlreadyParticipant.ʔ():
+ return ErrUserAlreadyParticipant
+ case ErrHideRequesterMissing.ʔ():
+ return ErrHideRequesterMissing
+ case ErrChannelsTooMuch.ʔ():
+ return ErrChannelsTooMuch
+ case ErrChannelsTooMuchUser.ʔ():
+ return ErrChannelsTooMuchUser
+ default:
+ return nil
+ }
+}
+
+// wrapError returns new wrapped telebot-related error.
+func wrapError(err error) error {
+ return fmt.Errorf("telebot: %w", err)
+}
diff --git a/internal/telebot/file.go b/internal/telebot/file.go
new file mode 100644
index 0000000..14c40f9
--- /dev/null
+++ b/internal/telebot/file.go
@@ -0,0 +1,87 @@
+package telebot
+
+import (
+ "io"
+ "os"
+)
+
+// File object represents any sort of file.
+type File struct {
+ FileID string `json:"file_id"`
+ UniqueID string `json:"file_unique_id"`
+ FileSize int64 `json:"file_size"`
+
+ // FilePath is used for files on Telegram server.
+ FilePath string `json:"file_path"`
+
+ // FileLocal is used for files on local file system.
+ FileLocal string `json:"file_local"`
+
+ // FileURL is used for file on the internet.
+ FileURL string `json:"file_url"`
+
+ // FileReader is used for file backed with io.Reader.
+ FileReader io.Reader `json:"-"`
+
+ fileName string
+}
+
+// FromDisk constructs a new local (on-disk) file object.
+//
+// Note, it returns File, not *File for a very good reason:
+// in telebot, File is pretty much an embeddable struct,
+// so upon uploading media you'll need to set embedded File
+// with something. NewFile() returning File makes it a one-liner.
+//
+// photo := &tele.Photo{File: tele.FromDisk("chicken.jpg")}
+//
+func FromDisk(filename string) File {
+ return File{FileLocal: filename}
+}
+
+// FromURL constructs a new file on provided HTTP URL.
+//
+// Note, it returns File, not *File for a very good reason:
+// in telebot, File is pretty much an embeddable struct,
+// so upon uploading media you'll need to set embedded File
+// with something. NewFile() returning File makes it a one-liner.
+//
+// photo := &tele.Photo{File: tele.FromURL("https://site.com/picture.jpg")}
+//
+func FromURL(url string) File {
+ return File{FileURL: url}
+}
+
+// FromReader constructs a new file from io.Reader.
+//
+// Note, it returns File, not *File for a very good reason:
+// in telebot, File is pretty much an embeddable struct,
+// so upon uploading media you'll need to set embedded File
+// with something. NewFile() returning File makes it a one-liner.
+//
+// photo := &tele.Photo{File: tele.FromReader(bytes.NewReader(...))}
+//
+func FromReader(reader io.Reader) File {
+ return File{FileReader: reader}
+}
+
+func (f *File) stealRef(g *File) {
+ if g.OnDisk() {
+ f.FileLocal = g.FileLocal
+ }
+
+ if g.FileURL != "" {
+ f.FileURL = g.FileURL
+ }
+}
+
+// InCloud tells whether the file is present on Telegram servers.
+func (f *File) InCloud() bool {
+ return f.FileID != ""
+}
+
+// OnDisk will return true if file is present on disk.
+func (f *File) OnDisk() bool {
+ _, err := os.Stat(f.FileLocal)
+ return err == nil
+}
diff --git a/internal/telebot/file_test.go b/internal/telebot/file_test.go
new file mode 100644
index 0000000..f727828
--- /dev/null
+++ b/internal/telebot/file_test.go
@@ -0,0 +1,24 @@
+package telebot
+
+import (
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFile(t *testing.T) {
+ f := FromDisk("telebot.go")
+ g := FromURL("http://")
+
+ assert.True(t, f.OnDisk())
+ assert.True(t, (&File{FileID: "1"}).InCloud())
+ assert.Equal(t, File{FileLocal: "telebot.go"}, f)
+ assert.Equal(t, File{FileURL: "http://"}, g)
+ assert.Equal(t, File{FileReader: io.Reader(nil)}, FromReader(io.Reader(nil)))
+
+ g.stealRef(&f)
+ f.stealRef(&g)
+ assert.Equal(t, g.FileLocal, f.FileLocal)
+ assert.Equal(t, f.FileURL, g.FileURL)
+}
diff --git a/internal/telebot/game.go b/internal/telebot/game.go
new file mode 100644
index 0000000..94078b3
--- /dev/null
+++ b/internal/telebot/game.go
@@ -0,0 +1,98 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+)
+
+// Game object represents a game.
+// Their short names acts as unique identifiers.
+type Game struct {
+ Name string `json:"game_short_name"`
+
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Photo *Photo `json:"photo"`
+
+ // (Optional)
+ Text string `json:"text"`
+ Entities []MessageEntity `json:"text_entities"`
+ Animation *Animation `json:"animation"`
+}
+
+// GameHighScore object represents one row
+// of the high scores table for a game.
+type GameHighScore struct {
+ User *User `json:"user"`
+ Position int `json:"position"`
+
+ Score int `json:"score"`
+ Force bool `json:"force"`
+ NoEdit bool `json:"disable_edit_message"`
+}
+
+// GameScores returns the score of the specified user
+// and several of their neighbors in a game.
+//
+// This function will panic upon nil Editable.
+//
+// Currently, it returns scores for the target user,
+// plus two of their closest neighbors on each side.
+// Will also return the top three users
+// if the user and his neighbors are not among them.
+func (b *Bot) GameScores(ctx context.Context, user Recipient, msg Editable) ([]GameHighScore, error) {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "user_id": user.Recipient(),
+ }
+
+ if chatID == 0 { // if inline message
+ params["inline_message_id"] = msgID
+ } else {
+ params["chat_id"] = strconv.FormatInt(chatID, 10)
+ params["message_id"] = msgID
+ }
+
+ data, err := b.Raw(ctx, "getGameHighScores", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []GameHighScore
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, err
+ }
+ return resp.Result, nil
+}
+
+// SetGameScore sets the score of the specified user in a game.
+//
+// If the message was sent by the bot, returns the edited Message,
+// otherwise returns nil and ErrTrueResult.
+func (b *Bot) SetGameScore(ctx context.Context, user Recipient, msg Editable, score GameHighScore) (*Message, error) {
+ msgID, chatID := msg.MessageSig()
+
+ params := map[string]string{
+ "user_id": user.Recipient(),
+ "score": strconv.Itoa(score.Score),
+ "force": strconv.FormatBool(score.Force),
+ "disable_edit_message": strconv.FormatBool(score.NoEdit),
+ }
+
+ if chatID == 0 { // if inline message
+ params["inline_message_id"] = msgID
+ } else {
+ params["chat_id"] = strconv.FormatInt(chatID, 10)
+ params["message_id"] = msgID
+ }
+
+ data, err := b.Raw(ctx, "setGameScore", params)
+ if err != nil {
+ return nil, err
+ }
+ return extractMessage(data)
+}
diff --git a/internal/telebot/inline.go b/internal/telebot/inline.go
new file mode 100644
index 0000000..2a88958
--- /dev/null
+++ b/internal/telebot/inline.go
@@ -0,0 +1,139 @@
+package telebot
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// Query is an incoming inline query. When the user sends
+// an empty query, your bot could return some default or
+// trending results.
+type Query struct {
+ // Unique identifier for this query. 1-64 bytes.
+ ID string `json:"id"`
+
+ // Sender.
+ Sender *User `json:"from"`
+
+ // Sender location, only for bots that request user location.
+ Location *Location `json:"location"`
+
+ // Text of the query (up to 512 characters).
+ Text string `json:"query"`
+
+ // Offset of the results to be returned, can be controlled by the bot.
+ Offset string `json:"offset"`
+
+ // ChatType of the type of the chat, from which the inline query was sent.
+ ChatType string `json:"chat_type"`
+}
+
+// QueryResponse builds a response to an inline Query.
+type QueryResponse struct {
+ // The ID of the query to which this is a response.
+ //
+ // Note: Telebot sets this field automatically!
+ QueryID string `json:"inline_query_id"`
+
+ // The results for the inline query.
+ Results Results `json:"results"`
+
+ // (Optional) The maximum amount of time in seconds that the result
+ // of the inline query may be cached on the server.
+ CacheTime int `json:"cache_time,omitempty"`
+
+ // (Optional) Pass True, if results may be cached on the server side
+ // only for the user that sent the query. By default, results may
+ // be returned to any user who sends the same query.
+ IsPersonal bool `json:"is_personal"`
+
+ // (Optional) Pass the offset that a client should send in the next
+ // query with the same text to receive more results. Pass an empty
+ // string if there are no more results or if you don‘t support
+ // pagination. Offset length can’t exceed 64 bytes.
+ NextOffset string `json:"next_offset"`
+
+ // (Optional) If passed, clients will display a button with specified
+ // text that switches the user to a private chat with the bot and sends
+ // the bot a start message with the parameter switch_pm_parameter.
+ SwitchPMText string `json:"switch_pm_text,omitempty"`
+
+ // (Optional) Parameter for the start message sent to the bot when user
+ // presses the switch button.
+ SwitchPMParameter string `json:"switch_pm_parameter,omitempty"`
+}
+
+// InlineResult represents a result of an inline query that was chosen
+// by the user and sent to their chat partner.
+type InlineResult struct {
+ Sender *User `json:"from"`
+ Location *Location `json:"location,omitempty"`
+ ResultID string `json:"result_id"`
+ Query string `json:"query"`
+ MessageID string `json:"inline_message_id"` // inline messages only!
+}
+
+// MessageSig satisfies Editable interface.
+func (ir *InlineResult) MessageSig() (string, int64) {
+ return ir.MessageID, 0
+}
+
+// Result represents one result of an inline query.
+type Result interface {
+ ResultID() string
+ SetResultID(string)
+ SetParseMode(ParseMode)
+ SetContent(InputMessageContent)
+ SetReplyMarkup(*ReplyMarkup)
+ Process(*Bot)
+}
+
+// Results is a slice wrapper for convenient marshalling.
+type Results []Result
+
+// MarshalJSON makes sure IQRs have proper IDs and Type variables set.
+func (results Results) MarshalJSON() ([]byte, error) {
+ for i, result := range results {
+ if result.ResultID() == "" {
+ result.SetResultID(fmt.Sprintf("%d", &results[i]))
+ }
+ if err := inferIQR(result); err != nil {
+ return nil, err
+ }
+ }
+
+ return json.Marshal([]Result(results))
+}
+
+func inferIQR(result Result) error {
+ switch r := result.(type) {
+ case *ArticleResult:
+ r.Type = "article"
+ case *AudioResult:
+ r.Type = "audio"
+ case *ContactResult:
+ r.Type = "contact"
+ case *DocumentResult:
+ r.Type = "document"
+ case *GifResult:
+ r.Type = "gif"
+ case *LocationResult:
+ r.Type = "location"
+ case *Mpeg4GifResult:
+ r.Type = "mpeg4_gif"
+ case *PhotoResult:
+ r.Type = "photo"
+ case *VenueResult:
+ r.Type = "venue"
+ case *VideoResult:
+ r.Type = "video"
+ case *VoiceResult:
+ r.Type = "voice"
+ case *StickerResult:
+ r.Type = "sticker"
+ default:
+ return fmt.Errorf("telebot: result %v is not supported", result)
+ }
+
+ return nil
+}
diff --git a/internal/telebot/inline_types.go b/internal/telebot/inline_types.go
new file mode 100644
index 0000000..d93cffc
--- /dev/null
+++ b/internal/telebot/inline_types.go
@@ -0,0 +1,373 @@
+package telebot
+
+// ResultBase must be embedded into all IQRs.
+type ResultBase struct {
+ // Unique identifier for this result, 1-64 Bytes.
+ // If left unspecified, a 64-bit FNV-1 hash will be calculated
+ ID string `json:"id"`
+
+ // Ignore. This field gets set automatically.
+ Type string `json:"type"`
+
+ // Optional. Send Markdown or HTML, if you want Telegram apps to show
+ // bold, italic, fixed-width text or inline URLs in the media caption.
+ ParseMode ParseMode `json:"parse_mode,omitempty"`
+
+ // Optional. Content of the message to be sent.
+ Content InputMessageContent `json:"input_message_content,omitempty"`
+
+ // Optional. Inline keyboard attached to the message.
+ ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
+}
+
+// ResultID returns ResultBase.ID.
+func (r *ResultBase) ResultID() string {
+ return r.ID
+}
+
+// SetResultID sets ResultBase.ID.
+func (r *ResultBase) SetResultID(id string) {
+ r.ID = id
+}
+
+// SetParseMode sets ResultBase.ParseMode.
+func (r *ResultBase) SetParseMode(mode ParseMode) {
+ r.ParseMode = mode
+}
+
+// SetContent sets ResultBase.Content.
+func (r *ResultBase) SetContent(content InputMessageContent) {
+ r.Content = content
+}
+
+// SetReplyMarkup sets ResultBase.ReplyMarkup.
+func (r *ResultBase) SetReplyMarkup(markup *ReplyMarkup) {
+ r.ReplyMarkup = markup
+}
+
+func (r *ResultBase) Process(b *Bot) {
+ if r.ParseMode == ModeDefault {
+ r.ParseMode = b.parseMode
+ }
+ if r.Content != nil {
+ c, ok := r.Content.(*InputTextMessageContent)
+ if ok && c.ParseMode == ModeDefault {
+ c.ParseMode = r.ParseMode
+ }
+ }
+ if r.ReplyMarkup != nil {
+ processButtons(r.ReplyMarkup.InlineKeyboard)
+ }
+}
+
+// ArticleResult represents a link to an article or web page.
+type ArticleResult struct {
+ ResultBase
+
+ // Title of the result.
+ Title string `json:"title"`
+
+ // Message text. Shortcut (and mutually exclusive to) specifying
+ // InputMessageContent.
+ Text string `json:"message_text,omitempty"`
+
+ // Optional. URL of the result.
+ URL string `json:"url,omitempty"`
+
+ // Optional. Pass True, if you don't want the URL to be shown in the message.
+ HideURL bool `json:"hide_url,omitempty"`
+
+ // Optional. Short description of the result.
+ Description string `json:"description,omitempty"`
+
+ // Optional. URL of the thumbnail for the result.
+ ThumbURL string `json:"thumb_url,omitempty"`
+
+ // Optional. Width of the thumbnail for the result.
+ ThumbWidth int `json:"thumb_width,omitempty"`
+
+ // Optional. Height of the thumbnail for the result.
+ ThumbHeight int `json:"thumb_height,omitempty"`
+}
+
+// AudioResult represents a link to an mp3 audio file.
+type AudioResult struct {
+ ResultBase
+
+ // Title.
+ Title string `json:"title"`
+
+ // A valid URL for the audio file.
+ URL string `json:"audio_url"`
+
+ // Optional. Performer.
+ Performer string `json:"performer,omitempty"`
+
+ // Optional. Audio duration in seconds.
+ Duration int `json:"audio_duration,omitempty"`
+
+ // Optional. Caption, 0-1024 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"audio_file_id,omitempty"`
+}
+
+// ContactResult represents a contact with a phone number.
+type ContactResult struct {
+ ResultBase
+
+ // Contact's phone number.
+ PhoneNumber string `json:"phone_number"`
+
+ // Optional. Additional data about the contact in the form of a vCard, 0-2048 bytes.
+ VCard string `json:"vcard,omitempty"`
+
+ // Contact's first name.
+ FirstName string `json:"first_name"`
+
+ // Optional. Contact's last name.
+ LastName string `json:"last_name,omitempty"`
+
+ // Optional. URL of the thumbnail for the result.
+ ThumbURL string `json:"thumb_url,omitempty"`
+
+ // Optional. Width of the thumbnail for the result.
+ ThumbWidth int `json:"thumb_width,omitempty"`
+
+ // Optional. Height of the thumbnail for the result.
+ ThumbHeight int `json:"thumb_height,omitempty"`
+}
+
+// DocumentResult represents a link to a file.
+type DocumentResult struct {
+ ResultBase
+
+ // Title for the result.
+ Title string `json:"title"`
+
+ // A valid URL for the file
+ URL string `json:"document_url"`
+
+ // Mime type of the content of the file, either “application/pdf” or
+ // “application/zip”.
+ MIME string `json:"mime_type"`
+
+ // Optional. Caption of the document to be sent, 0-200 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // Optional. Short description of the result.
+ Description string `json:"description,omitempty"`
+
+ // Optional. URL of the thumbnail (jpeg only) for the file.
+ ThumbURL string `json:"thumb_url,omitempty"`
+
+ // Optional. Width of the thumbnail for the result.
+ ThumbWidth int `json:"thumb_width,omitempty"`
+
+ // Optional. Height of the thumbnail for the result.
+ ThumbHeight int `json:"thumb_height,omitempty"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"document_file_id,omitempty"`
+}
+
+// GifResult represents a link to an animated GIF file.
+type GifResult struct {
+ ResultBase
+
+ // A valid URL for the GIF file. File size must not exceed 1MB.
+ URL string `json:"gif_url"`
+
+ // Optional. Width of the GIF.
+ Width int `json:"gif_width,omitempty"`
+
+ // Optional. Height of the GIF.
+ Height int `json:"gif_height,omitempty"`
+
+ // Optional. Duration of the GIF.
+ Duration int `json:"gif_duration,omitempty"`
+
+ // URL of the static thumbnail for the result (jpeg or gif).
+ ThumbURL string `json:"thumb_url"`
+
+ // Optional. MIME type of the thumbnail, must be one of
+ // “image/jpeg”, “image/gif”, or “video/mp4”.
+ ThumbMIME string `json:"thumb_mime_type,omitempty"`
+
+ // Optional. Title for the result.
+ Title string `json:"title,omitempty"`
+
+ // Optional. Caption of the GIF file to be sent, 0-200 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"gif_file_id,omitempty"`
+}
+
+// LocationResult represents a location on a map.
+type LocationResult struct {
+ ResultBase
+
+ Location
+
+ // Location title.
+ Title string `json:"title"`
+
+ // Optional. Url of the thumbnail for the result.
+ ThumbURL string `json:"thumb_url,omitempty"`
+}
+
+// Mpeg4GifResult represents a link to a video animation
+// (H.264/MPEG-4 AVC video without sound).
+type Mpeg4GifResult struct {
+ ResultBase
+
+ // A valid URL for the MP4 file.
+ URL string `json:"mpeg4_url"`
+
+ // Optional. Video width.
+ Width int `json:"mpeg4_width,omitempty"`
+
+ // Optional. Video height.
+ Height int `json:"mpeg4_height,omitempty"`
+
+ // Optional. Video duration.
+ Duration int `json:"mpeg4_duration,omitempty"`
+
+ // URL of the static thumbnail (jpeg or gif) for the result.
+ ThumbURL string `json:"thumb_url,omitempty"`
+
+ // Optional. MIME type of the thumbnail, must be one of
+ // “image/jpeg”, “image/gif”, or “video/mp4”.
+ ThumbMIME string `json:"thumb_mime_type,omitempty"`
+
+ // Optional. Title for the result.
+ Title string `json:"title,omitempty"`
+
+ // Optional. Caption of the MPEG-4 file to be sent, 0-200 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"mpeg4_file_id,omitempty"`
+}
+
+// PhotoResult represents a link to a photo.
+type PhotoResult struct {
+ ResultBase
+
+ // A valid URL of the photo. Photo must be in jpeg format.
+ // Photo size must not exceed 5MB.
+ URL string `json:"photo_url"`
+
+ // Optional. Width of the photo.
+ Width int `json:"photo_width,omitempty"`
+
+ // Optional. Height of the photo.
+ Height int `json:"photo_height,omitempty"`
+
+ // Optional. Title for the result.
+ Title string `json:"title,omitempty"`
+
+ // Optional. Short description of the result.
+ Description string `json:"description,omitempty"`
+
+ // Optional. Caption of the photo to be sent, 0-200 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // URL of the thumbnail for the photo.
+ ThumbURL string `json:"thumb_url"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"photo_file_id,omitempty"`
+}
+
+// VenueResult represents a venue.
+type VenueResult struct {
+ ResultBase
+
+ Location
+
+ // Title of the venue.
+ Title string `json:"title"`
+
+ // Address of the venue.
+ Address string `json:"address"`
+
+ // Optional. Foursquare identifier of the venue if known.
+ FoursquareID string `json:"foursquare_id,omitempty"`
+
+ // Optional. URL of the thumbnail for the result.
+ ThumbURL string `json:"thumb_url,omitempty"`
+
+ // Optional. Width of the thumbnail for the result.
+ ThumbWidth int `json:"thumb_width,omitempty"`
+
+ // Optional. Height of the thumbnail for the result.
+ ThumbHeight int `json:"thumb_height,omitempty"`
+}
+
+// VideoResult represents a link to a page containing an embedded
+// video player or a video file.
+type VideoResult struct {
+ ResultBase
+
+ // A valid URL for the embedded video player or video file.
+ URL string `json:"video_url"`
+
+ // Mime type of the content of video url, “text/html” or “video/mp4”.
+ MIME string `json:"mime_type"`
+
+ // URL of the thumbnail (jpeg only) for the video.
+ ThumbURL string `json:"thumb_url"`
+
+ // Title for the result.
+ Title string `json:"title"`
+
+ // Optional. Caption of the video to be sent, 0-200 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // Optional. Video width.
+ Width int `json:"video_width,omitempty"`
+
+ // Optional. Video height.
+ Height int `json:"video_height,omitempty"`
+
+ // Optional. Video duration in seconds.
+ Duration int `json:"video_duration,omitempty"`
+
+ // Optional. Short description of the result.
+ Description string `json:"description,omitempty"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"video_file_id,omitempty"`
+}
+
+// VoiceResult represents a link to a voice recording in an .ogg
+// container encoded with OPUS.
+type VoiceResult struct {
+ ResultBase
+
+ // A valid URL for the voice recording.
+ URL string `json:"voice_url"`
+
+ // Recording title.
+ Title string `json:"title"`
+
+ // Optional. Recording duration in seconds.
+ Duration int `json:"voice_duration"`
+
+ // Optional. Caption, 0-1024 characters.
+ Caption string `json:"caption,omitempty"`
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"voice_file_id,omitempty"`
+}
+
+// StickerResult represents an inline cached sticker response.
+type StickerResult struct {
+ ResultBase
+
+ // If Cache != "", it'll be used instead
+ Cache string `json:"sticker_file_id,omitempty"`
+}
diff --git a/internal/telebot/input_types.go b/internal/telebot/input_types.go
new file mode 100644
index 0000000..8186c07
--- /dev/null
+++ b/internal/telebot/input_types.go
@@ -0,0 +1,73 @@
+package telebot
+
+// InputMessageContent objects represent the content of a message to be sent
+// as a result of an inline query.
+type InputMessageContent interface {
+ IsInputMessageContent() bool
+}
+
+// InputTextMessageContent represents the content of a text message to be
+// sent as the result of an inline query.
+type InputTextMessageContent struct {
+ // Text of the message to be sent, 1-4096 characters.
+ Text string `json:"message_text"`
+
+ // Optional. Send Markdown or HTML, if you want Telegram apps to show
+ // bold, italic, fixed-width text or inline URLs in your bot's message.
+ ParseMode string `json:"parse_mode,omitempty"`
+
+ // Optional. Disables link previews for links in the sent message.
+ DisablePreview bool `json:"disable_web_page_preview"`
+}
+
+func (input *InputTextMessageContent) IsInputMessageContent() bool {
+ return true
+}
+
+// InputLocationMessageContent represents the content of a location message
+// to be sent as the result of an inline query.
+type InputLocationMessageContent struct {
+ Lat float32 `json:"latitude"`
+ Lng float32 `json:"longitude"`
+}
+
+func (input *InputLocationMessageContent) IsInputMessageContent() bool {
+ return true
+}
+
+// InputVenueMessageContent represents the content of a venue message to
+// be sent as the result of an inline query.
+type InputVenueMessageContent struct {
+ Lat float32 `json:"latitude"`
+ Lng float32 `json:"longitude"`
+
+ // Name of the venue.
+ Title string `json:"title"`
+
+ // Address of the venue.
+ Address string `json:"address"`
+
+ // Optional. Foursquare identifier of the venue, if known.
+ FoursquareID string `json:"foursquare_id,omitempty"`
+}
+
+func (input *InputVenueMessageContent) IsInputMessageContent() bool {
+ return true
+}
+
+// InputContactMessageContent represents the content of a contact
+// message to be sent as the result of an inline query.
+type InputContactMessageContent struct {
+ // Contact's phone number.
+ PhoneNumber string `json:"phone_number"`
+
+ // Contact's first name.
+ FirstName string `json:"first_name"`
+
+ // Optional. Contact's last name.
+ LastName string `json:"last_name,omitempty"`
+}
+
+func (input *InputContactMessageContent) IsInputMessageContent() bool {
+ return true
+}
diff --git a/internal/telebot/layout/config.go b/internal/telebot/layout/config.go
new file mode 100644
index 0000000..0704ea4
--- /dev/null
+++ b/internal/telebot/layout/config.go
@@ -0,0 +1,122 @@
+package layout
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/spf13/viper"
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+// Config represents typed map interface related to the "config" section in layout.
+type Config struct {
+ v *viper.Viper
+}
+
+// Unmarshal parses the whole config into the out value. It's useful when you want to
+// describe and to pre-define the fields in your custom configuration struct.
+func (c *Config) Unmarshal(v interface{}) error {
+ return c.v.Unmarshal(v)
+}
+
+// UnmarshalKey parses the specific key in the config into the out value.
+func (c *Config) UnmarshalKey(k string, v interface{}) error {
+ return c.v.UnmarshalKey(k, v)
+}
+
+// Get returns a child map field wrapped into Config.
+// If the field isn't a map, returns nil.
+func (c *Config) Get(k string) *Config {
+ v := c.v.Sub(k)
+ if v == nil {
+ return nil
+ }
+ return &Config{v: v}
+}
+
+// Slice returns a child slice of objects wrapped into Config.
+// If the field isn't a slice, returns nil.
+func (c *Config) Slice(k string) (slice []*Config) {
+ a, ok := c.v.Get(k).([]interface{})
+ if !ok {
+ return nil
+ }
+
+ for i := range a {
+ m, ok := a[i].(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ v := viper.New()
+ v.MergeConfigMap(m)
+ slice = append(slice, &Config{v: v})
+ }
+
+ return
+}
+
+// String returns a field casted to the string.
+func (c *Config) String(k string) string {
+ return c.v.GetString(k)
+}
+
+// Int returns a field casted to the int.
+func (c *Config) Int(k string) int {
+ return c.v.GetInt(k)
+}
+
+// Int64 returns a field casted to the int64.
+func (c *Config) Int64(k string) int64 {
+ return c.v.GetInt64(k)
+}
+
+// Float returns a field casted to the float64.
+func (c *Config) Float(k string) float64 {
+ return c.v.GetFloat64(k)
+}
+
+// Bool returns a field casted to the bool.
+func (c *Config) Bool(k string) bool {
+ return c.v.GetBool(k)
+}
+
+// Duration returns a field casted to the time.Duration.
+// Accepts number-represented duration or a string in 0nsuµmh format.
+func (c *Config) Duration(k string) time.Duration {
+ return c.v.GetDuration(k)
+}
+
+// ChatID returns a field casted to the ChatID.
+// The value must be an integer.
+func (c *Config) ChatID(k string) tele.ChatID {
+ return tele.ChatID(c.Int64(k))
+}
+
+// Strings returns a field casted to the string slice.
+func (c *Config) Strings(k string) []string {
+ return c.v.GetStringSlice(k)
+}
+
+// Ints returns a field casted to the int slice.
+func (c *Config) Ints(k string) []int {
+ return c.v.GetIntSlice(k)
+}
+
+// Int64s returns a field casted to the int64 slice.
+func (c *Config) Int64s(k string) (ints []int64) {
+ for _, s := range c.Strings(k) {
+ i, _ := strconv.ParseInt(s, 10, 64)
+ ints = append(ints, i)
+ }
+ return ints
+}
+
+// Floats returns a field casted to the float slice.
+func (c *Config) Floats(k string) (floats []float64) {
+ for _, s := range c.Strings(k) {
+ i, _ := strconv.ParseFloat(s, 64)
+ floats = append(floats, i)
+ }
+ return floats
+}
diff --git a/internal/telebot/layout/default.go b/internal/telebot/layout/default.go
new file mode 100644
index 0000000..2ccba11
--- /dev/null
+++ b/internal/telebot/layout/default.go
@@ -0,0 +1,43 @@
+package layout
+
+import (
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+// DefaultLayout is a simplified layout instance with pre-defined locale by default.
+type DefaultLayout struct {
+ locale string
+ lt *Layout
+
+ Config
+}
+
+// Settings returns layout settings.
+func (dlt *DefaultLayout) Settings() tele.Settings {
+ return dlt.lt.Settings()
+}
+
+// Text wraps localized layout function Text using your default locale.
+func (dlt *DefaultLayout) Text(k string, args ...interface{}) string {
+ return dlt.lt.TextLocale(dlt.locale, k, args...)
+}
+
+// Callback returns a callback endpoint used to handle buttons.
+func (dlt *DefaultLayout) Callback(k string) tele.CallbackEndpoint {
+ return dlt.lt.Callback(k)
+}
+
+// Button wraps localized layout function Button using your default locale.
+func (dlt *DefaultLayout) Button(k string, args ...interface{}) *tele.Btn {
+ return dlt.lt.ButtonLocale(dlt.locale, k, args...)
+}
+
+// Markup wraps localized layout function Markup using your default locale.
+func (dlt *DefaultLayout) Markup(k string, args ...interface{}) *tele.ReplyMarkup {
+ return dlt.lt.MarkupLocale(dlt.locale, k, args...)
+}
+
+// Result wraps localized layout function Result using your default locale.
+func (dlt *DefaultLayout) Result(k string, args ...interface{}) tele.Result {
+ return dlt.lt.ResultLocale(dlt.locale, k, args...)
+}
diff --git a/internal/telebot/layout/example.yml b/internal/telebot/layout/example.yml
new file mode 100644
index 0000000..a7f9c79
--- /dev/null
+++ b/internal/telebot/layout/example.yml
@@ -0,0 +1,76 @@
+settings:
+ token_env: TOKEN
+ parse_mode: html
+ long_poller: {}
+
+commands:
+ /start: Start the bot
+ /help: How to use the bot
+
+config:
+ str: string
+ num: 123
+ strs:
+ - abc
+ - def
+ nums:
+ - 123
+ - 456
+ obj: &obj
+ dur: 10m
+ arr:
+ - <<: *obj
+ - <<: *obj
+
+
+buttons:
+ # Shortened reply buttons
+ help: Help
+ settings: Settings
+
+ # Extended reply button
+ contact:
+ text: Send a contact
+ request_contact: true
+
+ # Inline button
+ stop:
+ unique: stop
+ text: Stop
+ data: '{{.}}'
+
+ # Callback data
+ pay:
+ unique: pay
+ text: Pay
+ data:
+ - '{{ .UserID }}'
+ - '{{ .Amount }}'
+ - '{{ .Currency }}'
+
+ web_app:
+ text: This is a web app
+ web_app:
+ url: https://google.com
+
+markups:
+ reply_shortened:
+ - [ help ]
+ - [ settings ]
+ reply_extended:
+ keyboard:
+ - [ contact ]
+ one_time_keyboard: true
+ inline:
+ - [ stop ]
+ web_app:
+ - [ web_app ]
+
+results:
+ article:
+ type: article
+ id: '{{ .ID }}'
+ title: '{{ .Title }}'
+ description: '{{ .Description }}'
+ thumb_url: '{{ .PreviewURL }}'
+ message_text: '{{ text `article_message` }}'
diff --git a/internal/telebot/layout/layout.go b/internal/telebot/layout/layout.go
new file mode 100644
index 0000000..1237957
--- /dev/null
+++ b/internal/telebot/layout/layout.go
@@ -0,0 +1,579 @@
+package layout
+
+import (
+ "bytes"
+ "encoding/json"
+ "io/ioutil"
+ "log"
+ "strings"
+ "sync"
+ "text/template"
+
+ "github.com/goccy/go-yaml"
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+type (
+ // Layout provides an interface to interact with the layout,
+ // parsed from the config file and locales.
+ Layout struct {
+ pref *tele.Settings
+ mu sync.RWMutex // protects ctxs
+ ctxs map[tele.Context]string
+ funcs template.FuncMap
+
+ commands map[string]string
+ buttons map[string]Button
+ markups map[string]Markup
+ results map[string]Result
+ locales map[string]*template.Template
+
+ Config
+ }
+
+ // Button is a shortcut for tele.Btn.
+ Button struct {
+ tele.Btn `yaml:",inline"`
+ Data interface{} `yaml:"data"`
+ IsReply bool `yaml:"reply"`
+ }
+
+ // Markup represents layout-specific markup to be parsed.
+ Markup struct {
+ inline *bool
+ keyboard *template.Template
+ ResizeKeyboard *bool `yaml:"resize_keyboard,omitempty"` // nil == true
+ ForceReply bool `yaml:"force_reply,omitempty"`
+ OneTimeKeyboard bool `yaml:"one_time_keyboard,omitempty"`
+ RemoveKeyboard bool `yaml:"remove_keyboard,omitempty"`
+ Selective bool `yaml:"selective,omitempty"`
+ }
+
+ // Result represents layout-specific result to be parsed.
+ Result struct {
+ result *template.Template
+ tele.ResultBase `yaml:",inline"`
+ Content ResultContent `yaml:"content"`
+ Markup string `yaml:"markup"`
+ }
+
+ // ResultBase represents layout-specific result's base to be parsed.
+ ResultBase struct {
+ tele.ResultBase `yaml:",inline"`
+ Content ResultContent `yaml:"content"`
+ }
+
+ // ResultContent represents any kind of InputMessageContent and implements it.
+ ResultContent map[string]interface{}
+)
+
+// New parses the given layout file.
+func New(path string, funcs ...template.FuncMap) (*Layout, error) {
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ lt := Layout{
+ ctxs: make(map[tele.Context]string),
+ funcs: make(template.FuncMap),
+ }
+
+ for k, v := range builtinFuncs {
+ lt.funcs[k] = v
+ }
+ for i := range funcs {
+ for k, v := range funcs[i] {
+ lt.funcs[k] = v
+ }
+ }
+
+ return <, yaml.Unmarshal(data, <)
+}
+
+// NewDefault parses the given layout file without localization features.
+// See Layout.Default for more details.
+func NewDefault(path, locale string, funcs ...template.FuncMap) (*DefaultLayout, error) {
+ lt, err := New(path, funcs...)
+ if err != nil {
+ return nil, err
+ }
+ return lt.Default(locale), nil
+}
+
+var builtinFuncs = template.FuncMap{
+ // Built-in blank and helper functions.
+ "locale": func() string { return "" },
+ "config": func(string) string { return "" },
+ "text": func(string, ...interface{}) string { return "" },
+}
+
+// Settings returns built telebot Settings required for bot initializing.
+//
+// settings:
+// url: (custom url if needed)
+// token: (not recommended)
+// updates: (chan capacity)
+// locales_dir: (optional)
+// token_env: (token env var name, example: TOKEN)
+// parse_mode: (default parse mode)
+// long_poller: (long poller settings)
+// webhook: (or webhook settings)
+//
+// Usage:
+//
+// lt, err := layout.New("bot.yml")
+// b, err := tele.NewBot(lt.Settings())
+// // That's all!
+func (lt *Layout) Settings() tele.Settings {
+ if lt.pref == nil {
+ panic("telebot/layout: settings is empty")
+ }
+ return *lt.pref
+}
+
+// Default returns a simplified layout instance with the pre-defined locale.
+// It's useful when you have no need for localization and don't want to pass
+// context each time you use layout functions.
+func (lt *Layout) Default(locale string) *DefaultLayout {
+ return &DefaultLayout{
+ locale: locale,
+ lt: lt,
+ Config: lt.Config,
+ }
+}
+
+// Locales returns all presented locales.
+func (lt *Layout) Locales() []string {
+ var keys []string
+ for k := range lt.locales {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+// Locale returns the context locale.
+func (lt *Layout) Locale(c tele.Context) (string, bool) {
+ lt.mu.RLock()
+ defer lt.mu.RUnlock()
+ locale, ok := lt.ctxs[c]
+ return locale, ok
+}
+
+// SetLocale allows you to change a locale for the passed context.
+func (lt *Layout) SetLocale(c tele.Context, locale string) {
+ lt.mu.Lock()
+ lt.ctxs[c] = locale
+ lt.mu.Unlock()
+}
+
+// Commands returns a list of telebot commands, which can be
+// used in b.SetCommands later.
+func (lt *Layout) Commands() (cmds []tele.Command) {
+ for k, v := range lt.commands {
+ cmds = append(cmds, tele.Command{
+ Text: strings.TrimLeft(k, "/"),
+ Description: v,
+ })
+ }
+ return
+}
+
+// CommandsLocale returns a list of telebot commands and localized description, which can be
+// used in b.SetCommands later.
+//
+// Example of bot.yml:
+//
+// commands:
+// /start: '{{ text `cmdStart` }}'
+//
+// en.yml:
+//
+// cmdStart: Start the bot
+//
+// ru.yml:
+//
+// cmdStart: Запуск бота
+//
+// Usage:
+//
+// b.SetCommands(lt.CommandsLocale("en"), "en")
+// b.SetCommands(lt.CommandsLocale("ru"), "ru")
+func (lt *Layout) CommandsLocale(locale string, args ...interface{}) (cmds []tele.Command) {
+ var arg interface{}
+ if len(args) > 0 {
+ arg = args[0]
+ }
+
+ for k, v := range lt.commands {
+ tmpl, err := lt.template(template.New(k).Funcs(lt.funcs), locale).Parse(v)
+ if err != nil {
+ log.Println("telebot/layout:", err)
+ return nil
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, arg); err != nil {
+ log.Println("telebot/layout:", err)
+ return nil
+ }
+
+ cmds = append(cmds, tele.Command{
+ Text: strings.TrimLeft(k, "/"),
+ Description: buf.String(),
+ })
+ }
+ return
+}
+
+// Text returns a text, which locale is dependent on the context.
+// The given optional argument will be passed to the template engine.
+//
+// Example of en.yml:
+//
+// start: Hi, {{.FirstName}}!
+//
+// Usage:
+//
+// func onStart(c tele.Context) error {
+// return c.Send(lt.Text(c, "start", c.Sender()))
+// }
+func (lt *Layout) Text(c tele.Context, k string, args ...interface{}) string {
+ locale, ok := lt.Locale(c)
+ if !ok {
+ return ""
+ }
+
+ return lt.TextLocale(locale, k, args...)
+}
+
+// TextLocale returns a localized text processed with text/template engine.
+// See Text for more details.
+func (lt *Layout) TextLocale(locale, k string, args ...interface{}) string {
+ tmpl, ok := lt.locales[locale]
+ if !ok {
+ return ""
+ }
+
+ var arg interface{}
+ if len(args) > 0 {
+ arg = args[0]
+ }
+
+ var buf bytes.Buffer
+ if err := lt.template(tmpl, locale).ExecuteTemplate(&buf, k, arg); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+
+ return buf.String()
+}
+
+// Callback returns a callback endpoint used to handle buttons.
+//
+// Example:
+//
+// // Handling settings button
+// b.Handle(lt.Callback("settings"), onSettings)
+func (lt *Layout) Callback(k string) tele.CallbackEndpoint {
+ btn, ok := lt.buttons[k]
+ if !ok {
+ return nil
+ }
+ return &btn
+}
+
+// Button returns a button, which locale is dependent on the context.
+// The given optional argument will be passed to the template engine.
+//
+// buttons:
+// item:
+// unique: item
+// callback_data: {{.ID}}
+// text: Item #{{.Number}}
+//
+// Usage:
+//
+// btns := make([]tele.Btn, len(items))
+// for i, item := range items {
+// btns[i] = lt.Button(c, "item", struct {
+// Number int
+// Item Item
+// }{
+// Number: i,
+// Item: item,
+// })
+// }
+//
+// m := b.NewMarkup()
+// m.Inline(m.Row(btns...))
+// // Your generated markup is ready.
+func (lt *Layout) Button(c tele.Context, k string, args ...interface{}) *tele.Btn {
+ locale, ok := lt.Locale(c)
+ if !ok {
+ return nil
+ }
+
+ return lt.ButtonLocale(locale, k, args...)
+}
+
+// ButtonLocale returns a localized button processed with text/template engine.
+// See Button for more details.
+func (lt *Layout) ButtonLocale(locale, k string, args ...interface{}) *tele.Btn {
+ btn, ok := lt.buttons[k]
+ if !ok {
+ return nil
+ }
+
+ var arg interface{}
+ if len(args) > 0 {
+ arg = args[0]
+ }
+
+ data, err := yaml.Marshal(btn)
+ if err != nil {
+ log.Println("telebot/layout:", err)
+ return nil
+ }
+
+ tmpl, err := lt.template(template.New(k).Funcs(lt.funcs), locale).Parse(string(data))
+ if err != nil {
+ log.Println("telebot/layout:", err)
+ return nil
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, arg); err != nil {
+ log.Println("telebot/layout:", err)
+ return nil
+ }
+
+ if err := yaml.Unmarshal(buf.Bytes(), &btn); err != nil {
+ log.Println("telebot/layout:", err)
+ return nil
+ }
+
+ return &btn.Btn
+}
+
+// Markup returns a markup, which locale is dependent on the context.
+// The given optional argument will be passed to the template engine.
+//
+// buttons:
+// settings: 'Settings'
+// markups:
+// menu:
+// - [settings]
+//
+// Usage:
+//
+// func onStart(c tele.Context) error {
+// return c.Send(
+// lt.Text(c, "start"),
+// lt.Markup(c, "menu"),
+// )
+// }
+func (lt *Layout) Markup(c tele.Context, k string, args ...interface{}) *tele.ReplyMarkup {
+ locale, ok := lt.Locale(c)
+ if !ok {
+ return nil
+ }
+
+ return lt.MarkupLocale(locale, k, args...)
+}
+
+// MarkupLocale returns a localized markup processed with text/template engine.
+// See Markup for more details.
+func (lt *Layout) MarkupLocale(locale, k string, args ...interface{}) *tele.ReplyMarkup {
+ markup, ok := lt.markups[k]
+ if !ok {
+ return nil
+ }
+
+ var arg interface{}
+ if len(args) > 0 {
+ arg = args[0]
+ }
+
+ var buf bytes.Buffer
+ if err := lt.template(markup.keyboard, locale).Execute(&buf, arg); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+
+ r := &tele.ReplyMarkup{}
+ if *markup.inline {
+ if err := yaml.Unmarshal(buf.Bytes(), &r.InlineKeyboard); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ } else {
+ r.ResizeKeyboard = markup.ResizeKeyboard == nil || *markup.ResizeKeyboard
+ r.ForceReply = markup.ForceReply
+ r.OneTimeKeyboard = markup.OneTimeKeyboard
+ r.RemoveKeyboard = markup.RemoveKeyboard
+ r.Selective = markup.Selective
+
+ if err := yaml.Unmarshal(buf.Bytes(), &r.ReplyKeyboard); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ }
+
+ return r
+}
+
+// Result returns an inline result, which locale is dependent on the context.
+// The given optional argument will be passed to the template engine.
+//
+// results:
+// article:
+// type: article
+// id: '{{ .ID }}'
+// title: '{{ .Title }}'
+// description: '{{ .Description }}'
+// message_text: '{{ .Content }}'
+// thumb_url: '{{ .PreviewURL }}'
+//
+// Usage:
+//
+// func onQuery(c tele.Context) error {
+// results := make(tele.Results, len(articles))
+// for i, article := range articles {
+// results[i] = lt.Result(c, "article", article)
+// }
+// return c.Answer(&tele.QueryResponse{
+// Results: results,
+// CacheTime: 100,
+// })
+// }
+func (lt *Layout) Result(c tele.Context, k string, args ...interface{}) tele.Result {
+ locale, ok := lt.Locale(c)
+ if !ok {
+ return nil
+ }
+
+ return lt.ResultLocale(locale, k, args...)
+}
+
+// ResultLocale returns a localized result processed with text/template engine.
+// See Result for more details.
+func (lt *Layout) ResultLocale(locale, k string, args ...interface{}) tele.Result {
+ result, ok := lt.results[k]
+ if !ok {
+ return nil
+ }
+
+ var arg interface{}
+ if len(args) > 0 {
+ arg = args[0]
+ }
+
+ var buf bytes.Buffer
+ if err := lt.template(result.result, locale).Execute(&buf, arg); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+
+ var (
+ data = buf.Bytes()
+ base Result
+ r tele.Result
+ )
+
+ if err := yaml.Unmarshal(data, &base); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+
+ switch base.Type {
+ case "article":
+ r = &tele.ArticleResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "audio":
+ r = &tele.AudioResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "contact":
+ r = &tele.ContactResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "document":
+ r = &tele.DocumentResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "gif":
+ r = &tele.GifResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "location":
+ r = &tele.LocationResult{ResultBase: base.ResultBase}
+ if err := json.Unmarshal(data, &r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "mpeg4_gif":
+ r = &tele.Mpeg4GifResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "photo":
+ r = &tele.PhotoResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "venue":
+ r = &tele.VenueResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "video":
+ r = &tele.VideoResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "voice":
+ r = &tele.VoiceResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ case "sticker":
+ r = &tele.StickerResult{ResultBase: base.ResultBase}
+ if err := yaml.Unmarshal(data, r); err != nil {
+ log.Println("telebot/layout:", err)
+ }
+ default:
+ log.Println("telebot/layout: unsupported inline result type")
+ return nil
+ }
+
+ if base.Content != nil {
+ r.SetContent(base.Content)
+ }
+
+ if result.Markup != "" {
+ markup := lt.MarkupLocale(locale, result.Markup, args...)
+ if markup == nil {
+ log.Printf("telebot/layout: markup with name %s was not found\n", result.Markup)
+ } else {
+ r.SetReplyMarkup(markup)
+ }
+ }
+
+ return r
+}
+
+func (lt *Layout) template(tmpl *template.Template, locale string) *template.Template {
+ funcs := make(template.FuncMap)
+
+ // Redefining built-in blank functions
+ funcs["config"] = lt.String
+ funcs["text"] = func(k string, args ...interface{}) string { return lt.TextLocale(locale, k, args...) }
+ funcs["locale"] = func() string { return locale }
+
+ return tmpl.Funcs(funcs)
+}
+
+// IsInputMessageContent implements telebot.InputMessageContent.
+func (ResultContent) IsInputMessageContent() bool {
+ return true
+}
diff --git a/internal/telebot/layout/layout_test.go b/internal/telebot/layout/layout_test.go
new file mode 100644
index 0000000..a06225f
--- /dev/null
+++ b/internal/telebot/layout/layout_test.go
@@ -0,0 +1,125 @@
+package layout
+
+import (
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+func TestLayout(t *testing.T) {
+ os.Setenv("TOKEN", "TEST")
+
+ lt, err := New("example.yml")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ pref := lt.Settings()
+ assert.Equal(t, "TEST", pref.Token)
+ assert.Equal(t, "html", pref.ParseMode)
+ assert.Equal(t, &tele.LongPoller{}, pref.Poller)
+
+ assert.ElementsMatch(t, []tele.Command{{
+ Text: "start",
+ Description: "Start the bot",
+ }, {
+ Text: "help",
+ Description: "How to use the bot",
+ }}, lt.Commands())
+
+ assert.Equal(t, "string", lt.String("str"))
+ assert.Equal(t, 123, lt.Int("num"))
+ assert.Equal(t, int64(123), lt.Int64("num"))
+ assert.Equal(t, float64(123), lt.Float("num"))
+ assert.Equal(t, tele.ChatID(123), lt.ChatID("num"))
+
+ assert.Equal(t, []string{"abc", "def"}, lt.Strings("strs"))
+ assert.Equal(t, []int{123, 456}, lt.Ints("nums"))
+ assert.Equal(t, []int64{123, 456}, lt.Int64s("nums"))
+ assert.Equal(t, []float64{123, 456}, lt.Floats("nums"))
+
+ obj := lt.Get("obj")
+ assert.NotNil(t, obj)
+
+ const dur = 10 * time.Minute
+ assert.Equal(t, dur, obj.Duration("dur"))
+ assert.True(t, lt.Duration("obj.dur") == obj.Duration("dur"))
+
+ arr := lt.Slice("arr")
+ assert.Len(t, arr, 2)
+
+ for _, v := range arr {
+ assert.Equal(t, dur, v.Duration("dur"))
+ }
+
+ assert.Equal(t, &tele.Btn{
+ Unique: "pay",
+ Text: "Pay",
+ Data: "1|100.00|USD",
+ }, lt.ButtonLocale("en", "pay", struct {
+ UserID int
+ Amount string
+ Currency string
+ }{
+ UserID: 1,
+ Amount: "100.00",
+ Currency: "USD",
+ }))
+
+ assert.Equal(t, &tele.ReplyMarkup{
+ ReplyKeyboard: [][]tele.ReplyButton{
+ {{Text: "Help"}},
+ {{Text: "Settings"}},
+ },
+ ResizeKeyboard: true,
+ }, lt.MarkupLocale("en", "reply_shortened"))
+
+ assert.Equal(t, &tele.ReplyMarkup{
+ ReplyKeyboard: [][]tele.ReplyButton{{{Text: "Send a contact", Contact: true}}},
+ ResizeKeyboard: true,
+ OneTimeKeyboard: true,
+ }, lt.MarkupLocale("en", "reply_extended"))
+
+ assert.Equal(t, &tele.ReplyMarkup{
+ InlineKeyboard: [][]tele.InlineButton{{
+ {
+ Unique: "stop",
+ Text: "Stop",
+ Data: "1",
+ },
+ }},
+ }, lt.MarkupLocale("en", "inline", 1))
+
+ assert.Equal(t, &tele.ReplyMarkup{
+ InlineKeyboard: [][]tele.InlineButton{{
+ {
+ Text: "This is a web app",
+ WebApp: &tele.WebApp{URL: "https://google.com"},
+ },
+ }},
+ }, lt.MarkupLocale("en", "web_app"))
+
+ assert.Equal(t, &tele.ArticleResult{
+ ResultBase: tele.ResultBase{
+ ID: "1853",
+ Type: "article",
+ },
+ Title: "Some title",
+ Description: "Some description",
+ ThumbURL: "https://preview.picture",
+ Text: "This is an article.",
+ }, lt.ResultLocale("en", "article", struct {
+ ID int
+ Title string
+ Description string
+ PreviewURL string
+ }{
+ ID: 1853,
+ Title: "Some title",
+ Description: "Some description",
+ PreviewURL: "https://preview.picture",
+ }))
+}
diff --git a/internal/telebot/layout/locales/en.yml b/internal/telebot/layout/locales/en.yml
new file mode 100644
index 0000000..e3bfee9
--- /dev/null
+++ b/internal/telebot/layout/locales/en.yml
@@ -0,0 +1 @@
+article_message: This is an article.
\ No newline at end of file
diff --git a/internal/telebot/layout/middleware.go b/internal/telebot/layout/middleware.go
new file mode 100644
index 0000000..dc46eeb
--- /dev/null
+++ b/internal/telebot/layout/middleware.go
@@ -0,0 +1,50 @@
+package layout
+
+import (
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+// LocaleFunc is the function used to fetch the locale of the recipient.
+// Returned locale will be remembered and linked to the corresponding context.
+type LocaleFunc func(tele.Recipient) string
+
+// Middleware builds a telebot middleware to make localization work.
+//
+// Usage:
+//
+// b.Use(lt.Middleware("en", func(r tele.Recipient) string {
+// loc, _ := db.UserLocale(r.Recipient())
+// return loc
+// }))
+func (lt *Layout) Middleware(defaultLocale string, localeFunc ...LocaleFunc) tele.MiddlewareFunc {
+ var f LocaleFunc
+ if len(localeFunc) > 0 {
+ f = localeFunc[0]
+ }
+
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return func(c tele.Context) error {
+ locale := defaultLocale
+ if f != nil {
+ if l := f(c.Sender()); l != "" {
+ locale = l
+ }
+ }
+
+ lt.SetLocale(c, locale)
+
+ defer func() {
+ lt.mu.Lock()
+ delete(lt.ctxs, c)
+ lt.mu.Unlock()
+ }()
+
+ return next(c)
+ }
+ }
+}
+
+// Middleware wraps ordinary layout middleware with your default locale.
+func (dlt *DefaultLayout) Middleware() tele.MiddlewareFunc {
+ return dlt.lt.Middleware(dlt.locale)
+}
diff --git a/internal/telebot/layout/parser.go b/internal/telebot/layout/parser.go
new file mode 100644
index 0000000..32e480a
--- /dev/null
+++ b/internal/telebot/layout/parser.go
@@ -0,0 +1,268 @@
+package layout
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+ "text/template"
+
+ "github.com/goccy/go-yaml"
+ "github.com/spf13/viper"
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+type Settings struct {
+ URL string
+ Token string
+ Updates int
+
+ LocalesDir string `yaml:"locales_dir"`
+ TokenEnv string `yaml:"token_env"`
+ ParseMode string `yaml:"parse_mode"`
+
+ Webhook *tele.Webhook `yaml:"webhook"`
+ LongPoller *tele.LongPoller `yaml:"long_poller"`
+}
+
+func (lt *Layout) UnmarshalYAML(data []byte) error {
+ var aux struct {
+ Settings *Settings
+ Config map[string]interface{}
+ Commands map[string]string
+ Buttons yaml.MapSlice
+ Markups yaml.MapSlice
+ Results yaml.MapSlice
+ Locales map[string]map[string]string
+ }
+ if err := yaml.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ v := viper.New()
+ if err := v.MergeConfigMap(aux.Config); err != nil {
+ return err
+ }
+
+ lt.Config = Config{v: v}
+ lt.commands = aux.Commands
+
+ if pref := aux.Settings; pref != nil {
+ lt.pref = &tele.Settings{
+ URL: pref.URL,
+ Token: pref.Token,
+ Updates: pref.Updates,
+ ParseMode: pref.ParseMode,
+ }
+
+ if pref.TokenEnv != "" {
+ lt.pref.Token = os.Getenv(pref.TokenEnv)
+ }
+
+ if pref.Webhook != nil {
+ lt.pref.Poller = pref.Webhook
+ } else if pref.LongPoller != nil {
+ lt.pref.Poller = pref.LongPoller
+ }
+ }
+
+ lt.buttons = make(map[string]Button, len(aux.Buttons))
+ for _, item := range aux.Buttons {
+ k, v := item.Key.(string), item.Value
+
+ // 1. Shortened reply button
+
+ if v, ok := v.(string); ok {
+ btn := tele.Btn{Text: v}
+ lt.buttons[k] = Button{Btn: btn}
+ continue
+ }
+
+ // 2. Extended reply or inline button
+
+ data, err := yaml.MarshalWithOptions(v, yaml.JSON())
+ if err != nil {
+ return err
+ }
+
+ var btn Button
+ if err := yaml.Unmarshal(data, &btn); err != nil {
+ return err
+ }
+
+ if !btn.IsReply && btn.Data != nil {
+ if a, ok := btn.Data.([]interface{}); ok {
+ s := make([]string, len(a))
+ for i, v := range a {
+ s[i] = fmt.Sprint(v)
+ }
+ btn.Btn.Data = strings.Join(s, "|")
+ } else if s, ok := btn.Data.(string); ok {
+ btn.Btn.Data = s
+ } else {
+ return fmt.Errorf("telebot/layout: invalid callback_data for %s button", k)
+ }
+ }
+
+ lt.buttons[k] = btn
+ }
+
+ lt.markups = make(map[string]Markup, len(aux.Markups))
+ for _, item := range aux.Markups {
+ k, v := item.Key.(string), item.Value
+
+ data, err := yaml.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ var shortenedMarkup [][]string
+ if yaml.Unmarshal(data, &shortenedMarkup) == nil {
+ // 1. Shortened reply or inline markup
+
+ kb := make([][]Button, len(shortenedMarkup))
+ for i, btns := range shortenedMarkup {
+ row := make([]Button, len(btns))
+ for j, btn := range btns {
+ b, ok := lt.buttons[btn]
+ if !ok {
+ return fmt.Errorf("telebot/layout: no %s button for %s markup", btn, k)
+ }
+ row[j] = b
+ }
+ kb[i] = row
+ }
+
+ data, err := yaml.Marshal(kb)
+ if err != nil {
+ return err
+ }
+
+ tmpl, err := template.New(k).Funcs(lt.funcs).Parse(string(data))
+ if err != nil {
+ return err
+ }
+
+ markup := Markup{keyboard: tmpl}
+ for _, row := range kb {
+ for _, btn := range row {
+ inline := btn.URL != "" ||
+ btn.Unique != "" ||
+ btn.InlineQuery != "" ||
+ btn.InlineQueryChat != "" ||
+ btn.Login != nil ||
+ btn.WebApp != nil
+ inline = !btn.IsReply && inline
+
+ if markup.inline == nil {
+ markup.inline = &inline
+ } else if *markup.inline != inline {
+ return fmt.Errorf("telebot/layout: mixed reply and inline buttons in %s markup", k)
+ }
+ }
+ }
+
+ lt.markups[k] = markup
+ } else {
+ // 2. Extended reply markup
+
+ var markup struct {
+ Markup `yaml:",inline"`
+ Keyboard [][]string `yaml:"keyboard"`
+ }
+ if err := yaml.Unmarshal(data, &markup); err != nil {
+ return err
+ }
+
+ kb := make([][]tele.ReplyButton, len(markup.Keyboard))
+ for i, btns := range markup.Keyboard {
+ row := make([]tele.ReplyButton, len(btns))
+ for j, btn := range btns {
+ row[j] = *lt.buttons[btn].Reply()
+ }
+ kb[i] = row
+ }
+
+ data, err := yaml.Marshal(kb)
+ if err != nil {
+ return err
+ }
+
+ tmpl, err := template.New(k).Funcs(lt.funcs).Parse(string(data))
+ if err != nil {
+ return err
+ }
+
+ markup.inline = new(bool)
+ markup.Markup.keyboard = tmpl
+ lt.markups[k] = markup.Markup
+ }
+ }
+
+ lt.results = make(map[string]Result, len(aux.Results))
+ for _, item := range aux.Results {
+ k, v := item.Key.(string), item.Value
+
+ data, err := yaml.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ tmpl, err := template.New(k).Funcs(lt.funcs).Parse(string(data))
+ if err != nil {
+ return err
+ }
+
+ var result Result
+ if err := yaml.Unmarshal(data, &result); err != nil {
+ return err
+ }
+
+ result.result = tmpl
+ lt.results[k] = result
+ }
+
+ if aux.Locales == nil {
+ if aux.Settings.LocalesDir == "" {
+ aux.Settings.LocalesDir = "locales"
+ }
+ return lt.parseLocales(aux.Settings.LocalesDir)
+ }
+
+ return nil
+}
+
+func (lt *Layout) parseLocales(dir string) error {
+ lt.locales = make(map[string]*template.Template)
+
+ return filepath.Walk(dir, func(path string, fi os.FileInfo, _ error) error {
+ if fi == nil || fi.IsDir() {
+ return nil
+ }
+
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ var texts map[string]string
+ if err := yaml.Unmarshal(data, &texts); err != nil {
+ return err
+ }
+
+ name := fi.Name()
+ name = strings.TrimSuffix(name, filepath.Ext(name))
+
+ tmpl := template.New(name).Funcs(lt.funcs)
+ for key, text := range texts {
+ _, err = tmpl.New(key).Parse(strings.Trim(text, "\r\n"))
+ if err != nil {
+ return err
+ }
+ }
+
+ lt.locales[name] = tmpl
+ return nil
+ })
+}
diff --git a/internal/telebot/markup.go b/internal/telebot/markup.go
new file mode 100644
index 0000000..29236db
--- /dev/null
+++ b/internal/telebot/markup.go
@@ -0,0 +1,365 @@
+package telebot
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// ReplyMarkup controls two convenient options for bot-user communications
+// such as reply keyboard and inline "keyboard" (a grid of buttons as a part
+// of the message).
+type ReplyMarkup struct {
+ // InlineKeyboard is a grid of InlineButtons displayed in the message.
+ //
+ // Note: DO NOT confuse with ReplyKeyboard and other keyboard properties!
+ InlineKeyboard [][]InlineButton `json:"inline_keyboard,omitempty"`
+
+ // ReplyKeyboard is a grid, consisting of keyboard buttons.
+ //
+ // Note: you don't need to set HideCustomKeyboard field to show custom keyboard.
+ ReplyKeyboard [][]ReplyButton `json:"keyboard,omitempty"`
+
+ // ForceReply forces Telegram clients to display
+ // a reply interface to the user (act as if the user
+ // has selected the bot‘s message and tapped "Reply").
+ ForceReply bool `json:"force_reply,omitempty"`
+
+ // Requests clients to resize the keyboard vertically for optimal fit
+ // (e.g. make the keyboard smaller if there are just two rows of buttons).
+ //
+ // Defaults to false, in which case the custom keyboard is always of the
+ // same height as the app's standard keyboard.
+ ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
+
+ // Requests clients to hide the reply keyboard as soon as it's been used.
+ //
+ // Defaults to false.
+ OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
+
+ // Requests clients to remove the reply keyboard.
+ //
+ // Defaults to false.
+ RemoveKeyboard bool `json:"remove_keyboard,omitempty"`
+
+ // Use this param if you want to force reply from
+ // specific users only.
+ //
+ // Targets:
+ // 1) Users that are @mentioned in the text of the Message object;
+ // 2) If the bot's message is a reply (has SendOptions.ReplyTo),
+ // sender of the original message.
+ Selective bool `json:"selective,omitempty"`
+
+ // Placeholder will be shown in the input field when the reply is active.
+ Placeholder string `json:"input_field_placeholder,omitempty"`
+
+ // IsPersistent allows to control when the keyboard is shown.
+ IsPersistent bool `json:"is_persistent,omitempty"`
+}
+
+func (r *ReplyMarkup) copy() *ReplyMarkup {
+ cp := *r
+
+ if len(r.ReplyKeyboard) > 0 {
+ cp.ReplyKeyboard = make([][]ReplyButton, len(r.ReplyKeyboard))
+ for i, row := range r.ReplyKeyboard {
+ cp.ReplyKeyboard[i] = make([]ReplyButton, len(row))
+ copy(cp.ReplyKeyboard[i], row)
+ }
+ }
+
+ if len(r.InlineKeyboard) > 0 {
+ cp.InlineKeyboard = make([][]InlineButton, len(r.InlineKeyboard))
+ for i, row := range r.InlineKeyboard {
+ cp.InlineKeyboard[i] = make([]InlineButton, len(row))
+ copy(cp.InlineKeyboard[i], row)
+ }
+ }
+
+ return &cp
+}
+
+// Btn is a constructor button, which will later become either a reply, or an inline button.
+type Btn struct {
+ Unique string `json:"unique,omitempty"`
+ Text string `json:"text,omitempty"`
+ URL string `json:"url,omitempty"`
+ Data string `json:"callback_data,omitempty"`
+ InlineQuery string `json:"switch_inline_query,omitempty"`
+ InlineQueryChat string `json:"switch_inline_query_current_chat,omitempty"`
+ Login *Login `json:"login_url,omitempty"`
+ WebApp *WebApp `json:"web_app,omitempty"`
+ Contact bool `json:"request_contact,omitempty"`
+ Location bool `json:"request_location,omitempty"`
+ Poll PollType `json:"request_poll,omitempty"`
+ User *ReplyRecipient `json:"request_user,omitempty"`
+ Chat *ReplyRecipient `json:"request_chat,omitempty"`
+}
+
+// Row represents an array of buttons, a row.
+type Row []Btn
+
+// Row creates a row of buttons.
+func (r *ReplyMarkup) Row(many ...Btn) Row {
+ return many
+}
+
+// Split splits the keyboard into the rows with N maximum number of buttons.
+// For example, if you pass six buttons and 3 as the max, you get two rows with
+// three buttons in each.
+//
+// `Split(3, []Btn{six buttons...}) -> [[1, 2, 3], [4, 5, 6]]`
+// `Split(2, []Btn{six buttons...}) -> [[1, 2],[3, 4],[5, 6]]`
+func (r *ReplyMarkup) Split(max int, btns []Btn) []Row {
+ rows := make([]Row, (max-1+len(btns))/max)
+ for i, b := range btns {
+ i /= max
+ rows[i] = append(rows[i], b)
+ }
+ return rows
+}
+
+func (r *ReplyMarkup) Inline(rows ...Row) {
+ inlineKeys := make([][]InlineButton, 0, len(rows))
+ for i, row := range rows {
+ keys := make([]InlineButton, 0, len(row))
+ for j, btn := range row {
+ btn := btn.Inline()
+ if btn == nil {
+ panic(fmt.Sprintf(
+ "telebot: button row %d column %d is not an inline button",
+ i, j))
+ }
+ keys = append(keys, *btn)
+ }
+ inlineKeys = append(inlineKeys, keys)
+ }
+
+ r.InlineKeyboard = inlineKeys
+}
+
+func (r *ReplyMarkup) Reply(rows ...Row) {
+ replyKeys := make([][]ReplyButton, 0, len(rows))
+ for i, row := range rows {
+ keys := make([]ReplyButton, 0, len(row))
+ for j, btn := range row {
+ btn := btn.Reply()
+ if btn == nil {
+ panic(fmt.Sprintf(
+ "telebot: button row %d column %d is not a reply button",
+ i, j))
+ }
+ keys = append(keys, *btn)
+ }
+ replyKeys = append(replyKeys, keys)
+ }
+
+ r.ReplyKeyboard = replyKeys
+}
+
+func (r *ReplyMarkup) Text(text string) Btn {
+ return Btn{Text: text}
+}
+
+func (r *ReplyMarkup) Data(text, unique string, data ...string) Btn {
+ return Btn{
+ Unique: unique,
+ Text: text,
+ Data: strings.Join(data, "|"),
+ }
+}
+
+func (r *ReplyMarkup) URL(text, url string) Btn {
+ return Btn{Text: text, URL: url}
+}
+
+func (r *ReplyMarkup) Query(text, query string) Btn {
+ return Btn{Text: text, InlineQuery: query}
+}
+
+func (r *ReplyMarkup) QueryChat(text, query string) Btn {
+ return Btn{Text: text, InlineQueryChat: query}
+}
+
+func (r *ReplyMarkup) Contact(text string) Btn {
+ return Btn{Contact: true, Text: text}
+}
+
+func (r *ReplyMarkup) Location(text string) Btn {
+ return Btn{Location: true, Text: text}
+}
+
+func (r *ReplyMarkup) Poll(text string, poll PollType) Btn {
+ return Btn{Poll: poll, Text: text}
+}
+
+func (r *ReplyMarkup) User(text string, user *ReplyRecipient) Btn {
+ return Btn{Text: text, User: user}
+}
+
+func (r *ReplyMarkup) Chat(text string, chat *ReplyRecipient) Btn {
+ return Btn{Text: text, Chat: chat}
+}
+
+func (r *ReplyMarkup) Login(text string, login *Login) Btn {
+ return Btn{Login: login, Text: text}
+}
+
+func (r *ReplyMarkup) WebApp(text string, app *WebApp) Btn {
+ return Btn{Text: text, WebApp: app}
+}
+
+// ReplyButton represents a button displayed in reply-keyboard.
+//
+// Set either Contact or Location to true in order to request
+// sensitive info, such as user's phone number or current location.
+type ReplyButton struct {
+ Text string `json:"text"`
+
+ Contact bool `json:"request_contact,omitempty"`
+ Location bool `json:"request_location,omitempty"`
+ Poll PollType `json:"request_poll,omitempty"`
+ User *ReplyRecipient `json:"request_user,omitempty"`
+ Chat *ReplyRecipient `json:"request_chat,omitempty"`
+ WebApp *WebApp `json:"web_app,omitempty"`
+}
+
+// MarshalJSON implements json.Marshaler. It allows passing PollType as a
+// keyboard's poll type instead of KeyboardButtonPollType object.
+func (pt PollType) MarshalJSON() ([]byte, error) {
+ return json.Marshal(&struct {
+ Type string `json:"type"`
+ }{
+ Type: string(pt),
+ })
+}
+
+// ReplyRecipient combines both KeyboardButtonRequestUser
+// and KeyboardButtonRequestChat objects. Use inside ReplyButton
+// to request the user or chat sharing with respective settings.
+//
+// To pass the pointers to bool use a special tele.Flag function,
+// that way you will be able to reflect the three-state bool (nil, false, true).
+type ReplyRecipient struct {
+ ID int32 `json:"request_id"`
+
+ Bot *bool `json:"user_is_bot,omitempty"` // user only, optional
+ Premium *bool `json:"user_is_premium,omitempty"` // user only, optional
+
+ Channel bool `json:"chat_is_channel,omitempty"` // chat only, required
+ Forum *bool `json:"chat_is_forum,omitempty"` // chat only, optional
+ WithUsername *bool `json:"chat_has_username,omitempty"` // chat only, optional
+ Created *bool `json:"chat_is_created,omitempty"` // chat only, optional
+ UserRights *Rights `json:"user_administrator_rights,omitempty"` // chat only, optional
+ BotRights *Rights `json:"bot_administrator_rights,omitempty"` // chat only, optional
+ BotMember *bool `json:"bot_is_member,omitempty"` // chat only, optional
+}
+
+// RecipientShared combines both UserShared and ChatShared objects.
+type RecipientShared struct {
+ ID int32 `json:"request_id"`
+ UserID int64 `json:"user_id"`
+ ChatID int64 `json:"chat_id"`
+}
+
+// InlineButton represents a button displayed in the message.
+type InlineButton struct {
+ // Unique slagish name for this kind of button,
+ // try to be as specific as possible.
+ //
+ // It will be used as a callback endpoint.
+ Unique string `json:"unique,omitempty"`
+
+ Text string `json:"text"`
+ URL string `json:"url,omitempty"`
+ Data string `json:"callback_data,omitempty"`
+ InlineQuery string `json:"switch_inline_query,omitempty"`
+ InlineQueryChat string `json:"switch_inline_query_current_chat"`
+ Login *Login `json:"login_url,omitempty"`
+ WebApp *WebApp `json:"web_app,omitempty"`
+}
+
+// MarshalJSON implements json.Marshaler interface.
+// It needed to avoid InlineQueryChat and Login or WebApp fields conflict.
+// If you have Login or WebApp field in your button, InlineQueryChat must be skipped.
+func (t *InlineButton) MarshalJSON() ([]byte, error) {
+ type IB InlineButton
+
+ if t.Login != nil || t.WebApp != nil {
+ return json.Marshal(struct {
+ IB
+ InlineQueryChat string `json:"switch_inline_query_current_chat,omitempty"`
+ }{
+ IB: IB(*t),
+ })
+ }
+ return json.Marshal(IB(*t))
+}
+
+// With returns a copy of the button with data.
+func (t *InlineButton) With(data string) *InlineButton {
+ return &InlineButton{
+ Unique: t.Unique,
+ Text: t.Text,
+ URL: t.URL,
+ InlineQuery: t.InlineQuery,
+ InlineQueryChat: t.InlineQueryChat,
+ Login: t.Login,
+ Data: data,
+ }
+}
+
+func (b Btn) Reply() *ReplyButton {
+ if b.Unique != "" {
+ return nil
+ }
+
+ return &ReplyButton{
+ Text: b.Text,
+ Contact: b.Contact,
+ Location: b.Location,
+ Poll: b.Poll,
+ User: b.User,
+ Chat: b.Chat,
+ WebApp: b.WebApp,
+ }
+}
+
+func (b Btn) Inline() *InlineButton {
+ return &InlineButton{
+ Unique: b.Unique,
+ Text: b.Text,
+ URL: b.URL,
+ Data: b.Data,
+ InlineQuery: b.InlineQuery,
+ InlineQueryChat: b.InlineQueryChat,
+ Login: b.Login,
+ WebApp: b.WebApp,
+ }
+}
+
+// Login represents a parameter of the inline keyboard button
+// used to automatically authorize a user. Serves as a great replacement
+// for the Telegram Login Widget when the user is coming from Telegram.
+type Login struct {
+ URL string `json:"url"`
+ Text string `json:"forward_text,omitempty"`
+ Username string `json:"bot_username,omitempty"`
+ WriteAccess bool `json:"request_write_access,omitempty"`
+}
+
+// MenuButton describes the bot's menu button in a private chat.
+type MenuButton struct {
+ Type MenuButtonType `json:"type"`
+ Text string `json:"text,omitempty"`
+ WebApp *WebApp `json:"web_app,omitempty"`
+}
+
+type MenuButtonType = string
+
+const (
+ MenuButtonDefault MenuButtonType = "default"
+ MenuButtonCommands MenuButtonType = "commands"
+ MenuButtonWebApp MenuButtonType = "web_app"
+)
diff --git a/internal/telebot/markup_test.go b/internal/telebot/markup_test.go
new file mode 100644
index 0000000..fb5fbb4
--- /dev/null
+++ b/internal/telebot/markup_test.go
@@ -0,0 +1,65 @@
+package telebot
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBtn(t *testing.T) {
+ r := &ReplyMarkup{}
+
+ assert.Equal(t, &ReplyButton{Text: "T"}, r.Text("T").Reply())
+ assert.Equal(t, &ReplyButton{Text: "T", Contact: true}, r.Contact("T").Reply())
+ assert.Equal(t, &ReplyButton{Text: "T", Location: true}, r.Location("T").Reply())
+ assert.Equal(t, &ReplyButton{Text: "T", Poll: PollAny}, r.Poll("T", PollAny).Reply())
+
+ assert.Nil(t, r.Data("T", "u").Reply())
+ assert.Equal(t, &InlineButton{Unique: "u", Text: "T"}, r.Data("T", "u").Inline())
+ assert.Equal(t, &InlineButton{Unique: "u", Text: "T", Data: "1|2"}, r.Data("T", "u", "1", "2").Inline())
+ assert.Equal(t, &InlineButton{Text: "T", URL: "url"}, r.URL("T", "url").Inline())
+ assert.Equal(t, &InlineButton{Text: "T", InlineQuery: "q"}, r.Query("T", "q").Inline())
+ assert.Equal(t, &InlineButton{Text: "T", InlineQueryChat: "q"}, r.QueryChat("T", "q").Inline())
+ assert.Equal(t, &InlineButton{Text: "T", Login: &Login{Text: "T"}}, r.Login("T", &Login{Text: "T"}).Inline())
+ assert.Equal(t, &InlineButton{Text: "T", WebApp: &WebApp{URL: "url"}}, r.WebApp("T", &WebApp{URL: "url"}).Inline())
+}
+
+func TestOptions(t *testing.T) {
+ r := &ReplyMarkup{}
+ r.Reply(
+ r.Row(r.Text("Menu")),
+ r.Row(r.Text("Settings")),
+ )
+
+ assert.Equal(t, [][]ReplyButton{
+ {{Text: "Menu"}},
+ {{Text: "Settings"}},
+ }, r.ReplyKeyboard)
+
+ i := &ReplyMarkup{}
+ i.Inline(i.Row(
+ i.Data("Previous", "prev"),
+ i.Data("Next", "next"),
+ ))
+
+ assert.Equal(t, [][]InlineButton{{
+ {Unique: "prev", Text: "Previous"},
+ {Unique: "next", Text: "Next"},
+ }}, i.InlineKeyboard)
+
+ assert.Panics(t, func() {
+ r.Reply(r.Row(r.Data("T", "u")))
+ i.Inline(i.Row(i.Text("T")))
+ })
+
+ assert.Equal(t, r.copy(), r)
+ assert.Equal(t, i.copy(), i)
+
+ o := &SendOptions{ReplyMarkup: r}
+ assert.Equal(t, o.copy(), o)
+
+ data, err := PollQuiz.MarshalJSON()
+ require.NoError(t, err)
+ assert.Equal(t, []byte(`{"type":"quiz"}`), data)
+}
diff --git a/internal/telebot/media.go b/internal/telebot/media.go
new file mode 100644
index 0000000..d161aa5
--- /dev/null
+++ b/internal/telebot/media.go
@@ -0,0 +1,358 @@
+package telebot
+
+import (
+ "encoding/json"
+)
+
+// Media is a generic type for all kinds of media that includes File.
+type Media interface {
+ // MediaType returns string-represented media type.
+ MediaType() string
+
+ // MediaFile returns a pointer to the media file.
+ MediaFile() *File
+}
+
+// InputMedia represents a composite InputMedia struct that is
+// used by Telebot in sending and editing media methods.
+type InputMedia struct {
+ Type string `json:"type"`
+ Media string `json:"media"`
+ Caption string `json:"caption"`
+ Thumbnail string `json:"thumb,omitempty"`
+ ParseMode string `json:"parse_mode,omitempty"`
+ Entities Entities `json:"caption_entities,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Duration int `json:"duration,omitempty"`
+ Title string `json:"title,omitempty"`
+ Performer string `json:"performer,omitempty"`
+ Streaming bool `json:"supports_streaming,omitempty"`
+ DisableTypeDetection bool `json:"disable_content_type_detection,omitempty"`
+ HasSpoiler bool `json:"is_spoiler,omitempty"`
+}
+
+// Inputtable is a generic type for all kinds of media you
+// can put into an album.
+type Inputtable interface {
+ Media
+
+ // InputMedia returns already marshalled InputMedia type
+ // ready to be used in sending and editing media methods.
+ InputMedia() InputMedia
+}
+
+// Album lets you group multiple media into a single message.
+type Album []Inputtable
+
+// Photo object represents a single photo file.
+type Photo struct {
+ File
+
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Caption string `json:"caption,omitempty"`
+}
+
+type photoSize struct {
+ File
+
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Caption string `json:"caption,omitempty"`
+}
+
+func (p *Photo) MediaType() string {
+ return "photo"
+}
+
+func (p *Photo) MediaFile() *File {
+ return &p.File
+}
+
+func (p *Photo) InputMedia() InputMedia {
+ return InputMedia{
+ Type: p.MediaType(),
+ Caption: p.Caption,
+ }
+}
+
+// UnmarshalJSON is custom unmarshaller required to abstract
+// away the hassle of treating different thumbnail sizes.
+// Instead, Telebot chooses the hi-res one and just sticks to it.
+//
+// I really do find it a beautiful solution.
+func (p *Photo) UnmarshalJSON(data []byte) error {
+ var hq photoSize
+
+ if data[0] == '{' {
+ if err := json.Unmarshal(data, &hq); err != nil {
+ return err
+ }
+ } else {
+ var sizes []photoSize
+ if err := json.Unmarshal(data, &sizes); err != nil {
+ return err
+ }
+
+ hq = sizes[len(sizes)-1]
+ }
+
+ p.File = hq.File
+ p.Width = hq.Width
+ p.Height = hq.Height
+
+ return nil
+}
+
+// Audio object represents an audio file.
+type Audio struct {
+ File
+
+ Duration int `json:"duration,omitempty"`
+
+ // (Optional)
+ Caption string `json:"caption,omitempty"`
+ Thumbnail *Photo `json:"thumb,omitempty"`
+ Title string `json:"title,omitempty"`
+ Performer string `json:"performer,omitempty"`
+ MIME string `json:"mime_type,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+}
+
+func (a *Audio) MediaType() string {
+ return "audio"
+}
+
+func (a *Audio) MediaFile() *File {
+ a.fileName = a.FileName
+ return &a.File
+}
+
+func (a *Audio) InputMedia() InputMedia {
+ return InputMedia{
+ Type: a.MediaType(),
+ Caption: a.Caption,
+ Duration: a.Duration,
+ Title: a.Title,
+ Performer: a.Performer,
+ }
+}
+
+// Document object represents a general file (as opposed to Photo or Audio).
+// Telegram users can send files of any type of up to 1.5 GB in size.
+type Document struct {
+ File
+
+ // (Optional)
+ Thumbnail *Photo `json:"thumb,omitempty"`
+ Caption string `json:"caption,omitempty"`
+ MIME string `json:"mime_type"`
+ FileName string `json:"file_name,omitempty"`
+ DisableTypeDetection bool `json:"disable_content_type_detection,omitempty"`
+}
+
+func (d *Document) MediaType() string {
+ return "document"
+}
+
+func (d *Document) MediaFile() *File {
+ d.fileName = d.FileName
+ return &d.File
+}
+
+func (d *Document) InputMedia() InputMedia {
+ return InputMedia{
+ Type: d.MediaType(),
+ Caption: d.Caption,
+ DisableTypeDetection: d.DisableTypeDetection,
+ }
+}
+
+// Video object represents a video file.
+type Video struct {
+ File
+
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Duration int `json:"duration,omitempty"`
+
+ // (Optional)
+ Caption string `json:"caption,omitempty"`
+ Thumbnail *Photo `json:"thumb,omitempty"`
+ Streaming bool `json:"supports_streaming,omitempty"`
+ MIME string `json:"mime_type,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+}
+
+func (v *Video) MediaType() string {
+ return "video"
+}
+
+func (v *Video) MediaFile() *File {
+ v.fileName = v.FileName
+ return &v.File
+}
+
+func (v *Video) InputMedia() InputMedia {
+ return InputMedia{
+ Type: v.MediaType(),
+ Caption: v.Caption,
+ Width: v.Width,
+ Height: v.Height,
+ Duration: v.Duration,
+ Streaming: v.Streaming,
+ }
+}
+
+// Animation object represents a animation file.
+type Animation struct {
+ File
+
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Duration int `json:"duration,omitempty"`
+
+ // (Optional)
+ Caption string `json:"caption,omitempty"`
+ Thumbnail *Photo `json:"thumb,omitempty"`
+ MIME string `json:"mime_type,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+}
+
+func (a *Animation) MediaType() string {
+ return "animation"
+}
+
+func (a *Animation) MediaFile() *File {
+ a.fileName = a.FileName
+ return &a.File
+}
+
+func (a *Animation) InputMedia() InputMedia {
+ return InputMedia{
+ Type: a.MediaType(),
+ Caption: a.Caption,
+ Width: a.Width,
+ Height: a.Height,
+ Duration: a.Duration,
+ }
+}
+
+// Voice object represents a voice note.
+type Voice struct {
+ File
+
+ Duration int `json:"duration"`
+
+ // (Optional)
+ Caption string `json:"caption,omitempty"`
+ MIME string `json:"mime_type,omitempty"`
+}
+
+func (v *Voice) MediaType() string {
+ return "voice"
+}
+
+func (v *Voice) MediaFile() *File {
+ return &v.File
+}
+
+// VideoNote represents a video message.
+type VideoNote struct {
+ File
+
+ Duration int `json:"duration"`
+
+ // (Optional)
+ Thumbnail *Photo `json:"thumb,omitempty"`
+ Length int `json:"length,omitempty"`
+}
+
+func (v *VideoNote) MediaType() string {
+ return "videoNote"
+}
+
+func (v *VideoNote) MediaFile() *File {
+ return &v.File
+}
+
+// Sticker object represents a WebP image, so-called sticker.
+type Sticker struct {
+ File
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Animated bool `json:"is_animated"`
+ Video bool `json:"is_video"`
+ Thumbnail *Photo `json:"thumb"`
+ Emoji string `json:"emoji"`
+ SetName string `json:"set_name"`
+ MaskPosition *MaskPosition `json:"mask_position"`
+ PremiumAnimation *File `json:"premium_animation"`
+ Type StickerSetType `json:"type"`
+ CustomEmoji string `json:"custom_emoji_id"`
+}
+
+func (s *Sticker) MediaType() string {
+ return "sticker"
+}
+
+func (s *Sticker) MediaFile() *File {
+ return &s.File
+}
+
+// Contact object represents a contact to Telegram user.
+type Contact struct {
+ PhoneNumber string `json:"phone_number"`
+ FirstName string `json:"first_name"`
+
+ // (Optional)
+ LastName string `json:"last_name"`
+ UserID int64 `json:"user_id,omitempty"`
+}
+
+// Location object represents geographic position.
+type Location struct {
+ Lat float32 `json:"latitude"`
+ Lng float32 `json:"longitude"`
+ HorizontalAccuracy *float32 `json:"horizontal_accuracy,omitempty"`
+ Heading int `json:"heading,omitempty"`
+ AlertRadius int `json:"proximity_alert_radius,omitempty"`
+
+ // Period in seconds for which the location will be updated
+ // (see Live Locations, should be between 60 and 86400.)
+ LivePeriod int `json:"live_period,omitempty"`
+}
+
+// Venue object represents a venue location with name, address and
+// optional foursquare ID.
+type Venue struct {
+ Location Location `json:"location"`
+ Title string `json:"title"`
+ Address string `json:"address"`
+
+ // (Optional)
+ FoursquareID string `json:"foursquare_id,omitempty"`
+ FoursquareType string `json:"foursquare_type,omitempty"`
+ GooglePlaceID string `json:"google_place_id,omitempty"`
+ GooglePlaceType string `json:"google_place_type,omitempty"`
+}
+
+// Dice object represents a dice with a random value
+// from 1 to 6 for currently supported base emoji.
+type Dice struct {
+ Type DiceType `json:"emoji"`
+ Value int `json:"value"`
+}
+
+// DiceType defines dice types.
+type DiceType string
+
+var (
+ Cube = &Dice{Type: "🎲"}
+ Dart = &Dice{Type: "🎯"}
+ Ball = &Dice{Type: "🏀"}
+ Goal = &Dice{Type: "⚽"}
+ Slot = &Dice{Type: "🎰"}
+ Bowl = &Dice{Type: "🎳"}
+)
diff --git a/internal/telebot/message.go b/internal/telebot/message.go
new file mode 100644
index 0000000..8299756
--- /dev/null
+++ b/internal/telebot/message.go
@@ -0,0 +1,463 @@
+package telebot
+
+import (
+ "strconv"
+ "time"
+ "unicode/utf16"
+)
+
+// Message object represents a message.
+type Message struct {
+ ID int `json:"message_id"`
+
+ // (Optional) Unique identifier of a message thread to which the message belongs; for supergroups only
+ ThreadID int `json:"message_thread_id"`
+
+ // For message sent to channels, Sender will be nil
+ Sender *User `json:"from"`
+
+ // Unixtime, use Message.Time() to get time.Time
+ Unixtime int64 `json:"date"`
+
+ // Conversation the message belongs to.
+ Chat *Chat `json:"chat"`
+
+ // Sender of the message, sent on behalf of a chat.
+ SenderChat *Chat `json:"sender_chat"`
+
+ // For forwarded messages, sender of the original message.
+ OriginalSender *User `json:"forward_from"`
+
+ // For forwarded messages, chat of the original message when
+ // forwarded from a channel.
+ OriginalChat *Chat `json:"forward_from_chat"`
+
+ // For forwarded messages, identifier of the original message
+ // when forwarded from a channel.
+ OriginalMessageID int `json:"forward_from_message_id"`
+
+ // For forwarded messages, signature of the post author.
+ OriginalSignature string `json:"forward_signature"`
+
+ // For forwarded messages, sender's name from users who
+ // disallow adding a link to their account.
+ OriginalSenderName string `json:"forward_sender_name"`
+
+ // For forwarded messages, unixtime of the original message.
+ OriginalUnixtime int `json:"forward_date"`
+
+ // Message is a channel post that was automatically forwarded to the connected discussion group.
+ AutomaticForward bool `json:"is_automatic_forward"`
+
+ // For replies, ReplyTo represents the original message.
+ //
+ // Note that the Message object in this field will not
+ // contain further ReplyTo fields even if it
+ // itself is a reply.
+ ReplyTo *Message `json:"reply_to_message"`
+
+ // Shows through which bot the message was sent.
+ Via *User `json:"via_bot"`
+
+ // (Optional) Time of last edit in Unix.
+ LastEdit int64 `json:"edit_date"`
+
+ // (Optional) True, if the message is sent to a forum topic.
+ TopicMessage bool `json:"is_topic_message"`
+
+ // (Optional) Message can't be forwarded.
+ Protected bool `json:"has_protected_content,omitempty"`
+
+ // AlbumID is the unique identifier of a media message group
+ // this message belongs to.
+ AlbumID string `json:"media_group_id"`
+
+ // Author signature (in channels).
+ Signature string `json:"author_signature"`
+
+ // For a text message, the actual UTF-8 text of the message.
+ Text string `json:"text"`
+
+ // For registered commands, will contain the string payload:
+ //
+ // Ex: `/command ` or `/command@botname `
+ Payload string `json:"-"`
+
+ // For text messages, special entities like usernames, URLs, bot commands,
+ // etc. that appear in the text.
+ Entities Entities `json:"entities,omitempty"`
+
+ // Some messages containing media, may as well have a caption.
+ Caption string `json:"caption,omitempty"`
+
+ // For messages with a caption, special entities like usernames, URLs,
+ // bot commands, etc. that appear in the caption.
+ CaptionEntities Entities `json:"caption_entities,omitempty"`
+
+ // For an audio recording, information about it.
+ Audio *Audio `json:"audio"`
+
+ // For a general file, information about it.
+ Document *Document `json:"document"`
+
+ // For a photo, all available sizes (thumbnails).
+ Photo *Photo `json:"photo"`
+
+ // For a sticker, information about it.
+ Sticker *Sticker `json:"sticker"`
+
+ // For a voice message, information about it.
+ Voice *Voice `json:"voice"`
+
+ // For a video note, information about it.
+ VideoNote *VideoNote `json:"video_note"`
+
+ // For a video, information about it.
+ Video *Video `json:"video"`
+
+ // For a animation, information about it.
+ Animation *Animation `json:"animation"`
+
+ // For a contact, contact information itself.
+ Contact *Contact `json:"contact"`
+
+ // For a location, its longitude and latitude.
+ Location *Location `json:"location"`
+
+ // For a venue, information about it.
+ Venue *Venue `json:"venue"`
+
+ // For a poll, information the native poll.
+ Poll *Poll `json:"poll"`
+
+ // For a game, information about it.
+ Game *Game `json:"game"`
+
+ // For a dice, information about it.
+ Dice *Dice `json:"dice"`
+
+ // For a service message, represents a user,
+ // that just got added to chat, this message came from.
+ //
+ // Sender leads to User, capable of invite.
+ //
+ // UserJoined might be the Bot itself.
+ UserJoined *User `json:"new_chat_member"`
+
+ // For a service message, represents a user,
+ // that just left chat, this message came from.
+ //
+ // If user was kicked, Sender leads to a User,
+ // capable of this kick.
+ //
+ // UserLeft might be the Bot itself.
+ UserLeft *User `json:"left_chat_member"`
+
+ // For a service message, represents a new title
+ // for chat this message came from.
+ //
+ // Sender would lead to a User, capable of change.
+ NewGroupTitle string `json:"new_chat_title"`
+
+ // For a service message, represents all available
+ // thumbnails of the new chat photo.
+ //
+ // Sender would lead to a User, capable of change.
+ NewGroupPhoto *Photo `json:"new_chat_photo"`
+
+ // For a service message, new members that were added to
+ // the group or supergroup and information about them
+ // (the bot itself may be one of these members).
+ UsersJoined []User `json:"new_chat_members"`
+
+ // For a service message, true if chat photo just
+ // got removed.
+ //
+ // Sender would lead to a User, capable of change.
+ GroupPhotoDeleted bool `json:"delete_chat_photo"`
+
+ // For a service message, true if group has been created.
+ //
+ // You would receive such a message if you are one of
+ // initial group chat members.
+ //
+ // Sender would lead to creator of the chat.
+ GroupCreated bool `json:"group_chat_created"`
+
+ // For a service message, true if supergroup has been created.
+ //
+ // You would receive such a message if you are one of
+ // initial group chat members.
+ //
+ // Sender would lead to creator of the chat.
+ SuperGroupCreated bool `json:"supergroup_chat_created"`
+
+ // For a service message, true if channel has been created.
+ //
+ // You would receive such a message if you are one of
+ // initial channel administrators.
+ //
+ // Sender would lead to creator of the chat.
+ ChannelCreated bool `json:"channel_chat_created"`
+
+ // For a service message, the destination (supergroup) you
+ // migrated to.
+ //
+ // You would receive such a message when your chat has migrated
+ // to a supergroup.
+ //
+ // Sender would lead to creator of the migration.
+ MigrateTo int64 `json:"migrate_to_chat_id"`
+
+ // For a service message, the Origin (normal group) you migrated
+ // from.
+ //
+ // You would receive such a message when your chat has migrated
+ // to a supergroup.
+ //
+ // Sender would lead to creator of the migration.
+ MigrateFrom int64 `json:"migrate_from_chat_id"`
+
+ // Specified message was pinned. Note that the Message object
+ // in this field will not contain further ReplyTo fields even
+ // if it is itself a reply.
+ PinnedMessage *Message `json:"pinned_message"`
+
+ // Message is an invoice for a payment.
+ Invoice *Invoice `json:"invoice"`
+
+ // Message is a service message about a successful payment.
+ Payment *Payment `json:"successful_payment"`
+
+ // For a service message, a user was shared with the bot.
+ UserShared *RecipientShared `json:"user_shared,omitempty"`
+
+ // For a service message, a chat was shared with the bot.
+ ChatShared *RecipientShared `json:"chat_shared,omitempty"`
+
+ // The domain name of the website on which the user has logged in.
+ ConnectedWebsite string `json:"connected_website,omitempty"`
+
+ // For a service message, a video chat started in the chat.
+ VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"`
+
+ // For a service message, a video chat ended in the chat.
+ VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"`
+
+ // For a service message, some users were invited in the video chat.
+ VideoChatParticipants *VideoChatParticipants `json:"video_chat_participants_invited,omitempty"`
+
+ // For a service message, a video chat schedule in the chat.
+ VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"`
+
+ // For a data sent by a Web App.
+ WebAppData *WebAppData `json:"web_app_data,omitempty"`
+
+ // For a service message, represents the content of a service message,
+ // sent whenever a user in the chat triggers a proximity alert set by another user.
+ ProximityAlert *ProximityAlert `json:"proximity_alert_triggered,omitempty"`
+
+ // For a service message, represents about a change in auto-delete timer settings.
+ AutoDeleteTimer *AutoDeleteTimer `json:"message_auto_delete_timer_changed,omitempty"`
+
+ // Inline keyboard attached to the message.
+ ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
+
+ // Service message: forum topic created
+ TopicCreated *Topic `json:"forum_topic_created,omitempty"`
+
+ // Service message: forum topic closed
+ TopicClosed *struct{} `json:"forum_topic_closed,omitempty"`
+
+ // Service message: forum topic reopened
+ TopicReopened *Topic `json:"forum_topic_reopened,omitempty"`
+
+ // Service message: forum topic deleted
+ TopicEdited *Topic `json:"forum_topic_edited,omitempty"`
+
+ // Service message: general forum topic hidden
+ GeneralTopicHidden *struct{} `json:"general_topic_hidden,omitempty"`
+
+ // Service message: general forum topic unhidden
+ GeneralTopicUnhidden *struct{} `json:"general_topic_unhidden,omitempty"`
+
+ // Service message: represents spoiler information about the message.
+ HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"`
+
+ // Service message: the user allowed the bot added to the attachment menu to write messages
+ WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"`
+}
+
+// MessageEntity object represents "special" parts of text messages,
+// including hashtags, usernames, URLs, etc.
+type MessageEntity struct {
+ // Specifies entity type.
+ Type EntityType `json:"type"`
+
+ // Offset in UTF-16 code units to the start of the entity.
+ Offset int `json:"offset"`
+
+ // Length of the entity in UTF-16 code units.
+ Length int `json:"length"`
+
+ // (Optional) For EntityTextLink entity type only.
+ //
+ // URL will be opened after user taps on the text.
+ URL string `json:"url,omitempty"`
+
+ // (Optional) For EntityTMention entity type only.
+ User *User `json:"user,omitempty"`
+
+ // (Optional) For EntityCodeBlock entity type only.
+ Language string `json:"language,omitempty"`
+
+ // (Optional) For EntityCustomEmoji entity type only.
+ CustomEmoji string `json:"custom_emoji_id"`
+}
+
+// EntityType is a MessageEntity type.
+type EntityType string
+
+const (
+ EntityMention EntityType = "mention"
+ EntityTMention EntityType = "text_mention"
+ EntityHashtag EntityType = "hashtag"
+ EntityCashtag EntityType = "cashtag"
+ EntityCommand EntityType = "bot_command"
+ EntityURL EntityType = "url"
+ EntityEmail EntityType = "email"
+ EntityPhone EntityType = "phone_number"
+ EntityBold EntityType = "bold"
+ EntityItalic EntityType = "italic"
+ EntityUnderline EntityType = "underline"
+ EntityStrikethrough EntityType = "strikethrough"
+ EntityCode EntityType = "code"
+ EntityCodeBlock EntityType = "pre"
+ EntityTextLink EntityType = "text_link"
+ EntitySpoiler EntityType = "spoiler"
+ EntityCustomEmoji EntityType = "custom_emoji"
+)
+
+// Entities is used to set message's text entities as a send option.
+type Entities []MessageEntity
+
+// ProximityAlert sent whenever a user in the chat triggers
+// a proximity alert set by another user.
+type ProximityAlert struct {
+ Traveler *User `json:"traveler,omitempty"`
+ Watcher *User `json:"watcher,omitempty"`
+ Distance int `json:"distance"`
+}
+
+// AutoDeleteTimer represents a service message about a change in auto-delete timer settings.
+type AutoDeleteTimer struct {
+ Unixtime int `json:"message_auto_delete_time"`
+}
+
+// MessageSig satisfies Editable interface (see Editable.)
+func (m *Message) MessageSig() (string, int64) {
+ return strconv.Itoa(m.ID), m.Chat.ID
+}
+
+// Time returns the moment of message creation in local time.
+func (m *Message) Time() time.Time {
+ return time.Unix(m.Unixtime, 0)
+}
+
+// LastEdited returns time.Time of last edit.
+func (m *Message) LastEdited() time.Time {
+ return time.Unix(m.LastEdit, 0)
+}
+
+// IsForwarded says whether message is forwarded copy of another
+// message or not.
+func (m *Message) IsForwarded() bool {
+ return m.OriginalSender != nil || m.OriginalChat != nil
+}
+
+// IsReply says whether message is a reply to another message.
+func (m *Message) IsReply() bool {
+ return m.ReplyTo != nil
+}
+
+// Private returns true, if it's a personal message.
+func (m *Message) Private() bool {
+ return m.Chat.Type == ChatPrivate
+}
+
+// FromGroup returns true, if message came from a group OR a supergroup.
+func (m *Message) FromGroup() bool {
+ return m.Chat.Type == ChatGroup || m.Chat.Type == ChatSuperGroup
+}
+
+// FromChannel returns true, if message came from a channel.
+func (m *Message) FromChannel() bool {
+ return m.Chat.Type == ChatChannel
+}
+
+// IsService returns true, if message is a service message,
+// returns false otherwise.
+//
+// Service messages are automatically sent messages, which
+// typically occur on some global action. For instance, when
+// anyone leaves the chat or chat title changes.
+func (m *Message) IsService() bool {
+ fact := false
+
+ fact = fact || m.UserJoined != nil
+ fact = fact || len(m.UsersJoined) > 0
+ fact = fact || m.UserLeft != nil
+ fact = fact || m.NewGroupTitle != ""
+ fact = fact || m.NewGroupPhoto != nil
+ fact = fact || m.GroupPhotoDeleted
+ fact = fact || m.GroupCreated || m.SuperGroupCreated
+ fact = fact || (m.MigrateTo != m.MigrateFrom)
+
+ return fact
+}
+
+// EntityText returns the substring of the message identified by the
+// given MessageEntity.
+//
+// It's safer than manually slicing Text because Telegram uses
+// UTF-16 indices whereas Go string are []byte.
+func (m *Message) EntityText(e MessageEntity) string {
+ text := m.Text
+ if text == "" {
+ text = m.Caption
+ }
+
+ a := utf16.Encode([]rune(text))
+ off, end := e.Offset, e.Offset+e.Length
+
+ if off < 0 || end > len(a) {
+ return ""
+ }
+
+ return string(utf16.Decode(a[off:end]))
+}
+
+// Media returns the message's media if it contains either photo,
+// voice, audio, animation, sticker, document, video or video note.
+func (m *Message) Media() Media {
+ switch {
+ case m.Photo != nil:
+ return m.Photo
+ case m.Voice != nil:
+ return m.Voice
+ case m.Audio != nil:
+ return m.Audio
+ case m.Animation != nil:
+ return m.Animation
+ case m.Sticker != nil:
+ return m.Sticker
+ case m.Document != nil:
+ return m.Document
+ case m.Video != nil:
+ return m.Video
+ case m.VideoNote != nil:
+ return m.VideoNote
+ default:
+ return nil
+ }
+}
diff --git a/internal/telebot/middleware.go b/internal/telebot/middleware.go
new file mode 100644
index 0000000..a8e2912
--- /dev/null
+++ b/internal/telebot/middleware.go
@@ -0,0 +1,38 @@
+package telebot
+
+// MiddlewareFunc represents a middleware processing function,
+// which get called before the endpoint group or specific handler.
+type MiddlewareFunc func(HandlerFunc) HandlerFunc
+
+func appendMiddleware(a, b []MiddlewareFunc) []MiddlewareFunc {
+ if len(a) == 0 {
+ return b
+ }
+
+ m := make([]MiddlewareFunc, 0, len(a)+len(b))
+ return append(m, append(a, b...)...)
+}
+
+func applyMiddleware(h HandlerFunc, m ...MiddlewareFunc) HandlerFunc {
+ for i := len(m) - 1; i >= 0; i-- {
+ h = m[i](h)
+ }
+ return h
+}
+
+// Group is a separated group of handlers, united by the general middleware.
+type Group struct {
+ b *Bot
+ middleware []MiddlewareFunc
+}
+
+// Use adds middleware to the chain.
+func (g *Group) Use(middleware ...MiddlewareFunc) {
+ g.middleware = append(g.middleware, middleware...)
+}
+
+// Handle adds endpoint handler to the bot, combining group's middleware
+// with the optional given middleware.
+func (g *Group) Handle(endpoint interface{}, h HandlerFunc, m ...MiddlewareFunc) {
+ g.b.Handle(endpoint, h, appendMiddleware(g.middleware, m)...)
+}
diff --git a/internal/telebot/middleware/logger.go b/internal/telebot/middleware/logger.go
new file mode 100644
index 0000000..9bb4a47
--- /dev/null
+++ b/internal/telebot/middleware/logger.go
@@ -0,0 +1,27 @@
+package middleware
+
+import (
+ "encoding/json"
+ "log"
+
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+// Logger returns a middleware that logs incoming updates.
+// If no custom logger provided, log.Default() will be used.
+func Logger(logger ...*log.Logger) tele.MiddlewareFunc {
+ var l *log.Logger
+ if len(logger) > 0 {
+ l = logger[0]
+ } else {
+ l = log.Default()
+ }
+
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return func(c tele.Context) error {
+ data, _ := json.MarshalIndent(c.Update(), "", " ")
+ l.Println(string(data))
+ return next(c)
+ }
+ }
+}
diff --git a/internal/telebot/middleware/middleware.go b/internal/telebot/middleware/middleware.go
new file mode 100644
index 0000000..1d65337
--- /dev/null
+++ b/internal/telebot/middleware/middleware.go
@@ -0,0 +1,64 @@
+package middleware
+
+import (
+ "context"
+ "errors"
+
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+// AutoRespond returns a middleware that automatically responds
+// to every callback.
+func AutoRespond() tele.MiddlewareFunc {
+ ctx := context.Background()
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return func(c tele.Context) error {
+ if c.Callback() != nil {
+ defer c.Respond(ctx)
+ }
+ return next(c)
+ }
+ }
+}
+
+// IgnoreVia returns a middleware that ignores all the
+// "sent via" messages.
+func IgnoreVia() tele.MiddlewareFunc {
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return func(c tele.Context) error {
+ if msg := c.Message(); msg != nil && msg.Via != nil {
+ return nil
+ }
+ return next(c)
+ }
+ }
+}
+
+// Recover returns a middleware that recovers a panic happened in
+// the handler.
+func Recover(onError ...func(error)) tele.MiddlewareFunc {
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return func(c tele.Context) error {
+ var f func(error)
+ if len(onError) > 0 {
+ f = onError[0]
+ } else {
+ f = func(err error) {
+ c.Bot().OnError(err, nil)
+ }
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ if err, ok := r.(error); ok {
+ f(err)
+ } else if s, ok := r.(string); ok {
+ f(errors.New(s))
+ }
+ }
+ }()
+
+ return next(c)
+ }
+ }
+}
diff --git a/internal/telebot/middleware/middleware_test.go b/internal/telebot/middleware/middleware_test.go
new file mode 100644
index 0000000..d3c032f
--- /dev/null
+++ b/internal/telebot/middleware/middleware_test.go
@@ -0,0 +1,30 @@
+package middleware
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ tele "github.com/teknologi-umum/captcha/internal/telebot"
+)
+
+var b, _ = tele.NewBot(tele.Settings{Offline: true})
+
+func TestRecover(t *testing.T) {
+ onError := func(err error) {
+ require.Error(t, err, "recover test")
+ }
+
+ h := func(c tele.Context) error {
+ panic("recover test")
+ }
+
+ assert.Panics(t, func() {
+ h(nil)
+ })
+
+ assert.NotPanics(t, func() {
+ Recover(onError)(h)(nil)
+ })
+}
diff --git a/internal/telebot/middleware/restrict.go b/internal/telebot/middleware/restrict.go
new file mode 100644
index 0000000..e35f462
--- /dev/null
+++ b/internal/telebot/middleware/restrict.go
@@ -0,0 +1,65 @@
+package middleware
+
+import tele "github.com/teknologi-umum/captcha/internal/telebot"
+
+// RestrictConfig defines config for Restrict middleware.
+type RestrictConfig struct {
+ // Chats is a list of chats that are going to be affected
+ // by either In or Out function.
+ Chats []int64
+
+ // In defines a function that will be called if the chat
+ // of an update will be found in the Chats list.
+ In tele.HandlerFunc
+
+ // Out defines a function that will be called if the chat
+ // of an update will NOT be found in the Chats list.
+ Out tele.HandlerFunc
+}
+
+// Restrict returns a middleware that handles a list of provided
+// chats with the logic defined by In and Out functions.
+// If the chat is found in the Chats field, In function will be called,
+// otherwise Out function will be called.
+func Restrict(v RestrictConfig) tele.MiddlewareFunc {
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ if v.In == nil {
+ v.In = next
+ }
+ if v.Out == nil {
+ v.Out = next
+ }
+ return func(c tele.Context) error {
+ for _, chat := range v.Chats {
+ if chat == c.Sender().ID {
+ return v.In(c)
+ }
+ }
+ return v.Out(c)
+ }
+ }
+}
+
+// Blacklist returns a middleware that skips the update for users
+// specified in the chats field.
+func Blacklist(chats ...int64) tele.MiddlewareFunc {
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return Restrict(RestrictConfig{
+ Chats: chats,
+ Out: next,
+ In: func(c tele.Context) error { return nil },
+ })(next)
+ }
+}
+
+// Whitelist returns a middleware that skips the update for users
+// NOT specified in the chats field.
+func Whitelist(chats ...int64) tele.MiddlewareFunc {
+ return func(next tele.HandlerFunc) tele.HandlerFunc {
+ return Restrict(RestrictConfig{
+ Chats: chats,
+ In: next,
+ Out: func(c tele.Context) error { return nil },
+ })(next)
+ }
+}
diff --git a/internal/telebot/options.go b/internal/telebot/options.go
new file mode 100644
index 0000000..56e0d9c
--- /dev/null
+++ b/internal/telebot/options.go
@@ -0,0 +1,226 @@
+package telebot
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// Option is a shortcut flag type for certain message features
+// (so-called options). It means that instead of passing
+// fully-fledged SendOptions* to Send(), you can use these
+// flags instead.
+//
+// Supported options are defined as iota-constants.
+type Option int
+
+const (
+ // NoPreview = SendOptions.DisableWebPagePreview
+ NoPreview Option = iota
+
+ // Silent = SendOptions.DisableNotification
+ Silent
+
+ // AllowWithoutReply = SendOptions.AllowWithoutReply
+ AllowWithoutReply
+
+ // Protected = SendOptions.Protected
+ Protected
+
+ // ForceReply = ReplyMarkup.ForceReply
+ ForceReply
+
+ // OneTimeKeyboard = ReplyMarkup.OneTimeKeyboard
+ OneTimeKeyboard
+
+ // RemoveKeyboard = ReplyMarkup.RemoveKeyboard
+ RemoveKeyboard
+)
+
+// Placeholder is used to set input field placeholder as a send option.
+func Placeholder(text string) *SendOptions {
+ return &SendOptions{
+ ReplyMarkup: &ReplyMarkup{
+ ForceReply: true,
+ Placeholder: text,
+ },
+ }
+}
+
+// SendOptions has most complete control over in what way the message
+// must be sent, providing an API-complete set of custom properties
+// and options.
+//
+// Despite its power, SendOptions is rather inconvenient to use all
+// the way through bot logic, so you might want to consider storing
+// and re-using it somewhere or be using Option flags instead.
+type SendOptions struct {
+ // If the message is a reply, original message.
+ ReplyTo *Message
+
+ // See ReplyMarkup struct definition.
+ ReplyMarkup *ReplyMarkup
+
+ // For text messages, disables previews for links in this message.
+ DisableWebPagePreview bool
+
+ // Sends the message silently. iOS users will not receive a notification, Android users will receive a notification with no sound.
+ DisableNotification bool
+
+ // ParseMode controls how client apps render your message.
+ ParseMode ParseMode
+
+ // Entities is a list of special entities that appear in message text, which can be specified instead of parse_mode.
+ Entities Entities
+
+ // AllowWithoutReply allows sending messages not a as reply if the replied-to message has already been deleted.
+ AllowWithoutReply bool
+
+ // Protected protects the contents of sent message from forwarding and saving.
+ Protected bool
+
+ // ThreadID supports sending messages to a thread.
+ ThreadID int
+
+ // HasSpoiler marks the message as containing a spoiler.
+ HasSpoiler bool
+
+}
+
+func (og *SendOptions) copy() *SendOptions {
+ cp := *og
+ if cp.ReplyMarkup != nil {
+ cp.ReplyMarkup = cp.ReplyMarkup.copy()
+ }
+ return &cp
+}
+
+func extractOptions(how []interface{}) *SendOptions {
+ opts := &SendOptions{}
+
+ for _, prop := range how {
+ switch opt := prop.(type) {
+ case *SendOptions:
+ opts = opt.copy()
+ case *ReplyMarkup:
+ if opt != nil {
+ opts.ReplyMarkup = opt.copy()
+ }
+ case Option:
+ switch opt {
+ case NoPreview:
+ opts.DisableWebPagePreview = true
+ case Silent:
+ opts.DisableNotification = true
+ case AllowWithoutReply:
+ opts.AllowWithoutReply = true
+ case ForceReply:
+ if opts.ReplyMarkup == nil {
+ opts.ReplyMarkup = &ReplyMarkup{}
+ }
+ opts.ReplyMarkup.ForceReply = true
+ case OneTimeKeyboard:
+ if opts.ReplyMarkup == nil {
+ opts.ReplyMarkup = &ReplyMarkup{}
+ }
+ opts.ReplyMarkup.OneTimeKeyboard = true
+ case RemoveKeyboard:
+ if opts.ReplyMarkup == nil {
+ opts.ReplyMarkup = &ReplyMarkup{}
+ }
+ opts.ReplyMarkup.RemoveKeyboard = true
+ case Protected:
+ opts.Protected = true
+ default:
+ panic("telebot: unsupported flag-option")
+ }
+ case ParseMode:
+ opts.ParseMode = opt
+ case Entities:
+ opts.Entities = opt
+ default:
+ panic("telebot: unsupported send-option")
+ }
+ }
+
+ return opts
+}
+
+func (b *Bot) embedSendOptions(params map[string]string, opt *SendOptions) {
+ if b.parseMode != ModeDefault {
+ params["parse_mode"] = b.parseMode
+ }
+
+ if opt == nil {
+ return
+ }
+
+ if opt.ReplyTo != nil && opt.ReplyTo.ID != 0 {
+ params["reply_to_message_id"] = strconv.Itoa(opt.ReplyTo.ID)
+ }
+
+ if opt.DisableWebPagePreview {
+ params["disable_web_page_preview"] = "true"
+ }
+
+ if opt.DisableNotification {
+ params["disable_notification"] = "true"
+ }
+
+ if opt.ParseMode != ModeDefault {
+ params["parse_mode"] = opt.ParseMode
+ }
+
+ if len(opt.Entities) > 0 {
+ delete(params, "parse_mode")
+ entities, _ := json.Marshal(opt.Entities)
+
+ if params["caption"] != "" {
+ params["caption_entities"] = string(entities)
+ } else {
+ params["entities"] = string(entities)
+ }
+ }
+
+ if opt.AllowWithoutReply {
+ params["allow_sending_without_reply"] = "true"
+ }
+
+ if opt.ReplyMarkup != nil {
+ processButtons(opt.ReplyMarkup.InlineKeyboard)
+ replyMarkup, _ := json.Marshal(opt.ReplyMarkup)
+ params["reply_markup"] = string(replyMarkup)
+ }
+
+ if opt.Protected {
+ params["protect_content"] = "true"
+ }
+
+ if opt.ThreadID != 0 {
+ params["message_thread_id"] = strconv.Itoa(opt.ThreadID)
+ }
+
+ if opt.HasSpoiler {
+ params["spoiler"] = "true"
+ }
+}
+
+func processButtons(keys [][]InlineButton) {
+ if keys == nil || len(keys) < 1 || len(keys[0]) < 1 {
+ return
+ }
+
+ for i := range keys {
+ for j := range keys[i] {
+ key := &keys[i][j]
+ if key.Unique != "" {
+ // Format: "\f|"
+ data := key.Data
+ if data == "" {
+ key.Data = "\f" + key.Unique
+ } else {
+ key.Data = "\f" + key.Unique + "|" + data
+ }
+ }
+ }
+ }
+}
diff --git a/internal/telebot/payments.go b/internal/telebot/payments.go
new file mode 100644
index 0000000..390d4c1
--- /dev/null
+++ b/internal/telebot/payments.go
@@ -0,0 +1,189 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "math"
+ "strconv"
+)
+
+// ShippingQuery contains information about an incoming shipping query.
+type ShippingQuery struct {
+ Sender *User `json:"from"`
+ ID string `json:"id"`
+ Payload string `json:"invoice_payload"`
+ Address ShippingAddress `json:"shipping_address"`
+}
+
+// ShippingAddress represents a shipping address.
+type ShippingAddress struct {
+ CountryCode string `json:"country_code"`
+ State string `json:"state"`
+ City string `json:"city"`
+ StreetLine1 string `json:"street_line1"`
+ StreetLine2 string `json:"street_line2"`
+ PostCode string `json:"post_code"`
+}
+
+// ShippingOption represents one shipping option.
+type ShippingOption struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Prices []Price `json:"prices"`
+}
+
+// Payment contains basic information about a successful payment.
+type Payment struct {
+ Currency string `json:"currency"`
+ Total int `json:"total_amount"`
+ Payload string `json:"invoice_payload"`
+ OptionID string `json:"shipping_option_id"`
+ Order Order `json:"order_info"`
+ TelegramChargeID string `json:"telegram_payment_charge_id"`
+ ProviderChargeID string `json:"provider_payment_charge_id"`
+}
+
+// PreCheckoutQuery contains information about an incoming pre-checkout query.
+type PreCheckoutQuery struct {
+ Sender *User `json:"from"`
+ ID string `json:"id"`
+ Currency string `json:"currency"`
+ Payload string `json:"invoice_payload"`
+ Total int `json:"total_amount"`
+ OptionID string `json:"shipping_option_id"`
+ Order Order `json:"order_info"`
+}
+
+// Order represents information about an order.
+type Order struct {
+ Name string `json:"name"`
+ PhoneNumber string `json:"phone_number"`
+ Email string `json:"email"`
+ Address ShippingAddress `json:"shipping_address"`
+}
+
+// Invoice contains basic information about an invoice.
+type Invoice struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Payload string `json:"payload"`
+ Currency string `json:"currency"`
+ Prices []Price `json:"prices"`
+ Token string `json:"provider_token"`
+ Data string `json:"provider_data"`
+
+ Photo *Photo `json:"photo"`
+ PhotoSize int `json:"photo_size"`
+
+ // Unique deep-linking parameter that can be used to
+ // generate this invoice when used as a start parameter (0).
+ Start string `json:"start_parameter"`
+
+ // Shows the total price in the smallest units of the currency.
+ // For example, for a price of US$ 1.45 pass amount = 145.
+ Total int `json:"total_amount"`
+
+ MaxTipAmount int `json:"max_tip_amount"`
+ SuggestedTipAmounts []int `json:"suggested_tip_amounts"`
+
+ NeedName bool `json:"need_name"`
+ NeedPhoneNumber bool `json:"need_phone_number"`
+ NeedEmail bool `json:"need_email"`
+ NeedShippingAddress bool `json:"need_shipping_address"`
+ SendPhoneNumber bool `json:"send_phone_number_to_provider"`
+ SendEmail bool `json:"send_email_to_provider"`
+ Flexible bool `json:"is_flexible"`
+}
+
+func (i Invoice) params() map[string]string {
+ params := map[string]string{
+ "title": i.Title,
+ "description": i.Description,
+ "start_parameter": i.Start,
+ "payload": i.Payload,
+ "provider_token": i.Token,
+ "provider_data": i.Data,
+ "currency": i.Currency,
+ "max_tip_amount": strconv.Itoa(i.MaxTipAmount),
+ "need_name": strconv.FormatBool(i.NeedName),
+ "need_phone_number": strconv.FormatBool(i.NeedPhoneNumber),
+ "need_email": strconv.FormatBool(i.NeedEmail),
+ "need_shipping_address": strconv.FormatBool(i.NeedShippingAddress),
+ "send_phone_number_to_provider": strconv.FormatBool(i.SendPhoneNumber),
+ "send_email_to_provider": strconv.FormatBool(i.SendEmail),
+ "is_flexible": strconv.FormatBool(i.Flexible),
+ }
+ if i.Photo != nil {
+ if i.Photo.FileURL != "" {
+ params["photo_url"] = i.Photo.FileURL
+ }
+ if i.PhotoSize > 0 {
+ params["photo_size"] = strconv.Itoa(i.PhotoSize)
+ }
+ if i.Photo.Width > 0 {
+ params["photo_width"] = strconv.Itoa(i.Photo.Width)
+ }
+ if i.Photo.Height > 0 {
+ params["photo_height"] = strconv.Itoa(i.Photo.Height)
+ }
+ }
+ if len(i.Prices) > 0 {
+ data, _ := json.Marshal(i.Prices)
+ params["prices"] = string(data)
+ }
+ if len(i.SuggestedTipAmounts) > 0 {
+ var amounts []string
+ for _, n := range i.SuggestedTipAmounts {
+ amounts = append(amounts, strconv.Itoa(n))
+ }
+
+ data, _ := json.Marshal(amounts)
+ params["suggested_tip_amounts"] = string(data)
+ }
+ return params
+}
+
+// Price represents a portion of the price for goods or services.
+type Price struct {
+ Label string `json:"label"`
+ Amount int `json:"amount"`
+}
+
+// Currency contains information about supported currency for payments.
+type Currency struct {
+ Code string `json:"code"`
+ Title string `json:"title"`
+ Symbol string `json:"symbol"`
+ Native string `json:"native"`
+ ThousandsSep string `json:"thousands_sep"`
+ DecimalSep string `json:"decimal_sep"`
+ SymbolLeft bool `json:"symbol_left"`
+ SpaceBetween bool `json:"space_between"`
+ Exp int `json:"exp"`
+ MinAmount interface{} `json:"min_amount"`
+ MaxAmount interface{} `json:"max_amount"`
+}
+
+func (c Currency) FromTotal(total int) float64 {
+ return float64(total) / math.Pow(10, float64(c.Exp))
+}
+
+func (c Currency) ToTotal(total float64) int {
+ return int(total) * int(math.Pow(10, float64(c.Exp)))
+}
+
+// CreateInvoiceLink creates a link for a payment invoice.
+func (b *Bot) CreateInvoiceLink(ctx context.Context, i Invoice) (string, error) {
+ data, err := b.Raw(ctx, "createInvoiceLink", i.params())
+ if err != nil {
+ return "", err
+ }
+
+ var resp struct {
+ Result string
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return "", wrapError(err)
+ }
+ return resp.Result, nil
+}
diff --git a/internal/telebot/payments_data.go b/internal/telebot/payments_data.go
new file mode 100644
index 0000000..a325c5b
--- /dev/null
+++ b/internal/telebot/payments_data.go
@@ -0,0 +1,14 @@
+package telebot
+
+import "encoding/json"
+
+const dataCurrencies = `{"AED":{"code":"AED","title":"United Arab Emirates Dirham","symbol":"AED","native":"\u062f.\u0625.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"367","max_amount":"3673200"},"AFN":{"code":"AFN","title":"Afghan Afghani","symbol":"AFN","native":"\u060b","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"7554","max_amount":"75540495"},"ALL":{"code":"ALL","title":"Albanian Lek","symbol":"ALL","native":"Lek","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":false,"exp":2,"min_amount":"10908","max_amount":"109085036"},"AMD":{"code":"AMD","title":"Armenian Dram","symbol":"AMD","native":"\u0564\u0580.","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"48398","max_amount":"483984962"},"ARS":{"code":"ARS","title":"Argentine Peso","symbol":"ARS","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3720","max_amount":"37202998"},"AUD":{"code":"AUD","title":"Australian Dollar","symbol":"AU$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"139","max_amount":"1392750"},"AZN":{"code":"AZN","title":"Azerbaijani Manat","symbol":"AZN","native":"\u043c\u0430\u043d.","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"170","max_amount":"1702500"},"BAM":{"code":"BAM","title":"Bosnia & Herzegovina Convertible Mark","symbol":"BAM","native":"KM","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"171","max_amount":"1715550"},"BDT":{"code":"BDT","title":"Bangladeshi Taka","symbol":"BDT","native":"\u09f3","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"8336","max_amount":"83367500"},"BGN":{"code":"BGN","title":"Bulgarian Lev","symbol":"BGN","native":"\u043b\u0432.","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"171","max_amount":"1716850"},"BND":{"code":"BND","title":"Brunei Dollar","symbol":"BND","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"134","max_amount":"1349850"},"BOB":{"code":"BOB","title":"Bolivian Boliviano","symbol":"BOB","native":"Bs","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"687","max_amount":"6877150"},"BRL":{"code":"BRL","title":"Brazilian Real","symbol":"R$","native":"R$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"377","max_amount":"3775397"},"CAD":{"code":"CAD","title":"Canadian Dollar","symbol":"CA$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"132","max_amount":"1321950"},"CHF":{"code":"CHF","title":"Swiss Franc","symbol":"CHF","native":"CHF","thousands_sep":"'","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"99","max_amount":"993220"},"CLP":{"code":"CLP","title":"Chilean Peso","symbol":"CLP","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":0,"min_amount":"666","max_amount":"6665199"},"CNY":{"code":"CNY","title":"Chinese Renminbi Yuan","symbol":"CN\u00a5","native":"CN\u00a5","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"674","max_amount":"6747298"},"COP":{"code":"COP","title":"Colombian Peso","symbol":"COP","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"315595","max_amount":"3155950000"},"CRC":{"code":"CRC","title":"Costa Rican Col\u00f3n","symbol":"CRC","native":"\u20a1","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"60113","max_amount":"601130282"},"CZK":{"code":"CZK","title":"Czech Koruna","symbol":"CZK","native":"K\u010d","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"2251","max_amount":"22510978"},"DKK":{"code":"DKK","title":"Danish Krone","symbol":"DKK","native":"kr","thousands_sep":"","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"654","max_amount":"6545403"},"DOP":{"code":"DOP","title":"Dominican Peso","symbol":"DOP","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"5032","max_amount":"50329504"},"DZD":{"code":"DZD","title":"Algerian Dinar","symbol":"DZD","native":"\u062f.\u062c.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"11872","max_amount":"118729869"},"EGP":{"code":"EGP","title":"Egyptian Pound","symbol":"EGP","native":"\u062c.\u0645.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1791","max_amount":"17912012"},"EUR":{"code":"EUR","title":"Euro","symbol":"\u20ac","native":"\u20ac","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"87","max_amount":"877155"},"GBP":{"code":"GBP","title":"British Pound","symbol":"\u00a3","native":"\u00a3","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"75","max_amount":"757605"},"GEL":{"code":"GEL","title":"Georgian Lari","symbol":"GEL","native":"GEL","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"266","max_amount":"2663750"},"GTQ":{"code":"GTQ","title":"Guatemalan Quetzal","symbol":"GTQ","native":"Q","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"768","max_amount":"7689850"},"HKD":{"code":"HKD","title":"Hong Kong Dollar","symbol":"HK$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"784","max_amount":"7845505"},"HNL":{"code":"HNL","title":"Honduran Lempira","symbol":"HNL","native":"L","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"2427","max_amount":"24277502"},"HRK":{"code":"HRK","title":"Croatian Kuna","symbol":"HRK","native":"kn","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"650","max_amount":"6506302"},"HUF":{"code":"HUF","title":"Hungarian Forint","symbol":"HUF","native":"Ft","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"27844","max_amount":"278440341"},"IDR":{"code":"IDR","title":"Indonesian Rupiah","symbol":"IDR","native":"Rp","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"1406555","max_amount":"14065550000"},"ILS":{"code":"ILS","title":"Israeli New Sheqel","symbol":"\u20aa","native":"\u20aa","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"366","max_amount":"3668230"},"INR":{"code":"INR","title":"Indian Rupee","symbol":"\u20b9","native":"\u20b9","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"7090","max_amount":"70900503"},"ISK":{"code":"ISK","title":"Icelandic Kr\u00f3na","symbol":"ISK","native":"kr","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":0,"min_amount":"119","max_amount":"1195599"},"JMD":{"code":"JMD","title":"Jamaican Dollar","symbol":"JMD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"13153","max_amount":"131539958"},"JPY":{"code":"JPY","title":"Japanese Yen","symbol":"\u00a5","native":"\uffe5","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"109","max_amount":"1095549"},"KES":{"code":"KES","title":"Kenyan Shilling","symbol":"KES","native":"Ksh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"10032","max_amount":"100322011"},"KGS":{"code":"KGS","title":"Kyrgyzstani Som","symbol":"KGS","native":"KGS","thousands_sep":"\u00a0","decimal_sep":"-","symbol_left":false,"space_between":true,"exp":2,"min_amount":"6982","max_amount":"69820300"},"KRW":{"code":"KRW","title":"South Korean Won","symbol":"\u20a9","native":"\u20a9","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"1119","max_amount":"11190001"},"KZT":{"code":"KZT","title":"Kazakhstani Tenge","symbol":"KZT","native":"\u20b8","thousands_sep":"\u00a0","decimal_sep":"-","symbol_left":true,"space_between":false,"exp":2,"min_amount":"37767","max_amount":"377674954"},"LBP":{"code":"LBP","title":"Lebanese Pound","symbol":"LBP","native":"\u0644.\u0644.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"150080","max_amount":"1500802255"},"LKR":{"code":"LKR","title":"Sri Lankan Rupee","symbol":"LKR","native":"\u0dbb\u0dd4.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"18078","max_amount":"180789638"},"MAD":{"code":"MAD","title":"Moroccan Dirham","symbol":"MAD","native":"\u062f.\u0645.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"955","max_amount":"9554850"},"MDL":{"code":"MDL","title":"Moldovan Leu","symbol":"MDL","native":"MDL","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1703","max_amount":"17038967"},"MNT":{"code":"MNT","title":"Mongolian T\u00f6gr\u00f6g","symbol":"MNT","native":"MNT","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"261750","max_amount":"2617500000"},"MUR":{"code":"MUR","title":"Mauritian Rupee","symbol":"MUR","native":"MUR","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3438","max_amount":"34384499"},"MVR":{"code":"MVR","title":"Maldivian Rufiyaa","symbol":"MVR","native":"MVR","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1550","max_amount":"15501063"},"MXN":{"code":"MXN","title":"Mexican Peso","symbol":"MX$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"1898","max_amount":"18988704"},"MYR":{"code":"MYR","title":"Malaysian Ringgit","symbol":"MYR","native":"RM","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"412","max_amount":"4124501"},"MZN":{"code":"MZN","title":"Mozambican Metical","symbol":"MZN","native":"MTn","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"6188","max_amount":"61889913"},"NGN":{"code":"NGN","title":"Nigerian Naira","symbol":"NGN","native":"\u20a6","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"36174","max_amount":"361749532"},"NIO":{"code":"NIO","title":"Nicaraguan C\u00f3rdoba","symbol":"NIO","native":"C$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3241","max_amount":"32415503"},"NOK":{"code":"NOK","title":"Norwegian Krone","symbol":"NOK","native":"kr","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"851","max_amount":"8510100"},"NPR":{"code":"NPR","title":"Nepalese Rupee","symbol":"NPR","native":"\u0928\u0947\u0930\u0942","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"11299","max_amount":"112995016"},"NZD":{"code":"NZD","title":"New Zealand Dollar","symbol":"NZ$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"146","max_amount":"1461850"},"PAB":{"code":"PAB","title":"Panamanian Balboa","symbol":"PAB","native":"B\/.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"99","max_amount":"995290"},"PEN":{"code":"PEN","title":"Peruvian Nuevo Sol","symbol":"PEN","native":"S\/.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"333","max_amount":"3331250"},"PHP":{"code":"PHP","title":"Philippine Peso","symbol":"PHP","native":"\u20b1","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"5260","max_amount":"52602981"},"PKR":{"code":"PKR","title":"Pakistani Rupee","symbol":"PKR","native":"\u20a8","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"13921","max_amount":"139214990"},"PLN":{"code":"PLN","title":"Polish Z\u0142oty","symbol":"PLN","native":"z\u0142","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"376","max_amount":"3764026"},"PYG":{"code":"PYG","title":"Paraguayan Guaran\u00ed","symbol":"PYG","native":"\u20b2","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":0,"min_amount":"6013","max_amount":"60134502"},"QAR":{"code":"QAR","title":"Qatari Riyal","symbol":"QAR","native":"\u0631.\u0642.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"364","max_amount":"3641101"},"RON":{"code":"RON","title":"Romanian Leu","symbol":"RON","native":"RON","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"417","max_amount":"4172003"},"RSD":{"code":"RSD","title":"Serbian Dinar","symbol":"RSD","native":"\u0434\u0438\u043d.","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"10391","max_amount":"103910127"},"RUB":{"code":"RUB","title":"Russian Ruble","symbol":"RUB","native":"\u0440\u0443\u0431.","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"6598","max_amount":"65986027"},"SAR":{"code":"SAR","title":"Saudi Riyal","symbol":"SAR","native":"\u0631.\u0633.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"373","max_amount":"3732650"},"SEK":{"code":"SEK","title":"Swedish Krona","symbol":"SEK","native":"kr","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"904","max_amount":"9047896"},"SGD":{"code":"SGD","title":"Singapore Dollar","symbol":"SGD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"135","max_amount":"1353897"},"THB":{"code":"THB","title":"Thai Baht","symbol":"\u0e3f","native":"\u0e3f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3156","max_amount":"31563499"},"TJS":{"code":"TJS","title":"Tajikistani Somoni","symbol":"TJS","native":"TJS","thousands_sep":"\u00a0","decimal_sep":";","symbol_left":false,"space_between":true,"exp":2,"min_amount":"938","max_amount":"9389950"},"TRY":{"code":"TRY","title":"Turkish Lira","symbol":"TRY","native":"TL","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"526","max_amount":"5267200"},"TTD":{"code":"TTD","title":"Trinidad and Tobago Dollar","symbol":"TTD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"675","max_amount":"6757850"},"TWD":{"code":"TWD","title":"New Taiwan Dollar","symbol":"NT$","native":"NT$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3072","max_amount":"30722993"},"TZS":{"code":"TZS","title":"Tanzanian Shilling","symbol":"TZS","native":"TSh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"230200","max_amount":"2302000188"},"UAH":{"code":"UAH","title":"Ukrainian Hryvnia","symbol":"UAH","native":"\u20b4","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":false,"exp":2,"min_amount":"2764","max_amount":"27648991"},"UGX":{"code":"UGX","title":"Ugandan Shilling","symbol":"UGX","native":"USh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"3657","max_amount":"36575502"},"USD":{"code":"USD","title":"United States Dollar","symbol":"$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"100","max_amount":1000000},"UYU":{"code":"UYU","title":"Uruguayan Peso","symbol":"UYU","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3246","max_amount":"32469503"},"UZS":{"code":"UZS","title":"Uzbekistani Som","symbol":"UZS","native":"UZS","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"832759","max_amount":"8327599915"},"VND":{"code":"VND","title":"Vietnamese \u0110\u1ed3ng","symbol":"\u20ab","native":"\u20ab","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":0,"min_amount":"23084","max_amount":"230840500"},"YER":{"code":"YER","title":"Yemeni Rial","symbol":"YER","native":"\u0631.\u064a.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"25030","max_amount":"250301249"},"ZAR":{"code":"ZAR","title":"South African Rand","symbol":"ZAR","native":"R","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1362","max_amount":"13620106"}}`
+
+var SupportedCurrencies = make(map[string]Currency)
+
+func init() {
+ err := json.Unmarshal([]byte(dataCurrencies), &SupportedCurrencies)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/telebot/poll.go b/internal/telebot/poll.go
new file mode 100644
index 0000000..8e2e509
--- /dev/null
+++ b/internal/telebot/poll.go
@@ -0,0 +1,75 @@
+package telebot
+
+import "time"
+
+// PollType defines poll types.
+type PollType string
+
+const (
+ // NOTE:
+ // Despite "any" type isn't described in documentation,
+ // it needed for proper KeyboardButtonPollType marshaling.
+ PollAny PollType = "any"
+
+ PollQuiz PollType = "quiz"
+ PollRegular PollType = "regular"
+)
+
+// Poll contains information about a poll.
+type Poll struct {
+ ID string `json:"id"`
+ Type PollType `json:"type"`
+ Question string `json:"question"`
+ Options []PollOption `json:"options"`
+ VoterCount int `json:"total_voter_count"`
+
+ // (Optional)
+ Closed bool `json:"is_closed,omitempty"`
+ CorrectOption int `json:"correct_option_id,omitempty"`
+ MultipleAnswers bool `json:"allows_multiple_answers,omitempty"`
+ Explanation string `json:"explanation,omitempty"`
+ ParseMode ParseMode `json:"explanation_parse_mode,omitempty"`
+ Entities []MessageEntity `json:"explanation_entities"`
+
+ // True by default, shouldn't be omitted.
+ Anonymous bool `json:"is_anonymous"`
+
+ // (Mutually exclusive)
+ OpenPeriod int `json:"open_period,omitempty"`
+ CloseUnixdate int64 `json:"close_date,omitempty"`
+}
+
+// PollOption contains information about one answer option in a poll.
+type PollOption struct {
+ Text string `json:"text"`
+ VoterCount int `json:"voter_count"`
+}
+
+// PollAnswer represents an answer of a user in a non-anonymous poll.
+type PollAnswer struct {
+ PollID string `json:"poll_id"`
+ Sender *User `json:"user"`
+ Options []int `json:"option_ids"`
+}
+
+// IsRegular says whether poll is a regular.
+func (p *Poll) IsRegular() bool {
+ return p.Type == PollRegular
+}
+
+// IsQuiz says whether poll is a quiz.
+func (p *Poll) IsQuiz() bool {
+ return p.Type == PollQuiz
+}
+
+// CloseDate returns the close date of poll in local time.
+func (p *Poll) CloseDate() time.Time {
+ return time.Unix(p.CloseUnixdate, 0)
+}
+
+// AddOptions adds text options to the poll.
+func (p *Poll) AddOptions(opts ...string) {
+ for _, t := range opts {
+ p.Options = append(p.Options, PollOption{Text: t})
+ }
+}
diff --git a/internal/telebot/poll_test.go b/internal/telebot/poll_test.go
new file mode 100644
index 0000000..48425b2
--- /dev/null
+++ b/internal/telebot/poll_test.go
@@ -0,0 +1,53 @@
+package telebot
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPoll(t *testing.T) {
+ assert.True(t, (&Poll{Type: PollRegular}).IsRegular())
+ assert.True(t, (&Poll{Type: PollQuiz}).IsQuiz())
+
+ p := &Poll{}
+ opts := []PollOption{{Text: "Option 1"}, {Text: "Option 2"}}
+ p.AddOptions(opts[0].Text, opts[1].Text)
+ assert.Equal(t, opts, p.Options)
+}
+
+func TestPollSend(t *testing.T) {
+ if b == nil {
+ t.Skip("Cached bot instance is bad (probably wrong or empty TELEBOT_SECRET)")
+ }
+ if userID == 0 {
+ t.Skip("USER_ID is required for Poll methods test")
+ }
+
+ _, err := b.Send(context.Background(), user, &Poll{}) // empty poll
+ assert.Equal(t, ErrBadPollOptions, err)
+
+ poll := &Poll{
+ Type: PollQuiz,
+ Question: "Test Poll",
+ CloseUnixdate: time.Now().Unix() + 60,
+ Explanation: "Explanation",
+ }
+ poll.AddOptions("1", "2")
+
+ msg, err := b.Send(context.Background(), user, poll)
+ require.NoError(t, err)
+ assert.Equal(t, poll.Type, msg.Poll.Type)
+ assert.Equal(t, poll.Question, msg.Poll.Question)
+ assert.Equal(t, poll.Options, msg.Poll.Options)
+ assert.Equal(t, poll.CloseUnixdate, msg.Poll.CloseUnixdate)
+ assert.Equal(t, poll.CloseDate(), msg.Poll.CloseDate())
+
+ p, err := b.StopPoll(context.Background(), msg)
+ require.NoError(t, err)
+ assert.Equal(t, poll.Options, p.Options)
+ assert.Equal(t, 0, p.VoterCount)
+}
diff --git a/internal/telebot/poller.go b/internal/telebot/poller.go
new file mode 100644
index 0000000..60e4224
--- /dev/null
+++ b/internal/telebot/poller.go
@@ -0,0 +1,116 @@
+package telebot
+
+import (
+ "context"
+ "time"
+)
+
+// Poller is a provider of Updates.
+//
+// All pollers must implement Poll(), which accepts bot
+// pointer and subscription channel and start polling
+// synchronously straight away.
+type Poller interface {
+ // Poll is supposed to take the bot object
+ // subscription channel and start polling
+ // for Updates immediately.
+ //
+ // Poller must listen for stop constantly and close
+ // it as soon as it's done polling.
+ Poll(ctx context.Context, b *Bot, updates chan Update, stop chan struct{})
+}
+
+// LongPoller is a classic LongPoller with timeout.
+type LongPoller struct {
+ Limit int
+ Timeout time.Duration
+ LastUpdateID int
+
+ // AllowedUpdates contains the update types
+ // you want your bot to receive.
+ //
+ // Possible values:
+ // message
+ // edited_message
+ // channel_post
+ // edited_channel_post
+ // inline_query
+ // chosen_inline_result
+ // callback_query
+ // shipping_query
+ // pre_checkout_query
+ // poll
+ // poll_answer
+ //
+ AllowedUpdates []string `yaml:"allowed_updates"`
+}
+
+// Poll does long polling.
+func (p *LongPoller) Poll(ctx context.Context, b *Bot, dest chan Update, stop chan struct{}) {
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+
+ updates, err := b.getUpdates(ctx, p.LastUpdateID+1, p.Limit, p.Timeout, p.AllowedUpdates)
+ if err != nil {
+ b.debug(err)
+ continue
+ }
+
+ for _, update := range updates {
+ p.LastUpdateID = update.ID
+ dest <- update
+ }
+ }
+}
+
+// MiddlewarePoller is a special kind of poller that acts
+// like a filter for updates. It could be used for spam
+// handling, banning or whatever.
+//
+// For heavy middleware, use increased capacity.
+type MiddlewarePoller struct {
+ Capacity int // Default: 1
+ Poller Poller
+ Filter func(*Update) bool
+}
+
+// NewMiddlewarePoller wait for it... constructs a new middleware poller.
+func NewMiddlewarePoller(original Poller, filter func(*Update) bool) *MiddlewarePoller {
+ return &MiddlewarePoller{
+ Poller: original,
+ Filter: filter,
+ }
+}
+
+// Poll sieves updates through middleware filter.
+func (p *MiddlewarePoller) Poll(ctx context.Context, b *Bot, dest chan Update, stop chan struct{}) {
+ if p.Capacity < 1 {
+ p.Capacity = 1
+ }
+
+ middle := make(chan Update, p.Capacity)
+ stopPoller := make(chan struct{})
+ stopConfirm := make(chan struct{})
+
+ go func() {
+ p.Poller.Poll(ctx, b, middle, stopPoller)
+ close(stopConfirm)
+ }()
+
+ for {
+ select {
+ case <-stop:
+ close(stopPoller)
+ <-stopConfirm
+ return
+ case upd := <-middle:
+ if p.Filter(&upd) {
+ dest <- upd
+ }
+ }
+ }
+}
diff --git a/internal/telebot/poller_test.go b/internal/telebot/poller_test.go
new file mode 100644
index 0000000..f8f2c70
--- /dev/null
+++ b/internal/telebot/poller_test.go
@@ -0,0 +1,68 @@
+package telebot
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type testPoller struct {
+ updates chan Update
+ done chan struct{}
+}
+
+func newTestPoller() *testPoller {
+ return &testPoller{
+ updates: make(chan Update, 1),
+ done: make(chan struct{}, 1),
+ }
+}
+
+func (p *testPoller) Poll(ctx context.Context, b *Bot, updates chan Update, stop chan struct{}) {
+ for {
+ select {
+ case upd := <-p.updates:
+ updates <- upd
+ case <-stop:
+ return
+ default:
+ }
+ }
+}
+
+func TestMiddlewarePoller(t *testing.T) {
+ tp := newTestPoller()
+ var ids []int
+
+ pref := defaultSettings()
+ pref.Offline = true
+
+ b, err := NewBot(pref)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ b.Poller = NewMiddlewarePoller(tp, func(u *Update) bool {
+ if u.ID > 0 {
+ ids = append(ids, u.ID)
+ return true
+ }
+
+ tp.done <- struct{}{}
+ return false
+ })
+
+ go func() {
+ tp.updates <- Update{ID: 1}
+ tp.updates <- Update{ID: 2}
+ tp.updates <- Update{ID: 0}
+ }()
+
+ go b.Start()
+ <-tp.done
+ b.Stop()
+
+ assert.Contains(t, ids, 1)
+ assert.Contains(t, ids, 2)
+}
diff --git a/internal/telebot/sendable.go b/internal/telebot/sendable.go
new file mode 100644
index 0000000..f260132
--- /dev/null
+++ b/internal/telebot/sendable.go
@@ -0,0 +1,408 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strconv"
+)
+
+// Recipient is any possible endpoint you can send
+// messages to: either user, group or a channel.
+type Recipient interface {
+ Recipient() string // must return legit Telegram chat_id or username
+}
+
+// Sendable is any object that can send itself.
+//
+// This is pretty cool, since it lets bots implement
+// custom Sendables for complex kind of media or
+// chat objects spanning across multiple messages.
+type Sendable interface {
+ Send(context.Context, *Bot, Recipient, *SendOptions) (*Message, error)
+}
+
+// Send delivers media through bot b to recipient.
+func (p *Photo) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "caption": p.Caption,
+ }
+ b.embedSendOptions(params, opt)
+
+ msg, err := b.sendMedia(ctx, p, params, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ msg.Photo.File.stealRef(&p.File)
+ *p = *msg.Photo
+ p.Caption = msg.Caption
+
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (a *Audio) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "caption": a.Caption,
+ "performer": a.Performer,
+ "title": a.Title,
+ "file_name": a.FileName,
+ }
+ b.embedSendOptions(params, opt)
+
+ if a.Duration != 0 {
+ params["duration"] = strconv.Itoa(a.Duration)
+ }
+
+ msg, err := b.sendMedia(ctx, a, params, thumbnailToFilemap(a.Thumbnail))
+ if err != nil {
+ return nil, err
+ }
+
+ if msg.Audio != nil {
+ msg.Audio.File.stealRef(&a.File)
+ *a = *msg.Audio
+ a.Caption = msg.Caption
+ }
+
+ if msg.Document != nil {
+ msg.Document.File.stealRef(&a.File)
+ a.File = msg.Document.File
+ }
+
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (d *Document) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "caption": d.Caption,
+ "file_name": d.FileName,
+ }
+ b.embedSendOptions(params, opt)
+
+ if d.FileSize != 0 {
+ params["file_size"] = strconv.FormatInt(d.FileSize, 10)
+ }
+ if d.DisableTypeDetection {
+ params["disable_content_type_detection"] = "true"
+ }
+
+ msg, err := b.sendMedia(ctx, d, params, thumbnailToFilemap(d.Thumbnail))
+ if err != nil {
+ return nil, err
+ }
+
+ if doc := msg.Document; doc != nil {
+ doc.File.stealRef(&d.File)
+ *d = *doc
+ d.Caption = msg.Caption
+ } else if vid := msg.Video; vid != nil {
+ vid.File.stealRef(&d.File)
+ d.Caption = vid.Caption
+ d.MIME = vid.MIME
+ d.Thumbnail = vid.Thumbnail
+ }
+
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (s *Sticker) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ }
+ b.embedSendOptions(params, opt)
+
+ msg, err := b.sendMedia(ctx, s, params, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ msg.Sticker.File.stealRef(&s.File)
+ *s = *msg.Sticker
+
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (v *Video) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "caption": v.Caption,
+ "file_name": v.FileName,
+ }
+ b.embedSendOptions(params, opt)
+
+ if v.Duration != 0 {
+ params["duration"] = strconv.Itoa(v.Duration)
+ }
+ if v.Width != 0 {
+ params["width"] = strconv.Itoa(v.Width)
+ }
+ if v.Height != 0 {
+ params["height"] = strconv.Itoa(v.Height)
+ }
+ if v.Streaming {
+ params["supports_streaming"] = "true"
+ }
+
+ msg, err := b.sendMedia(ctx, v, params, thumbnailToFilemap(v.Thumbnail))
+ if err != nil {
+ return nil, err
+ }
+
+ if vid := msg.Video; vid != nil {
+ vid.File.stealRef(&v.File)
+ *v = *vid
+ v.Caption = msg.Caption
+ } else if doc := msg.Document; doc != nil {
+ // If video has no sound, Telegram can turn it into Document (GIF)
+ doc.File.stealRef(&v.File)
+
+ v.Caption = doc.Caption
+ v.MIME = doc.MIME
+ v.Thumbnail = doc.Thumbnail
+ }
+
+ return msg, nil
+}
+
+// Send delivers animation through bot b to recipient.
+func (a *Animation) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "caption": a.Caption,
+ "file_name": a.FileName,
+ }
+ b.embedSendOptions(params, opt)
+
+ if a.Duration != 0 {
+ params["duration"] = strconv.Itoa(a.Duration)
+ }
+ if a.Width != 0 {
+ params["width"] = strconv.Itoa(a.Width)
+ }
+ if a.Height != 0 {
+ params["height"] = strconv.Itoa(a.Height)
+ }
+
+ // file_name is required, without it animation sends as a document
+ if params["file_name"] == "" && a.File.OnDisk() {
+ params["file_name"] = filepath.Base(a.File.FileLocal)
+ }
+
+ msg, err := b.sendMedia(ctx, a, params, thumbnailToFilemap(a.Thumbnail))
+ if err != nil {
+ return nil, err
+ }
+
+ if anim := msg.Animation; anim != nil {
+ anim.File.stealRef(&a.File)
+ *a = *msg.Animation
+ } else if doc := msg.Document; doc != nil {
+ *a = Animation{
+ File: doc.File,
+ Thumbnail: doc.Thumbnail,
+ MIME: doc.MIME,
+ FileName: doc.FileName,
+ }
+ }
+
+ a.Caption = msg.Caption
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (v *Voice) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "caption": v.Caption,
+ }
+ b.embedSendOptions(params, opt)
+
+ if v.Duration != 0 {
+ params["duration"] = strconv.Itoa(v.Duration)
+ }
+
+ msg, err := b.sendMedia(ctx, v, params, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ msg.Voice.File.stealRef(&v.File)
+ *v = *msg.Voice
+
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (v *VideoNote) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ }
+ b.embedSendOptions(params, opt)
+
+ if v.Duration != 0 {
+ params["duration"] = strconv.Itoa(v.Duration)
+ }
+ if v.Length != 0 {
+ params["length"] = strconv.Itoa(v.Length)
+ }
+
+ msg, err := b.sendMedia(ctx, v, params, thumbnailToFilemap(v.Thumbnail))
+ if err != nil {
+ return nil, err
+ }
+
+ msg.VideoNote.File.stealRef(&v.File)
+ *v = *msg.VideoNote
+
+ return msg, nil
+}
+
+// Send delivers media through bot b to recipient.
+func (x *Location) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "latitude": fmt.Sprintf("%f", x.Lat),
+ "longitude": fmt.Sprintf("%f", x.Lng),
+ "live_period": strconv.Itoa(x.LivePeriod),
+ }
+ if x.HorizontalAccuracy != nil {
+ params["horizontal_accuracy"] = fmt.Sprintf("%f", *x.HorizontalAccuracy)
+ }
+ if x.Heading != 0 {
+ params["heading"] = strconv.Itoa(x.Heading)
+ }
+ if x.AlertRadius != 0 {
+ params["proximity_alert_radius"] = strconv.Itoa(x.Heading)
+ }
+ b.embedSendOptions(params, opt)
+
+ data, err := b.Raw(ctx, "sendLocation", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Send delivers media through bot b to recipient.
+func (v *Venue) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "latitude": fmt.Sprintf("%f", v.Location.Lat),
+ "longitude": fmt.Sprintf("%f", v.Location.Lng),
+ "title": v.Title,
+ "address": v.Address,
+ "foursquare_id": v.FoursquareID,
+ "foursquare_type": v.FoursquareType,
+ "google_place_id": v.GooglePlaceID,
+ "google_place_type": v.GooglePlaceType,
+ }
+ b.embedSendOptions(params, opt)
+
+ data, err := b.Raw(ctx, "sendVenue", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Send delivers invoice through bot b to recipient.
+func (i *Invoice) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := i.params()
+ params["chat_id"] = to.Recipient()
+ b.embedSendOptions(params, opt)
+
+ data, err := b.Raw(ctx, "sendInvoice", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Send delivers poll through bot b to recipient.
+func (p *Poll) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "question": p.Question,
+ "type": string(p.Type),
+ "is_closed": strconv.FormatBool(p.Closed),
+ "is_anonymous": strconv.FormatBool(p.Anonymous),
+ "allows_multiple_answers": strconv.FormatBool(p.MultipleAnswers),
+ "correct_option_id": strconv.Itoa(p.CorrectOption),
+ }
+ if p.Explanation != "" {
+ params["explanation"] = p.Explanation
+ params["explanation_parse_mode"] = p.ParseMode
+ }
+ if p.OpenPeriod != 0 {
+ params["open_period"] = strconv.Itoa(p.OpenPeriod)
+ } else if p.CloseUnixdate != 0 {
+ params["close_date"] = strconv.FormatInt(p.CloseUnixdate, 10)
+ }
+ b.embedSendOptions(params, opt)
+
+ var options []string
+ for _, o := range p.Options {
+ options = append(options, o.Text)
+ }
+
+ opts, _ := json.Marshal(options)
+ params["options"] = string(opts)
+
+ data, err := b.Raw(ctx, "sendPoll", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Send delivers dice through bot b to recipient.
+func (d *Dice) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "emoji": string(d.Type),
+ }
+ b.embedSendOptions(params, opt)
+
+ data, err := b.Raw(ctx, "sendDice", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+// Send delivers game through bot b to recipient.
+func (g *Game) Send(ctx context.Context, b *Bot, to Recipient, opt *SendOptions) (*Message, error) {
+ params := map[string]string{
+ "chat_id": to.Recipient(),
+ "game_short_name": g.Name,
+ }
+ b.embedSendOptions(params, opt)
+
+ data, err := b.Raw(ctx, "sendGame", params)
+ if err != nil {
+ return nil, err
+ }
+
+ return extractMessage(data)
+}
+
+func thumbnailToFilemap(thumb *Photo) map[string]File {
+ if thumb != nil {
+ return map[string]File{"thumb": thumb.File}
+ }
+ return nil
+}
diff --git a/internal/telebot/stickers.go b/internal/telebot/stickers.go
new file mode 100644
index 0000000..096eea0
--- /dev/null
+++ b/internal/telebot/stickers.go
@@ -0,0 +1,212 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+)
+
+type StickerSetType = string
+
+const (
+ StickerRegular = "regular"
+ StickerMask = "mask"
+ StickerCustomEmoji = "custom_emoji"
+)
+
+// StickerSet represents a sticker set.
+type StickerSet struct {
+ Type StickerSetType `json:"sticker_type"`
+ Name string `json:"name"`
+ Title string `json:"title"`
+ Animated bool `json:"is_animated"`
+ Video bool `json:"is_video"`
+ Stickers []Sticker `json:"stickers"`
+ Thumbnail *Photo `json:"thumb"`
+ PNG *File `json:"png_sticker"`
+ TGS *File `json:"tgs_sticker"`
+ WebM *File `json:"webm_sticker"`
+ Emojis string `json:"emojis"`
+ ContainsMasks bool `json:"contains_masks"` // FIXME: can be removed
+ MaskPosition *MaskPosition `json:"mask_position"`
+}
+
+// MaskPosition describes the position on faces where
+// a mask should be placed by default.
+type MaskPosition struct {
+ Feature MaskFeature `json:"point"`
+ XShift float32 `json:"x_shift"`
+ YShift float32 `json:"y_shift"`
+ Scale float32 `json:"scale"`
+}
+
+// MaskFeature defines sticker mask position.
+type MaskFeature string
+
+const (
+ FeatureForehead MaskFeature = "forehead"
+ FeatureEyes MaskFeature = "eyes"
+ FeatureMouth MaskFeature = "mouth"
+ FeatureChin MaskFeature = "chin"
+)
+
+// UploadSticker uploads a PNG file with a sticker for later use.
+func (b *Bot) UploadSticker(ctx context.Context, to Recipient, png *File) (*File, error) {
+ files := map[string]File{
+ "png_sticker": *png,
+ }
+ params := map[string]string{
+ "user_id": to.Recipient(),
+ }
+
+ data, err := b.sendFiles(ctx, "uploadStickerFile", files, params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result File
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return &resp.Result, nil
+}
+
+// StickerSet returns a sticker set on success.
+func (b *Bot) StickerSet(ctx context.Context, name string) (*StickerSet, error) {
+ data, err := b.Raw(ctx, "getStickerSet", map[string]string{"name": name})
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *StickerSet
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// CreateStickerSet creates a new sticker set.
+func (b *Bot) CreateStickerSet(ctx context.Context, to Recipient, s StickerSet) error {
+ files := make(map[string]File)
+ if s.PNG != nil {
+ files["png_sticker"] = *s.PNG
+ }
+ if s.TGS != nil {
+ files["tgs_sticker"] = *s.TGS
+ }
+ if s.WebM != nil {
+ files["webm_sticker"] = *s.WebM
+ }
+
+ params := map[string]string{
+ "user_id": to.Recipient(),
+ "sticker_type": s.Type,
+ "name": s.Name,
+ "title": s.Title,
+ "emojis": s.Emojis,
+ "contains_masks": strconv.FormatBool(s.ContainsMasks),
+ }
+
+ if s.MaskPosition != nil {
+ data, _ := json.Marshal(&s.MaskPosition)
+ params["mask_position"] = string(data)
+ }
+
+ _, err := b.sendFiles(ctx, "createNewStickerSet", files, params)
+ return err
+}
+
+// AddSticker adds a new sticker to the existing sticker set.
+func (b *Bot) AddSticker(ctx context.Context, to Recipient, s StickerSet) error {
+ files := make(map[string]File)
+ if s.PNG != nil {
+ files["png_sticker"] = *s.PNG
+ } else if s.TGS != nil {
+ files["tgs_sticker"] = *s.TGS
+ } else if s.WebM != nil {
+ files["webm_sticker"] = *s.WebM
+ }
+
+ params := map[string]string{
+ "user_id": to.Recipient(),
+ "name": s.Name,
+ "emojis": s.Emojis,
+ }
+
+ if s.MaskPosition != nil {
+ data, _ := json.Marshal(&s.MaskPosition)
+ params["mask_position"] = string(data)
+ }
+
+ _, err := b.sendFiles(ctx, "addStickerToSet", files, params)
+ return err
+}
+
+// SetStickerPosition moves a sticker in set to a specific position.
+func (b *Bot) SetStickerPosition(ctx context.Context, sticker string, position int) error {
+ params := map[string]string{
+ "sticker": sticker,
+ "position": strconv.Itoa(position),
+ }
+
+ _, err := b.Raw(ctx, "setStickerPositionInSet", params)
+ return err
+}
+
+// DeleteSticker deletes a sticker from a set created by the bot.
+func (b *Bot) DeleteSticker(ctx context.Context, sticker string) error {
+ _, err := b.Raw(ctx, "deleteStickerFromSet", map[string]string{"sticker": sticker})
+ return err
+
+}
+
+// SetStickerSetThumb sets a thumbnail of the sticker set.
+// Animated thumbnails can be set for animated sticker sets only.
+//
+// Thumbnail must be a PNG image, up to 128 kilobytes in size
+// and have width and height exactly 100px, or a TGS animation
+// up to 32 kilobytes in size.
+//
+// Animated sticker set thumbnail can't be uploaded via HTTP URL.
+func (b *Bot) SetStickerSetThumb(ctx context.Context, to Recipient, s StickerSet) error {
+ files := make(map[string]File)
+ if s.PNG != nil {
+ files["thumb"] = *s.PNG
+ } else if s.TGS != nil {
+ files["thumb"] = *s.TGS
+ }
+
+ params := map[string]string{
+ "name": s.Name,
+ "user_id": to.Recipient(),
+ }
+
+ _, err := b.sendFiles(ctx, "setStickerSetThumb", files, params)
+ return err
+}
+
+// CustomEmojiStickers returns the information about custom emoji stickers by their ids.
+func (b *Bot) CustomEmojiStickers(ctx context.Context, ids []string) ([]Sticker, error) {
+ data, _ := json.Marshal(ids)
+
+ params := map[string]string{
+ "custom_emoji_ids": string(data),
+ }
+
+ data, err := b.Raw(ctx, "getCustomEmojiStickers", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []Sticker
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
diff --git a/internal/telebot/telebot.go b/internal/telebot/telebot.go
new file mode 100644
index 0000000..36e30f7
--- /dev/null
+++ b/internal/telebot/telebot.go
@@ -0,0 +1,150 @@
+// Package telebot is a framework for Telegram bots.
+//
+// Example:
+//
+// package main
+//
+// import (
+// "time"
+// tele "github.com/teknologi-umum/captcha/internal/telebot"
+// )
+//
+// func main() {
+// b, err := tele.NewBot(tele.Settings{
+// Token: "...",
+// Poller: &tele.LongPoller{Timeout: 10 * time.Second},
+// })
+// if err != nil {
+// return
+// }
+//
+// b.Handle("/start", func(c tele.Context) error {
+// return c.Send("Hello world!")
+// })
+//
+// b.Start()
+// }
+package telebot
+
+import "errors"
+
+var (
+ ErrBadRecipient = errors.New("telebot: recipient is nil")
+ ErrUnsupportedWhat = errors.New("telebot: unsupported what argument")
+ ErrCouldNotUpdate = errors.New("telebot: could not fetch new updates")
+ ErrTrueResult = errors.New("telebot: result is True")
+ ErrBadContext = errors.New("telebot: context does not contain message")
+)
+
+const DefaultApiURL = "https://api.telegram.org"
+
+// These are one of the possible events Handle() can deal with.
+//
+// For convenience, all Telebot-provided endpoints start with
+// an "alert" character \a.
+const (
+ // Basic message handlers.
+ OnText = "\atext"
+ OnEdited = "\aedited"
+ OnPhoto = "\aphoto"
+ OnAudio = "\aaudio"
+ OnAnimation = "\aanimation"
+ OnDocument = "\adocument"
+ OnSticker = "\asticker"
+ OnVideo = "\avideo"
+ OnVoice = "\avoice"
+ OnVideoNote = "\avideo_note"
+ OnContact = "\acontact"
+ OnLocation = "\alocation"
+ OnVenue = "\avenue"
+ OnDice = "\adice"
+ OnInvoice = "\ainvoice"
+ OnPayment = "\apayment"
+ OnGame = "\agame"
+ OnPoll = "\apoll"
+ OnPollAnswer = "\apoll_answer"
+ OnPinned = "\apinned"
+ OnChannelPost = "\achannel_post"
+ OnEditedChannelPost = "\aedited_channel_post"
+ OnTopicCreated = "\atopic_created"
+ OnTopicReopened = "\atopic_reopened"
+ OnTopicClosed = "\atopic_closed"
+ OnTopicEdited = "\atopic_edited"
+ OnGeneralTopicHidden = "\ageneral_topic_hidden"
+ OnGeneralTopicUnhidden = "\ageneral_topic_unhidden"
+ OnWriteAccessAllowed = "\awrite_access_allowed"
+
+ OnAddedToGroup = "\aadded_to_group"
+ OnUserJoined = "\auser_joined"
+ OnUserLeft = "\auser_left"
+ OnUserShared = "\auser_shared"
+ OnChatShared = "\achat_shared"
+ OnNewGroupTitle = "\anew_chat_title"
+ OnNewGroupPhoto = "\anew_chat_photo"
+ OnGroupPhotoDeleted = "\achat_photo_deleted"
+ OnGroupCreated = "\agroup_created"
+ OnSuperGroupCreated = "\asupergroup_created"
+ OnChannelCreated = "\achannel_created"
+
+ // OnMigration happens when group switches to
+ // a supergroup. You might want to update
+ // your internal references to this chat
+ // upon switching as its ID will change.
+ OnMigration = "\amigration"
+
+ OnMedia = "\amedia"
+ OnCallback = "\acallback"
+ OnQuery = "\aquery"
+ OnInlineResult = "\ainline_result"
+ OnShipping = "\ashipping_query"
+ OnCheckout = "\apre_checkout_query"
+ OnMyChatMember = "\amy_chat_member"
+ OnChatMember = "\achat_member"
+ OnChatJoinRequest = "\achat_join_request"
+ OnProximityAlert = "\aproximity_alert_triggered"
+ OnAutoDeleteTimer = "\amessage_auto_delete_timer_changed"
+ OnWebApp = "\aweb_app"
+
+ OnVideoChatStarted = "\avideo_chat_started"
+ OnVideoChatEnded = "\avideo_chat_ended"
+ OnVideoChatParticipants = "\avideo_chat_participants_invited"
+ OnVideoChatScheduled = "\avideo_chat_scheduled"
+)
+
+// ChatAction is a client-side status indicating bot activity.
+type ChatAction string
+
+const (
+ Typing ChatAction = "typing"
+ UploadingPhoto ChatAction = "upload_photo"
+ UploadingVideo ChatAction = "upload_video"
+ UploadingAudio ChatAction = "upload_audio"
+ UploadingDocument ChatAction = "upload_document"
+ UploadingVNote ChatAction = "upload_video_note"
+ RecordingVideo ChatAction = "record_video"
+ RecordingAudio ChatAction = "record_audio"
+ RecordingVNote ChatAction = "record_video_note"
+ FindingLocation ChatAction = "find_location"
+ ChoosingSticker ChatAction = "choose_sticker"
+)
+
+// ParseMode determines the way client applications treat the text of the message
+type ParseMode = string
+
+const (
+ ModeDefault ParseMode = ""
+ ModeMarkdown ParseMode = "Markdown"
+ ModeMarkdownV2 ParseMode = "MarkdownV2"
+ ModeHTML ParseMode = "HTML"
+)
+
+// M is a shortcut for map[string]interface{}.
+// Useful for passing arguments to the layout functions.
+type M = map[string]interface{}
+
+// Flag returns a pointer to the given bool.
+// Useful for passing the three-state flags to a Bot API.
+// For example, see ReplyRecipient type.
+func Flag(b bool) *bool {
+ return &b
+}
diff --git a/internal/telebot/topic.go b/internal/telebot/topic.go
new file mode 100644
index 0000000..fa9a080
--- /dev/null
+++ b/internal/telebot/topic.go
@@ -0,0 +1,173 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+)
+
+type Topic struct {
+ Name string `json:"name"`
+ IconColor int `json:"icon_color"`
+ IconCustomEmojiID string `json:"icon_custom_emoji_id"`
+ ThreadID int `json:"message_thread_id"`
+}
+
+// CreateTopic creates a topic in a forum supergroup chat.
+func (b *Bot) CreateTopic(ctx context.Context, chat *Chat, topic *Topic) (*Topic, error) {
+ params := map[string]string{
+ "chat_id": chat.Recipient(),
+ "name": topic.Name,
+ }
+
+ if topic.IconColor != 0 {
+ params["icon_color"] = strconv.Itoa(topic.IconColor)
+ }
+ if topic.IconCustomEmojiID != "" {
+ params["icon_custom_emoji_id"] = topic.IconCustomEmojiID
+ }
+
+ data, err := b.Raw(ctx, "createForumTopic", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result *Topic
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, err
+}
+
+// EditTopic edits name and icon of a topic in a forum supergroup chat.
+func (b *Bot) EditTopic(ctx context.Context, chat *Chat, topic *Topic) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "message_thread_id": topic.ThreadID,
+ }
+
+ if topic.Name != "" {
+ params["name"] = topic.Name
+ }
+ if topic.IconCustomEmojiID != "" {
+ params["icon_custom_emoji_id"] = topic.IconCustomEmojiID
+ }
+
+ _, err := b.Raw(ctx, "editForumTopic", params)
+ return err
+}
+
+// CloseTopic closes an open topic in a forum supergroup chat.
+func (b *Bot) CloseTopic(ctx context.Context, chat *Chat, topic *Topic) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "message_thread_id": topic.ThreadID,
+ }
+
+ _, err := b.Raw(ctx, "closeForumTopic", params)
+ return err
+}
+
+// ReopenTopic reopens a closed topic in a forum supergroup chat.
+func (b *Bot) ReopenTopic(ctx context.Context, chat *Chat, topic *Topic) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "message_thread_id": topic.ThreadID,
+ }
+
+ _, err := b.Raw(ctx, "reopenForumTopic", params)
+ return err
+}
+
+// DeleteTopic deletes a forum topic along with all its messages in a forum supergroup chat.
+func (b *Bot) DeleteTopic(ctx context.Context, chat *Chat, topic *Topic) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "message_thread_id": topic.ThreadID,
+ }
+
+ _, err := b.Raw(ctx, "deleteForumTopic", params)
+ return err
+}
+
+// UnpinAllTopicMessages clears the list of pinned messages in a forum topic. The bot must be an administrator in the chat for this to work and must have the can_pin_messages administrator right in the supergroup.
+func (b *Bot) UnpinAllTopicMessages(ctx context.Context, chat *Chat, topic *Topic) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "message_thread_id": topic.ThreadID,
+ }
+
+ _, err := b.Raw(ctx, "unpinAllForumTopicMessages", params)
+ return err
+}
+
+// TopicIconStickers gets custom emoji stickers, which can be used as a forum topic icon by any user.
+func (b *Bot) TopicIconStickers(ctx context.Context) ([]Sticker, error) {
+ params := map[string]string{}
+
+ data, err := b.Raw(ctx, "getForumTopicIconStickers", params)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result []Sticker
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return resp.Result, nil
+}
+
+// EditGeneralTopic edits name of the 'General' topic in a forum supergroup chat.
+func (b *Bot) EditGeneralTopic(ctx context.Context, chat *Chat, topic *Topic) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ "name": topic.Name,
+ }
+
+ _, err := b.Raw(ctx, "editGeneralForumTopic", params)
+ return err
+}
+
+// CloseGeneralTopic closes an open 'General' topic in a forum supergroup chat.
+func (b *Bot) CloseGeneralTopic(ctx context.Context, chat *Chat) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "closeGeneralForumTopic", params)
+ return err
+}
+
+// ReopenGeneralTopic reopens a closed 'General' topic in a forum supergroup chat.
+func (b *Bot) ReopenGeneralTopic(ctx context.Context, chat *Chat) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "reopenGeneralForumTopic", params)
+ return err
+}
+
+// HideGeneralTopic hides the 'General' topic in a forum supergroup chat.
+func (b *Bot) HideGeneralTopic(ctx context.Context, chat *Chat) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "hideGeneralForumTopic", params)
+ return err
+}
+
+// UnhideGeneralTopic unhides the 'General' topic in a forum supergroup chat.
+func (b *Bot) UnhideGeneralTopic(ctx context.Context, chat *Chat) error {
+ params := map[string]interface{}{
+ "chat_id": chat.Recipient(),
+ }
+
+ _, err := b.Raw(ctx, "unhideGeneralForumTopic", params)
+ return err
+}
diff --git a/internal/telebot/update.go b/internal/telebot/update.go
new file mode 100644
index 0000000..12a065a
--- /dev/null
+++ b/internal/telebot/update.go
@@ -0,0 +1,375 @@
+package telebot
+
+import "strings"
+
+// Update object represents an incoming update.
+type Update struct {
+ ID int `json:"update_id"`
+
+ Message *Message `json:"message,omitempty"`
+ EditedMessage *Message `json:"edited_message,omitempty"`
+ ChannelPost *Message `json:"channel_post,omitempty"`
+ EditedChannelPost *Message `json:"edited_channel_post,omitempty"`
+ Callback *Callback `json:"callback_query,omitempty"`
+ Query *Query `json:"inline_query,omitempty"`
+ InlineResult *InlineResult `json:"chosen_inline_result,omitempty"`
+ ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"`
+ PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"`
+ Poll *Poll `json:"poll,omitempty"`
+ PollAnswer *PollAnswer `json:"poll_answer,omitempty"`
+ MyChatMember *ChatMemberUpdate `json:"my_chat_member,omitempty"`
+ ChatMember *ChatMemberUpdate `json:"chat_member,omitempty"`
+ ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"`
+}
+
+// ProcessUpdate processes a single incoming update.
+// A started bot calls this function automatically.
+func (b *Bot) ProcessUpdate(u Update) {
+ c := b.NewContext(u)
+
+ if u.Message != nil {
+ m := u.Message
+
+ if m.PinnedMessage != nil {
+ b.handle(OnPinned, c)
+ return
+ }
+
+ // Commands
+ if m.Text != "" {
+ // Filtering malicious messages
+ if m.Text[0] == '\a' {
+ return
+ }
+
+ match := cmdRx.FindAllStringSubmatch(m.Text, -1)
+ if match != nil {
+ // Syntax: "@ "
+ command, botName := match[0][1], match[0][3]
+
+ if botName != "" && !strings.EqualFold(b.Me.Username, botName) {
+ return
+ }
+
+ m.Payload = match[0][5]
+ if b.handle(command, c) {
+ return
+ }
+ }
+
+ // 1:1 satisfaction
+ if b.handle(m.Text, c) {
+ return
+ }
+
+ b.handle(OnText, c)
+ return
+ }
+
+ if b.handleMedia(c) {
+ return
+ }
+
+ if m.Contact != nil {
+ b.handle(OnContact, c)
+ return
+ }
+ if m.Location != nil {
+ b.handle(OnLocation, c)
+ return
+ }
+ if m.Venue != nil {
+ b.handle(OnVenue, c)
+ return
+ }
+ if m.Game != nil {
+ b.handle(OnGame, c)
+ return
+ }
+ if m.Dice != nil {
+ b.handle(OnDice, c)
+ return
+ }
+ if m.Invoice != nil {
+ b.handle(OnInvoice, c)
+ return
+ }
+ if m.Payment != nil {
+ b.handle(OnPayment, c)
+ return
+ }
+
+ if m.TopicCreated != nil {
+ b.handle(OnTopicCreated, c)
+ return
+ }
+ if m.TopicReopened != nil {
+ b.handle(OnTopicReopened, c)
+ return
+ }
+ if m.TopicClosed != nil {
+ b.handle(OnTopicClosed, c)
+ return
+ }
+ if m.TopicEdited != nil {
+ b.handle(OnTopicEdited, c)
+ return
+ }
+ if m.GeneralTopicHidden != nil {
+ b.handle(OnGeneralTopicHidden, c)
+ return
+ }
+ if m.GeneralTopicUnhidden != nil {
+ b.handle(OnGeneralTopicUnhidden, c)
+ return
+ }
+ if m.WriteAccessAllowed != nil {
+ b.handle(OnWriteAccessAllowed, c)
+ return
+ }
+
+ wasAdded := (m.UserJoined != nil && m.UserJoined.ID == b.Me.ID) ||
+ (m.UsersJoined != nil && isUserInList(b.Me, m.UsersJoined))
+ if m.GroupCreated || m.SuperGroupCreated || wasAdded {
+ b.handle(OnAddedToGroup, c)
+ return
+ }
+
+ if m.UserJoined != nil {
+ b.handle(OnUserJoined, c)
+ return
+ }
+ if m.UsersJoined != nil {
+ for _, user := range m.UsersJoined {
+ m.UserJoined = &user
+ b.handle(OnUserJoined, c)
+ }
+ return
+ }
+ if m.UserLeft != nil {
+ b.handle(OnUserLeft, c)
+ return
+ }
+
+ if m.UserShared != nil {
+ b.handle(OnUserShared, c)
+ return
+ }
+ if m.ChatShared != nil {
+ b.handle(OnChatShared, c)
+ return
+ }
+
+ if m.NewGroupTitle != "" {
+ b.handle(OnNewGroupTitle, c)
+ return
+ }
+ if m.NewGroupPhoto != nil {
+ b.handle(OnNewGroupPhoto, c)
+ return
+ }
+ if m.GroupPhotoDeleted {
+ b.handle(OnGroupPhotoDeleted, c)
+ return
+ }
+
+ if m.GroupCreated {
+ b.handle(OnGroupCreated, c)
+ return
+ }
+ if m.SuperGroupCreated {
+ b.handle(OnSuperGroupCreated, c)
+ return
+ }
+ if m.ChannelCreated {
+ b.handle(OnChannelCreated, c)
+ return
+ }
+
+ if m.MigrateTo != 0 {
+ m.MigrateFrom = m.Chat.ID
+ b.handle(OnMigration, c)
+ return
+ }
+
+ if m.VideoChatStarted != nil {
+ b.handle(OnVideoChatStarted, c)
+ return
+ }
+ if m.VideoChatEnded != nil {
+ b.handle(OnVideoChatEnded, c)
+ return
+ }
+ if m.VideoChatParticipants != nil {
+ b.handle(OnVideoChatParticipants, c)
+ return
+ }
+ if m.VideoChatScheduled != nil {
+ b.handle(OnVideoChatScheduled, c)
+ return
+ }
+
+ if m.WebAppData != nil {
+ b.handle(OnWebApp, c)
+ return
+ }
+
+ if m.ProximityAlert != nil {
+ b.handle(OnProximityAlert, c)
+ return
+ }
+ if m.AutoDeleteTimer != nil {
+ b.handle(OnAutoDeleteTimer, c)
+ return
+ }
+ }
+
+ if u.EditedMessage != nil {
+ b.handle(OnEdited, c)
+ return
+ }
+
+ if u.ChannelPost != nil {
+ m := u.ChannelPost
+
+ if m.PinnedMessage != nil {
+ b.handle(OnPinned, c)
+ return
+ }
+
+ b.handle(OnChannelPost, c)
+ return
+ }
+
+ if u.EditedChannelPost != nil {
+ b.handle(OnEditedChannelPost, c)
+ return
+ }
+
+ if u.Callback != nil {
+ if data := u.Callback.Data; data != "" && data[0] == '\f' {
+ match := cbackRx.FindAllStringSubmatch(data, -1)
+ if match != nil {
+ unique, payload := match[0][1], match[0][3]
+ if handler, ok := b.handlers["\f"+unique]; ok {
+ u.Callback.Unique = unique
+ u.Callback.Data = payload
+ b.runHandler(handler, c)
+ return
+ }
+ }
+ }
+
+ b.handle(OnCallback, c)
+ return
+ }
+
+ if u.Query != nil {
+ b.handle(OnQuery, c)
+ return
+ }
+
+ if u.InlineResult != nil {
+ b.handle(OnInlineResult, c)
+ return
+ }
+
+ if u.ShippingQuery != nil {
+ b.handle(OnShipping, c)
+ return
+ }
+
+ if u.PreCheckoutQuery != nil {
+ b.handle(OnCheckout, c)
+ return
+ }
+
+ if u.Poll != nil {
+ b.handle(OnPoll, c)
+ return
+ }
+
+ if u.PollAnswer != nil {
+ b.handle(OnPollAnswer, c)
+ return
+ }
+
+ if u.MyChatMember != nil {
+ b.handle(OnMyChatMember, c)
+ return
+ }
+
+ if u.ChatMember != nil {
+ b.handle(OnChatMember, c)
+ return
+ }
+
+ if u.ChatJoinRequest != nil {
+ b.handle(OnChatJoinRequest, c)
+ return
+ }
+}
+
+func (b *Bot) handle(end string, c Context) bool {
+ if handler, ok := b.handlers[end]; ok {
+ b.runHandler(handler, c)
+ return true
+ }
+ return false
+}
+
+func (b *Bot) handleMedia(c Context) bool {
+ var (
+ m = c.Message()
+ fired = true
+ )
+
+ switch {
+ case m.Photo != nil:
+ fired = b.handle(OnPhoto, c)
+ case m.Voice != nil:
+ fired = b.handle(OnVoice, c)
+ case m.Audio != nil:
+ fired = b.handle(OnAudio, c)
+ case m.Animation != nil:
+ fired = b.handle(OnAnimation, c)
+ case m.Document != nil:
+ fired = b.handle(OnDocument, c)
+ case m.Sticker != nil:
+ fired = b.handle(OnSticker, c)
+ case m.Video != nil:
+ fired = b.handle(OnVideo, c)
+ case m.VideoNote != nil:
+ fired = b.handle(OnVideoNote, c)
+ default:
+ return false
+ }
+
+ if !fired {
+ return b.handle(OnMedia, c)
+ }
+
+ return true
+}
+
+func (b *Bot) runHandler(h HandlerFunc, c Context) {
+ f := func() {
+ if err := h(c); err != nil {
+ b.OnError(err, c)
+ }
+ }
+ if b.synchronous {
+ f()
+ } else {
+ go f()
+ }
+}
+
+func isUserInList(user *User, list []User) bool {
+ for _, user2 := range list {
+ if user.ID == user2.ID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/telebot/video_chat.go b/internal/telebot/video_chat.go
new file mode 100644
index 0000000..4952e36
--- /dev/null
+++ b/internal/telebot/video_chat.go
@@ -0,0 +1,31 @@
+package telebot
+
+import "time"
+
+type (
+ // VideoChatStarted represents a service message about a video chat
+ // started in the chat.
+ VideoChatStarted struct{}
+
+ // VideoChatEnded represents a service message about a video chat
+ // ended in the chat.
+ VideoChatEnded struct {
+ Duration int `json:"duration"` // in seconds
+ }
+
+ // VideoChatParticipants represents a service message about new
+ // members invited to a video chat
+ VideoChatParticipants struct {
+ Users []User `json:"users"`
+ }
+
+ // VideoChatScheduled represents a service message about a video chat scheduled in the chat.
+ VideoChatScheduled struct {
+ Unixtime int64 `json:"start_date"`
+ }
+)
+
+// StartsAt returns the point when the video chat is supposed to be started by a chat administrator.
+func (v *VideoChatScheduled) StartsAt() time.Time {
+ return time.Unix(v.Unixtime, 0)
+}
diff --git a/internal/telebot/web_app.go b/internal/telebot/web_app.go
new file mode 100644
index 0000000..e5c9070
--- /dev/null
+++ b/internal/telebot/web_app.go
@@ -0,0 +1,24 @@
+package telebot
+
+// WebApp represents a parameter of the inline keyboard button
+// or the keyboard button used to launch Web App.
+type WebApp struct {
+ URL string `json:"url"`
+}
+
+// WebAppMessage describes an inline message sent by a Web App on behalf of a user.
+type WebAppMessage struct {
+ InlineMessageID string `json:"inline_message_id"`
+}
+
+// WebAppData object represents a data sent from a Web App to the bot
+type WebAppData struct {
+ Data string `json:"data"`
+ Text string `json:"button_text"`
+}
+
+// WebAppAccessAllowed represents a service message about a user allowing
+// a bot to write messages after adding the bot to the attachment menu or launching a Web App from a link.
+type WriteAccessAllowed struct {
+ WebAppName string `json:"web_app_name,omitempty"`
+}
diff --git a/internal/telebot/webhook.go b/internal/telebot/webhook.go
new file mode 100644
index 0000000..42fbe0d
--- /dev/null
+++ b/internal/telebot/webhook.go
@@ -0,0 +1,206 @@
+package telebot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+)
+
+// A WebhookTLS specifies the path to a key and a cert so the poller can open
+// a TLS listener.
+type WebhookTLS struct {
+ Key string `json:"key"`
+ Cert string `json:"cert"`
+}
+
+// A WebhookEndpoint describes the endpoint to which telegram will send its requests.
+// This must be a public URL and can be a loadbalancer or something similar. If the
+// endpoint uses TLS and the certificate is self-signed you have to add the certificate
+// path of this certificate so telegram will trust it. This field can be ignored if you
+// have a trusted certificate (letsencrypt, ...).
+type WebhookEndpoint struct {
+ PublicURL string `json:"public_url"`
+ Cert string `json:"cert"`
+}
+
+// A Webhook configures the poller for webhooks. It opens a port on the given
+// listen address. If TLS is filled, the listener will use the key and cert to open
+// a secure port. Otherwise it will use plain HTTP.
+//
+// If you have a loadbalancer ore other infrastructure in front of your service, you
+// must fill the Endpoint structure so this poller will send this data to telegram. If
+// you leave these values empty, your local address will be sent to telegram which is mostly
+// not what you want (at least while developing). If you have a single instance of your
+// bot you should consider to use the LongPoller instead of a WebHook.
+//
+// You can also leave the Listen field empty. In this case it is up to the caller to
+// add the Webhook to a http-mux.
+type Webhook struct {
+ Listen string `json:"url"`
+ MaxConnections int `json:"max_connections"`
+ AllowedUpdates []string `json:"allowed_updates"`
+ IP string `json:"ip_address"`
+ DropUpdates bool `json:"drop_pending_updates"`
+ SecretToken string `json:"secret_token"`
+
+ // (WebhookInfo)
+ HasCustomCert bool `json:"has_custom_certificate"`
+ PendingUpdates int `json:"pending_update_count"`
+ ErrorUnixtime int64 `json:"last_error_date"`
+ ErrorMessage string `json:"last_error_message"`
+ SyncErrorUnixtime int64 `json:"last_synchronization_error_date"`
+
+ TLS *WebhookTLS
+ Endpoint *WebhookEndpoint
+
+ dest chan<- Update
+ bot *Bot
+}
+
+func (h *Webhook) getFiles() map[string]File {
+ m := make(map[string]File)
+
+ if h.TLS != nil {
+ m["certificate"] = FromDisk(h.TLS.Cert)
+ }
+ // check if it is overwritten by an endpoint
+ if h.Endpoint != nil {
+ if h.Endpoint.Cert == "" {
+ // this can be the case if there is a loadbalancer or reverseproxy in
+ // front with a public cert. in this case we do not need to upload it
+ // to telegram. we delete the certificate from the map, because someone
+ // can have an internal TLS listener with a private cert
+ delete(m, "certificate")
+ } else {
+ // someone configured a certificate
+ m["certificate"] = FromDisk(h.Endpoint.Cert)
+ }
+ }
+ return m
+}
+
+func (h *Webhook) getParams() map[string]string {
+ params := make(map[string]string)
+
+ if h.MaxConnections != 0 {
+ params["max_connections"] = strconv.Itoa(h.MaxConnections)
+ }
+ if len(h.AllowedUpdates) > 0 {
+ data, _ := json.Marshal(h.AllowedUpdates)
+ params["allowed_updates"] = string(data)
+ }
+ if h.IP != "" {
+ params["ip_address"] = h.IP
+ }
+ if h.DropUpdates {
+ params["drop_pending_updates"] = strconv.FormatBool(h.DropUpdates)
+ }
+ if h.SecretToken != "" {
+ params["secret_token"] = h.SecretToken
+ }
+
+ if h.TLS != nil {
+ params["url"] = "https://" + h.Listen
+ } else {
+ // this will not work with telegram, they want TLS
+ // but i allow this because telegram will send an error
+ // when you register this hook. in their docs they write
+ // that port 80/http is allowed ...
+ params["url"] = "http://" + h.Listen
+ }
+ if h.Endpoint != nil {
+ params["url"] = h.Endpoint.PublicURL
+ }
+ return params
+}
+
+func (h *Webhook) Poll(ctx context.Context, b *Bot, dest chan Update, stop chan struct{}) {
+ if err := b.SetWebhook(ctx, h); err != nil {
+ b.OnError(err, nil)
+ close(stop)
+ return
+ }
+
+ // store the variables so the HTTP-handler can use 'em
+ h.dest = dest
+ h.bot = b
+
+ if h.Listen == "" {
+ h.waitForStop(stop)
+ return
+ }
+
+ s := &http.Server{
+ Addr: h.Listen,
+ Handler: h,
+ }
+
+ go func(stop chan struct{}) {
+ h.waitForStop(stop)
+ s.Shutdown(context.Background())
+ }(stop)
+
+ if h.TLS != nil {
+ s.ListenAndServeTLS(h.TLS.Cert, h.TLS.Key)
+ } else {
+ s.ListenAndServe()
+ }
+}
+
+func (h *Webhook) waitForStop(stop chan struct{}) {
+ <-stop
+ close(stop)
+}
+
+// The handler simply reads the update from the body of the requests
+// and writes them to the update channel.
+func (h *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if h.SecretToken != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != h.SecretToken {
+ h.bot.debug(fmt.Errorf("invalid secret token in request"))
+ return
+ }
+
+ var update Update
+ if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
+ h.bot.debug(fmt.Errorf("cannot decode update: %v", err))
+ return
+ }
+ h.dest <- update
+}
+
+// Webhook returns the current webhook status.
+func (b *Bot) Webhook(ctx context.Context) (*Webhook, error) {
+ data, err := b.Raw(ctx, "getWebhookInfo", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var resp struct {
+ Result Webhook
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, wrapError(err)
+ }
+ return &resp.Result, nil
+}
+
+// SetWebhook configures a bot to receive incoming
+// updates via an outgoing webhook.
+func (b *Bot) SetWebhook(ctx context.Context, w *Webhook) error {
+ _, err := b.sendFiles(ctx, "setWebhook", w.getFiles(), w.getParams())
+ return err
+}
+
+// RemoveWebhook removes webhook integration.
+func (b *Bot) RemoveWebhook(ctx context.Context, dropPending ...bool) error {
+ drop := false
+ if len(dropPending) > 0 {
+ drop = dropPending[0]
+ }
+ _, err := b.Raw(ctx, "deleteWebhook", map[string]bool{
+ "drop_pending_updates": drop,
+ })
+ return err
+}
diff --git a/reminder/handler.go b/reminder/handler.go
index ee788d6..d9c8c7c 100644
--- a/reminder/handler.go
+++ b/reminder/handler.go
@@ -9,14 +9,15 @@ import (
"time"
"github.com/getsentry/sentry-go"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
)
func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
input := strings.TrimPrefix(strings.TrimPrefix(c.Text(), "/remind@TeknumCaptchaBot"), "/remind")
if input == "" {
err := c.Reply(
+ ctx,
"To use /remind properly, you should add with the remaining text including the normal human grammatical that I can understand.\n\n"+
"For English, see this Grammarly article about sentence structure.\n"+
"Untuk Indonesia, pakai SPO + Keterangan Waktu yang baik dan benar, belajar lagi biar tambah pinter.",
@@ -35,6 +36,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
sentry.GetHubFromContext(ctx).CaptureException(err)
err := c.Reply(
+ ctx,
"Sorry, a reminder can't be created because of internal error. Please contact the admin!",
&tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true},
)
@@ -47,6 +49,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
if reminderCount >= 3 {
err := c.Reply(
+ ctx,
"You have exceeded your reminder quota of 3 active reminders per user. Spend your money on a real reminder app.",
&tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true},
)
@@ -86,6 +89,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
if err != nil {
if errors.Is(err, ErrExceeds24Hours) {
err := c.Reply(
+ ctx,
"You are attempting to create a reminder that exceeds 24 hour from now. It's prohibited, try shorter time.. or spend your money on a real reminder app.",
&tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true},
)
@@ -98,6 +102,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
sentry.GetHubFromContext(ctx).CaptureException(err)
err := c.Reply(
+ ctx,
"Sorry, I can't create your reminder, something wrong happened on my end. Please contact the admin!",
&tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true},
)
@@ -109,6 +114,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
if reminder.Time.IsZero() || len(reminder.Subject) == 0 || reminder.Object == "" || reminder.Time.Unix() < time.Now().Unix() {
err := c.Reply(
+ ctx,
"Sorry, I'm unable to parse the reminder text that you just sent. Send /remind and see the guide for this command.",
&tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true},
)
@@ -142,7 +148,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
strings.Join(reminder.Subject, ", "),
utils.SanitizeInput(reminder.Object),
)
- _, err := c.Bot().Send(c.Chat(), template, &tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true})
+ _, err := c.Bot().Send(ctx, c.Chat(), template, &tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true})
if err != nil {
sentry.GetHubFromContext(ctx).CaptureException(err)
}
@@ -158,7 +164,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) error {
sentry.GetHubFromContext(ctx).CaptureException(err)
}
- err = c.Reply(fmt.Sprintf("Reminder for %s was created", reminder.Time.Format(time.RFC1123)))
+ err = c.Reply(ctx, fmt.Sprintf("Reminder for %s was created", reminder.Time.Format(time.RFC1123)))
if err != nil {
return err
}
diff --git a/setir/setir.go b/setir/setir.go
index 0ece2c6..a5c0138 100644
--- a/setir/setir.go
+++ b/setir/setir.go
@@ -7,7 +7,7 @@ import (
"strings"
"github.com/getsentry/sentry-go"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
type Dependency struct {
@@ -55,7 +55,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) {
}
}
- _, err = d.Bot.Send(tb.ChatID(d.HomeID), c.Message().ReplyTo.Text, &tb.SendOptions{
+ _, err = d.Bot.Send(ctx, tb.ChatID(d.HomeID), c.Message().ReplyTo.Text, &tb.SendOptions{
ParseMode: tb.ModeHTML,
AllowWithoutReply: true,
ReplyTo: &tb.Message{
@@ -66,13 +66,13 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) {
},
})
if err != nil {
- _, err = d.Bot.Send(c.Chat(), "Failed sending that message: "+err.Error())
+ _, err = d.Bot.Send(ctx, c.Chat(), "Failed sending that message: "+err.Error())
if err != nil {
sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("failed sending that message: %w", err))
return nil
}
} else {
- _, err = d.Bot.Send(c.Chat(), "Message sent")
+ _, err = d.Bot.Send(ctx, c.Chat(), "Message sent")
if err != nil {
sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", err))
return nil
@@ -93,9 +93,9 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) {
return nil
}
- _, err = d.Bot.Send(tb.ChatID(d.HomeID), toBeSent, &tb.SendOptions{AllowWithoutReply: true})
+ _, err = d.Bot.Send(ctx, tb.ChatID(d.HomeID), toBeSent, &tb.SendOptions{AllowWithoutReply: true})
if err != nil {
- _, e := d.Bot.Send(c.Message().Chat, "Failed sending that photo: "+err.Error())
+ _, e := d.Bot.Send(ctx, c.Message().Chat, "Failed sending that photo: "+err.Error())
if e != nil {
sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", e))
return nil
@@ -105,7 +105,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) {
return nil
}
- _, err = d.Bot.Send(c.Chat(), "Photo sent")
+ _, err = d.Bot.Send(ctx, c.Chat(), "Photo sent")
if err != nil {
return fmt.Errorf("sending message that says 'photo sent': %w", err)
}
@@ -113,9 +113,9 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) {
}
- _, err = d.Bot.Send(tb.ChatID(d.HomeID), c.Message().Payload, &tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true})
+ _, err = d.Bot.Send(ctx, tb.ChatID(d.HomeID), c.Message().Payload, &tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true})
if err != nil {
- _, e := d.Bot.Send(c.Chat(), "Failed sending that message: "+err.Error())
+ _, e := d.Bot.Send(ctx, c.Chat(), "Failed sending that message: "+err.Error())
if e != nil {
sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", e))
return nil
@@ -125,7 +125,7 @@ func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) {
return nil
}
- _, err = d.Bot.Send(c.Chat(), "Message sent")
+ _, err = d.Bot.Send(ctx, c.Chat(), "Message sent")
if err != nil {
sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", err))
return nil
diff --git a/shared/error.go b/shared/error.go
index fd4eeaa..d9680c0 100644
--- a/shared/error.go
+++ b/shared/error.go
@@ -6,7 +6,7 @@ import (
"net/http"
"os"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
"github.com/getsentry/sentry-go"
"github.com/pkg/errors"
@@ -65,6 +65,7 @@ func HandleBotError(ctx context.Context, e error, bot *tb.Bot, m *tb.Message) {
hub.CaptureException(errors.WithStack(e))
_, err := bot.Send(
+ ctx,
m.Chat,
"Oh no, something went wrong with me! Can you guys help me to ping my masters?",
&tb.SendOptions{ParseMode: tb.ModeHTML},
diff --git a/underattack/handler.go b/underattack/handler.go
index a604661..c6e43eb 100644
--- a/underattack/handler.go
+++ b/underattack/handler.go
@@ -2,6 +2,7 @@ package underattack
import (
"context"
+ "errors"
"strconv"
"strings"
"time"
@@ -10,7 +11,7 @@ import (
"github.com/teknologi-umum/captcha/utils"
"github.com/getsentry/sentry-go"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// EnableUnderAttackModeHandler provides a handler for /UnderAttack command.
@@ -36,7 +37,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
Timestamp: time.Now(),
}, &sentry.BreadcrumbHint{})
- admins, err := c.Bot().AdminsOf(c.Chat())
+ admins, err := c.Bot().AdminsOf(ctx, c.Chat())
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, c.Message())
return nil
@@ -48,6 +49,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
// at one. Hence, we should retry every enabling command for under attack.
for {
_, err := c.Bot().Send(
+ ctx,
c.Chat(),
"Cuma admin yang boleh jalanin command ini. Ada baiknya kamu ping adminnya langsung :)",
&tb.SendOptions{
@@ -56,16 +58,13 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
},
)
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
@@ -104,6 +103,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
if underAttackModeEnabled {
for {
_, err := c.Bot().Send(
+ ctx,
c.Chat(),
"Mode under attack sudah menyala. Untuk mematikan, kirim /disableunderattack",
&tb.SendOptions{
@@ -112,16 +112,13 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
},
)
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
@@ -144,6 +141,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
var notificationMessage *tb.Message
for {
notificationMessage, err = c.Bot().Send(
+ ctx,
c.Chat(),
"Grup ini dalam kondisi under attack sampai pukul "+
expiresAt.In(time.FixedZone("WIB", 7*60*60)).Format("15:04 MST")+
@@ -158,16 +156,13 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
},
)
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
@@ -195,7 +190,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
return nil
}
- err = c.Bot().Pin(notificationMessage)
+ err = c.Bot().Pin(ctx, notificationMessage)
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, c.Message())
return nil
@@ -229,7 +224,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont
Timestamp: time.Now(),
}, &sentry.BreadcrumbHint{})
- err := c.Bot().Unpin(notificationMessage.Chat, notificationMessage.ID)
+ err := c.Bot().Unpin(ctx, notificationMessage.Chat, notificationMessage.ID)
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, c.Message())
}
@@ -249,7 +244,7 @@ func (d *Dependency) DisableUnderAttackModeHandler(ctx context.Context, c tb.Con
defer span.Finish()
ctx = span.Context()
- admins, err := c.Bot().AdminsOf(c.Chat())
+ admins, err := c.Bot().AdminsOf(ctx, c.Chat())
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, c.Message())
return nil
@@ -257,6 +252,7 @@ func (d *Dependency) DisableUnderAttackModeHandler(ctx context.Context, c tb.Con
if !utils.IsAdmin(admins, c.Sender()) {
_, err := c.Bot().Send(
+ ctx,
c.Chat(),
"Cuma admin yang boleh jalanin command ini. Ada baiknya kamu ping adminnya langsung :)",
&tb.SendOptions{
@@ -311,7 +307,7 @@ func (d *Dependency) DisableUnderAttackModeHandler(ctx context.Context, c tb.Con
return nil
}
- err = c.Bot().Unpin(c.Chat(), int(underAttackEntry.NotificationMessageID))
+ err = c.Bot().Unpin(ctx, c.Chat(), int(underAttackEntry.NotificationMessageID))
if err != nil {
shared.HandleBotError(ctx, err, d.Bot, c.Message())
return nil
diff --git a/underattack/kicker.go b/underattack/kicker.go
index 3916ad0..dd1020e 100644
--- a/underattack/kicker.go
+++ b/underattack/kicker.go
@@ -2,14 +2,14 @@ package underattack
import (
"context"
+ "errors"
"fmt"
- "strconv"
"strings"
"time"
"github.com/getsentry/sentry-go"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
func (d *Dependency) Kicker(ctx context.Context, c tb.Context) error {
@@ -17,18 +17,15 @@ func (d *Dependency) Kicker(ctx context.Context, c tb.Context) error {
defer span.Finish()
for {
- err := c.Bot().Ban(c.Chat(), &tb.ChatMember{User: c.Sender(), RestrictedUntil: tb.Forever()})
+ err := c.Bot().Ban(ctx, c.Chat(), &tb.ChatMember{User: c.Sender(), RestrictedUntil: tb.Forever()})
if err != nil {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
@@ -44,21 +41,18 @@ func (d *Dependency) Kicker(ctx context.Context, c tb.Context) error {
}
for {
- err := d.Bot.Delete(c.Message())
+ err := d.Bot.Delete(ctx, c.Message())
if err != nil && !strings.Contains(err.Error(), "message to delete not found") {
- if strings.Contains(err.Error(), "retry after") {
- // Acquire the retry number
- retry, err := strconv.Atoi(strings.Split(strings.Split(err.Error(), "telegram: retry after ")[1], " ")[0])
- if err != nil {
- // If there's an error, we'll just retry after 15 second
- retry = 15
+ var floodError tb.FloodError
+ if errors.As(err, &floodError) {
+ if floodError.RetryAfter == 0 {
+ floodError.RetryAfter = 15
}
- // Let's wait a bit and retry
- time.Sleep(time.Second * time.Duration(retry))
+ time.Sleep(time.Second * time.Duration(floodError.RetryAfter))
continue
}
-
+
if strings.Contains(err.Error(), "Gateway Timeout (504)") {
time.Sleep(time.Second * 10)
continue
diff --git a/underattack/underattack.go b/underattack/underattack.go
index 49e5f3e..8a36605 100644
--- a/underattack/underattack.go
+++ b/underattack/underattack.go
@@ -4,7 +4,7 @@ import (
"time"
"github.com/allegro/bigcache/v3"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
// Dependency contains the dependency injection struct
diff --git a/utils/tele.go b/utils/tele.go
index 12dd681..2979341 100644
--- a/utils/tele.go
+++ b/utils/tele.go
@@ -1,6 +1,6 @@
package utils
-import tb "gopkg.in/telebot.v3"
+import tb "github.com/teknologi-umum/captcha/internal/telebot"
// ShouldAddSpace thinks whether to add a space
// to the given user, considering their first name
diff --git a/utils/tele_test.go b/utils/tele_test.go
index e43f5ef..27cea3c 100644
--- a/utils/tele_test.go
+++ b/utils/tele_test.go
@@ -5,7 +5,7 @@ import (
"github.com/teknologi-umum/captcha/utils"
- tb "gopkg.in/telebot.v3"
+ tb "github.com/teknologi-umum/captcha/internal/telebot"
)
func TestShouldAddSpace(t *testing.T) {