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:
2026-05-09 13:09:27 +01:00
commit ac7cae8fa7
164 changed files with 100239 additions and 0 deletions
+27664
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -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"`
}
+87
View File
@@ -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")
}
+75
View File
@@ -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
}
+100
View File
@@ -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"
]
}
+87
View File
@@ -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"))
}