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
+39
View File
@@ -0,0 +1,39 @@
# Examples
Each subdirectory contains a self-contained sample bot demonstrating one feature area.
| Example | What it shows |
|---|---|
| [echo](./echo) | Long-poll bot that echoes text back to the sender |
| [webhook](./webhook) | Webhook delivery with secret-token verification |
| [callback](./callback) | Inline keyboard with callback queries and counter state |
| [conversation](./conversation) | Multi-step conversation flow with `dispatch/conversation` |
| [files](./files) | Upload and download files via `api.DownloadFile` |
| [inline](./inline) | Inline-mode bot returning search-style results |
| [middleware](./middleware) | Custom middleware chains via `Router.Use` |
| [stateful](./stateful) | Per-user state managed via closures |
| [welcome](./welcome) | Greet new chat members; detect and log departures |
| [moderation](./moderation) | `/kick`, `/ban`, `/mute`, `/warn` with admin permission checks |
| [polls](./polls) | Create polls and tally answers via `OnPollAnswer` |
| [payments](./payments) | Telegram Payments: sendInvoice → pre_checkout_query → successful_payment |
| [pagination](./pagination) | Multi-page inline keyboard with stateless prev/next navigation |
| [admin](./admin) | Auth middleware allowlisting specific user IDs via `Router.Use` |
## Running
All examples follow the same pattern:
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/<name>
```
Webhook examples need a public HTTPS endpoint (use Cloudflare Tunnel, ngrok, or similar).
## Common patterns
**Retry-safe HTTP** — every example wraps the HTTP client with `client.NewRetryDoer`, which automatically honours Telegram's `retry_after` field on 429 responses.
**Graceful shutdown** — all examples use `signal.NotifyContext` so the bot drains cleanly on `SIGINT`/`SIGTERM`.
**Structured logging** — for production, wire a logger via `client.WithLogger` and wrap the process in supervision (systemd unit, k8s liveness probe, etc.).
+40
View File
@@ -0,0 +1,40 @@
# admin
Authentication middleware that restricts the bot to an allowlist of Telegram user IDs.
## What it shows
- `router.Use(...)` to install a global `Middleware[*api.Update]`
- Parsing `ALLOWED_USERS` env var into a `map[int64]bool` lookup set
- Extracting sender ID from multiple update types in one helper
- Silent drop pattern for unauthorized updates (no error, no reply)
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `TELEGRAM_BOT_TOKEN` | Yes | Bot token from @BotFather |
| `ALLOWED_USERS` | No | Comma-separated numeric user IDs, e.g. `123456,789012`. If unset, all users are permitted. |
## Finding your user ID
Send `/whoami` to the bot — it replies with your numeric Telegram user ID. Add that ID to `ALLOWED_USERS` to restrict the bot to you.
## Extending
Combine with `examples/moderation` to ensure only group admins can invoke moderation commands:
```go
router.Use(allowlistMiddleware(adminIDs))
router.OnCommand("/ban", banHandler)
```
For group-context admin checks (verify the sender is an admin of *that specific group*), use `api.GetChatAdministrators` and check the result dynamically rather than a static ID list.
## Running
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
export ALLOWED_USERS=111111,222222
go run ./examples/admin
```
+127
View File
@@ -0,0 +1,127 @@
// Package main demonstrates auth middleware that restricts the bot to an
// allowlist of Telegram user IDs.
//
// Set ALLOWED_USERS to a comma-separated list of numeric user IDs.
// Messages from all other users are silently dropped.
//
// TELEGRAM_BOT_TOKEN=xxx ALLOWED_USERS=123456,789012 go run ./examples/admin
package main
import (
"context"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
// parseAllowedIDs parses a comma-separated list of user IDs from an env var.
func parseAllowedIDs(raw string) map[int64]bool {
out := make(map[int64]bool)
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseInt(part, 10, 64)
if err != nil {
log.Printf("invalid user ID in ALLOWED_USERS: %q — skipping", part)
continue
}
out[id] = true
}
return out
}
// extractSenderID returns the Telegram user ID from the most common update types.
func extractSenderID(u *api.Update) int64 {
if u.Message != nil && u.Message.From != nil {
return u.Message.From.ID
}
if u.EditedMessage != nil && u.EditedMessage.From != nil {
return u.EditedMessage.From.ID
}
if u.CallbackQuery != nil && u.CallbackQuery.From.ID != 0 {
return u.CallbackQuery.From.ID
}
if u.InlineQuery != nil {
return u.InlineQuery.From.ID
}
return 0
}
// allowlistMiddleware drops updates from users not in the allowlist.
// Passing an empty allowlist (ALLOWED_USERS unset) allows everyone through,
// so this example is safe to run without the env var set.
func allowlistMiddleware(allowed map[int64]bool) dispatch.Middleware[*api.Update] {
return func(next dispatch.Handler[*api.Update]) dispatch.Handler[*api.Update] {
return func(c *dispatch.Context, u *api.Update) error {
if len(allowed) == 0 {
// No allowlist configured — permit all.
return next(c, u)
}
senderID := extractSenderID(u)
if senderID != 0 && !allowed[senderID] {
log.Printf("dropping update from unauthorized user %d", senderID)
return nil // Silent drop.
}
return next(c, u)
}
}
}
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
allowedIDs := parseAllowedIDs(os.Getenv("ALLOWED_USERS"))
if len(allowedIDs) == 0 {
log.Println("ALLOWED_USERS not set — all users permitted (demo mode)")
} else {
log.Printf("allowlist: %d user(s)", len(allowedIDs))
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
router.Use(allowlistMiddleware(allowedIDs))
router.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "You are authorized.",
})
return err
})
router.OnCommand("/whoami", func(c *dispatch.Context, m *api.Message) error {
from := m.From
if from == nil {
return nil
}
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Your user ID is: " + strconv.FormatInt(from.ID, 10),
})
return err
})
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
# callback
Inline keyboard with a counter that updates via callback queries. Demonstrates `OnCallback`, `AnswerCallbackQuery`, and `EditMessageText`.
## Run
```bash
export TELEGRAM_BOT_TOKEN=...
go run ./examples/callback
```
Send `/start` in a chat with the bot.
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"fmt"
"strconv"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
)
// register wires all handlers onto the router.
func register(r *dispatch.Router) {
r.OnCommand("/start", handleStart)
r.OnCallback(`^count:(-?\d+):(inc|dec)$`, handleCallback)
}
func handleStart(c *dispatch.Context, m *api.Message) error {
return sendMenu(c.Ctx, c.Bot, m.Chat.ID, 0)
}
func handleCallback(c *dispatch.Context, q *api.CallbackQuery) error {
groups := c.Values["regex_match"].([]string)
current, _ := strconv.Atoi(groups[1])
if groups[2] == "inc" {
current++
} else {
current--
}
// Acknowledge the callback (removes the loading spinner).
_, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{
CallbackQueryID: q.ID,
Text: fmt.Sprintf("counter is now %d", current),
})
// Edit the message to reflect the new state.
if q.Message == nil {
return nil
}
msg, ok := q.Message.(*api.Message)
if !ok {
return nil
}
chatID := api.ChatIDFromInt(msg.Chat.ID)
mid := msg.MessageID
_, err := api.EditMessageText(c.Ctx, c.Bot, &api.EditMessageTextParams{
ChatID: &chatID,
MessageID: &mid,
Text: fmt.Sprintf("Counter: %d", current),
ReplyMarkup: counterKeyboard(current),
})
return err
}
func sendMenu(ctx context.Context, bot *client.Bot, chatID int64, value int) error {
_, err := api.SendMessage(ctx, bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(chatID),
Text: fmt.Sprintf("Counter: %d", value),
ReplyMarkup: counterKeyboard(value),
})
return err
}
func counterKeyboard(value int) *api.InlineKeyboardMarkup {
return &api.InlineKeyboardMarkup{
InlineKeyboard: [][]api.InlineKeyboardButton{
{
{Text: "", CallbackData: fmt.Sprintf("count:%d:dec", value)},
{Text: "+", CallbackData: fmt.Sprintf("count:%d:inc", value)},
},
},
}
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
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)
}
func okResp(body string) *http.Response {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
const (
sendMsgResult = `{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":42,"type":"private"}}}`
editMsgResult = `{"ok":true,"result":{"message_id":10,"date":0,"chat":{"id":42,"type":"private"}}}`
answerCbResult = `{"ok":true,"result":true}`
)
func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context {
c := dispatch.NewContext(context.Background(), bot, upd)
for k, v := range extra {
c.Values[k] = v
}
return c
}
// --- handleStart ---
func TestHandleStart_SendsInitialKeyboard(t *testing.T) {
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
return false
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(r.Body)
body := buf.String()
// Counter starts at 0; keyboard must contain both buttons
return strings.Contains(body, `"Counter: 0"`) &&
strings.Contains(body, `"reply_markup"`) &&
strings.Contains(body, `"count:0:dec"`) &&
strings.Contains(body, `"count:0:inc"`)
})).Return(okResp(sendMsgResult), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "/start",
}
upd := &api.Update{UpdateID: 1, Message: msg}
require.NoError(t, handleStart(makeCtx(bot, upd, nil), msg))
m.AssertExpectations(t)
}
// --- handleCallback ---
func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context {
upd := &api.Update{UpdateID: 1, CallbackQuery: q}
return makeCtx(bot, upd, map[string]any{"regex_match": groups})
}
func callbackQuery(data string, msgID int64, chatID int64) *api.CallbackQuery {
msg := &api.Message{
MessageID: msgID,
Chat: api.Chat{ID: chatID, Type: string(api.ChatTypePrivate)},
}
return &api.CallbackQuery{
ID: "cb1",
From: api.User{ID: 7},
Message: msg,
Data: data,
}
}
func TestHandleCallback_Increments(t *testing.T) {
m := &mockDoer{}
// AnswerCallbackQuery
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/answerCallbackQuery")
})).Return(okResp(answerCbResult), nil)
// EditMessageText — counter must show 6
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/editMessageText") {
return false
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(r.Body)
return strings.Contains(buf.String(), `"Counter: 6"`)
})).Return(okResp(editMsgResult), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
q := callbackQuery("count:5:inc", 10, 42)
// groups: [full_match, "5", "inc"]
c := callbackCtx(bot, q, []string{"count:5:inc", "5", "inc"})
require.NoError(t, handleCallback(c, q))
m.AssertExpectations(t)
}
func TestHandleCallback_Decrements(t *testing.T) {
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return strings.HasSuffix(r.URL.Path, "/answerCallbackQuery")
})).Return(okResp(answerCbResult), nil)
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/editMessageText") {
return false
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(r.Body)
return strings.Contains(buf.String(), `"Counter: 4"`)
})).Return(okResp(editMsgResult), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
q := callbackQuery("count:5:dec", 10, 42)
c := callbackCtx(bot, q, []string{"count:5:dec", "5", "dec"})
require.NoError(t, handleCallback(c, q))
m.AssertExpectations(t)
}
+39
View File
@@ -0,0 +1,39 @@
// Package main demonstrates inline keyboards and callback queries with
// go-telegram. Send /start to the bot in any chat to see two buttons.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/callback
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
register(router)
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+10
View File
@@ -0,0 +1,10 @@
# conversation
Multi-step conversation flow demonstrating `dispatch/conversation`. Send `/newbot` to start; reply with a name then a description. Send `/cancel` at any point to abort.
## Run
```bash
export TELEGRAM_BOT_TOKEN=...
go run ./examples/conversation
```
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/dispatch/conversation"
msgfilter "github.com/lukaszraczylo/go-telegram/dispatch/filters/message"
)
// onMsg lifts a Filter[*api.Message] into a Filter[*api.Update].
func onMsg(f dispatch.Filter[*api.Message]) dispatch.Filter[*api.Update] {
return func(u *api.Update) bool { return u.Message != nil && f(u.Message) }
}
// notCmd matches message updates that are NOT a bot command.
var notCmd = onMsg(msgfilter.AnyCommand().Not())
// buildConv constructs the /newbot conversation. Accepts an optional Storage
// so tests can inject MemoryStorage for isolation; pass nil for the default.
func buildConv(storage conversation.Storage) *conversation.Conversation {
conv := &conversation.Conversation{
Storage: storage,
EntryPoints: []conversation.Step{{
Filter: onMsg(msgfilter.Command("/newbot")),
Handler: func(c *dispatch.Context, u *api.Update) error {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(u.Message.Chat.ID),
Text: "What should the bot's name be?",
})
return conversation.Next("await_name")
},
}},
States: map[conversation.State][]conversation.Step{
"await_name": {{
Filter: notCmd,
Handler: func(c *dispatch.Context, u *api.Update) error {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(u.Message.Chat.ID),
Text: "Got it! What's the description?",
})
return conversation.Next("await_desc")
},
}},
"await_desc": {{
Filter: notCmd,
Handler: func(c *dispatch.Context, u *api.Update) error {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(u.Message.Chat.ID),
Text: "Done! Your bot has been configured.",
})
return conversation.End()
},
}},
},
Exits: []conversation.Step{{
Filter: onMsg(msgfilter.Command("/cancel")),
Handler: func(c *dispatch.Context, u *api.Update) error {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(u.Message.Chat.ID),
Text: "Cancelled.",
})
return conversation.End()
},
}},
}
return conv
}
// register wires the conversation middleware onto the router.
func register(r *dispatch.Router, bot *client.Bot) {
_ = bot // bot available for future handlers if needed
conv := buildConv(nil)
r.Use(conv.Dispatch)
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"bytes"
"context"
"io"
"net/http"
"testing"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/dispatch/conversation"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// mockDoer satisfies client.HTTPDoer.
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)
}
func anyResp() *http.Response {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":1,"type":"private"}}}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
// msgUpd builds a message update with the given user/chat/text.
func msgUpd(userID, chatID int64, text string) api.Update {
entities := []api.MessageEntity{}
if len(text) > 0 && text[0] == '/' {
end := len(text)
for i, r := range text {
if r == ' ' {
end = i
break
}
}
entities = append(entities, api.MessageEntity{
Type: string(api.EntityBotCommand),
Offset: 0,
Length: int64(end),
})
}
return api.Update{
UpdateID: 1,
Message: &api.Message{
MessageID: 1,
From: &api.User{ID: userID},
Chat: api.Chat{ID: chatID, Type: string(api.ChatTypePrivate)},
Text: text,
Entities: entities,
},
}
}
func makeCtx(bot *client.Bot, u *api.Update) *dispatch.Context {
return dispatch.NewContext(context.Background(), bot, u)
}
// allowAny mocks unlimited sendMessage calls.
func allowAny(m *mockDoer) {
m.On("Do", mock.Anything).Return(anyResp(), nil)
}
func TestConversation_NewBot_AsksName(t *testing.T) {
m := &mockDoer{}
allowAny(m)
bot := client.New("test:token", client.WithHTTPClient(m))
store := conversation.NewMemoryStorage()
conv := buildConv(store)
mw := conv.Dispatch(func(c *dispatch.Context, u *api.Update) error { return nil })
u := msgUpd(42, 1, "/newbot")
require.NoError(t, mw(makeCtx(bot, &u), &u))
state, err := store.Get(context.Background(), "uc:1:42")
require.NoError(t, err)
require.Equal(t, conversation.State("await_name"), state)
}
func TestConversation_NewBot_StoresName_AsksDesc(t *testing.T) {
m := &mockDoer{}
allowAny(m)
bot := client.New("test:token", client.WithHTTPClient(m))
store := conversation.NewMemoryStorage()
conv := buildConv(store)
mw := conv.Dispatch(func(c *dispatch.Context, u *api.Update) error { return nil })
// Enter conversation.
u1 := msgUpd(42, 1, "/newbot")
require.NoError(t, mw(makeCtx(bot, &u1), &u1))
// Reply with name — should advance to await_desc.
u2 := msgUpd(42, 1, "MyBot")
require.NoError(t, mw(makeCtx(bot, &u2), &u2))
state, err := store.Get(context.Background(), "uc:1:42")
require.NoError(t, err)
require.Equal(t, conversation.State("await_desc"), state)
}
func TestConversation_Cancel_EndsConversation(t *testing.T) {
m := &mockDoer{}
allowAny(m)
bot := client.New("test:token", client.WithHTTPClient(m))
store := conversation.NewMemoryStorage()
conv := buildConv(store)
mw := conv.Dispatch(func(c *dispatch.Context, u *api.Update) error { return nil })
// Enter conversation.
u1 := msgUpd(42, 1, "/newbot")
require.NoError(t, mw(makeCtx(bot, &u1), &u1))
// Cancel mid-flow.
u2 := msgUpd(42, 1, "/cancel")
require.NoError(t, mw(makeCtx(bot, &u2), &u2))
_, err := store.Get(context.Background(), "uc:1:42")
require.ErrorIs(t, err, conversation.ErrKeyNotFound, "cancel must clear state")
}
+37
View File
@@ -0,0 +1,37 @@
// Package main demonstrates a /newbot-style conversation flow using
// dispatch/conversation.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/conversation
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())))
router := dispatch.New(bot)
register(router, bot)
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+10
View File
@@ -0,0 +1,10 @@
# echo
Long-poll echo bot. Replies to `/start` with a greeting and echoes any other text.
## Run
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/echo
```
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"fmt"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/dispatch"
)
// register wires all handlers onto the router. Exposed so tests can call
// handlers directly without going through the router run loop.
func register(r *dispatch.Router) {
r.OnCommand("/start", handleStart)
r.OnText(`.+`, handleEcho)
}
func handleStart(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: fmt.Sprintf("hello %s, send me anything to echo", m.From.FirstName),
})
return err
}
func handleEcho(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: m.Text,
ReplyParameters: &api.ReplyParameters{MessageID: m.MessageID},
})
return err
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// mockDoer satisfies client.HTTPDoer via testify/mock.
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)
}
func okResp(body string) *http.Response {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
const sendMsgResult = `{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":42,"type":"private"}}}`
func makeCtx(bot *client.Bot, upd *api.Update) *dispatch.Context {
return dispatch.NewContext(context.Background(), bot, upd)
}
func TestHandleStart_GreetsUser(t *testing.T) {
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
return false
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(r.Body)
body := buf.String()
return strings.Contains(body, `"text"`) && strings.Contains(body, "Alice")
})).Return(okResp(sendMsgResult), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "/start",
}
upd := &api.Update{UpdateID: 1, Message: msg}
require.NoError(t, handleStart(makeCtx(bot, upd), msg))
m.AssertExpectations(t)
}
func TestHandleEcho_RepliesWithSameText(t *testing.T) {
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
return false
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(r.Body)
body := buf.String()
// text is echoed and reply_to_message_id is set to source message ID (5)
return strings.Contains(body, `"hello echo"`) &&
strings.Contains(body, `"message_id":5`)
})).Return(okResp(sendMsgResult), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 5,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "hello echo",
}
upd := &api.Update{UpdateID: 1, Message: msg}
require.NoError(t, handleEcho(makeCtx(bot, upd), msg))
m.AssertExpectations(t)
}
+43
View File
@@ -0,0 +1,43 @@
// Package main is a long-poll echo bot. Run with:
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/echo
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())))
me, err := api.GetMe(ctx, bot, &api.GetMeParams{})
if err != nil {
log.Fatalf("getMe: %v", err)
}
log.Printf("running as @%s", me.Username)
router := dispatch.New(bot)
register(router)
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
# files
Bot that downloads documents users send and re-uploads them. Demonstrates `api.DownloadFile`, `api.SendDocument` with `*InputFile`, and middleware-based dispatch on document updates.
## Run
```bash
export TELEGRAM_BOT_TOKEN=...
go run ./examples/files
```
Send any document to the bot.
+110
View File
@@ -0,0 +1,110 @@
// Package main demonstrates file upload and download with go-telegram.
// Send any document or photo to the bot — it downloads, then re-uploads
// the file with a "received: <bytes>" caption.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/files
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
router.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Send me a document and I'll download then re-upload it.",
})
return err
})
// Document uploads land in Message.Document. Use middleware to handle them.
router.Use(func(next dispatch.Handler[*api.Update]) dispatch.Handler[*api.Update] {
return func(c *dispatch.Context, u *api.Update) error {
if u.Message != nil && u.Message.Document != nil {
return handleDocument(c, u.Message)
}
return next(c, u)
}
})
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
func handleDocument(c *dispatch.Context, m *api.Message) error {
doc := m.Document
fileSize := int64(0)
if doc.FileSize != nil {
fileSize = *doc.FileSize
}
log.Printf("received: %s (%d bytes)", doc.FileName, fileSize)
// Download.
rc, _, err := api.DownloadFile(c.Ctx, c.Bot, doc.FileID)
if err != nil {
return reply(c, m.Chat.ID, fmt.Sprintf("download failed: %v", err))
}
defer func() {
_ = rc.Close()
}()
body, err := io.ReadAll(rc)
if err != nil {
return reply(c, m.Chat.ID, fmt.Sprintf("read failed: %v", err))
}
// Re-upload.
name := filepath.Base(doc.FileName)
if name == "" || name == "." {
name = "file"
}
_, err = api.SendDocument(c.Ctx, c.Bot, &api.SendDocumentParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Document: &api.InputFile{
Reader: bytes.NewReader(body),
Filename: name,
},
Caption: fmt.Sprintf("received: %d bytes", len(body)),
})
if err != nil {
return reply(c, m.Chat.ID, fmt.Sprintf("upload failed: %v", err))
}
return nil
}
func reply(c *dispatch.Context, chatID int64, text string) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(chatID),
Text: text,
})
return err
}
+12
View File
@@ -0,0 +1,12 @@
# inline
Demonstrates inline-mode queries. Enable inline mode for your bot via [@BotFather](https://t.me/BotFather) (`/setinline`).
## Run
```bash
export TELEGRAM_BOT_TOKEN=...
go run ./examples/inline
```
In any Telegram chat, type `@yourbot something` to see two results: an echo and an UPPERCASE'd version.
+64
View File
@@ -0,0 +1,64 @@
// Package main demonstrates inline-mode queries with go-telegram.
//
// Enable inline mode for your bot via @BotFather: /setinline → enable.
// Then type @yourbot something in any chat to see results.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/inline
package main
import (
"context"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token)
router := dispatch.New(bot)
router.OnInlineQuery(func(c *dispatch.Context, q *api.InlineQuery) error {
// Echo the query as article results.
results := []api.InlineQueryResult{
&api.InlineQueryResultArticle{
Type: "article",
ID: "echo",
Title: "Echo: " + q.Query,
InputMessageContent: &api.InputTextMessageContent{
MessageText: q.Query,
},
},
&api.InlineQueryResultArticle{
Type: "article",
ID: "upper",
Title: "UPPER: " + strings.ToUpper(q.Query),
InputMessageContent: &api.InputTextMessageContent{
MessageText: strings.ToUpper(q.Query),
},
},
}
_, err := api.AnswerInlineQuery(c.Ctx, c.Bot, &api.AnswerInlineQueryParams{
InlineQueryID: q.ID,
Results: results,
})
return err
})
if err := router.Run(ctx, transport.NewLongPoller(bot)); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+11
View File
@@ -0,0 +1,11 @@
# middleware
Demonstrates two custom dispatch middlewares: timing (logs handler latency) and auth (restricts updates to a single owner via `OWNER_USER_ID` env var).
## Run
```bash
export TELEGRAM_BOT_TOKEN=...
export OWNER_USER_ID=123456789 # optional
go run ./examples/middleware
```
+86
View File
@@ -0,0 +1,86 @@
// Package main demonstrates custom middleware for go-telegram.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/middleware
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
// timing wraps a handler chain with start-end timing logged via stdlib log.
func timing() dispatch.Middleware[*api.Update] {
return func(next dispatch.Handler[*api.Update]) dispatch.Handler[*api.Update] {
return func(c *dispatch.Context, u *api.Update) error {
start := time.Now()
err := next(c, u)
log.Printf("update %d processed in %s err=%v", u.UpdateID, time.Since(start), err)
return err
}
}
}
// auth restricts updates to a single allowed user ID via env var.
func auth(allowedUserID int64) dispatch.Middleware[*api.Update] {
return func(next dispatch.Handler[*api.Update]) dispatch.Handler[*api.Update] {
return func(c *dispatch.Context, u *api.Update) error {
sender := senderID(u)
if allowedUserID != 0 && sender != allowedUserID {
log.Printf("blocked update %d from user %d", u.UpdateID, sender)
return nil
}
return next(c, u)
}
}
}
func senderID(u *api.Update) int64 {
switch {
case u.Message != nil && u.Message.From != nil:
return u.Message.From.ID
case u.CallbackQuery != nil:
return u.CallbackQuery.From.ID
}
return 0
}
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token)
router := dispatch.New(bot)
router.Use(timing())
// To restrict to a single owner, set OWNER_USER_ID env to your numeric ID.
var ownerID int64
_, _ = fmt.Sscanf(os.Getenv("OWNER_USER_ID"), "%d", &ownerID)
if ownerID != 0 {
router.Use(auth(ownerID))
}
router.OnCommand("/start", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "middleware demo: this update was timed and (optionally) auth-checked",
})
return err
})
if err := router.Run(ctx, transport.NewLongPoller(bot)); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+39
View File
@@ -0,0 +1,39 @@
# moderation
Group moderation commands: `/kick`, `/ban`, `/mute`, `/warn`, `/unwarn`.
## What it shows
- `OnCommand` for each moderation action
- `api.BanChatMember` / `api.UnbanChatMember` for kick and ban
- `api.RestrictChatMember` with `ChatPermissions` for muting
- `errors.Is(err, client.ErrForbidden)` to surface missing-permissions errors cleanly
- In-memory warn counter via `sync.Map` (auto-bans at 3 warnings)
## Required bot permissions
The bot must be an **admin** in the group with **"can ban users"** and **"can restrict members"** permissions. Without those rights, commands will reply with a friendly error message instead of crashing.
## Usage
All commands work by **replying** to a target user's message:
```
/kick — remove from group (can rejoin)
/ban — permanent ban
/mute — silence for 1 hour
/warn — issue a warning (3 warnings = auto-ban)
/unwarn — remove the last warning
```
## Production notes
- The warn counter is in-memory and lost on restart. For production, back it with Redis or a database.
- Consider adding an admin check (see `examples/admin`) so only group admins can invoke these commands.
## Running
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/moderation
```
+206
View File
@@ -0,0 +1,206 @@
// Package main demonstrates group moderation commands: /kick, /ban, /mute, /warn.
//
// The bot must be an admin in the group with "can ban users" permission.
// Use by replying to a target user's message, e.g.: /kick (as a reply).
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/moderation
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
const maxWarns = 3
// warnKey uniquely identifies a user in a chat.
type warnKey struct {
chatID int64
userID int64
}
var warnCounts sync.Map // map[warnKey]int — in-memory only; use a DB in production
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
router.OnCommand("/kick", kickHandler)
router.OnCommand("/ban", banHandler)
router.OnCommand("/mute", muteHandler)
router.OnCommand("/warn", warnHandler)
router.OnCommand("/unwarn", unwarnHandler)
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
// resolveTarget returns the user ID of the moderation target.
// Priority: replied-to message sender, then first @mention in the text.
func resolveTarget(m *api.Message) int64 {
if m.ReplyToMessage != nil && m.ReplyToMessage.From != nil {
return m.ReplyToMessage.From.ID
}
return 0
}
func reply(c *dispatch.Context, chatID int64, text string) {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(chatID),
Text: text,
})
}
func handleAdminErr(c *dispatch.Context, chatID int64, err error) error {
if errors.Is(err, client.ErrForbidden) {
reply(c, chatID, "I need admin rights with 'can ban users' permission.")
return nil
}
return err
}
func kickHandler(c *dispatch.Context, m *api.Message) error {
target := resolveTarget(m)
if target == 0 {
reply(c, m.Chat.ID, "Reply to a user's message with /kick to remove them.")
return nil
}
// Kick = ban then immediately unban (removes from group, can rejoin).
if _, err := api.BanChatMember(c.Ctx, c.Bot, &api.BanChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
}); err != nil {
return handleAdminErr(c, m.Chat.ID, err)
}
truVal := true
if _, err := api.UnbanChatMember(c.Ctx, c.Bot, &api.UnbanChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
OnlyIfBanned: &truVal,
}); err != nil {
log.Printf("unban after kick: %v", err)
}
reply(c, m.Chat.ID, fmt.Sprintf("User %d has been kicked.", target))
return nil
}
func banHandler(c *dispatch.Context, m *api.Message) error {
target := resolveTarget(m)
if target == 0 {
reply(c, m.Chat.ID, "Reply to a user's message with /ban to ban them permanently.")
return nil
}
if _, err := api.BanChatMember(c.Ctx, c.Bot, &api.BanChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
}); err != nil {
return handleAdminErr(c, m.Chat.ID, err)
}
reply(c, m.Chat.ID, fmt.Sprintf("User %d has been banned.", target))
return nil
}
func muteHandler(c *dispatch.Context, m *api.Message) error {
target := resolveTarget(m)
if target == 0 {
reply(c, m.Chat.ID, "Reply to a user's message with /mute to silence them for 1 hour.")
return nil
}
until := time.Now().Add(time.Hour).Unix()
falseVal := false
if _, err := api.RestrictChatMember(c.Ctx, c.Bot, &api.RestrictChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
Permissions: api.ChatPermissions{
CanSendMessages: &falseVal,
CanSendAudios: &falseVal,
CanSendDocuments: &falseVal,
CanSendPhotos: &falseVal,
CanSendVideos: &falseVal,
CanSendVideoNotes: &falseVal,
CanSendVoiceNotes: &falseVal,
CanSendPolls: &falseVal,
CanSendOtherMessages: &falseVal,
},
UntilDate: &until,
}); err != nil {
return handleAdminErr(c, m.Chat.ID, err)
}
reply(c, m.Chat.ID, fmt.Sprintf("User %d muted for 1 hour.", target))
return nil
}
func warnHandler(c *dispatch.Context, m *api.Message) error {
target := resolveTarget(m)
if target == 0 {
reply(c, m.Chat.ID, "Reply to a user's message with /warn to issue a warning.")
return nil
}
key := warnKey{chatID: m.Chat.ID, userID: target}
var count int
if v, ok := warnCounts.Load(key); ok {
count = v.(int)
}
count++
warnCounts.Store(key, count)
if count >= maxWarns {
warnCounts.Delete(key)
reply(c, m.Chat.ID, fmt.Sprintf("User %d reached %d warnings — auto-banning.", target, maxWarns))
if _, err := api.BanChatMember(c.Ctx, c.Bot, &api.BanChatMemberParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
UserID: target,
}); err != nil {
return handleAdminErr(c, m.Chat.ID, err)
}
return nil
}
reply(c, m.Chat.ID, fmt.Sprintf("User %d warned (%d/%d). Reach %d and they're banned.", target, count, maxWarns, maxWarns))
return nil
}
func unwarnHandler(c *dispatch.Context, m *api.Message) error {
target := resolveTarget(m)
if target == 0 {
reply(c, m.Chat.ID, "Reply to a user's message with /unwarn to remove their last warning.")
return nil
}
key := warnKey{chatID: m.Chat.ID, userID: target}
if v, ok := warnCounts.Load(key); ok {
count := v.(int) - 1
if count <= 0 {
warnCounts.Delete(key)
reply(c, m.Chat.ID, fmt.Sprintf("User %d has no more warnings.", target))
} else {
warnCounts.Store(key, count)
reply(c, m.Chat.ID, fmt.Sprintf("User %d warning removed (%d/%d remaining).", target, count, maxWarns))
}
} else {
reply(c, m.Chat.ID, fmt.Sprintf("User %d has no warnings.", target))
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
# pagination
Multi-page inline keyboard for browsing a list. No server-side session required — page state is encoded in callback data.
## What it shows
- `OnCommand("/list")` sends the first page with inline navigation buttons
- `OnCallback("^page:(\\d+)$")` parses page number from callback data via `c.Values["regex_match"]`
- `api.EditMessageText` edits the message in-place on each page turn
- `api.AnswerCallbackQuery` dismisses the loading spinner
## Pattern
Callback data format: `page:<n>` where `n` is the 0-based page index.
The `renderPage` helper builds both the text content and the keyboard in one call. Only [« Prev] or [Next »] buttons that make sense for the current page are rendered, so the keyboard is always minimal.
## Running
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/pagination
```
Send `/list` to the bot. Tap Next/Prev to navigate 20 sample items, 5 per page.
## Extending
To paginate dynamic data (database results, API responses), replace `sampleItems` with a function that takes `(page, pageSize)` and returns items + total count.
+146
View File
@@ -0,0 +1,146 @@
// Package main demonstrates multi-page inline keyboard navigation.
//
// /list shows page 1 of a sample item list with [« Prev] [Next »] buttons.
// Tapping a button edits the message in-place to show the next/previous page.
// Page state is encoded directly in callback data ("page:<n>") — no server-side
// session needed.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/pagination
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
const itemsPerPage = 5
// sampleItems is the list being paginated.
var sampleItems = []string{
"Alpha", "Bravo", "Charlie", "Delta", "Echo",
"Foxtrot", "Golf", "Hotel", "India", "Juliet",
"Kilo", "Lima", "Mike", "November", "Oscar",
"Papa", "Quebec", "Romeo", "Sierra", "Tango",
}
// renderPage builds the message text and keyboard for the given page number.
func renderPage(items []string, page int) (string, *api.InlineKeyboardMarkup) {
start := page * itemsPerPage
if start >= len(items) {
start = 0
page = 0
}
end := start + itemsPerPage
if end > len(items) {
end = len(items)
}
var sb strings.Builder
fmt.Fprintf(&sb, "Items (page %d of %d):\n\n", page+1, totalPages(len(items)))
for i := start; i < end; i++ {
fmt.Fprintf(&sb, "%d. %s\n", i+1, items[i])
}
var btns []api.InlineKeyboardButton
if page > 0 {
btns = append(btns, api.InlineKeyboardButton{
Text: "« Prev",
CallbackData: fmt.Sprintf("page:%d", page-1),
})
}
if end < len(items) {
btns = append(btns, api.InlineKeyboardButton{
Text: "Next »",
CallbackData: fmt.Sprintf("page:%d", page+1),
})
}
var markup *api.InlineKeyboardMarkup
if len(btns) > 0 {
markup = &api.InlineKeyboardMarkup{
InlineKeyboard: [][]api.InlineKeyboardButton{btns},
}
}
return sb.String(), markup
}
func totalPages(total int) int {
pages := total / itemsPerPage
if total%itemsPerPage != 0 {
pages++
}
return pages
}
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
// /list — send page 0.
router.OnCommand("/list", func(c *dispatch.Context, m *api.Message) error {
text, markup := renderPage(sampleItems, 0)
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: text,
ReplyMarkup: markup,
})
return err
})
// page:<n> callbacks — edit message in-place.
router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error {
groups := c.Values["regex_match"].([]string)
page, _ := strconv.Atoi(groups[1])
// Acknowledge the tap first.
_, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{
CallbackQueryID: q.ID,
})
if q.Message == nil {
return nil
}
msg, ok := q.Message.(*api.Message)
if !ok {
return nil
}
text, markup := renderPage(sampleItems, page)
chatID := api.ChatIDFromInt(msg.Chat.ID)
mid := msg.MessageID
_, err := api.EditMessageText(c.Ctx, c.Bot, &api.EditMessageTextParams{
ChatID: &chatID,
MessageID: &mid,
Text: text,
ReplyMarkup: markup,
})
return err
})
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+47
View File
@@ -0,0 +1,47 @@
# payments
Full Telegram Payments flow: invoice → pre-checkout confirmation → successful payment.
## What it shows
- `api.SendInvoice` to send a product invoice with `LabeledPrice` breakdown
- `router.OnPreCheckoutQuery` + `api.AnswerPreCheckoutQuery` — must respond within 10 s
- `router.OnMessageFilter` matching `Message.SuccessfulPayment` to confirm payment
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `TELEGRAM_BOT_TOKEN` | Yes | Bot token from @BotFather |
| `PAYMENT_PROVIDER_TOKEN` | No | Provider token from @BotFather. Leave empty for Telegram Stars (XTR). |
| `CURRENCY` | No | ISO 4217 code (default: `USD`). Use `XTR` for Stars. |
## Test payments
Telegram provides a test payment provider to avoid real charges during development:
1. In @BotFather, use `/mybots` → choose your bot → **Payments** → select "Stripe TEST".
2. Use the test provider token — test payments are free and won't charge users.
3. In the Telegram client, use a test card number such as `4242 4242 4242 4242`.
**Never expose a live provider token in source code.** Use environment variables or secrets management.
## Flow
```
User: /buy
Bot: [Invoice message — "Premium Widget $2.19"]
User: [taps Pay]
Telegram → Bot: pre_checkout_query (bot has 10 s to respond)
Bot → Telegram: answerPreCheckoutQuery ok=true
Telegram → Bot: Message.SuccessfulPayment
Bot: "Payment received! Your widget is on its way."
```
## Running
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
export PAYMENT_PROVIDER_TOKEN=<stripe-test-token>
go run ./examples/payments
```
+101
View File
@@ -0,0 +1,101 @@
// Package main demonstrates the Telegram Payments flow:
//
// 1. /buy → bot sends an invoice via sendInvoice
// 2. User confirms → Telegram sends pre_checkout_query → bot answers ok=true
// 3. User pays → Telegram sends successful_payment in a Message
//
// For testing, use Telegram's test payment provider.
// Set PAYMENT_PROVIDER_TOKEN to the token from @BotFather (test or live).
// For Telegram Stars payments, set PAYMENT_PROVIDER_TOKEN="" and CURRENCY="XTR".
//
// TELEGRAM_BOT_TOKEN=xxx PAYMENT_PROVIDER_TOKEN=yyy go run ./examples/payments
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
providerToken := os.Getenv("PAYMENT_PROVIDER_TOKEN") // empty = Telegram Stars (XTR)
currency := os.Getenv("CURRENCY")
if currency == "" {
currency = "USD"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
// Step 1: user sends /buy — bot replies with an invoice.
router.OnCommand("/buy", func(c *dispatch.Context, m *api.Message) error {
_, err := api.SendInvoice(c.Ctx, c.Bot, &api.SendInvoiceParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Title: "Premium Widget",
Description: "A top-quality widget that does absolutely everything.",
Payload: "widget-purchase-v1",
ProviderToken: providerToken,
Currency: currency,
Prices: []api.LabeledPrice{
{Label: "Widget", Amount: 199}, // $1.99
{Label: "Tax", Amount: 20}, // $0.20
},
})
if err != nil {
log.Printf("sendInvoice error: %v", err)
}
return err
})
// Step 2: user confirms order — Telegram sends pre_checkout_query.
// Bot MUST respond within 10 seconds.
router.OnPreCheckoutQuery(func(c *dispatch.Context, q *api.PreCheckoutQuery) error {
log.Printf("pre_checkout: id=%s payload=%q total=%d %s from=%d",
q.ID, q.InvoicePayload, q.TotalAmount, q.Currency, q.From.ID)
// Validate the order here (check stock, pricing, etc.).
// For this demo, always approve.
_, err := api.AnswerPreCheckoutQuery(c.Ctx, c.Bot, &api.AnswerPreCheckoutQueryParams{
PreCheckoutQueryID: q.ID,
Ok: true,
})
return err
})
// Step 3: payment completed — Telegram delivers a Message.SuccessfulPayment.
router.OnMessageFilter(
func(m *api.Message) bool { return m.SuccessfulPayment != nil },
func(c *dispatch.Context, m *api.Message) error {
sp := m.SuccessfulPayment
log.Printf("payment success: charge_id=%s payload=%q amount=%d %s",
sp.TelegramPaymentChargeID, sp.InvoicePayload, sp.TotalAmount, sp.Currency)
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Payment received! Your widget is on its way.",
})
return err
},
)
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+29
View File
@@ -0,0 +1,29 @@
# polls
Create polls and tally answers in real time via `OnPollAnswer`.
## What it shows
- `api.SendPoll` with `[]api.InputPollOption` and `IsAnonymous: false`
- `router.OnPollAnswer` to receive vote updates (`PollAnswer.OptionIds`, `PollAnswer.User`)
- Concurrent-safe in-memory tally with `sync.Mutex`
## Commands
| Command | Description |
|---|---|
| `/poll <question>` | Creates a poll with four preset options (A/B/C/D) |
| `/tally <poll_id>` | Shows current vote counts for a poll |
## Notes
- `OnPollAnswer` only fires for **non-anonymous** polls. For anonymous polls, Telegram does not send user identifiers.
- The poll ID is logged when the poll is created; copy it to use with `/tally`.
- Vote tallies are in-memory and reset on restart.
## Running
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/polls
```
+146
View File
@@ -0,0 +1,146 @@
// Package main demonstrates creating polls and tallying answers via OnPollAnswer.
//
// Usage: send "/poll <question>" in a group or private chat.
// The bot creates a non-anonymous poll with four preset options and tallies votes.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/polls
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
// pollTally maps pollID → optionIndex → voteCount.
type pollTally struct {
mu sync.Mutex
votes map[string]map[int64]int
}
func newPollTally() *pollTally {
return &pollTally{votes: make(map[string]map[int64]int)}
}
func (t *pollTally) record(pollID string, opts []int64) {
t.mu.Lock()
defer t.mu.Unlock()
if t.votes[pollID] == nil {
t.votes[pollID] = make(map[int64]int)
}
for _, opt := range opts {
t.votes[pollID][opt]++
}
}
func (t *pollTally) summary(pollID string) string {
t.mu.Lock()
defer t.mu.Unlock()
m := t.votes[pollID]
if len(m) == 0 {
return "No votes yet."
}
var sb strings.Builder
fmt.Fprintf(&sb, "Tally for poll %s:\n", pollID)
for opt, count := range m {
fmt.Fprintf(&sb, " Option %d: %d vote(s)\n", opt, count)
}
return sb.String()
}
var pollOptions = []api.InputPollOption{
{Text: "Option A"},
{Text: "Option B"},
{Text: "Option C"},
{Text: "Option D"},
}
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
tally := newPollTally()
router := dispatch.New(bot)
router.OnCommand("/poll", func(c *dispatch.Context, m *api.Message) error {
question := strings.TrimSpace(m.Text)
// Strip the "/poll" command prefix.
if after, ok := strings.CutPrefix(question, "/poll"); ok {
question = strings.TrimSpace(after)
}
if question == "" {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Usage: /poll <question>",
})
return nil
}
isAnon := false
msg, err := api.SendPoll(c.Ctx, c.Bot, &api.SendPollParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Question: question,
Options: pollOptions,
IsAnonymous: &isAnon,
})
if err != nil {
return err
}
if msg != nil && msg.Poll != nil {
log.Printf("poll created: id=%s question=%q", msg.Poll.ID, msg.Poll.Question)
}
return nil
})
router.OnCommand("/tally", func(c *dispatch.Context, m *api.Message) error {
pollID := strings.TrimSpace(m.Text)
if after, ok := strings.CutPrefix(pollID, "/tally"); ok {
pollID = strings.TrimSpace(after)
}
if pollID == "" {
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "Usage: /tally <poll_id>",
})
return nil
}
_, _ = api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: tally.summary(pollID),
})
return nil
})
router.OnPollAnswer(func(c *dispatch.Context, pa *api.PollAnswer) error {
userID := int64(0)
if pa.User != nil {
userID = pa.User.ID
}
log.Printf("poll answer: poll=%s user=%d options=%v", pa.PollID, userID, pa.OptionIds)
tally.record(pa.PollID, pa.OptionIds)
return nil
})
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
# stateful
Per-user counter with no globals: state lives in a struct passed by closure into handlers.
## Run
```bash
export TELEGRAM_BOT_TOKEN=...
go run ./examples/stateful
```
Send `/count` to the bot in any chat. Each user has an independent counter.
+64
View File
@@ -0,0 +1,64 @@
// Package main demonstrates per-user state without globals via closures.
// Each user has an independent counter that persists for the bot's lifetime.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/stateful
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
// counterStore is a concurrent-safe per-user counter. Production code
// would back this with Redis / Postgres / sqlite. For demo purposes,
// in-memory is fine.
type counterStore struct {
mu sync.Mutex
counts map[int64]int
}
func (s *counterStore) inc(userID int64) int {
s.mu.Lock()
defer s.mu.Unlock()
s.counts[userID]++
return s.counts[userID]
}
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token)
store := &counterStore{counts: map[int64]int{}}
router := dispatch.New(bot)
router.OnCommand("/count", func(c *dispatch.Context, m *api.Message) error {
if m.From == nil {
return nil
}
n := store.inc(m.From.ID)
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: fmt.Sprintf("Your count: %d", n),
})
return err
})
if err := router.Run(ctx, transport.NewLongPoller(bot)); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}
+14
View File
@@ -0,0 +1,14 @@
# webhook
Bot using HTTPS webhooks. Replies to `/ping` with `pong`.
## Run
You need a public HTTPS endpoint pointed at port 8080. For local development use a tunnel like Cloudflare Tunnel or ngrok.
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
export WEBHOOK_URL=https://your.tunnel.example/bot
export WEBHOOK_SECRET=randomsecret123
go run ./examples/webhook
```
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/dispatch"
)
// register wires all handlers onto the router.
func register(r *dispatch.Router) {
r.OnCommand("/ping", handlePing)
}
func handlePing(c *dispatch.Context, m *api.Message) error {
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: "pong",
})
return err
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
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)
}
func okResp(body string) *http.Response {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
const sendMsgResult = `{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":42,"type":"private"}}}`
func TestHandlePing_RepliesWithPong(t *testing.T) {
m := &mockDoer{}
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
return false
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(r.Body)
return strings.Contains(buf.String(), `"pong"`)
})).Return(okResp(sendMsgResult), nil)
bot := client.New("test:token", client.WithHTTPClient(m))
msg := &api.Message{
MessageID: 1,
Chat: api.Chat{ID: 42, Type: string(api.ChatTypePrivate)},
From: &api.User{ID: 7, FirstName: "Alice"},
Text: "/ping",
}
upd := &api.Update{UpdateID: 1, Message: msg}
c := dispatch.NewContext(context.Background(), bot, upd)
require.NoError(t, handlePing(c, msg))
m.AssertExpectations(t)
}
+75
View File
@@ -0,0 +1,75 @@
// Package main is a webhook bot. Run with:
//
// TELEGRAM_BOT_TOKEN=xxx \
// WEBHOOK_URL=https://example.com/bot \
// WEBHOOK_SECRET=somethingrandom \
// go run ./examples/webhook
//
// The bot sets its webhook to WEBHOOK_URL on startup, listens on :8080,
// and clears the webhook on shutdown.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
url := os.Getenv("WEBHOOK_URL")
secret := os.Getenv("WEBHOOK_SECRET")
if token == "" || url == "" {
log.Fatal("TELEGRAM_BOT_TOKEN and WEBHOOK_URL required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())))
if _, err := api.SetWebhook(ctx, bot, &api.SetWebhookParams{
URL: url,
SecretToken: secret,
}); err != nil {
log.Fatalf("setWebhook: %v", err)
}
defer func() {
_, _ = api.DeleteWebhook(context.Background(), bot, &api.DeleteWebhookParams{})
}()
wh := transport.NewWebhookServer(bot)
wh.SecretToken = secret
router := dispatch.New(bot)
register(router)
mux := http.NewServeMux()
mux.Handle("/bot", wh)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("http server exited: %v", err)
stop()
}
}()
if err := router.Run(ctx, wh); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
_ = srv.Shutdown(context.Background())
}
+22
View File
@@ -0,0 +1,22 @@
# welcome
Greet new chat members as they join a group and log member departures.
## What it shows
- `OnMessageFilter` matching `Message.NewChatMembers` to send a welcome message for each joiner
- `OnMessageFilter` matching `Message.LeftChatMember` to log departures
- `OnMyChatMember` to detect when the bot itself is added to or removed from a group
## Required bot permissions
The bot must be an **admin** in the group (or at minimum have the *"Read Messages"* permission granted to non-admin bots via `setMyDefaultAdminRights`). Without this, Telegram does not forward service messages about member joins and leaves.
## Running
```bash
export TELEGRAM_BOT_TOKEN=123456:ABC...
go run ./examples/welcome
```
Add the bot to a group, then have another user join or leave — the bot will greet joiners and log departures to stdout.
+82
View File
@@ -0,0 +1,82 @@
// Package main demonstrates greeting new chat members and detecting leaves.
//
// The bot must be an admin in the group with "can read messages" (or at least
// be able to receive service messages) to get new-member and left-member events.
//
// TELEGRAM_BOT_TOKEN=xxx go run ./examples/welcome
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/lukaszraczylo/go-telegram/dispatch"
"github.com/lukaszraczylo/go-telegram/transport"
)
func main() {
token := os.Getenv("TELEGRAM_BOT_TOKEN")
if token == "" {
log.Fatal("TELEGRAM_BOT_TOKEN required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bot := client.New(token,
client.WithHTTPClient(client.NewRetryDoer(client.NewDefaultHTTPDoer())),
)
router := dispatch.New(bot)
// Greet every new member that joins the group.
router.OnMessageFilter(
func(m *api.Message) bool { return len(m.NewChatMembers) > 0 },
func(c *dispatch.Context, m *api.Message) error {
for _, u := range m.NewChatMembers {
name := u.FirstName
if u.LastName != "" {
name += " " + u.LastName
}
_, err := api.SendMessage(c.Ctx, c.Bot, &api.SendMessageParams{
ChatID: api.ChatIDFromInt(m.Chat.ID),
Text: fmt.Sprintf("Welcome, %s! Please read the pinned rules before posting.", name),
})
if err != nil {
log.Printf("send welcome: %v", err)
}
}
return nil
},
)
// Log when a member leaves (or is removed from) the group.
router.OnMessageFilter(
func(m *api.Message) bool { return m.LeftChatMember != nil },
func(c *dispatch.Context, m *api.Message) error {
log.Printf("user %d (%s) left chat %d",
m.LeftChatMember.ID,
m.LeftChatMember.FirstName,
m.Chat.ID,
)
return nil
},
)
// Detect when the bot itself is added to a group.
router.OnMyChatMember(func(c *dispatch.Context, u *api.ChatMemberUpdated) error {
log.Printf("bot chat membership changed in %d: new status = %T", u.Chat.ID, u.NewChatMember)
return nil
})
poller := transport.NewLongPoller(bot)
if err := router.Run(ctx, poller); err != nil && err != context.Canceled {
log.Printf("router exited: %v", err)
}
}