mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-09 23:04:05 +00:00
Initial release of go-telegram
A fully-generated, strongly-typed Go client for the Telegram Bot API. * 176 methods + 301 types generated from Bot API v10.0 * 1408 auto-generated tests (8 scenarios per method) * Typed unions throughout — no 'any' in the public surface * Pluggable HTTP transport and JSON codec (default goccy/go-json) * Built-in retry middleware honouring Telegram's retry_after * Generic dispatcher with filters and conversation handlers * Self-verifying codegen pipeline (regen → audit → emit → run tests) * 14 example bots covering common patterns
This commit is contained in:
@@ -0,0 +1,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.).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user