mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-13 02:51:55 +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:
+27664
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
// Package spec defines the intermediate representation produced by the
|
||||
// Telegram Bot API scraper (cmd/scrape) and consumed by the code generator
|
||||
// (cmd/genapi). It is committed as internal/spec/api.json so PR diffs read
|
||||
// as a Telegram changelog.
|
||||
package spec
|
||||
|
||||
import "fmt"
|
||||
|
||||
// API is the top-level IR document.
|
||||
type API struct {
|
||||
// Version is the Telegram Bot API version parsed from the "Recent changes" section of the docs page.
|
||||
Version string `json:"version"`
|
||||
// Types lists all object types in declaration order.
|
||||
Types []TypeDecl `json:"types"`
|
||||
// Methods lists all API methods in declaration order.
|
||||
Methods []MethodDecl `json:"methods"`
|
||||
}
|
||||
|
||||
// TypeDecl describes a Telegram object type.
|
||||
type TypeDecl struct {
|
||||
Name string `json:"name"`
|
||||
Doc string `json:"doc,omitempty"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
// OneOf, when non-empty, indicates this type is a union and lists the concrete variant type names.
|
||||
// Variants are emitted as concrete structs implementing a sealed interface.
|
||||
OneOf []string `json:"one_of,omitempty"`
|
||||
}
|
||||
|
||||
// MethodDecl describes a Telegram API method.
|
||||
type MethodDecl struct {
|
||||
Name string `json:"name"`
|
||||
Doc string `json:"doc,omitempty"`
|
||||
Params []Field `json:"params,omitempty"`
|
||||
Returns TypeRef `json:"returns"`
|
||||
// HasFiles is true when any parameter accepts an InputFile, requiring a multipart/form-data request.
|
||||
HasFiles bool `json:"has_files,omitempty"`
|
||||
}
|
||||
|
||||
// Field describes a single field on a type or a single parameter on a method.
|
||||
type Field struct {
|
||||
// Name is the Go-style identifier (e.g. "ChatID").
|
||||
Name string `json:"name"`
|
||||
// JSONName is the wire name (e.g. "chat_id").
|
||||
JSONName string `json:"json_name"`
|
||||
Type TypeRef `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Doc string `json:"doc,omitempty"`
|
||||
}
|
||||
|
||||
// Kind enumerates TypeRef shapes.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
// KindPrimitive: int64, string, bool, float64.
|
||||
KindPrimitive Kind = iota
|
||||
// KindNamed: a TypeDecl by name.
|
||||
KindNamed
|
||||
// KindArray: ElemType is the element type.
|
||||
KindArray
|
||||
// KindOneOf: Variants lists discriminant union members.
|
||||
KindOneOf
|
||||
)
|
||||
|
||||
// String returns a stable, lowercase representation suitable for serialisation.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindPrimitive:
|
||||
return "primitive"
|
||||
case KindNamed:
|
||||
return "named"
|
||||
case KindArray:
|
||||
return "array"
|
||||
case KindOneOf:
|
||||
return "oneOf"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalText / UnmarshalText keep JSON output human-readable.
|
||||
func (k Kind) MarshalText() ([]byte, error) { return []byte(k.String()), nil }
|
||||
|
||||
func (k *Kind) UnmarshalText(b []byte) error {
|
||||
switch string(b) {
|
||||
case "primitive":
|
||||
*k = KindPrimitive
|
||||
case "named":
|
||||
*k = KindNamed
|
||||
case "array":
|
||||
*k = KindArray
|
||||
case "oneOf":
|
||||
*k = KindOneOf
|
||||
default:
|
||||
return fmt.Errorf("unknown Kind: %q", string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TypeRef is a structural reference used wherever a Field type is expressed.
|
||||
type TypeRef struct {
|
||||
Kind Kind `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ElemType *TypeRef `json:"elem_type,omitempty"`
|
||||
Variants []string `json:"variants,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIRoundTripJSON(t *testing.T) {
|
||||
in := API{
|
||||
Version: "7.10",
|
||||
Types: []TypeDecl{{
|
||||
Name: "User",
|
||||
Doc: "This object represents a Telegram user or bot.",
|
||||
Fields: []Field{
|
||||
{Name: "ID", JSONName: "id", Type: TypeRef{Kind: KindPrimitive, Name: "int64"}, Required: true, Doc: "Unique identifier."},
|
||||
{Name: "Username", JSONName: "username", Type: TypeRef{Kind: KindPrimitive, Name: "string"}, Required: false, Doc: "Optional username."},
|
||||
},
|
||||
}},
|
||||
Methods: []MethodDecl{{
|
||||
Name: "getMe",
|
||||
Doc: "A simple method for testing your bot's authentication token.",
|
||||
Returns: TypeRef{Kind: KindNamed, Name: "User"},
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(in, "", " ")
|
||||
require.NoError(t, err)
|
||||
|
||||
var out API
|
||||
require.NoError(t, json.Unmarshal(data, &out))
|
||||
require.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestTypeRefKindString(t *testing.T) {
|
||||
require.Equal(t, "primitive", KindPrimitive.String())
|
||||
require.Equal(t, "named", KindNamed.String())
|
||||
require.Equal(t, "array", KindArray.String())
|
||||
require.Equal(t, "oneOf", KindOneOf.String())
|
||||
}
|
||||
|
||||
func TestAPIRoundTrip_ArrayAndOneOf(t *testing.T) {
|
||||
elem := &TypeRef{Kind: KindNamed, Name: "Update"}
|
||||
in := API{
|
||||
Version: "7.10",
|
||||
Types: []TypeDecl{{
|
||||
Name: "InputMedia",
|
||||
OneOf: []string{"InputMediaPhoto", "InputMediaVideo"},
|
||||
}},
|
||||
Methods: []MethodDecl{{
|
||||
Name: "getUpdates",
|
||||
Params: []Field{{Name: "Limit", JSONName: "limit", Type: TypeRef{Kind: KindPrimitive, Name: "int"}}},
|
||||
Returns: TypeRef{Kind: KindArray, ElemType: elem},
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(in)
|
||||
require.NoError(t, err)
|
||||
var out API
|
||||
require.NoError(t, json.Unmarshal(data, &out))
|
||||
require.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestKind_MarshalUnmarshalText(t *testing.T) {
|
||||
cases := []Kind{KindPrimitive, KindNamed, KindArray, KindOneOf}
|
||||
for _, k := range cases {
|
||||
b, err := k.MarshalText()
|
||||
require.NoError(t, err)
|
||||
var out Kind
|
||||
require.NoError(t, out.UnmarshalText(b))
|
||||
require.Equal(t, k, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKind_UnmarshalText_UnknownReturnsError(t *testing.T) {
|
||||
var k Kind
|
||||
err := k.UnmarshalText([]byte("bogus"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestField_OmitsOptional(t *testing.T) {
|
||||
f := Field{Name: "X", JSONName: "x", Type: TypeRef{Kind: KindPrimitive, Name: "string"}}
|
||||
data, err := json.Marshal(f)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "required")
|
||||
require.NotContains(t, string(data), "doc")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Overrides is the schema of internal/spec/overrides.json. It lets engineers
|
||||
// pin specific method returns or field types, and approve methods that
|
||||
// genuinely return bool but whose doc phrasing the scraper doesn't recognise.
|
||||
type Overrides struct {
|
||||
// MethodReturns maps "<methodName>" → desired return TypeRef.
|
||||
// Applied AFTER the scraper extracts a return type, overriding it.
|
||||
MethodReturns map[string]TypeRef `json:"method_returns,omitempty"`
|
||||
|
||||
// FieldTypes maps "<TypeName>.<FieldName>" → desired field TypeRef.
|
||||
// Applied AFTER the scraper builds the IR, overriding the field type.
|
||||
FieldTypes map[string]TypeRef `json:"field_types,omitempty"`
|
||||
|
||||
// ApprovedBoolMethods lists methods whose returns are genuinely bool.
|
||||
// The audit tool ignores these.
|
||||
ApprovedBoolMethods []string `json:"approved_bool_methods,omitempty"`
|
||||
}
|
||||
|
||||
// LoadOverrides reads and parses overrides.json. Returns an empty Overrides
|
||||
// (not an error) if the file does not exist.
|
||||
func LoadOverrides(path string) (*Overrides, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &Overrides{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var o Overrides
|
||||
if err := json.Unmarshal(data, &o); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// Apply patches an API in place using the overrides.
|
||||
func (o *Overrides) Apply(api *API) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
for i, m := range api.Methods {
|
||||
if rt, ok := o.MethodReturns[m.Name]; ok {
|
||||
api.Methods[i].Returns = rt
|
||||
}
|
||||
}
|
||||
for i, t := range api.Types {
|
||||
for j, f := range t.Fields {
|
||||
key := t.Name + "." + f.Name
|
||||
if ft, ok := o.FieldTypes[key]; ok {
|
||||
api.Types[i].Fields[j].Type = ft
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsBoolApproved reports whether methodName is on the approved bool list.
|
||||
func (o *Overrides) IsBoolApproved(methodName string) bool {
|
||||
if o == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range o.ApprovedBoolMethods {
|
||||
if n == methodName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"method_returns": {},
|
||||
"field_types": {},
|
||||
"approved_bool_methods": [
|
||||
"setWebhook",
|
||||
"deleteWebhook",
|
||||
"logOut",
|
||||
"close",
|
||||
"sendMessageDraft",
|
||||
"sendChatAction",
|
||||
"setMessageReaction",
|
||||
"setUserEmojiStatus",
|
||||
"banChatMember",
|
||||
"unbanChatMember",
|
||||
"restrictChatMember",
|
||||
"promoteChatMember",
|
||||
"setChatAdministratorCustomTitle",
|
||||
"setChatMemberTag",
|
||||
"banChatSenderChat",
|
||||
"unbanChatSenderChat",
|
||||
"setChatPermissions",
|
||||
"approveChatJoinRequest",
|
||||
"declineChatJoinRequest",
|
||||
"setChatPhoto",
|
||||
"deleteChatPhoto",
|
||||
"setChatTitle",
|
||||
"setChatDescription",
|
||||
"pinChatMessage",
|
||||
"unpinChatMessage",
|
||||
"unpinAllChatMessages",
|
||||
"leaveChat",
|
||||
"setChatStickerSet",
|
||||
"deleteChatStickerSet",
|
||||
"editForumTopic",
|
||||
"closeForumTopic",
|
||||
"reopenForumTopic",
|
||||
"deleteForumTopic",
|
||||
"unpinAllForumTopicMessages",
|
||||
"editGeneralForumTopic",
|
||||
"closeGeneralForumTopic",
|
||||
"reopenGeneralForumTopic",
|
||||
"hideGeneralForumTopic",
|
||||
"unhideGeneralForumTopic",
|
||||
"unpinAllGeneralForumTopicMessages",
|
||||
"answerCallbackQuery",
|
||||
"setManagedBotAccessSettings",
|
||||
"setMyCommands",
|
||||
"deleteMyCommands",
|
||||
"setMyName",
|
||||
"setMyDescription",
|
||||
"setMyShortDescription",
|
||||
"setMyProfilePhoto",
|
||||
"removeMyProfilePhoto",
|
||||
"setChatMenuButton",
|
||||
"setMyDefaultAdministratorRights",
|
||||
"sendGift",
|
||||
"giftPremiumSubscription",
|
||||
"verifyUser",
|
||||
"verifyChat",
|
||||
"removeUserVerification",
|
||||
"removeChatVerification",
|
||||
"readBusinessMessage",
|
||||
"deleteBusinessMessages",
|
||||
"setBusinessAccountName",
|
||||
"setBusinessAccountUsername",
|
||||
"setBusinessAccountBio",
|
||||
"setBusinessAccountProfilePhoto",
|
||||
"removeBusinessAccountProfilePhoto",
|
||||
"setBusinessAccountGiftSettings",
|
||||
"transferBusinessAccountStars",
|
||||
"convertGiftToStars",
|
||||
"upgradeGift",
|
||||
"transferGift",
|
||||
"deleteStory",
|
||||
"approveSuggestedPost",
|
||||
"declineSuggestedPost",
|
||||
"deleteMessage",
|
||||
"deleteMessages",
|
||||
"deleteMessageReaction",
|
||||
"deleteAllMessageReactions",
|
||||
"createNewStickerSet",
|
||||
"addStickerToSet",
|
||||
"setStickerPositionInSet",
|
||||
"deleteStickerFromSet",
|
||||
"replaceStickerInSet",
|
||||
"setStickerEmojiList",
|
||||
"setStickerKeywords",
|
||||
"setStickerMaskPosition",
|
||||
"setStickerSetTitle",
|
||||
"setStickerSetThumbnail",
|
||||
"setCustomEmojiStickerSetThumbnail",
|
||||
"deleteStickerSet",
|
||||
"answerInlineQuery",
|
||||
"answerShippingQuery",
|
||||
"answerPreCheckoutQuery",
|
||||
"refundStarPayment",
|
||||
"editUserStarSubscription",
|
||||
"setPassportDataErrors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadOverrides_MissingFile(t *testing.T) {
|
||||
o, err := LoadOverrides(filepath.Join(t.TempDir(), "nonexistent.json"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, o)
|
||||
require.Empty(t, o.MethodReturns)
|
||||
require.Empty(t, o.FieldTypes)
|
||||
require.Empty(t, o.ApprovedBoolMethods)
|
||||
}
|
||||
|
||||
func TestLoadOverrides_MalformedJSON(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "bad.json")
|
||||
require.NoError(t, os.WriteFile(p, []byte("{bad json"), 0o600))
|
||||
_, err := LoadOverrides(p)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApply_PatchesMethodReturn(t *testing.T) {
|
||||
api := &API{
|
||||
Methods: []MethodDecl{
|
||||
{Name: "getMe", Returns: TypeRef{Kind: KindPrimitive, Name: "bool"}},
|
||||
},
|
||||
}
|
||||
o := &Overrides{
|
||||
MethodReturns: map[string]TypeRef{
|
||||
"getMe": {Kind: KindNamed, Name: "User"},
|
||||
},
|
||||
}
|
||||
o.Apply(api)
|
||||
require.Equal(t, KindNamed, api.Methods[0].Returns.Kind)
|
||||
require.Equal(t, "User", api.Methods[0].Returns.Name)
|
||||
}
|
||||
|
||||
func TestApply_PatchesFieldType(t *testing.T) {
|
||||
api := &API{
|
||||
Types: []TypeDecl{
|
||||
{
|
||||
Name: "Message",
|
||||
Fields: []Field{
|
||||
{Name: "ChatID", JSONName: "chat_id", Type: TypeRef{Kind: KindPrimitive, Name: "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
o := &Overrides{
|
||||
FieldTypes: map[string]TypeRef{
|
||||
"Message.ChatID": {Kind: KindOneOf, Variants: []string{"int64", "string"}},
|
||||
},
|
||||
}
|
||||
o.Apply(api)
|
||||
require.Equal(t, KindOneOf, api.Types[0].Fields[0].Type.Kind)
|
||||
require.Equal(t, []string{"int64", "string"}, api.Types[0].Fields[0].Type.Variants)
|
||||
}
|
||||
|
||||
func TestApply_NilOverrides(t *testing.T) {
|
||||
api := &API{
|
||||
Methods: []MethodDecl{{Name: "getMe", Returns: TypeRef{Kind: KindPrimitive, Name: "bool"}}},
|
||||
}
|
||||
var o *Overrides
|
||||
require.NotPanics(t, func() { o.Apply(api) })
|
||||
require.Equal(t, "bool", api.Methods[0].Returns.Name)
|
||||
}
|
||||
|
||||
func TestIsBoolApproved_Hit(t *testing.T) {
|
||||
o := &Overrides{ApprovedBoolMethods: []string{"setWebhook", "deleteWebhook"}}
|
||||
require.True(t, o.IsBoolApproved("setWebhook"))
|
||||
require.True(t, o.IsBoolApproved("deleteWebhook"))
|
||||
}
|
||||
|
||||
func TestIsBoolApproved_Miss(t *testing.T) {
|
||||
o := &Overrides{ApprovedBoolMethods: []string{"setWebhook"}}
|
||||
require.False(t, o.IsBoolApproved("getMe"))
|
||||
}
|
||||
|
||||
func TestIsBoolApproved_NilOverrides(t *testing.T) {
|
||||
var o *Overrides
|
||||
require.False(t, o.IsBoolApproved("anything"))
|
||||
}
|
||||
Reference in New Issue
Block a user