288 lines
8.8 KiB
Go
288 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const telegramInputImageLimit = 20 << 20
|
|
|
|
type telegramInputMedia struct {
|
|
MIME string
|
|
Path string
|
|
}
|
|
|
|
func commandParts(text string) (string, []string) {
|
|
fields := strings.Fields(strings.TrimSpace(text))
|
|
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
|
|
return "", nil
|
|
}
|
|
command := strings.TrimPrefix(strings.ToLower(fields[0]), "/")
|
|
command, _, _ = strings.Cut(command, "@")
|
|
return command, fields[1:]
|
|
}
|
|
|
|
const inboundTelegramEditNotice = "Transport note: this Telegram photo is available for visual analysis on this turn, but it is not stored as a generated-image artifact. The image_edit_latest tools can reuse only an image Hermes generated. Do not claim an exact edit of this inbound photo; explain the limitation and offer either a new image inspired by it or an edit of the latest Hermes-generated image."
|
|
|
|
func looksLikeImageEdit(text string) bool {
|
|
lower := strings.ToLower(strings.Join(strings.Fields(text), " "))
|
|
for _, phrase := range []string{
|
|
"edit this", "edit the image", "edit the photo", "change this image",
|
|
"change this photo", "modify this", "retouch", "turn this into",
|
|
"turn this cat", "turn this dog", "remove from the image",
|
|
"replace in the image", "edit it", "change it", "modify it", "crop it",
|
|
"resize it", "upscale it",
|
|
} {
|
|
if strings.Contains(lower, phrase) {
|
|
return true
|
|
}
|
|
}
|
|
hasTarget := false
|
|
for _, target := range []string{"image", "photo", "picture", "portrait", "selfie", "screenshot"} {
|
|
if strings.Contains(lower, target) {
|
|
hasTarget = true
|
|
break
|
|
}
|
|
}
|
|
if hasTarget {
|
|
for _, action := range []string{
|
|
"add ", "blur", "brighten", "change", "color", "convert", "crop",
|
|
"edit", "make ", "modify", "remove", "replace", "resize", "sharpen",
|
|
"transform", "turn ", "upscale",
|
|
} {
|
|
if strings.Contains(lower, action) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func telegramInputPrompt(text string, hasPhoto bool) string {
|
|
if hasPhoto {
|
|
return inboundTelegramEditNotice + "\n\nUser request: " + text
|
|
}
|
|
if !looksLikeImageEdit(text) {
|
|
return text
|
|
}
|
|
return "Transport note: no image is attached to this Telegram message. Use image_edit_latest only if Hermes previously generated an image in this private tenant. Otherwise do not claim access to an earlier inbound Telegram photo; offer a new image or ask the user to use Hermes WebUI with the source attachment.\n\nUser request: " + text
|
|
}
|
|
|
|
func (bot *telegramBot) startTelegramActivity(chatID int64) func() {
|
|
done := make(chan struct{})
|
|
stopped := make(chan struct{})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
every := bot.activityEvery
|
|
if every <= 0 {
|
|
every = 4 * time.Second
|
|
}
|
|
go func() {
|
|
defer close(stopped)
|
|
ticker := time.NewTicker(every)
|
|
defer ticker.Stop()
|
|
for {
|
|
_ = bot.sendActionContext(ctx, chatID, "typing")
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}()
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
close(done)
|
|
cancel()
|
|
})
|
|
<-stopped
|
|
}
|
|
}
|
|
|
|
func largestTelegramPhoto(photos []telegramPhotoSize) (telegramPhotoSize, error) {
|
|
var selected telegramPhotoSize
|
|
for _, photo := range photos {
|
|
if strings.TrimSpace(photo.FileID) == "" {
|
|
continue
|
|
}
|
|
if selected.FileID == "" || int64(photo.Width)*int64(photo.Height) > int64(selected.Width)*int64(selected.Height) {
|
|
selected = photo
|
|
}
|
|
}
|
|
if selected.FileID == "" {
|
|
return telegramPhotoSize{}, errors.New("Telegram photo has no file identifier")
|
|
}
|
|
if selected.FileSize > telegramInputImageLimit {
|
|
return telegramPhotoSize{}, errors.New("Telegram photo exceeds input limit")
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func safeTelegramFilePath(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" || strings.Contains(value, `\`) || strings.ContainsRune(value, '\x00') {
|
|
return "", errors.New("invalid Telegram file path")
|
|
}
|
|
cleaned := path.Clean(value)
|
|
if cleaned == "." || path.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
|
return "", errors.New("invalid Telegram file path")
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func escapedTelegramFilePath(value string) string {
|
|
components := strings.Split(value, "/")
|
|
for index := range components {
|
|
components[index] = url.PathEscape(components[index])
|
|
}
|
|
return strings.Join(components, "/")
|
|
}
|
|
|
|
func supportedTelegramImageType(header []byte) (string, error) {
|
|
detected := http.DetectContentType(header)
|
|
switch detected {
|
|
case "image/jpeg", "image/png", "image/webp", "image/gif":
|
|
return detected, nil
|
|
default:
|
|
return "", errors.New("unsupported Telegram image type")
|
|
}
|
|
}
|
|
|
|
func (bot *telegramBot) fetchTelegramPhoto(photos []telegramPhotoSize) (telegramInputMedia, error) {
|
|
selected, err := largestTelegramPhoto(photos)
|
|
if err != nil {
|
|
return telegramInputMedia{}, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second)
|
|
defer cancel()
|
|
var remote struct {
|
|
FilePath string `json:"file_path"`
|
|
FileSize int64 `json:"file_size"`
|
|
}
|
|
if err := bot.call(ctx, "getFile", url.Values{"file_id": {selected.FileID}}, &remote); err != nil {
|
|
return telegramInputMedia{}, errors.New("Telegram file lookup failed")
|
|
}
|
|
if remote.FileSize > telegramInputImageLimit {
|
|
return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit")
|
|
}
|
|
remotePath, err := safeTelegramFilePath(remote.FilePath)
|
|
if err != nil {
|
|
return telegramInputMedia{}, err
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(bot.fileBase, "/")+"/"+escapedTelegramFilePath(remotePath), nil)
|
|
if err != nil {
|
|
return telegramInputMedia{}, errors.New("create Telegram file request")
|
|
}
|
|
response, err := bot.client.Do(request)
|
|
if err != nil {
|
|
return telegramInputMedia{}, errors.New("Telegram file download failed")
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return telegramInputMedia{}, errors.New("Telegram file download rejected")
|
|
}
|
|
if response.ContentLength > telegramInputImageLimit {
|
|
return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit")
|
|
}
|
|
temporary, err := os.CreateTemp("", "hermes-telegram-input-*")
|
|
if err != nil {
|
|
return telegramInputMedia{}, errors.New("stage Telegram image")
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
keep := false
|
|
defer func() {
|
|
_ = temporary.Close()
|
|
if !keep {
|
|
_ = os.Remove(temporaryPath)
|
|
}
|
|
}()
|
|
written, err := io.Copy(temporary, io.LimitReader(response.Body, telegramInputImageLimit+1))
|
|
if err != nil {
|
|
return telegramInputMedia{}, errors.New("read Telegram image")
|
|
}
|
|
if written > telegramInputImageLimit {
|
|
return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit")
|
|
}
|
|
if _, err := temporary.Seek(0, io.SeekStart); err != nil {
|
|
return telegramInputMedia{}, errors.New("inspect Telegram image")
|
|
}
|
|
header := make([]byte, 512)
|
|
read, readErr := temporary.Read(header)
|
|
if readErr != nil && readErr != io.EOF {
|
|
return telegramInputMedia{}, errors.New("inspect Telegram image")
|
|
}
|
|
mimeType, err := supportedTelegramImageType(header[:read])
|
|
if err != nil {
|
|
return telegramInputMedia{}, err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return telegramInputMedia{}, errors.New("stage Telegram image")
|
|
}
|
|
keep = true
|
|
return telegramInputMedia{MIME: mimeType, Path: temporaryPath}, nil
|
|
}
|
|
|
|
func streamTelegramImageRequest(writer io.Writer, prompt string, media telegramInputMedia, topic activeTelegramTopic) error {
|
|
promptJSON, err := json.Marshal(prompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(writer, `{"input":[{"role":"user","content":[{"type":"input_text","text":%s},{"type":"input_image","image_url":"data:%s;base64,`, promptJSON, media.MIME); err != nil {
|
|
return err
|
|
}
|
|
file, err := os.Open(media.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoder := base64.NewEncoder(base64.StdEncoding, writer)
|
|
_, copyErr := io.Copy(encoder, file)
|
|
closeEncoderErr := encoder.Close()
|
|
closeFileErr := file.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if closeEncoderErr != nil {
|
|
return closeEncoderErr
|
|
}
|
|
if closeFileErr != nil {
|
|
return closeFileErr
|
|
}
|
|
conversationJSON, err := json.Marshal(topic.Conversation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(writer, `"}]}],"conversation":%s,"store":true,"truncation":"auto"}`, conversationJSON)
|
|
return err
|
|
}
|
|
|
|
func (bot *telegramBot) askTenantImage(slot int, prompt string, media telegramInputMedia, updateID int64, topic activeTelegramTopic) (string, error) {
|
|
reader, writer := io.Pipe()
|
|
writeDone := make(chan error, 1)
|
|
go func() {
|
|
err := streamTelegramImageRequest(writer, prompt, media, topic)
|
|
_ = writer.CloseWithError(err)
|
|
writeDone <- err
|
|
}()
|
|
answer, requestErr := bot.askTenantRequest(slot, reader, updateID, topic)
|
|
_ = reader.Close()
|
|
writeErr := <-writeDone
|
|
if requestErr != nil {
|
|
return "", requestErr
|
|
}
|
|
if writeErr != nil {
|
|
return "", errors.New("encode Telegram image")
|
|
}
|
|
return answer, nil
|
|
}
|