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:
2026-07-26 11:57:21 +09:00
parent fd14c07ae0
commit e2f8036682
2 changed files with 56 additions and 2 deletions

20
main.go
View File

@@ -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
}