commit ccdd7c3280f19e7cce0279192a1ab0b7e3e3e8fe Author: Steve Date: Wed Jul 13 15:07:01 2022 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d22f12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.yaml +statedata +config.yml diff --git a/client.go b/client.go new file mode 100644 index 0000000..c69f7b1 --- /dev/null +++ b/client.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +func newMatrixClient(config *botConfig) *mautrix.Client { + fmt.Println("Logging into", config.Homeserver, "as", config.Username) + var client *mautrix.Client + var err error + if config.Token == "" { + client, err = mautrix.NewClient(config.Homeserver, "", "") + if err != nil { + panic(err) + } + } else { + log.Println("using token login") + client, err = mautrix.NewClient(config.Homeserver, id.NewUserID(config.Username, config.Domain), config.Token) + if err != nil { + panic(err) + } + } + client.Store = NewLazyMemStore(config.Statefile) + if config.Token == "" { + loginRes, err := client.Login(&mautrix.ReqLogin{ + Type: "m.login.password", + Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: config.Username}, + Password: config.Password, + StoreCredentials: true, + }) + if err != nil { + panic(err) + } + config.Token = loginRes.AccessToken + log.Println("Login succesful, saving access_token to config file") + } else { + log.Println("skipping login since token provided") + } + return client +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..236256a --- /dev/null +++ b/config.go @@ -0,0 +1,31 @@ +package main + +import ( + "io/ioutil" + + "gopkg.in/yaml.v2" +) + +type botConfig struct { + Homeserver string `yaml:"homeserver"` + Domain string `yaml:"domain"` + Dimension string `yaml:"dimension"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Statefile string `yaml:"statefile"` + Token string `yaml:"token"` +} + +func loadConfig(filename string) *botConfig { + yamlFile, err := ioutil.ReadFile(filename) + cnf := &botConfig{} + if err == nil { + err = yaml.Unmarshal(yamlFile, cnf) + } else { + panic(err) + } + if err != nil { + panic(err) + } + return cnf +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..88a2deb --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.saintnet.tech/stryan/nunbot + +go 1.18 + +require ( + gopkg.in/yaml.v2 v2.4.0 + maunium.net/go/mautrix v0.11.0 +) + +require ( + github.com/stretchr/testify v1.8.0 // indirect + golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 // indirect + golang.org/x/net v0.0.0-20220513224357-95641704303c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b954c48 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c= +golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20220513224357-95641704303c h1:nF9mHSvoKBLkQNQhJZNsc66z2UzAMUbLGjC95CF3pU0= +golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.11.0 h1:B1FBHcvE4Mud+AC+zgNQQOw0JxSVrt40watCejhVA7w= +maunium.net/go/mautrix v0.11.0/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA= diff --git a/lazystore.go b/lazystore.go new file mode 100644 index 0000000..713e5fc --- /dev/null +++ b/lazystore.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "encoding/gob" + + "os" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type LazyMemStore struct { + mem *mautrix.InMemoryStore + NextBatch map[id.UserID]string + saveFile string +} + +func NewLazyMemStore(fileloc string) *LazyMemStore { + return &LazyMemStore{ + mem: mautrix.NewInMemoryStore(), + NextBatch: make(map[id.UserID]string), + saveFile: fileloc, + } +} + +func (l *LazyMemStore) SaveFilterID(userID id.UserID, filterID string) { + l.mem.SaveFilterID(userID, filterID) +} + +func (l *LazyMemStore) LoadFilterID(userID id.UserID) string { + return l.mem.LoadFilterID(userID) +} + +func (l *LazyMemStore) SaveNextBatch(userID id.UserID, nextBatchToken string) { + b := new(bytes.Buffer) + l.NextBatch[userID] = nextBatchToken + e := gob.NewEncoder(b) + err := e.Encode(l.NextBatch) + if err != nil { + panic(err) + } + if err := os.WriteFile(l.saveFile, b.Bytes(), 0666); err != nil { + panic(err) + } + +} + +func (l *LazyMemStore) LoadNextBatch(userID id.UserID) string { + + dat, err := os.ReadFile(l.saveFile) + if err != nil { + if os.IsNotExist(err) { + b := new(bytes.Buffer) + e := gob.NewEncoder(b) + err := e.Encode(l.NextBatch) + if err != nil { + panic(err) + } + if err := os.WriteFile(l.saveFile, b.Bytes(), 0666); err != nil { + panic(err) + } + dat, err = os.ReadFile(l.saveFile) + if err != nil { + panic(err) + } + } else { + panic(err) + } + } + d := gob.NewDecoder(bytes.NewBuffer(dat)) + err = d.Decode(&l.NextBatch) + if err != nil { + panic(err) + } + return l.NextBatch[userID] +} + +func (l *LazyMemStore) SaveRoom(room *mautrix.Room) { + l.mem.SaveRoom(room) +} + +func (l *LazyMemStore) LoadRoom(roomID id.RoomID) *mautrix.Room { + return l.mem.LoadRoom(roomID) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e81caa3 --- /dev/null +++ b/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +//GitTag is current git tag +var GitTag string + +//GitCommit is current git commit +var GitCommit string + +func main() { + conf := loadConfig("config.yaml") + matrixClient := newMatrixClient(conf) + //redditClient := newRedditClient(conf) + syncer := matrixClient.Syncer.(*mautrix.DefaultSyncer) + syncer.OnEventType(event.EventMessage, func(source mautrix.EventSource, evt *event.Event) { + if evt.Sender == matrixClient.UserID { + return //ignore events from self + } + fmt.Printf("<%[1]s> %[4]s (%[2]s/%[3]s)\n", evt.Sender, evt.Type.String(), evt.ID, evt.Content.AsMessage().Body) + body := evt.Content.AsMessage().Body + bodyS := strings.Split(body, " ") + if bodyS[0] != "!nun" { + return + } + if len(bodyS) < 2 { + return //nothing to parse + } + switch bodyS[1] { + case "version": + // print version + if GitTag != "" { + matrixClient.SendText(evt.RoomID, "NunBot version "+GitTag) + } else { + matrixClient.SendText(evt.RoomID, "NunBot version "+GitCommit) + } + case "help": + matrixClient.SendText(evt.RoomID, "Supported commands: version,stats") + default: + //command not found + matrixClient.SendText(evt.RoomID, "command not recognized") + } + }) + syncer.OnEventType(event.StateMember, func(source mautrix.EventSource, evt *event.Event) { + fmt.Printf("<%[1]s> %[4]s (%[2]s/%[3]s)\n", evt.Sender, evt.Type.String(), evt.ID, evt.Content.AsMessage().Body) + if evt.Content.AsMember().Membership.IsInviteOrJoin() { + _, err := matrixClient.JoinRoomByID(evt.RoomID) + if err != nil { + fmt.Printf("error joining room %v", evt.RoomID) + } else { + fmt.Printf("joined room %v", evt.RoomID) + } + } + }) + + var curPost post + go func() { + for { + time.Sleep(30 * time.Second) + newPost := getNewestPost("LittleNuns") + if curPost.Title != newPost.Title { + curPost = newPost + roomResp, err := matrixClient.JoinedRooms() + if err != nil { + log.Printf("error getting joined rooms: %v", err) + continue + } + rooms := roomResp.JoinedRooms + for _, room := range rooms { + matrixClient.SendText(room, fmt.Sprintf("%v\n%v", curPost.Title, curPost.Link)) + } + + } + } + }() + + err := matrixClient.Sync() + if err != nil { + panic(err) + } + +} diff --git a/reddit.go b/reddit.go new file mode 100644 index 0000000..4042179 --- /dev/null +++ b/reddit.go @@ -0,0 +1,54 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "time" +) + +type post struct { + Title string + Link string +} + +func getNewestPost(subreddit string) post { + var resp redditResp + //building request from scratch because reddit api is weird + url := fmt.Sprintf("https://www.reddit.com/r/%v/new.json?sort=new&limit=1", subreddit) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "Custom Agent") + req.Header.Set("Host", "reddit.com") + var defaultClient = http.Client{ + Transport: &http.Transport{ + TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, + }, + } + res, err := defaultClient.Do(req) + if err != nil { + log.Printf("Got %v, retrying in 5s", err) + time.Sleep(5 * time.Second) + res, err = http.Get(url) + if err != nil { + log.Printf("Got %v, not trying again", err) + return post{} + } + } + defer res.Body.Close() + body, readErr := ioutil.ReadAll(res.Body) + if readErr != nil { + log.Fatalf("error reading reddit resp: %v", readErr) + } + err = json.Unmarshal(body, &resp) + if err != nil { + log.Fatalf("unmarshal error: %v", err) + } + + return post{ + Title: resp.Data.Children[0].Data.Title, + Link: resp.Data.Children[0].Data.URL, + } +} diff --git a/reddit_struct.go b/reddit_struct.go new file mode 100644 index 0000000..eb93d8e --- /dev/null +++ b/reddit_struct.go @@ -0,0 +1,164 @@ +package main + +type redditResp struct { + Kind string `json:"kind"` + Data struct { + After string `json:"after"` + Dist int `json:"dist"` + Modhash string `json:"modhash"` + GeoFilter string `json:"geo_filter"` + Children []struct { + Kind string `json:"kind"` + Data struct { + ApprovedAtUtc interface{} `json:"approved_at_utc"` + Subreddit string `json:"subreddit"` + Selftext string `json:"selftext"` + AuthorFullname string `json:"author_fullname"` + Saved bool `json:"saved"` + ModReasonTitle interface{} `json:"mod_reason_title"` + Gilded int `json:"gilded"` + Clicked bool `json:"clicked"` + Title string `json:"title"` + LinkFlairRichtext []interface{} `json:"link_flair_richtext"` + SubredditNamePrefixed string `json:"subreddit_name_prefixed"` + Collections []struct { + Permalink string `json:"permalink"` + LinkIds []string `json:"link_ids"` + Description string `json:"description"` + Title string `json:"title"` + CreatedAtUtc float64 `json:"created_at_utc"` + SubredditID string `json:"subreddit_id"` + AuthorName string `json:"author_name"` + CollectionID string `json:"collection_id"` + AuthorID string `json:"author_id"` + LastUpdateUtc float64 `json:"last_update_utc"` + DisplayLayout string `json:"display_layout"` + } `json:"collections"` + Hidden bool `json:"hidden"` + Pwls interface{} `json:"pwls"` + LinkFlairCSSClass interface{} `json:"link_flair_css_class"` + Downs int `json:"downs"` + ThumbnailHeight int `json:"thumbnail_height"` + TopAwardedType interface{} `json:"top_awarded_type"` + HideScore bool `json:"hide_score"` + Name string `json:"name"` + Quarantine bool `json:"quarantine"` + LinkFlairTextColor string `json:"link_flair_text_color"` + UpvoteRatio float64 `json:"upvote_ratio"` + AuthorFlairBackgroundColor string `json:"author_flair_background_color"` + Ups int `json:"ups"` + TotalAwardsReceived int `json:"total_awards_received"` + MediaEmbed struct { + } `json:"media_embed"` + ThumbnailWidth int `json:"thumbnail_width"` + AuthorFlairTemplateID string `json:"author_flair_template_id"` + IsOriginalContent bool `json:"is_original_content"` + UserReports []interface{} `json:"user_reports"` + SecureMedia interface{} `json:"secure_media"` + IsRedditMediaDomain bool `json:"is_reddit_media_domain"` + IsMeta bool `json:"is_meta"` + Category interface{} `json:"category"` + SecureMediaEmbed struct { + } `json:"secure_media_embed"` + LinkFlairText interface{} `json:"link_flair_text"` + CanModPost bool `json:"can_mod_post"` + Score int `json:"score"` + ApprovedBy interface{} `json:"approved_by"` + IsCreatedFromAdsUI bool `json:"is_created_from_ads_ui"` + AuthorPremium bool `json:"author_premium"` + Thumbnail string `json:"thumbnail"` + Edited bool `json:"edited"` + AuthorFlairCSSClass string `json:"author_flair_css_class"` + AuthorFlairRichtext []struct { + A string `json:"a,omitempty"` + E string `json:"e"` + U string `json:"u,omitempty"` + T string `json:"t,omitempty"` + } `json:"author_flair_richtext"` + Gildings struct { + } `json:"gildings"` + PostHint string `json:"post_hint"` + ContentCategories interface{} `json:"content_categories"` + IsSelf bool `json:"is_self"` + SubredditType string `json:"subreddit_type"` + Created float64 `json:"created"` + LinkFlairType string `json:"link_flair_type"` + Wls interface{} `json:"wls"` + RemovedByCategory interface{} `json:"removed_by_category"` + BannedBy interface{} `json:"banned_by"` + AuthorFlairType string `json:"author_flair_type"` + Domain string `json:"domain"` + AllowLiveComments bool `json:"allow_live_comments"` + SelftextHTML interface{} `json:"selftext_html"` + Likes interface{} `json:"likes"` + SuggestedSort interface{} `json:"suggested_sort"` + BannedAtUtc interface{} `json:"banned_at_utc"` + URLOverriddenByDest string `json:"url_overridden_by_dest"` + ViewCount interface{} `json:"view_count"` + Archived bool `json:"archived"` + NoFollow bool `json:"no_follow"` + IsCrosspostable bool `json:"is_crosspostable"` + Pinned bool `json:"pinned"` + Over18 bool `json:"over_18"` + Preview struct { + Images []struct { + Source struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"source"` + Resolutions []struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"resolutions"` + Variants struct { + } `json:"variants"` + ID string `json:"id"` + } `json:"images"` + Enabled bool `json:"enabled"` + } `json:"preview"` + AllAwardings []interface{} `json:"all_awardings"` + Awarders []interface{} `json:"awarders"` + MediaOnly bool `json:"media_only"` + CanGild bool `json:"can_gild"` + Spoiler bool `json:"spoiler"` + Locked bool `json:"locked"` + AuthorFlairText string `json:"author_flair_text"` + TreatmentTags []interface{} `json:"treatment_tags"` + Visited bool `json:"visited"` + RemovedBy interface{} `json:"removed_by"` + ModNote interface{} `json:"mod_note"` + Distinguished interface{} `json:"distinguished"` + SubredditID string `json:"subreddit_id"` + AuthorIsBlocked bool `json:"author_is_blocked"` + ModReasonBy interface{} `json:"mod_reason_by"` + NumReports interface{} `json:"num_reports"` + RemovalReason interface{} `json:"removal_reason"` + LinkFlairBackgroundColor string `json:"link_flair_background_color"` + ID string `json:"id"` + IsRobotIndexable bool `json:"is_robot_indexable"` + ReportReasons interface{} `json:"report_reasons"` + Author string `json:"author"` + DiscussionType interface{} `json:"discussion_type"` + NumComments int `json:"num_comments"` + SendReplies bool `json:"send_replies"` + WhitelistStatus interface{} `json:"whitelist_status"` + ContestMode bool `json:"contest_mode"` + ModReports []interface{} `json:"mod_reports"` + AuthorPatreonFlair bool `json:"author_patreon_flair"` + AuthorFlairTextColor string `json:"author_flair_text_color"` + Permalink string `json:"permalink"` + ParentWhitelistStatus interface{} `json:"parent_whitelist_status"` + Stickied bool `json:"stickied"` + URL string `json:"url"` + SubredditSubscribers int `json:"subreddit_subscribers"` + CreatedUtc float64 `json:"created_utc"` + NumCrossposts int `json:"num_crossposts"` + Media interface{} `json:"media"` + IsVideo bool `json:"is_video"` + } `json:"data"` + } `json:"children"` + Before interface{} `json:"before"` + } `json:"data"` +}