feat: sync built-in sticker bot

This commit is contained in:
A 2026-07-01 21:55:55 +08:00
parent 7096625e13
commit 6867d201ed
60 changed files with 7063 additions and 144 deletions

307
cmd/bots/botcheck/main.go Normal file
View file

@ -0,0 +1,307 @@
// Command botcheck 是 bot 客户端验证工具(开发用,非生产组件)。
//
// 它用 BotFather 发的 token 经 auth.importBotAuthorization 登录本地 telesrv
// 自检 bot 身份 / updates 状态 / userFull.bot_info并可选以 echo 模式持续回显
// 收到的私聊消息,验证 bot 收发闭环(在线推送 → bot 处理 → bot 回复)。
//
// 用法:
//
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" # 仅登录自检
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
//
// 连接生产 telesrvobfuscated TCP靠 DCOption.TCPObfuscatedOnly=true
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接
// 匹配 telesrv 生产 server 的 transport.ObfuscatedListenerobfuscated2.Accept(conn, nil))。
// gotd 内置 dcs.Plain 的 obfuscated 路径走 MTProxy强制 secret不适用这里。
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
// obfuscated2 握手携带 intermediate codec 的 init tag空 secret = 标准 obfuscation。
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
// NoHeadertag 已由 obf 握手发出codec 不再重发。
proto := transport.NewProtocol(func() transport.Codec {
return codec.NoHeader{Codec: codec.Intermediate{}}
})
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
token := flag.String("token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>,默认取 env TELESRV_BOT_TOKEN")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA key 路径(仅读 public key")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
echo := flag.Bool("echo", false, "登录后持续 echo 收到的私聊消息")
sendTo := flag.Int64("send-to", 0, "主动发送目标 user_id验证 bot 主动发起消息)")
sendText := flag.String("send-text", "", "主动发送的文本")
runFor := flag.Duration("for", 0, "echo 模式运行时长0=直到 Ctrl+C")
flag.Parse()
if *token == "" {
fmt.Fprintln(os.Stderr, "缺少 -token或设环境变量 TELESRV_BOT_TOKEN")
os.Exit(2)
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, err := strconv.Atoi(portStr)
if err != nil {
logger.Fatal("解析端口失败", zap.Error(err))
}
var client *telegram.Client
handler := telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error {
if !*echo {
return nil
}
return echoUpdates(ctx, tg.NewClient(client), u, logger)
})
client = telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{
{ID: *dcID, IPAddress: host, Port: port, Static: true},
}},
Logger: logzap.New(logger.Named("client")),
UpdateHandler: handler,
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
raw := tg.NewClient(client)
authz, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: *apiID, APIHash: *apiHash, BotAuthToken: *token,
})
if err != nil {
return fmt.Errorf("importBotAuthorization: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return fmt.Errorf("authorization 类型 = %Twant *tg.AuthAuthorization", authz)
}
self, ok := a.User.(*tg.User)
if !ok {
return fmt.Errorf("self 类型 = %Twant *tg.User", a.User)
}
ver, hasVer := self.GetBotInfoVersion()
fmt.Println("==== 登录成功 ====")
fmt.Printf(" id=%d username=@%s name=%q bot=%v self=%v bot_info_version=%d(present=%v)\n",
self.ID, self.Username, self.FirstName, self.Bot, self.Self, ver, hasVer)
if !self.Bot || !hasVer {
fmt.Println(" [警告] self 缺 bot flag 或 bot_info_version —— TDesktop 不会当 bot 处理")
}
if _, hasStatus := self.GetStatus(); hasStatus {
fmt.Println(" [警告] bot 携带 status —— 官方 bot 不应有 presence")
}
if self.Phone != "" {
fmt.Printf(" [警告] bot 携带 phone=%q —— 官方 bot 无手机号\n", self.Phone)
}
state, err := raw.UpdatesGetState(ctx)
if err != nil {
return fmt.Errorf("getState: %w", err)
}
fmt.Printf(" getState: pts=%d qts=%d date=%d seq=%d\n", state.Pts, state.Qts, state.Date, state.Seq)
full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{})
if err != nil {
return fmt.Errorf("getFullUser: %w", err)
}
if bi, ok := full.FullUser.GetBotInfo(); ok {
uid, _ := bi.GetUserID()
desc, _ := bi.GetDescription()
cmds, _ := bi.GetCommands()
fmt.Printf(" bot_info: user_id=%d description=%q commands=%d\n", uid, desc, len(cmds))
if uid != self.ID {
fmt.Printf(" [警告] bot_info.user_id=%d != self.id=%d —— TDesktop 会整体忽略 bot_info\n", uid, self.ID)
}
for _, c := range cmds {
fmt.Printf(" /%s - %s\n", c.Command, c.Description)
}
} else {
fmt.Println(" [警告] userFull 缺 bot_info —— TDesktop 会反复重拉 getFullUser")
}
fmt.Println("==== 自检通过 ====")
// 主动发起bot 主动给指定用户发消息(用户须先与 bot 交互过)。
// access_hash 经 getUsers 解析bot 重新登录时手里没有该用户的 update
if *sendTo != 0 && *sendText != "" {
target := &tg.InputPeerUser{UserID: *sendTo}
if list, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: *sendTo}}); err == nil {
for _, uc := range list {
if u, ok := uc.(*tg.User); ok && u.ID == *sendTo {
target.AccessHash = u.AccessHash
fmt.Printf(" 解析目标用户: id=%d username=@%s name=%q\n", u.ID, u.Username, u.FirstName)
}
}
} else {
fmt.Printf(" [警告] getUsers(%d) 失败: %v仍尝试 access_hash=0 发送)\n", *sendTo, err)
}
rid, _ := randInt64()
if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: target,
Message: *sendText,
RandomID: rid,
}); err != nil {
return fmt.Errorf("主动发送: %w", err)
}
fmt.Printf(">> 已主动发送给 %d: %q\n", *sendTo, *sendText)
}
if !*echo {
return nil
}
fmt.Println(">> echo 模式:从 TDesktop/Android 给这个 bot 发私聊消息bot 会回 \"echo: <原文>\"")
if *runFor > 0 {
fmt.Printf(">> 运行 %s 后自动退出(或 Ctrl+C\n", *runFor)
t := time.NewTimer(*runFor)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
return nil
}
fmt.Println(">> Ctrl+C 退出")
<-ctx.Done()
return nil
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
fmt.Println("已退出")
}
// echoUpdates 解析推送的 updates对收到的 incoming 私聊消息回 "echo: <原文>"。
func echoUpdates(ctx context.Context, raw *tg.Client, u tg.UpdatesClass, logger *zap.Logger) error {
var ups []tg.UpdateClass
var users []tg.UserClass
switch v := u.(type) {
case *tg.Updates:
ups, users = v.Updates, v.Users
case *tg.UpdatesCombined:
ups, users = v.Updates, v.Users
case *tg.UpdateShort:
ups = []tg.UpdateClass{v.Update}
default:
return nil
}
hashByID := make(map[int64]int64, len(users))
for _, uc := range users {
if usr, ok := uc.(*tg.User); ok {
hashByID[usr.ID] = usr.AccessHash
}
}
for _, up := range ups {
nm, ok := up.(*tg.UpdateNewMessage)
if !ok {
continue
}
msg, ok := nm.Message.(*tg.Message)
if !ok || msg.Out {
continue
}
peer, ok := msg.PeerID.(*tg.PeerUser)
if !ok {
continue
}
from := peer.UserID
fmt.Printf("<< 收到来自 %d: %q\n", from, msg.Message)
rid, _ := randInt64()
if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: from, AccessHash: hashByID[from]},
Message: "echo: " + msg.Message,
RandomID: rid,
}); err != nil {
logger.Warn("echo 回复失败", zap.Int64("to", from), zap.Error(err))
continue
}
fmt.Printf(">> 已回复 %d: %q\n", from, "echo: "+msg.Message)
}
return nil
}
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}

