Skip to content

Commit 90ac468

Browse files
ptoneScion Agent (mi-dev-b)
andauthored
fix(telegram): backtick code spans silently dropped from message content (#518)
* fix(telegram): restore backtick code spans from Telegram entities Telegram parses backtick-delimited code spans and code blocks from message text and stores the formatting as entities (type "code"/"pre"), stripping the backticks from the text field. The broker was ignoring these entities, silently dropping code formatting from inbound messages. Extend resolveUserMentions to reconstruct backtick wrapping from "code" and "pre" entities so agents receive the original formatting. Also fix stripMentions to preserve newlines (previously collapsed all whitespace via strings.Fields), which is needed for multi-line code blocks. Closes #285 * fix(telegram): preserve whitespace in stripMentions for code blocks Replace strings.Fields-based word processing with regex-based approach that finds and strips target mentions without collapsing whitespace or destroying indentation in code blocks and inline code spans. --------- Co-authored-by: Scion Agent (mi-dev-b) <agent@scion.dev>
1 parent 070b849 commit 90ac468

5 files changed

Lines changed: 409 additions & 17 deletions

File tree

extras/scion-telegram/internal/telegram/api.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,11 @@ type TGMessage struct {
102102

103103
// MessageEntity represents a special entity in a Telegram message (e.g. @mentions, commands).
104104
type MessageEntity struct {
105-
Type string `json:"type"`
106-
Offset int `json:"offset"`
107-
Length int `json:"length"`
108-
User *TGUser `json:"user,omitempty"`
105+
Type string `json:"type"`
106+
Offset int `json:"offset"`
107+
Length int `json:"length"`
108+
User *TGUser `json:"user,omitempty"`
109+
Language string `json:"language,omitempty"`
109110
}
110111

111112
// TGUser represents a Telegram user.

extras/scion-telegram/internal/telegram/broker_v2.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1958,7 +1958,9 @@ func (b *TelegramBrokerV2) downloadTelegramFile(ctx context.Context, tgMsg *TGMe
19581958
// resolveUserMentions extracts @username and text_mention entities from a
19591959
// Telegram message, looks each up in the user_mappings store, and returns:
19601960
// - modifiedText: the message text with text_mention display names (offset>0)
1961-
// replaced by "user:email" for registered users
1961+
// replaced by "user:email" for registered users, and "code"/"pre" entity
1962+
// spans re-wrapped in backticks (Telegram strips backticks from the text
1963+
// field and stores the formatting as entities)
19621964
// - resolvedJSON: a JSON string mapping display names → "user:email" for all
19631965
// resolved mentions (empty string if none resolve)
19641966
func (b *TelegramBrokerV2) resolveUserMentions(ctx context.Context, tgMsg *TGMessage) (string, string) {
@@ -2036,6 +2038,30 @@ func (b *TelegramBrokerV2) resolveUserMentions(ctx context.Context, tgMsg *TGMes
20362038
replacement: scionIdentity,
20372039
})
20382040
}
2041+
case "code":
2042+
span, ok := utf16Extract(tgMsg.Text, ent.Offset, ent.Length)
2043+
if !ok {
2044+
continue
2045+
}
2046+
replacements = append(replacements, textReplacement{
2047+
offset: ent.Offset,
2048+
length: ent.Length,
2049+
replacement: "`" + span + "`",
2050+
})
2051+
case "pre":
2052+
span, ok := utf16Extract(tgMsg.Text, ent.Offset, ent.Length)
2053+
if !ok {
2054+
continue
2055+
}
2056+
prefix := "```"
2057+
if ent.Language != "" {
2058+
prefix += ent.Language
2059+
}
2060+
replacements = append(replacements, textReplacement{
2061+
offset: ent.Offset,
2062+
length: ent.Length,
2063+
replacement: prefix + "\n" + span + "\n```",
2064+
})
20392065
}
20402066
}
20412067

