Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (

require (
github.com/charmbracelet/x/ansi v0.11.6
github.com/eekstunt/telegramify-markdown-go v0.2.0
github.com/gorilla/websocket v1.5.3
github.com/tta-lab/codex-server-go v0.1.0
golang.org/x/term v0.42.0
Expand Down Expand Up @@ -78,6 +79,7 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.mau.fi/util v0.9.6 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
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/eekstunt/telegramify-markdown-go v0.2.0 h1:12NErifwcZWTbNySPAX/cS4OZTcYGDSL5610jJJfzlU=
github.com/eekstunt/telegramify-markdown-go v0.2.0/go.mod h1:lpEJ5GTV0/b0Yl93yrmLm+Hbn/963FZKptAjsr+F9Vk=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
Expand Down Expand Up @@ -165,6 +167,8 @@ github.com/tta-lab/codex-server-go v0.1.0 h1:HrZ3PvwKS1ecLlNQnQ6hBE/ss3q0eA4UWY/
github.com/tta-lab/codex-server-go v0.1.0/go.mod h1:t+1tE2M7bM6g74JUUbN/eDMqaoE3xHt/K4ZIaeiDWew=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func persistMsg(msgSvc *message.Service, p message.CreateParams) {
// applyOverflow truncates oversize message bodies and writes the full content
// to the overflow directory. Only applied to agent/worker (tmux) channels —
// human channels (Telegram) pass through untouched since Telegram already
// handles chunking via splitMessage().
// handles message chunking.
func applyOverflow(msg string) string {
dir := filepath.Join(config.ResolveDataDir(), "files", "default")
return overflow.Write(msg, overflow.DefaultThreshold, dir)
Expand Down
64 changes: 33 additions & 31 deletions internal/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

tgmd "github.com/eekstunt/telegramify-markdown-go"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
Expand All @@ -25,38 +26,38 @@ func ParseChatID(s string) (int64, error) {

const maxMessageLen = 4096

// splitMessage splits text into chunks that fit within Telegram's 4096-rune limit.
// Splits at natural boundaries: paragraph breaks > newlines > spaces > hard cut.
func splitMessage(text string) []string {
runes := []rune(text)
if len(runes) <= maxMessageLen {
return []string{text}
}

var parts []string
for len(runes) > 0 {
if len(runes) <= maxMessageLen {
parts = append(parts, string(runes))
break
}
type messageChunk struct {
Text string
Entities []models.MessageEntity
}

chunk := string(runes[:maxMessageLen])
cutAt := maxMessageLen // in runes
if i := strings.LastIndex(chunk, "\n\n"); i > 0 {
cutAt = len([]rune(chunk[:i]))
} else if i := strings.LastIndex(chunk, "\n"); i > 0 {
cutAt = len([]rune(chunk[:i]))
} else if i := strings.LastIndex(chunk, " "); i > 0 {
cutAt = len([]rune(chunk[:i]))
}
func renderMessageChunks(text string) []messageChunk {
messages := tgmd.ConvertAndSplit(text, tgmd.WithMaxMessageLen(maxMessageLen))
chunks := make([]messageChunk, 0, len(messages))
for _, msg := range messages {
chunks = append(chunks, messageChunk{
Text: msg.Text,
Entities: telegramMessageEntities(msg.Entities),
})
}
return chunks
}

part := strings.TrimRight(string(runes[:cutAt]), " \n")
if part != "" {
parts = append(parts, part)
func telegramMessageEntities(entities []tgmd.Entity) []models.MessageEntity {
if len(entities) == 0 {
return nil
}
out := make([]models.MessageEntity, len(entities))
for i, e := range entities {
out[i] = models.MessageEntity{
Type: models.MessageEntityType(e.Type),
Offset: e.Offset,
Length: e.Length,
URL: e.URL,
Language: e.Language,
}
runes = []rune(strings.TrimLeft(string(runes[cutAt:]), " \n"))
}
return parts
return out
}

// SendMessage sends a text message to a chat via the Telegram Bot API.
Expand All @@ -76,14 +77,15 @@ func SendMessage(botToken, chatID, text string) error {
return err
}

chunks := splitMessage(text)
chunks := renderMessageChunks(text)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(len(chunks)+1)*15*time.Second)
defer cancel()

for i, chunk := range chunks {
if _, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: id,
Text: chunk,
ChatID: id,
Text: chunk.Text,
Entities: chunk.Entities,
}); err != nil {
return fmt.Errorf("telegram send (chunk %d/%d): %w", i+1, len(chunks), err)
}
Expand Down
90 changes: 66 additions & 24 deletions internal/telegram/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,44 @@ package telegram
import (
"strings"
"testing"

"github.com/go-telegram/bot/models"
)

func TestSplitMessage_Short(t *testing.T) {
func TestRenderMessageChunks_Short(t *testing.T) {
text := "hello world"
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) != 1 || parts[0] != text {
t.Errorf("expected single chunk %q, got %v", text, parts)
}
}

func TestSplitMessage_Exact4096(t *testing.T) {
func TestRenderMessageChunks_Exact4096(t *testing.T) {
text := strings.Repeat("a", 4096)
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) != 1 {
t.Errorf("expected 1 chunk, got %d", len(parts))
}
}

func TestSplitMessage_ParagraphBreak(t *testing.T) {
func TestRenderMessageChunks_ParagraphBreak(t *testing.T) {
half := strings.Repeat("a", 2500)
text := half + "\n\n" + half
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) != 2 {
t.Errorf("expected 2 chunks, got %d", len(parts))
}
for _, p := range parts {
if strings.HasPrefix(p, "\n") || strings.HasSuffix(p, "\n") {
t.Errorf("chunk has leading/trailing newline: %q", p)
if len([]rune(p)) > maxMessageLen {
t.Errorf("chunk too long: %d chars", len([]rune(p)))
}
}
}

func TestSplitMessage_SingleNewline(t *testing.T) {
func TestRenderMessageChunks_SingleNewline(t *testing.T) {
line := strings.Repeat("a", 100) + "\n"
text := strings.Repeat(line, 50) // 5050 chars
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) < 2 {
t.Errorf("expected at least 2 chunks, got %d", len(parts))
}
Expand All @@ -49,10 +51,10 @@ func TestSplitMessage_SingleNewline(t *testing.T) {
}
}

func TestSplitMessage_Space(t *testing.T) {
func TestRenderMessageChunks_Space(t *testing.T) {
word := strings.Repeat("a", 50) + " "
text := strings.Repeat(word, 98) // ~4998 chars, no newlines
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) < 2 {
t.Errorf("expected at least 2 chunks, got %d", len(parts))
}
Expand All @@ -63,9 +65,9 @@ func TestSplitMessage_Space(t *testing.T) {
}
}

func TestSplitMessage_MultiChunk(t *testing.T) {
func TestRenderMessageChunks_MultiChunk(t *testing.T) {
text := strings.Repeat("x\n", 4001) // 8002 chars
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) < 2 {
t.Errorf("expected multiple chunks, got %d", len(parts))
}
Expand All @@ -76,10 +78,10 @@ func TestSplitMessage_MultiChunk(t *testing.T) {
}
}

func TestSplitMessage_HardCut(t *testing.T) {
func TestRenderMessageChunks_HardCut(t *testing.T) {
const textLen = 5000
text := strings.Repeat("a", textLen)
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) != 2 {
t.Errorf("expected 2 chunks, got %d", len(parts))
}
Expand All @@ -92,27 +94,27 @@ func TestSplitMessage_HardCut(t *testing.T) {
}
}

func TestSplitMessage_Empty(t *testing.T) {
parts := splitMessage("")
func TestRenderMessageChunks_Empty(t *testing.T) {
parts := renderMessageTexts("")
if len(parts) != 1 {
t.Errorf("expected 1 part for empty string, got %d", len(parts))
}
}

func TestSplitMessage_AllWhitespaceOverLimit(t *testing.T) {
func TestRenderMessageChunks_AllWhitespaceOverLimit(t *testing.T) {
text := strings.Repeat("\n", 5000)
parts := splitMessage(text)
parts := renderMessageTexts(text)
// verifies no panic or infinite loop; all whitespace trims to nothing so
// splitMessage returns an empty slice. SendMessage's TrimSpace guard prevents
// this input from ever reaching splitMessage in practice.
// rendering returns an empty slice. SendMessage's TrimSpace guard prevents
// this input from ever reaching renderMessageChunks in practice.
_ = parts
}

func TestSplitMessage_NonASCII(t *testing.T) {
func TestRenderMessageChunks_NonASCII(t *testing.T) {
// Each emoji is 4 bytes — 4096 runes = 16384 bytes.
// Byte-based slicing would corrupt at char boundaries; rune-based is safe.
text := strings.Repeat("😀", 5000)
parts := splitMessage(text)
parts := renderMessageTexts(text)
if len(parts) < 2 {
t.Errorf("expected at least 2 chunks, got %d", len(parts))
}
Expand All @@ -123,6 +125,46 @@ func TestSplitMessage_NonASCII(t *testing.T) {
}
}

func renderMessageTexts(text string) []string {
chunks := renderMessageChunks(text)
parts := make([]string, 0, len(chunks))
for _, chunk := range chunks {
parts = append(parts, chunk.Text)
}
return parts
}

func TestRenderMessageChunks_MarkdownEntities(t *testing.T) {
chunks := renderMessageChunks("hello **world**")
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(chunks))
}
if chunks[0].Text != "hello world" {
t.Fatalf("text = %q, want %q", chunks[0].Text, "hello world")
}
if len(chunks[0].Entities) != 1 {
t.Fatalf("expected 1 entity, got %d", len(chunks[0].Entities))
}
if chunks[0].Entities[0].Type != models.MessageEntityTypeBold {
t.Errorf("entity type = %q, want %q", chunks[0].Entities[0].Type, models.MessageEntityTypeBold)
}
if chunks[0].Entities[0].Offset != 6 || chunks[0].Entities[0].Length != 5 {
t.Errorf("entity span = (%d, %d), want (6, 5)", chunks[0].Entities[0].Offset, chunks[0].Entities[0].Length)
}
}

func TestRenderMessageChunks_SplitsMarkdown(t *testing.T) {
chunks := renderMessageChunks(strings.Repeat("**bold** ", 1000))
if len(chunks) < 2 {
t.Fatalf("expected multiple chunks, got %d", len(chunks))
}
for _, chunk := range chunks {
if len([]rune(chunk.Text)) > maxMessageLen {
t.Errorf("chunk exceeds max length: %d", len([]rune(chunk.Text)))
}
}
}

func TestToolEmoji(t *testing.T) {
tests := []struct {
tool string
Expand Down
Loading