diff --git a/main.go b/main.go index e3c97e2..83cc5f0 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,11 @@ 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<>"']+`) @@ -39,6 +44,17 @@ 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 @@ -145,8 +161,8 @@ func (dist *distributor) reportf(format string, args ...any) { // 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 := utf8.RuneCountInString(event.Content); length > maxTweetLength { - dist.reportf("Error: message is %d characters, exceeding the %d character limit; not posted", length, maxTweetLength) + 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 } diff --git a/main_test.go b/main_test.go index 7908a98..4ae3e42 100644 --- a/main_test.go +++ b/main_test.go @@ -6,8 +6,10 @@ import ( "image/color" "image/jpeg" "math/rand" + "strings" "testing" "tweetdistributor/output" + "unicode/utf8" ) // noiseJPEG returns a JPEG image of random noise, which compresses poorly @@ -28,6 +30,42 @@ func noiseJPEG(t *testing.T, width, height int) []byte { return buf.Bytes() } +func TestTweetLength(t *testing.T) { + tests := []struct { + name string + content string + want int + }{ + {"no link", "こんにちは", 5}, + {"long link", "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabcdefghijklmnop", urlLength}, + {"short link", "https://a.jp", urlLength}, + {"link with text", "みてね " + strings.Repeat("x", 10) + " https://youtu.be/dQw4w9WgXcQ", 4 + 10 + 1 + urlLength}, + {"two links", "https://a.jp https://b.jp", urlLength*2 + 1}, + {"trailing punctuation still counts", "https://a.jp。", urlLength + 1}, + {"multibyte path", "https://ja.wikipedia.org/wiki/日本語", urlLength}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tweetLength(tt.content); got != tt.want { + t.Errorf("tweetLength(%q) = %d, want %d", tt.content, got, tt.want) + } + }) + } +} + +func TestTweetLengthAcceptsMessageOnlyLinksMakeTooLong(t *testing.T) { + // A message that the raw rune count would reject but Twitter accepts. + content := strings.Repeat("あ", 100) + " https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120s" + + if raw := utf8.RuneCountInString(content); raw <= maxTweetLength { + t.Fatalf("test message should be %d raw characters, got %d", maxTweetLength+1, raw) + } + if got := tweetLength(content); got > maxTweetLength { + t.Errorf("tweetLength = %d, want it to fit within %d", got, maxTweetLength) + } +} + func TestShrinkImageLarge(t *testing.T) { data := noiseJPEG(t, 3000, 3000) if len(data) <= maxImageBytes {