extras/scion-telegram/internal/telegram/broker_v2_test.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2314,3 +2314,295 @@ func TestV2_DownloadTelegramFile_DownloadFailure(t *testing.T) {
23142314
require.Error(t, err)
23152315
assert.Contains(t, err.Error(), "download file")
23162316
}
2317+
2318+
// --- Code span restoration tests ---
2319+
2320+
func TestV2_HandleGroupMessage_CodeSpanPreserved(t *testing.T) {
2321+
tgSrv := newFakeTGServerV2(t)
2322+
hub := newFakeHubClient()
2323+
hub.agents["proj-1"] = []AgentInfo{{Slug: "coder"}}
2324+
b := newTestBrokerV2WithHub(t, tgSrv, hub)
2325+
2326+
ctx := context.Background()
2327+
require.NoError(t, b.store.SaveUserMapping(ctx, &TelegramUserMapping{
2328+
TelegramUserID: "456",
2329+
ScionEmail: "alice@example.com",
2330+
LinkedAt: time.Now().UTC(),
2331+
}))
2332+
require.NoError(t, b.store.SaveGroupLink(ctx, &GroupLink{
2333+
ChatID: -200,
2334+
ProjectID: "proj-1",
2335+
ProjectSlug: "my-project",
2336+
DefaultAgent: "coder",
2337+
LinkedAt: time.Now().UTC(),
2338+
Active: true,
2339+
}))
2340+
require.NoError(t, b.store.SaveProjectAgents(ctx, &ProjectAgents{
2341+
ProjectID: "proj-1",
2342+
Agents: []AgentInfo{{Slug: "coder"}},
2343+
RefreshedAt: time.Now(),
2344+
}))
2345+
2346+
var deliveredMsg *messages.StructuredMessage
2347+
done := make(chan struct{}, 1)
2348+
b.InboundHandler = func(_ string, msg *messages.StructuredMessage) {
2349+
deliveredMsg = msg
2350+
select {
2351+
case done <- struct{}{}:
2352+
default:
2353+
}
2354+
}
2355+
2356+
// Simulate: user typed "check the `config` file" in Telegram.
2357+
// Telegram strips backticks and puts formatting in entities.
2358+
b.handleGroupMessage(&TGMessage{
2359+
MessageID: 42,
2360+
From: &TGUser{ID: 456, Username: "alice"},
2361+
Chat: TGChat{ID: -200, Type: "group"},
2362+
Date: time.Now().Unix(),
2363+
Text: "@coder check the config file",
2364+
Entities: []MessageEntity{
2365+
{Type: "mention", Offset: 0, Length: 6},
2366+
{Type: "code", Offset: 17, Length: 6},
2367+
},
2368+
})
2369+
2370+
select {
2371+
case <-done:
2372+
case <-time.After(2 * time.Second):
2373+
t.Fatal("timed out waiting for delivery")
2374+
}
2375+
2376+
assert.Equal(t, "check the `config` file", deliveredMsg.Msg)
2377+
}
2378+
2379+
func TestV2_HandleGroupMessage_MultipleCodeSpans(t *testing.T) {
2380+
tgSrv := newFakeTGServerV2(t)
2381+
hub := newFakeHubClient()
2382+
hub.agents["proj-1"] = []AgentInfo{{Slug: "coder"}}
2383+
b := newTestBrokerV2WithHub(t, tgSrv, hub)
2384+
2385+
ctx := context.Background()
2386+
require.NoError(t, b.store.SaveUserMapping(ctx, &TelegramUserMapping{
2387+
TelegramUserID: "456",
2388+
ScionEmail: "alice@example.com",
2389+
LinkedAt: time.Now().UTC(),
2390+
}))
2391+
require.NoError(t, b.store.SaveGroupLink(ctx, &GroupLink{
2392+
ChatID: -200,
2393+
ProjectID: "proj-1",
2394+
ProjectSlug: "my-project",
2395+
DefaultAgent: "coder",
2396+
LinkedAt: time.Now().UTC(),
2397+
Active: true,
2398+
}))
2399+
require.NoError(t, b.store.SaveProjectAgents(ctx, &ProjectAgents{
2400+
ProjectID: "proj-1",
2401+
Agents: []AgentInfo{{Slug: "coder"}},
2402+
RefreshedAt: time.Now(),
2403+
}))
2404+
2405+
var deliveredMsg *messages.StructuredMessage
2406+
done := make(chan struct{}, 1)
2407+
b.InboundHandler = func(_ string, msg *messages.StructuredMessage) {
2408+
deliveredMsg = msg
2409+
select {
2410+
case done <- struct{}{}:
2411+
default:
2412+
}
2413+
}
2414+
2415+
// Simulate: "run foo and bar" where foo and bar were in backticks.
2416+
b.handleGroupMessage(&TGMessage{
2417+
MessageID: 43,
2418+
From: &TGUser{ID: 456, Username: "alice"},
2419+
Chat: TGChat{ID: -200, Type: "group"},
2420+
Date: time.Now().Unix(),
2421+
Text: "@coder run foo and bar",
2422+
Entities: []MessageEntity{
2423+
{Type: "mention", Offset: 0, Length: 6},
2424+
{Type: "code", Offset: 11, Length: 3},
2425+
{Type: "code", Offset: 19, Length: 3},
2426+
},
2427+
})
2428+
2429+
select {
2430+
case <-done:
2431+
case <-time.After(2 * time.Second):
2432+
t.Fatal("timed out waiting for delivery")
2433+
}
2434+
2435+
assert.Equal(t, "run `foo` and `bar`", deliveredMsg.Msg)
2436+
}
2437+
2438+
func TestV2_HandleGroupMessage_PreBlockPreserved(t *testing.T) {
2439+
tgSrv := newFakeTGServerV2(t)
2440+
hub := newFakeHubClient()
2441+
hub.agents["proj-1"] = []AgentInfo{{Slug: "coder"}}
2442+
b := newTestBrokerV2WithHub(t, tgSrv, hub)
2443+
2444+
ctx := context.Background()
2445+
require.NoError(t, b.store.SaveUserMapping(ctx, &TelegramUserMapping{
2446+
TelegramUserID: "456",
2447+
ScionEmail: "alice@example.com",
2448+
LinkedAt: time.Now().UTC(),
2449+
}))
2450+
require.NoError(t, b.store.SaveGroupLink(ctx, &GroupLink{
2451+
ChatID: -200,
2452+
ProjectID: "proj-1",
2453+
ProjectSlug: "my-project",
2454+
DefaultAgent: "coder",
2455+
LinkedAt: time.Now().UTC(),
2456+
Active: true,
2457+
}))
2458+
require.NoError(t, b.store.SaveProjectAgents(ctx, &ProjectAgents{
2459+
ProjectID: "proj-1",
2460+
Agents: []AgentInfo{{Slug: "coder"}},
2461+
RefreshedAt: time.Now(),
2462+
}))
2463+
2464+
var deliveredMsg *messages.StructuredMessage
2465+
done := make(chan struct{}, 1)
2466+
b.InboundHandler = func(_ string, msg *messages.StructuredMessage) {
2467+
deliveredMsg = msg
2468+
select {
2469+
case done <- struct{}{}:
2470+
default:
2471+
}
2472+
}
2473+
2474+
// Simulate: user sent a code block (triple backticks) in Telegram.
2475+
b.handleGroupMessage(&TGMessage{
2476+
MessageID: 44,
2477+
From: &TGUser{ID: 456, Username: "alice"},
2478+
Chat: TGChat{ID: -200, Type: "group"},
2479+
Date: time.Now().Unix(),
2480+
Text: "@coder apply this:\nfmt.Println(\"hello\")",
2481+
Entities: []MessageEntity{
2482+
{Type: "mention", Offset: 0, Length: 6},
2483+
{Type: "pre", Offset: 19, Length: 20},
2484+
},
2485+
})
2486+
2487+
select {
2488+
case <-done:
2489+
case <-time.After(2 * time.Second):
2490+
t.Fatal("timed out waiting for delivery")
2491+
}
2492+
2493+
assert.Equal(t, "apply this:\n```\nfmt.Println(\"hello\")\n```", deliveredMsg.Msg)
2494+
}
2495+
2496+
func TestV2_HandleGroupMessage_PreBlockWithLanguage(t *testing.T) {
2497+
tgSrv := newFakeTGServerV2(t)
2498+
hub := newFakeHubClient()
2499+
hub.agents["proj-1"] = []AgentInfo{{Slug: "coder"}}
2500+
b := newTestBrokerV2WithHub(t, tgSrv, hub)
2501+
2502+
ctx := context.Background()
2503+
require.NoError(t, b.store.SaveUserMapping(ctx, &TelegramUserMapping{
2504+
TelegramUserID: "456",
2505+
ScionEmail: "alice@example.com",
2506+
LinkedAt: time.Now().UTC(),
2507+
}))
2508+
require.NoError(t, b.store.SaveGroupLink(ctx, &GroupLink{
2509+
ChatID: -200,
2510+
ProjectID: "proj-1",
2511+
ProjectSlug: "my-project",
2512+
DefaultAgent: "coder",
2513+
LinkedAt: time.Now().UTC(),
2514+
Active: true,
2515+
}))
2516+
require.NoError(t, b.store.SaveProjectAgents(ctx, &ProjectAgents{
2517+
ProjectID: "proj-1",
2518+
Agents: []AgentInfo{{Slug: "coder"}},
2519+
RefreshedAt: time.Now(),
2520+
}))
2521+
2522+
var deliveredMsg *messages.StructuredMessage
2523+
done := make(chan struct{}, 1)
2524+
b.InboundHandler = func(_ string, msg *messages.StructuredMessage) {
2525+
deliveredMsg = msg
2526+
select {
2527+
case done <- struct{}{}:
2528+
default:
2529+
}
2530+
}
2531+
2532+
// Simulate: user sent ```go\nfmt.Println("hello")\n``` in Telegram.
2533+
b.handleGroupMessage(&TGMessage{
2534+
MessageID: 45,
2535+
From: &TGUser{ID: 456, Username: "alice"},
2536+
Chat: TGChat{ID: -200, Type: "group"},
2537+
Date: time.Now().Unix(),
2538+
Text: "fmt.Println(\"hello\")",
2539+
Entities: []MessageEntity{
2540+
{Type: "pre", Offset: 0, Length: 20, Language: "go"},
2541+
},
2542+
})
2543+
2544+
select {
2545+
case <-done:
2546+
case <-time.After(2 * time.Second):
2547+
t.Fatal("timed out waiting for delivery")
2548+
}
2549+
2550+
assert.Equal(t, "```go\nfmt.Println(\"hello\")\n```", deliveredMsg.Msg)
2551+
}
2552+
2553+
func TestV2_HandleGroupMessage_CodeSpanWithDefaultAgent(t *testing.T) {
2554+
tgSrv := newFakeTGServerV2(t)
2555+
hub := newFakeHubClient()
2556+
hub.agents["proj-1"] = []AgentInfo{{Slug: "coder"}}
2557+
b := newTestBrokerV2WithHub(t, tgSrv, hub)
2558+
2559+
ctx := context.Background()
2560+
require.NoError(t, b.store.SaveUserMapping(ctx, &TelegramUserMapping{
2561+
TelegramUserID: "456",
2562+
ScionEmail: "alice@example.com",
2563+
LinkedAt: time.Now().UTC(),
2564+
}))
2565+
require.NoError(t, b.store.SaveGroupLink(ctx, &GroupLink{
2566+
ChatID: -200,
2567+
ProjectID: "proj-1",
2568+
ProjectSlug: "my-project",
2569+
DefaultAgent: "coder",
2570+
LinkedAt: time.Now().UTC(),
2571+
Active: true,
2572+
}))
2573+
require.NoError(t, b.store.SaveProjectAgents(ctx, &ProjectAgents{
2574+
ProjectID: "proj-1",
2575+
Agents: []AgentInfo{{Slug: "coder"}},
2576+
RefreshedAt: time.Now(),
2577+
}))
2578+
2579+
var deliveredMsg *messages.StructuredMessage
2580+
done := make(chan struct{}, 1)
2581+
b.InboundHandler = func(_ string, msg *messages.StructuredMessage) {
2582+
deliveredMsg = msg
2583+
select {
2584+
case done <- struct{}{}:
2585+
default:
2586+
}
2587+
}
2588+
2589+
// No mention — routes to default agent. Code span should be preserved.
2590+
b.handleGroupMessage(&TGMessage{
2591+
MessageID: 46,
2592+
From: &TGUser{ID: 456, Username: "alice"},
2593+
Chat: TGChat{ID: -200, Type: "group"},
2594+
Date: time.Now().Unix(),
2595+
Text: "check the config file",
2596+
Entities: []MessageEntity{
2597+
{Type: "code", Offset: 10, Length: 6},
2598+
},
2599+
})
2600+
2601+
select {
2602+
case <-done:
2603+
case <-time.After(2 * time.Second):
2604+
t.Fatal("timed out waiting for delivery")
2605+
}
2606+
2607+
assert.Equal(t, "check the `config` file", deliveredMsg.Msg)
2608+
}

0 commit comments

Comments
 (0)