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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@ Register the server in your MCP client, e.g.:
### Run

```bash
bin/telegram-api login & # if not already logged in via tele-tui or telegram-mcp
bin/telegram-api serve # listens on 127.0.0.1:8080
bin/telegram-api login # interactive; skip if already logged in via telegram-mcp
bin/telegram-api serve # listens on 127.0.0.1:8080
```

It binds **127.0.0.1 only** by default (no auth token needed). Change the address with `-addr` or the `TELETUI_API_ADDR` env var:
Expand Down Expand Up @@ -334,6 +334,14 @@ curl -s -X POST http://127.0.0.1:8080/api/send \
| POST | `/api/mark-read` | Mark read `{chat_id, message_ids[]}` |
| GET | `/api/media?chat_id=&message_id=` | Download message media, returns local path |

`limit` must be a positive signed 32-bit integer and is capped at 100.
`from_message_id` must be nonnegative; `offset` accepts negative values for
relative pagination. Both must fit a signed 32-bit integer. Chat IDs remain
64-bit integers. Invalid numeric parameters return HTTP 400.

JSON request bodies must contain exactly one value. Trailing whitespace is
accepted; additional JSON values or trailing non-JSON data return HTTP 400.

Errors are JSON (`{"error": "..."}`) with status 400 (bad params), 404 (unknown route/chat), or 502 (upstream Telegram error).

## Contributing
Expand Down
52 changes: 23 additions & 29 deletions internal/render/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package render

