owpengram-server/cmd/bots/botdemo/main.go

479 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/iamxvbaba/td/exchange"
"github.com/iamxvbaba/td/mtproxy"
"github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/iamxvbaba/td/proto/codec"
"github.com/iamxvbaba/td/telegram"
"github.com/iamxvbaba/td/telegram/dcs"
"github.com/iamxvbaba/td/telegram/uploader"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/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
}