Twitter rewrites every URL to a t.co address of a fixed 23 characters, so counting the raw characters rejected messages Twitter would have accepted: 100 characters of text plus a 50 character YouTube link counted as 150 and was refused, where Twitter sees 123. The punctuation trimmed off the end of a link keeps counting as ordinary text. Only links written with a scheme are recognised; Twitter also shortens bare hosts like example.com/foo, but detecting those without mistaking Node.js for a link is not worth it here, and counting them in full errs toward refusing rather than posting something too long. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
7.5 KiB
Go
273 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
"image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"tweetdistributor/discord"
|
|
"tweetdistributor/output"
|
|
"tweetdistributor/store"
|
|
"unicode/utf8"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
"golang.org/x/image/draw"
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
const maxTweetLength = 140
|
|
const maxImagesPerPost = 4
|
|
|
|
// Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB.
|
|
const maxImageBytes = 2_000_000
|
|
|
|
// urlLength is what a link costs against maxTweetLength: Twitter rewrites
|
|
// every URL to a t.co address of this fixed length, however long the original
|
|
// was, so counting the raw characters would reject messages it would accept.
|
|
const urlLength = 23
|
|
|
|
// urlPattern picks candidate links out of message text; each one is parsed
|
|
// properly before it is judged to be a YouTube link.
|
|
var urlPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
|
|
|
// trimURL drops the punctuation that ends the sentence rather than the URL.
|
|
func trimURL(match string) string {
|
|
return strings.TrimRight(match, ".,!?、。)]}>")
|
|
}
|
|
|
|
// tweetLength counts a message the way Twitter does, charging every link a
|
|
// fixed length instead of its actual one.
|
|
func tweetLength(content string) int {
|
|
length := utf8.RuneCountInString(content)
|
|
for _, match := range urlPattern.FindAllString(content, -1) {
|
|
// The trimmed punctuation is still ordinary text and keeps counting.
|
|
length += urlLength - utf8.RuneCountInString(trimURL(match))
|
|
}
|
|
return length
|
|
}
|
|
|
|
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// shrinkImage re-encodes (and if necessary downscales) an image until it
|
|
// fits within maxImageBytes. Images already small enough pass through
|
|
// untouched.
|
|
func shrinkImage(img output.Image) (output.Image, error) {
|
|
if len(img.Data) <= maxImageBytes {
|
|
return img, nil
|
|
}
|
|
|
|
src, _, err := image.Decode(bytes.NewReader(img.Data))
|
|
if err != nil {
|
|
return output.Image{}, fmt.Errorf("decoding %s: %w", img.Filename, err)
|
|
}
|
|
|
|
for scale := 1.0; scale > 0.05; scale *= 0.7 {
|
|
width := int(float64(src.Bounds().Dx()) * scale)
|
|
height := int(float64(src.Bounds().Dy()) * scale)
|
|
if width < 1 || height < 1 {
|
|
break
|
|
}
|
|
|
|
scaled := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, src.Bounds(), draw.Src, nil)
|
|
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil {
|
|
return output.Image{}, fmt.Errorf("encoding %s: %w", img.Filename, err)
|
|
}
|
|
|
|
if buf.Len() <= maxImageBytes {
|
|
filename := strings.TrimSuffix(img.Filename, path.Ext(img.Filename)) + ".jpg"
|
|
return output.Image{
|
|
Data: buf.Bytes(),
|
|
ContentType: "image/jpeg",
|
|
Filename: filename,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return output.Image{}, fmt.Errorf("%s could not be shrunk below %d bytes", img.Filename, maxImageBytes)
|
|
}
|
|
|
|
// fetch GETs url and returns its body.
|
|
func fetch(url string) ([]byte, error) {
|
|
resp, err := httpClient.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("status %s", resp.Status)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// downloadImage fetches an image and shrinks it to a postable size.
|
|
func downloadImage(url, filename, contentType string) (output.Image, error) {
|
|
data, err := fetch(url)
|
|
if err != nil {
|
|
return output.Image{}, fmt.Errorf("downloading %s: %w", filename, err)
|
|
}
|
|
return shrinkImage(output.Image{
|
|
Data: data,
|
|
ContentType: contentType,
|
|
Filename: filename,
|
|
})
|
|
}
|
|
|
|
func downloadImages(attachments []*discordgo.MessageAttachment) ([]output.Image, error) {
|
|
var images []output.Image
|
|
for _, attachment := range attachments {
|
|
if !strings.HasPrefix(attachment.ContentType, "image/") {
|
|
continue
|
|
}
|
|
img, err := downloadImage(attachment.URL, attachment.Filename, attachment.ContentType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
images = append(images, img)
|
|
}
|
|
return images, nil
|
|
}
|
|
|
|
// distributor mirrors what happens on the Discord channel to every output.
|
|
type distributor struct {
|
|
d *discord.Client
|
|
outputs []output.OutputInterface
|
|
store *store.Store
|
|
}
|
|
|
|
// reportf logs an error and echoes it back into the Discord channel.
|
|
func (dist *distributor) reportf(format string, args ...any) {
|
|
errstr := fmt.Sprintf(format, args...)
|
|
fmt.Fprintln(os.Stderr, errstr)
|
|
dist.d.Write(errstr)
|
|
}
|
|
|
|
// created posts a new Discord message to every output, as a reply when the
|
|
// Discord message itself was a reply to something we already distributed.
|
|
func (dist *distributor) created(event discord.Event) {
|
|
if length := tweetLength(event.Content); length > maxTweetLength {
|
|
dist.reportf("Error: message is %d characters counting each link as %d, exceeding the %d character limit; not posted", length, urlLength, maxTweetLength)
|
|
return
|
|
}
|
|
|
|
images, err := downloadImages(event.Attachments)
|
|
if err != nil {
|
|
dist.reportf("Error: %s; not posted", err)
|
|
return
|
|
}
|
|
if len(images) > maxImagesPerPost {
|
|
dist.reportf("Error: %d images attached, exceeding the limit of %d; not posted", len(images), maxImagesPerPost)
|
|
return
|
|
}
|
|
|
|
var preview *output.Preview
|
|
if videoURL := findYouTubeURL(event.Content); videoURL != "" {
|
|
preview, err = youtubePreview(videoURL)
|
|
if err != nil {
|
|
// The post is still worth making without its card.
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
// A reply to a message we never distributed becomes a top level post.
|
|
var parents store.Refs
|
|
if event.ReplyToID != "" {
|
|
parents, _ = dist.store.Get(event.ReplyToID)
|
|
}
|
|
|
|
refs := store.Refs{}
|
|
for _, out := range dist.outputs {
|
|
post := output.Post{
|
|
Text: event.Content,
|
|
Images: images,
|
|
Preview: preview,
|
|
}
|
|
if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() {
|
|
post.ReplyTo = &parent
|
|
}
|
|
|
|
ref, err := out.Write(post)
|
|
if err != nil {
|
|
dist.reportf("%s Error: %s", out.GetName(), err)
|
|
continue
|
|
}
|
|
refs[out.GetName()] = ref
|
|
}
|
|
|
|
if len(refs) == 0 {
|
|
return
|
|
}
|
|
if err := dist.store.Put(event.MessageID, refs); err != nil {
|
|
dist.reportf("Error: could not remember the posts for this message: %s", err)
|
|
}
|
|
}
|
|
|
|
// deleted removes the posts that a now deleted Discord message produced.
|
|
func (dist *distributor) deleted(event discord.Event) {
|
|
refs, ok := dist.store.Get(event.MessageID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for _, out := range dist.outputs {
|
|
ref, ok := refs[out.GetName()]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := out.Delete(ref); err != nil {
|
|
dist.reportf("%s Error: could not delete the post: %s", out.GetName(), err)
|
|
}
|
|
}
|
|
|
|
if err := dist.store.Delete(event.MessageID); err != nil {
|
|
dist.reportf("Error: could not forget the posts for this message: %s", err)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
posts, err := store.New(os.Getenv("POST_STORE"))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
d := discord.Discord(os.Getenv("DISCORD_TOKEN"), os.Getenv("DISCORD_CHANNEL"))
|
|
|
|
eventchannel := make(chan discord.Event, 1)
|
|
d.BeginRead(eventchannel)
|
|
|
|
d.Write("Tweetdistributor Started")
|
|
|
|
var outputs []output.OutputInterface
|
|
outputs = append(outputs, output.StdOutput())
|
|
outputs = append(outputs, output.TwitterOutput(os.Getenv("TW_ACCESS_TOKEN"), os.Getenv("TW_ACCESS_SECRET")))
|
|
outputs = append(outputs, output.BlueskyOutput(os.Getenv("BSKY_IDENTIFIER"), os.Getenv("BSKY_PASSWORD")))
|
|
|
|
dist := &distributor{d: d, outputs: outputs, store: posts}
|
|
|
|
for event := range eventchannel {
|
|
switch event.Kind {
|
|
case discord.MessageCreated:
|
|
dist.created(event)
|
|
case discord.MessageDeleted:
|
|
dist.deleted(event)
|
|
}
|
|
}
|
|
}
|