Add Telegram file upload directive
This commit is contained in:
24
.codex/skills/telegram-file/SKILL.md
Normal file
24
.codex/skills/telegram-file/SKILL.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: telegram-file
|
||||||
|
description: Use when Codex should send, show, upload, or share a local non-image file into the Telegram chat through the bot without calling Telegram tools.
|
||||||
|
metadata:
|
||||||
|
short-description: Send Telegram files from assistant output
|
||||||
|
---
|
||||||
|
|
||||||
|
# Telegram File
|
||||||
|
|
||||||
|
When asked to send/show/upload/share a local file in Telegram, emit a file directive in normal assistant output. The bot strips the directive and sends the file as a Telegram document.
|
||||||
|
|
||||||
|
Use exactly one directive line per file, outside code fences:
|
||||||
|
|
||||||
|
`<!-- telegram-file {"path":"<absolute-local-file-path>","caption":"<optional caption>"} -->`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Replace `<absolute-local-file-path>` with an absolute path that the bot container can read.
|
||||||
|
- The source file may live anywhere on the host, but the bot runs in Docker; before emitting the directive, make sure the file is inside a workspace under the configured playground base directory or another path explicitly mounted into the bot container.
|
||||||
|
- If the file is outside container-visible paths, copy it into an appropriate workspace-local location first, then use the copied file's absolute path in the directive.
|
||||||
|
- Do not hardcode machine-specific directories, user names, repository paths, workspace names, or sample filenames in this skill.
|
||||||
|
- Use `telegram-photo` instead for image files that should appear as Telegram photos rather than generic documents.
|
||||||
|
- `caption` is optional and should be short; omit the `caption` field when no caption is needed.
|
||||||
|
- Do not use external Telegram tool calls for this.
|
||||||
|
- If no usable container-visible file path is known, ask for the path or explain what local file is needed.
|
||||||
@@ -26,11 +26,12 @@ const (
|
|||||||
resumeThreadPageSize = 8
|
resumeThreadPageSize = 8
|
||||||
assistantStreamEditInterval = 1200 * time.Millisecond
|
assistantStreamEditInterval = 1200 * time.Millisecond
|
||||||
assistantStreamInitialRunes = 24
|
assistantStreamInitialRunes = 24
|
||||||
|
telegramFileDirectiveStart = "<!-- telegram-file "
|
||||||
telegramPhotoDirectiveStart = "<!-- telegram-photo "
|
telegramPhotoDirectiveStart = "<!-- telegram-photo "
|
||||||
telegramThreadRenameDirectiveStart = "<!-- codex-thread-rename "
|
telegramThreadRenameDirectiveStart = "<!-- codex-thread-rename "
|
||||||
telegramThreadCWDDirectiveStart = "<!-- codex-thread-cwd "
|
telegramThreadCWDDirectiveStart = "<!-- codex-thread-cwd "
|
||||||
telegramDirectiveEnd = " -->"
|
telegramDirectiveEnd = " -->"
|
||||||
telegramPhotoCaptionLimit = 1024
|
telegramCaptionLimit = 1024
|
||||||
pictureMediaGroupLimit = 10
|
pictureMediaGroupLimit = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,11 +55,17 @@ type Bot struct {
|
|||||||
|
|
||||||
type assistantMessageSegment struct {
|
type assistantMessageSegment struct {
|
||||||
Text string
|
Text string
|
||||||
|
File *assistantFileDirective
|
||||||
Photo *assistantPhotoDirective
|
Photo *assistantPhotoDirective
|
||||||
ThreadRename *assistantThreadRenameDirective
|
ThreadRename *assistantThreadRenameDirective
|
||||||
ThreadCWD *assistantThreadCWDDirective
|
ThreadCWD *assistantThreadCWDDirective
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type assistantFileDirective struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Caption string `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type assistantPhotoDirective struct {
|
type assistantPhotoDirective struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Caption string `json:"caption,omitempty"`
|
Caption string `json:"caption,omitempty"`
|
||||||
@@ -2580,6 +2587,11 @@ func splitAssistantMessageSegments(text string) []assistantMessageSegment {
|
|||||||
for _, line := range strings.SplitAfter(text, "\n") {
|
for _, line := range strings.SplitAfter(text, "\n") {
|
||||||
body := strings.TrimSuffix(line, "\n")
|
body := strings.TrimSuffix(line, "\n")
|
||||||
body = strings.TrimSuffix(body, "\r")
|
body = strings.TrimSuffix(body, "\r")
|
||||||
|
if directive, ok := parseAssistantFileDirectiveLine(body); ok {
|
||||||
|
flushVisible()
|
||||||
|
segments = append(segments, assistantMessageSegment{File: &directive})
|
||||||
|
continue
|
||||||
|
}
|
||||||
if directive, ok := parseAssistantPhotoDirectiveLine(body); ok {
|
if directive, ok := parseAssistantPhotoDirectiveLine(body); ok {
|
||||||
flushVisible()
|
flushVisible()
|
||||||
segments = append(segments, assistantMessageSegment{Photo: &directive})
|
segments = append(segments, assistantMessageSegment{Photo: &directive})
|
||||||
@@ -2631,6 +2643,22 @@ func parseAssistantThreadCWDDirectiveLine(line string) (assistantThreadCWDDirect
|
|||||||
return directive, true
|
return directive, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseAssistantFileDirectiveLine(line string) (assistantFileDirective, bool) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(trimmed, telegramFileDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
|
||||||
|
return assistantFileDirective{}, false
|
||||||
|
}
|
||||||
|
raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramFileDirectiveStart), telegramDirectiveEnd)
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
var directive assistantFileDirective
|
||||||
|
if err := json.Unmarshal([]byte(raw), &directive); err != nil {
|
||||||
|
return assistantFileDirective{}, false
|
||||||
|
}
|
||||||
|
directive.Path = strings.TrimSpace(directive.Path)
|
||||||
|
directive.Caption = strings.TrimSpace(directive.Caption)
|
||||||
|
return directive, true
|
||||||
|
}
|
||||||
|
|
||||||
func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, bool) {
|
func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, bool) {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
|
if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
|
||||||
@@ -2654,6 +2682,14 @@ func (b *Bot) sendAssistantText(ctx context.Context, threadID string, chatID int
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if segment.File != nil {
|
||||||
|
if err := b.sendAssistantFile(ctx, chatID, *segment.File); err != nil {
|
||||||
|
b.logger.Printf("send assistant file: %v", err)
|
||||||
|
if sendErr := b.sendLong(ctx, chatID, "Could not send file: "+err.Error()); sendErr != nil {
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if segment.Photo != nil {
|
if segment.Photo != nil {
|
||||||
if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil {
|
if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil {
|
||||||
b.logger.Printf("send assistant photo: %v", err)
|
b.logger.Printf("send assistant photo: %v", err)
|
||||||
@@ -2721,6 +2757,25 @@ func (b *Bot) applyAssistantThreadCWD(ctx context.Context, threadID string, dire
|
|||||||
return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID)
|
return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendAssistantFile(ctx context.Context, chatID int64, directive assistantFileDirective) error {
|
||||||
|
path := strings.TrimSpace(directive.Path)
|
||||||
|
if path == "" {
|
||||||
|
return errors.New("file directive is missing a path")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return fmt.Errorf("file path must be absolute: %s", path)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %v", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
caption := truncateTelegramCaption(directive.Caption)
|
||||||
|
if _, err := b.tg.SendDocumentBytes(ctx, chatID, path, data, caption); err != nil {
|
||||||
|
return fmt.Errorf("send %s: %v", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive assistantPhotoDirective) error {
|
func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive assistantPhotoDirective) error {
|
||||||
path := strings.TrimSpace(directive.Path)
|
path := strings.TrimSpace(directive.Path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -2736,22 +2791,22 @@ func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive as
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read %s: %v", filepath.Base(path), err)
|
return fmt.Errorf("read %s: %v", filepath.Base(path), err)
|
||||||
}
|
}
|
||||||
caption := truncateTelegramPhotoCaption(directive.Caption)
|
caption := truncateTelegramCaption(directive.Caption)
|
||||||
if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil {
|
if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil {
|
||||||
return fmt.Errorf("send %s: %v", filepath.Base(path), err)
|
return fmt.Errorf("send %s: %v", filepath.Base(path), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateTelegramPhotoCaption(caption string) string {
|
func truncateTelegramCaption(caption string) string {
|
||||||
runes := []rune(caption)
|
runes := []rune(caption)
|
||||||
if len(runes) <= telegramPhotoCaptionLimit {
|
if len(runes) <= telegramCaptionLimit {
|
||||||
return caption
|
return caption
|
||||||
}
|
}
|
||||||
if telegramPhotoCaptionLimit <= 3 {
|
if telegramCaptionLimit <= 3 {
|
||||||
return string(runes[:telegramPhotoCaptionLimit])
|
return string(runes[:telegramCaptionLimit])
|
||||||
}
|
}
|
||||||
return string(runes[:telegramPhotoCaptionLimit-3]) + "..."
|
return string(runes[:telegramCaptionLimit-3]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func assistantStreamPreview(text string) string {
|
func assistantStreamPreview(text string) string {
|
||||||
@@ -2768,7 +2823,9 @@ func assistantStreamPreview(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAssistantDirectiveStart(line string) bool {
|
func isAssistantDirectiveStart(line string) bool {
|
||||||
return strings.HasPrefix(line, telegramPhotoDirectiveStart) ||
|
return strings.HasPrefix(line, telegramFileDirectiveStart) ||
|
||||||
|
strings.HasPrefix(line, strings.TrimSpace(telegramFileDirectiveStart)) ||
|
||||||
|
strings.HasPrefix(line, telegramPhotoDirectiveStart) ||
|
||||||
strings.HasPrefix(line, strings.TrimSpace(telegramPhotoDirectiveStart)) ||
|
strings.HasPrefix(line, strings.TrimSpace(telegramPhotoDirectiveStart)) ||
|
||||||
strings.HasPrefix(line, telegramThreadRenameDirectiveStart) ||
|
strings.HasPrefix(line, telegramThreadRenameDirectiveStart) ||
|
||||||
strings.HasPrefix(line, strings.TrimSpace(telegramThreadRenameDirectiveStart)) ||
|
strings.HasPrefix(line, strings.TrimSpace(telegramThreadRenameDirectiveStart)) ||
|
||||||
|
|||||||
@@ -252,6 +252,24 @@ func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitAssistantMessageSegmentsWithFileDirective(t *testing.T) {
|
||||||
|
filePath := filepath.Join(string(filepath.Separator), "workspace", "report.pdf")
|
||||||
|
text := fmt.Sprintf("before\n<!-- telegram-file {\"path\":%q,\"caption\":\"report\"} -->\nafter", filePath)
|
||||||
|
segments := splitAssistantMessageSegments(text)
|
||||||
|
if len(segments) != 3 {
|
||||||
|
t.Fatalf("segments = %d, want 3: %#v", len(segments), segments)
|
||||||
|
}
|
||||||
|
if segments[0].Text != "before\n" || segments[0].File != nil {
|
||||||
|
t.Fatalf("unexpected first segment: %#v", segments[0])
|
||||||
|
}
|
||||||
|
if segments[1].File == nil || segments[1].File.Path != filePath || segments[1].File.Caption != "report" {
|
||||||
|
t.Fatalf("unexpected file segment: %#v", segments[1])
|
||||||
|
}
|
||||||
|
if segments[2].Text != "after" || segments[2].File != nil {
|
||||||
|
t.Fatalf("unexpected final segment: %#v", segments[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
|
func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
|
||||||
text := "<!-- telegram-photo not-json -->"
|
text := "<!-- telegram-photo not-json -->"
|
||||||
segments := splitAssistantMessageSegments(text)
|
segments := splitAssistantMessageSegments(text)
|
||||||
@@ -261,7 +279,7 @@ func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAssistantStreamPreviewHidesDirectives(t *testing.T) {
|
func TestAssistantStreamPreviewHidesDirectives(t *testing.T) {
|
||||||
text := "before\n<!-- telegram-photo {\"path\":\"/workspace/photo.jpg\"} -->\nafter\n<!-- codex-thread-rename "
|
text := "before\n<!-- telegram-file {\"path\":\"/workspace/report.pdf\"} -->\n<!-- telegram-photo {\"path\":\"/workspace/photo.jpg\"} -->\nafter\n<!-- codex-thread-rename "
|
||||||
got := assistantStreamPreview(text)
|
got := assistantStreamPreview(text)
|
||||||
want := "before\nafter\n"
|
want := "before\nafter\n"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|||||||
Reference in New Issue
Block a user