package main import ( "errors" "fmt" "net/url" "os" "strings" "time" mbl "git.saintnet.tech/stryan/matrixbotlib" "github.com/charmbracelet/log" "golang.org/x/exp/slices" "gopkg.in/yaml.v2" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) // SimpConf is the simpbot conf that's not matrix type SimpConf struct { DimensionServer string `yaml:"dimension"` HolodexToken string `yaml:"api_token"` Vtubers []vtuberConfig `yaml:"vtubers"` } type simpState struct { client *mautrix.Client startTime time.Time currStream int maxStream int vtubers []*Vtuber stop chan bool stopRun chan bool change chan int rooms []id.RoomID filename string } type simp struct { Config *SimpConf State *simpState } func newSimp(filename string, client *mautrix.Client) (*simp, error) { cnf := &SimpConf{} state := &simpState{ startTime: time.Now(), stop: make(chan bool), stopRun: make(chan bool), change: make(chan int), client: client, } yamlFile, err := os.ReadFile(filename) if err == nil { err = yaml.Unmarshal(yamlFile, cnf) } else { return nil, err } if err != nil { return nil, err } state.filename = filename for _, vt := range cnf.Vtubers { log.Infof("adding vtuber %v", vt) v := newVtuber(vt.Name, vt.ChannelID, vt.LiveMsg, vt.Announce) state.vtubers = append(state.vtubers, v) } log.Print(cnf) if len(state.vtubers) == 0 { return nil, fmt.Errorf("no vtubers defined") } if cnf.HolodexToken == "" { return nil, fmt.Errorf("no holodex token") } if cnf.DimensionServer == "" { return nil, fmt.Errorf("no dimension server") } return &simp{cnf, state}, nil } func (s *simp) Sync() error { go s.Run() for _, v := range s.State.vtubers { err := v.Update(s.Config.HolodexToken) if err != nil { log.Error("error pinging holodex", "error", err) continue } } for _, r := range s.State.rooms { for _, v := range s.State.vtubers { var content YoutubeWidget err := s.State.client.StateEvent( r, event.NewEventType("im.vector.modular.widgets"), "dimension-m.video-simp-"+v.Name, &content, ) if err != nil { if !errors.Is(err, mautrix.MNotFound) { log.Errorf("error getting state event in room %v: %v", r, err) } } embed := content.ID != "" if v.IsLive() != embed { err := s.Update(v) if err != nil { log.Warn("error updating", "error", err) } } } } log.Info("starting normal sync") ticker := time.NewTicker(time.Second * 45) for { select { case <-s.State.stop: s.State.stopRun <- true return nil case <-ticker.C: for i, v := range s.State.vtubers { cur := v.IsLive() err := v.Update(s.Config.HolodexToken) if err != nil { log.Error("error pinging holodex", "error", err) continue } now := v.IsLive() if cur != now { s.State.change <- i } } } } } func (s *simp) PopulateRoom(room id.RoomID) error { log.Info("updating room", "room", room) for _, v := range s.State.vtubers { log.Printf("Updating vtuber %v", v.Name) if v.IsLive() { if v.AnnounceLive { _, err := s.State.client.SendText(room, v.LiveMsg) if err != nil { log.Warn("error sending message to room", "error", err) } } else { if isValidURL(v.LiveMsg) { _, err := s.State.client.SendNotice(room, fmt.Sprintf("%v has gone live", v.Name)) if err != nil { log.Warn("error updating state", "error", err) } } else { _, err := s.State.client.SendNotice(room, v.LiveMsg) if err != nil { log.Warn("error updating state", "error", err) } } } _, err := s.State.client.SendNotice(room, fmt.Sprintf("%v's Title: %v", v.Name, v.CurrentStreamTitle)) if err != nil { log.Warn("error sending notice", "error", err) } var subs string for k := range v.Subs { subs += k.String() + " " } if len(v.Subs) > 0 { _, err = s.State.client.SendText(room, fmt.Sprintf("Pinging %v", subs)) if err != nil { log.Warn("error sending text", "error", err) } } resp, err := s.State.client.SendStateEvent( room, event.NewEventType("im.vector.modular.widgets"), "dimension-m.video-simp-"+v.Name, s.NewYT(v.Name+"'s stream", v.CurrentStream, string(room)), ) if err != nil { return err } v.TotalStreams = v.TotalStreams + 1 s.State.currStream++ if s.State.currStream > s.State.maxStream { s.State.maxStream = s.State.currStream } log.Info("Embed stream added", "event", resp, "vtuber", v.Name) } } return nil } func (s *simp) Update(v *Vtuber) error { for _, room := range s.State.rooms { if v.IsLive() { if v.AnnounceLive { _, err := s.State.client.SendText(room, v.LiveMsg) if err != nil { log.Warn("error sending text", "error", err) } } else { if isValidURL(v.LiveMsg) { _, err := s.State.client.SendNotice(room, fmt.Sprintf("%v has gone live", v.Name)) if err != nil { return err } } else { _, err := s.State.client.SendNotice(room, v.LiveMsg) if err != nil { return err } } } _, err := s.State.client.SendNotice(room, fmt.Sprintf("%v's Title: %v", v.Name, v.CurrentStreamTitle)) if err != nil { log.Warn("error sending notice", "error", err) } var subs string for k := range v.Subs { subs += k.String() + " " } if len(v.Subs) > 0 { _, err = s.State.client.SendText(room, fmt.Sprintf("Pinging %v", subs)) if err != nil { log.Warn("error sending text", "error", err) } } resp, err := s.State.client.SendStateEvent( room, event.NewEventType("im.vector.modular.widgets"), "dimension-m.video-simp-"+v.Name, s.NewYT(v.Name+"'s stream", v.CurrentStream, string(room)), ) if err != nil { return err } v.TotalStreams = v.TotalStreams + 1 s.State.currStream++ if s.State.currStream > s.State.maxStream { s.State.maxStream = s.State.currStream } log.Info("Embed stream added", "event", resp, "vtuber", v.Name) } else { resp, err := s.State.client.SendStateEvent(room, event.NewEventType("im.vector.modular.widgets"), "dimension-m.video-simp-"+v.Name, struct{}{}) if err != nil { return err } s.State.currStream-- log.Info("Embed stream removed", "event", resp, "vtuber", v.Name) } } return nil } func (s *simp) Run() { for { select { case <-s.State.stopRun: log.Info("stopping runner") return case v := <-s.State.change: err := s.Update(s.State.vtubers[v]) if err != nil { log.Warn("error updating vtube memory", "error", err) } } } } func (s *simp) SetupMatrix() error { err := mbl.SetupAccountDataStore(s.State.client, "s.batch") if err != nil { return err } syncer := s.State.client.Syncer.(*mautrix.DefaultSyncer) uid := s.State.client.UserID.String() syncer.OnEventType(event.EventMessage, func(_ mautrix.EventSource, evt *event.Event) { if evt.Sender == s.State.client.UserID { return // ignore events from self } log.Debugf("<%[1]s> %[4]s (%[2]s/%[3]s)\n", evt.Sender, evt.Type.String(), evt.ID, evt.Content.AsMessage().Body) var cmd []string cmd, err = mbl.ParseCommand(evt, "simp") if err != nil { if err != mbl.ErrCmdParseNoPrefix { log.Printf("invalid command: %v", err) } return } switch cmd[0] { case "info": // print info page var infomsg string vlist := []string{} for _, vt := range s.State.vtubers { ann := "" if vt.AnnounceLive { ann = "*" } vlist = append(vlist, fmt.Sprintf("%v%v", vt.Name, ann)) } infomsg = fmt.Sprintf("Currently Simping For: \n%v", strings.Join(vlist, "\n")) _, err = s.State.client.SendText(evt.RoomID, infomsg) if err != nil { log.Warn("error sending text", "error", err) } case "stats": var statmsg string vlist := []string{} t := 0 for _, vt := range s.State.vtubers { vlist = append(vlist, fmt.Sprintf("%v Total:%v", vt.Name, vt.TotalStreams)) t = t + vt.TotalStreams } statmsg = fmt.Sprintf( "Current Stats Since %v:\n%v\n\nTotal Streams: %v\nMost Concurrent: %v/%v\n", s.State.startTime, strings.Join(vlist, "\n"), t, s.State.maxStream, len(s.State.vtubers), ) _, err = s.State.client.SendText(evt.RoomID, statmsg) if err != nil { log.Warn("error sending text", "error", err) } case "version": _, err = s.State.client.SendText(evt.RoomID, "not implemented") if err != nil { log.Warn("error sending text", "error", err) } case "subscribe": if len(cmd) < 3 { _, err = s.State.client.SendText(evt.RoomID, "Need a member to subscribe to") if err != nil { log.Warn("error sending text", "error", err) } } vt := cmd[1] var subbed bool for _, v := range s.State.vtubers { if strings.EqualFold(v.Name, vt) { v.Subs[evt.Sender] = true subbed = true } } if subbed { _, err = s.State.client.SendText(evt.RoomID, "subbed") if err != nil { log.Warn("error sending text", "error", err) } } else { _, err = s.State.client.SendText(evt.RoomID, "could not identify talent to subscribe to") if err != nil { log.Warn("error sending text", "error", err) } } case "help": _, err = s.State.client.SendText(evt.RoomID, "Supported commands: info,version,stats,subscribe") if err != nil { log.Warn("error sending text", "error", err) } default: // command not found _, err = s.State.client.SendText(evt.RoomID, "command not recognized") if err != nil { log.Warn("error sending text", "error", err) } } }) syncer.OnEventType(event.StateMember, func(_ mautrix.EventSource, evt *event.Event) { // log.Infof("<%[1]s> %[4]s (%[2]s/%[3]s)", evt.Sender, evt.Type.String(), evt.ID, evt.Content.AsMessage().Body) mevt := evt.Content.AsMember() log.Printf("FBL %v", mevt.Membership) if mevt.Membership == event.MembershipInvite { _, err = s.State.client.JoinRoomByID(evt.RoomID) if err != nil { log.Errorf("error joining room %v", evt.RoomID) } else { log.Infof("joined room %v", evt.RoomID) s.State.rooms = append(s.State.rooms, evt.RoomID) } } else if evt.Content.AsMember().Membership.IsLeaveOrBan() { if evt.StateKey != nil && *evt.StateKey == uid { log.Infof("leaving room %v", evt.RoomID) index := slices.Index(s.State.rooms, evt.RoomID) if index != -1 { slices.Delete(s.State.rooms, index, index) } else { log.Warn("asked to leave room not in memory: %v", evt.RoomID) } } } }) roomResp, err := s.State.client.JoinedRooms() if err != nil { return err } s.State.rooms = roomResp.JoinedRooms log.Infof("initial room count: %v", len(s.State.rooms)) return nil } func (s *simp) Stop() { s.State.stop <- true s.State.client.StopSync() } func (s *simp) NewYT(videoName, videoID, roomID string) *YoutubeWidget { encodedVod := url.QueryEscape("https://youtube.com/embed/" + videoID) return &YoutubeWidget{ Type: "im.vector.modular.widgets", URL: "https://" + s.Config.DimensionServer + "/widgets/video?url=" + encodedVod, Name: videoName, Data: VideoData{ VideoURL: "https://www.youtube.com/watch?v=" + videoID, URL: "https://youtube.com/embed/" + videoID, DimensionAppMetadata: DimensionAppMetadata{ InRoomID: roomID, WrapperURLBase: "https://" + s.Config.DimensionServer + "/widgets/video?url=", WrapperID: "video", ScalarWrapperID: "youtube", Integration: Integration{ Category: "widget", Type: "youtube", }, LastUpdatedTS: time.Now().UnixNano() / int64(time.Millisecond), }, }, CreatorUserID: string(s.State.client.UserID), ID: "dimension-m.video-simp", } }