Merge pull request #189 from velour/webshit

Webshit: a game
This commit is contained in:
Chris Sexton 2019-07-15 22:04:18 -04:00 committed by GitHub
commit aeaf102479
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 559 additions and 4 deletions

5
go.mod
View File

@ -1,7 +1,8 @@
module github.com/velour/catbase module github.com/velour/catbase
require ( 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/armon/go-radix v1.0.0 // indirect
github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff
github.com/go-sql-driver/mysql v1.4.1 // indirect 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/chat v0.0.0-20180713122344-fd1d1606cb89
github.com/velour/velour v0.0.0-20160303155839-8e090e68d158 github.com/velour/velour v0.0.0-20160303155839-8e090e68d158
golang.org/x/exp v0.0.0-20190321205749-f0864edee7f3 // indirect 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 gonum.org/v1/gonum v0.0.0-20190325211145-e42c1265cdd5 // indirect
google.golang.org/appengine v1.4.0 // indirect google.golang.org/appengine v1.4.0 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect

6
go.sum
View File

@ -1,6 +1,8 @@
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= 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/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 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= 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-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-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-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-20190606173856-1492cefac77f h1:IWHgpgFqnL5AhBUBZSgBdjl2vkQUEzcY+JNKWfcgAU0=
golang.org/x/net v0.0.0-20190326090315-15845e8f865b/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=

View File

@ -5,6 +5,7 @@ package main
import ( import (
"flag" "flag"
"github.com/velour/catbase/plugins/cli" "github.com/velour/catbase/plugins/cli"
"github.com/velour/catbase/plugins/newsbid"
"math/rand" "math/rand"
"net/http" "net/http"
"os" "os"
@ -124,6 +125,7 @@ func main() {
b.AddPlugin(nerdepedia.New(b)) b.AddPlugin(nerdepedia.New(b))
b.AddPlugin(tldr.New(b)) b.AddPlugin(tldr.New(b))
b.AddPlugin(stock.New(b)) b.AddPlugin(stock.New(b))
b.AddPlugin(newsbid.New(b))
b.AddPlugin(cli.New(b)) b.AddPlugin(cli.New(b))
// catches anything left, will always return true // catches anything left, will always return true
b.AddPlugin(fact.New(b)) b.AddPlugin(fact.New(b))

115
plugins/newsbid/newsbid.go Normal file
View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}