package adapter import ( "fmt" . "forge.lightcrystal.systems/lightcrystal/underbbs/models" "github.com/yitsushi/go-misskey" mkcore "github.com/yitsushi/go-misskey/core" mkm "github.com/yitsushi/go-misskey/models" n "github.com/yitsushi/go-misskey/services/notes" tl "github.com/yitsushi/go-misskey/services/notes/timeline" users "github.com/yitsushi/go-misskey/services/users" "log" "os" "strings" "sync" "time" ) type MisskeyAdapter struct { data *chan SocketData nickname string server string apiKey string mk *misskey.Client // unlike the mastodon client, we have to manage combining resources // from different API calls instead of streaming them in a single channel cache map[string]time.Time mtx sync.RWMutex stop chan bool } func (self *MisskeyAdapter) send(data SocketData) { if self.data != nil { *self.data <- data } else { fmt.Fprintln(os.Stderr, string(data.ToDatagram())) } } func (self *MisskeyAdapter) Name() string { return self.nickname } func (self *MisskeyAdapter) Init(settings Settings, data *chan SocketData) error { log.Print("initializing misskey adapter") self.nickname = settings.Nickname self.server = *settings.Server self.apiKey = *settings.ApiKey self.data = data log.Print("getting ready to initialize internal client") client, err := misskey.NewClientWithOptions( misskey.WithAPIToken(self.apiKey), misskey.WithBaseURL("https", self.server, ""), ) if err != nil { log.Print(err.Error()) return err } log.Print("misskey client initialized") self.mk = client self.cache = make(map[string]time.Time) return nil } func (self *MisskeyAdapter) Subscribe(filter string) []error { // misskey streaming API is undocumented.... // we could try to reverse engineer it by directly connecting to the websocket??? // alternatively, we can poll timelines, mentions, etc with a cancellation channel, // keep a cache of IDs in memory, and send new objects on the data channel // TODO: decode the filter so we can send data to the mk services // same as in masto, we will want to close and reopen the stop channel if self.stop != nil { close(self.stop) } self.stop = make(chan bool) // in the background, continuously read data from these API endpoints // if they are newer than the cache, convert them to UnderBBS objects // and send them on the data channel go self.poll() return nil } func (self *MisskeyAdapter) poll() { var latest *time.Time var notesService *n.Service var timelineService *tl.Service for { select { case _, ok := <-self.stop: if !ok { return } default: notesService = self.mk.Notes() timelineService = notesService.Timeline() // TODO: we have to actually decode and pass our filter criteria // probe for new notes probenote, err := timelineService.Get(tl.GetRequest{ Limit: 1, }) if err == nil && len(probenote) > 0 && self.isNew(probenote[0]) { if latest == nil { latest = &probenote[0].CreatedAt // this is the first fetch of notes, we can just grab them notes, err := timelineService.Get(tl.GetRequest{ Limit: 100, }) // if latest is nil also get mentions history mentions, merr := notesService.Mentions(n.MentionsRequest{ Limit: 100, }) if err != nil { log.Print(err.Error()) } if merr != nil { log.Print(merr.Error()) } // check the cache for everything we just collected // if anything is newer or as of yet not in the cache, add it // and convert it to a SocketData implementation before sending on data channel for _, n := range notes { msg := self.toMessageIfNew(n) if msg != nil { self.send(msg) } } for _, n := range mentions { msg := self.toMessageIfNew(n) if msg != nil { self.send(msg) } } } else { for { // get notes since latest, until the probe notes, err := timelineService.Get(tl.GetRequest{ SinceDate: uint64(latest.Unix()), UntilDate: uint64(probenote[0].CreatedAt.Unix()), Limit: 100, }) if err != nil { log.Print(err.Error()) } for _, n := range notes { msg := self.toMessageIfNew(n) if msg == nil { latest = &probenote[0].CreatedAt break } self.send(msg) } if *latest == probenote[0].CreatedAt { break } } } } time.Sleep(5 * time.Second) } } } func (self *MisskeyAdapter) isNew(n mkm.Note) bool { self.mtx.RLock() timestamp, exists := self.cache[n.ID] self.mtx.RUnlock() return !exists || timestamp.Before(n.CreatedAt) } func (self *MisskeyAdapter) toMessageIfNew(n mkm.Note) *Message { return self.toMessage(n, false) } func (self *MisskeyAdapter) toMessage(n mkm.Note, bustCache bool) *Message { self.mtx.RLock() timestamp, exists := self.cache[n.ID] self.mtx.RUnlock() if bustCache || !exists || timestamp.Before(n.CreatedAt) { host := mkcore.StringValue(n.User.Host) authorId := "" if host != "" { authorId = fmt.Sprintf("@%s@%s", n.User.Username, host) } else { authorId = fmt.Sprintf("@%s", n.User.Username) } self.mtx.Lock() self.cache[n.ID] = n.CreatedAt self.mtx.Unlock() msg := Message{ Datagram: Datagram{ Id: n.ID, Uri: n.URI, Protocol: "misskey", Adapter: self.nickname, Type: "message", Created: n.CreatedAt.UnixMilli(), }, Author: authorId, Content: n.Text, Attachments: []Attachment{}, Visibility: n.Visibility, ReplyTo: n.ReplyID, ReplyCount: int(n.RepliesCount), Replies: []string{}, RenoteId: (*string)(n.RenoteID), } for _, f := range n.Files { msg.Attachments = append(msg.Attachments, Attachment{ Src: f.URL, ThumbSrc: f.ThumbnailURL, Size: f.Size, Desc: f.Comment, Created: f.CreatedAt.UnixMilli(), }) } return &msg } return nil } func (self *MisskeyAdapter) toAuthor(usr mkm.User, bustCache bool) *Author { host := mkcore.StringValue(usr.Host) authorId := "" if host != "" { authorId = fmt.Sprintf("@%s@%s", usr.Username, host) } else { authorId = fmt.Sprintf("@%s", usr.Username) } self.mtx.RLock() timestamp, exists := self.cache[authorId] self.mtx.RUnlock() var updated *int64 = nil if usr.UpdatedAt != nil { updatedTmp := usr.UpdatedAt.UnixMilli() updated = &updatedTmp } if bustCache || !exists || (updated != nil && timestamp.Before(time.UnixMilli(*updated))) || timestamp.Before(*usr.CreatedAt) { log.Print("converting author: " + usr.ID) if usr.UpdatedAt != nil { self.cache[authorId] = *usr.UpdatedAt } else { self.cache[authorId] = *usr.CreatedAt } author := Author{ Datagram: Datagram{ Id: authorId, Uri: mkcore.StringValue(usr.URL), Protocol: "misskey", Adapter: self.nickname, Type: "author", Created: usr.CreatedAt.UnixMilli(), Updated: updated, }, Name: usr.Name, ProfilePic: usr.AvatarURL, ProfileData: usr.Description, } return &author } return nil } func (self *MisskeyAdapter) Fetch(etype string, ids []string) error { for _, id := range ids { switch etype { case "byAuthor": // fetch notes by this author case "message": data, err := self.mk.Notes().Show(id) if err != nil { return err } else { msg := self.toMessage(data, true) if msg != nil { self.send(msg) } } case "children": data, err := self.mk.Notes().Children(n.ChildrenRequest{ NoteID: id, Limit: 100, }) if err != nil { return err } else { for _, n := range data { msg := self.toMessage(n, true) if msg != nil { self.send(msg) } } } case "convoy": data, err := self.mk.Notes().Conversation(n.ConversationRequest{ NoteID: id, Limit: 100, }) if err != nil { return err } else { for _, n := range data { msg := self.toMessage(n, true) if msg != nil { self.send(msg) } } } case "author": user := "" host := "" idParts := strings.Split(id, "@") user = idParts[1] if len(idParts) == 3 { host = idParts[2] } var hostPtr *string = nil if len(host) > 0 { hostPtr = &host } data, err := self.mk.Users().Show(users.ShowRequest{ Username: &user, Host: hostPtr, }) if err != nil { return err } else { a := self.toAuthor(data, false) if a != nil { self.send(a) } } } } return nil } func (self *MisskeyAdapter) Do(action string, data map[string]string) error { return nil } func (self *MisskeyAdapter) DefaultSubscriptionFilter() string { return "" }