mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +00:00
Initial release of go-telegram
A fully-generated, strongly-typed Go client for the Telegram Bot API. * 176 methods + 301 types generated from Bot API v10.0 * 1408 auto-generated tests (8 scenarios per method) * Typed unions throughout — no 'any' in the public surface * Pluggable HTTP transport and JSON codec (default goccy/go-json) * Built-in retry middleware honouring Telegram's retry_after * Generic dispatcher with filters and conversation handlers * Self-verifying codegen pipeline (regen → audit → emit → run tests) * 14 example bots covering common patterns
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/client"
|
||||
)
|
||||
|
||||
// DownloadFile fetches the contents of a Telegram-hosted file given a
|
||||
// previously-uploaded file_id. It calls GetFile to resolve the file's
|
||||
// download path, then issues an HTTP GET to the file CDN endpoint.
|
||||
//
|
||||
// The returned io.ReadCloser must be closed by the caller. The size of
|
||||
// the file is reported via *File.FileSize when known.
|
||||
//
|
||||
// For files larger than 20 MB, Telegram requires a self-hosted Bot API
|
||||
// server (default api.telegram.org has a 20 MB limit on getFile).
|
||||
func DownloadFile(ctx context.Context, b *client.Bot, fileID string) (io.ReadCloser, *File, error) {
|
||||
f, err := GetFile(ctx, b, &GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getFile: %w", err)
|
||||
}
|
||||
if f == nil || f.FilePath == "" {
|
||||
return nil, f, fmt.Errorf("telegram: file %q has no download path", fileID)
|
||||
}
|
||||
rc, err := DownloadFileByPath(ctx, b, f.FilePath)
|
||||
if err != nil {
|
||||
return nil, f, err
|
||||
}
|
||||
return rc, f, nil
|
||||
}
|
||||
|
||||
// DownloadFileByPath fetches a file by its file_path (typically obtained
|
||||
// from a prior File response). Useful when the caller already has a
|
||||
// *File and wants to skip the GetFile round-trip.
|
||||
func DownloadFileByPath(ctx context.Context, b *client.Bot, filePath string) (io.ReadCloser, error) {
|
||||
url := fmt.Sprintf("%s/file/bot%s/%s", b.BaseURL(), b.Token(), filePath)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := b.HTTP().Do(req)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
return nil, fmt.Errorf("download: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("download: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/client"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDownloadFile_HappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getFile"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"abc","file_unique_id":"u","file_size":11,"file_path":"documents/hello.txt"}}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/file/bot"):
|
||||
_, _ = w.Write([]byte("hello world"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
bot := client.New("123:abc", client.WithBaseURL(srv.URL))
|
||||
rc, file, err := DownloadFile(context.Background(), bot, "abc")
|
||||
require.NoError(t, err)
|
||||
defer rc.Close()
|
||||
require.Equal(t, "documents/hello.txt", file.FilePath)
|
||||
body, err := io.ReadAll(rc)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello world", string(body))
|
||||
}
|
||||
|
||||
func TestDownloadFile_GetFileFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: invalid file_id"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
bot := client.New("t", client.WithBaseURL(srv.URL))
|
||||
_, _, err := DownloadFile(context.Background(), bot, "bad")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "getFile")
|
||||
}
|
||||
|
||||
func TestDownloadFile_NoFilePath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// result without file_path
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"abc","file_unique_id":"u"}}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
bot := client.New("t", client.WithBaseURL(srv.URL))
|
||||
_, _, err := DownloadFile(context.Background(), bot, "abc")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no download path")
|
||||
}
|
||||
|
||||
func TestDownloadFileByPath_HTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/file/bot") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
bot := client.New("t", client.WithBaseURL(srv.URL))
|
||||
_, err := DownloadFileByPath(context.Background(), bot, "secret/file")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "403")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Code generated by cmd/genapi. DO NOT EDIT.
|
||||
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
package api
|
||||
|
||||
// ParseMode controls how Telegram interprets formatting in message text.
|
||||
type ParseMode string
|
||||
|
||||
const (
|
||||
ParseModeMarkdown ParseMode = "Markdown" // legacy
|
||||
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
|
||||
ParseModeHTML ParseMode = "HTML"
|
||||
)
|
||||
|
||||
// ChatType is the type of a Telegram chat.
|
||||
type ChatType string
|
||||
|
||||
const (
|
||||
ChatTypePrivate ChatType = "private"
|
||||
ChatTypeGroup ChatType = "group"
|
||||
ChatTypeSupergroup ChatType = "supergroup"
|
||||
ChatTypeChannel ChatType = "channel"
|
||||
)
|
||||
|
||||
// UpdateType identifies an Update payload variant. Used by allowed_updates
|
||||
// in getUpdates / setWebhook.
|
||||
type UpdateType string
|
||||
|
||||
const (
|
||||
UpdateMessage UpdateType = "message"
|
||||
UpdateEditedMessage UpdateType = "edited_message"
|
||||
UpdateChannelPost UpdateType = "channel_post"
|
||||
UpdateEditedChannelPost UpdateType = "edited_channel_post"
|
||||
UpdateCallbackQuery UpdateType = "callback_query"
|
||||
UpdateInlineQuery UpdateType = "inline_query"
|
||||
)
|
||||
|
||||
// MessageEntityType is the kind of an entity (mention, hashtag, command, ...).
|
||||
type MessageEntityType string
|
||||
|
||||
const (
|
||||
EntityMention MessageEntityType = "mention"
|
||||
EntityHashtag MessageEntityType = "hashtag"
|
||||
EntityCashtag MessageEntityType = "cashtag"
|
||||
EntityBotCommand MessageEntityType = "bot_command"
|
||||
EntityURL MessageEntityType = "url"
|
||||
EntityEmail MessageEntityType = "email"
|
||||
EntityPhoneNumber MessageEntityType = "phone_number"
|
||||
EntityBold MessageEntityType = "bold"
|
||||
EntityItalic MessageEntityType = "italic"
|
||||
EntityUnderline MessageEntityType = "underline"
|
||||
EntityStrike MessageEntityType = "strikethrough"
|
||||
EntitySpoiler MessageEntityType = "spoiler"
|
||||
EntityCode MessageEntityType = "code"
|
||||
EntityPre MessageEntityType = "pre"
|
||||
EntityTextLink MessageEntityType = "text_link"
|
||||
EntityTextMention MessageEntityType = "text_mention"
|
||||
EntityCustomEmoji MessageEntityType = "custom_emoji"
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/client"
|
||||
)
|
||||
|
||||
// MeCache caches the result of GetMe across calls. Construct one per
|
||||
// Bot and call Get to retrieve the cached User on subsequent invocations.
|
||||
//
|
||||
// var meCache api.MeCache
|
||||
// me, err := meCache.Get(ctx, bot)
|
||||
//
|
||||
// MeCache is safe for concurrent use.
|
||||
type MeCache struct {
|
||||
mu sync.Mutex
|
||||
cached *User
|
||||
}
|
||||
|
||||
// Get returns the User from a cached GetMe call. If the cache is empty,
|
||||
// it calls GetMe and populates the cache on success.
|
||||
func (c *MeCache) Get(ctx context.Context, b *client.Bot) (*User, error) {
|
||||
c.mu.Lock()
|
||||
if c.cached != nil {
|
||||
u := c.cached
|
||||
c.mu.Unlock()
|
||||
return u, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
u, err := GetMe(ctx, b, &GetMeParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cached = u
|
||||
c.mu.Unlock()
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Reset clears the cache. Useful in tests or after the bot's identity
|
||||
// is known to have changed (very rare).
|
||||
func (c *MeCache) Reset() {
|
||||
c.mu.Lock()
|
||||
c.cached = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/go-telegram/client"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMeCache_FetchesOnce(t *testing.T) {
|
||||
m := &mockDoer{}
|
||||
var calls atomic.Int32
|
||||
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
|
||||
if strings.HasSuffix(r.URL.Path, "/getMe") {
|
||||
calls.Add(1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo","username":"echo_bot"}}`), nil)
|
||||
|
||||
bot := client.New("t", client.WithHTTPClient(m))
|
||||
var cache MeCache
|
||||
|
||||
me1, err := cache.Get(context.Background(), bot)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "echo_bot", me1.Username)
|
||||
|
||||
me2, err := cache.Get(context.Background(), bot)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, me1, me2)
|
||||
require.Equal(t, int32(1), calls.Load(), "should fetch only once")
|
||||
}
|
||||
|
||||
func TestMeCache_Reset(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
m := &mockDoer{}
|
||||
m.On("Do", mock.Anything).Run(func(args mock.Arguments) {
|
||||
calls.Add(1)
|
||||
}).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo","username":"echo_bot"}}`), nil).Once()
|
||||
m.On("Do", mock.Anything).Run(func(args mock.Arguments) {
|
||||
calls.Add(1)
|
||||
}).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo","username":"echo_bot"}}`), nil).Once()
|
||||
|
||||
bot := client.New("t", client.WithHTTPClient(m))
|
||||
var cache MeCache
|
||||
|
||||
_, err := cache.Get(context.Background(), bot)
|
||||
require.NoError(t, err)
|
||||
cache.Reset()
|
||||
_, err = cache.Get(context.Background(), bot)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
}
|
||||
+5145
File diff suppressed because it is too large
Load Diff
+24703
File diff suppressed because it is too large
Load Diff
+143
@@ -0,0 +1,143 @@
|
||||
// Package api contains the Telegram Bot API types and method wrappers.
|
||||
// Most of the package is generated by cmd/genapi from internal/spec/api.json;
|
||||
// this file holds the runtime types that are intentionally hand-coded.
|
||||
//
|
||||
// InputFile carries either a local upload (Reader+Filename) or a reference
|
||||
// to a previously-uploaded file (file_id) / URL Telegram can fetch. It is
|
||||
// not a pure JSON type, so the codegen skips it (see runtimeTypes in
|
||||
// cmd/genapi/emitter.go).
|
||||
//
|
||||
// ResponseParameters mirrors client.ResponseParameters so callers importing
|
||||
// only `api` can access retry_after and migrate_to_chat_id without pulling
|
||||
// in the client package.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// InputFile carries either a file path (for upload) or a Telegram file_id
|
||||
// / URL string (for reuse). When PathOrID names a local file, the request
|
||||
// is sent as multipart/form-data; otherwise the value is sent inline.
|
||||
type InputFile struct {
|
||||
// PathOrID is one of: an absolute or relative filesystem path, a
|
||||
// previously-uploaded Telegram file_id, or an HTTPS URL Telegram
|
||||
// can fetch.
|
||||
PathOrID string
|
||||
// Reader, when non-nil, is used as the file content (Filename names it).
|
||||
Reader io.Reader
|
||||
// Filename is the upload filename used when Reader is set.
|
||||
Filename string
|
||||
}
|
||||
|
||||
// IsLocalUpload reports whether this InputFile triggers a multipart upload.
|
||||
func (f *InputFile) IsLocalUpload() bool {
|
||||
if f == nil {
|
||||
return false
|
||||
}
|
||||
return f.Reader != nil
|
||||
}
|
||||
|
||||
// ResponseParameters is the optional metadata Telegram includes on certain
|
||||
// failures. The most common is RetryAfter (seconds) on 429 responses.
|
||||
//
|
||||
// https://core.telegram.org/bots/api#responseparameters
|
||||
type ResponseParameters struct {
|
||||
MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
|
||||
RetryAfter int `json:"retry_after,omitempty"`
|
||||
}
|
||||
|
||||
// ChatID identifies a chat by either numeric id or "@username". The Telegram
|
||||
// Bot API spells the same field as either an integer or a string; ChatID
|
||||
// preserves both forms with explicit constructors and a custom MarshalJSON
|
||||
// so callers never see `any` at the source level.
|
||||
type ChatID struct {
|
||||
int64Set bool
|
||||
intID int64
|
||||
username string
|
||||
}
|
||||
|
||||
// ChatIDFromInt builds a ChatID for a numeric chat identifier (e.g. -1001234567890).
|
||||
func ChatIDFromInt(id int64) ChatID { return ChatID{int64Set: true, intID: id} }
|
||||
|
||||
// ChatIDFromUsername builds a ChatID for a public chat (e.g. "@channel").
|
||||
// The leading "@" is required by Telegram for usernames.
|
||||
func ChatIDFromUsername(name string) ChatID { return ChatID{username: name} }
|
||||
|
||||
// IsZero reports whether c carries no value.
|
||||
func (c ChatID) IsZero() bool { return !c.int64Set && c.username == "" }
|
||||
|
||||
// String returns the wire form (decimal integer or "@name") for use in
|
||||
// multipart bodies.
|
||||
func (c ChatID) String() string {
|
||||
if c.int64Set {
|
||||
return strconv.FormatInt(c.intID, 10)
|
||||
}
|
||||
return c.username
|
||||
}
|
||||
|
||||
// MarshalJSON emits either a JSON number (integer form) or a JSON string
|
||||
// (@username form). Empty values marshal as "null".
|
||||
func (c ChatID) MarshalJSON() ([]byte, error) {
|
||||
if c.int64Set {
|
||||
return []byte(strconv.FormatInt(c.intID, 10)), nil
|
||||
}
|
||||
if c.username != "" {
|
||||
return json.Marshal(c.username)
|
||||
}
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts either a JSON number or a JSON string.
|
||||
func (c *ChatID) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
|
||||
*c = ChatID{}
|
||||
return nil
|
||||
}
|
||||
if data[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = ChatIDFromUsername(s)
|
||||
return nil
|
||||
}
|
||||
var n int64
|
||||
if err := json.Unmarshal(data, &n); err != nil {
|
||||
return fmt.Errorf("ChatID: %w", err)
|
||||
}
|
||||
*c = ChatIDFromInt(n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageOrBool wraps the "Message or True" return shape Telegram uses on
|
||||
// edit methods (editMessageText, editMessageCaption, etc.). When the bot
|
||||
// edits a regular chat message, Message is non-nil; when it edits an
|
||||
// inline message, OK is true.
|
||||
type MessageOrBool struct {
|
||||
Message *Message
|
||||
OK bool
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes either {...} into Message or `true`/`false` into OK.
|
||||
func (m *MessageOrBool) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if data[0] == '{' {
|
||||
m.Message = new(Message)
|
||||
return json.Unmarshal(data, m.Message)
|
||||
}
|
||||
var b bool
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return fmt.Errorf("MessageOrBool: %w", err)
|
||||
}
|
||||
m.OK = b
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChatID_IntForm(t *testing.T) {
|
||||
c := ChatIDFromInt(-1001234567890)
|
||||
require.False(t, c.IsZero())
|
||||
require.Equal(t, "-1001234567890", c.String())
|
||||
|
||||
data, err := json.Marshal(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "-1001234567890", string(data))
|
||||
|
||||
var c2 ChatID
|
||||
require.NoError(t, json.Unmarshal(data, &c2))
|
||||
require.Equal(t, c, c2)
|
||||
}
|
||||
|
||||
func TestChatID_UsernameForm(t *testing.T) {
|
||||
c := ChatIDFromUsername("@channel")
|
||||
require.False(t, c.IsZero())
|
||||
require.Equal(t, "@channel", c.String())
|
||||
|
||||
data, err := json.Marshal(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `"@channel"`, string(data))
|
||||
|
||||
var c2 ChatID
|
||||
require.NoError(t, json.Unmarshal(data, &c2))
|
||||
require.Equal(t, c, c2)
|
||||
}
|
||||
|
||||
func TestChatID_Zero(t *testing.T) {
|
||||
var c ChatID
|
||||
require.True(t, c.IsZero())
|
||||
require.Equal(t, "", c.String())
|
||||
|
||||
data, err := json.Marshal(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "null", string(data))
|
||||
|
||||
var c2 ChatID
|
||||
require.NoError(t, json.Unmarshal([]byte("null"), &c2))
|
||||
require.True(t, c2.IsZero())
|
||||
}
|
||||
|
||||
func TestChatID_UnmarshalInvalid(t *testing.T) {
|
||||
var c ChatID
|
||||
err := json.Unmarshal([]byte(`"not-a-number"`), &c)
|
||||
require.NoError(t, err) // string always succeeds as username
|
||||
require.Equal(t, "not-a-number", c.username)
|
||||
}
|
||||
|
||||
func TestMessageOrBool_TrueForm(t *testing.T) {
|
||||
var m MessageOrBool
|
||||
require.NoError(t, json.Unmarshal([]byte("true"), &m))
|
||||
require.True(t, m.OK)
|
||||
require.Nil(t, m.Message)
|
||||
}
|
||||
|
||||
func TestMessageOrBool_FalseForm(t *testing.T) {
|
||||
var m MessageOrBool
|
||||
require.NoError(t, json.Unmarshal([]byte("false"), &m))
|
||||
require.False(t, m.OK)
|
||||
require.Nil(t, m.Message)
|
||||
}
|
||||
|
||||
func TestMessageOrBool_MessageForm(t *testing.T) {
|
||||
// Message is a generated type; we can only test that it unmarshals without
|
||||
// error into the struct — the generated api/*.gen.go is not available in
|
||||
// the test build unless built. Use build tag !ignore_autogenerated default.
|
||||
// Skip if Message type is not yet present (bootstrap phase).
|
||||
data := []byte(`{"message_id":42,"date":0,"chat":{"id":1,"type":"private"}}`)
|
||||
var m MessageOrBool
|
||||
require.NoError(t, json.Unmarshal(data, &m))
|
||||
require.NotNil(t, m.Message)
|
||||
require.False(t, m.OK)
|
||||
}
|
||||
|
||||
func TestInputFile_IsLocalUpload(t *testing.T) {
|
||||
require.False(t, (*InputFile)(nil).IsLocalUpload())
|
||||
require.False(t, (&InputFile{PathOrID: "AgADAgADu7gxG..."}).IsLocalUpload())
|
||||
require.True(t, (&InputFile{Reader: nopReader{}}).IsLocalUpload())
|
||||
}
|
||||
|
||||
type nopReader struct{}
|
||||
|
||||
func (nopReader) Read(p []byte) (int, error) { return 0, nil }
|
||||
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
// Sender condenses the various ways a Telegram update can identify the
|
||||
// originator of a message or reaction into a single shape. Use the
|
||||
// GetSender methods on supported types to construct one.
|
||||
type Sender struct {
|
||||
// User is the human user who sent the update, when applicable.
|
||||
User *User
|
||||
// Chat is the chat that sent the update (channel forwards,
|
||||
// anonymous group admins, anonymous channel posts).
|
||||
Chat *Chat
|
||||
// IsAutomaticForward is true when the update originated as an
|
||||
// automatic forward from a linked channel.
|
||||
IsAutomaticForward bool
|
||||
// ChatID is the chat the update was delivered into. Used to
|
||||
// distinguish "this user" from "this anonymous admin posting
|
||||
// in <chat>" when User is nil.
|
||||
ChatID int64
|
||||
// AuthorSignature is the custom title of an anonymous group
|
||||
// administrator. Only meaningful when Chat == this chat.
|
||||
AuthorSignature string
|
||||
}
|
||||
|
||||
// ID returns the most-specific identifier available: prefers Chat.ID
|
||||
// over User.ID. Returns 0 if neither is set.
|
||||
func (s *Sender) ID() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
if s.Chat != nil {
|
||||
return s.Chat.ID
|
||||
}
|
||||
if s.User != nil {
|
||||
return s.User.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsAnonymousAdmin reports whether the sender is a group admin posting
|
||||
// anonymously (Chat equals the message's own chat).
|
||||
func (s *Sender) IsAnonymousAdmin() bool {
|
||||
return s != nil && s.Chat != nil && s.Chat.ID == s.ChatID
|
||||
}
|
||||
|
||||
// IsAnonymousChannel reports whether the sender is an anonymous
|
||||
// channel post (Chat differs from the message's own chat).
|
||||
func (s *Sender) IsAnonymousChannel() bool {
|
||||
return s != nil && s.Chat != nil && s.Chat.ID != s.ChatID
|
||||
}
|
||||
|
||||
// GetSender constructs a Sender for a Message. The result is never nil.
|
||||
func (m *Message) GetSender() *Sender {
|
||||
if m == nil {
|
||||
return &Sender{}
|
||||
}
|
||||
isAuto := false
|
||||
if m.IsAutomaticForward != nil {
|
||||
isAuto = *m.IsAutomaticForward
|
||||
}
|
||||
return &Sender{
|
||||
User: m.From,
|
||||
Chat: m.SenderChat,
|
||||
IsAutomaticForward: isAuto,
|
||||
ChatID: m.Chat.ID,
|
||||
AuthorSignature: m.AuthorSignature,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSender constructs a Sender for a MessageReactionUpdated.
|
||||
func (mru *MessageReactionUpdated) GetSender() *Sender {
|
||||
if mru == nil {
|
||||
return &Sender{}
|
||||
}
|
||||
return &Sender{
|
||||
User: mru.User,
|
||||
Chat: mru.ActorChat,
|
||||
ChatID: mru.Chat.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSender constructs a Sender for a PollAnswer.
|
||||
func (pa *PollAnswer) GetSender() *Sender {
|
||||
if pa == nil {
|
||||
return &Sender{}
|
||||
}
|
||||
return &Sender{
|
||||
User: pa.User,
|
||||
Chat: pa.VoterChat,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSenderID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sender *Sender
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "nil sender",
|
||||
sender: nil,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "empty sender",
|
||||
sender: &Sender{},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "user only",
|
||||
sender: &Sender{
|
||||
User: &User{ID: 123},
|
||||
},
|
||||
want: 123,
|
||||
},
|
||||
{
|
||||
name: "chat only",
|
||||
sender: &Sender{
|
||||
Chat: &Chat{ID: 456},
|
||||
},
|
||||
want: 456,
|
||||
},
|
||||
{
|
||||
name: "chat prefers over user",
|
||||
sender: &Sender{
|
||||
User: &User{ID: 123},
|
||||
Chat: &Chat{ID: 456},
|
||||
},
|
||||
want: 456,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.sender.ID()
|
||||
if got != tt.want {
|
||||
t.Errorf("ID() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func chatEqual(a, b *Chat) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return a.ID == b.ID
|
||||
}
|
||||
|
||||
func userEqual(a, b *User) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return a.ID == b.ID
|
||||
}
|
||||
|
||||
func TestSenderIsAnonymousAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sender *Sender
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil sender",
|
||||
sender: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no chat",
|
||||
sender: &Sender{User: &User{ID: 123}, ChatID: 456},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "chat id matches (anonymous admin)",
|
||||
sender: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 789,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "chat id differs (not anonymous admin)",
|
||||
sender: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 456,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.sender.IsAnonymousAdmin()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsAnonymousAdmin() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderIsAnonymousChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sender *Sender
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil sender",
|
||||
sender: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no chat",
|
||||
sender: &Sender{User: &User{ID: 123}, ChatID: 456},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "chat id differs (anonymous channel)",
|
||||
sender: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 456,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "chat id matches (not anonymous channel)",
|
||||
sender: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 789,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.sender.IsAnonymousChannel()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsAnonymousChannel() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageGetSender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *Message
|
||||
want *Sender
|
||||
}{
|
||||
{
|
||||
name: "nil message",
|
||||
msg: nil,
|
||||
want: &Sender{},
|
||||
},
|
||||
{
|
||||
name: "regular user message",
|
||||
msg: &Message{
|
||||
From: &User{ID: 123},
|
||||
Chat: Chat{ID: 456},
|
||||
},
|
||||
want: &Sender{
|
||||
User: &User{ID: 123},
|
||||
ChatID: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "channel forward",
|
||||
msg: &Message{
|
||||
From: &User{ID: 123},
|
||||
SenderChat: &Chat{ID: 789},
|
||||
Chat: Chat{ID: 456},
|
||||
},
|
||||
want: &Sender{
|
||||
User: &User{ID: 123},
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "anonymous admin",
|
||||
msg: &Message{
|
||||
SenderChat: &Chat{ID: 456},
|
||||
Chat: Chat{ID: 456},
|
||||
AuthorSignature: "Admin Signature",
|
||||
},
|
||||
want: &Sender{
|
||||
Chat: &Chat{ID: 456},
|
||||
ChatID: 456,
|
||||
AuthorSignature: "Admin Signature",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "anonymous channel post",
|
||||
msg: &Message{
|
||||
SenderChat: &Chat{ID: 789},
|
||||
Chat: Chat{ID: 456},
|
||||
},
|
||||
want: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "automatic forward",
|
||||
msg: &Message{
|
||||
From: &User{ID: 123},
|
||||
IsAutomaticForward: func() *bool {
|
||||
b := true
|
||||
return &b
|
||||
}(),
|
||||
Chat: Chat{ID: 456},
|
||||
},
|
||||
want: &Sender{
|
||||
User: &User{ID: 123},
|
||||
IsAutomaticForward: true,
|
||||
ChatID: 456,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.msg.GetSender()
|
||||
if got == nil {
|
||||
t.Fatal("GetSender() returned nil")
|
||||
}
|
||||
if !userEqual(got.User, tt.want.User) {
|
||||
t.Errorf("User: got %v, want %v", got.User, tt.want.User)
|
||||
}
|
||||
if !chatEqual(got.Chat, tt.want.Chat) {
|
||||
t.Errorf("Chat: got %v, want %v", got.Chat, tt.want.Chat)
|
||||
}
|
||||
if got.IsAutomaticForward != tt.want.IsAutomaticForward {
|
||||
t.Errorf("IsAutomaticForward: got %v, want %v", got.IsAutomaticForward, tt.want.IsAutomaticForward)
|
||||
}
|
||||
if got.ChatID != tt.want.ChatID {
|
||||
t.Errorf("ChatID: got %d, want %d", got.ChatID, tt.want.ChatID)
|
||||
}
|
||||
if got.AuthorSignature != tt.want.AuthorSignature {
|
||||
t.Errorf("AuthorSignature: got %q, want %q", got.AuthorSignature, tt.want.AuthorSignature)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageReactionUpdatedGetSender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mru *MessageReactionUpdated
|
||||
want *Sender
|
||||
}{
|
||||
{
|
||||
name: "nil reaction",
|
||||
mru: nil,
|
||||
want: &Sender{},
|
||||
},
|
||||
{
|
||||
name: "user reaction",
|
||||
mru: &MessageReactionUpdated{
|
||||
User: &User{ID: 123},
|
||||
Chat: Chat{ID: 456},
|
||||
},
|
||||
want: &Sender{
|
||||
User: &User{ID: 123},
|
||||
ChatID: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "anonymous reaction",
|
||||
mru: &MessageReactionUpdated{
|
||||
ActorChat: &Chat{ID: 789},
|
||||
Chat: Chat{ID: 456},
|
||||
},
|
||||
want: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
ChatID: 456,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.mru.GetSender()
|
||||
if got == nil {
|
||||
t.Fatal("GetSender() returned nil")
|
||||
}
|
||||
if !userEqual(got.User, tt.want.User) {
|
||||
t.Errorf("User: got %v, want %v", got.User, tt.want.User)
|
||||
}
|
||||
if !chatEqual(got.Chat, tt.want.Chat) {
|
||||
t.Errorf("Chat: got %v, want %v", got.Chat, tt.want.Chat)
|
||||
}
|
||||
if got.ChatID != tt.want.ChatID {
|
||||
t.Errorf("ChatID: got %d, want %d", got.ChatID, tt.want.ChatID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAnswerGetSender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pa *PollAnswer
|
||||
want *Sender
|
||||
}{
|
||||
{
|
||||
name: "nil poll answer",
|
||||
pa: nil,
|
||||
want: &Sender{},
|
||||
},
|
||||
{
|
||||
name: "user vote",
|
||||
pa: &PollAnswer{
|
||||
User: &User{ID: 123},
|
||||
},
|
||||
want: &Sender{
|
||||
User: &User{ID: 123},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "anonymous vote",
|
||||
pa: &PollAnswer{
|
||||
VoterChat: &Chat{ID: 789},
|
||||
},
|
||||
want: &Sender{
|
||||
Chat: &Chat{ID: 789},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.pa.GetSender()
|
||||
if got == nil {
|
||||
t.Fatal("GetSender() returned nil")
|
||||
}
|
||||
if !userEqual(got.User, tt.want.User) {
|
||||
t.Errorf("User: got %v, want %v", got.User, tt.want.User)
|
||||
}
|
||||
if !chatEqual(got.Chat, tt.want.Chat) {
|
||||
t.Errorf("Chat: got %v, want %v", got.Chat, tt.want.Chat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// mockDoer is a testify-mock HTTPDoer shared by hand-written tests.
|
||||
type mockDoer struct{ mock.Mock }
|
||||
|
||||
func (m *mockDoer) Do(r *http.Request) (*http.Response, error) {
|
||||
args := m.Called(r)
|
||||
if v := args.Get(0); v != nil {
|
||||
return v.(*http.Response), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
// newJSONResp constructs an *http.Response with a JSON body.
|
||||
func newJSONResp(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}
|
||||
}
|
||||
+5871
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user