diff --git a/go.mod b/go.mod index 6d3988c..fa4a86a 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,8 @@ module github.com/velour/catbase require ( - github.com/PuerkitoBio/goquery v1.5.0 // indirect + github.com/PaulRosset/go-hacknews v0.0.0-20170815075127-4aad99273a3c + github.com/PuerkitoBio/goquery v1.5.0 github.com/armon/go-radix v1.0.0 // indirect github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff github.com/go-sql-driver/mysql v1.4.1 // indirect @@ -26,7 +27,7 @@ require ( github.com/velour/chat v0.0.0-20180713122344-fd1d1606cb89 github.com/velour/velour v0.0.0-20160303155839-8e090e68d158 golang.org/x/exp v0.0.0-20190321205749-f0864edee7f3 // indirect - golang.org/x/net v0.0.0-20190326090315-15845e8f865b // indirect + golang.org/x/net v0.0.0-20190606173856-1492cefac77f // indirect gonum.org/v1/gonum v0.0.0-20190325211145-e42c1265cdd5 // indirect google.golang.org/appengine v1.4.0 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect diff --git a/go.sum b/go.sum index a5f9830..646cbc7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/PaulRosset/go-hacknews v0.0.0-20170815075127-4aad99273a3c h1:bJ0HbTMaInVjakxM76G+2gsmbKTdHzpTUGyLGYxdMO0= +github.com/PaulRosset/go-hacknews v0.0.0-20170815075127-4aad99273a3c/go.mod h1:8+24kIp7vJsYy0GmQDDNnPwAYEWkl3OcaPxJSDAfe1U= github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= @@ -74,8 +76,8 @@ golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190326090315-15845e8f865b h1:LlDMQZ0I/u8J45sbt31TecpsFNErRGwDgS4WvT9hKzE= -golang.org/x/net v0.0.0-20190326090315-15845e8f865b/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190606173856-1492cefac77f h1:IWHgpgFqnL5AhBUBZSgBdjl2vkQUEzcY+JNKWfcgAU0= +golang.org/x/net v0.0.0-20190606173856-1492cefac77f/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= diff --git a/main.go b/main.go index 8a1fdae..9e9dcb8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( "flag" "github.com/velour/catbase/plugins/cli" + "github.com/velour/catbase/plugins/newsbid" "math/rand" "net/http" "os" @@ -124,6 +125,7 @@ func main() { b.AddPlugin(nerdepedia.New(b)) b.AddPlugin(tldr.New(b)) b.AddPlugin(stock.New(b)) + b.AddPlugin(newsbid.New(b)) b.AddPlugin(cli.New(b)) // catches anything left, will always return true b.AddPlugin(fact.New(b)) diff --git a/plugins/newsbid/newsbid.go b/plugins/newsbid/newsbid.go new file mode 100644 index 0000000..ffdb2fc --- /dev/null +++ b/plugins/newsbid/newsbid.go @@ -0,0 +1,115 @@ +package newsbid + +import ( + "fmt" + "github.com/jmoiron/sqlx" + "github.com/velour/catbase/bot" + "github.com/velour/catbase/bot/msg" + "github.com/velour/catbase/plugins/newsbid/webshit" + "sort" + "strconv" + "strings" +) + +type NewsBid struct { + bot bot.Bot + db *sqlx.DB + ws *webshit.Webshit +} + +func New(b bot.Bot) *NewsBid { + ws := webshit.New(b.DB()) + p := &NewsBid{ + bot: b, + db: b.DB(), + ws: ws, + } + p.bot.Register(p, bot.Message, p.message) + return p +} + +func (p *NewsBid) message(conn bot.Connector, k bot.Kind, message msg.Message, args ...interface{}) bool { + body := strings.ToLower(message.Body) + ch := message.Channel + if message.Command && body == "balance" { + bal := p.ws.GetBalance(message.User.Name) + p.bot.Send(conn, bot.Message, ch, fmt.Sprintf("%s, your current balance is %d.", + message.User.Name, bal)) + return true + } + if message.Command && body == "bids" { + bids, err := p.ws.GetAllBids() + if err != nil { + p.bot.Send(conn, bot.Message, ch, fmt.Sprintf("Error getting bids: %s", err)) + return true + } + if len(bids) == 0 { + p.bot.Send(conn, bot.Message, ch, "No bids to report.") + return true + } + sort.Slice(bids, func(i, j int) bool { return bids[i].User < bids[j].User }) + out := "Bids:\n" + for _, b := range bids { + out += fmt.Sprintf("%s bid %d on %s\n", b.User, b.Bid, b.Title) + } + p.bot.Send(conn, bot.Message, ch, out) + return true + } + if message.Command && body == "scores" { + bals, err := p.ws.GetAllBalances() + if err != nil { + p.bot.Send(conn, bot.Message, ch, fmt.Sprintf("Error getting bids: %s", err)) + return true + } + if len(bals) == 0 { + p.bot.Send(conn, bot.Message, ch, "No balances to report.") + return true + } + out := "NGate balances:\n" + for _, b := range bals { + out += fmt.Sprintf("%s has a total score of %d with %d left to bid this session\n", b.User, b.Score, b.Balance) + } + p.bot.Send(conn, bot.Message, ch, out) + return true + + } + if message.Command && strings.HasPrefix(body, "bid") { + parts := strings.Fields(body) + if len(parts) != 3 { + p.bot.Send(conn, bot.Message, ch, "You must bid with an amount and a URL.") + return true + } + amount, _ := strconv.Atoi(parts[1]) + url := parts[2] + if err := p.ws.Bid(message.User.Name, amount, url); err != nil { + p.bot.Send(conn, bot.Message, ch, fmt.Sprintf("Error placing bid: %s", err)) + } else { + p.bot.Send(conn, bot.Message, ch, "Your bid has been placed.") + } + return true + } + if message.Command && body == "check ngate" { + p.check(conn, ch) + return true + } + return false +} + +func (p *NewsBid) check(conn bot.Connector, ch string) { + wr, err := p.ws.Check() + if err != nil { + p.bot.Send(conn, bot.Message, ch, fmt.Sprintf("Error checking ngate: %s", err)) + return + } + for _, res := range wr { + msg := fmt.Sprintf("%s won %d and lost %d for a score of %d", + res.User, res.Won, res.Lost, res.Score) + if len(res.WinningArticles) > 0 { + msg += "\nWinning articles: " + res.WinningArticles.Titles() + } + if len(res.LosingArticles) > 0 { + msg += "\nLosing articles: " + res.LosingArticles.Titles() + } + p.bot.Send(conn, bot.Message, ch, msg) + } +} diff --git a/plugins/newsbid/webshit/webshit.go b/plugins/newsbid/webshit/webshit.go new file mode 100644 index 0000000..b8e7d6b --- /dev/null +++ b/plugins/newsbid/webshit/webshit.go @@ -0,0 +1,358 @@ +package webshit + +import ( + "bytes" + "fmt" + "github.com/PaulRosset/go-hacknews" + "github.com/PuerkitoBio/goquery" + "github.com/jmoiron/sqlx" + "github.com/mmcdole/gofeed" + "github.com/rs/zerolog/log" + "net/http" + "net/url" + "strings" + "time" +) + +type Config struct { + HNFeed string + HNLimit int + BalanceReferesh int +} + +var DefaultConfig = Config{ + HNFeed: "topstories", + HNLimit: 10, + BalanceReferesh: 100, +} + +type Webshit struct { + db *sqlx.DB + config Config +} + +type Story struct { + Title string + URL string +} + +type Stories []Story + +func (s Stories) Titles() string { + out := "" + for i, v := range s { + if i > 0 { + out += ", " + } + out += v.Title + } + return out +} + +type Bid struct { + ID int + User string + Title string + URL string + Bid int + Placed int64 +} + +func (b Bid) PlacedParsed() time.Time { + return time.Unix(b.Placed, 0) +} + +type Balance struct { + User string + Balance int + Score int +} + +type WeeklyResult struct { + User string + Won int + WinningArticles Stories + Lost int + LosingArticles Stories + Score int +} + +func New(db *sqlx.DB) *Webshit { + return NewConfig(db, DefaultConfig) +} + +func NewConfig(db *sqlx.DB, cfg Config) *Webshit { + w := &Webshit{db: db, config: cfg} + w.setup() + return w +} + +// setup will create any necessary SQL tables and populate them with minimal data +func (w *Webshit) setup() { + w.db.MustExec(`create table if not exists webshit_bids ( + id integer primary key autoincrement, + user string, + title string, + url string, + bid integer, + placed integer + )`) + w.db.MustExec(`create table if not exists webshit_balances ( + user string primary key, + balance int, + score int + )`) +} + +func (w *Webshit) Check() ([]WeeklyResult, error) { + stories, published, err := w.GetWeekly() + if err != nil { + return nil, err + } + + var bids []Bid + if err = w.db.Select(&bids, `select user,title,url,bid from webshit_bids where placed < ?`, + published.Unix()); err != nil { + return nil, err + } + + // Assuming no bids earlier than the weekly means there hasn't been a new weekly + if len(bids) == 0 { + return nil, fmt.Errorf("there are no bids against the current ngate post") + } + + storyMap := map[string]Story{} + for _, s := range stories { + storyMap[s.Title] = s + } + + wr := w.checkBids(bids, storyMap) + + // Update all balance scores in a tx + if err := w.updateScores(wr); err != nil { + return nil, err + } + + // Delete all those bids + if _, err = w.db.Exec(`delete from webshit_bids where placed < ?`, + published.Unix()); err != nil { + return nil, err + } + + // Set all balances to 100 + if _, err = w.db.Exec(`update webshit_balances set balance=?`, + w.config.BalanceReferesh); err != nil { + return nil, err + } + + return wr, nil +} + +func (w *Webshit) checkBids(bids []Bid, storyMap map[string]Story) []WeeklyResult { + wr := map[string]WeeklyResult{} + for _, b := range bids { + score := w.GetScore(b.User) + if _, ok := wr[b.User]; !ok { + wr[b.User] = WeeklyResult{ + User: b.User, + Won: 0, + Lost: 0, + Score: score, + } + } + rec := wr[b.User] + + if s, ok := storyMap[b.Title]; ok { + log.Debug().Interface("story", s).Msg("won bid") + rec.Won += b.Bid + rec.Score += b.Bid + rec.WinningArticles = append(rec.WinningArticles, s) + log.Debug().Interface("story", s).Msg("Appending to winning log") + } else { + log.Debug().Interface("story", s).Msg("lost bid") + rec.Lost += b.Bid + rec.Score -= b.Bid + rec.LosingArticles = append(rec.LosingArticles, Story{Title: b.Title, URL: b.URL}) + log.Debug().Interface("story", s).Msg("Appending to losing log") + } + wr[b.User] = rec + log.Debug().Interface("WR User", wr[b.User]).Str("user", b.User).Msg("setting WR") + } + return wrMapToSlice(wr) +} + +// GetHeadlines will return the current possible news headlines for bidding +func (w *Webshit) GetHeadlines() ([]Story, error) { + news := hacknews.Initializer{Story: w.config.HNFeed, NbPosts: w.config.HNLimit} + ids, err := news.GetCodesStory() + if err != nil { + return nil, err + } + posts, err := news.GetPostStory(ids) + if err != nil { + return nil, err + } + var stories []Story + for _, p := range posts { + stories = append(stories, Story{ + Title: p.Title, + URL: p.Url, + }) + } + return stories, nil +} + +// GetWeekly will return the headlines in the last webshit weekly report +func (w *Webshit) GetWeekly() ([]Story, *time.Time, error) { + fp := gofeed.NewParser() + feed, err := fp.ParseURL("http://n-gate.com/hackernews/index.rss") + if err != nil { + return nil, nil, err + } + if len(feed.Items) <= 0 { + return nil, nil, fmt.Errorf("no webshit weekly found") + } + + published := feed.Items[0].PublishedParsed + + buf := bytes.NewBufferString(feed.Items[0].Description) + doc, err := goquery.NewDocumentFromReader(buf) + if err != nil { + return nil, nil, err + } + + var items []Story + doc.Find(".storylink").Each(func(i int, s *goquery.Selection) { + story := Story{ + Title: s.Find("a").Text(), + URL: s.Find("a").AttrOr("src", ""), + } + items = append(items, story) + }) + + return items, published, nil +} + +// GetBalances returns the current balance for all known users +// Any unknown user has a default balance on their first bid +func (w *Webshit) GetBalance(user string) int { + q := `select balance from webshit_balances where user=?` + var balance int + err := w.db.Get(&balance, q, user) + if err != nil { + return 100 + } + return balance +} + +func (w *Webshit) GetScore(user string) int { + q := `select score from webshit_balances where user=?` + var score int + err := w.db.Get(&score, q, user) + if err != nil { + return 0 + } + return score +} + +func (w *Webshit) GetAllBids() ([]Bid, error) { + var bids []Bid + err := w.db.Select(&bids, `select * from webshit_bids`) + if err != nil { + return nil, err + } + return bids, nil +} + +func (w *Webshit) GetAllBalances() ([]Balance, error) { + var balances []Balance + err := w.db.Select(&balances, `select * from webshit_balances`) + if err != nil { + return nil, err + } + return balances, nil +} + +// Bid allows a user to place a bid on a particular story +func (w *Webshit) Bid(user string, amount int, URL string) error { + bal := w.GetBalance(user) + if bal < amount { + return fmt.Errorf("cannot bid more than balance, %d", bal) + } + story, err := w.getStoryByURL(URL) + if err != nil { + return err + } + + tx := w.db.MustBegin() + _, err = tx.Exec(`insert into webshit_bids (user,title,url,bid,placed) values (?,?,?,?,?)`, + user, story.Title, story.URL, amount, time.Now().Unix()) + if err != nil { + tx.Rollback() + return err + } + q := `insert into webshit_balances (user,balance,score) values (?,?,0) + on conflict(user) do update set balance=?` + _, err = tx.Exec(q, user, bal-amount, bal-amount) + if err != nil { + tx.Rollback() + return err + } + tx.Commit() + + return err +} + +// getStoryByURL scrapes the URL for a title +func (w *Webshit) getStoryByURL(URL string) (Story, error) { + u, err := url.Parse(URL) + if err != nil { + return Story{}, err + } + if u.Host != "news.ycombinator.com" { + return Story{}, fmt.Errorf("expected HN link") + } + res, err := http.Get(URL) + if err != nil { + return Story{}, err + } + defer res.Body.Close() + if res.StatusCode != 200 { + return Story{}, fmt.Errorf("bad response code: %d", res.StatusCode) + } + + // Load the HTML document + doc, err := goquery.NewDocumentFromReader(res.Body) + if err != nil { + return Story{}, err + } + + // Find the review items + title := doc.Find("title").Text() + title = strings.ReplaceAll(title, " | Hacker News", "") + return Story{ + Title: title, + URL: URL, + }, nil +} + +func (w *Webshit) updateScores(results []WeeklyResult) error { + tx := w.db.MustBegin() + for _, res := range results { + if _, err := tx.Exec(`update webshit_balances set score=? where user=?`, + res.Score, res.User); err != nil { + tx.Rollback() + return err + } + } + err := tx.Commit() + return err +} + +func wrMapToSlice(wr map[string]WeeklyResult) []WeeklyResult { + var out = []WeeklyResult{} + for _, r := range wr { + out = append(out, r) + } + return out +} diff --git a/plugins/newsbid/webshit/webshit_test.go b/plugins/newsbid/webshit/webshit_test.go new file mode 100644 index 0000000..92decbc --- /dev/null +++ b/plugins/newsbid/webshit/webshit_test.go @@ -0,0 +1,77 @@ +package webshit + +import ( + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "os" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func init() { + log.Logger = log.Logger.Output(zerolog.ConsoleWriter{Out: os.Stderr}) +} + +func make(t *testing.T) *Webshit { + db := sqlx.MustOpen("sqlite3", "file::memory:?mode=memory&cache=shared") + w := New(db) + assert.Equal(t, w.db, db) + return w +} + +func TestWebshit_GetWeekly(t *testing.T) { + w := make(t) + weekly, pub, err := w.GetWeekly() + t.Logf("Pub: %v", pub) + assert.NotNil(t, pub) + assert.Nil(t, err) + assert.NotEmpty(t, weekly) +} + +func TestWebshit_GetHeadlines(t *testing.T) { + w := make(t) + headlines, err := w.GetHeadlines() + assert.Nil(t, err) + assert.NotEmpty(t, headlines) +} + +func TestWebshit_getStoryByURL(t *testing.T) { + w := make(t) + expected := "Developer Tropes: “Google Does It”" + s, err := w.getStoryByURL("https://news.ycombinator.com/item?id=20432887") + assert.Nil(t, err) + assert.Equal(t, s.Title, expected) +} + +func TestWebshit_getStoryByURL_BadURL(t *testing.T) { + w := make(t) + _, err := w.getStoryByURL("https://google.com") + assert.Error(t, err) +} + +func TestWebshit_GetBalance(t *testing.T) { + w := make(t) + expected := 100 + actual := w.GetBalance("foo") + assert.Equal(t, expected, actual) +} + +func TestWebshit_checkBids(t *testing.T) { + w := make(t) + bids := []Bid{ + Bid{User: "foo", Title: "bar", URL: "baz", Bid: 10}, + Bid{User: "foo", Title: "bar2", URL: "baz2", Bid: 10}, + } + storyMap := map[string]Story{ + "bar": Story{Title: "bar", URL: "baz"}, + } + result := w.checkBids(bids, storyMap) + assert.Len(t, result, 1) + if len(result) > 0 { + assert.Len(t, result[0].WinningArticles, 1) + assert.Len(t, result[0].LosingArticles, 1) + } +}