479
cmd/bots/botdemo/main.go Normal file
View file

@ -0,0 +1,479 @@
// Command botdemo 是一个「定时向群/用户主动发消息」的 bot 参考示例(开发/教学用)。
//
// 它演示一个外部 bot 程序如何:
// 1. 用 BotFather 发的 token 经 auth.importBotAuthorization 登录 telesrv
// 2. 解析目标 peer超级群 channel 或用户 user
// 3. 在一个定时循环里,按设定间隔【主动】发送三种内容:
// - 纯文本 messages.sendMessage
// - 图片 upload 文件 + messages.sendMedia(inputMediaUploadedPhoto)
// - 图文 (同上,但带 caption
//
// bot 在群里发消息和任何普通成员一样走 messages.sendMessage/sendMedia无需被「@/命令」
// 触发——这里的定时器就是「主动发」的触发源;换成业务事件/cron 也是同一套调用。隐私模式
// bot_chat_history只影响 bot 能【收到】哪些消息,不限制它【发送】。
//
// 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条):
//
// go run ./cmd/bots/botdemo \
// -token "<bot_id>:<secret>" \
// -chat <channel_id> -chat-hash <access_hash> \
// -interval 15s -mode rotate
//
// 其它常用参数:
//
// -to <user_id> -to-hash <hash> # 改为给某个用户私聊发(需用户先与 bot 交互过)
// -mode text|photo|caption|rotate # 只发文本 / 只发图片 / 只发图文 / 三者轮流(默认 rotate
// -image path/to/pic.jpg # 指定图片文件;不指定则程序自动生成一张 PNG
// -text "..." -caption "..." # 自定义文本与图文说明(程序会自动追加序号+时间使每条不同)
// -count 5 # 发 5 条后退出(默认 0 = 一直发到 Ctrl+C
//
// 连接 telesrv 用标准无-secret MTProto TCP obfuscationobfuscated2与生产 server 一致。
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"net"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg"
"github.com/gotd/td/transport"
"telesrv/internal/mtprotoedge"
)
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接 telesrv
// 匹配生产 server 的 transport.ObfuscatedListener。gotd 内置 dcs.Plain 的 obfuscated 路径
// 走 MTProxy强制 secret不适用这里所以自定义一个 Resolver。
type obfuscatedResolver struct {
host string
port int
}
func (r obfuscatedResolver) dial(ctx context.Context, dc int) (transport.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(r.host, strconv.Itoa(r.port)))
if err != nil {
return nil, err
}
obf := obfuscator.Obfuscated2(rand.Reader, conn)
if err := obf.Handshake(codec.IntermediateClientStart, dc, mtproxy.Secret{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("obfuscated2 handshake: %w", err)
}
proto := transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: codec.Intermediate{}} })
tc, err := proto.Handshake(obf)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("transport handshake: %w", err)
}
return tc, nil
}
func (r obfuscatedResolver) Primary(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) MediaOnly(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
func (r obfuscatedResolver) CDN(ctx context.Context, dc int, _ dcs.List) (transport.Conn, error) {
return r.dial(ctx, dc)
}
type config struct {
chatID, chatHash int64
toID, toHash int64
interval time.Duration
count int
mode string
text, caption string
imagePath string
}
func main() {
addr := flag.String("addr", "127.0.0.1:2398", "telesrv MTProto 地址")
dcID := flag.Int("dc", 2, "DC id")
token := flag.String("token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>(默认取 env TELESRV_BOT_TOKEN")
rsaPath := flag.String("rsa", "data/server_rsa.pem", "server RSA 公钥路径")
apiID := flag.Int("api-id", 1, "api_id")
apiHash := flag.String("api-hash", "hash", "api_hash")
chatID := flag.Int64("chat", 0, "目标超级群 channel id非 0 即发到群)")
chatHash := flag.Int64("chat-hash", 0, "目标群 access_hashtelesrv 下可留 0")
toID := flag.Int64("to", 0, "目标用户 id-chat 为 0 时改为私聊该用户)")
toHash := flag.Int64("to-hash", 0, "目标用户 access_hashtelesrv 下可留 0")
interval := flag.Duration("interval", 15*time.Second, "定时发送间隔")
count := flag.Int("count", 0, "发送条数上限0 = 一直发到 Ctrl+C")
mode := flag.String("mode", "rotate", "发送内容text | photo | caption | rotate")
text := flag.String("text", "telesrv bot demo · 定时主动消息", "文本内容(会自动追加 #序号+时间)")
caption := flag.String("caption", "telesrv bot demo · 图文消息", "图文说明(会自动追加 #序号+时间)")
imagePath := flag.String("image", "", "图片文件路径(留空则程序自动生成一张 PNG")
flag.Parse()
if *token == "" {
fmt.Fprintln(os.Stderr, "缺少 -token或设环境变量 TELESRV_BOT_TOKEN")
os.Exit(2)
}
if *chatID == 0 && *toID == 0 {
fmt.Fprintln(os.Stderr, "需指定目标:-chat <群id>(或 -to <用户id>")
os.Exit(2)
}
switch *mode {
case "text", "photo", "caption", "rotate":
default:
fmt.Fprintf(os.Stderr, "未知 -mode=%qtext|photo|caption|rotate\n", *mode)
os.Exit(2)
}
cfg := config{
chatID: *chatID, chatHash: *chatHash, toID: *toID, toHash: *toHash,
interval: *interval, count: *count, mode: *mode,
text: *text, caption: *caption, imagePath: *imagePath,
}
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
priv, err := mtprotoedge.LoadOrGenerateRSAKey(*rsaPath)
if err != nil {
logger.Fatal("加载 RSA key 失败", zap.Error(err))
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
logger.Fatal("解析地址失败", zap.Error(err))
}
port, _ := strconv.Atoi(portStr)
client := telegram.NewClient(*apiID, *apiHash, telegram.Options{
PublicKeys: []exchange.PublicKey{{RSA: &priv.PublicKey}},
Resolver: obfuscatedResolver{host: host, port: port},
DCList: dcs.List{Options: []tg.DCOption{{ID: *dcID, IPAddress: host, Port: port, Static: true}}},
Logger: logzap.New(logger.Named("client")),
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Run(ctx, func(ctx context.Context) error {
api := tg.NewClient(client)
// 1) 登录为 bot。
self, err := loginBot(ctx, api, *apiID, *apiHash, *token)
if err != nil {
return err
}
fmt.Printf("==== bot 登录成功:@%s (id=%d) ====\n", self.Username, self.ID)
// 2) 解析目标 peer。
peer, label, err := resolveTarget(ctx, api, cfg)
if err != nil {
return err
}
fmt.Printf(">> 目标:%s\n>> 间隔:%s模式%s上限%s\n", label, cfg.interval, cfg.mode, countLabel(cfg.count))
// 3) 定时主动发送。
return runLoop(ctx, api, uploader.NewUploader(api), peer, cfg, logger)
}); err != nil {
logger.Fatal("运行失败", zap.Error(err))
}
fmt.Println("已退出")
}
// loginBot 用 token 登录并返回 self 用户。
func loginBot(ctx context.Context, api *tg.Client, apiID int, apiHash, token string) (*tg.User, error) {
authz, err := api.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{
APIID: apiID, APIHash: apiHash, BotAuthToken: token,
})
if err != nil {
return nil, fmt.Errorf("importBotAuthorization: %w", err)
}
a, ok := authz.(*tg.AuthAuthorization)
if !ok {
return nil, fmt.Errorf("authorization 类型 = %T", authz)
}
self, ok := a.User.(*tg.User)
if !ok || !self.Bot {
return nil, fmt.Errorf("登录账号不是 bot%T", a.User)
}
return self, nil
}
// resolveTarget 把命令行目标解析成 InputPeer。群优先access_hash 给 0 时尝试经
// getChannels/getUsers 解析出真实 hashtelesrv 允许 hash=0 旁路,但解析更稳、也更贴近真实客户端)。
func resolveTarget(ctx context.Context, api *tg.Client, cfg config) (tg.InputPeerClass, string, error) {
if cfg.chatID != 0 {
hash := cfg.chatHash
title := ""
if res, err := api.ChannelsGetChannels(ctx, []tg.InputChannelClass{
&tg.InputChannel{ChannelID: cfg.chatID, AccessHash: cfg.chatHash},
}); err == nil {
for _, c := range res.GetChats() {
if ch, ok := c.(*tg.Channel); ok && ch.ID == cfg.chatID {
hash, title = ch.AccessHash, ch.Title
}
}
}
return &tg.InputPeerChannel{ChannelID: cfg.chatID, AccessHash: hash},
fmt.Sprintf("超级群 %q (id=%d)", title, cfg.chatID), nil
}
hash := cfg.toHash
name := ""
if list, err := api.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: cfg.toID, AccessHash: cfg.toHash}}); err == nil {
for _, uc := range list {
if u, ok := uc.(*tg.User); ok && u.ID == cfg.toID {
hash, name = u.AccessHash, u.FirstName
}
}
}
return &tg.InputPeerUser{UserID: cfg.toID, AccessHash: hash},
fmt.Sprintf("用户 %q (id=%d)", name, cfg.toID), nil
}
// runLoop 是定时主动发送的核心:每 interval 发一条,按 mode 选内容count>0 时发够即停。
func runLoop(ctx context.Context, api *tg.Client, up *uploader.Uploader, peer tg.InputPeerClass, cfg config, logger *zap.Logger) error {
ticker := time.NewTicker(cfg.interval)
defer ticker.Stop()
seq := 0
send := func() error {
seq++
kind := pickKind(cfg.mode, seq)
stamp := time.Now().Format("15:04:05")
switch kind {
case "text":
msg := fmt.Sprintf("%s · #%d · %s", cfg.text, seq, stamp)
if err := sendText(ctx, api, peer, msg); err != nil {
return err
}
fmt.Printf("[%d] 文本已发:%q\n", seq, msg)
case "photo":
if err := sendPhoto(ctx, api, up, peer, seq, "", cfg.imagePath); err != nil {
return err
}
fmt.Printf("[%d] 图片已发\n", seq)
case "caption":
captionText := fmt.Sprintf("%s · #%d · %s", cfg.caption, seq, stamp)
if err := sendPhoto(ctx, api, up, peer, seq, captionText, cfg.imagePath); err != nil {
return err
}
fmt.Printf("[%d] 图文已发:%q\n", seq, captionText)
}
return nil
}
// 立即发第一条(不必等满一个 interval随后按 ticker 周期发。
if err := send(); err != nil {
logger.Warn("发送失败", zap.Error(err))
}
if cfg.count > 0 && seq >= cfg.count {
return nil
}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := send(); err != nil {
logger.Warn("发送失败", zap.Error(err))
continue
}
if cfg.count > 0 && seq >= cfg.count {
return nil
}
}
}
}
// pickKind 决定本次发什么固定模式直接返回rotate 模式按序号轮流 text→photo→caption。
func pickKind(mode string, seq int) string {
if mode != "rotate" {
return mode
}
switch (seq - 1) % 3 {
case 0:
return "text"
case 1:
return "photo"
default:
return "caption"
}
}
// sendText 发一条纯文本消息。
func sendText(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, message string) error {
rid, _ := randInt64()
_, err := api.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: peer,
Message: message,
RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendMessage: %w", err)
}
return nil
}
// sendPhoto 上传一张图片并发出caption 非空即「图文」,为空即纯图片。
// 图片来源cfg.imagePath 指定的文件,或程序按序号生成的 PNG保证每条都不一样
func sendPhoto(ctx context.Context, api *tg.Client, up *uploader.Uploader, peer tg.InputPeerClass, seq int, caption, imagePath string) error {
var (
file tg.InputFileClass
err error
)
if imagePath != "" {
// uploader 会自动分片 upload.saveFilePart大文件走 big-file 路径)。
file, err = up.FromPath(ctx, imagePath)
if err != nil {
return fmt.Errorf("上传图片文件 %q: %w", imagePath, err)
}
} else {
data := generatePNG(seq)
file, err = up.FromBytes(ctx, fmt.Sprintf("botdemo-%d.png", seq), data)
if err != nil {
return fmt.Errorf("上传生成图片: %w", err)
}
}
rid, _ := randInt64()
_, err = api.MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
Peer: peer,
Media: &tg.InputMediaUploadedPhoto{File: file},
Message: caption, // 空 = 纯图片;非空 = 图文(图片 + 说明文字)
RandomID: rid,
})
if err != nil {
return fmt.Errorf("sendMedia(photo): %w", err)
}
return nil
}
// generatePNG 程序化生成一张 600x400 的 PNG颜色随序号变化、并画一个随序号移动的方块
// 让连发的每张图肉眼可区分。纯标准库实现,无需外部图片文件,方便直接跑 demo。
func generatePNG(seq int) []byte {
const w, h = 600, 400
img := image.NewRGBA(image.Rect(0, 0, w, h))
// 背景:按序号轮转色相的横向渐变。
bg := hsv(float64((seq*37)%360), 0.55, 0.95)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
shade := uint8(40 + (x*40)/w)
img.Set(x, y, color.RGBA{
R: clamp(int(bg.R) - 20 + int(shade)),
G: clamp(int(bg.G) - 20 + int(shade)),
B: clamp(int(bg.B) - 20 + int(shade)),
A: 255,
})
}
}
// 一个随序号横向移动的对比色方块——直观体现「这是第几条」。
block := hsv(float64((seq*37+180)%360), 0.8, 0.9)
bx := 30 + (seq*45)%(w-130)
for y := 150; y < 250; y++ {
for x := bx; x < bx+100 && x < w; x++ {
img.Set(x, y, block)
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// hsv 把 HSV(h∈[0,360), s,v∈[0,1]) 转成 RGBA标准库无 HSV简单实现一个
func hsv(hDeg, s, v float64) color.RGBA {
c := v * s
x := c * (1 - absf(modf(hDeg/60, 2)-1))
m := v - c
var r, g, b float64
switch {
case hDeg < 60:
r, g, b = c, x, 0
case hDeg < 120:
r, g, b = x, c, 0
case hDeg < 180:
r, g, b = 0, c, x
case hDeg < 240:
r, g, b = 0, x, c
case hDeg < 300:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
return color.RGBA{
R: uint8((r + m) * 255),
G: uint8((g + m) * 255),
B: uint8((b + m) * 255),
A: 255,
}
}
func absf(f float64) float64 {
if f < 0 {
return -f
}
return f
}
func modf(a, m float64) float64 {
for a >= m {
a -= m
}
for a < 0 {
a += m
}
return a
}
func clamp(v int) uint8 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint8(v)
}
func countLabel(count int) string {
if count <= 0 {
return "无限"
}
return strconv.Itoa(count)
}
// randInt64 生成非零随机 random_id防 (sender,random_id) 幂等键碰撞)。
func randInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return time.Now().UnixNano(), err
}
v := int64(binary.LittleEndian.Uint64(b[:]))
if v == 0 {
v = 1
}
return v, nil
}