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) {