feat: sync built-in sticker bot
This commit is contained in:
parent
7096625e13
commit
6867d201ed
60 changed files with 7063 additions and 144 deletions
307
cmd/bots/botcheck/main.go
Normal file
307
cmd/bots/botcheck/main.go
Normal 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
|
||||
//
|
||||
// 连接生产 telesrv(obfuscated 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 obfuscation(obfuscated2)连接,
|
||||
// 匹配 telesrv 生产 server 的 transport.ObfuscatedListener(obfuscated2.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)
|
||||
}
|
||||
// NoHeader:tag 已由 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 类型 = %T,want *tg.AuthAuthorization", authz)
|
||||
}
|
||||
self, ok := a.User.(*tg.User)
|
||||
if !ok {
|
||||
return fmt.Errorf("self 类型 = %T,want *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
479
cmd/bots/botdemo/main.go
Normal 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 obfuscation(obfuscated2),与生产 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 obfuscation(obfuscated2)连接 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_hash(telesrv 下可留 0)")
|
||||
toID := flag.Int64("to", 0, "目标用户 id(-chat 为 0 时改为私聊该用户)")
|
||||
toHash := flag.Int64("to-hash", 0, "目标用户 access_hash(telesrv 下可留 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=%q(text|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 解析出真实 hash(telesrv 允许 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue