Support Telegram animated stickers (tgs) format (#1173)

This is half a fix for #874

This patch introduces a new config flag:
- MediaConvertTgs

These need to be treated independently from the existing
MediaConvertWebPToPNG flag because Tgs→WebP results in an
*animated* WebP, and the WebP→PNG converter can't handle
animated WebP files yet.

Furthermore, some platforms (like discord) don't even support
animated WebP files, so the user may want to fall back to
static PNGs (not APNGs).

The final reason why this is only half a fix is that this
introduces an external dependency, namely lottie, to be
installed like this:

$ pip3 install lottie cairosvg

This patch works by writing the tgs to a temporary file in /tmp,
calling lottie to convert it (this conversion may take several seconds!),
and then deleting the temporary file.
The temporary file is absolutely necessary, as lottie refuses to
work on non-seekable files.
If anyone comes up with a reasonable use case where /tmp is
unavailable, I can add yet another config option for that, if desired.

Telegram will bail out if the option is configured but lottie isn't found.
This commit is contained in:
Ben Wiederhake 2020-08-23 22:34:28 +02:00 committed by GitHub
parent 491fe35397
commit b2af76e7dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 110 additions and 9 deletions

View File

@ -100,6 +100,7 @@ type Protocol struct {
MediaDownloadSize int // all protocols MediaDownloadSize int // all protocols
MediaServerDownload string MediaServerDownload string
MediaServerUpload string MediaServerUpload string
MediaConvertTgs string // telegram
MediaConvertWebPToPNG bool // telegram MediaConvertWebPToPNG bool // telegram
MessageDelay int // IRC, time in millisecond to wait between messages MessageDelay int // IRC, time in millisecond to wait between messages
MessageFormat string // telegram MessageFormat string // telegram

View File

@ -5,7 +5,10 @@ import (
"fmt" "fmt"
"image/png" "image/png"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"os"
"os/exec"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -192,7 +195,7 @@ func ParseMarkdown(input string) string {
return res return res
} }
// ConvertWebPToPNG convert input data (which should be WebP format to PNG format) // ConvertWebPToPNG converts input data (which should be WebP format) to PNG format
func ConvertWebPToPNG(data *[]byte) error { func ConvertWebPToPNG(data *[]byte) error {
r := bytes.NewReader(*data) r := bytes.NewReader(*data)
m, err := webp.Decode(r) m, err := webp.Decode(r)
@ -207,3 +210,49 @@ func ConvertWebPToPNG(data *[]byte) error {
*data = w.Bytes() *data = w.Bytes()
return nil return nil
} }
// CanConvertTgsToX Checks whether the external command necessary for ConvertTgsToX works.
func CanConvertTgsToX() error {
// We depend on the fact that `lottie_convert.py --help` has exit status 0.
// Hyrum's Law predicted this, and Murphy's Law predicts that this will break eventually.
// However, there is no alternative like `lottie_convert.py --is-properly-installed`
cmd := exec.Command("lottie_convert.py", "--help")
return cmd.Run()
}
// ConvertTgsToWebP convert input data (which should be tgs format) to WebP format
// This relies on an external command, which is ugly, but works.
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
// lottie can't handle input from a pipe, so write to a temporary file:
tmpFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-*.tgs")
if err != nil {
return err
}
tmpFileName := tmpFile.Name()
defer func() {
if removeErr := os.Remove(tmpFileName); removeErr != nil {
logger.Errorf("Could not delete temporary file %s: %v", tmpFileName, removeErr)
}
}()
if _, writeErr := tmpFile.Write(*data); writeErr != nil {
return writeErr
}
// Must close before calling lottie to avoid data races:
if closeErr := tmpFile.Close(); closeErr != nil {
return closeErr
}
// Call lottie to transform:
cmd := exec.Command("lottie_convert.py", "--input-format", "lottie", "--output-format", outputFormat, tmpFileName, "/dev/stdout")
cmd.Stderr = nil
// NB: lottie writes progress into to stderr in all cases.
stdout, stderr := cmd.Output()
if stderr != nil {
// 'stderr' already contains some parts of Stderr, because it was set to 'nil'.
return stderr
}
*data = stdout
return nil
}

View File

@ -217,6 +217,46 @@ func (b *Btelegram) handleDownloadAvatar(userid int, channel string) {
} }
} }
func (b *Btelegram) maybeConvertTgs(name *string, data *[]byte) {
var format string
switch b.GetString("MediaConvertTgs") {
case FormatWebp:
b.Log.Debugf("Tgs to WebP conversion enabled, converting %v", name)
format = FormatWebp
case FormatPng:
// The WebP to PNG converter can't handle animated webp files yet,
// and I'm not going to write a path for x/image/webp.
// The error message would be:
// conversion failed: webp: non-Alpha VP8X is not implemented
// So instead, we tell lottie to directly go to PNG.
b.Log.Debugf("Tgs to PNG conversion enabled, converting %v", name)
format = FormatPng
default:
// Otherwise, no conversion was requested. Trying to run the usual webp
// converter would fail, because '.tgs.webp' is actually a gzipped JSON
// file, and has nothing to do with WebP.
return
}
err := helper.ConvertTgsToX(data, format, b.Log)
if err != nil {
b.Log.Errorf("conversion failed: %v", err)
} else {
*name = strings.Replace(*name, "tgs.webp", format, 1)
}
}
func (b *Btelegram) maybeConvertWebp(name *string, data *[]byte) {
if b.GetBool("MediaConvertWebPToPNG") {
b.Log.Debugf("WebP to PNG conversion enabled, converting %v", name)
err := helper.ConvertWebPToPNG(data)
if err != nil {
b.Log.Errorf("conversion failed: %v", err)
} else {
*name = strings.Replace(*name, ".webp", ".png", 1)
}
}
}
// handleDownloadFile handles file download // handleDownloadFile handles file download
func (b *Btelegram) handleDownload(rmsg *config.Message, message *tgbotapi.Message) error { func (b *Btelegram) handleDownload(rmsg *config.Message, message *tgbotapi.Message) error {
size := 0 size := 0
@ -264,15 +304,13 @@ func (b *Btelegram) handleDownload(rmsg *config.Message, message *tgbotapi.Messa
if err != nil { if err != nil {
return err return err
} }
if strings.HasSuffix(name, ".webp") && b.GetBool("MediaConvertWebPToPNG") {
b.Log.Debugf("WebP to PNG conversion enabled, converting %s", name) if strings.HasSuffix(name, ".tgs.webp") {
err := helper.ConvertWebPToPNG(data) b.maybeConvertTgs(&name, data)
if err != nil { } else if strings.HasSuffix(name, ".webp") {
b.Log.Errorf("conversion failed: %s", err) b.maybeConvertWebp(&name, data)
} else {
name = strings.Replace(name, ".webp", ".png", 1)
}
} }
helper.HandleDownloadData(b.Log, rmsg, name, message.Caption, "", data, b.General) helper.HandleDownloadData(b.Log, rmsg, name, message.Caption, "", data, b.General)
return nil return nil
} }

View File

@ -2,6 +2,7 @@ package btelegram
import ( import (
"html" "html"
"log"
"strconv" "strconv"
"strings" "strings"
@ -16,6 +17,8 @@ const (
HTMLFormat = "HTML" HTMLFormat = "HTML"
HTMLNick = "htmlnick" HTMLNick = "htmlnick"
MarkdownV2 = "MarkdownV2" MarkdownV2 = "MarkdownV2"
FormatPng = "png"
FormatWebp = "webp"
) )
type Btelegram struct { type Btelegram struct {
@ -25,6 +28,16 @@ type Btelegram struct {
} }
func New(cfg *bridge.Config) bridge.Bridger { func New(cfg *bridge.Config) bridge.Bridger {
tgsConvertFormat := cfg.GetString("MediaConvertTgs")
if tgsConvertFormat != "" {
err := helper.CanConvertTgsToX()
if err != nil {
log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but lottie does not appear to work:\n%#v", tgsConvertFormat, err)
}
if tgsConvertFormat != FormatPng && tgsConvertFormat != FormatWebp {
log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but only '%s' and '%s' are supported.", FormatPng, FormatWebp, tgsConvertFormat)
}
}
return &Btelegram{Config: cfg, avatarMap: make(map[string]string)} return &Btelegram{Config: cfg, avatarMap: make(map[string]string)}
} }