Skip to content

Commit 5d65de6

Browse files
committed
feat: add send_message tool for Telegram — messages, files, and keyboards
The agent can now send intermediate messages mid-task instead of being limited to a single final answer. This replaces the MEDIA: prefix hack with a proper tool. send_message tool: - text: message body (MarkdownV2) - file: absolute path to attachment (photo/document/voice auto-detected) - buttons: inline keyboard rows with cb: prefix convention Wired only in Telegram mode. Non-blocking — returns immediately, doesn't wait for user response (use clarify for interactive prompts). 13 tests covering text-only, file attachment, button rendering, callback_data prefix normalization, error paths, and schema validation. Updated runtime.go system prompt to reflect actual tool availability.
1 parent a05ed4f commit 5d65de6

4 files changed

Lines changed: 486 additions & 2 deletions

File tree

cmd/odek/telegram.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,31 @@ func handleChatMessage(
10721072
}
10731073
}))
10741074

1075+
// ── Send Message Tool ──────────────────────────────────────────
1076+
// Wire the send_message tool so the agent can send intermediate
1077+
// messages, files, and interactive keyboards mid-task — not just
1078+
// at the final answer.
1079+
agentTools = append(agentTools, toolpkg.NewSendMessageTool(
1080+
func(text string, file string, buttons [][]map[string]string) error {
1081+
if file != "" {
1082+
// Detect media type from extension.
1083+
mediaType := mediaTypeFromExt(file)
1084+
return sendTelegramMedia(bot, chatID, mediaType, file, text, buttons)
1085+
}
1086+
if len(buttons) > 0 {
1087+
markup := buttonsToMarkup(buttons)
1088+
_, err := bot.SendMessage(chatID, text, &telegram.SendOpts{
1089+
ParseMode: telegram.ParseModeMarkdownV2,
1090+
ReplyMarkup: markup,
1091+
})
1092+
return err
1093+
}
1094+
_, err := bot.SendMessage(chatID, text, &telegram.SendOpts{
1095+
ParseMode: telegram.ParseModeMarkdownV2,
1096+
})
1097+
return err
1098+
}))
1099+
10751100
// Resolve skills config (same logic as main.go run command).
10761101
var skillsCfg *skills.SkillsConfig
10771102
if resolved.Skills.Learn {
@@ -1567,3 +1592,60 @@ func acquireLock() (*instanceLock, error) {
15671592
func (l *instanceLock) release() {
15681593
os.Remove(l.pidFile)
15691594
}
1595+
1596+
// ── send_message helpers ──────────────────────────────────────────────
1597+
1598+
// mediaTypeFromExt returns the Telegram media type for a file extension.
1599+
func mediaTypeFromExt(path string) string {
1600+
ext := strings.ToLower(filepath.Ext(path))
1601+
switch ext {
1602+
case ".png", ".jpg", ".jpeg", ".webp", ".gif":
1603+
return "photo"
1604+
case ".ogg", ".mp3", ".wav", ".opus":
1605+
return "voice"
1606+
default:
1607+
return "document"
1608+
}
1609+
}
1610+
1611+
// sendTelegramMedia sends a file as a Telegram media message with caption
1612+
// and optional inline keyboard. Detects the media type from file extension.
1613+
func sendTelegramMedia(bot *telegram.Bot, chatID int64, mediaType, path, caption string, buttons [][]map[string]string) error {
1614+
var replyMarkup *telegram.InlineKeyboardMarkup
1615+
if len(buttons) > 0 {
1616+
replyMarkup = buttonsToMarkup(buttons)
1617+
}
1618+
opts := &telegram.SendOpts{
1619+
ParseMode: telegram.ParseModeMarkdownV2,
1620+
ReplyMarkup: replyMarkup,
1621+
}
1622+
switch mediaType {
1623+
case "photo":
1624+
_, err := bot.SendPhoto(chatID, path, caption, opts)
1625+
return err
1626+
case "voice":
1627+
_, err := bot.SendVoice(chatID, path, caption, opts)
1628+
return err
1629+
default:
1630+
_, err := bot.SendDocument(chatID, path, caption, opts)
1631+
return err
1632+
}
1633+
}
1634+
1635+
// buttonsToMarkup converts the tool's button format to Telegram's
1636+
// InlineKeyboardMarkup type.
1637+
func buttonsToMarkup(buttons [][]map[string]string) *telegram.InlineKeyboardMarkup {
1638+
markup := &telegram.InlineKeyboardMarkup{
1639+
InlineKeyboard: make([][]telegram.InlineKeyboardButton, len(buttons)),
1640+
}
1641+
for i, row := range buttons {
1642+
markup.InlineKeyboard[i] = make([]telegram.InlineKeyboardButton, len(row))
1643+
for j, btn := range row {
1644+
markup.InlineKeyboard[i][j] = telegram.InlineKeyboardButton{
1645+
Text: btn["text"],
1646+
CallbackData: btn["callback_data"],
1647+
}
1648+
}
1649+
}
1650+
return markup
1651+
}

internal/tool/send_message.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Package tool provides the send_message tool — send intermediate messages,
2+
// files, or interactive prompts to the Telegram chat during an agent run.
3+
package tool
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
// ── Types ──────────────────────────────────────────────────────────────
14+
15+
// SendMessageTool lets the agent send arbitrary messages to the Telegram
16+
// chat with optional inline keyboards and file attachments. Unlike the
17+
// final answer (which flows through the handler's SendResponse), this tool
18+
// sends messages immediately mid-task.
19+
type SendMessageTool struct {
20+
// Sender delivers the message. Called synchronously — returns nil on
21+
// success or an error if delivery failed. The tool does NOT wait for
22+
// user response; use clarify for interactive prompts.
23+
Sender func(text string, file string, buttons [][]map[string]string) error
24+
}
25+
26+
// sendMessageArgs is the JSON schema for send_message tool arguments.
27+
type sendMessageArgs struct {
28+
Text string `json:"text"`
29+
File string `json:"file,omitempty"`
30+
Buttons [][]sendMessageButtonArg `json:"buttons,omitempty"`
31+
}
32+
33+
type sendMessageButtonArg struct {
34+
Text string `json:"text"`
35+
CallbackData string `json:"callback_data"`
36+
}
37+
38+
// ── Tool interface ─────────────────────────────────────────────────────
39+
40+
func (t *SendMessageTool) Name() string { return "send_message" }
41+
42+
func (t *SendMessageTool) Description() string {
43+
return `Send an intermediate message, file, or interactive prompt to the Telegram chat.
44+
Use this for:
45+
- Multi-step progress updates (before the final answer)
46+
- Sending generated files (images, CSVs, zip archives)
47+
- Asking questions with custom inline keyboard buttons
48+
- Showing notifications or warnings mid-task
49+
50+
For simple Yes/No questions, prefer the clarify tool.
51+
For final answers, just return the text — no need to use send_message.
52+
53+
File type is auto-detected from extension:
54+
.png/.jpg/.webp → photo .ogg/.mp3 → voice everything else → document`
55+
}
56+
57+
func (t *SendMessageTool) Schema() any {
58+
return map[string]any{
59+
"type": "object",
60+
"properties": map[string]any{
61+
"text": map[string]any{
62+
"type": "string",
63+
"description": "Message text (MarkdownV2). Can be empty if only sending a file.",
64+
},
65+
"file": map[string]any{
66+
"type": "string",
67+
"description": "Absolute path to a file to send as attachment (photo/document/voice/zip).",
68+
},
69+
"buttons": map[string]any{
70+
"type": "array",
71+
"items": map[string]any{
72+
"type": "array",
73+
"items": map[string]any{
74+
"type": "object",
75+
"properties": map[string]any{
76+
"text": map[string]any{
77+
"type": "string",
78+
"description": "Button label shown to the user.",
79+
},
80+
"callback_data": map[string]any{
81+
"type": "string",
82+
"description": "Callback data sent when user clicks. Must start with 'cb:' for agent-routed callbacks.",
83+
},
84+
},
85+
"required": []string{"text", "callback_data"},
86+
},
87+
},
88+
"description": "Inline keyboard button rows. Each inner array is one row of buttons.",
89+
},
90+
},
91+
"required": []string{"text"},
92+
}
93+
}
94+
95+
// Call sends the message via the injected Sender function.
96+
func (t *SendMessageTool) Call(argsJSON string) (string, error) {
97+
if t.Sender == nil {
98+
return "", fmt.Errorf("send_message: Sender not configured (platform must wire it)")
99+
}
100+
101+
var args sendMessageArgs
102+
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
103+
return "", fmt.Errorf("send_message: parse args: %w", err)
104+
}
105+
106+
// Validate file path if provided.
107+
if args.File != "" {
108+
if !filepath.IsAbs(args.File) {
109+
return "", fmt.Errorf("send_message: file path must be absolute: %s", args.File)
110+
}
111+
if _, err := os.Stat(args.File); err != nil {
112+
return "", fmt.Errorf("send_message: file not found: %s: %w", args.File, err)
113+
}
114+
}
115+
116+
// Normalise buttons to the expected format.
117+
buttons := make([][]map[string]string, len(args.Buttons))
118+
for i, row := range args.Buttons {
119+
buttons[i] = make([]map[string]string, len(row))
120+
for j, btn := range row {
121+
// Validate callback_data prefix convention.
122+
cd := btn.CallbackData
123+
if !strings.HasPrefix(cd, "cb:") && !strings.HasPrefix(cd, "apr:") && !strings.HasPrefix(cd, "den:") && !strings.HasPrefix(cd, "trs:") {
124+
cd = "cb:" + cd
125+
}
126+
buttons[i][j] = map[string]string{
127+
"text": btn.Text,
128+
"callback_data": cd,
129+
}
130+
}
131+
}
132+
133+
if err := t.Sender(args.Text, args.File, buttons); err != nil {
134+
return "", fmt.Errorf("send_message: %w", err)
135+
}
136+
137+
// Build a short confirmation.
138+
parts := []string{}
139+
if args.File != "" {
140+
parts = append(parts, "file sent")
141+
}
142+
if len(args.Buttons) > 0 {
143+
parts = append(parts, "buttons sent")
144+
}
145+
if len(parts) == 0 {
146+
parts = append(parts, "message sent")
147+
}
148+
return "✅ " + strings.Join(parts, ", "), nil
149+
}
150+
151+
// ── Convenience ────────────────────────────────────────────────────────
152+
153+
// NewSendMessageTool creates a SendMessageTool with the given sender function.
154+
func NewSendMessageTool(sender func(text string, file string, buttons [][]map[string]string) error) *SendMessageTool {
155+
return &SendMessageTool{Sender: sender}
156+
}

0 commit comments

Comments
 (0)