mirror of
https://github.com/HugeFrog24/go-telegram-bot.git
synced 2026-10-27 21:35:26 +00:00
289 lines
7.8 KiB
Go
289 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot"
|
|
)
|
|
|
|
const (
|
|
// draftUpdateInterval throttles sendMessageDraft. Telegram documents no
|
|
// limit for drafts specifically, so this matches its general guidance of
|
|
// about one send per second per chat before 429s start.
|
|
draftUpdateInterval = 1 * time.Second
|
|
// draftKeepaliveInterval re-sends the current draft during silence, such
|
|
// as a server-side web search. The preview lives roughly 30 seconds when
|
|
// untouched; whether updates extend it is undocumented, so this keeps
|
|
// well inside that either way.
|
|
draftKeepaliveInterval = 10 * time.Second
|
|
// telegramMessageLimit is the text cap, in characters, for both drafts
|
|
// and sent messages.
|
|
telegramMessageLimit = 4096
|
|
)
|
|
|
|
// draftEligible reports whether a turn may stream into a Telegram draft.
|
|
// Telegram documents drafts for private chats only, and its reference lists
|
|
// no business_connection_id parameter for sendMessageDraft (the Go library's
|
|
// struct carries the field, but nothing says the server honours it), so
|
|
// business traffic keeps the segment path until that is verified live.
|
|
func (b *Bot) draftEligible(chatID int64, businessConnectionID string) bool {
|
|
return b.config.StreamDrafts && businessConnectionID == "" && chatID > 0
|
|
}
|
|
|
|
// draftStream is one growing Telegram draft for one turn.
|
|
//
|
|
// A single sender goroutine owns every sendMessageDraft call, so sends are
|
|
// strictly sequential, never run on the model's stream goroutine, and cannot
|
|
// land at Telegram out of order. The stream goroutine only records the newest
|
|
// text and signals; the sender throttles, keeps the draft alive through silent
|
|
// stretches, and goes quiet after the first failure so a broken draft degrades
|
|
// to a plain final message.
|
|
type draftStream struct {
|
|
b *Bot
|
|
chatID int64
|
|
draftID string
|
|
interval time.Duration
|
|
keepalive time.Duration
|
|
onFirst func()
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
kick chan struct{}
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
|
|
mu sync.Mutex
|
|
latest string // newest text from the model
|
|
shown string // last text Telegram accepted
|
|
failed bool
|
|
}
|
|
|
|
// newDraftStream builds the stream; start launches the sender. The two are
|
|
// separate so tests can shorten the intervals before anything runs.
|
|
func (b *Bot) newDraftStream(parent context.Context, chatID int64, draftID string, onFirst func()) *draftStream {
|
|
ctx, cancel := context.WithCancel(parent)
|
|
return &draftStream{
|
|
b: b,
|
|
chatID: chatID,
|
|
draftID: draftID,
|
|
interval: draftUpdateInterval,
|
|
keepalive: draftKeepaliveInterval,
|
|
onFirst: onFirst,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
kick: make(chan struct{}, 1),
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (d *draftStream) start() { go d.run() }
|
|
|
|
// update records the full reply text so far and wakes the sender. It never
|
|
// blocks on Telegram.
|
|
func (d *draftStream) update(text string) {
|
|
d.mu.Lock()
|
|
d.latest = text
|
|
d.mu.Unlock()
|
|
select {
|
|
case d.kick <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// run is the sender loop: wake on new text or on the keepalive deadline,
|
|
// respect the throttle, send the newest text, repeat until closed or failed.
|
|
func (d *draftStream) run() {
|
|
defer close(d.done)
|
|
|
|
var lastSend time.Time
|
|
for {
|
|
var keepalive <-chan time.Time
|
|
if !lastSend.IsZero() {
|
|
keepalive = time.After(d.keepalive - time.Since(lastSend))
|
|
}
|
|
woken := false
|
|
select {
|
|
case <-d.ctx.Done():
|
|
return
|
|
case <-d.kick:
|
|
case <-keepalive:
|
|
woken = true
|
|
}
|
|
|
|
if wait := d.interval - time.Since(lastSend); !lastSend.IsZero() && wait > 0 {
|
|
select {
|
|
case <-d.ctx.Done():
|
|
return
|
|
case <-time.After(wait):
|
|
}
|
|
}
|
|
|
|
d.mu.Lock()
|
|
text, shown := d.latest, d.shown
|
|
d.mu.Unlock()
|
|
if text == "" || (text == shown && !woken) {
|
|
continue
|
|
}
|
|
if !d.send(text) {
|
|
return
|
|
}
|
|
lastSend = time.Now()
|
|
}
|
|
}
|
|
|
|
// send performs one sendMessageDraft call and reports whether the draft is
|
|
// still usable afterwards.
|
|
func (d *draftStream) send(text string) bool {
|
|
text = clampRunes(text, telegramMessageLimit)
|
|
_, err := d.b.tgBot.SendMessageDraft(d.ctx, &bot.SendMessageDraftParams{
|
|
ChatID: d.chatID,
|
|
DraftID: d.draftID,
|
|
Text: text,
|
|
CanStop: true,
|
|
KeepOnStop: true,
|
|
})
|
|
if err != nil {
|
|
d.mu.Lock()
|
|
d.failed = true
|
|
d.mu.Unlock()
|
|
if d.ctx.Err() == nil {
|
|
// Cosmetic from here on: the final message is still sent.
|
|
InfoLogger.Printf("[%s] draft %s for chat %d failed, falling back to final message only: %v",
|
|
d.b.config.ID, d.draftID, d.chatID, err)
|
|
}
|
|
return false
|
|
}
|
|
|
|
d.mu.Lock()
|
|
first := d.shown == ""
|
|
d.shown = text
|
|
d.mu.Unlock()
|
|
if first && d.onFirst != nil {
|
|
d.onFirst()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// current returns the text the user has actually seen.
|
|
func (d *draftStream) current() string {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
return d.shown
|
|
}
|
|
|
|
// close stops the sender and waits for any send in flight, so nothing lands
|
|
// on the chat after the final message. Telegram removes the draft itself once
|
|
// the bot sends a message.
|
|
func (d *draftStream) close() {
|
|
d.closeOnce.Do(func() {
|
|
d.cancel()
|
|
<-d.done
|
|
})
|
|
}
|
|
|
|
// replyOutput is the sink a turn writes into. Two implementations: a draft
|
|
// that grows in place and becomes one final message, or the original
|
|
// one-message-per-text-block segments.
|
|
type replyOutput struct {
|
|
onSegment func(string) error
|
|
onProgress func(string)
|
|
finish func(ctx context.Context, joined string) error
|
|
partial func() string
|
|
close func()
|
|
}
|
|
|
|
func (b *Bot) newReplyOutput(turnCtx context.Context, turn *chatTurn, chatID int64, businessConnectionID string) replyOutput {
|
|
if b.draftEligible(chatID, businessConnectionID) {
|
|
draftID := turn.draftIDString()
|
|
d := b.newDraftStream(turnCtx, chatID, draftID, func() { b.markCommitted(turn) })
|
|
b.setTurnDraft(turn, draftID)
|
|
d.start()
|
|
return replyOutput{
|
|
onProgress: d.update,
|
|
finish: func(ctx context.Context, joined string) error {
|
|
d.close()
|
|
return b.sendLongMessage(ctx, chatID, joined, businessConnectionID)
|
|
},
|
|
partial: d.current,
|
|
close: d.close,
|
|
}
|
|
}
|
|
|
|
return replyOutput{
|
|
onSegment: func(seg string) error {
|
|
b.markCommitted(turn)
|
|
return b.sendOneSegment(turnCtx, chatID, seg, businessConnectionID)
|
|
},
|
|
finish: func(context.Context, string) error { return nil },
|
|
partial: func() string { return "" },
|
|
close: func() {},
|
|
}
|
|
}
|
|
|
|
// sendLongMessage delivers text as one message, or as few as the character
|
|
// cap allows, splitting on paragraph and line boundaries before cutting words.
|
|
func (b *Bot) sendLongMessage(ctx context.Context, chatID int64, text, businessConnectionID string) error {
|
|
for _, chunk := range splitTelegramMessage(text, telegramMessageLimit) {
|
|
if err := b.sendOneSegment(ctx, chatID, chunk, businessConnectionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitTelegramMessage splits text into pieces of at most limit runes,
|
|
// preferring paragraph breaks, then line breaks, then spaces.
|
|
func splitTelegramMessage(text string, limit int) []string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for {
|
|
off := runeOffset(text, limit)
|
|
if off < 0 {
|
|
return append(out, text)
|
|
}
|
|
window := text[:off]
|
|
cut := -1
|
|
for _, sep := range []string{"\n\n", "\n", " "} {
|
|
if i := strings.LastIndex(window, sep); i > 0 {
|
|
cut = i
|
|
break
|
|
}
|
|
}
|
|
if cut < 0 {
|
|
cut = off
|
|
}
|
|
out = append(out, strings.TrimSpace(text[:cut]))
|
|
text = strings.TrimSpace(text[cut:])
|
|
if text == "" {
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
|
|
// runeOffset returns the byte offset just past the n-th rune of s, or -1 when
|
|
// s holds at most n runes.
|
|
func runeOffset(s string, n int) int {
|
|
count := 0
|
|
for i := range s {
|
|
if count == n {
|
|
return i
|
|
}
|
|
count++
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// clampRunes truncates s to at most n runes.
|
|
func clampRunes(s string, n int) string {
|
|
if off := runeOffset(s, n); off >= 0 {
|
|
return s[:off]
|
|
}
|
|
return s
|
|
}
|