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
+142
View File
@@ -0,0 +1,142 @@
// Package message provides Filter helpers for *api.Message payloads.
package message
import (
"regexp"
"strings"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/dispatch"
)
// Text returns a Filter that matches messages whose Text matches pattern (regex).
// Panics at registration time on an invalid pattern.
func Text(pattern string) dispatch.Filter[*api.Message] {
re := regexp.MustCompile(pattern)
return func(m *api.Message) bool {
return m != nil && re.MatchString(m.Text)
}
}
// TextEquals returns a Filter that matches messages whose Text equals s exactly.
func TextEquals(s string) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.Text == s
}
}
// TextPrefix returns a Filter that matches messages whose Text starts with prefix.
func TextPrefix(prefix string) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && strings.HasPrefix(m.Text, prefix)
}
}
// TextContains returns a Filter that matches messages whose Text contains sub.
func TextContains(sub string) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && strings.Contains(m.Text, sub)
}
}
// Command returns a Filter that matches messages whose first entity is a
// bot_command equal to "/<name>" (with or without "@BotName" suffix).
func Command(name string) dispatch.Filter[*api.Message] {
want := "/" + strings.TrimPrefix(name, "/")
return func(m *api.Message) bool {
if m == nil || len(m.Entities) == 0 || m.Text == "" {
return false
}
first := m.Entities[0]
if first.Type != string(api.EntityBotCommand) || first.Offset != 0 {
return false
}
end := int(first.Length)
runes := []rune(m.Text)
if end > len(runes) {
return false
}
cmd := string(runes[:end])
if i := strings.Index(cmd, "@"); i >= 0 {
cmd = cmd[:i]
}
return cmd == want
}
}
// AnyCommand returns a Filter that matches any message starting with a
// bot_command entity at offset 0.
func AnyCommand() dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
if m == nil || len(m.Entities) == 0 {
return false
}
first := m.Entities[0]
return first.Type == string(api.EntityBotCommand) && first.Offset == 0
}
}
// IsReply returns a Filter that matches messages that have ReplyToMessage set.
func IsReply() dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.ReplyToMessage != nil
}
}
// IsForward returns a Filter that matches messages that have ForwardOrigin set.
func IsForward() dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.ForwardOrigin != nil
}
}
// HasPhoto returns a Filter that matches messages with a Photo attachment.
func HasPhoto() dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && len(m.Photo) > 0
}
}
// HasDocument returns a Filter that matches messages with a Document attachment.
func HasDocument() dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.Document != nil
}
}
// HasEntity returns a Filter that matches messages whose Entities contain at
// least one entity of type t (e.g. string(api.EntityBotCommand)).
func HasEntity(t string) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
if m == nil {
return false
}
for _, e := range m.Entities {
if e.Type == t {
return true
}
}
return false
}
}
// ChatType returns a Filter that matches messages whose Chat.Type equals t.
func ChatType(t api.ChatType) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.Chat.Type == string(t)
}
}
// FromUser returns a Filter that matches messages whose From.ID equals userID.
func FromUser(userID int64) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.From != nil && m.From.ID == userID
}
}
// InChat returns a Filter that matches messages whose Chat.ID equals chatID.
func InChat(chatID int64) dispatch.Filter[*api.Message] {
return func(m *api.Message) bool {
return m != nil && m.Chat.ID == chatID
}
}
+188
View File
@@ -0,0 +1,188 @@
package message_test
import (
"testing"
"github.com/lukaszraczylo/go-telegram/api"
msgfilter "github.com/lukaszraczylo/go-telegram/dispatch/filters/message"
"github.com/stretchr/testify/require"
)
func msg(text string) *api.Message {
return &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Text: text,
}
}
func cmdMsg(cmd string) *api.Message {
text := cmd
return &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 1, Type: string(api.ChatTypePrivate)},
Text: text,
Entities: []api.MessageEntity{
{Type: string(api.EntityBotCommand), Offset: 0, Length: int64(len([]rune(text)))},
},
}
}
func TestText(t *testing.T) {
f := msgfilter.Text(`^hello`)
require.True(t, f(msg("hello world")))
require.False(t, f(msg("world hello")))
require.False(t, f(nil))
}
func TestText_PanicsOnBadPattern(t *testing.T) {
require.Panics(t, func() { msgfilter.Text(`[invalid`) })
}
func TestTextEquals(t *testing.T) {
f := msgfilter.TextEquals("hi")
require.True(t, f(msg("hi")))
require.False(t, f(msg("hi there")))
require.False(t, f(nil))
}
func TestTextPrefix(t *testing.T) {
f := msgfilter.TextPrefix("/start")
require.True(t, f(msg("/start now")))
require.False(t, f(msg("no prefix")))
require.False(t, f(nil))
}
func TestTextContains(t *testing.T) {
f := msgfilter.TextContains("bot")
require.True(t, f(msg("my bot is cool")))
require.False(t, f(msg("nothing here")))
require.False(t, f(nil))
}
func TestCommand(t *testing.T) {
t.Run("matches exact command", func(t *testing.T) {
f := msgfilter.Command("/start")
require.True(t, f(cmdMsg("/start")))
})
t.Run("matches without leading slash", func(t *testing.T) {
f := msgfilter.Command("start")
require.True(t, f(cmdMsg("/start")))
})
t.Run("strips BotName suffix", func(t *testing.T) {
m := &api.Message{
Text: "/start@MyBot",
Entities: []api.MessageEntity{{Type: string(api.EntityBotCommand), Offset: 0, Length: 12}},
}
f := msgfilter.Command("/start")
require.True(t, f(m))
})
t.Run("no match different command", func(t *testing.T) {
f := msgfilter.Command("/stop")
require.False(t, f(cmdMsg("/start")))
})
t.Run("nil message", func(t *testing.T) {
require.False(t, msgfilter.Command("/start")(nil))
})
t.Run("no entities", func(t *testing.T) {
require.False(t, msgfilter.Command("/start")(msg("/start")))
})
}
func TestAnyCommand(t *testing.T) {
f := msgfilter.AnyCommand()
require.True(t, f(cmdMsg("/anything")))
require.False(t, f(msg("plain text")))
require.False(t, f(nil))
}
func TestIsReply(t *testing.T) {
f := msgfilter.IsReply()
m := msg("reply")
m.ReplyToMessage = &api.Message{MessageID: 2}
require.True(t, f(m))
require.False(t, f(msg("no reply")))
require.False(t, f(nil))
}
func TestIsForward(t *testing.T) {
// ForwardOrigin is a MessageOrigin interface; set via a concrete type.
f := msgfilter.IsForward()
m := msg("fwd")
m.ForwardOrigin = &api.MessageOriginUser{Type: "user"}
require.True(t, f(m))
require.False(t, f(msg("no fwd")))
require.False(t, f(nil))
}
func TestHasPhoto(t *testing.T) {
f := msgfilter.HasPhoto()
m := msg("")
m.Photo = []api.PhotoSize{{FileID: "x", Width: 100, Height: 100}}
require.True(t, f(m))
require.False(t, f(msg("no photo")))
require.False(t, f(nil))
}
func TestHasDocument(t *testing.T) {
f := msgfilter.HasDocument()
m := msg("")
m.Document = &api.Document{FileID: "doc1"}
require.True(t, f(m))
require.False(t, f(msg("no doc")))
require.False(t, f(nil))
}
func TestHasEntity(t *testing.T) {
f := msgfilter.HasEntity(string(api.EntityURL))
m := msg("check https://example.com")
m.Entities = []api.MessageEntity{{Type: string(api.EntityURL), Offset: 6, Length: 19}}
require.True(t, f(m))
require.False(t, f(msg("plain")))
require.False(t, f(nil))
}
func TestChatType(t *testing.T) {
f := msgfilter.ChatType(api.ChatTypePrivate)
private := msg("hi")
require.True(t, f(private))
group := msg("hi")
group.Chat.Type = string(api.ChatTypeGroup)
require.False(t, f(group))
require.False(t, f(nil))
}
func TestFromUser(t *testing.T) {
f := msgfilter.FromUser(42)
m := msg("hi")
m.From = &api.User{ID: 42}
require.True(t, f(m))
m2 := msg("hi")
m2.From = &api.User{ID: 99}
require.False(t, f(m2))
require.False(t, f(msg("no from")))
require.False(t, f(nil))
}
func TestInChat(t *testing.T) {
f := msgfilter.InChat(1)
require.True(t, f(msg("hi")))
m2 := msg("hi")
m2.Chat.ID = 2
require.False(t, f(m2))
require.False(t, f(nil))
}
func TestComposedMessageFilters(t *testing.T) {
// private chat AND contains "hello"
f := msgfilter.ChatType(api.ChatTypePrivate).And(msgfilter.TextContains("hello"))
m := msg("say hello")
require.True(t, f(m))
m2 := msg("say hello")
m2.Chat.Type = string(api.ChatTypeGroup)
require.False(t, f(m2))
}