count links as a fixed length against the tweet limit
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>
This commit is contained in:
20
main.go
20
main.go
@@ -30,6 +30,11 @@ const maxImagesPerPost = 4
|
|||||||
// Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB.
|
// Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB.
|
||||||
const maxImageBytes = 2_000_000
|
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
|
// urlPattern picks candidate links out of message text; each one is parsed
|
||||||
// properly before it is judged to be a YouTube link.
|
// properly before it is judged to be a YouTube link.
|
||||||
var urlPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
var urlPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||||
@@ -39,6 +44,17 @@ func trimURL(match string) string {
|
|||||||
return strings.TrimRight(match, ".,!?、。)]}>")
|
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}
|
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
// shrinkImage re-encodes (and if necessary downscales) an image until it
|
// 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
|
// 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.
|
// Discord message itself was a reply to something we already distributed.
|
||||||
func (dist *distributor) created(event discord.Event) {
|
func (dist *distributor) created(event discord.Event) {
|
||||||
if length := utf8.RuneCountInString(event.Content); length > maxTweetLength {
|
if length := tweetLength(event.Content); length > maxTweetLength {
|
||||||
dist.reportf("Error: message is %d characters, exceeding the %d character limit; not posted", 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
main_test.go
38
main_test.go
@@ -6,8 +6,10 @@ import (
|
|||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"tweetdistributor/output"
|
"tweetdistributor/output"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// noiseJPEG returns a JPEG image of random noise, which compresses poorly
|
// 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()
|
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) {
|
func TestShrinkImageLarge(t *testing.T) {
|
||||||
data := noiseJPEG(t, 3000, 3000)
|
data := noiseJPEG(t, 3000, 3000)
|
||||||
if len(data) <= maxImageBytes {
|
if len(data) <= maxImageBytes {
|
||||||
|
|||||||
Reference in New Issue
Block a user