Skip to content

Commit b67076f

Browse files
ernadoclaude
andcommitted
docs: add business example bot
A runnable bot for verifying the Telegram Business surface online: it logs business connection updates, echoes/acts on inbound business messages as the connected account, and exposes "!" commands to exercise each assumption — text/photo/album sends as the account, profile name/bio/username edits, avatar set/remove with the public (fallback) distinction, connection dump and star balance. Each command reports ok/error back into the chat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9dc8c08 commit b67076f

2 files changed

Lines changed: 281 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ func main() {
7676
See the [**guide**](./docs/guide.md) for the full surface (sending, media,
7777
keyboards, handlers, predicates, middleware, commands, files, chat management,
7878
errors, pooling) and [`examples/`](./examples) for runnable bots
79-
(`echo`, `buttons`, `inline`, `media`, `rich`, `background`, `advanced`).
79+
(`echo`, `buttons`, `inline`, `media`, `rich`, `background`, `advanced`,
80+
`business`).
8081

8182
`Options.Storage` is optional — leave it nil to keep session, peers and update
8283
state in memory (nothing survives a restart). `storage.Open("bot.bbolt")`

examples/business/main.go

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Command business is a bot for verifying the Telegram Business surface over
2+
// MTProto: inbound business updates, acting on behalf of a connected business
3+
// account (text, media, albums, profile edits), and reading the connection.
4+
//
5+
// Setup:
6+
//
7+
// 1. Run the bot with an MTProto app identity (https://my.telegram.org) and a
8+
// BotFather token.
9+
//
10+
// 2. On a Telegram Premium account, open Settings → Business → Chatbots and add
11+
// this bot. That delivers the account's 1-to-1 chats to the bot over a
12+
// business connection.
13+
//
14+
// 3. From another account, write to the business account. Plain text is echoed
15+
// back (as the account). Messages starting with "!" are commands:
16+
//
17+
// !help list commands
18+
// !ping reply as the account
19+
// !conn dump the business connection + granted rights
20+
// !balance the account's Telegram Stars balance
21+
// !photo send a generated photo as the account
22+
// !album send a 2-photo album as the account
23+
// !name First [Last] set the account's name
24+
// !bio <text> set the account's bio
25+
// !username <name> set the account's username
26+
// !avatar [public] set the account's profile photo (public ⇒ fallback)
27+
// !rmavatar [public] remove the account's profile photo
28+
//
29+
// Every command replies with "ok" or "<action> failed: <error>", so the online
30+
// behavior of each assumption is visible in the chat.
31+
//
32+
// APP_ID=12345 APP_HASH=abcdef BOT_TOKEN=123:abc go run ./examples/business
33+
package main
34+
35+
import (
36+
"bytes"
37+
"context"
38+
"fmt"
39+
"image"
40+
"image/color"
41+
"image/draw"
42+
"image/png"
43+
"os"
44+
"os/signal"
45+
"strings"
46+
"time"
47+
48+
"github.com/gotd/log/logzap"
49+
"go.uber.org/zap"
50+
51+
"github.com/gotd/botapi"
52+
"github.com/gotd/botapi/storage"
53+
)
54+
55+
func main() {
56+
log, _ := zap.NewProduction()
57+
defer func() { _ = log.Sync() }()
58+
59+
appID, err := atoi(os.Getenv("APP_ID"))
60+
if err != nil {
61+
log.Fatal("APP_ID must be a number (see https://my.telegram.org)", zap.Error(err))
62+
}
63+
64+
store, err := storage.Open("session.bbolt")
65+
if err != nil {
66+
log.Fatal("Open storage", zap.Error(err))
67+
}
68+
69+
defer func() { _ = store.Close() }()
70+
71+
bot, err := botapi.New(os.Getenv("BOT_TOKEN"), botapi.Options{
72+
AppID: appID,
73+
AppHash: os.Getenv("APP_HASH"),
74+
Logger: logzap.New(log),
75+
Storage: store,
76+
})
77+
if err != nil {
78+
log.Fatal("Create bot", zap.Error(err))
79+
}
80+
81+
bot.Use(botapi.Recover(), botapi.Timeout(60*time.Second))
82+
83+
bot.OnCommand("start", "How to use this bot", func(c *botapi.Context) error {
84+
_, err := c.Reply("Add me to a Telegram Business account " +
85+
"(Settings → Business → Chatbots), then message that account. Send !help in a business chat.")
86+
87+
return err
88+
})
89+
90+
// Log the connection whenever it is established, disabled or its rights
91+
// change — lets you eyeball GetBusinessConnection / the rights mapping.
92+
bot.OnBusinessConnection(func(c *botapi.Context) error {
93+
conn := c.Update.BusinessConnection
94+
log.Info("Business connection update",
95+
zap.String("id", conn.ID),
96+
zap.Int64("user_id", conn.User.ID),
97+
zap.Bool("enabled", conn.IsEnabled),
98+
zap.Any("rights", conn.Rights),
99+
)
100+
101+
return nil
102+
})
103+
104+
bot.OnBusinessMessage(handleBusiness(log))
105+
106+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
107+
defer cancel()
108+
109+
log.Info("Starting business bot")
110+
111+
if err := bot.Run(ctx); err != nil {
112+
log.Fatal("Run", zap.Error(err))
113+
}
114+
}
115+
116+
// handleBusiness processes a message delivered over a business connection.
117+
func handleBusiness(log *zap.Logger) botapi.Handler {
118+
return func(c *botapi.Context) error {
119+
bm := c.BusinessMessage()
120+
if bm == nil {
121+
return nil
122+
}
123+
124+
// Messages the account itself sent (including our own replies) come back
125+
// on the stream with Out set — ignore them to avoid a reply loop.
126+
if raw := bm.Raw(); raw != nil && raw.Out {
127+
return nil
128+
}
129+
130+
bc, ok := c.Business()
131+
if !ok {
132+
return nil
133+
}
134+
135+
log.Info("Business message",
136+
zap.String("connection", bc.ConnectionID()),
137+
zap.Int64("chat", bm.Chat.ID),
138+
zap.String("text", bm.Text),
139+
)
140+
141+
text := strings.TrimSpace(bm.Text)
142+
if !strings.HasPrefix(text, "!") {
143+
_, err := c.Reply("echo (sent as the business account): " + text + "\nSend !help for commands.")
144+
return err
145+
}
146+
147+
return dispatchCommand(c, bc, strings.Fields(text[1:]))
148+
}
149+
}
150+
151+
// dispatchCommand runs one "!" command from a business chat.
152+
func dispatchCommand(c *botapi.Context, bc *botapi.BusinessContext, fields []string) error {
153+
if len(fields) == 0 {
154+
return nil
155+
}
156+
157+
cmd, args := fields[0], fields[1:]
158+
chat := botapi.ID(c.BusinessMessage().Chat.ID)
159+
160+
switch cmd {
161+
case "help":
162+
return reply(c, helpText)
163+
case "ping":
164+
return reply(c, "pong — sent on behalf of the business account")
165+
case "conn":
166+
return showConnection(c, bc)
167+
case "balance":
168+
return showBalance(c, bc)
169+
case "name":
170+
return report(c, "set name", bc.SetName(c, first(args), rest(args)))
171+
case "bio":
172+
return report(c, "set bio", bc.SetBio(c, strings.Join(args, " ")))
173+
case "username":
174+
return report(c, "set username", bc.SetUsername(c, first(args)))
175+
case "photo":
176+
_, err := bc.SendPhoto(c, chat, samplePhoto(color.RGBA{R: 0x2a, G: 0x9d, B: 0x8f, A: 0xff}), "sent as the account")
177+
return report(c, "send photo", err)
178+
case "album":
179+
return sendAlbum(c, bc, chat)
180+
case "avatar":
181+
photo := botapi.InputProfilePhotoStatic{Photo: samplePhoto(color.RGBA{R: 0xe7, G: 0x6f, B: 0x51, A: 0xff})}
182+
return report(c, "set avatar", bc.SetProfilePhoto(c, photo, isPublic(args)))
183+
case "rmavatar":
184+
return report(c, "remove avatar", bc.RemoveProfilePhoto(c, isPublic(args)))
185+
default:
186+
return reply(c, "unknown command; send !help")
187+
}
188+
}
189+
190+
const helpText = "Commands: !ping, !conn, !balance, !photo, !album, " +
191+
"!name First [Last], !bio <text>, !username <name>, !avatar [public], !rmavatar [public]"
192+
193+
func showConnection(c *botapi.Context, bc *botapi.BusinessContext) error {
194+
conn, err := bc.Connection(c)
195+
if err != nil {
196+
return report(c, "get connection", err)
197+
}
198+
199+
return reply(c, fmt.Sprintf("connection %s\nuser_id %d\nenabled %t\nrights %+v",
200+
conn.ID, conn.User.ID, conn.IsEnabled, conn.Rights))
201+
}
202+
203+
func showBalance(c *botapi.Context, bc *botapi.BusinessContext) error {
204+
amount, err := bc.StarBalance(c)
205+
if err != nil {
206+
return report(c, "get balance", err)
207+
}
208+
209+
return reply(c, fmt.Sprintf("balance: %d stars (%d nanostars)", amount.Amount, amount.NanostarAmount))
210+
}
211+
212+
func sendAlbum(c *botapi.Context, bc *botapi.BusinessContext, chat botapi.ChatID) error {
213+
media := []botapi.InputMedia{
214+
&botapi.InputMediaPhoto{Media: upload("one.png", color.RGBA{R: 0x26, G: 0x46, B: 0x53, A: 0xff}), Caption: "one"},
215+
&botapi.InputMediaPhoto{Media: upload("two.png", color.RGBA{R: 0xf4, G: 0xa2, B: 0x61, A: 0xff}), Caption: "two"},
216+
}
217+
218+
_, err := c.Bot.SendMediaGroup(c, chat, media, botapi.WithBusinessConnection(bc.ConnectionID()))
219+
220+
return report(c, "send album", err)
221+
}
222+
223+
// report replies with the outcome of an action so it is visible in the chat.
224+
func report(c *botapi.Context, action string, err error) error {
225+
if err != nil {
226+
return reply(c, action+" failed: "+err.Error())
227+
}
228+
229+
return reply(c, action+" ok")
230+
}
231+
232+
func reply(c *botapi.Context, text string) error {
233+
_, err := c.Reply(text)
234+
235+
return err
236+
}
237+
238+
// samplePhoto returns a generated solid-color InputFile so the example needs no
239+
// asset files on disk.
240+
func samplePhoto(c color.Color) botapi.InputFile {
241+
return upload("photo.png", c)
242+
}
243+
244+
func upload(name string, c color.Color) *botapi.InputFileUpload {
245+
img := image.NewRGBA(image.Rect(0, 0, 512, 512))
246+
draw.Draw(img, img.Bounds(), &image.Uniform{C: c}, image.Point{}, draw.Src)
247+
248+
var buf bytes.Buffer
249+
250+
_ = png.Encode(&buf, img)
251+
252+
return &botapi.InputFileUpload{Name: name, Bytes: buf.Bytes()}
253+
}
254+
255+
func isPublic(args []string) bool { return len(args) > 0 && args[0] == "public" }
256+
257+
func first(args []string) string {
258+
if len(args) == 0 {
259+
return ""
260+
}
261+
262+
return args[0]
263+
}
264+
265+
func rest(args []string) string {
266+
if len(args) < 2 {
267+
return ""
268+
}
269+
270+
return strings.Join(args[1:], " ")
271+
}
272+
273+
func atoi(s string) (int, error) {
274+
var n int
275+
276+
_, err := fmt.Sscanf(s, "%d", &n)
277+
278+
return n, err
279+
}

0 commit comments

Comments
 (0)