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