add telegram bot
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/remembos/internal/memos"
|
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||||
"git.vakhrushev.me/av/remembos/internal/search"
|
"git.vakhrushev.me/av/remembos/internal/search"
|
||||||
"git.vakhrushev.me/av/remembos/internal/storage"
|
"git.vakhrushev.me/av/remembos/internal/storage"
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/telegram"
|
||||||
"git.vakhrushev.me/av/remembos/internal/web"
|
"git.vakhrushev.me/av/remembos/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,6 +84,16 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Telegram bot
|
||||||
|
if cfg.Telegram.Token != "" {
|
||||||
|
tgBot, err := telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, loc, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to create telegram bot", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
go tgBot.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("starting server", "addr", cfg.Web.Listen)
|
logger.Info("starting server", "addr", cfg.Web.Listen)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
|||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var timeFormatRe = regexp.MustCompile(`^\d{2}:\d{2}$`)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Memos MemosConfig `toml:"memos"`
|
Memos MemosConfig `toml:"memos"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
@@ -119,6 +122,9 @@ func setDefaults(cfg *Config) {
|
|||||||
if cfg.General.LogLevel == "" {
|
if cfg.General.LogLevel == "" {
|
||||||
cfg.General.LogLevel = "info"
|
cfg.General.LogLevel = "info"
|
||||||
}
|
}
|
||||||
|
if cfg.Telegram.SendAt == "" {
|
||||||
|
cfg.Telegram.SendAt = "09:00"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(cfg *Config) error {
|
func validate(cfg *Config) error {
|
||||||
@@ -131,5 +137,13 @@ func validate(cfg *Config) error {
|
|||||||
if sum := cfg.Search.TierWeights.Sum(); sum != 100 {
|
if sum := cfg.Search.TierWeights.Sum(); sum != 100 {
|
||||||
return fmt.Errorf("search.tier_weights must sum to 100, got %d", sum)
|
return fmt.Errorf("search.tier_weights must sum to 100, got %d", sum)
|
||||||
}
|
}
|
||||||
|
if cfg.Telegram.Token != "" {
|
||||||
|
if cfg.Telegram.ChatID == 0 {
|
||||||
|
return fmt.Errorf("telegram.chat_id is required when telegram.token is set")
|
||||||
|
}
|
||||||
|
if !timeFormatRe.MatchString(cfg.Telegram.SendAt) {
|
||||||
|
return fmt.Errorf("telegram.send_at must be in HH:MM format, got %q", cfg.Telegram.SendAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,43 @@ func (c *Client) ListMemos(ctx context.Context, filter string, pageSize int, pag
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadAttachment downloads the attachment data as bytes.
|
||||||
|
func (c *Client) DownloadAttachment(ctx context.Context, att Attachment) ([]byte, error) {
|
||||||
|
var reqURL string
|
||||||
|
var needsAuth bool
|
||||||
|
|
||||||
|
if att.ExternalLink != "" {
|
||||||
|
reqURL = att.ExternalLink
|
||||||
|
} else {
|
||||||
|
reqURL = fmt.Sprintf("%s/file/%s/%s", c.baseURL, att.Name, att.Filename)
|
||||||
|
needsAuth = true
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
if needsAuth {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("do request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("download attachment %s: status %d", att.Name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRandomMemo fetches a single memo without any filter (for full fallback).
|
// GetRandomMemo fetches a single memo without any filter (for full fallback).
|
||||||
func (c *Client) GetRandomMemo(ctx context.Context) (*Memo, error) {
|
func (c *Client) GetRandomMemo(ctx context.Context) (*Memo, error) {
|
||||||
resp, err := c.ListMemos(ctx, "", 1, "")
|
resp, err := c.ListMemos(ctx, "", 1, "")
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/config"
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/memory"
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bot sends a daily memory via Telegram.
|
||||||
|
type Bot struct {
|
||||||
|
api *tgbotapi.BotAPI
|
||||||
|
service *memory.Service
|
||||||
|
client *memos.Client
|
||||||
|
chatID int64
|
||||||
|
sendAt string // "HH:MM"
|
||||||
|
publicURL string
|
||||||
|
loc *time.Location
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBot creates a new Telegram bot.
|
||||||
|
func NewBot(
|
||||||
|
cfg config.TelegramConfig,
|
||||||
|
service *memory.Service,
|
||||||
|
client *memos.Client,
|
||||||
|
memosURL, publicURL string,
|
||||||
|
loc *time.Location,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) (*Bot, error) {
|
||||||
|
api, err := tgbotapi.NewBotAPI(cfg.Token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create telegram bot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := publicURL
|
||||||
|
if pub == "" {
|
||||||
|
pub = memosURL
|
||||||
|
}
|
||||||
|
pub = strings.TrimRight(pub, "/")
|
||||||
|
|
||||||
|
logger.Info("telegram bot authorized", "username", api.Self.UserName)
|
||||||
|
|
||||||
|
return &Bot{
|
||||||
|
api: api,
|
||||||
|
service: service,
|
||||||
|
client: client,
|
||||||
|
chatID: cfg.ChatID,
|
||||||
|
sendAt: cfg.SendAt,
|
||||||
|
publicURL: pub,
|
||||||
|
loc: loc,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the scheduling loop. It blocks until ctx is cancelled.
|
||||||
|
func (b *Bot) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
next := b.nextSendTime()
|
||||||
|
delay := time.Until(next)
|
||||||
|
b.logger.Info("next telegram send scheduled", "at", next.Format("2006-01-02 15:04:05"), "in", delay.Round(time.Second))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
b.logger.Info("telegram bot stopped")
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
b.sendDaily(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSendTime returns the next occurrence of sendAt in the configured timezone.
|
||||||
|
func (b *Bot) nextSendTime() time.Time {
|
||||||
|
now := time.Now().In(b.loc)
|
||||||
|
|
||||||
|
parts := strings.SplitN(b.sendAt, ":", 2)
|
||||||
|
hour := 9
|
||||||
|
minute := 0
|
||||||
|
if len(parts) == 2 {
|
||||||
|
fmt.Sscanf(parts[0], "%d", &hour)
|
||||||
|
fmt.Sscanf(parts[1], "%d", &minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, b.loc)
|
||||||
|
if !target.After(now) {
|
||||||
|
target = target.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendDaily fetches today's memory and sends it.
|
||||||
|
func (b *Bot) sendDaily(ctx context.Context) {
|
||||||
|
b.logger.Info("sending daily memory via telegram")
|
||||||
|
|
||||||
|
mem, err := b.service.GetTodayMemory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Error("failed to get today memory", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mem == nil {
|
||||||
|
b.logger.Info("no memory for today, skipping telegram send")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mainText, captionText := formatMemory(mem, b.publicURL)
|
||||||
|
images := imageAttachments(mem.Memo)
|
||||||
|
|
||||||
|
// Try to download images
|
||||||
|
var downloaded []imageFile
|
||||||
|
if len(images) > 0 {
|
||||||
|
downloaded = b.downloadImages(ctx, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.sendWithRetry(ctx, mainText, captionText, downloaded); err != nil {
|
||||||
|
b.logger.Error("failed to send telegram message after retries", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageFile struct {
|
||||||
|
filename string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadImages downloads image attachments, skipping failures.
|
||||||
|
func (b *Bot) downloadImages(ctx context.Context, attachments []memos.Attachment) []imageFile {
|
||||||
|
var files []imageFile
|
||||||
|
for _, att := range attachments {
|
||||||
|
data, err := b.client.DownloadAttachment(ctx, att)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, imageFile{
|
||||||
|
filename: att.Filename,
|
||||||
|
data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWithRetry attempts to send the message with up to 3 retries.
|
||||||
|
func (b *Bot) sendWithRetry(ctx context.Context, mainText, captionText string, images []imageFile) error {
|
||||||
|
backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := range 3 {
|
||||||
|
lastErr = b.send(mainText, captionText, images)
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.logger.Warn("telegram send failed", "attempt", attempt+1, "error", lastErr)
|
||||||
|
|
||||||
|
if attempt < 2 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(backoffs[attempt]):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// send executes the actual Telegram API calls based on the sending strategy.
|
||||||
|
func (b *Bot) send(mainText, captionText string, images []imageFile) error {
|
||||||
|
switch {
|
||||||
|
case len(images) == 0:
|
||||||
|
// No images — just text
|
||||||
|
return b.sendText(mainText)
|
||||||
|
|
||||||
|
case len(images) == 1 && len(captionText) <= maxCaptionLen:
|
||||||
|
// Single image with short caption
|
||||||
|
return b.sendPhoto(images[0], captionText)
|
||||||
|
|
||||||
|
case len(images) > 1 && len(captionText) <= maxCaptionLen:
|
||||||
|
// Multiple images with short caption
|
||||||
|
return b.sendMediaGroup(images, captionText)
|
||||||
|
|
||||||
|
case len(images) >= 1 && len(captionText) > maxCaptionLen:
|
||||||
|
// Images with long text — send text first, then images without caption
|
||||||
|
if err := b.sendText(mainText); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(images) == 1 {
|
||||||
|
return b.sendPhoto(images[0], "")
|
||||||
|
}
|
||||||
|
return b.sendMediaGroup(images, "")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return b.sendText(mainText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendText(text string) error {
|
||||||
|
msg := tgbotapi.NewMessage(b.chatID, text)
|
||||||
|
msg.ParseMode = tgbotapi.ModeHTML
|
||||||
|
msg.DisableWebPagePreview = true
|
||||||
|
_, err := b.api.Send(msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendPhoto(img imageFile, caption string) error {
|
||||||
|
photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{
|
||||||
|
Name: img.filename,
|
||||||
|
Bytes: img.data,
|
||||||
|
})
|
||||||
|
if caption != "" {
|
||||||
|
photo.Caption = caption
|
||||||
|
photo.ParseMode = tgbotapi.ModeHTML
|
||||||
|
}
|
||||||
|
_, err := b.api.Send(photo)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendMediaGroup(images []imageFile, caption string) error {
|
||||||
|
media := make([]interface{}, len(images))
|
||||||
|
for i, img := range images {
|
||||||
|
photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{
|
||||||
|
Name: img.filename,
|
||||||
|
Bytes: img.data,
|
||||||
|
})
|
||||||
|
if i == 0 && caption != "" {
|
||||||
|
photo.Caption = caption
|
||||||
|
photo.ParseMode = tgbotapi.ModeHTML
|
||||||
|
}
|
||||||
|
media[i] = photo
|
||||||
|
}
|
||||||
|
|
||||||
|
mg := tgbotapi.NewMediaGroup(b.chatID, media)
|
||||||
|
_, err := b.api.SendMediaGroup(mg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/memos"
|
||||||
|
"git.vakhrushev.me/av/remembos/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxMessageLen = 4096
|
||||||
|
maxCaptionLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatMemory returns (mainText, captionText) formatted as HTML for Telegram.
|
||||||
|
// mainText is for sendMessage (up to 4096 chars), captionText for photo captions (up to 1024 chars).
|
||||||
|
func formatMemory(mem *search.Memory, publicURL string) (mainText, captionText string) {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Header: date and "ago" text
|
||||||
|
b.WriteString(fmt.Sprintf("<b>%s</b>", formatDate(mem.Date)))
|
||||||
|
if ago := agoText(mem.Date); ago != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" <i>(%s)</i>", ago))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Content
|
||||||
|
b.WriteString(escapeHTML(mem.Memo.Content))
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if len(mem.Memo.Tags) > 0 {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
tags := make([]string, len(mem.Memo.Tags))
|
||||||
|
for i, t := range mem.Memo.Tags {
|
||||||
|
tags[i] = "#" + t
|
||||||
|
}
|
||||||
|
b.WriteString(strings.Join(tags, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to original
|
||||||
|
memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name)
|
||||||
|
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">Оригинал</a>", memoURL))
|
||||||
|
|
||||||
|
full := b.String()
|
||||||
|
mainText = truncateHTML(full, maxMessageLen)
|
||||||
|
captionText = truncateHTML(full, maxCaptionLen)
|
||||||
|
return mainText, captionText
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageAttachments returns image attachments from the memo.
|
||||||
|
func imageAttachments(memo *memos.Memo) []memos.Attachment {
|
||||||
|
var images []memos.Attachment
|
||||||
|
for _, att := range memo.Attachments {
|
||||||
|
if att.IsImage() {
|
||||||
|
images = append(images, att)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateHTML truncates text to maxLen, cutting at a word/line boundary and adding "...".
|
||||||
|
func truncateHTML(text string, maxLen int) string {
|
||||||
|
if len(text) <= maxLen {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve space for "..."
|
||||||
|
cut := maxLen - 3
|
||||||
|
|
||||||
|
// Find last newline or space before cut point
|
||||||
|
idx := strings.LastIndexAny(text[:cut], "\n ")
|
||||||
|
if idx <= 0 {
|
||||||
|
idx = cut
|
||||||
|
}
|
||||||
|
|
||||||
|
return text[:idx] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeHTML escapes special HTML characters for Telegram HTML parse mode.
|
||||||
|
func escapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var months = [...]string{
|
||||||
|
1: "января", 2: "февраля", 3: "марта",
|
||||||
|
4: "апреля", 5: "мая", 6: "июня",
|
||||||
|
7: "июля", 8: "августа", 9: "сентября",
|
||||||
|
10: "октября", 11: "ноября", 12: "декабря",
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(t time.Time) string {
|
||||||
|
return fmt.Sprintf("%d %s %d", t.Day(), months[t.Month()], t.Year())
|
||||||
|
}
|
||||||
|
|
||||||
|
func agoText(t time.Time) string {
|
||||||
|
now := time.Now()
|
||||||
|
years := now.Year() - t.Year()
|
||||||
|
monthsDiff := int(now.Month()) - int(t.Month())
|
||||||
|
if monthsDiff < 0 {
|
||||||
|
years--
|
||||||
|
monthsDiff += 12
|
||||||
|
}
|
||||||
|
|
||||||
|
if years > 0 {
|
||||||
|
return fmt.Sprintf("%s назад", pluralYears(years))
|
||||||
|
}
|
||||||
|
if monthsDiff > 0 {
|
||||||
|
return fmt.Sprintf("%s назад", pluralMonths(monthsDiff))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralYears(n int) string {
|
||||||
|
mod10 := n % 10
|
||||||
|
mod100 := n % 100
|
||||||
|
switch {
|
||||||
|
case mod100 >= 11 && mod100 <= 14:
|
||||||
|
return fmt.Sprintf("%d лет", n)
|
||||||
|
case mod10 == 1:
|
||||||
|
return fmt.Sprintf("%d год", n)
|
||||||
|
case mod10 >= 2 && mod10 <= 4:
|
||||||
|
return fmt.Sprintf("%d года", n)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d лет", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralMonths(n int) string {
|
||||||
|
mod10 := n % 10
|
||||||
|
mod100 := n % 100
|
||||||
|
switch {
|
||||||
|
case mod100 >= 11 && mod100 <= 14:
|
||||||
|
return fmt.Sprintf("%d месяцев", n)
|
||||||
|
case mod10 == 1:
|
||||||
|
return fmt.Sprintf("%d месяц", n)
|
||||||
|
case mod10 >= 2 && mod10 <= 4:
|
||||||
|
return fmt.Sprintf("%d месяца", n)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d месяцев", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user