www.byjp.me/tools/import/webmentionio/main.go
2024-05-10 10:36:58 +01:00

226 lines
5 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"slices"
"sort"
"strings"
"time"
"github.com/by-jp/www.byjp.me/tools/shared"
synd "github.com/by-jp/www.byjp.me/tools/syndicate/shared"
)
const webmentions = "https://webmention.io/api/mentions.jf2?domain=%s&token=%s"
const siteTarget = "https://www.byjp.me/"
var myURLs = []string{
"https://bsky.app/profile/byjp.me",
}
type Config struct {
Domain string
Token string
Root string
}
func main() {
var c Config
check(shared.LoadConfig("webmentionio", &c))
mentions, err := retrieveMentions(c)
check(err)
pathChecker := checkValidPath(path.Join(c.Root, "content"))
for _, m := range mentions.Children {
pathStr, interaction, err := parseJF2(m, pathChecker)
switch err {
case nil:
// Proceed
case ErrNoEntry, ErrIsPrivate, ErrIncorrectTarget, ErrIsMine:
// Skip
continue
default:
// Stop
check(err)
}
dst := path.Join(c.Root, "data/interactions", pathStr+".json")
addErr := addInteraction(dst, interaction)
if addErr != nil {
fmt.Fprintf(os.Stderr, "Couldn't save interaction %s to %s: %v\n", interaction.GUID, dst, addErr)
}
}
}
func checkValidPath(contentDir string) func(string) bool {
return func(pathStr string) bool {
pathDir := path.Join(contentDir, pathStr)
std, err := os.Stat(pathDir)
if err == nil && std.IsDir() {
return true
}
pathFile := pathDir + ".md"
stf, err := os.Stat(pathFile)
if err == nil && stf.Mode().IsRegular() {
return true
}
return false
}
}
func addInteraction(jsonPath string, newIn synd.Interaction) error {
inf := InteractionFile{}
rf, err := os.Open(jsonPath)
if err == nil {
if err := json.NewDecoder(rf).Decode(&inf); err != nil && err != io.EOF {
return err
}
} else if !os.IsNotExist(err) {
return err
}
rf.Close()
added := false
for idx, in := range inf.Interactions {
if in.GUID == newIn.GUID {
// A hack because webmentions.io can't process emoji properly currently
if strings.Contains(newIn.Author.Name, "????") {
newIn.Author.Name = in.Author.Name
}
if strings.Contains(newIn.Comment, "????") {
newIn.Comment = in.Comment
}
inf.Interactions[idx] = newIn
added = true
}
}
if !added {
inf.Interactions = append(inf.Interactions, newIn)
}
sort.Sort(ByTimestamp(inf.Interactions))
if err := os.MkdirAll(path.Dir(jsonPath), 0755); err != nil {
return err
}
wf, err := os.OpenFile(jsonPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer wf.Close()
return json.NewEncoder(wf).Encode(inf)
}
var ErrNoEntry = errors.New("JF2 item isn't an entry")
var ErrIsPrivate = errors.New("JF2 item is set to private")
var ErrIncorrectTarget = errors.New("JF2 item describes a different target site")
var ErrIsMine = errors.New("JF2 item was created by the author")
func parseJF2(m Mention, isValidPath func(string) bool) (string, synd.Interaction, error) {
if m.Type != "entry" {
return "", synd.Interaction{}, ErrNoEntry
}
if m.WebmentionPrivate {
return "", synd.Interaction{}, ErrIsPrivate
}
if !strings.HasPrefix(m.WebmentionTarget, siteTarget) {
return "", synd.Interaction{}, ErrIncorrectTarget
}
if strings.HasPrefix(m.URL, siteTarget) {
return "", synd.Interaction{}, ErrIncorrectTarget
}
sitePath := strings.TrimRight(m.WebmentionTarget[len(siteTarget):], "/")
if !isValidPath(sitePath) {
return "", synd.Interaction{}, ErrIncorrectTarget
}
if len(sitePath) == 0 {
return "", synd.Interaction{}, ErrIncorrectTarget
}
if slices.Contains(myURLs, m.Author.URL) {
return "", synd.Interaction{}, ErrIsMine
}
i := synd.Interaction{
GUID: fmt.Sprintf("webmentions.io#%d", m.WebmentionID),
URL: m.URL,
Comment: m.Content.Text,
Author: synd.Author{
// TODO: Handle Bluesky no name on likes
Name: m.Author.Name,
URL: m.Author.URL,
// TODO: Photo?
},
}
p, err := time.Parse(time.RFC3339, m.Published)
if err != nil || p.IsZero() {
p, _ = time.Parse(time.RFC3339, m.WebmentionReceived)
}
i.Timestamp = p
// TODO: very long content?
// TODO: If wm-source != URL then acting on a reference, not on a syndicated post
switch m.WebmentionProperty {
case "like-of":
if strings.Contains(m.Author.URL, "/@") {
i.Emoji = "⭐️"
} else {
i.Emoji = "♥️"
}
case "repost-of":
i.Emoji = "🔁"
case "in-reply-to":
i.Emoji = "💬"
case "mention-of":
i.Emoji = "🗣️"
}
return sitePath, i, nil
}
func retrieveMentions(c Config) (Mentions, error) {
url := webmentionsURL(c.Domain, c.Token)
res, err := http.Get(url)
if err != nil {
return Mentions{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
return Mentions{}, fmt.Errorf("non 200 response code: %v\n%s", res.StatusCode, body)
}
var m Mentions
return m, json.NewDecoder(res.Body).Decode(&m)
}
func webmentionsURL(domain, token string) string {
return fmt.Sprintf(webmentions, domain, token)
}
func check(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}