mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-18 10:15:08 +00:00
Correct comments that described the retired webhook transport, a removed /cron route, the old KV store and wrapper types, and behaviour that has since changed; drop plan and review labels; reword two startup log lines that overstated or misnamed what they report.
205 lines
6.7 KiB
Go
205 lines
6.7 KiB
Go
// Package chathelper holds the small Telegram helpers shared by every module:
|
|
// state scoping (SubjectFor), argument parsing, topic-preserving replies and
|
|
// edits, fetch-deadline budgeting, and text formatting.
|
|
package chathelper
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
)
|
|
|
|
// MonospaceTable renders an HTML-safe, left-aligned table for Telegram's
|
|
// <pre> mode. Callers should send the result with ReplyHTML.
|
|
func MonospaceTable(headers []string, rows [][]string) string {
|
|
widths := make([]int, len(headers))
|
|
for index, header := range headers {
|
|
widths[index] = len([]rune(header))
|
|
}
|
|
for _, row := range rows {
|
|
for index := range headers {
|
|
if index < len(row) && len([]rune(row[index])) > widths[index] {
|
|
widths[index] = len([]rune(row[index]))
|
|
}
|
|
}
|
|
}
|
|
var lines []string
|
|
appendRow := func(row []string) {
|
|
cells := make([]string, len(headers))
|
|
for index := range headers {
|
|
if index < len(row) {
|
|
cells[index] = row[index]
|
|
}
|
|
if index < len(headers)-1 {
|
|
cells[index] += strings.Repeat(" ", widths[index]-len([]rune(cells[index])))
|
|
}
|
|
}
|
|
lines = append(lines, strings.Join(cells, " "))
|
|
}
|
|
appendRow(headers)
|
|
separator := make([]string, len(headers))
|
|
for index := range headers {
|
|
separator[index] = strings.Repeat("-", widths[index])
|
|
}
|
|
appendRow(separator)
|
|
for _, row := range rows {
|
|
appendRow(row)
|
|
}
|
|
return "<pre>" + html.EscapeString(strings.Join(lines, "\n")) + "</pre>"
|
|
}
|
|
|
|
// SubjectFor returns the identity key per-module state should be scoped by:
|
|
// group/supergroup → chat ID (shared game state), otherwise → user ID.
|
|
// Returns "" when no usable id is present (caller should reply with a
|
|
// "cannot identify chat" error). Channels and unknown chat types fall
|
|
// through to From.ID.
|
|
func SubjectFor(msg *models.Message) string {
|
|
if msg == nil {
|
|
return ""
|
|
}
|
|
switch msg.Chat.Type {
|
|
case models.ChatTypeGroup, models.ChatTypeSupergroup:
|
|
return strconv.FormatInt(msg.Chat.ID, 10)
|
|
default:
|
|
if msg.From != nil {
|
|
return strconv.FormatInt(msg.From.ID, 10)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ArgAfterCommand returns everything after the first space in text, trimmed.
|
|
// Works for `/cmd arg`, `/cmd@bot arg`, etc.
|
|
func ArgAfterCommand(text string) string {
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
idx := strings.IndexByte(text, ' ')
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(text[idx+1:])
|
|
}
|
|
|
|
// NowMillis returns current UTC ms-since-epoch.
|
|
func NowMillis() int64 { return time.Now().UTC().UnixMilli() }
|
|
|
|
// replyReserve is the slice of a caller's deadline kept aside for delivering
|
|
// the Telegram reply. When the context carries a deadline (for example a
|
|
// cron's per-fire timeout) and upstream fetches consume all of it, the final
|
|
// SendMessage fails with "context deadline exceeded" and the user sees no
|
|
// response; reserving a fixed tail keeps delivery headroom. Update handlers
|
|
// under long polling currently receive a context with no deadline, so there
|
|
// is nothing to reserve on that path.
|
|
const replyReserve = 3 * time.Second
|
|
|
|
// FetchContext derives a child of ctx for upstream data fetches, leaving
|
|
// replyReserve of the parent's deadline for the subsequent Reply (which must be
|
|
// called with the original ctx, not this child). If ctx has no deadline, the
|
|
// child is a plain cancelable context with no deadline of its own — the usual
|
|
// case for update handlers today. If less than replyReserve (plus a second)
|
|
// remains, the child gets a one-second floor so a fetch still attempts rather
|
|
// than failing instantly. Callers must call the returned cancel.
|
|
func FetchContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
dl, ok := ctx.Deadline()
|
|
if !ok {
|
|
return context.WithCancel(ctx)
|
|
}
|
|
budget := time.Until(dl) - replyReserve
|
|
if budget < time.Second {
|
|
budget = time.Second
|
|
}
|
|
return context.WithTimeout(ctx, budget)
|
|
}
|
|
|
|
// Reply sends a plain-text response to the chat the inbound message came from.
|
|
//
|
|
// Forwards MessageThreadID so replies in a forum-supergroup topic stay in the
|
|
// same topic. Telegram routes outgoing messages with an absent/zero
|
|
// message_thread_id to the General topic — that mis-routing is the precise
|
|
// reason this helper takes the whole message instead of just a chat ID.
|
|
func Reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
|
|
_, err := SendText(ctx, b, msg, text)
|
|
return err
|
|
}
|
|
|
|
// SendText behaves like Reply but returns the id of the message it sent, so a
|
|
// caller that posts a progress placeholder can later edit or delete it with
|
|
// EditText or DeleteMessage. Returns 0 when nothing was sent.
|
|
func SendText(ctx context.Context, b *bot.Bot, msg *models.Message, text string) (int, error) {
|
|
if msg == nil {
|
|
return 0, nil
|
|
}
|
|
sent, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
|
ChatID: msg.Chat.ID,
|
|
MessageThreadID: msg.MessageThreadID,
|
|
Text: text,
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return sent.ID, nil
|
|
}
|
|
|
|
// EditText replaces the text of messageID in the chat msg came from.
|
|
//
|
|
// The chat comes from the inbound message rather than from whatever the send
|
|
// call returned, because that is the chat the bot is addressing; the returned
|
|
// message only supplies the id. No-op when there is no message to edit.
|
|
func EditText(ctx context.Context, b *bot.Bot, msg *models.Message, messageID int, text string) error {
|
|
if msg == nil || messageID == 0 {
|
|
return nil
|
|
}
|
|
_, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{
|
|
ChatID: msg.Chat.ID,
|
|
MessageID: messageID,
|
|
Text: text,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// DeleteMessage removes messageID from the chat msg came from. No-op when
|
|
// there is no message to delete.
|
|
func DeleteMessage(ctx context.Context, b *bot.Bot, msg *models.Message, messageID int) error {
|
|
if msg == nil || messageID == 0 {
|
|
return nil
|
|
}
|
|
_, err := b.DeleteMessage(ctx, &bot.DeleteMessageParams{
|
|
ChatID: msg.Chat.ID,
|
|
MessageID: messageID,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// ReplyHTML sends a Telegram HTML-formatted response to the chat the inbound
|
|
// message came from. Forwards MessageThreadID — see Reply for rationale.
|
|
func ReplyHTML(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
|
ChatID: msg.Chat.ID,
|
|
MessageThreadID: msg.MessageThreadID,
|
|
Text: text,
|
|
ParseMode: models.ParseModeHTML,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// WinRate computes wins/played as a percentage rounded to nearest int.
|
|
// Uses math.Round (round half away from zero for positive inputs) so 2/3
|
|
// renders as 67%, not 66% as plain int(...) truncation would give.
|
|
// Returns 0 when played == 0 (avoids NaN).
|
|
func WinRate(wins, played int) int {
|
|
if played <= 0 {
|
|
return 0
|
|
}
|
|
return int(math.Round(float64(wins) / float64(played) * 100))
|
|
}
|