tweetsvg/main.go

276 lines
7.2 KiB
Go

package main
import (
"bytes"
"embed"
"encoding/base64"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/ChimeraCoder/anaconda"
strip "github.com/grokify/html-strip-tags-go"
"github.com/joho/godotenv"
"github.com/rivo/uniseg"
emoji "github.com/tmdvs/Go-Emoji-Utils"
)
//go:embed index.html tweet.svg.tmpl suspendedTweet.svg
var content embed.FS
var (
api *anaconda.TwitterApi
tweetDateLayout = "Mon Jan 2 15:04:05 -0700 2006"
port string
accessToken string
accessTokenSecret string
consumerKey string
consumerSecret string
)
func init() {
godotenv.Load(os.Getenv("DOTENV_DIR") + ".env")
port = os.Getenv("PORT")
accessToken = os.Getenv("ACCESS_TOKEN")
accessTokenSecret = os.Getenv("ACCESS_TOKEN_SECRET")
consumerKey = os.Getenv("CONSUMER_KEY")
consumerSecret = os.Getenv("CONSUMER_SECRET")
}
func main() {
if accessToken == "" || accessTokenSecret == "" || consumerKey == "" || consumerSecret == "" {
panic("Missing Twitter credentials")
}
api = anaconda.NewTwitterApiWithCredentials(accessToken, accessTokenSecret, consumerKey, consumerSecret)
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if len(r.URL.Path) > 1 {
getTweet(w, r)
} else {
body, _ := content.ReadFile("index.html")
w.Write(body)
}
})
fmt.Println("Server started at port " + port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func getTweet(w http.ResponseWriter, r *http.Request) {
id := strings.ReplaceAll(r.URL.Path, "/", "")
i, err := strconv.ParseInt(id, 10, 64)
if err != nil {
w.WriteHeader(400)
return
}
tweet, err := api.GetTweet(i, nil)
if err != nil {
switch err := err.(type) {
case *anaconda.ApiError:
switch err.Decoded.Errors[0].Code {
case 63:
fmt.Printf("Generating suspended tweet image for %s\n", id)
suspendedTweet(w)
return
}
}
fmt.Println(err)
w.WriteHeader(404)
return
}
processTweet(&tweet)
w.Header().Set("Content-type", "image/svg+xml")
_, err = w.Write(renderTemplate(tweet, false))
if err != nil {
fmt.Println(err)
w.WriteHeader(500)
return
}
}
func processTweet(tweet *anaconda.Tweet) {
gr := uniseg.NewGraphemes(tweet.FullText)
count := 0
displayText := ""
for gr.Next() {
if count >= tweet.DisplayTextRange[0] && count < tweet.DisplayTextRange[1] {
displayText += gr.Str()
}
count += 1
}
tweet.FullText = displayText
for _, user := range tweet.Entities.User_mentions {
tweet.FullText = strings.ReplaceAll(tweet.FullText, "@"+user.Screen_name, fmt.Sprintf("<a rel=\"noopener\" target=\"_blank\" href=\"https://twitter.com/%s/\">@%s</a>", user.Screen_name, user.Screen_name))
}
for _, url := range tweet.Entities.Urls {
tweet.FullText = strings.ReplaceAll(tweet.FullText, url.Url, fmt.Sprintf("<a rel=\"noopener\" target=\"_blank\" href=\"%s\">%s</a>", url.Expanded_url, url.Display_url))
}
for _, hashtag := range tweet.Entities.Hashtags {
tweet.FullText = strings.ReplaceAll(tweet.FullText, "#"+hashtag.Text, fmt.Sprintf("<a rel=\"noopener\" target=\"_blank\" href=\"https://twitter.com/hashtag/%s\">#%s</a>", hashtag.Text, hashtag.Text))
}
tweet.FullText = strings.ReplaceAll(tweet.FullText, "\n", "<br />")
if tweet.QuotedStatus != nil {
processTweet(tweet.QuotedStatus)
}
}
func renderTemplate(tweet anaconda.Tweet, isQuoted bool) []byte {
templateFuncs := template.FuncMap{
"base64": func(url string) string {
res, err := http.Get(url)
if err != nil {
return ""
}
buf := new(bytes.Buffer)
buf.ReadFrom(res.Body)
return base64.StdEncoding.EncodeToString(buf.Bytes())
},
"isoDate": func(date string) string {
t, _ := time.Parse(tweetDateLayout, date)
return t.Format(time.RFC3339)
},
"humanDate": func(date string) string {
t, _ := time.Parse(tweetDateLayout, date)
return t.Format("3:04 PM · Jan 2, 2006")
},
"html": func(in string) template.HTML {
return template.HTML(in)
},
"calculateHeight": func(tweet anaconda.Tweet) string {
return fmt.Sprintf("%dpx", calculateHeight(tweet))
},
"renderTweet": func(tweet anaconda.Tweet) template.HTML {
return template.HTML(string(renderTemplate(tweet, true)))
},
"tweetWidth": func() string {
if isQuoted {
return "450px"
}
return "499px"
},
"className": func() string {
if isQuoted {
return "subtweet"
}
return "tweetsvg"
},
}
t := template.Must(
template.New("tweet.svg.tmpl").
Funcs(templateFuncs).
ParseFS(content, "tweet.svg.tmpl"))
var buf bytes.Buffer
t.Execute(&buf, tweet)
return buf.Bytes()
}
func calculateHeight(tweet anaconda.Tweet) int64 {
height := 64.0 /* Avatar */ + 20 /* footer */ + 46 /* text margin */ + 22 /* margin */
lineWidth := 0.0
lineHeight := 28.0
tweetText := strings.ReplaceAll(tweet.FullText, "<br />", " \n")
tweetText = strip.StripTags(tweetText)
words := regexp.MustCompile(`[ |-]`).Split(tweetText, -1)
for _, word := range words {
if len(emoji.FindAll(word)) > 0 {
lineHeight = 32.0
}
if strings.Contains(word, "\n") {
height += lineHeight
lineHeight = 28.0
lineWidth = 0
continue
}
chars := strings.Split(word, "")
wordWidth := 0.0
for _, char := range chars {
wordWidth += getCharWidth(char)
}
if wordWidth > 435 {
height += (lineHeight * (math.Ceil(wordWidth/435) + 1))
lineHeight = 28.0
lineWidth = 0
} else if lineWidth+getCharWidth(" ")+wordWidth > 435 {
height += lineHeight
lineHeight = 28.0
lineWidth = wordWidth
} else {
lineWidth += wordWidth
}
}
if lineWidth > 0 {
height += lineHeight
}
if tweet.InReplyToScreenName != "" {
height += 42
}
for i, img := range tweet.ExtendedEntities.Media {
ratio := float64(img.Sizes.Small.W) / 468
tweet.ExtendedEntities.Media[i].Sizes.Small.W = 468
tweet.ExtendedEntities.Media[i].Sizes.Small.H = int((float64(img.Sizes.Small.H) / ratio) + 5.0)
}
if len(tweet.ExtendedEntities.Media) > 1 {
for i, img := range tweet.ExtendedEntities.Media {
tweet.ExtendedEntities.Media[i].Sizes.Small.W = (img.Sizes.Small.W / 2) - 20
tweet.ExtendedEntities.Media[i].Sizes.Small.H = (img.Sizes.Small.H / 2) - 20
}
}
switch len(tweet.ExtendedEntities.Media) {
case 1:
height += float64(tweet.ExtendedEntities.Media[0].Sizes.Small.H)
case 2:
height += math.Max(float64(tweet.ExtendedEntities.Media[0].Sizes.Small.H), float64(tweet.ExtendedEntities.Media[1].Sizes.Small.H)) + 5
case 3:
height += math.Max(float64(tweet.ExtendedEntities.Media[0].Sizes.Small.H), float64(tweet.ExtendedEntities.Media[1].Sizes.Small.H)) + 5
height += float64(tweet.ExtendedEntities.Media[2].Sizes.Small.H) + 35
case 4:
height += math.Max(float64(tweet.ExtendedEntities.Media[0].Sizes.Small.H), float64(tweet.ExtendedEntities.Media[1].Sizes.Small.H)) + 10
height += math.Max(float64(tweet.ExtendedEntities.Media[2].Sizes.Small.H), float64(tweet.ExtendedEntities.Media[3].Sizes.Small.H)) + 10
height += 7
}
if tweet.QuotedStatus != nil {
height += float64(calculateHeight(*tweet.QuotedStatus)) + 9
}
return int64(height)
}
func suspendedTweet(w http.ResponseWriter) {
w.Header().Set("Content-type", "image/svg+xml")
tweet, _ := content.ReadFile("suspendedTweet.svg")
w.Write(tweet)
}