From ccdd7c3280f19e7cce0279192a1ab0b7e3e3e8fe Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 13 Jul 2022 15:07:01 -0400 Subject: [PATCH] init --- .gitignore | 3 + client.go | 44 +++++++++++++ config.go | 31 +++++++++ go.mod | 14 ++++ go.sum | 23 +++++++ lazystore.go | 85 ++++++++++++++++++++++++ main.go | 90 ++++++++++++++++++++++++++ reddit.go | 54 ++++++++++++++++ reddit_struct.go | 164 +++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 508 insertions(+) create mode 100644 .gitignore create mode 100644 client.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lazystore.go create mode 100644 main.go create mode 100644 reddit.go create mode 100644 reddit_struct.go 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"` +}