import (
"fmt"
"sort"
"strings"

"github.com/imtaqin/telegram-cli/internal/telegram"
Expand All @@ -20,25 +19,22 @@ func EntitiesToMarkdown(text *telegram.FormattedText) string {

runes := []rune(text.Text)

// Sort entities by offset.
entities := make([]*telegram.TextEntity, len(text.Entities))
copy(entities, text.Entities)
sort.Slice(entities, func(i, j int) bool {
return entities[i].Offset < entities[j].Offset
})

var b strings.Builder
lastEnd := int32(0)
lastEnd := 0

for _, e := range entities {
for _, span := range entitySpans(runes, text.Entities) {
// This flat renderer retains the enclosing formatting for overlaps.
if span.start < lastEnd {
continue
}
// Append text before this entity.
if e.Offset > lastEnd {
b.WriteString(string(runes[lastEnd:e.Offset]))
if span.start > lastEnd {
b.WriteString(string(runes[lastEnd:span.start]))
}

entityText := string(runes[e.Offset : e.Offset+e.Length])
entityText := string(runes[span.start:span.end])

switch t := e.Type.(type) {
switch t := span.entity.Type.(type) {
case *telegram.TextEntityTypeBold:
b.WriteString("**")
b.WriteString(entityText)
Expand Down Expand Up @@ -116,11 +112,11 @@ func EntitiesToMarkdown(text *telegram.FormattedText) string {
b.WriteString(entityText)
}

lastEnd = e.Offset + e.Length
lastEnd = span.end
}

// Append remaining text.
if lastEnd < int32(len(runes)) {
if lastEnd < len(runes) {
b.WriteString(string(runes[lastEnd:]))
}

Expand All @@ -138,23 +134,21 @@ func EntitiesToANSI(text *telegram.FormattedText) string {
}

runes := []rune(text.Text)
entities := make([]*telegram.TextEntity, len(text.Entities))
copy(entities, text.Entities)
sort.Slice(entities, func(i, j int) bool {
return entities[i].Offset < entities[j].Offset
})

var b strings.Builder
lastEnd := int32(0)
lastEnd := 0

for _, e := range entities {
if e.Offset > lastEnd {
b.WriteString(string(runes[lastEnd:e.Offset]))
for _, span := range entitySpans(runes, text.Entities) {
if span.start < lastEnd {
continue
}
if span.start > lastEnd {
b.WriteString(string(runes[lastEnd:span.start]))
}

entityText := string(runes[e.Offset : e.Offset+e.Length])
entityText := string(runes[span.start:span.end])

switch e.Type.(type) {
switch span.entity.Type.(type) {
case *telegram.TextEntityTypeBold:
b.WriteString("\033[1m")
b.WriteString(entityText)
Expand All @@ -179,10 +173,10 @@ func EntitiesToANSI(text *telegram.FormattedText) string {
b.WriteString(entityText)
}

lastEnd = e.Offset + e.Length
lastEnd = span.end
}

if lastEnd < int32(len(runes)) {
if lastEnd < len(runes) {
b.WriteString(string(runes[lastEnd:]))
}

Expand Down
181 changes: 181 additions & 0 deletions internal/render/entities_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package render

import (
"strings"
"testing"
"unicode/utf8"

"github.com/imtaqin/telegram-cli/internal/telegram"
)

func TestEntityRenderersUTF16(t *testing.T) {
tests := []struct {
name string
text string
entities []*telegram.TextEntity
markdown string
ansi string
}{
{
name: "ASCII",
text: "hello world",
entities: []*telegram.TextEntity{
{Offset: 6, Length: 5, Type: &telegram.TextEntityTypeBold{}},
},
markdown: "hello **world**",
ansi: "hello \033[1mworld\033[22m",
},
{
name: "emoji_before_formatting",
text: "😀你好世界",
entities: []*telegram.TextEntity{
{Offset: 4, Length: 2, Type: &telegram.TextEntityTypeBold{}},
},
markdown: "😀你好**世界**",
ansi: "😀你好\033[1m世界\033[22m",
},
{
name: "many_emoji_before_formatting",
text: strings.Repeat("😀", 40) + "end",
entities: []*telegram.TextEntity{
{Offset: 80, Length: 3, Type: &telegram.TextEntityTypeBold{}},
},
markdown: strings.Repeat("😀", 40) + "**end**",
ansi: strings.Repeat("😀", 40) + "\033[1mend\033[22m",
},
{
name: "formatted_emoji",
text: "A😀B",
entities: []*telegram.TextEntity{
{Offset: 1, Length: 2, Type: &telegram.TextEntityTypeBold{}},
},
markdown: "A**😀**B",
ansi: "A\033[1m😀\033[22mB",
},
{
name: "supplementary_CJK",
text: "𠀀汉字",
entities: []*telegram.TextEntity{
{Offset: 2, Length: 2, Type: &telegram.TextEntityTypeItalic{}},
},
markdown: "𠀀*汉字*",
ansi: "𠀀\033[3m汉字\033[23m",
},
{
name: "link_after_emoji",
text: "🚀详情",
entities: []*telegram.TextEntity{
{Offset: 2, Length: 2, Type: &telegram.TextEntityTypeTextURL{URL: "https://example.com"}},
},
markdown: "🚀[详情](https://example.com)",
ansi: "🚀详情",
},
{
name: "unsorted_entities",
text: "😀abc",
entities: []*telegram.TextEntity{
{Offset: 4, Length: 1, Type: &telegram.TextEntityTypeItalic{}},
{Offset: 2, Length: 1, Type: &telegram.TextEntityTypeBold{}},
},
markdown: "😀**a**b*c*",
ansi: "😀\033[1ma\033[22mb\033[3mc\033[23m",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
text := &telegram.FormattedText{Text: tt.text, Entities: tt.entities}
t.Run("markdown", func(t *testing.T) {
if got := EntitiesToMarkdown(text); got != tt.markdown {
t.Fatalf("got %q, want %q", got, tt.markdown)
}
})
t.Run("ANSI", func(t *testing.T) {
if got := EntitiesToANSI(text); got != tt.ansi {
t.Fatalf("got %q, want %q", got, tt.ansi)
}
})
})
}
}

func TestEntityRenderersInvalidRanges(t *testing.T) {
tests := []struct {
name string
entity *telegram.TextEntity
}{
{name: "nil_entity"},
{name: "negative_offset", entity: &telegram.TextEntity{Offset: -1, Length: 1}},
{name: "negative_length", entity: &telegram.TextEntity{Offset: 2, Length: -1}},
{name: "zero_length", entity: &telegram.TextEntity{Offset: 2}},
{name: "offset_past_end", entity: &telegram.TextEntity{Offset: 20, Length: 1}},
{name: "end_past_end", entity: &telegram.TextEntity{Offset: 3, Length: 20}},
{name: "int32_overflow", entity: &telegram.TextEntity{Offset: 2, Length: 2147483647}},
{name: "split_surrogate_start", entity: &telegram.TextEntity{Offset: 1, Length: 1}},
{name: "split_surrogate_end", entity: &telegram.TextEntity{Offset: 0, Length: 1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.entity != nil {
tt.entity.Type = &telegram.TextEntityTypeBold{}
}
text := &telegram.FormattedText{
Text: "😀abc",
Entities: []*telegram.TextEntity{
tt.entity,
{Offset: 3, Length: 1, Type: &telegram.TextEntityTypeBold{}},
},
}
if got, want := EntitiesToMarkdown(text), "😀a**b**c"; got != want {
t.Errorf("markdown: got %q, want %q", got, want)
}
if got, want := EntitiesToANSI(text), "😀a\033[1mb\033[22mc"; got != want {
t.Errorf("ANSI: got %q, want %q", got, want)
}
})
}
}

func TestEntityRenderersOverlappingRanges(t *testing.T) {
text := &telegram.FormattedText{
Text: "😀abc!",
Entities: []*telegram.TextEntity{
{Offset: 2, Length: 1, Type: &telegram.TextEntityTypeItalic{}},
{Offset: 2, Length: 3, Type: &telegram.TextEntityTypeBold{}},
{Offset: 3, Length: 3, Type: &telegram.TextEntityTypeItalic{}},
},
}
// The flat renderer keeps the outer formatting and emits overlapping text once.
if got, want := EntitiesToMarkdown(text), "😀**abc**!"; got != want {
t.Errorf("markdown: got %q, want %q", got, want)
}
if got, want := EntitiesToANSI(text), "😀\033[1mabc\033[22m!"; got != want {
t.Errorf("ANSI: got %q, want %q", got, want)
}
}

func FuzzEntityRenderersPreserveText(f *testing.F) {
f.Add("😀你好世界", int32(4), int32(2), int32(2), int32(1))
f.Add("😀abc", int32(2), int32(2147483647), int32(-1), int32(1))
f.Add("", int32(0), int32(0), int32(0), int32(1))
f.Fuzz(func(t *testing.T, value string, offset, length, otherOffset, otherLength int32) {
if !utf8.ValidString(value) {
t.Skip()
}
// Unknown entity types should preserve the body regardless of ranges.
text := &telegram.FormattedText{
Text: value,
Entities: []*telegram.TextEntity{
{Offset: offset, Length: length},
nil,
{Offset: otherOffset, Length: otherLength},
},
}
if got := EntitiesToMarkdown(text); got != value {
t.Fatalf("markdown changed body: got %q, want %q", got, value)
}
if got := EntitiesToANSI(text); got != value {
t.Fatalf("ANSI changed body: got %q, want %q", got, value)
}
})
}
56 changes: 56 additions & 0 deletions internal/render/entity_ranges.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package render

import (
"sort"

"github.com/imtaqin/telegram-cli/internal/telegram"
)

type entitySpan struct {
entity *telegram.TextEntity
start, end int
}

// entitySpans converts Telegram's UTF-16 offsets to rune indexes without
// modifying the original entities. Invalid ranges are left as plain text.
func entitySpans(runes []rune, entities []*telegram.TextEntity) []entitySpan {
utf16ToRune := make([]int, 1, len(runes)+1)
for i, r := range runes {
if r > 0xFFFF {
// An entity boundary cannot split a UTF-16 surrogate pair.
utf16ToRune = append(utf16ToRune, -1)
}
utf16ToRune = append(utf16ToRune, i+1)
}

spans := make([]entitySpan, 0, len(entities))
for _, entity := range entities {
if entity == nil {
continue
}
// Widen before adding: malformed int32 offsets can otherwise overflow.
start, length := int64(entity.Offset), int64(entity.Length)
end := start + length
if start < 0 || length <= 0 || end >= int64(len(utf16ToRune)) {
continue
}
startRune, endRune := utf16ToRune[start], utf16ToRune[end]
if startRune < 0 || endRune < 0 {
continue
}
spans = append(spans, entitySpan{
entity: entity,
start: startRune,
end: endRune,
})
}

sort.SliceStable(spans, func(i, j int) bool {
if spans[i].start != spans[j].start {
return spans[i].start < spans[j].start
}
// Prefer the enclosing entity when the flat renderer sees nesting.
return spans[i].end > spans[j].end
})
return spans
}
Loading