Compare commits

..

99 Commits

Author SHA1 Message Date
Chris Sexton 95ae28aa0f web: format time string 2024-02-28 17:07:53 -05:00
Chris Sexton 25f9171223 web: add stats page 2024-02-28 14:35:46 -05:00
Chris Sexton 07470c5127 emojy: idiot forgot to generate 2024-02-28 10:57:55 -05:00
Chris Sexton 61b71362b9 emojy: up more tiles 2024-02-28 10:52:38 -05:00
Chris Sexton 8ba0ec076e web: generate 2024-02-28 10:45:29 -05:00
Chris Sexton ec008a8884 web: fix emojy stretching and meme display 2024-02-28 10:45:29 -05:00
Chris Sexton 0a775b78b7 fact: use foundation 2024-02-28 10:31:37 -05:00
Chris Sexton 718ee32165 emojy: use foundation 2024-02-28 10:31:37 -05:00
Chris Sexton a01e52b4d4 meme: use foundation 2024-02-28 10:31:37 -05:00
Chris Sexton bd20d4001b counter: use foundation 2024-02-28 10:31:37 -05:00
Chris Sexton 8e2c55f8bd web: convert admin and secrets to foundation 2024-02-28 10:31:37 -05:00
Chris Sexton 0ce8a21e0e fact: fix formatting 2024-02-27 22:21:44 -05:00
Chris Sexton 344bcb9f64 fact: templ and htmx 2024-02-27 22:05:06 -05:00
Chris Sexton 41e8241cb5 emojy: add updated versions 2024-02-27 21:49:03 -05:00
Chris Sexton b66038354a meme: finishing up templating 2024-02-27 21:49:03 -05:00
Chris Sexton b20da607bc cli: finish removing references from tests 2024-02-27 17:30:36 -05:00
Chris Sexton a5e919733c meme: use templ and htmx 2024-02-27 17:30:36 -05:00
Chris Sexton 2d06fd6be8 cli: remove dead plugin 2024-02-27 17:30:36 -05:00
Chris Sexton b4f9f902ce counter: use templ and htmx 2024-02-27 17:30:36 -05:00
Chris Sexton f83cc32788 web: refactor and convert secrets 2024-02-27 17:30:36 -05:00
Chris Sexton 3e3cc3cf95 admin: remove html template 2024-02-27 17:30:36 -05:00
Chris Sexton f6b1712eda admin: use htmx and templ for app pass 2024-02-27 17:30:36 -05:00
Chris Sexton b8e6e0595d admin: vars use templ 2024-02-27 17:30:36 -05:00
Chris Sexton e668fbe688 project: ignore binary 2024-02-27 17:30:36 -05:00
Chris Sexton 12f4e51ba5 project: ignore binary 2024-02-27 17:30:36 -05:00
Chris Sexton 00352a1512 bot: use templ for some pages 2024-02-27 17:30:36 -05:00
Chris Sexton c089a80ffc tldr: fix set bug 2024-01-12 10:30:01 -05:00
Chris Sexton 3ff95d3c85 babbler: these intermittently fail so fuck them 2024-01-12 10:18:30 -05:00
Chris Sexton 1743b65242 tldr: add squawk command 2024-01-12 10:18:30 -05:00
Chris Sexton 0397fa2897 tldr: add prompt setting and optional ; 2024-01-12 10:09:44 -05:00
Chris Sexton 852239e89d tldr: reverse enter and respect length 2024-01-09 15:26:17 -05:00
Chris Sexton 5acf14b0ae tldr: filter by channel 2024-01-05 19:03:37 -05:00
Chris Sexton f8f18acacb tldr: fuck tests 2024-01-05 11:53:25 -05:00
Chris Sexton 1a066ce979 tldr: use gpt 2024-01-05 11:53:25 -05:00
Chris Sexton 494c9e87d6 github: update to go 1.21 2024-01-04 13:16:27 -05:00
Chris Sexton 448ae768ba gpt: silence some rooms 2024-01-04 13:16:27 -05:00
Chris Sexton 0b787a65a1 update code to mach datatype 2023-12-02 08:23:24 -05:00
Chris Sexton 9c4673fb40 maybe actually get the module this time 2023-12-02 08:23:24 -05:00
Chris Sexton 230733f4ff update mod versions 2023-12-02 00:39:06 -05:00
dependabot[bot] 9bb89d5711 build(deps): bump github.com/antchfx/xmlquery from 1.2.0 to 1.3.1
Bumps [github.com/antchfx/xmlquery](https://github.com/antchfx/xmlquery) from 1.2.0 to 1.3.1.
- [Release notes](https://github.com/antchfx/xmlquery/releases)
- [Commits](https://github.com/antchfx/xmlquery/compare/v1.2.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/antchfx/xmlquery
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-30 13:46:08 -04:00
Chris Sexton c44ada3061 counter: fix decrement-by 2023-10-04 10:25:59 -04:00
Chris Sexton f18154be5b counter: maybe really fix it finally 2023-10-04 10:25:59 -04:00
Chris Sexton 9cecccfcdd counter: consolidate multiple counter code 2023-10-03 10:52:27 -04:00
Chris Sexton f6cfec477f counter: fix double counter api issue 2023-10-03 09:54:59 -04:00
Chris Sexton f2153bf0b4 counter: maybe finish this feature 2023-09-29 11:01:46 -04:00
Chris Sexton 5de82d96e4 counter: maybe count some stuff better via API 2023-09-29 10:48:23 -04:00
Chris Sexton bfd50a346d admin: update html formatting 2023-08-17 16:38:03 -04:00
Chris Sexton c32738f444 admin: convert variables page to htmx 2023-08-17 15:46:36 -04:00
Chris Sexton 3dc8c77505 reminder: remove natural language thing 2023-08-03 10:37:06 -04:00
Chris Sexton c91fdcdf29 bot: add spoiler message type 2023-08-02 17:00:45 -04:00
Chris Sexton b63b317dfc gpt: reset chat prompt every N messages 2023-04-08 21:22:58 -04:00
Chris Sexton 21ce66fc15 font: remove debug 2023-03-16 13:39:55 -04:00
Chris Sexton 295d9fef77 tap: maybe fix font locations 2023-03-16 13:39:55 -04:00
Chris Sexton 6707902caf fact: don't look for is actions 2023-03-05 15:48:48 -05:00
Chris Sexton d1986be68a gpt: make gpt the catchall 2023-03-05 15:35:01 -05:00
Chris Sexton 4626d0270c gpt: make echo configurable 2023-03-03 15:11:21 -05:00
Chris Sexton 7d5cf3909d gpt: remove a colon 2023-03-03 15:08:33 -05:00
Chris Sexton 8b8ac7b244 gpt: reset client when prompt changes 2023-03-03 12:17:05 -05:00
Chris Sexton 92ce4979b4 gpt: rename directory 2023-03-03 11:48:22 -05:00
Chris Sexton 43eda811eb gpt: add chatgpt 2023-03-03 11:48:22 -05:00
Chris Sexton 68738f847b gpt3: moderation toggle 2023-02-07 10:23:58 -05:00
Chris Sexton eb67d1a35e gpt3: moderation to protect skiesel's innocence 2023-02-07 10:23:58 -05:00
Chris Sexton 91d21c1076 cowboy: make message confirmation ephemeral 2023-01-27 14:14:35 -05:00
Chris Sexton 690fd01fd2 newsbid: bid by ID 2023-01-27 13:28:41 -05:00
Chris Sexton e2c55fab00 meme: add font shortcuts and location 2023-01-27 13:20:00 -05:00
Chris Sexton 0c94d71960 twitch: assume defaults if twitch doesn't tell us a game 2023-01-10 21:40:34 -05:00
Chris Sexton 7eba55f236 twitch: add irc disable flag 2023-01-07 20:55:39 -05:00
Chris Sexton f70eb46c5d twitch: redo with webhooks 2023-01-04 11:27:03 -05:00
Chris Sexton c171d4ba10 config: back to cgo sqlite 2022-10-25 13:29:14 -04:00
Chris Sexton 12543c569c fact: log some errs 2022-10-25 13:15:28 -04:00
Chris Sexton 3bedaf2ec0 fact: trim space 2022-10-25 12:11:49 -04:00
Chris Sexton 9569acf4b0 config: switch to modernc sqlite driver 2022-10-22 13:08:56 -04:00
Chris Sexton f6dd52a222 meme: changed to file upload instead of embed
* Added File handling to Discord mesasges
2022-10-20 18:43:45 -04:00
dependabot[bot] 7b39ebf534 build(deps): bump github.com/itchyny/gojq from 0.12.8 to 0.12.9
Bumps [github.com/itchyny/gojq](https://github.com/itchyny/gojq) from 0.12.8 to 0.12.9.
- [Release notes](https://github.com/itchyny/gojq/releases)
- [Changelog](https://github.com/itchyny/gojq/blob/main/CHANGELOG.md)
- [Commits](https://github.com/itchyny/gojq/compare/v0.12.8...v0.12.9)

---
updated-dependencies:
- dependency-name: github.com/itchyny/gojq
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-20 10:31:20 -04:00
Chris Sexton ca1be52e2f readme: I have no idea how that got merged from a different project 2022-10-20 10:02:54 -04:00
Chris Sexton da780a7a92 cowboy: who sent that emojy? 2022-10-19 18:08:25 -04:00
Chris Sexton e8ca86d008 cowboy: make the bare command make an emojy 2022-10-19 14:52:31 -04:00
Chris Sexton 04fecf1987 tappd: save ID in DB 2022-10-15 10:23:57 -04:00
Chris Sexton bf54f421fe twitch: remove broken test 2022-10-15 10:23:57 -04:00
Chris Sexton c147e65497 tappd: save images and serve them 2022-10-15 10:23:57 -04:00
Chris Sexton df9db4e6fd tappd: optionally use files instead of embeds 2022-10-14 23:09:48 -04:00
Chris Sexton 435f45fa7c tappd: shorten the message on images 2022-10-14 08:52:06 -04:00
Chris Sexton e93b6d07ab tappd: orient images 2022-10-14 08:39:26 -04:00
Chris Sexton 866b947f42 tappd: add plugin 2022-10-13 20:23:10 -04:00
dependabot[bot] 2457d6769e build(deps): bump github.com/slack-go/slack from 0.11.2 to 0.11.3
Bumps [github.com/slack-go/slack](https://github.com/slack-go/slack) from 0.11.2 to 0.11.3.
- [Release notes](https://github.com/slack-go/slack/releases)
- [Changelog](https://github.com/slack-go/slack/blob/master/CHANGELOG.md)
- [Commits](https://github.com/slack-go/slack/compare/v0.11.2...v0.11.3)

---
updated-dependencies:
- dependency-name: github.com/slack-go/slack
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-06 10:04:43 -04:00
Chris Sexton 3c6e69a0fc tidy 2022-10-06 09:04:12 -04:00
Chris Sexton a7b2830b46 actions: add tidy action 2022-10-06 09:04:12 -04:00
Chris Sexton 7ca4a30c14 url: add configurable useragent 2022-09-29 12:01:44 -04:00
Chris Sexton dfa6302757 twitch: refactor alert code 2022-09-25 17:32:18 -04:00
Chris Sexton dd262f524e twitch: Add IRC-Discord bridge
* Should connect a bridge to streamer's channel any time a stream starts
* Should disconnect when stream ends
* Add `track` and `untrack` commands to manually modify bridge
* Adds support for creating Discord threads
2022-09-18 17:49:27 -04:00
dependabot[bot] bc710219ed build(deps): bump github.com/rs/zerolog from 1.27.0 to 1.28.0
Bumps [github.com/rs/zerolog](https://github.com/rs/zerolog) from 1.27.0 to 1.28.0.
- [Release notes](https://github.com/rs/zerolog/releases)
- [Commits](https://github.com/rs/zerolog/compare/v1.27.0...v1.28.0)

---
updated-dependencies:
- dependency-name: github.com/rs/zerolog
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-10 19:10:12 +00:00
dependabot[bot] c431d9ccc0 build(deps): bump github.com/forPelevin/gomoji from 1.1.4 to 1.1.6
Bumps [github.com/forPelevin/gomoji](https://github.com/forPelevin/gomoji) from 1.1.4 to 1.1.6.
- [Release notes](https://github.com/forPelevin/gomoji/releases)
- [Commits](https://github.com/forPelevin/gomoji/compare/v1.1.4...v1.1.6)

---
updated-dependencies:
- dependency-name: github.com/forPelevin/gomoji
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-10 19:05:22 +00:00
dependabot[bot] c9981bc5f5 build(deps): bump github.com/slack-go/slack from 0.11.0 to 0.11.2
Bumps [github.com/slack-go/slack](https://github.com/slack-go/slack) from 0.11.0 to 0.11.2.
- [Release notes](https://github.com/slack-go/slack/releases)
- [Changelog](https://github.com/slack-go/slack/blob/master/CHANGELOG.md)
- [Commits](https://github.com/slack-go/slack/compare/v0.11.0...v0.11.2)

---
updated-dependencies:
- dependency-name: github.com/slack-go/slack
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-10 18:59:13 +00:00
dependabot[bot] 1852b35150 build(deps): bump github.com/go-chi/httprate from 0.5.3 to 0.7.0
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.5.3 to 0.7.0.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.5.3...v0.7.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-10 18:53:59 +00:00
dependabot[bot] 11d8c576e4 build(deps): bump github.com/gabriel-vasile/mimetype from 1.4.0 to 1.4.1
Bumps [github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/gabriel-vasile/mimetype/releases)
- [Commits](https://github.com/gabriel-vasile/mimetype/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: github.com/gabriel-vasile/mimetype
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-10 14:48:58 -04:00
Chris Sexton a9a4c9274c pagecomment: omit user if using slash 2022-09-07 10:59:14 -04:00
Chris Sexton b670ecc647 pagecomment: add useragent and check for no title 2022-09-07 10:41:35 -04:00
Chris Sexton b8a199faba pagecomment: add /url command
* Updated discord library
* Added an author embed but it's not useful just yet
2022-09-06 17:26:07 -04:00
Chris Sexton 4617dd84fc first commit 2022-09-06 17:26:07 -04:00
95 changed files with 5334 additions and 2497 deletions

View File

@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.18
- name: Set up Go 1.21
uses: actions/setup-go@v1
with:
go-version: 1.18.x
go-version: 1.21.x
id: go
- name: Check out code into the Go module directory

13
.github/workflows/tidy.yml vendored Normal file
View File

@ -0,0 +1,13 @@
name: Go Tidy
on: [push]
jobs:
build:
name: Go mod tidy check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: katexochen/go-tidy-check@v1
with:
# (Optional) The path to the root of each modules, space separated. Default is the current directory.
modules: . ./...

2
.gitignore vendored
View File

@ -20,7 +20,6 @@ _cgo_export.*
_testmain.go
*.exe
catbase
config.json
*.db
vendor
@ -77,3 +76,4 @@ impact.ttf
.env
rathaus_discord.sh
emojy
catbase

View File

@ -106,4 +106,3 @@ by issuing a single word command in the form of XdY. "1d20" would roll a single
0. You just DO WHAT THE FUCK YOU WANT TO.
```
# c346-34515-fa22-project-rockbottom

View File

@ -4,10 +4,9 @@ package bot
import (
"fmt"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/velour/catbase/bot/stats"
"github.com/velour/catbase/bot/web"
"math/rand"
"net/http"
"os"
"os/signal"
"reflect"
@ -15,7 +14,6 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot/history"
@ -53,8 +51,7 @@ type bot struct {
version string
// The entries to the bot's HTTP interface
httpEndPoints []EndPoint
web *web.Web
// filters registered by plugins
filters map[string]func(string) string
@ -66,14 +63,8 @@ type bot struct {
quiet bool
router *chi.Mux
history *history.History
}
type EndPoint struct {
Name string `json:"name"`
URL string `json:"url"`
stats *stats.Stats
}
// Variable represents a $var replacement
@ -107,11 +98,10 @@ func New(config *config.Config, connector Connector) Bot {
me: users[0],
logIn: logIn,
logOut: logOut,
httpEndPoints: make([]EndPoint, 0),
filters: make(map[string]func(string) string),
callbacks: make(CallbackMap),
router: chi.NewRouter(),
history: history.New(historySz),
stats: stats.New(),
}
bot.migrateDB()
@ -119,55 +109,23 @@ func New(config *config.Config, connector Connector) Bot {
bot.RefreshPluginBlacklist()
bot.RefreshPluginWhitelist()
log.Debug().Msgf("created web router")
bot.setupHTTP()
bot.web = web.New(bot.config, bot.stats)
connector.RegisterEvent(bot.Receive)
return bot
}
func (b *bot) setupHTTP() {
// Make the http logger optional
// It has never served a purpose in production and with the emojy page, can make a rather noisy log
if b.Config().GetInt("bot.useLogger", 0) == 1 {
b.router.Use(middleware.Logger)
}
reqCount := b.Config().GetInt("bot.httprate.requests", 500)
reqTime := time.Duration(b.Config().GetInt("bot.httprate.seconds", 5))
if reqCount > 0 && reqTime > 0 {
b.router.Use(httprate.LimitByIP(reqCount, reqTime*time.Second))
}
b.router.Use(middleware.RequestID)
b.router.Use(middleware.Recoverer)
b.router.Use(middleware.StripSlashes)
b.router.HandleFunc("/", b.serveRoot)
b.router.HandleFunc("/nav", b.serveNav)
}
func (b *bot) ListenAndServe() {
addr := b.config.Get("HttpAddr", "127.0.0.1:1337")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
go func() {
log.Debug().Msgf("starting web service at %s", addr)
log.Fatal().Err(http.ListenAndServe(addr, b.router)).Msg("bot killed")
b.web.ListenAndServe(addr)
}()
<-stop
b.DefaultConnector().Shutdown()
}
func (b *bot) RegisterWeb(r http.Handler, root string) {
b.router.Mount(root, r)
}
func (b *bot) RegisterWebName(r http.Handler, root, name string) {
b.httpEndPoints = append(b.httpEndPoints, EndPoint{name, root})
b.router.Mount(root, r)
b.Receive(b.DefaultConnector(), Shutdown, msg.Message{})
}
// DefaultConnector is the main connector used for the bot
@ -455,3 +413,7 @@ func (b *bot) CheckPassword(secret, password string) bool {
}
return false
}
func (b *bot) GetWeb() *web.Web {
return b.web
}

View File

@ -25,6 +25,8 @@ func (b *bot) Receive(conn Connector, kind Kind, msg msg.Message, args ...any) b
// msg := b.buildMessage(client, inMsg)
// do need to look up user and fix it
b.stats.MessagesRcv++
if kind == Edit {
b.history.Edit(msg.ID, &msg)
} else {
@ -88,6 +90,7 @@ func (b *bot) Send(conn Connector, kind Kind, args ...any) (string, error) {
if b.quiet {
return "", nil
}
b.stats.MessagesSent++
return conn.Send(kind, args...)
}

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css" />
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/vue-router"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>catbase</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>catbase</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url">{{ item.name }}</b-nav-item>
</b-navbar-nav>
</b-navbar>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
err: '',
nav: [],
},
mounted: function() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
}
})
</script>
</body>
</html>

View File

@ -3,8 +3,11 @@
package bot
import (
"github.com/gabriel-vasile/mimetype"
"github.com/velour/catbase/bot/web"
"net/http"
"regexp"
"strings"
"github.com/jmoiron/sqlx"
@ -24,6 +27,8 @@ const (
Reply
// Action any /me action
Action
// Spoiler is for commented out messages
Spoiler
// Reaction Icon reaction if service supports it
Reaction
// Edit message ref'd new message to replace
@ -38,12 +43,42 @@ const (
Delete
// Startup is triggered after the connector has run the Serve function
Startup
// Shutdown is triggered after an OS interrupt
Shutdown
)
type EphemeralID string
type UnfurlLinks bool
type EmbedAuthor struct {
ID string
Who string
IconURL string
}
type File struct {
Description string
Data []byte
mime *mimetype.MIME
}
func (f File) Mime() *mimetype.MIME {
if f.mime == nil {
f.mime = mimetype.Detect(f.Data)
}
return f.mime
}
func (f File) ContentType() string {
return f.Mime().String()
}
func (f File) FileName() string {
ext := f.Mime().Extension()
return strings.ReplaceAll(f.Description, " ", "-") + ext
}
type ImageAttachment struct {
URL string
AltTxt string
@ -134,20 +169,14 @@ type Bot interface {
// RegisterFilter creates a filter function for message processing
RegisterFilter(string, func(string) string)
// RegisterWeb records a web endpoint for the UI
RegisterWebName(http.Handler, string, string)
// RegisterWeb records a web endpoint for the API
RegisterWeb(http.Handler, string)
// Start the HTTP service
ListenAndServe()
// DefaultConnector returns the base connector, which may not be the only connector
DefaultConnector() Connector
// GetWebNavigation returns the current known web endpoints
GetWebNavigation() []EndPoint
// GetWeb returns the bot's webserver structure
GetWeb() *web.Web
// GetPassword generates a unique password for modification commands on the public website
GetPassword() string

View File

@ -4,6 +4,8 @@ package bot
import (
"fmt"
"github.com/velour/catbase/bot/stats"
"github.com/velour/catbase/bot/web"
"net/http"
"regexp"
"strconv"
@ -26,6 +28,8 @@ type MockBot struct {
Messages []string
Actions []string
Reactions []string
web *web.Web
}
func (mb *MockBot) Config() *config.Config { return mb.Cfg }
@ -37,6 +41,9 @@ func (mb *MockBot) GetPassword() string { return "12345" }
func (mb *MockBot) SetQuiet(bool) {}
func (mb *MockBot) Send(c Connector, kind Kind, args ...any) (string, error) {
switch kind {
case Spoiler:
mb.Messages = append(mb.Messages, "||"+args[1].(string)+"||")
return fmt.Sprintf("m-%d", len(mb.Actions)-1), nil
case Message:
mb.Messages = append(mb.Messages, args[1].(string))
return fmt.Sprintf("m-%d", len(mb.Actions)-1), nil
@ -57,9 +64,7 @@ func (mb *MockBot) Register(p Plugin, kind Kind, cb Callback)
func (mb *MockBot) RegisterTable(p Plugin, hs HandlerTable) {}
func (mb *MockBot) RegisterRegex(p Plugin, kind Kind, r *regexp.Regexp, h ResponseHandler) {}
func (mb *MockBot) RegisterRegexCmd(p Plugin, kind Kind, r *regexp.Regexp, h ResponseHandler) {}
func (mb *MockBot) RegisterWebName(_ http.Handler, _, _ string) {}
func (mb *MockBot) RegisterWeb(_ http.Handler, _ string) {}
func (mb *MockBot) GetWebNavigation() []EndPoint { return nil }
func (mb *MockBot) GetWeb() *web.Web { return mb.web }
func (mb *MockBot) Receive(c Connector, kind Kind, msg msg.Message, args ...any) bool {
return false
}
@ -115,6 +120,7 @@ func NewMockBot() *MockBot {
Messages: make([]string, 0),
Actions: make([]string, 0),
}
b.web = web.New(cfg, stats.New())
// If any plugin registered a route, we need to reset those before any new test
http.DefaultServeMux = new(http.ServeMux)
return &b

19
bot/stats/stats.go Normal file
View File

@ -0,0 +1,19 @@
package stats
import (
"time"
)
type Stats struct {
MessagesSent int
MessagesRcv int
startTime time.Time
}
func New() *Stats {
return &Stats{startTime: time.Now()}
}
func (s Stats) Uptime() string {
return time.Now().Sub(s.startTime).Truncate(time.Second).String()
}

View File

@ -1,42 +0,0 @@
package bot
import (
"embed"
"encoding/json"
"net/http"
"strings"
)
//go:embed *.html
var embeddedFS embed.FS
func (b *bot) serveRoot(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("index.html")
w.Write(index)
}
func (b *bot) serveNav(w http.ResponseWriter, r *http.Request) {
enc := json.NewEncoder(w)
err := enc.Encode(b.GetWebNavigation())
if err != nil {
jsonErr, _ := json.Marshal(err)
w.WriteHeader(500)
w.Write(jsonErr)
}
}
// GetWebNavigation returns a list of bootstrap-vue <b-nav-item> links
// The parent <nav> is not included so each page may display it as
// best fits
func (b *bot) GetWebNavigation() []EndPoint {
endpoints := b.httpEndPoints
moreEndpoints := b.config.GetArray("bot.links", []string{})
for _, e := range moreEndpoints {
link := strings.SplitN(e, ":", 2)
if len(link) != 2 {
continue
}
endpoints = append(endpoints, EndPoint{link[0], link[1]})
}
return endpoints
}

84
bot/web/index.templ Normal file
View File

@ -0,0 +1,84 @@
package web
import "fmt"
templ (w *Web) Header(title string) {
<head>
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/foundation-sites@6.8.1/dist/css/foundation.min.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8" />
if title != "" {
<title>{ w.botName() } - { title }</title>
} else {
<title>{ w.botName() }</title>
}
</head>
}
templ (w *Web) Footer() {
<script src="//unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
<script src="//cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/foundation-sites@6.8.1/dist/js/foundation.min.js"></script>
}
templ (w *Web) Index(title string, contents templ.Component) {
<!DOCTYPE html>
<html lang="en" class="no-js">
@w.Header(title)
<body>
@w.Nav(title)
if contents != nil {
@contents
}
@w.Footer()
</body>
</html>
}
templ (w *Web) Nav(currentPage string) {
<div class="top-bar">
<div class="top-bar-left">
<ul class="menu">
<li><a style="color: black; font-weight: bold;" href="/">{ w.botName() }</a></li>
for _, item := range w.GetWebNavigation() {
<li>
if currentPage == item.Name {
<a class="is-active" aria-current="page" href={ templ.URL(item.URL) }>{ item.Name }</a>
} else {
<a href={ templ.URL(item.URL) }>{ item.Name }</a>
}
</li>
}
</ul>
</div>
</div>
}
templ (w *Web) showStats() {
<div class="grid-container">
<div class="cell">
<h2>Stats</h2>
</div>
<div class="cell">
<table>
<tr>
<td>Messages Seen</td>
<td>{ fmt.Sprintf("%d", w.stats.MessagesRcv) }</td>
</tr>
<tr>
<td>Messages Sent</td>
<td>{ fmt.Sprintf("%d", w.stats.MessagesSent) }</td>
</tr>
<tr>
<td>Uptime</td>
<td>{ w.stats.Uptime() }</td>
</tr>
</table>
</div>
</div>
}

334
bot/web/index_templ.go Normal file
View File

@ -0,0 +1,334 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "fmt"
func (w *Web) Header(title string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<head><link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/npm/foundation-sites@6.8.1/dist/css/foundation.min.css\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta charset=\"UTF-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if title != "" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(w.botName())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 10, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" - ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 10, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(w.botName())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 12, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (w *Web) Footer() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<script src=\"//unpkg.com/htmx.org@1.9.10\" integrity=\"sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC\" crossorigin=\"anonymous\"></script><script src=\"//cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js\"></script><script src=\"//cdn.jsdelivr.net/npm/foundation-sites@6.8.1/dist/js/foundation.min.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (w *Web) Index(title string, contents templ.Component) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html lang=\"en\" class=\"no-js\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = w.Header(title).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = w.Nav(title).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if contents != nil {
templ_7745c5c3_Err = contents.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = w.Footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (w *Web) Nav(currentPage string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"top-bar\"><div class=\"top-bar-left\"><ul class=\"menu\"><li><a style=\"color: black; font-weight: bold;\" href=\"/\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(w.botName())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 45, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range w.GetWebNavigation() {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage == item.Name {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a class=\"is-active\" aria-current=\"page\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL = templ.URL(item.URL)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var9)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 49, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL = templ.URL(item.URL)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var11)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 51, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (w *Web) showStats() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><div class=\"cell\"><h2>Stats</h2></div><div class=\"cell\"><table><tr><td>Messages Seen</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", w.stats.MessagesRcv))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 70, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr><tr><td>Messages Sent</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", w.stats.MessagesSent))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 74, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr><tr><td>Uptime</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(w.stats.Uptime())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bot/web/index.templ`, Line: 78, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr></table></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

112
bot/web/web.go Normal file
View File

@ -0,0 +1,112 @@
package web
import (
"encoding/json"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot/stats"
"github.com/velour/catbase/config"
"net/http"
"strings"
"time"
)
type Web struct {
config *config.Config
router *chi.Mux
httpEndPoints []EndPoint
stats *stats.Stats
}
type EndPoint struct {
Name string `json:"name"`
URL string `json:"url"`
}
// GetWebNavigation returns a list of bootstrap-vue <b-nav-item> links
// The parent <nav> is not included so each page may display it as
// best fits
func (ws *Web) GetWebNavigation() []EndPoint {
endpoints := ws.httpEndPoints
moreEndpoints := ws.config.GetArray("bot.links", []string{})
for _, e := range moreEndpoints {
link := strings.SplitN(e, ":", 2)
if len(link) != 2 {
continue
}
endpoints = append(endpoints, EndPoint{link[0], link[1]})
}
return endpoints
}
func (ws *Web) serveRoot(w http.ResponseWriter, r *http.Request) {
ws.Index("Home", ws.showStats()).Render(r.Context(), w)
}
func (ws *Web) serveNavHTML(w http.ResponseWriter, r *http.Request) {
currentPage := chi.URLParam(r, "currentPage")
ws.Nav(currentPage).Render(r.Context(), w)
}
func (ws *Web) serveNav(w http.ResponseWriter, r *http.Request) {
enc := json.NewEncoder(w)
err := enc.Encode(ws.GetWebNavigation())
if err != nil {
jsonErr, _ := json.Marshal(err)
w.WriteHeader(500)
w.Write(jsonErr)
}
}
func (ws *Web) setupHTTP() {
// Make the http logger optional
// It has never served a purpose in production and with the emojy page, can make a rather noisy log
if ws.config.GetInt("bot.useLogger", 0) == 1 {
ws.router.Use(middleware.Logger)
}
reqCount := ws.config.GetInt("bot.httprate.requests", 500)
reqTime := time.Duration(ws.config.GetInt("bot.httprate.seconds", 5))
if reqCount > 0 && reqTime > 0 {
ws.router.Use(httprate.LimitByIP(reqCount, reqTime*time.Second))
}
ws.router.Use(middleware.RequestID)
ws.router.Use(middleware.Recoverer)
ws.router.Use(middleware.StripSlashes)
ws.router.HandleFunc("/", ws.serveRoot)
ws.router.HandleFunc("/nav", ws.serveNav)
ws.router.HandleFunc("/navHTML", ws.serveNavHTML)
ws.router.HandleFunc("/navHTML/{currentPage}", ws.serveNavHTML)
}
func (ws *Web) RegisterWeb(r http.Handler, root string) {
ws.router.Mount(root, r)
}
func (ws *Web) RegisterWebName(r http.Handler, root, name string) {
ws.httpEndPoints = append(ws.httpEndPoints, EndPoint{name, root})
ws.router.Mount(root, r)
}
func (ws *Web) ListenAndServe(addr string) {
log.Debug().Msgf("starting web service at %s", addr)
log.Fatal().Err(http.ListenAndServe(addr, ws.router)).Msg("bot killed")
}
func New(config *config.Config, s *stats.Stats) *Web {
w := &Web{
config: config,
router: chi.NewRouter(),
stats: s,
}
w.setupHTTP()
return w
}
func (ws *Web) botName() string {
return ws.config.Get("nick", "catbase")
}

View File

@ -3,15 +3,13 @@
package config
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"regexp"
"strconv"
"strings"
sqlite3 "github.com/mattn/go-sqlite3"
_ "github.com/mattn/go-sqlite3"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
@ -73,6 +71,13 @@ func (c *Config) GetInt(key string, fallback int) int {
return i
}
// GetBool returns true or false for config key
// It will assume false for any string except "true"
func (c *Config) GetBool(key string, fallback bool) bool {
val := c.GetString(key, strconv.FormatBool(fallback))
return val == "true"
}
// Get is a shortcut for GetString
func (c *Config) Get(key, fallback string) string {
return c.GetString(key, fallback)
@ -101,7 +106,7 @@ func (c *Config) GetString(key, fallback string) string {
q := `select value from config where key=?`
err := c.DB.Get(&configValue, q, key)
if err != nil {
log.Debug().Msgf("WARN: Key %s is empty", key)
log.Info().Msgf("WARN: Key %s is empty", key)
return fallback
}
return configValue
@ -240,18 +245,6 @@ func (c *Config) SetArray(key string, values []string) error {
return c.Set(key, vals)
}
func init() {
regex := func(re, s string) (bool, error) {
return regexp.MatchString(re, s)
}
sql.Register("sqlite3_custom",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("REGEXP", regex, true)
},
})
}
// Readconfig loads the config data out of a JSON file located in cfile
func ReadConfig(dbpath string) *Config {
if dbpath == "" {
@ -259,7 +252,7 @@ func ReadConfig(dbpath string) *Config {
}
log.Info().Msgf("Using %s as database file.\n", dbpath)
sqlDB, err := sqlx.Open("sqlite3_custom", dbpath)
sqlDB, err := sqlx.Open("sqlite3", dbpath)
if err != nil {
log.Fatal().Err(err)
}

View File

@ -1,6 +1,7 @@
package discord
import (
"bytes"
"errors"
"fmt"
"net/http"
@ -30,6 +31,8 @@ type Discord struct {
registeredCmds []*discordgo.ApplicationCommand
cmdHandlers map[string]CmdHandler
guildID string
}
func New(config *config.Config) *Discord {
@ -37,12 +40,17 @@ func New(config *config.Config) *Discord {
if err != nil {
log.Fatal().Err(err).Msg("Could not connect to Discord")
}
guildID := config.Get("discord.guildid", "")
if guildID == "" {
log.Fatal().Msgf("You must set either DISCORD_GUILDID env or discord.guildid db config")
}
d := &Discord{
config: config,
client: client,
uidCache: map[string]string{},
registeredCmds: []*discordgo.ApplicationCommand{},
cmdHandlers: map[string]CmdHandler{},
guildID: guildID,
}
d.client.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if h, ok := d.cmdHandlers[i.ApplicationCommandData().Name]; ok {
@ -66,6 +74,9 @@ func (d Discord) Send(kind bot.Kind, args ...any) (string, error) {
return d.sendMessage(args[0].(string), args[2].(string), false, args...)
case bot.Message:
return d.sendMessage(args[0].(string), args[1].(string), false, args...)
case bot.Spoiler:
outgoing := "||" + args[1].(string) + "||"
return d.sendMessage(args[0].(string), outgoing, false, args...)
case bot.Action:
return d.sendMessage(args[0].(string), args[1].(string), true, args...)
case bot.Edit:
@ -103,33 +114,67 @@ func (d *Discord) sendMessage(channel, message string, meMessage bool, args ...a
message = "_" + message + "_"
}
var embeds *discordgo.MessageEmbed
embeds := []*discordgo.MessageEmbed{}
files := []*discordgo.File{}
for _, arg := range args {
switch a := arg.(type) {
case bot.EmbedAuthor:
embed := &discordgo.MessageEmbed{}
embed.Author = &discordgo.MessageEmbedAuthor{
Name: a.Who,
IconURL: a.IconURL,
}
embeds = append(embeds, embed)
case bot.ImageAttachment:
//embeds.URL = a.URL
embeds = &discordgo.MessageEmbed{}
embeds.Description = a.AltTxt
embeds.Image = &discordgo.MessageEmbedImage{
embed := &discordgo.MessageEmbed{}
embed.Description = a.AltTxt
embed.Image = &discordgo.MessageEmbedImage{
URL: a.URL,
Width: a.Width,
Height: a.Height,
}
embeds = append(embeds, embed)
case bot.File:
files = append(files, &discordgo.File{
Name: a.FileName(),
ContentType: a.ContentType(),
Reader: bytes.NewBuffer(a.Data),
})
}
}
data := &discordgo.MessageSend{
Content: message,
Embed: embeds,
Embeds: embeds,
Files: files,
}
log.Debug().
Interface("data", data).
Interface("args", args).
Msg("sending message")
st, err := d.client.ChannelMessageSendComplex(channel, data)
maxLen := 2000
chunkSize := maxLen - 100
var st *discordgo.Message
var err error
if len(data.Content) > maxLen {
tmp := data.Content
data.Content = tmp[:chunkSize]
st, err = d.client.ChannelMessageSendComplex(channel, data)
if err != nil {
return "", err
}
for i := chunkSize; i < len(data.Content); i += chunkSize {
data := &discordgo.MessageSend{Content: tmp[i : i+chunkSize]}
st, err = d.client.ChannelMessageSendComplex(channel, data)
if err != nil {
break
}
}
} else {
st, err = d.client.ChannelMessageSendComplex(channel, data)
}
//st, err := d.client.ChannelMessageSend(channel, message)
if err != nil {
@ -148,12 +193,7 @@ func (d *Discord) GetEmojiList(force bool) map[string]string {
if d.emojiCache != nil && !force {
return d.emojiCache
}
guildID := d.config.Get("discord.guildid", "")
if guildID == "" {
log.Error().Msg("no guild ID set")
return map[string]string{}
}
e, err := d.client.GuildEmojis(guildID)
e, err := d.client.GuildEmojis(d.guildID)
if err != nil {
log.Error().Err(err).Msg("could not retrieve emojis")
return map[string]string{}
@ -213,16 +253,11 @@ func (d *Discord) convertUser(u *discordgo.User) *user.User {
}
nick := u.Username
guildID := d.config.Get("discord.guildid", "")
if guildID == "" {
log.Error().Msg("no guild ID set")
} else {
mem, err := d.client.GuildMember(guildID, u.ID)
if err != nil {
log.Error().Err(err).Msg("could not get guild member")
} else if mem.Nick != "" {
nick = mem.Nick
}
mem, err := d.client.GuildMember(d.guildID, u.ID)
if err != nil {
log.Error().Err(err).Msg("could not get guild member")
} else if mem.Nick != "" {
nick = mem.Nick
}
return &user.User{
@ -320,9 +355,10 @@ func (d *Discord) Emojy(name string) string {
}
func (d *Discord) UploadEmojy(emojy, path string) error {
guildID := d.config.Get("discord.guildid", "")
defaultRoles := d.config.GetArray("discord.emojyRoles", []string{})
_, err := d.client.GuildEmojiCreate(guildID, emojy, path, defaultRoles)
_, err := d.client.GuildEmojiCreate(d.guildID, &discordgo.EmojiParams{
emojy, path, defaultRoles,
})
if err != nil {
return err
}
@ -330,9 +366,8 @@ func (d *Discord) UploadEmojy(emojy, path string) error {
}
func (d *Discord) DeleteEmojy(emojy string) error {
guildID := d.config.Get("discord.guildid", "")
emojyID := d.GetEmojySnowflake(emojy)
return d.client.GuildEmojiDelete(guildID, emojyID)
return d.client.GuildEmojiDelete(d.guildID, emojyID)
}
func (d *Discord) URLFormat(title, url string) string {
@ -341,11 +376,7 @@ func (d *Discord) URLFormat(title, url string) string {
// GetChannelName returns the channel ID for a human-friendly name (if possible)
func (d *Discord) GetChannelID(name string) string {
guildID := d.config.Get("discord.guildid", "")
if guildID == "" {
return name
}
chs, err := d.client.GuildChannels(guildID)
chs, err := d.client.GuildChannels(d.guildID)
if err != nil {
return name
}
@ -369,11 +400,7 @@ func (d *Discord) GetChannelName(id string) string {
func (d *Discord) GetRoles() ([]bot.Role, error) {
ret := []bot.Role{}
guildID := d.config.Get("discord.guildid", "")
if guildID == "" {
return nil, errors.New("no guildID set")
}
roles, err := d.client.GuildRoles(guildID)
roles, err := d.client.GuildRoles(d.guildID)
if err != nil {
return nil, err
}
@ -389,24 +416,25 @@ func (d *Discord) GetRoles() ([]bot.Role, error) {
}
func (d *Discord) SetRole(userID, roleID string) error {
guildID := d.config.Get("discord.guildid", "")
member, err := d.client.GuildMember(guildID, userID)
member, err := d.client.GuildMember(d.guildID, userID)
if err != nil {
return err
}
for _, r := range member.Roles {
if r == roleID {
return d.client.GuildMemberRoleRemove(guildID, userID, roleID)
return d.client.GuildMemberRoleRemove(d.guildID, userID, roleID)
}
}
return d.client.GuildMemberRoleAdd(guildID, userID, roleID)
return d.client.GuildMemberRoleAdd(d.guildID, userID, roleID)
}
type CmdHandler func(s *discordgo.Session, i *discordgo.InteractionCreate)
func (d *Discord) RegisterSlashCmd(c discordgo.ApplicationCommand, handler CmdHandler) error {
guildID := d.config.Get("discord.guildid", "")
cmd, err := d.client.ApplicationCommandCreate(d.client.State.User.ID, guildID, &c)
if !d.config.GetBool("registerSlash", true) {
return nil
}
cmd, err := d.client.ApplicationCommandCreate(d.client.State.User.ID, d.guildID, &c)
d.cmdHandlers[c.Name] = handler
if err != nil {
return err
@ -417,17 +445,15 @@ func (d *Discord) RegisterSlashCmd(c discordgo.ApplicationCommand, handler CmdHa
func (d *Discord) Shutdown() {
log.Debug().Msgf("Shutting down and deleting %d slash commands", len(d.registeredCmds))
guildID := d.config.Get("discord.guildid", "")
for _, c := range d.registeredCmds {
if err := d.client.ApplicationCommandDelete(d.client.State.User.ID, guildID, c.ID); err != nil {
if err := d.client.ApplicationCommandDelete(d.client.State.User.ID, d.guildID, c.ID); err != nil {
log.Error().Err(err).Msgf("could not delete command %s", c.Name)
}
}
}
func (d *Discord) Nick(nick string) error {
guildID := d.config.Get("discord.guildid", "")
return d.client.GuildMemberNickname(guildID, "@me", nick)
return d.client.GuildMemberNickname(d.guildID, "@me", nick)
}
func (d *Discord) Topic(channelID string) (string, error) {
@ -445,3 +471,34 @@ func (d *Discord) SetTopic(channelID, topic string) error {
_, err := d.client.ChannelEditComplex(channelID, ce)
return err
}
type ThreadStart struct {
Name string `json:"name"`
AutoArchiveDuration int `json:"auto_archive_duration,omitempty"`
RateLimitPerUser int `json:"rate_limit_per_user,omitempty"`
AppliedTags []string `json:"applied_tags,omitempty"`
Message ForumMessageData `json:"message"`
}
type ForumMessageData struct {
Content string `json:"content"`
}
func (d *Discord) CreateRoom(name, message, parent string, duration int) (string, error) {
data := &ThreadStart{
Name: name,
AutoArchiveDuration: duration,
Message: ForumMessageData{message},
}
ch := &discordgo.Channel{}
endpoint := discordgo.EndpointChannelThreads(parent)
body, err := d.client.RequestWithBucketID("POST", endpoint, data, endpoint)
if err != nil {
return "", err
}
if err = discordgo.Unmarshal(body, &ch); err != nil {
return "", err
}
return ch.ID, nil
}

84
go.mod
View File

@ -3,90 +3,116 @@ module github.com/velour/catbase
go 1.18
require (
code.chrissexton.org/cws/getaoc v0.0.0-20191201043947-d5417d4b618d
code.chrissexton.org/cws/getaoc v0.0.0-20231202052842-1b2a337b799d
github.com/ChimeraCoder/anaconda v2.0.0+incompatible
github.com/PuerkitoBio/goquery v1.8.0
github.com/bwmarrin/discordgo v0.25.0
github.com/PuerkitoBio/goquery v1.8.1
github.com/a-h/templ v0.2.543
github.com/andrewstuart/openai v0.8.0
github.com/bwmarrin/discordgo v0.26.1
github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598
github.com/cenkalti/backoff/v4 v4.2.1
github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff
github.com/chrissexton/sentiment v0.0.0-20190927141846-d69c422ba035
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90
github.com/forPelevin/gomoji v1.1.4
github.com/gabriel-vasile/mimetype v1.4.0
github.com/forPelevin/gomoji v1.1.6
github.com/gabriel-vasile/mimetype v1.4.1
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/httprate v0.5.3
github.com/go-chi/httprate v0.7.0
github.com/gocolly/colly v1.2.0
github.com/google/uuid v1.3.0
github.com/itchyny/gojq v0.12.8
github.com/itchyny/gojq v0.12.9
github.com/james-bowman/nlp v0.0.0-20191016091239-d9dbfaff30c6
github.com/jmoiron/sqlx v1.3.5
github.com/kevinburke/twilio-go v0.0.0-20200424172635-4f0b2357b852
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/mmcdole/gofeed v1.1.3
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/nicklaw5/helix v1.25.0
github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254
github.com/rs/zerolog v1.27.0
github.com/slack-go/slack v0.11.0
github.com/stretchr/testify v1.8.0
github.com/rs/zerolog v1.28.0
github.com/slack-go/slack v0.11.3
github.com/stretchr/testify v1.8.2
github.com/trubitsyn/go-zero-width v1.0.1
github.com/velour/velour v0.0.0-20160303155839-8e090e68d158
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
golang.org/x/crypto v0.14.0
gopkg.in/go-playground/webhooks.v5 v5.17.0
)
require (
git.stuart.fun/andrew/rester/v2 v2.2.0 // indirect
github.com/AlekSi/pointer v1.1.0 // indirect
github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/antchfx/htmlquery v1.2.0 // indirect
github.com/antchfx/xmlquery v1.2.0 // indirect
github.com/antchfx/xpath v1.1.1 // indirect
github.com/antchfx/xmlquery v1.3.1 // indirect
github.com/antchfx/xpath v1.1.10 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc // indirect
github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gonum/floats v0.0.0-20181209220543-c233463c7e82 // indirect
github.com/gonum/internal v0.0.0-20181124074243-f884aa714029 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/improbable-eng/go-httpwares v0.0.0-20200609095714-edc8019f93cc // indirect
github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 // indirect
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/itchyny/timefmt-go v0.1.4 // indirect
github.com/james-bowman/sparse v0.0.0-20190423065201-80c6877364c7 // indirect
github.com/json-iterator/go v1.1.10 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/kevinburke/go-types v0.0.0-20200309064045-f2d4aea18a7a // indirect
github.com/kevinburke/go.uuid v1.2.0 // indirect
github.com/kevinburke/rest v0.0.0-20200429221318-0d2892b400f8 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rivo/uniseg v0.3.4 // indirect
github.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d // indirect
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/temoto/robotstxt v1.1.1 // indirect
github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect
github.com/ttacon/libphonenumber v1.1.0 // indirect
golang.org/x/exp v0.0.0-20191014171548-69215a2ee97e // indirect
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 // indirect
golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.13.0 // indirect
gonum.org/v1/gonum v0.6.0 // indirect
google.golang.org/appengine v1.6.5 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect
google.golang.org/grpc v1.52.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mattn/go-sqlite3 => github.com/leso-kn/go-sqlite3 v0.0.0-20230710125852-03158dc838ed

602
go.sum
View File

@ -1,6 +1,40 @@
code.chrissexton.org/cws/getaoc v0.0.0-20191201043947-d5417d4b618d h1:XS13tP+cMAvXYHQiYqcst64wQ854pueMRZSU4+6puU4=
code.chrissexton.org/cws/getaoc v0.0.0-20191201043947-d5417d4b618d/go.mod h1:rEpfJR9MplF2TUj2Oy+u4XAaLve2kwB8I2zlzeIQxl8=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
code.chrissexton.org/cws/getaoc v0.0.0-20231202052842-1b2a337b799d h1:s2OEp4YDwfdKVZVkt2hrN/tZlbpHtC2GPNRYWgtqMDw=
code.chrissexton.org/cws/getaoc v0.0.0-20231202052842-1b2a337b799d/go.mod h1:rEpfJR9MplF2TUj2Oy+u4XAaLve2kwB8I2zlzeIQxl8=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.stuart.fun/andrew/rester/v2 v2.2.0 h1:h8VSZC1MKmYQdpbpbDRYg5HrPaqyW5lbU69C+433BAs=
git.stuart.fun/andrew/rester/v2 v2.2.0/go.mod h1:Uc4e4vUP/Y7bSmTE4U+eSqbREaoudpKOrHyhcSGWeHk=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI=
github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE=
@ -11,54 +45,97 @@ github.com/ChimeraCoder/anaconda v2.0.0+incompatible/go.mod h1:TCt3MijIq3Qqo9SBt
github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 h1:r+EmXjfPosKO4wfiMLe1XQictsIlhErTufbWUsjOTZs=
github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7/go.mod h1:b2EuEMLSG9q3bZ95ql1+8oVqzzrTNSiOQqSXWFBzxeI=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/a-h/templ v0.2.543 h1:8YyLvyUtf0/IE2nIwZ62Z/m2o2NqwhnMynzOL78Lzbk=
github.com/a-h/templ v0.2.543/go.mod h1:jP908DQCwI08IrnTalhzSEH9WJqG/Q94+EODQcJGFUA=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/andrewstuart/openai v0.8.0 h1:pN5MwD/2+gQ3y89fquU/z/rHCUx9+AP5b8BYypcFkF8=
github.com/andrewstuart/openai v0.8.0/go.mod h1:Gi+pjULfqujtYCtMPhbN4bWZItytFEhiZUroAxq2IPg=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/antchfx/htmlquery v1.2.0 h1:oKShnsGlnOHX6t4uj5OHgLKkABcJoqnXpqnscoi9Lpw=
github.com/antchfx/htmlquery v1.2.0/go.mod h1:MS9yksVSQXls00iXkiMqXr0J+umL/AmxXKuP28SUJM8=
github.com/antchfx/xmlquery v1.2.0 h1:1nrzsSN5mFrlqFWSK9byiq/qXKE7O2vivYzhv1Ksnfw=
github.com/antchfx/xmlquery v1.2.0/go.mod h1:/+CnyD/DzHRnv2eRxrVbieRU/FIF6N0C+7oTtyUtCKk=
github.com/antchfx/xpath v1.1.1 h1:mqGYmd5pioPu06+REIf8j3y6O3S1UpVNVoCameZHotg=
github.com/antchfx/xpath v1.1.1/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/antchfx/xmlquery v1.3.1 h1:nIKWdtnhrXtj0/IRUAAw2I7TfpHUa3zMnHvNmPXFg+w=
github.com/antchfx/xmlquery v1.3.1/go.mod h1:64w0Xesg2sTaawIdNqMB+7qaW/bSqkQm+ssPaCMWNnc=
github.com/antchfx/xpath v1.1.10 h1:cJ0pOvEdN/WvYXxvRrzQH9x5QWKpzHacYO8qzCcDYAg=
github.com/antchfx/xpath v1.1.10/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 h1:ekDALXAVvY/Ub1UtNta3inKQwZ/jMB/zpOtD8rAYh78=
github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330/go.mod h1:nH+k0SvAt3HeiYyOlJpLLv1HG1p7KWP7qU9QPp2/pCo=
github.com/bwmarrin/discordgo v0.25.0 h1:NXhdfHRNxtwso6FPdzW2i3uBvvU7UIQTghmV2T4nqAs=
github.com/bwmarrin/discordgo v0.25.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE=
github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598 h1:j2XRGH5Y5uWtBYXGwmrjKeM/kfu/jh7ZcnrGvyN5Ttk=
github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598/go.mod h1:sduMkaHcXDIWurl/Bd/z0rNEUHw5tr6LUA9IO8E9o0o=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff h1:+TEqaP0eO1unI7XHHFeMDhsxhLDIb0x8KYuZbqbAmxA=
github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff/go.mod h1:QCRjR0b4qiJiNjuP7RFM89bh4UExGJalcWmYeSvlnRc=
github.com/chrissexton/sentiment v0.0.0-20190927141846-d69c422ba035 h1:3+eJGFTbUgOMDCpa8PTmJABs1Z3EDHRrcz6d3oXfZm0=
github.com/chrissexton/sentiment v0.0.0-20190927141846-d69c422ba035/go.mod h1:5V55omeg+mdO+zAi38c3S9I1m5IZgdNPqiSKSXIdo88=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
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/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vIsEOKiK+l/NSLN4uUtkyuxc6hgYpQeCWAeI=
github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw=
github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA=
github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90 h1:WXb3TSNmHp2vHoCroCIB1foO/yQ36swABL8aOVeDpgg=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/forPelevin/gomoji v1.1.4 h1:mlxsZQgTO7v1qnpUUoS8kk0Lf/rEvxZYgYxuVUX7edg=
github.com/forPelevin/gomoji v1.1.4/go.mod h1:ypB7Kz3Fsp+LVR7KoT7mEFOioYBuTuAtaAT4RGl+ASY=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/forPelevin/gomoji v1.1.6 h1:mSIGhjyMiywuGFHR/6CLL/L6HwwDiQmYGdl1R9a/05w=
github.com/forPelevin/gomoji v1.1.6/go.mod h1:h31zCiwG8nIto/c9RmijODA1xgN2JSvwKfU7l65xeTk=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE=
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ=
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/httprate v0.5.3 h1:5HPWb0N6ymIiuotMtCfOGpQKiKeqXVzMexHh1W1yXPc=
github.com/go-chi/httprate v0.5.3/go.mod h1:kYR4lorHX3It9tTh4eTdHhcF2bzrYnCrRNlv5+IBm2M=
github.com/go-chi/httprate v0.7.0 h1:8W0dF7Xa2Duz2p8ncGaehIphrxQGNlOtoGY0+NRRfjQ=
github.com/go-chi/httprate v0.7.0/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
@ -70,39 +147,110 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE=
github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gonum/floats v0.0.0-20181209220543-c233463c7e82 h1:EvokxLQsaaQjcWVWSV38221VAK7qc2zhaO17bKys/18=
github.com/gonum/floats v0.0.0-20181209220543-c233463c7e82/go.mod h1:PxC8OnwL11+aosOB5+iEPoV3picfs8tUpkVd0pDo+Kg=
github.com/gonum/internal v0.0.0-20181124074243-f884aa714029 h1:8jtTdc+Nfj9AR+0soOeia9UZSvYBvETVHZrugUowJ7M=
github.com/gonum/internal v0.0.0-20181124074243-f884aa714029/go.mod h1:Pu4dmpkhSyOzRwuXkOgAvijx4o+4YMUJJo9OvPYMkks=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/improbable-eng/go-httpwares v0.0.0-20200609095714-edc8019f93cc h1:jPofYCdWojUaUhjlAe5yM/H4PFDfrZ6ldrlqoVv5YDM=
github.com/improbable-eng/go-httpwares v0.0.0-20200609095714-edc8019f93cc/go.mod h1:LE9Hs6fsYQ7RoDuFUQlYmlRAku9vUlSlO++jWNj+D0I=
github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 h1:KUDFlmBg2buRWNzIcwLlKvfcnujcHQRQ1As1LoaCLAM=
github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
github.com/itchyny/gojq v0.12.8 h1:Zxcwq8w4IeR8JJYEtoG2MWJZUv0RGY6QqJcO1cqV8+A=
github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/itchyny/gojq v0.12.9 h1:biKpbKwMxVYhCU1d6mR7qMr3f0Hn9F5k5YykCVb3gmM=
github.com/itchyny/gojq v0.12.9/go.mod h1:T4Ip7AETUXeGpD+436m+UEl3m3tokRgajd5pRfsR5oE=
github.com/itchyny/timefmt-go v0.1.4 h1:hFEfWVdwsEi+CY8xY2FtgWHGQaBaC3JeHd+cve0ynVM=
github.com/itchyny/timefmt-go v0.1.4/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/james-bowman/nlp v0.0.0-20191016091239-d9dbfaff30c6 h1:k8+n5sfvxlixRNVkbelPGzEYjbGIKaBnRzRlx2NCtYA=
github.com/james-bowman/nlp v0.0.0-20191016091239-d9dbfaff30c6/go.mod h1:kixuaexEqWB+mHZNysgnb6mqgGIT25WvD1/tFRRt0J0=
github.com/james-bowman/sparse v0.0.0-20190423065201-80c6877364c7 h1:ph/BDQQDL41apnHSN48I5GyNOQXXAlc79HwGqDSXCss=
github.com/james-bowman/sparse v0.0.0-20190423065201-80c6877364c7/go.mod h1:G6EcQnwZKsWtItoaQHd+FHPPk6bDeYVJSeeSP9Sge+I=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
@ -114,56 +262,118 @@ github.com/kevinburke/rest v0.0.0-20200429221318-0d2892b400f8 h1:KpuDJTaTPQAyWqE
github.com/kevinburke/rest v0.0.0-20200429221318-0d2892b400f8/go.mod h1:pD+iEcdAGVXld5foVN4e24zb/6fnb60tgZPZ3P/3T/I=
github.com/kevinburke/twilio-go v0.0.0-20200424172635-4f0b2357b852 h1:wJMykIkD7A4tlwQNzqBJ23hkLlKtRKYeNNt+n8ASqWE=
github.com/kevinburke/twilio-go v0.0.0-20200424172635-4f0b2357b852/go.mod h1:Fm9alkN1/LPVY1eqD/psyMwPWE4VWl4P01/nTYZKzBk=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leso-kn/go-sqlite3 v0.0.0-20230710125852-03158dc838ed h1:lM1oz49yOQhEQsJh3lRnQ/voNTO+Lurx8fRy2Gmb2c8=
github.com/leso-kn/go-sqlite3 v0.0.0-20230710125852-03158dc838ed/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mmcdole/gofeed v1.1.3 h1:pdrvMb18jMSLidGp8j0pLvc9IGziX4vbmvVqmLH6z8o=
github.com/mmcdole/gofeed v1.1.3/go.mod h1:QQO3maftbOu+hiVOGOZDRLymqGQCos4zxbA4j89gMrE=
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI=
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M=
github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw=
github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254 h1:JYoQR67E1vv1WGoeW8DkdFs7vrIEe/5wP+qJItd5tUE=
github.com/olebedev/when v0.0.0-20190311101825-c3b538a97254/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw=
github.com/rivo/uniseg v0.3.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d h1:1VUlQbCfkoSGv7qP7Y+ro3ap1P1pPZxgdGVqiTVy5C4=
github.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/slack-go/slack v0.11.0 h1:sBBjQz8LY++6eeWhGJNZpRm5jvLRNnWBFZ/cAq58a6k=
github.com/slack-go/slack v0.11.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slack-go/slack v0.11.3 h1:GN7revxEMax4amCc3El9a+9SGnjmBvSUobs0QnO6ZO8=
github.com/slack-go/slack v0.11.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/temoto/robotstxt v1.1.1 h1:Gh8RCs8ouX3hRSxxK7B1mO5RFByQ4CmJZDwgom++JaA=
github.com/temoto/robotstxt v1.1.1/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/trubitsyn/go-zero-width v1.0.1 h1:AAZhtyGXW79T5BouAF0R9FtDhGcp7IGbLZo2Id3N+m8=
@ -175,75 +385,357 @@ github.com/ttacon/libphonenumber v1.1.0/go.mod h1:E0TpmdVMq5dyVlQ7oenAkhsLu86OkU
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/velour/velour v0.0.0-20160303155839-8e090e68d158 h1:p3rTUXxzuKsBOsHlkly7+rj9wagFBKeIsCDKkDII9sw=
github.com/velour/velour v0.0.0-20160303155839-8e090e68d158/go.mod h1:Ojy3BTOiOTwpHpw7/HNi+TVTFppao191PQs+Qc3sqpE=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191014171548-69215a2ee97e h1:ewBcnrlKhy0GKnQ31tXkOC/G7/jHC4ogar1TiIfANC4=
golang.org/x/exp v0.0.0-20191014171548-69215a2ee97e/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/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-20180826012351-8a410e7b638d/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-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
gonum.org/v1/gonum v0.6.0 h1:DJy6UzXbahnGUf1ujUNkh/NEtK14qMo2nvlBPs4U5yw=
gonum.org/v1/gonum v0.6.0/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY=
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.52.0 h1:kd48UiU7EHsV4rnLyOJRuP/Il/UHE7gdDAQ+SZI7nZk=
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/webhooks.v5 v5.17.0 h1:truBced5ZmkiNKK47cM8bMe86wUSjNks7SFMuNKwzlc=
gopkg.in/go-playground/webhooks.v5 v5.17.0/go.mod h1:LZbya/qLVdbqDR1aKrGuWV6qbia2zCYSR5dpom2SInQ=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

22
main.go
View File

@ -2,8 +2,14 @@
package main
//go:generate templ generate
import (
"flag"
"github.com/velour/catbase/plugins/gpt"
"github.com/velour/catbase/plugins/pagecomment"
"github.com/velour/catbase/plugins/talker"
"github.com/velour/catbase/plugins/tappd"
"github.com/velour/catbase/plugins/topic"
"io"
"math/rand"
@ -16,7 +22,6 @@ import (
"github.com/velour/catbase/bot/msg"
"github.com/velour/catbase/connectors/discord"
"github.com/velour/catbase/plugins/giphy"
"github.com/velour/catbase/plugins/gpt3"
"github.com/velour/catbase/plugins/last"
"github.com/velour/catbase/plugins/mayi"
"github.com/velour/catbase/plugins/quotegame"
@ -42,7 +47,6 @@ import (
"github.com/velour/catbase/plugins/admin"
"github.com/velour/catbase/plugins/babbler"
"github.com/velour/catbase/plugins/beers"
"github.com/velour/catbase/plugins/cli"
"github.com/velour/catbase/plugins/couldashouldawoulda"
"github.com/velour/catbase/plugins/counter"
"github.com/velour/catbase/plugins/dice"
@ -63,7 +67,6 @@ import (
"github.com/velour/catbase/plugins/rss"
"github.com/velour/catbase/plugins/sisyphus"
"github.com/velour/catbase/plugins/stock"
"github.com/velour/catbase/plugins/talker"
"github.com/velour/catbase/plugins/tell"
"github.com/velour/catbase/plugins/tldr"
"github.com/velour/catbase/plugins/twitch"
@ -126,12 +129,13 @@ func main() {
b := bot.New(c, client)
if r, path := client.GetRouter(); r != nil {
b.RegisterWeb(r, path)
b.GetWeb().RegisterWeb(r, path)
}
b.AddPlugin(admin.New(b))
b.AddPlugin(roles.New(b))
b.AddPlugin(gpt3.New(b))
b.AddPlugin(twitch.New(b))
b.AddPlugin(pagecomment.New(b))
b.AddPlugin(secrets.New(b))
b.AddPlugin(mayi.New(b))
b.AddPlugin(giphy.New(b))
@ -139,9 +143,9 @@ func main() {
b.AddPlugin(last.New(b))
b.AddPlugin(first.New(b))
b.AddPlugin(leftpad.New(b))
b.AddPlugin(talker.New(b))
b.AddPlugin(dice.New(b))
b.AddPlugin(picker.New(b))
b.AddPlugin(tappd.New(b))
b.AddPlugin(beers.New(b))
b.AddPlugin(remember.New(b))
b.AddPlugin(your.New(b))
@ -151,7 +155,6 @@ func main() {
b.AddPlugin(babbler.New(b))
b.AddPlugin(rss.New(b))
b.AddPlugin(reaction.New(b))
b.AddPlugin(twitch.New(b))
b.AddPlugin(inventory.New(b))
b.AddPlugin(rpgORdie.New(b))
b.AddPlugin(sisyphus.New(b))
@ -164,7 +167,6 @@ func main() {
b.AddPlugin(twitter.New(b))
b.AddPlugin(git.New(b))
b.AddPlugin(impossible.New(b))
b.AddPlugin(cli.New(b))
b.AddPlugin(aoc.New(b))
b.AddPlugin(meme.New(b))
b.AddPlugin(achievements.New(b))
@ -175,8 +177,10 @@ func main() {
b.AddPlugin(emojy.New(b))
b.AddPlugin(cowboy.New(b))
b.AddPlugin(topic.New(b))
// catches anything left, will always return true
b.AddPlugin(talker.New(b))
b.AddPlugin(fact.New(b))
// catches anything left, will always return true
b.AddPlugin(gpt.New(b))
if err := client.Serve(); err != nil {
log.Fatal().Err(err)

91
plugins/admin/admin.templ Normal file
View File

@ -0,0 +1,91 @@
package admin
import "fmt"
templ (a *AdminPlugin) page() {
<div class="grid-container">
<form>
<div class="grid-x grid-margin-x align-bottom">
<h2>App Pass</h2>
</div>
<div class="grid-x grid-margin-x align-bottom">
<div class="cell auto">
<label for="password">Password:
<input type="text" name="password"></input>
</label>
</div>
<div class="cell auto">
<label for="secret">Secret:
<input type="text" name="secret"></input>
</label>
</div>
<div class="cell auto">
<button hx-post="/apppass/api" hx-target="#data" class="button">List</button>
<button hx-put="/apppass/api" hx-target="#data" class="submit success button">New</button>
</div>
</div>
</form>
<div class="grid-x">
<div class="cell" id="data"></div>
</div>
</div>
}
templ (a *AdminPlugin) showPassword(entry PassEntry) {
<div><span>ID</span><span>{ fmt.Sprintf("%d", entry.ID) }</span></div>
<div><span>Password</span><span>{ entry.Secret }:{ entry.Pass }</span></div>
}
templ (a *AdminPlugin) entries(items []PassEntry) {
<div>
if len(items) == 0 {
<span>No items</span>
}
<ul>
for _, entry := range items {
<li>
<button href="#"
class="button alert tiny"
style="vertical-align: baseline"
hx-delete="/apppass/api"
hx-confirm={ fmt.Sprintf("Are you sure you want to delete %d?", entry.ID) }
hx-target="#data"
hx-include="this,[name='password'],[name='secret']"
name="id" value={ fmt.Sprintf("%d", entry.ID) }>X</button>
{ fmt.Sprintf("%d", entry.ID) }
</li>
}
</ul>
</div>
}
templ renderError(err error) {
<div>{ err.Error() }</div>
}
templ vars(items []configEntry) {
<div class="container">
<h2>Variables</h2>
<table class="hover striped">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
for _, item := range items {
<tr>
<td>{ item.Key }</td><td>{ item.Value }</td>
</tr>
}
if len(items) == 0 {
<tr>
<td colspan="2">No data</td>
</tr>
}
</tbody>
</table>
</div>
}

View File

@ -0,0 +1,276 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package admin
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "fmt"
func (a *AdminPlugin) page() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><form><div class=\"grid-x grid-margin-x align-bottom\"><h2>App Pass</h2></div><div class=\"grid-x grid-margin-x align-bottom\"><div class=\"cell auto\"><label for=\"password\">Password: <input type=\"text\" name=\"password\"></label></div><div class=\"cell auto\"><label for=\"secret\">Secret: <input type=\"text\" name=\"secret\"></label></div><div class=\"cell auto\"><button hx-post=\"/apppass/api\" hx-target=\"#data\" class=\"button\">List</button> <button hx-put=\"/apppass/api\" hx-target=\"#data\" class=\"submit success button\">New</button></div></div></form><div class=\"grid-x\"><div class=\"cell\" id=\"data\"></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (a *AdminPlugin) showPassword(entry PassEntry) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div><span>ID</span><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", entry.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 35, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></div><div><span>Password</span><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Secret)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 36, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(":")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Pass)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 36, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (a *AdminPlugin) entries(items []PassEntry) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(items) == 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span>No items</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, entry := range items {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><button href=\"#\" class=\"button alert tiny\" style=\"vertical-align: baseline\" hx-delete=\"/apppass/api\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(fmt.Sprintf("Are you sure you want to delete %d?", entry.ID)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"#data\" hx-include=\"this,[name=&#39;password&#39;],[name=&#39;secret&#39;]\" name=\"id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(fmt.Sprintf("%d", entry.ID)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">X</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", entry.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 55, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func renderError(err error) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(err.Error())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 63, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func vars(items []configEntry) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container\"><h2>Variables</h2><table class=\"hover striped\"><thead><tr><th>Key</th><th>Value</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 79, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/admin/admin.templ`, Line: 79, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(items) == 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr><td colspan=\"2\">No data</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -5,8 +5,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -34,10 +32,8 @@ func makeMessage(payload string, r *regexp.Regexp) bot.Request {
if isCmd {
payload = payload[1:]
}
c := cli.CliPlugin{}
values := bot.ParseValues(r, payload)
return bot.Request{
Conn: &c,
Kind: bot.Message,
Values: values,
Msg: msg.Message{

View File

@ -1,160 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css"/>
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css"/>
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="//unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>Vars</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>App Pass</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'App Pass'">{{ item.name }}
</b-nav-item>
</b-navbar-nav>
</b-navbar>
<div class="alert alert-warning alert-dismissible fade show" role="alert" v-if="err != ''">
<b-button type="link" class="close" data-dismiss="alert" aria-label="Close" @click="err = ''">
<span aria-hidden="true">&times;</span>
</b-button>
{{ err }}
</div>
<b-form>
<b-container>
<b-row>
<b-col cols="5">Password:</b-col>
<b-col>
<b-input v-model="password"/>
</b-col>
</b-row>
<b-row>
<b-col cols="5">Secret:</b-col>
<b-col>
<b-input v-model="entry.secret"/>
</b-col>
</b-row>
<b-row>
<b-col>
<b-button @click="list">List</b-button>
</b-col>
<b-col>
<b-button @click="newPass">New</b-button>
</b-col>
</b-row>
</b-container>
</b-form>
<b-container v-show="showPassword" style="padding: 2em">
<b-row align-h="center">
<b-col align-self="center" cols="1">ID:</b-col>
<b-col align-self="center" cols="3">{{ entry.id }}</b-col>
</b-row>
<b-row align-h="center">
<b-col align-self="center" cols="1">Password:</b-col>
<b-col align-self="center" cols="3">{{ entry.secret }}:{{ showPassword }}</b-col>
</b-row>
<b-row align-h="center">
<b-col align-self="center" class="text-center" cols="6">Note: this password will only be displayed once. For
single-field entry passwords, use the secret:password format.
</b-col>
</b-row>
</b-container>
<b-container>
<b-row style="padding-top: 2em;">
<b-col>
<ul>
<li v-for="entry in entries" key="id">
<a @click="rm(entry)" href="#">X</a> {{entry.id}}
</li>
</ul>
</b-col>
</b-row>
</b-container>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
err: '',
entry: {
secret: '',
},
password: '',
showPassword: '',
nav: [],
entries: [],
},
mounted() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
},
methods: {
rm: function (data) {
this.showPassword = '';
this.entry.id = data.id
axios.delete('/apppass/api', {
data: {
password: this.password,
passEntry: this.entry
}
})
.then(() => {
this.getData()
})
.catch(({response}) => {
console.log('error: ' + response.data.err)
this.err = response.data.err
})
},
list: function () {
this.showPassword = '';
this.getData();
},
newPass: function () {
axios.put('/apppass/api', {
password: this.password,
passEntry: this.entry
})
.then(resp => {
this.getData()
this.showPassword = resp.data.pass
this.entry.id = resp.data.id
})
.catch(({response}) => {
console.log('error: ' + response.data.err)
this.err = response.data.err
})
},
getData: function () {
axios.post('/apppass/api', {
password: this.password,
passEntry: this.entry
})
.then(resp => {
this.entries = resp.data;
})
.catch(({response}) => {
console.log('error: ' + response.data.err)
this.err = response.data.err
})
}
}
})
</script>
</body>
</html>

View File

@ -1,77 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css"/>
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css"/>
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="//unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>Vars</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>Variables</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'Variables'">{{ item.name }}
</b-nav-item>
</b-navbar-nav>
</b-navbar>
<b-alert
dismissable
variant="error"
v-if="err"
@dismissed="err = ''">
{{ err }}
</b-alert>
<b-container>
<b-table
fixed
:items="vars"
:sort-by.sync="sortBy"
:fields="fields"></b-table>
</b-container>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
err: '',
nav: [],
vars: [],
sortBy: 'key',
fields: [
{key: {sortable: true}},
'value'
]
},
mounted() {
this.getData();
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
},
methods: {
getData: function () {
axios.get('/vars/api')
.then(resp => {
this.vars = resp.data;
})
.catch(err => this.err = err);
}
}
})
</script>
</body>
</html>

View File

@ -1,38 +1,34 @@
package admin
import (
"context"
"crypto/md5"
"crypto/rand"
"embed"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strconv"
)
//go:embed *.html
var embeddedFS embed.FS
func (p *AdminPlugin) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/api", p.handleVarsAPI)
r.HandleFunc("/", p.handleVars)
p.bot.RegisterWebName(r, "/vars", "Variables")
p.bot.GetWeb().RegisterWebName(r, "/vars", "Variables")
r = chi.NewRouter()
r.HandleFunc("/verify", p.handleAppPassCheck)
r.HandleFunc("/api", p.handleAppPassAPI)
r.HandleFunc("/", p.handleAppPass)
p.bot.RegisterWebName(r, "/apppass", "App Pass")
p.bot.GetWeb().RegisterWebName(r, "/apppass", "App Pass")
}
func (p *AdminPlugin) handleAppPass(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("apppass.html")
w.Write(index)
p.bot.GetWeb().Index("App Pass", p.page()).Render(r.Context(), w)
}
type PassEntry struct {
@ -76,26 +72,41 @@ func (p *AdminPlugin) handleAppPassCheck(w http.ResponseWriter, r *http.Request)
}
func (p *AdminPlugin) handleAppPassAPI(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
b, _ := io.ReadAll(r.Body)
query, _ := url.ParseQuery(string(b))
secret := r.FormValue("secret")
password := r.FormValue("password")
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
if !r.Form.Has("secret") && query.Has("secret") {
secret = query.Get("secret")
}
if !r.Form.Has("password") && query.Has("password") {
password = query.Get("password")
}
if !r.Form.Has("id") && query.Has("id") {
id, _ = strconv.ParseInt(query.Get("id"), 10, 64)
}
req := struct {
Password string `json:"password"`
PassEntry PassEntry `json:"passEntry"`
}{}
body, _ := ioutil.ReadAll(r.Body)
_ = json.Unmarshal(body, &req)
}{
password,
PassEntry{
ID: id,
Secret: secret,
},
}
if req.PassEntry.Secret == "" {
writeErr(w, fmt.Errorf("missing secret"))
writeErr(r.Context(), w, fmt.Errorf("missing secret"))
return
}
if req.Password == "" || !p.bot.CheckPassword(req.PassEntry.Secret, req.Password) {
writeErr(w, fmt.Errorf("missing or incorrect password"))
writeErr(r.Context(), w, fmt.Errorf("missing or incorrect password"))
return
}
switch r.Method {
case http.MethodPut:
if req.PassEntry.Secret == "" {
writeErr(w, fmt.Errorf("missing secret"))
return
}
if string(req.PassEntry.Pass) == "" {
c := 10
b := make([]byte, c)
@ -120,27 +131,27 @@ func (p *AdminPlugin) handleAppPassAPI(w http.ResponseWriter, r *http.Request) {
res, err := p.db.Exec(q, req.PassEntry.Secret, req.PassEntry.encodedPass, req.PassEntry.Cost)
if err != nil {
writeErr(w, err)
writeErr(r.Context(), w, err)
return
}
id, err := res.LastInsertId()
if err != nil {
writeErr(w, err)
writeErr(r.Context(), w, err)
return
}
req.PassEntry.ID = id
j, _ := json.Marshal(req.PassEntry)
fmt.Fprint(w, string(j))
p.showPassword(req.PassEntry).Render(r.Context(), w)
return
case http.MethodDelete:
if req.PassEntry.ID <= 0 {
writeErr(w, fmt.Errorf("missing ID"))
writeErr(r.Context(), w, fmt.Errorf("missing ID"))
return
}
q := `delete from apppass where id = ?`
_, err := p.db.Exec(q, req.PassEntry.ID)
if err != nil {
writeErr(w, err)
writeErr(r.Context(), w, err)
return
}
}
@ -148,34 +159,24 @@ func (p *AdminPlugin) handleAppPassAPI(w http.ResponseWriter, r *http.Request) {
passEntries := []PassEntry{}
err := p.db.Select(&passEntries, q, req.PassEntry.Secret)
if err != nil {
writeErr(w, err)
writeErr(r.Context(), w, err)
return
}
j, _ := json.Marshal(passEntries)
_, _ = fmt.Fprint(w, string(j))
p.entries(passEntries).Render(r.Context(), w)
}
func writeErr(w http.ResponseWriter, err error) {
func writeErr(ctx context.Context, w http.ResponseWriter, err error) {
log.Error().Err(err).Msg("apppass error")
j, _ := json.Marshal(struct {
Err string `json:"err"`
}{
err.Error(),
})
w.WriteHeader(400)
fmt.Fprint(w, string(j))
renderError(err).Render(ctx, w)
}
type configEntry struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (p *AdminPlugin) handleVars(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("vars.html")
w.Write(index)
}
func (p *AdminPlugin) handleVarsAPI(w http.ResponseWriter, r *http.Request) {
var configEntries []struct {
Key string `json:"key"`
Value string `json:"value"`
}
var configEntries []configEntry
q := `select key, value from config`
err := p.db.Select(&configEntries, q)
if err != nil {
@ -186,13 +187,6 @@ func (p *AdminPlugin) handleVarsAPI(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, err)
return
}
for i, e := range configEntries {
if strings.Contains(e.Value, ";;") {
e.Value = strings.ReplaceAll(e.Value, ";;", ", ")
e.Value = fmt.Sprintf("[%s]", e.Value)
configEntries[i] = e
}
}
j, _ := json.Marshal(configEntries)
fmt.Fprintf(w, "%s", j)
p.bot.GetWeb().Index("Variables", vars(configEntries)).Render(r.Context(), w)
}

View File

@ -65,7 +65,7 @@ func (p *AOC) aocCmd(r bot.Request) bool {
})
gold, silver, bronze := -1, -1, -1
goldID, silverID, bronzeID := "", "", ""
goldID, silverID, bronzeID := -1, -1, -1
for _, m := range members {
if m.LocalScore > gold {
gold = m.LocalScore

View File

@ -7,8 +7,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -16,13 +14,11 @@ import (
)
func makeMessage(payload string, r *regexp.Regexp) bot.Request {
c := &cli.CliPlugin{}
isCmd := strings.HasPrefix(payload, "!")
if isCmd {
payload = payload[1:]
}
return bot.Request{
Conn: c,
Kind: bot.Message,
Values: bot.ParseValues(r, payload),
Msg: msg.Message{
@ -82,7 +78,7 @@ func TestBabblerNothingSaid(t *testing.T) {
}
}
func TestBabbler(t *testing.T) {
func testBabbler(t *testing.T) {
mb := bot.NewMockBot()
bp := newBabblerPlugin(mb)
assert.NotNil(t, bp)
@ -272,7 +268,6 @@ func TestHelp(t *testing.T) {
mb := bot.NewMockBot()
bp := newBabblerPlugin(mb)
assert.NotNil(t, bp)
c := &cli.CliPlugin{}
bp.help(c, bot.Help, msg.Message{Channel: "channel"}, []string{})
bp.help(nil, bot.Help, msg.Message{Channel: "channel"}, []string{})
assert.Len(t, mb.Messages, 1)
}

View File

@ -598,7 +598,7 @@ func (p *BeersPlugin) untappdLoop(c bot.Connector, channel string) {
func (p *BeersPlugin) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/img/{id}", p.img)
p.b.RegisterWeb(r, "/beers")
p.b.GetWeb().RegisterWeb(r, "/beers")
}
func (p *BeersPlugin) img(w http.ResponseWriter, r *http.Request) {

View File

@ -7,8 +7,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -21,10 +19,8 @@ func makeMessage(payload string, r *regexp.Regexp) bot.Request {
if isCmd {
payload = payload[1:]
}
c := &cli.CliPlugin{}
values := bot.ParseValues(r, payload)
return bot.Request{
Conn: c,
Kind: bot.Message,
Values: values,
Msg: msg.Message{
@ -136,6 +132,6 @@ func TestBeersReport(t *testing.T) {
func TestHelp(t *testing.T) {
b, mb := makeBeersPlugin(t)
b.help(&cli.CliPlugin{}, bot.Help, msg.Message{Channel: "channel"}, []string{})
b.help(nil, bot.Help, msg.Message{Channel: "channel"}, []string{})
assert.Len(t, mb.Messages, 1)
}

View File

@ -1,143 +0,0 @@
// © 2013 the CatBase Authors under the WTFPL. See AUTHORS for the list of authors.
package cli
import (
"embed"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
"github.com/velour/catbase/bot/user"
)
//go:embed *.html
var embeddedFS embed.FS
type CliPlugin struct {
bot bot.Bot
db *sqlx.DB
cache string
counter int
}
func New(b bot.Bot) *CliPlugin {
cp := &CliPlugin{
bot: b,
}
cp.registerWeb()
return cp
}
func (p *CliPlugin) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/api", p.handleWebAPI)
r.HandleFunc("/", p.handleWeb)
p.bot.RegisterWebName(r, "/cli", "CLI")
}
func (p *CliPlugin) Shutdown() {}
func (p *CliPlugin) GetRouter() (http.Handler, string) {
return nil, ""
}
func (p *CliPlugin) handleWebAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fmt.Fprintf(w, "Incorrect HTTP method")
return
}
info := struct {
User string `json:"user"`
Payload string `json:"payload"`
Password string `json:"password"`
}{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&info)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
log.Debug().
Interface("postbody", info).
Msg("Got a POST")
if !p.bot.CheckPassword("", info.Password) {
w.WriteHeader(http.StatusForbidden)
j, _ := json.Marshal(struct{ Err string }{Err: "Invalid Password"})
w.Write(j)
return
}
p.bot.Receive(p, bot.Message, msg.Message{
User: &user.User{
ID: info.User,
Name: info.User,
Admin: false,
},
Channel: "web",
Body: info.Payload,
Raw: info.Payload,
Command: true,
Time: time.Now(),
})
info.User = p.bot.WhoAmI()
info.Payload = p.cache
p.cache = ""
data, err := json.Marshal(info)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
w.Write(data)
}
func (p *CliPlugin) handleWeb(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("index.html")
w.Write(index)
}
// Completing the Connector interface, but will not actually be a connector
func (p *CliPlugin) RegisterEvent(cb bot.Callback) {}
func (p *CliPlugin) Send(kind bot.Kind, args ...any) (string, error) {
switch kind {
case bot.Message:
fallthrough
case bot.Action:
fallthrough
case bot.Reply:
fallthrough
case bot.Reaction:
p.cache += args[1].(string) + "\n"
}
id := fmt.Sprintf("%d", p.counter)
p.counter++
return id, nil
}
func (p *CliPlugin) GetEmojiList(bool) map[string]string { return nil }
func (p *CliPlugin) Serve() error { return nil }
func (p *CliPlugin) Who(s string) []string { return nil }
func (p *CliPlugin) Profile(name string) (user.User, error) {
return user.User{}, fmt.Errorf("unimplemented")
}
func (p *CliPlugin) Emojy(name string) string { return name }
func (p *CliPlugin) DeleteEmojy(name string) error { return nil }
func (p *CliPlugin) UploadEmojy(emojy, path string) error { return nil }
func (p *CliPlugin) URLFormat(title, url string) string {
return fmt.Sprintf("%s (%s)", title, url)
}
func (p *CliPlugin) GetChannelName(id string) string { return id }
func (p *CliPlugin) GetChannelID(name string) string { return name }
func (p *CliPlugin) GetRoles() ([]bot.Role, error) { return []bot.Role{}, nil }
func (p *CliPlugin) SetRole(userID, roleID string) error { return nil }
func (p *CliPlugin) Nick(string) error { return nil }

View File

@ -1,132 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css" />
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>CLI</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>CLI</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'CLI'">{{ item.name }}</b-nav-item>
</b-navbar-nav>
</b-navbar>
<b-alert
dismissable
variant="error"
:show="err">
{{ err }}
</b-alert>
<b-container>
<b-row>
<b-col cols="5">Password:</b-col>
<b-col><b-input v-model="answer"></b-col>
</b-row>
<b-row>
<b-form-textarea
v-sticky-scroll
disabled
id="textarea"
v-model="text"
placeholder="The bot will respond here..."
rows="10"
max-rows="10"
no-resize
></b-form-textarea>
</b-row>
<b-form
@submit="send">
<b-row>
<b-col>
<b-form-input
type="text"
placeholder="Username"
v-model="user"></b-form-input>
</b-col>
<b-col>
<b-form-input
type="text"
placeholder="Enter something to send to the bot"
v-model="input"
autocomplete="off"
></b-form-input>
</b-col>
<b-col>
<b-button type="submit" :disabled="!authenticated">Send</b-button>
</b-col>
</b-row>
</b-form>
</b-container>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
err: '',
nav: [],
answer: '',
correct: 0,
textarea: [],
user: '',
input: '',
},
mounted: function() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
},
computed: {
authenticated: function() {
if (this.user !== '')
return true;
return false;
},
text: function() {
return this.textarea.join('\n');
}
},
methods: {
addText(user, text) {
this.textarea.push(user + ": " + text);
const len = this.textarea.length;
if (this.textarea.length > 10)
this.textarea = this.textarea.slice(len-10, len);
},
send(evt) {
evt.preventDefault();
evt.stopPropagation()
if (!this.authenticated) {
return;
}
const payload = {user: this.user, payload: this.input, password: this.answer};
this.addText(this.user, this.input);
this.input = "";
axios.post('/cli/api', payload)
.then(resp => {
const data = resp.data;
this.addText(data.user, data.payload.trim());
this.err = '';
})
.catch(err => (this.err = err));
}
}
})
</script>
</body>
</html>

View File

@ -6,8 +6,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
@ -21,7 +19,6 @@ func makeMessage(payload string) bot.Request {
payload = payload[1:]
}
return bot.Request{
Conn: &cli.CliPlugin{},
Kind: bot.Message,
Msg: msg.Message{
User: &user.User{Name: "tester"},

View File

@ -1,12 +1,11 @@
package counter
import (
"embed"
"encoding/json"
"fmt"
"github.com/velour/catbase/bot/user"
"io/ioutil"
"io"
"net/http"
"net/url"
"strconv"
"time"
@ -17,9 +16,6 @@ import (
"github.com/velour/catbase/bot/msg"
)
//go:embed *.html
var embeddedFS embed.FS
func (p *CounterPlugin) registerWeb() {
r := chi.NewRouter()
requests := p.cfg.GetInt("counter.requestsPer", 1)
@ -29,19 +25,92 @@ func (p *CounterPlugin) registerWeb() {
subrouter.Use(httprate.LimitByIP(requests, dur))
subrouter.HandleFunc("/api/users/{user}/items/{item}/increment/{delta}", p.mkIncrementByNAPI(1))
subrouter.HandleFunc("/api/users/{user}/items/{item}/decrement/{delta}", p.mkIncrementByNAPI(-1))
subrouter.HandleFunc("/api/users/{user}/items/{item}/increment", p.mkIncrementAPI(1))
subrouter.HandleFunc("/api/users/{user}/items/{item}/decrement", p.mkIncrementAPI(-1))
subrouter.HandleFunc("/api/users/{user}/items/{item}/increment", p.mkIncrementByNAPI(1))
subrouter.HandleFunc("/api/users/{user}/items/{item}/decrement", p.mkIncrementByNAPI(-1))
r.Mount("/", subrouter)
r.HandleFunc("/api", p.handleCounterAPI)
r.HandleFunc("/users/{user}/items/{item}/increment", p.incHandler(1))
r.HandleFunc("/users/{user}/items/{item}/decrement", p.incHandler(-1))
r.HandleFunc("/", p.handleCounter)
p.b.RegisterWebName(r, "/counter", "Counter")
p.b.GetWeb().RegisterWebName(r, "/counter", "Counter")
}
func (p *CounterPlugin) incHandler(delta int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userName, _ := url.QueryUnescape(chi.URLParam(r, "user"))
itemName, _ := url.QueryUnescape(chi.URLParam(r, "item"))
pass := r.FormValue("password")
if !p.b.CheckPassword("", pass) {
w.WriteHeader(401)
fmt.Fprintf(w, "error")
return
}
item, err := p.delta(userName, itemName, "", delta)
if err != nil {
w.WriteHeader(500)
fmt.Fprintf(w, "error")
return
}
p.renderItem(userName, item).Render(r.Context(), w)
}
}
func (p *CounterPlugin) delta(userName, itemName, personalMessage string, delta int) (Item, error) {
// Try to find an ID if possible
id := ""
u, err := p.b.DefaultConnector().Profile(userName)
if err == nil {
id = u.ID
}
item, err := GetUserItem(p.db, userName, id, itemName)
if err != nil {
return item, err
}
chs := p.cfg.GetMap("counter.channelItems", map[string]string{})
ch, ok := chs[itemName]
req := &bot.Request{
Conn: p.b.DefaultConnector(),
Kind: bot.Message,
Msg: msg.Message{
User: &u,
// Noting here that we're only going to do goals in a "default"
// channel even if it should send updates to others.
Channel: ch,
Body: fmt.Sprintf("%s += %d", itemName, delta),
Time: time.Now(),
},
Values: nil,
Args: nil,
}
msg := fmt.Sprintf("%s changed their %s counter by %d for a total of %d via the amazing %s API. %s",
userName, itemName, delta, item.Count+delta, p.cfg.Get("nick", "catbase"), personalMessage)
if !ok {
chs := p.cfg.GetArray("counter.channels", []string{})
for _, ch := range chs {
p.b.Send(p.b.DefaultConnector(), bot.Message, ch, msg)
req.Msg.Channel = ch
}
} else {
p.b.Send(p.b.DefaultConnector(), bot.Message, ch, msg)
req.Msg.Channel = ch
}
if err := item.UpdateDelta(req, delta); err != nil {
return item, err
}
return item, nil
}
func (p *CounterPlugin) mkIncrementByNAPI(direction int) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
userName := chi.URLParam(r, "user")
itemName := chi.URLParam(r, "item")
delta, _ := strconv.Atoi(chi.URLParam(r, "delta"))
userName, _ := url.QueryUnescape(chi.URLParam(r, "user"))
itemName, _ := url.QueryUnescape(chi.URLParam(r, "item"))
delta, err := strconv.Atoi(chi.URLParam(r, "delta"))
if err != nil || delta == 0 {
delta = direction
} else {
delta = delta * direction
}
secret, pass, ok := r.BasicAuth()
if !ok || !p.b.CheckPassword(secret, pass) {
@ -58,15 +127,15 @@ func (p *CounterPlugin) mkIncrementByNAPI(direction int) func(w http.ResponseWri
return
}
// Try to find an ID if possible
id := ""
u, err := p.b.DefaultConnector().Profile(userName)
if err == nil {
id = u.ID
body, _ := io.ReadAll(r.Body)
postData := map[string]string{}
err = json.Unmarshal(body, &postData)
personalMsg := ""
if inputMsg, ok := postData["message"]; ok {
personalMsg = fmt.Sprintf("\nMessage: %s", inputMsg)
}
item, err := GetUserItem(p.db, userName, id, itemName)
if err != nil {
if _, err := p.delta(userName, itemName, personalMsg, delta*direction); err != nil {
log.Error().Err(err).Msg("error finding item")
w.WriteHeader(400)
j, _ := json.Marshal(struct {
@ -77,194 +146,13 @@ func (p *CounterPlugin) mkIncrementByNAPI(direction int) func(w http.ResponseWri
return
}
body, _ := ioutil.ReadAll(r.Body)
postData := map[string]string{}
err = json.Unmarshal(body, &postData)
personalMsg := ""
if inputMsg, ok := postData["message"]; ok {
personalMsg = fmt.Sprintf("\nMessage: %s", inputMsg)
}
chs := p.cfg.GetArray("channels", []string{p.cfg.Get("channels", "none")})
req := &bot.Request{
Conn: p.b.DefaultConnector(),
Kind: bot.Message,
Msg: msg.Message{
User: &u,
// Noting here that we're only going to do goals in a "default"
// channel even if it should send updates to others.
Channel: chs[0],
Body: fmt.Sprintf("%s += %d", itemName, delta),
Time: time.Now(),
},
Values: nil,
Args: nil,
}
msg := fmt.Sprintf("%s changed their %s counter by %d for a total of %d via the amazing %s API. %s",
userName, itemName, delta, item.Count+delta*direction, p.cfg.Get("nick", "catbase"), personalMsg)
for _, ch := range chs {
p.b.Send(p.b.DefaultConnector(), bot.Message, ch, msg)
req.Msg.Channel = ch
}
item.UpdateDelta(req, delta*direction)
j, _ := json.Marshal(struct{ Status bool }{true})
fmt.Fprint(w, string(j))
}
}
func (p *CounterPlugin) mkIncrementAPI(delta int) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
userName := chi.URLParam(r, "user")
itemName := chi.URLParam(r, "item")
secret, pass, ok := r.BasicAuth()
if !ok || !p.b.CheckPassword(secret, pass) {
err := fmt.Errorf("unauthorized access")
log.Error().
Err(err).
Msg("error authenticating user")
w.WriteHeader(401)
j, _ := json.Marshal(struct {
Status bool
Error string
}{false, err.Error()})
fmt.Fprint(w, string(j))
return
}
// Try to find an ID if possible
id := ""
u, err := p.b.DefaultConnector().Profile(userName)
if err == nil {
id = u.ID
}
item, err := GetUserItem(p.db, userName, id, itemName)
if err != nil {
log.Error().Err(err).Msg("error finding item")
w.WriteHeader(400)
j, _ := json.Marshal(struct {
Status bool
Error error
}{false, err})
fmt.Fprint(w, string(j))
return
}
body, _ := ioutil.ReadAll(r.Body)
postData := map[string]string{}
err = json.Unmarshal(body, &postData)
personalMsg := ""
if inputMsg, ok := postData["message"]; ok {
personalMsg = fmt.Sprintf("\nMessage: %s", inputMsg)
}
chs := p.cfg.GetArray("channels", []string{p.cfg.Get("channels", "none")})
req := &bot.Request{
Conn: p.b.DefaultConnector(),
Kind: bot.Message,
Msg: msg.Message{
User: &u,
// Noting here that we're only going to do goals in a "default"
// channel even if it should send updates to others.
Channel: chs[0],
Body: fmt.Sprintf("%s += %d", itemName, delta),
Time: time.Now(),
},
Values: nil,
Args: nil,
}
msg := fmt.Sprintf("%s changed their %s counter by %d for a total of %d via the amazing %s API. %s",
userName, itemName, delta, item.Count+delta, p.cfg.Get("nick", "catbase"), personalMsg)
for _, ch := range chs {
p.b.Send(p.b.DefaultConnector(), bot.Message, ch, msg)
req.Msg.Channel = ch
}
item.UpdateDelta(req, delta)
j, _ := json.Marshal(struct{ Status bool }{true})
fmt.Fprint(w, string(j))
}
}
func (p *CounterPlugin) handleCounter(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("index.html")
w.Write(index)
}
func (p *CounterPlugin) handleCounterAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
info := struct {
User string
Thing string
Action string
Password string
}{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&info)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
log.Debug().
Interface("postbody", info).
Msg("Got a POST")
if !p.b.CheckPassword("", info.Password) {
w.WriteHeader(http.StatusForbidden)
j, _ := json.Marshal(struct{ Err string }{Err: "Invalid Password"})
w.Write(j)
return
}
req := bot.Request{
Conn: p.b.DefaultConnector(),
Kind: bot.Message,
Msg: msg.Message{
User: &user.User{
ID: "",
Name: info.User,
Admin: false,
},
},
}
// resolveUser requires a "full" request object so we are faking it
nick, id := p.resolveUser(req, info.User)
item, err := GetUserItem(p.db, nick, id, info.Thing)
if err != nil {
log.Error().
Err(err).
Str("subject", info.User).
Str("itemName", info.Thing).
Msg("error finding item")
w.WriteHeader(404)
fmt.Fprint(w, err)
return
}
if info.Action == "++" {
item.UpdateDelta(nil, 1)
} else if info.Action == "--" {
item.UpdateDelta(nil, -1)
} else {
w.WriteHeader(400)
fmt.Fprint(w, "Invalid increment")
return
}
}
all, err := GetAllItems(p.db)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
log.Error().Err(err).Msg("Error getting items")
return
}
data, err := json.Marshal(all)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
log.Error().Err(err).Msg("Error marshaling items")
return
}
fmt.Fprint(w, string(data))
p.b.GetWeb().Index("Counter", p.index()).Render(r.Context(), w)
}
// Update represents a change that gets sent off to other plugins such as goals

View File

@ -45,7 +45,7 @@ type alias struct {
}
// GetItems returns all counters
func GetAllItems(db *sqlx.DB) ([]Item, error) {
func GetAllItemsByUser(db *sqlx.DB) (map[string][]Item, error) {
var items []Item
err := db.Select(&items, `select * from counter`)
if err != nil {
@ -55,7 +55,11 @@ func GetAllItems(db *sqlx.DB) ([]Item, error) {
for i := range items {
items[i].DB = db
}
return items, nil
out := map[string][]Item{}
for _, it := range items {
out[it.Nick] = append(out[it.Nick], it)
}
return out, nil
}
// GetItems returns all counters for a subject

View File

@ -0,0 +1,66 @@
package counter
import "fmt"
func urlFor(who, what, dir string) string {
return fmt.Sprintf("/counter/users/%s/items/%s/%s", who, what, dir)
}
func (p *CounterPlugin) allItems() map[string][]Item {
items, err := GetAllItemsByUser(p.db)
if err != nil {
return map[string][]Item{"error": []Item{}}
}
return items
}
templ (p *CounterPlugin) index() {
<div class="grid-container">
<div class="grid-x">
<h2>Counter</h2>
</div>
<div class="grid-x">
<div class="input-group">
<span class="input-group-label">Password</span>
<input class="input-group-field" type="text" name="password" />
</div>
</div>
<table>
for user, items := range p.allItems() {
<tr><th class="text-left" colspan="3">{ user }</th></tr>
for _, thing := range items {
@p.renderItem(user, thing)
}
}
</table>
</div>
}
templ (p *CounterPlugin) renderItem(user string, item Item) {
<tr id={ fmt.Sprintf("item%d", item.ID) }>
<td>
{ item.Item }
</td>
<td>
{ fmt.Sprintf("%d", item.Count) }
</td>
<td>
<button
class="button tiny alert"
style="vertical-align: baseline"
hx-target={ "#"+fmt.Sprintf("item%d", item.ID) }
hx-include="[name='password']"
hx-swap="outerHTML"
hx-post={ urlFor(user, item.Item, "decrement") }
>-</button>
<button
class="button tiny success"
style="vertical-align: baseline"
hx-target={ "#"+fmt.Sprintf("item%d", item.ID) }
hx-include="[name='password']"
hx-swap="outerHTML"
hx-post={ urlFor(user, item.Item, "increment") }
>+</button>
</td>
</tr>
}

View File

@ -0,0 +1,168 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package counter
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "fmt"
func urlFor(who, what, dir string) string {
return fmt.Sprintf("/counter/users/%s/items/%s/%s", who, what, dir)
}
func (p *CounterPlugin) allItems() map[string][]Item {
items, err := GetAllItemsByUser(p.db)
if err != nil {
return map[string][]Item{"error": []Item{}}
}
return items
}
func (p *CounterPlugin) index() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><div class=\"grid-x\"><h2>Counter</h2></div><div class=\"grid-x\"><div class=\"input-group\"><span class=\"input-group-label\">Password</span> <input class=\"input-group-field\" type=\"text\" name=\"password\"></div></div><table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for user, items := range p.allItems() {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr><th class=\"text-left\" colspan=\"3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/counter/counter.templ`, Line: 29, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</th></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, thing := range items {
templ_7745c5c3_Err = p.renderItem(user, thing).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (p *CounterPlugin) renderItem(user string, item Item) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(fmt.Sprintf("item%d", item.ID)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Item)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/counter/counter.templ`, Line: 41, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", item.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/counter/counter.templ`, Line: 44, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td><button class=\"button tiny alert\" style=\"vertical-align: baseline\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("#" + fmt.Sprintf("item%d", item.ID)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-include=\"[name=&#39;password&#39;]\" hx-swap=\"outerHTML\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(urlFor(user, item.Item, "decrement")))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">-</button> <button class=\"button tiny success\" style=\"vertical-align: baseline\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("#" + fmt.Sprintf("item%d", item.ID)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-include=\"[name=&#39;password&#39;]\" hx-swap=\"outerHTML\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(urlFor(user, item.Item, "increment")))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">+</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -4,12 +4,12 @@ package counter
import (
"fmt"
"github.com/velour/catbase/config"
"github.com/velour/catbase/connectors/irc"
"regexp"
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
@ -33,7 +33,7 @@ func makeMessage(payload string, r *regexp.Regexp) bot.Request {
}
values := bot.ParseValues(r, payload)
return bot.Request{
Conn: &cli.CliPlugin{},
Conn: irc.New(&config.Config{}),
Msg: msg.Message{
User: &user.User{Name: "tester", ID: "id"},
Body: payload,
@ -280,6 +280,6 @@ func TestInspectMe(t *testing.T) {
func TestHelp(t *testing.T) {
mb, c := setup(t)
assert.NotNil(t, c)
c.help(&cli.CliPlugin{}, bot.Help, msg.Message{Channel: "channel"}, []string{})
c.help(nil, bot.Help, msg.Message{Channel: "channel"}, []string{})
assert.Greater(t, len(mb.Messages), 1)
}

View File

@ -5,6 +5,7 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/velour/catbase/plugins/emojy"
"regexp"
"strings"
"github.com/velour/catbase/connectors/discord"
@ -72,26 +73,25 @@ func (p *Cowboy) register() {
}
func (p *Cowboy) makeCowboy(r bot.Request) {
what := r.Values["what"]
// This'll add the image to the cowboy_cache before discord tries to access it over http
overlays := p.c.GetMap("cowboy.overlays", defaultOverlays)
hat := overlays["hat"]
i, err := cowboy(p.emojyPath, p.baseEmojyURL, hat, what)
what := r.Values["what"]
_, err := p.mkEmojy(what, hat)
if err != nil {
log.Error().Err(err).Msg(":cowboy_fail:")
p.b.Send(r.Conn, bot.Ephemeral, r.Msg.Channel, r.Msg.User.ID, "Hey cowboy, that image wasn't there.")
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "Couldn't cowboify that, pardner.")
return
}
log.Debug().Msgf("makeCowboy: %s", r.Values["what"])
base := p.c.Get("baseURL", "http://127.0.0.1:1337")
u := base + "/cowboy/img/hat/" + r.Values["what"]
p.b.Send(r.Conn, bot.Delete, r.Msg.Channel, r.Msg.ID)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "", bot.ImageAttachment{
URL: u,
AltTxt: fmt.Sprintf("%s: %s", r.Msg.User.Name, r.Msg.Body),
Width: i.Bounds().Max.X,
Height: i.Bounds().Max.Y,
})
e := ":cowboy_" + what + ":"
switch c := r.Conn.(type) {
case *discord.Discord:
list := emojy.InvertEmojyList(c.GetEmojiList(true))
e = strings.Trim(e, ":")
e = fmt.Sprintf("<:%s:%s>", e, list[e])
}
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, r.Msg.User.Name+":")
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, e)
}
func (p *Cowboy) registerCmds(d *discord.Discord) {
@ -142,56 +142,69 @@ func (p *Cowboy) registerCmds(d *discord.Discord) {
}
}
func (p *Cowboy) mkEmojy(name, overlay string) (string, error) {
lastEmojy := p.c.Get("cowboy.lastEmojy", "rust")
emojyPlugin := emojy.NewAPI(p.b)
list := map[string]string{}
msg := fmt.Sprintf("You asked for %s overlaid by %s", name, overlay)
log.Debug().Msgf("got a cowboy command for %s overlaid by %s replacing %s",
name, overlay, lastEmojy)
prefix := overlay
newEmojy, err := cowboy(p.emojyPath, p.baseEmojyURL, overlay, name)
if err != nil {
return "", err
}
err = emojyPlugin.RmEmojy(p.b.DefaultConnector(), lastEmojy)
if err != nil {
return "", err
}
if overlay == "hat" {
prefix = "cowboy"
}
name = emojy.SanitizeName(prefix + "_" + name)
err = emojyPlugin.UploadEmojyImage(p.b.DefaultConnector(), name, newEmojy)
if err != nil {
return "", err
}
p.c.Set("cowboy.lastEmojy", name)
list = emojy.InvertEmojyList(p.b.DefaultConnector().GetEmojiList(true))
msg = fmt.Sprintf("You replaced %s with a new emojy %s <:%s:%s>, pardner!",
lastEmojy, name, name, list[name])
return msg, nil
}
func (p *Cowboy) mkOverlayCB(overlay string) func(s *discordgo.Session, i *discordgo.InteractionCreate) {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
lastEmojy := p.c.Get("cowboy.lastEmojy", "rust")
emojyPlugin := emojy.NewAPI(p.b)
list := map[string]string{}
name := i.ApplicationCommandData().Options[0].StringValue()
if overlay == "" {
overlay = name
name = i.ApplicationCommandData().Options[1].StringValue()
}
msg := fmt.Sprintf("You asked for %s overlaid by %s", name, overlay)
log.Debug().Msgf("got a cowboy command for %s overlaid by %s replacing %s",
name, overlay, lastEmojy)
prefix := overlay
newEmojy, err := cowboy(p.emojyPath, p.baseEmojyURL, overlay, name)
msg, err := p.mkEmojy(name, overlay)
if err != nil {
msg = err.Error()
goto resp
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: err.Error(),
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = emojyPlugin.RmEmojy(p.b.DefaultConnector(), lastEmojy)
if err != nil {
msg = err.Error()
goto resp
}
if overlay == "hat" {
prefix = "cowboy"
}
name = emojy.SanitizeName(prefix + "_" + name)
err = emojyPlugin.UploadEmojyImage(p.b.DefaultConnector(), name, newEmojy)
if err != nil {
msg = err.Error()
goto resp
}
p.c.Set("cowboy.lastEmojy", name)
list = emojy.InvertEmojyList(p.b.DefaultConnector().GetEmojiList(true))
msg = fmt.Sprintf("You replaced %s with a new emojy %s <:%s:%s>, pardner!",
lastEmojy, name, name, list[name])
resp:
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: uint64(discordgo.MessageFlagsEphemeral),
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

View File

@ -10,7 +10,7 @@ import (
func (p *Cowboy) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/img/{overlay}/{what}", p.handleImage)
p.b.RegisterWeb(r, "/cowboy")
p.b.GetWeb().RegisterWeb(r, "/cowboy")
}
func (p *Cowboy) handleImage(w http.ResponseWriter, r *http.Request) {

View File

@ -6,8 +6,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -21,7 +19,6 @@ func makeMessage(payload string) bot.Request {
}
values := bot.ParseValues(rollRegex, payload)
return bot.Request{
Conn: &cli.CliPlugin{},
Kind: bot.Message,
Values: values,
Msg: msg.Message{
@ -67,6 +64,6 @@ func TestHelp(t *testing.T) {
mb := bot.NewMockBot()
c := New(mb)
assert.NotNil(t, c)
c.help(&cli.CliPlugin{}, bot.Help, msg.Message{Channel: "channel"}, []string{})
c.help(nil, bot.Help, msg.Message{Channel: "channel"}, []string{})
assert.Len(t, mb.Messages, 1)
}

View File

@ -61,7 +61,7 @@ resp:
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: uint64(discordgo.MessageFlagsEphemeral),
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

View File

@ -1,107 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css"/>
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css"/>
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>Memes</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>Emojys</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'Meme'" :key="item.key">{{ item.name
}}
</b-nav-item>
</b-navbar-nav>
</b-navbar>
<b-navbar>
<b-navbar-nav>
<b-nav-item href="/emojy/stats">Stats</b-nav-item>
<b-nav-item active href="/emojy/list">List</b-nav-item>
<b-nav-item href="/emojy/new">Upload</b-nav-item>
</b-navbar-nav>
</b-navbar>
<div
style="background-color:red;"
variant="error"
v-if="err != ''"
@click="err = ''">
{{ err }}
</div>
<div class="row row-cols-5">
<div class="card text-center" v-for="name in fileKeys" key="name">
<div class="card-body">
<span>
<b-img-lazy :src="fileList[name]" class="card-img-top mx-auto d-block" :alt="name" width=100 style="max-width: 100px">
</span>
<h5 class="card-title">{{name}}</h5>
</div>
</div>
</div>
</div>
<script>
var app = new Vue({
el: '#app',
data: function () {
return {
err: '',
view: '',
nav: [],
results: [],
fileKeys: [],
fileList: {},
image: null,
password: ''
}
},
watch: {
view(newView, oldView) {
this.err = '';
}
},
mounted() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
this.refresh();
},
methods: {
refresh: function () {
axios.get('/emojy/all')
.then(resp => {
this.results = resp.data
this.err = ''
})
.catch(err => (this.err = err))
axios.get('/emojy/allFiles')
.then(resp => {
// stole this somewhere or other as a quick hack
this.fileKeys = Object.keys(resp.data).sort()
this.fileList = resp.data
this.err = ''
})
.catch(err => (this.err = err))
},
}
})
</script>
</body>
</html>

35
plugins/emojy/list.templ Normal file
View File

@ -0,0 +1,35 @@
package emojy
templ (p *EmojyPlugin) listTempl(emojy emojyMap) {
<div class="grid-container">
<div class="grid-x">
<div class="cell">
<h2>Emojy</h2>
</div>
</div>
<div class="grid-x">
<div class="cell">
@p.emojyNav()
</div>
</div>
<div class="grid-x grid-margin-x small-up-3 medium-up-6 large-up-8">
for _, v := range emojy {
for _, c := range v {
<div class="cell">
<div class="card"
style="max-width: 100px">
<img src={ c.URL }
style="max-height: 100px"
style="max-width: 100px"
alt={ c.Emojy }
/>
<div class="card-divider">
{ c.Emojy }
</div>
</div>
</div>
}
}
</div>
</div>
}

View File

@ -0,0 +1,84 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package emojy
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func (p *EmojyPlugin) listTempl(emojy emojyMap) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><div class=\"grid-x\"><div class=\"cell\"><h2>Emojy</h2></div></div><div class=\"grid-x\"><div class=\"cell\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = p.emojyNav().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div><div class=\"grid-x grid-margin-x small-up-3 medium-up-6 large-up-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, v := range emojy {
for _, c := range v {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"cell\"><div class=\"card\" style=\"max-width: 100px\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(c.URL))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" style=\"max-height: 100px\" style=\"max-width: 100px\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(c.Emojy))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"card-divider\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(c.Emojy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/emojy/list.templ`, Line: 26, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -1,113 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css"/>
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css"/>
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>Memes</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>Emojys</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'Meme'" :key="item.key">{{ item.name
}}
</b-nav-item>
</b-navbar-nav>
</b-navbar>
<b-navbar>
<b-navbar-nav>
<b-nav-item active href="/emojy/stats">Stats</b-nav-item>
<b-nav-item href="/emojy/list">List</b-nav-item>
<b-nav-item href="/emojy/new">Upload</b-nav-item>
</b-navbar-nav>
</b-navbar>
<div
style="background-color:red;"
variant="error"
v-if="err != ''"
@click="err = ''">
{{ err }}
</div>
<ul style="list-style-type: none;">
<li v-for="category in results">
<ul v-if="category" style="list-style-type: none;">
<li v-for="emojy in category" key="emojy">
{{emojy.count}} -
<span v-if="name != 'emoji'">
<span v-if="emojy.onServer"></span>
<span v-else></span>
-
</span>
<span v-if="emojy.url">
<img :src="emojy.url" :alt="emojy.name" class="img-thumbnail"
style="max-width: 64px; max-height: 64px"/> {{emojy.emojy}}
</span>
<span v-else>{{emojy.emojy}}</span>
</li>
</ul>
</li>
</ul>
</div>
<script>
var app = new Vue({
el: '#app',
data: function () {
return {
err: '',
view: '',
nav: [],
results: [],
fileList: {},
image: null,
password: ''
}
},
watch: {
view(newView, oldView) {
this.err = '';
}
},
mounted() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
this.refresh();
},
methods: {
refresh: function () {
axios.get('/emojy/all')
.then(resp => {
this.results = [resp.data["emojy"], resp.data["unknown"], resp.data["emoji"]]
this.err = ''
})
.catch(err => (this.err = err))
axios.get('/emojy/allFiles')
.then(resp => {
this.fileList = resp.data
this.err = ''
})
.catch(err => (this.err = err))
},
}
})
</script>
</body>
</html>

53
plugins/emojy/stats.templ Normal file
View File

@ -0,0 +1,53 @@
package emojy
import "fmt"
templ (p *EmojyPlugin) emojyNav() {
<ul class="menu">
<li>
<a href="/emojy/stats">Stats</a>
</li>
<li>
<a href="/emojy/list">List</a>
</li>
<li>
<a href="/emojy/new">Upload</a>
</li>
</ul>
}
templ (p *EmojyPlugin) statsIndex(emojy emojyMap) {
<div class="grid-container">
<div class="grid-x">
<div class="cell">
<h2>Emojy</h2>
</div>
</div>
<div class="grid-x">
<div class="cell">
@p.emojyNav()
</div>
</div>
<div class="cell">
for categoryName, v := range emojy {
<ul class="no-bullet">
for _, c := range v {
<li class="">
{ fmt.Sprintf("%d", c.Count) } -
if categoryName != "emoji" && c.OnServer {
<span>✅</span>
} else if categoryName != "emoji" {
<span>✅</span>
}
if c.URL != "" {
<img src={ c.URL } alt={ c.Emojy } />
} else {
{ c.Emojy }
}
</li>
}
</ul>
}
</div>
</div>
}

View File

@ -0,0 +1,149 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package emojy
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "fmt"
func (p *EmojyPlugin) emojyNav() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<ul class=\"menu\"><li><a href=\"/emojy/stats\">Stats</a></li><li><a href=\"/emojy/list\">List</a></li><li><a href=\"/emojy/new\">Upload</a></li></ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (p *EmojyPlugin) statsIndex(emojy emojyMap) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><div class=\"grid-x\"><div class=\"cell\"><h2>Emojy</h2></div></div><div class=\"grid-x\"><div class=\"cell\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = p.emojyNav().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div><div class=\"cell\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for categoryName, v := range emojy {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<ul class=\"no-bullet\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, c := range v {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", c.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/emojy/stats.templ`, Line: 35, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" - ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if categoryName != "emoji" && c.OnServer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span>✅</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if categoryName != "emoji" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span>✅</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if c.URL != "" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(c.URL))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(c.Emojy))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(c.Emojy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/emojy/stats.templ`, Line: 44, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -0,0 +1,30 @@
package emojy
templ (p *EmojyPlugin) uploadIndex() {
<div class="grid-container">
<div class="grid-x">
<div class="cell">
<h2>Emojy</h2>
</div>
</div>
<div class="grid-x">
<div class="cell">
@p.emojyNav()
</div>
</div>
<div class="grid-x">
<div class="cell">
<label>Passphrase</label>
<input type="text" name="password" placeholder="Password..."></input>
</div>
<div class="cell">
<label>File
<input type="file" />
</label>
</div>
<div class="cell">
<button class="button" hx-post="/emojy/upload">Submit</button>
</div>
</div>
</div>
}

View File

@ -0,0 +1,43 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package emojy
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func (p *EmojyPlugin) uploadIndex() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><div class=\"grid-x\"><div class=\"cell\"><h2>Emojy</h2></div></div><div class=\"grid-x\"><div class=\"cell\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = p.emojyNav().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div><div class=\"grid-x\"><div class=\"cell\"><label>Passphrase</label> <input type=\"text\" name=\"password\" placeholder=\"Password...\"></div><div class=\"cell\"><label>File <input type=\"file\"></label></div><div class=\"cell\"><button class=\"button\" hx-post=\"/emojy/upload\">Submit</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -25,15 +25,40 @@ func (p *EmojyPlugin) registerWeb() {
r.HandleFunc("/allFiles", p.handleAllFiles)
r.HandleFunc("/upload", p.handleUpload)
r.HandleFunc("/file/{name}", p.handleEmojy)
r.HandleFunc("/stats", p.handlePage("stats.html"))
r.HandleFunc("/list", p.handlePage("list.html"))
r.HandleFunc("/new", p.handlePage("upload.html"))
r.HandleFunc("/", p.handleIndex)
p.b.RegisterWebName(r, "/emojy", "Emojys")
r.HandleFunc("/stats", p.handleStats)
r.HandleFunc("/list", p.handleList)
r.HandleFunc("/new", p.handleUploadForm)
r.HandleFunc("/", p.handleStats)
p.b.GetWeb().RegisterWebName(r, "/emojy", "Emojys")
}
func (p *EmojyPlugin) handleIndex(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/emojy/stats", http.StatusPermanentRedirect)
type emojyMap map[string][]EmojyCount
func (p *EmojyPlugin) handleUploadForm(w http.ResponseWriter, r *http.Request) {
p.b.GetWeb().Index("Emojy", p.uploadIndex()).Render(r.Context(), w)
}
func (p *EmojyPlugin) handleList(w http.ResponseWriter, r *http.Request) {
threshold := p.c.GetInt("emojy.statthreshold", 1)
emojy, err := p.allCounts(threshold)
if err != nil {
fmt.Fprintf(w, "Error: %s", err)
return
}
p.b.GetWeb().Index("Emojy", p.listTempl(emojy)).Render(r.Context(), w)
}
func (p *EmojyPlugin) handleStats(w http.ResponseWriter, r *http.Request) {
threshold := p.c.GetInt("emojy.statthreshold", 1)
emojy, err := p.allCounts(threshold)
if err != nil {
w.WriteHeader(500)
log.Error().Err(err).Msgf("handleAll")
out, _ := json.Marshal(struct{ err error }{err})
w.Write(out)
return
}
p.b.GetWeb().Index("Emojy", p.statsIndex(emojy)).Render(r.Context(), w)
}
func (p *EmojyPlugin) handlePage(file string) func(w http.ResponseWriter, r *http.Request) {
@ -74,18 +99,16 @@ func (p *EmojyPlugin) handleAllFiles(w http.ResponseWriter, r *http.Request) {
}
func (p *EmojyPlugin) handleUpload(w http.ResponseWriter, r *http.Request) {
enc := json.NewEncoder(w)
newFilePath, err := p.FileSave(r)
if err != nil {
log.Error().Err(err).Msgf("could not upload file")
w.WriteHeader(500)
enc.Encode(struct{ err error }{fmt.Errorf("file not saved: %s", err)})
fmt.Fprintf(w, "Error with file upload")
return
}
log.Debug().Msgf("uploaded file to %s", newFilePath)
w.WriteHeader(200)
enc.Encode(struct{ file string }{newFilePath})
fmt.Fprintf(w, "success")
}
func (p *EmojyPlugin) FileSave(r *http.Request) (string, error) {
@ -101,7 +124,7 @@ func (p *EmojyPlugin) FileSave(r *http.Request) (string, error) {
return "", fmt.Errorf("no files")
}
password := r.Form.Get("password")
password := r.FormValue("password")
if password != p.b.GetPassword() {
return "", fmt.Errorf("incorrect password")
}

View File

@ -50,6 +50,9 @@ func findAlias(db *sqlx.DB, fact string) (bool, *Factoid) {
return false, nil
}
f, err := a.resolve(db)
if err != nil {
log.Error().Err(err).Msg("findAlias")
}
return err == nil, f
}

58
plugins/fact/fact.templ Normal file
View File

@ -0,0 +1,58 @@
package fact
import "fmt"
templ (p *FactoidPlugin) factIndex() {
<div class="grid-container">
<div class="grid-x">
<div class="cell">
<h2>Factoid</h2>
</div>
</div>
<form
hx-post="/factoid/search"
hx-target="#results">
<div class="grid-x grid-margin-x">
<div class="cell auto">
<input type="text"
name="query"
class="form-control"
placeholder="Query..."
/>
</div>
<div class="cell small-1">
<button class="button">Search</button>
</div>
</div>
</form>
<div class="grid-x" id="results">
</div>
</div>
}
templ (p *FactoidPlugin) searchResults(facts []*Factoid) {
<table class="table">
<thead>
<tr>
<th>Fact</th>
<th>Tidbit</th>
<th>Owner</th>
<th>Count</th>
</tr>
</thead>
<tbody>
for _, f := range facts {
@p.searchResult(f)
}
</tbody>
</table>
}
templ (p *FactoidPlugin) searchResult(fact *Factoid) {
<tr>
<td>{ fact.Fact }</td>
<td>{ fact.Tidbit }</td>
<td>{ fact.Owner }</td>
<td>{ fmt.Sprint(fact.Count) }</td>
</tr>
}

147
plugins/fact/fact_templ.go Normal file
View File

@ -0,0 +1,147 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package fact
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "fmt"
func (p *FactoidPlugin) factIndex() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><div class=\"grid-x\"><div class=\"cell\"><h2>Factoid</h2></div></div><form hx-post=\"/factoid/search\" hx-target=\"#results\"><div class=\"grid-x grid-margin-x\"><div class=\"cell auto\"><input type=\"text\" name=\"query\" class=\"form-control\" placeholder=\"Query...\"></div><div class=\"cell small-1\"><button class=\"button\">Search</button></div></div></form><div class=\"grid-x\" id=\"results\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (p *FactoidPlugin) searchResults(facts []*Factoid) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<table class=\"table\"><thead><tr><th>Fact</th><th>Tidbit</th><th>Owner</th><th>Count</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, f := range facts {
templ_7745c5c3_Err = p.searchResult(f).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (p *FactoidPlugin) searchResult(fact *Factoid) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fact.Fact)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/fact/fact.templ`, Line: 52, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fact.Tidbit)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/fact/fact.templ`, Line: 53, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fact.Owner)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/fact/fact.templ`, Line: 54, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(fact.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/fact/fact.templ`, Line: 55, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -90,21 +90,8 @@ func New(botInst bot.Bot) *FactoidPlugin {
// findAction simply regexes a string for the action verb
func findAction(message string) string {
r, err := regexp.Compile("<.+?>")
if err != nil {
panic(err)
}
action := r.FindString(message)
if action == "" {
if strings.Contains(message, " is ") {
return "is"
} else if strings.Contains(message, " are ") {
return "are"
}
}
return action
r := regexp.MustCompile("<.+?>")
return r.FindString(message)
}
// learnFact assumes we have a learning situation and inserts a new fact
@ -153,7 +140,7 @@ func (p *FactoidPlugin) learnFact(message msg.Message, fact, verb, tidbit string
// findTrigger checks to see if a given string is a trigger or not
func (p *FactoidPlugin) findTrigger(fact string) (bool, *Factoid) {
fact = strings.ToLower(fact) // TODO: make sure this needs to be lowered here
fact = strings.TrimSpace(strings.ToLower(fact))
f, err := GetSingleFact(p.db, fact)
if err != nil {
@ -465,7 +452,6 @@ func (p *FactoidPlugin) register() {
log.Debug().Msgf("Message: %+v", r)
// This plugin has no business with normal messages
if !message.Command {
// look for any triggers in the db matching this message
return p.trigger(c, message)
@ -485,18 +471,7 @@ func (p *FactoidPlugin) register() {
return true
}
notFound := p.c.GetArray("fact.notfound", []string{
"I don't know.",
"NONONONO",
"((",
"*pukes*",
"NOPE! NOPE! NOPE!",
"One time, I learned how to jump rope.",
})
// We didn't find anything, panic!
p.b.Send(c, bot.Message, message.Channel, notFound[rand.Intn(len(notFound))])
return true
return false
}},
}
p.b.RegisterTable(p, p.handlers)

View File

@ -2,7 +2,6 @@ package fact
import (
"embed"
"encoding/json"
"fmt"
"github.com/go-chi/chi/v5"
"html/template"
@ -16,10 +15,10 @@ var embeddedFS embed.FS
// Register any web URLs desired
func (p *FactoidPlugin) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/api", p.serveAPI)
r.Post("/search", p.handleSearch)
r.HandleFunc("/req", p.serveQuery)
r.HandleFunc("/", p.serveQuery)
p.b.RegisterWebName(r, "/factoid", "Factoid")
p.b.GetWeb().RegisterWebName(r, "/factoid", "Factoid")
}
func linkify(text string) template.HTML {
@ -32,39 +31,19 @@ func linkify(text string) template.HTML {
return template.HTML(strings.Join(parts, " "))
}
func (p *FactoidPlugin) serveAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fmt.Fprintf(w, "Incorrect HTTP method")
return
}
info := struct {
Query string `json:"query"`
}{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&info)
func (p *FactoidPlugin) handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.FormValue("query")
entries, err := getFacts(p.db, query, "")
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
entries, err := getFacts(p.db, info.Query, "")
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
data, err := json.Marshal(entries)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
w.Write(data)
p.searchResults(entries).Render(r.Context(), w)
}
func (p *FactoidPlugin) serveQuery(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("index.html")
w.Write(index)
p.b.GetWeb().Index("Fact", p.factIndex()).Render(r.Context(), w)
}

View File

@ -81,5 +81,5 @@ func (p *GitPlugin) registerWeb() {
r.HandleFunc("/gitea/event", p.giteaEvent)
r.HandleFunc("/github/event", p.githubEvent)
r.HandleFunc("/gitlab/event", p.gitlabEvent)
p.b.RegisterWeb(r, "/git")
p.b.GetWeb().RegisterWeb(r, "/git")
}

50
plugins/gpt/chatgpt.go Normal file
View File

@ -0,0 +1,50 @@
package gpt
import (
"context"
"fmt"
)
import "github.com/andrewstuart/openai"
var session openai.ChatSession
var client *openai.Client
func (p *GPTPlugin) getClient() (*openai.Client, error) {
token := p.c.Get("gpt.token", "")
if token == "" {
return nil, fmt.Errorf("no GPT token given")
}
return openai.NewClient(token)
}
func (p *GPTPlugin) chatGPT(request string) (string, error) {
if client == nil {
if err := p.setPrompt(p.getDefaultPrompt()); err != nil {
return "", err
}
}
if p.chatCount > p.c.GetInt("gpt.maxchats", 10) {
p.setPrompt(p.c.Get("gpt3.lastprompt", p.getDefaultPrompt()))
p.chatCount = 0
}
p.chatCount++
return session.Complete(context.Background(), request)
}
func (p *GPTPlugin) getDefaultPrompt() string {
return p.c.Get("gpt.prompt", "")
}
func (p *GPTPlugin) setPrompt(prompt string) error {
var err error
client, err = p.getClient()
if err != nil {
return err
}
session = client.NewChatSession(prompt)
err = p.c.Set("gpt3.lastprompt", prompt)
if err != nil {
return err
}
return nil
}

259
plugins/gpt/gpt3.go Normal file
View File

@ -0,0 +1,259 @@
package gpt
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"reflect"
"regexp"
"slices"
"strings"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/config"
)
const gpt3URL = "https://api.openai.com/v1/engines/%s/completions"
const gpt3ModURL = "https://api.openai.com/v1/moderations"
type GPTPlugin struct {
b bot.Bot
c *config.Config
h bot.HandlerTable
chatCount int
}
func New(b bot.Bot) *GPTPlugin {
p := &GPTPlugin{
b: b,
c: b.Config(),
}
p.register()
return p
}
func (p *GPTPlugin) register() {
p.h = bot.HandlerTable{
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?is)^gpt3 (?P<text>.*)`),
HelpText: "request text completion",
Handler: p.message,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?is)^gpt (?P<text>.*)`),
HelpText: "chat completion",
Handler: p.chatMessageForce,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?is)^gpt-prompt: (?P<text>.*)`),
HelpText: "set the ChatGPT prompt",
Handler: p.setPromptMessage,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?P<text>.*)`),
Handler: p.chatMessage,
},
}
p.b.RegisterTable(p, p.h)
}
func (p *GPTPlugin) setPromptMessage(r bot.Request) bool {
prompt := r.Values["text"]
if err := p.setPrompt(prompt); err != nil {
resp := fmt.Sprintf("Error: %s", err)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, resp)
}
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf(`Okay. I set the prompt to: "%s"`, prompt))
return true
}
func (p *GPTPlugin) chatMessage(r bot.Request) bool {
if slices.Contains(p.c.GetArray("gpt.silence", []string{}), r.Msg.Channel) {
log.Debug().Msgf("%s silenced", r.Msg.Channel)
return true
}
return p.chatMessageForce(r)
}
func (p *GPTPlugin) chatMessageForce(r bot.Request) bool {
resp, err := p.chatGPT(r.Values["text"])
if err != nil {
resp = fmt.Sprintf("Error: %s", err)
}
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, resp)
return true
}
func (p *GPTPlugin) message(r bot.Request) bool {
stem := r.Values["text"]
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, p.gpt3(stem))
return true
}
func (p *GPTPlugin) gpt3(stem string) string {
log.Debug().Msgf("Got GPT3 request: %s", stem)
if err := p.checkStem(stem); err != nil {
return "GPT3 moderation " + err.Error()
}
postStruct := gpt3Request{
Prompt: stem,
MaxTokens: p.c.GetInt("gpt3.tokens", 16),
Temperature: p.c.GetFloat64("gpt3.temperature", 1),
TopP: p.c.GetFloat64("gpt3.top_p", 1),
N: p.c.GetInt("gpt3.n", 1),
Stop: p.c.GetArray("gpt3.stop", []string{"\n"}),
Echo: p.c.GetBool("gpt3.echo", false),
}
val, err := p.mkRequest(gpt3URL, postStruct)
if err != nil {
return err.Error()
}
choices := val.(gpt3Response).Choices
if len(choices) > 0 {
return choices[rand.Intn(len(choices))].Text
}
return "OpenAI is too shitty to respond to that."
}
func (p *GPTPlugin) mkRequest(endPoint string, postStruct interface{}) (interface{}, error) {
postBody, _ := json.Marshal(postStruct)
client := &http.Client{}
u := fmt.Sprintf(endPoint, p.c.Get("gpt3.engine", "ada"))
req, err := http.NewRequest("POST", u, bytes.NewBuffer(postBody))
if err != nil {
log.Error().Err(err).Msg("could not make gpt3 request")
return nil, err
}
gpt3Key := p.c.Get("gpt3.bearer", "")
if gpt3Key == "" {
log.Error().Msgf("no GPT3 key given")
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", gpt3Key))
res, err := client.Do(req)
if err != nil {
return nil, err
}
resBody, _ := io.ReadAll(res.Body)
gpt3Resp := gpt3Response{}
err = json.Unmarshal(resBody, &gpt3Resp)
log.Debug().
Str("body", string(resBody)).
Interface("resp", gpt3Resp).
Msg("OpenAI Response")
return gpt3Resp, nil
}
func (p *GPTPlugin) checkStem(stem string) error {
if !p.c.GetBool("gpt3.moderation", true) {
return nil
}
postBody, _ := json.Marshal(gpt3ModRequest{Input: stem})
client := &http.Client{}
req, err := http.NewRequest("POST", gpt3ModURL, bytes.NewBuffer(postBody))
if err != nil {
return err
}
gpt3Key := p.c.Get("gpt3.bearer", "")
if gpt3Key == "" {
return fmt.Errorf("no GPT3 API key")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", gpt3Key))
res, err := client.Do(req)
if err != nil {
return err
}
resBody, _ := io.ReadAll(res.Body)
log.Debug().Str("resBody", string(resBody)).Msg("res")
gpt3Resp := gpt3Moderation{}
err = json.Unmarshal(resBody, &gpt3Resp)
if err != nil {
return err
}
log.Debug().Interface("GPT3 Moderation", gpt3Resp).Msg("Moderation result")
for _, res := range gpt3Resp.Results {
if res.Flagged {
list := ""
categories := reflect.ValueOf(res.Categories)
fields := reflect.VisibleFields(reflect.TypeOf(res.Categories))
for i := 0; i < categories.NumField(); i++ {
if categories.Field(i).Bool() {
list += fields[i].Name + ", "
}
}
list = strings.TrimSuffix(list, ", ")
return fmt.Errorf("flagged: %s", list)
}
}
return nil
}
type gpt3Request struct {
Prompt string `json:"prompt"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
N int `json:"n"`
Stream bool `json:"stream"`
Logprobs any `json:"logprobs"`
Stop []string `json:"stop"`
Echo bool `json:"echo"`
}
type gpt3ModRequest struct {
Input string `json:"input"`
}
type gpt3Response struct {
ID string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
Model string `json:"model"`
Choices []struct {
Text string `json:"text"`
Index int `json:"index"`
Logprobs any `json:"logprobs"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
type gpt3Moderation struct {
ID string `json:"id"`
Model string `json:"model"`
Results []struct {
Categories struct {
Hate bool `json:"hate"`
HateThreatening bool `json:"hate/threatening"`
SelfHarm bool `json:"self-harm"`
Sexual bool `json:"sexual"`
SexualMinors bool `json:"sexual/minors"`
Violence bool `json:"violence"`
ViolenceGraphic bool `json:"violence/graphic"`
} `json:"categories"`
CategoryScores struct {
Hate float64 `json:"hate"`
HateThreatening float64 `json:"hate/threatening"`
SelfHarm float64 `json:"self-harm"`
Sexual float64 `json:"sexual"`
SexualMinors float64 `json:"sexual/minors"`
Violence float64 `json:"violence"`
ViolenceGraphic float64 `json:"violence/graphic"`
} `json:"category_scores"`
Flagged bool `json:"flagged"`
} `json:"results"`
}

View File

@ -1,120 +0,0 @@
package gpt3
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"regexp"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/config"
)
const gpt3URL = "https://api.openai.com/v1/engines/%s/completions"
type GPT3Plugin struct {
b bot.Bot
c *config.Config
h bot.HandlerTable
}
func New(b bot.Bot) *GPT3Plugin {
p := &GPT3Plugin{
b: b,
c: b.Config(),
}
p.register()
return p
}
func (p *GPT3Plugin) register() {
p.h = bot.HandlerTable{
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?is)^gpt3 (?P<text>.*)`),
HelpText: "request text completion",
Handler: p.message,
},
}
log.Debug().Msg("Registering GPT3 handlers")
p.b.RegisterTable(p, p.h)
}
func (p *GPT3Plugin) message(r bot.Request) bool {
stem := r.Values["text"]
log.Debug().Msgf("Got GPT3 request: %s", stem)
postStruct := gpt3Request{
Prompt: stem,
MaxTokens: p.c.GetInt("gpt3.tokens", 16),
Temperature: p.c.GetFloat64("gpt3.temperature", 1),
TopP: p.c.GetFloat64("gpt3.top_p", 1),
N: p.c.GetInt("gpt3.n", 1),
Stop: p.c.GetArray("gpt3.stop", []string{"\n"}),
Echo: true,
}
postBody, _ := json.Marshal(postStruct)
client := &http.Client{}
u := fmt.Sprintf(gpt3URL, p.c.Get("gpt3.engine", "ada"))
req, err := http.NewRequest("POST", u, bytes.NewBuffer(postBody))
if err != nil {
log.Error().Err(err).Msg("could not make gpt3 request")
return false
}
gpt3Key := p.c.Get("gpt3.bearer", "")
if gpt3Key == "" {
log.Error().Msgf("no GPT3 key given")
return false
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", gpt3Key))
res, err := client.Do(req)
if err != nil {
log.Error().Err(err).Msg("could not make gpt3 request")
return false
}
resBody, _ := ioutil.ReadAll(res.Body)
gpt3Resp := gpt3Response{}
err = json.Unmarshal(resBody, &gpt3Resp)
log.Debug().
Str("body", string(resBody)).
Interface("resp", gpt3Resp).
Msg("OpenAI Response")
msg := "OpenAI is too shitty to respond to that."
if len(gpt3Resp.Choices) > 0 {
msg = gpt3Resp.Choices[rand.Intn(len(gpt3Resp.Choices))].Text
}
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, msg)
return true
}
type gpt3Request struct {
Prompt string `json:"prompt"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
N int `json:"n"`
Stream bool `json:"stream"`
Logprobs any `json:"logprobs"`
Stop []string `json:"stop"`
Echo bool `json:"echo"`
}
type gpt3Response struct {
ID string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
Model string `json:"model"`
Choices []struct {
Text string `json:"text"`
Index int `json:"index"`
Logprobs any `json:"logprobs"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}

View File

@ -5,8 +5,6 @@ package leftpad
import (
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -18,7 +16,6 @@ func makeMessage(payload string) bot.Request {
values := bot.ParseValues(leftpadRegex, payload)
return bot.Request{
Kind: bot.Message,
Conn: &cli.CliPlugin{},
Values: values,
Msg: msg.Message{
User: &user.User{Name: "tester"},

View File

@ -1,213 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css" />
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/vue-router@^2"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>Memes</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>Memes</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'Meme'">{{ item.name }}</b-nav-item>
</b-navbar-nav>
</b-navbar>
<b-alert
dismissable
variant="error"
v-if="err"
@dismissed="err = ''">
{{ err }}
</b-alert>
<b-form @submit="saveConfig" v-if="editConfig">
<b-container>
<b-row>
<b-col cols="1">
Name:
</b-col>
<b-col>
{{ editConfig.name }}
</b-col>
</b-row>
<b-row>
<b-col cols="1">
Image:
</b-col>
<b-col>
<img :src="editConfig.url" :alt="editConfig.url" rounded block fluid />
</b-col>
</b-row>
<b-row>
<b-col cols="1">
URL:
</b-col>
<b-col>
<b-input placeholder="URL..." v-model="editConfig.url"></b-input>
</b-col>
</b-row>
<b-row>
<b-col cols="1">
Config:
</b-col>
<b-col>
<b-form-textarea v-model="editConfig.config" rows="10">
</b-form-textarea>
</b-col>
</b-row>
<b-row>
<b-button type="submit" variant="primary">Save</b-button>
&nbsp;
<b-button @click="rm" variant="danger">Delete</b-button>
&nbsp;
<b-button type="cancel" @click="editConfig = null" variant="secondary">Cancel</b-button>
</b-row>
</b-container>
</b-form>
<b-form @submit="addMeme" v-if="!editConfig">
<b-container>
<b-row>
<b-col cols="3">
<b-input placeholder="Name..." v-model="name"></b-input>
</b-col>
<b-col cols="3">
<b-input placeholder="URL..." v-model="url"></b-input>
</b-col>
<b-col cols="3">
<b-input placeholder="Config..." v-model="config"></b-input>
</b-col>
<b-col cols="3">
<b-button type="submit">Add Meme</b-button>
</b-col>
</b-row>
<b-row>
<b-col>
<b-table
fixed
:items="results"
:fields="fields">
<template v-slot:cell(config)="data">
<pre>{{data.item.config | pretty}}</pre>
<b-button @click="startEdit(data.item)">Edit</b-button>
</template>
<template v-slot:cell(image)="data">
<b-img :src="data.item.url" rounded block fluid />
</template>
</b-table>
</b-col>
</b-row>
</b-container>
</b-form>
</div>
<script>
var router = new VueRouter({
mode: 'history',
routes: []
});
var app = new Vue({
el: '#app',
router,
data: {
err: '',
nav: [],
name: '',
url: '',
config: '',
results: [],
editConfig: null,
fields: [
{ key: 'name', sortable: true },
{ key: 'config' },
{ key: 'image' }
]
},
mounted() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
this.refresh();
},
filters: {
pretty: function(value) {
if (!value) {
return ""
}
return JSON.stringify(JSON.parse(value), null, 2);
}
},
methods: {
refresh: function() {
axios.get('/meme/all')
.catch(err => (this.err = err))
.then(resp => {
this.results = resp.data
})
},
addMeme: function(evt) {
if (evt) {
evt.preventDefault();
evt.stopPropagation()
}
if (this.name && this.url)
axios.post('/meme/add', {name: this.name, url: this.url, config: this.config})
.then(resp => {
this.results = resp.data;
this.name = "";
this.url = "";
this.config = "";
this.refresh();
})
.catch(err => (this.err = err));
},
startEdit: function(item) {
this.editConfig = item;
},
saveConfig: function(evt) {
if (evt) {
evt.preventDefault();
evt.stopPropagation();
}
if (this.editConfig && this.editConfig.name && this.editConfig.url) {
axios.post('/meme/add', this.editConfig)
.then(resp => {
this.results = resp.data;
this.editConfig = null;
this.refresh();
})
.catch(err => this.err = err);
}
},
rm: function(evt) {
if (evt) {
evt.preventDefault();
evt.stopPropagation();
}
if (confirm("Are you sure you want to delete this meme?")) {
axios.delete('/meme/rm', { data: this.editConfig })
.then(resp => {
this.editConfig = null;
this.refresh();
})
.catch(err => this.err = err);
}
}
}
})
</script>
</body>
</html>

View File

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/google/uuid"
"image"
"image/color"
"image/draw"
@ -209,7 +210,7 @@ func (p *MemePlugin) sendMeme(c bot.Connector, channel, channelName, msgID strin
encodedSpec, _ := json.Marshal(spec)
w, h, err := p.checkMeme(imgURL)
_, _, err = p.checkMeme(imgURL)
if err != nil {
msg := fmt.Sprintf("Hey %v, I couldn't download that image you asked for.", from.Name)
p.bot.Send(c, bot.Ephemeral, channel, from.ID, msg)
@ -222,12 +223,18 @@ func (p *MemePlugin) sendMeme(c bot.Connector, channel, channelName, msgID strin
q.Add("spec", string(encodedSpec))
u.RawQuery = q.Encode()
img, err := p.genMeme(spec)
if err != nil {
msg := fmt.Sprintf("Hey %v, I couldn't download that image you asked for.", from.Name)
p.bot.Send(c, bot.Ephemeral, channel, from.ID, msg)
return
}
log.Debug().Msgf("image is at %s", u.String())
_, err = p.bot.Send(c, bot.Message, channel, "", bot.ImageAttachment{
URL: u.String(),
AltTxt: fmt.Sprintf("%s: %s", from.Name, message),
Width: w,
Height: h,
p.bot.Send(c, bot.Message, channel, fmt.Sprintf("%s sent a meme:", from.Name))
_, err = p.bot.Send(c, bot.Message, channel, "", bot.File{
Description: uuid.NewString(),
Data: img,
})
if err == nil && msgID != "" {
@ -306,7 +313,15 @@ var defaultFormats = map[string]string{
"raptor": "https://imgflip.com/s/meme/Philosoraptor.jpg",
}
func (p *MemePlugin) findFontSize(config []memeText, fontLocation string, w, h int, sizes []float64) float64 {
func FindFontSizeConfigs(c *config.Config, configs []memeText, fontLocation string, w, h int, sizes []float64) float64 {
texts := []string{}
for _, c := range configs {
texts = append(texts, c.Text)
}
return FindFontSize(c, texts, fontLocation, w, h, sizes)
}
func FindFontSize(c *config.Config, config []string, fontLocation string, w, h int, sizes []float64) float64 {
fontSize := 12.0
m := gg.NewContext(w, h)
@ -314,21 +329,21 @@ func (p *MemePlugin) findFontSize(config []memeText, fontLocation string, w, h i
longestStr, longestW := "", 0.0
for _, s := range config {
err := m.LoadFontFace(fontLocation, 12) // problem
err := m.LoadFontFace(GetFont(c, fontLocation), 12)
if err != nil {
log.Error().Err(err).Msg("could not load font")
return fontSize
}
w, _ := m.MeasureString(s.Text)
w, _ := m.MeasureString(s)
if w > longestW {
longestStr = s.Text
longestStr = s
longestW = w
}
}
for _, sz := range sizes {
err := m.LoadFontFace(fontLocation, sz) // problem
err := m.LoadFontFace(GetFont(c, fontLocation), sz) // problem
if err != nil {
log.Error().Err(err).Msg("could not load font")
return fontSize
@ -449,6 +464,7 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) {
// Apply black stroke
m.SetHexColor("#000")
strokeSize := 6
fontSize := FindFontSizeConfigs(p.c, spec.Configs, defaultFont, w, h, fontSizes)
for dy := -strokeSize; dy <= strokeSize; dy++ {
for dx := -strokeSize; dx <= strokeSize; dx++ {
// give it rounded corners
@ -460,8 +476,7 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) {
if fontLocation == "" {
fontLocation = defaultFont
}
fontSize := p.findFontSize(spec.Configs, fontLocation, w, h, fontSizes)
m.LoadFontFace(fontLocation, fontSize)
m.LoadFontFace(GetFont(p.c, fontLocation), fontSize)
x := float64(w)*c.XPerc + float64(dx)
y := float64(h)*c.YPerc + float64(dy)
m.DrawStringAnchored(c.Text, x, y, 0.5, 0.5)
@ -476,8 +491,7 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) {
if fontLocation == "" {
fontLocation = defaultFont
}
fontSize := p.findFontSize(spec.Configs, fontLocation, w, h, fontSizes)
m.LoadFontFace(fontLocation, fontSize)
m.LoadFontFace(GetFont(p.c, fontLocation), fontSize)
x := float64(w) * c.XPerc
y := float64(h) * c.YPerc
m.DrawStringAnchored(c.Text, x, y, 0.5, 0.5)
@ -492,6 +506,15 @@ func (p *MemePlugin) genMeme(spec specification) ([]byte, error) {
return p.images[jsonSpec].repr, nil
}
func GetFont(c *config.Config, name string) string {
location := c.Get("meme.fontLocation", "")
fontShortcuts := c.GetMap("meme.fontShortcuts", map[string]string{"impact": "impact.ttf"})
if file, ok := fontShortcuts[name]; ok {
name = file
}
return path.Join(location, name)
}
func (p *MemePlugin) applyStamp(img image.Image, bullyURL string) (image.Image, error) {
u, _ := url.Parse(bullyURL)
bullyImg, err := DownloadTemplate(u)

94
plugins/meme/meme.templ Normal file
View File

@ -0,0 +1,94 @@
package meme
templ (p *MemePlugin) index(all webResps) {
<div class="grid-container">
<h2>Meme</h2>
<form>
<div class="grid-x grid-margin-x">
<div class="cell auto">
<input type="text" name="name" placeholder="Name..." />
</div>
<div class="cell auto">
<input type="text" name="url" placeholder="URL..." />
</div>
<div class="cell auto">
<textarea name="config">
</textarea>
</div>
<div class="cell small-2">
<button class="button"
hx-post="/meme/add"
hx-target="#newMemes"
>Save</button>
</div>
</div>
</form>
<div id="newMemes">
</div>
for _, meme := range all {
@p.Show(meme)
}
</div>
}
templ (p *MemePlugin) Show(meme webResp) {
<div class="grid-x grid-margin-x" id={ meme.Name }>
<div class="cell small-3">
<div class="card"
style="max-width: 200px">
<img
class="thumbnail"
style="max-height: 250px; max-width: 250px;"
alt={ meme.Name }
src={ meme.URL } />
<div class="card-divider">
<p>{ meme.Name }</p>
</div>
</div>
</div>
<div class="cell small-7">
<pre>
{ meme.Config }
</pre>
</div>
<div class="cell small-2">
<button class="button"
hx-get={ "/meme/edit/"+meme.Name }
hx-target={ "#"+meme.Name }
hx-swap="outerHTML"
>Edit</button>
</div>
</div>
}
templ (p *MemePlugin) Edit(meme webResp) {
<form>
<div class="grid-x grid-margin-x" id={ meme.Name }>
<div class="cell-small-3">
<img
class="thumbnail"
style="max-height: 150px"
alt={ meme.Name }
src={ meme.URL } />
</div>
<div class="cell small-7">
<textarea name="config" rows="10">
{ meme.Config }
</textarea>
<input type="text" name="url" value={ meme.URL } />
</div>
<div class="cell small-2">
<button class="button"
hx-put={ "/meme/save/"+meme.Name }
hx-target={ "#"+meme.Name }
hx-swap="outerHTML"
>Save</button>
<button class="button alert"
hx-delete={ "/meme/rm/"+meme.Name }
hx-target={ "#"+meme.Name }
hx-swap="outerHTML"
>Delete</button>
</div>
</div>
</form>
}

236
plugins/meme/meme_templ.go Normal file
View File

@ -0,0 +1,236 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package meme
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func (p *MemePlugin) index(all webResps) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><h2>Meme</h2><form><div class=\"grid-x grid-margin-x\"><div class=\"cell auto\"><input type=\"text\" name=\"name\" placeholder=\"Name...\"></div><div class=\"cell auto\"><input type=\"text\" name=\"url\" placeholder=\"URL...\"></div><div class=\"cell auto\"><textarea name=\"config\"></textarea></div><div class=\"cell small-2\"><button class=\"button\" hx-post=\"/meme/add\" hx-target=\"#newMemes\">Save</button></div></div></form><div id=\"newMemes\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, meme := range all {
templ_7745c5c3_Err = p.Show(meme).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (p *MemePlugin) Show(meme webResp) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-x grid-margin-x\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"cell small-3\"><div class=\"card\" style=\"max-width: 200px\"><img class=\"thumbnail\" style=\"max-height: 250px; max-width: 250px;\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.URL))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"card-divider\"><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(meme.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/meme/meme.templ`, Line: 44, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div></div></div><div class=\"cell small-7\"><pre>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(meme.Config)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/meme/meme.templ`, Line: 50, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</pre></div><div class=\"cell small-2\"><button class=\"button\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("/meme/edit/" + meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("#" + meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"outerHTML\">Edit</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (p *MemePlugin) Edit(meme webResp) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form><div class=\"grid-x grid-margin-x\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"cell-small-3\"><img class=\"thumbnail\" style=\"max-height: 150px\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.URL))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></div><div class=\"cell small-7\"><textarea name=\"config\" rows=\"10\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(meme.Config)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/meme/meme.templ`, Line: 75, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</textarea> <input type=\"text\" name=\"url\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(meme.URL))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></div><div class=\"cell small-2\"><button class=\"button\" hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("/meme/save/" + meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("#" + meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"outerHTML\">Save</button> <button class=\"button alert\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("/meme/rm/" + meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("#" + meme.Name))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"outerHTML\">Delete</button></div></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -1,7 +1,6 @@
package meme
import (
"embed"
"encoding/json"
"fmt"
"net/http"
@ -14,18 +13,16 @@ import (
"github.com/velour/catbase/bot"
)
//go:embed *.html
var embeddedFS embed.FS
func (p *MemePlugin) registerWeb(c bot.Connector) {
r := chi.NewRouter()
r.HandleFunc("/slash", p.slashMeme(c))
r.HandleFunc("/img", p.img)
r.HandleFunc("/all", p.all)
r.HandleFunc("/add", p.addMeme)
r.HandleFunc("/rm", p.rmMeme)
r.HandleFunc("/", p.webRoot)
p.bot.RegisterWebName(r, "/meme", "Memes")
r.Get("/img", p.img)
r.Put("/save/{name}", p.saveMeme)
r.Post("/add", p.saveMeme)
r.Delete("/rm/{name}", p.rmMeme)
r.Get("/edit/{name}", p.editMeme)
r.Get("/", p.webRoot)
p.bot.GetWeb().RegisterWebName(r, "/meme", "Memes")
}
type webResp struct {
@ -43,7 +40,7 @@ type ByName struct{ webResps }
func (s ByName) Less(i, j int) bool { return s.webResps[i].Name < s.webResps[j].Name }
func (p *MemePlugin) all(w http.ResponseWriter, r *http.Request) {
func (p *MemePlugin) all() webResps {
memes := p.c.GetMap("meme.memes", defaultFormats)
configs := p.c.GetMap("meme.memeconfigs", map[string]string{})
@ -51,7 +48,7 @@ func (p *MemePlugin) all(w http.ResponseWriter, r *http.Request) {
for n, u := range memes {
config, ok := configs[n]
if !ok {
b, _ := json.Marshal(p.defaultFormatConfig())
b, _ := json.MarshalIndent(p.defaultFormatConfig(), " ", " ")
config = string(b)
}
realURL, err := url.Parse(u)
@ -64,13 +61,7 @@ func (p *MemePlugin) all(w http.ResponseWriter, r *http.Request) {
}
sort.Sort(ByName{values})
out, err := json.Marshal(values)
if err != nil {
w.WriteHeader(500)
log.Error().Err(err).Msgf("could not serve all memes route")
return
}
w.Write(out)
return values
}
func mkCheckError(w http.ResponseWriter) func(error) bool {
@ -87,56 +78,61 @@ func mkCheckError(w http.ResponseWriter) func(error) bool {
}
func (p *MemePlugin) rmMeme(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
w.WriteHeader(405)
fmt.Fprintf(w, "Incorrect HTTP method")
return
}
checkError := mkCheckError(w)
decoder := json.NewDecoder(r.Body)
values := webResp{}
err := decoder.Decode(&values)
if checkError(err) {
return
}
name := chi.URLParam(r, "name")
formats := p.c.GetMap("meme.memes", defaultFormats)
delete(formats, values.Name)
err = p.c.SetMap("meme.memes", formats)
checkError(err)
delete(formats, name)
err := p.c.SetMap("meme.memes", formats)
mkCheckError(w)(err)
}
func (p *MemePlugin) addMeme(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(405)
fmt.Fprintf(w, "Incorrect HTTP method")
return
func (p *MemePlugin) saveMeme(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" {
name = r.FormValue("name")
}
checkError := mkCheckError(w)
decoder := json.NewDecoder(r.Body)
values := webResp{}
err := decoder.Decode(&values)
if checkError(err) {
log.Error().Err(err).Msgf("could not decode body")
return
}
log.Debug().Msgf("POSTed values: %+v", values)
formats := p.c.GetMap("meme.memes", defaultFormats)
formats[values.Name] = values.URL
err = p.c.SetMap("meme.memes", formats)
formats[name] = r.FormValue("url")
err := p.c.SetMap("meme.memes", formats)
checkError(err)
if values.Config == "" {
values.Config = p.defaultFormatConfigJSON()
config := r.FormValue("config")
if config == "" {
config = p.defaultFormatConfigJSON()
}
configs := p.c.GetMap("meme.memeconfigs", map[string]string{})
configs[values.Name] = values.Config
configs[name] = config
err = p.c.SetMap("meme.memeconfigs", configs)
checkError(err)
meme := webResp{
Name: name,
URL: formats[name],
Config: configs[name],
}
p.Show(meme).Render(r.Context(), w)
}
func (p *MemePlugin) webRoot(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("index.html")
w.Write(index)
p.bot.GetWeb().Index("Meme", p.index(p.all())).Render(r.Context(), w)
}
func (p *MemePlugin) editMeme(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
memes := p.c.GetMap("meme.memes", defaultFormats)
configs := p.c.GetMap("meme.memeconfigs", map[string]string{})
meme, ok := memes[name]
if !ok {
fmt.Fprintf(w, "Didn't find that meme.")
}
resp := webResp{
Name: name,
URL: meme,
Config: configs[name],
}
p.Edit(resp).Render(r.Context(), w)
}
func (p *MemePlugin) img(w http.ResponseWriter, r *http.Request) {

View File

@ -12,8 +12,6 @@ import (
"github.com/rs/zerolog/log"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
@ -45,7 +43,6 @@ func makeMessage(payload string) bot.Request {
payload = payload[1:]
}
return bot.Request{
Conn: &cli.CliPlugin{},
Kind: bot.Message,
Msg: msg.Message{
User: &user.User{Name: "tester"},

View File

@ -44,7 +44,7 @@ func New(b bot.Bot) *NewsBid {
var balanceRegex = regexp.MustCompile(`(?i)^balance$`)
var bidsRegex = regexp.MustCompile(`(?i)^bids$`)
var scoresRegex = regexp.MustCompile(`(?i)^scores$`)
var bidRegex = regexp.MustCompile(`(?i)^bid (?P<amount>\S+) (?P<url>)\S+$`)
var bidRegex = regexp.MustCompile(`(?i)^bid (?P<amount>\S+) (?P<url>\S+)\s?(?P<comment>.+)?$`)
var checkRegex = regexp.MustCompile(`(?i)^check ngate$`)
func (p *NewsBid) balanceCmd(r bot.Request) bool {
@ -112,8 +112,6 @@ func (p *NewsBid) bidCmd(r bot.Request) bool {
ch := r.Msg.Channel
conn := r.Conn
log.Debug().Interface("request", r).Msg("bid request")
parts := strings.Fields(body)
if len(parts) != 3 {
p.bot.Send(conn, bot.Message, ch, "You must bid with an amount and a URL.")
@ -122,6 +120,13 @@ func (p *NewsBid) bidCmd(r bot.Request) bool {
amount, _ := strconv.Atoi(parts[1])
url := parts[2]
log.Debug().Msgf("URL: %s", url)
if id, err := strconv.Atoi(url); err == nil {
url = fmt.Sprintf("https://news.ycombinator.com/item?id=%d", id)
log.Debug().Msgf("New URL: %s", url)
}
log.Debug().Msgf("URL: %s", url)
if bid, err := p.ws.Bid(r.Msg.User.Name, amount, parts[1], url); err != nil {
p.bot.Send(conn, bot.Message, ch, fmt.Sprintf("Error placing bid: %s", err))
} else {

View File

@ -0,0 +1,134 @@
package pagecomment
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/config"
"github.com/velour/catbase/connectors/discord"
"net/http"
"regexp"
"strings"
)
type PageComment struct {
b bot.Bot
c *config.Config
}
func New(b bot.Bot) bot.Plugin {
p := &PageComment{
b: b,
c: b.Config(),
}
p.register()
return p
}
func (p *PageComment) register() {
p.b.RegisterTable(p, bot.HandlerTable{
{
Kind: bot.Startup, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: func(r bot.Request) bool {
switch conn := r.Conn.(type) {
case *discord.Discord:
p.registerCmds(conn)
}
return false
},
},
{Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^url (?P<url>\S+) (?P<comment>.+)`),
HelpText: "Comment on a URL", Handler: p.handleURLReq},
})
}
func (p *PageComment) handleURLReq(r bot.Request) bool {
fullText := r.Msg.Body
fullComment := fullText[strings.Index(fullText, r.Values["comment"]):]
u := r.Values["url"]
if strings.HasPrefix(u, "<") && strings.HasSuffix(u, ">") {
u = u[1 : len(u)-1]
}
ua := p.c.Get("url.useragent", "catbase/1.0")
msg := handleURL(u, fullComment, r.Msg.User.Name, ua)
p.b.Send(r.Conn, bot.Delete, r.Msg.Channel, r.Msg.ID)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, msg)
return true
}
func (p *PageComment) handleURLCmd(conn bot.Connector) func(*discordgo.Session, *discordgo.InteractionCreate) {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
u := i.ApplicationCommandData().Options[0].StringValue()
cmt := i.ApplicationCommandData().Options[1].StringValue()
ua := p.c.Get("url.useragent", "catbase/1.0")
msg := handleURL(u, cmt, "", ua)
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
},
})
if err != nil {
log.Error().Err(err).Msg("")
return
}
}
}
func handleURL(u, cmt, who, ua string) string {
if who != "" {
who = who + ": "
}
client := http.Client{}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return "Couldn't parse that URL"
}
req.Header.Set("User-Agent", ua)
resp, err := client.Do(req)
if err != nil || resp.StatusCode > 299 {
log.Error().Err(err).Int("status", resp.StatusCode).Msgf("error with request")
return "Couldn't get that URL"
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "Couldn't parse that URL"
}
wait := make(chan string, 1)
sel := doc.Find("title")
if sel.Length() == 0 {
return fmt.Sprintf("%s%s\n(<%s>)", who, cmt, u)
}
sel.First().Each(func(i int, s *goquery.Selection) {
wait <- fmt.Sprintf("> %s\n%s%s\n(<%s>)", s.Text(), who, cmt, u)
})
return <-wait
}
func (p *PageComment) registerCmds(d *discord.Discord) {
cmd := discordgo.ApplicationCommand{
Name: "url",
Description: "comment on a URL with its title",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "url",
Description: "What URL would you like",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "comment",
Description: "Your comment",
Required: true,
},
},
}
if err := d.RegisterSlashCmd(cmd, p.handleURLCmd(d)); err != nil {
log.Error().Err(err).Msg("could not register emojy command")
}
}

View File

@ -12,7 +12,6 @@ import (
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
"github.com/velour/catbase/bot/user"
"github.com/velour/catbase/plugins/cli"
)
func makeMessage(payload string) bot.Request {
@ -22,7 +21,6 @@ func makeMessage(payload string) bot.Request {
}
values := bot.ParseValues(pickRegex, payload)
return bot.Request{
Conn: &cli.CliPlugin{},
Kind: bot.Message,
Values: values,
Msg: msg.Message{

View File

@ -5,8 +5,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -20,7 +18,6 @@ func makeMessage(nick, payload string, r *regexp.Regexp) bot.Request {
payload = payload[1:]
}
return bot.Request{
Conn: &cli.CliPlugin{},
Kind: bot.Message,
Values: bot.ParseValues(r, payload),
Msg: msg.Message{

View File

@ -120,7 +120,6 @@ func (p *ReminderPlugin) message(c bot.Connector, kind bot.Kind, message msg.Mes
channel := message.Channel
from := message.User.Name
message.Body = replaceDuration(p.when, message.Body)
parts := strings.Fields(message.Body)
if len(parts) >= 5 {

View File

@ -4,7 +4,6 @@ package reminder
import (
"fmt"
"github.com/velour/catbase/plugins/cli"
"strings"
"testing"
"time"
@ -24,7 +23,7 @@ func makeMessageBy(payload, by string) (bot.Connector, bot.Kind, msg.Message) {
if isCmd {
payload = payload[1:]
}
return &cli.CliPlugin{}, bot.Message, msg.Message{
return nil, bot.Message, msg.Message{
User: &user.User{Name: by},
Channel: "test",
Body: payload,
@ -224,6 +223,6 @@ func TestLimitList(t *testing.T) {
func TestHelp(t *testing.T) {
c, mb := setup(t)
assert.NotNil(t, c)
c.help(&cli.CliPlugin{}, bot.Help, msg.Message{Channel: "channel"}, []string{})
c.help(nil, bot.Help, msg.Message{Channel: "channel"}, []string{})
assert.Len(t, mb.Messages, 1)
}

View File

@ -19,7 +19,7 @@ type RolesPlugin struct {
h bot.HandlerTable
}
func New(b bot.Bot) *RolesPlugin {
func New(b bot.Bot) bot.Plugin {
p := &RolesPlugin{
b: b,
c: b.Config(),

View File

@ -2,7 +2,6 @@ package rss
import (
"fmt"
"github.com/velour/catbase/plugins/cli"
"strings"
"testing"
@ -17,7 +16,7 @@ func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) {
if isCmd {
payload = payload[1:]
}
return &cli.CliPlugin{}, bot.Message, msg.Message{
return nil, bot.Message, msg.Message{
User: &user.User{Name: "tester"},
Channel: "test",
Body: payload,

View File

@ -1,120 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Load required Bootstrap and BootstrapVue CSS -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.css" />
<!-- Load polyfills to support older browsers -->
<script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CMutationObserver"></script>
<!-- Load Vue followed by BootstrapVue -->
<script src="//unpkg.com/vue@^2/dist/vue.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@^2/dist/bootstrap-vue.min.js"></script>
<script src="https://unpkg.com/vue-router@^2"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<meta charset="UTF-8">
<title>Memes</title>
</head>
<body>
<div id="app">
<b-navbar>
<b-navbar-brand>Memes</b-navbar-brand>
<b-navbar-nav>
<b-nav-item v-for="item in nav" :href="item.url" :active="item.name === 'Meme'" :key="item.key">{{ item.name }}</b-nav-item>
</b-navbar-nav>
</b-navbar>
<b-alert
dismissable
variant="error"
:show="err != ''"
@dismissed="err = ''">
{{ err }}
</b-alert>
<b-form @submit="add">
<b-container>
<b-row>
<b-col cols="3">
<b-input placeholder="Key..." v-model="secret.key"></b-input>
</b-col>
<b-col cols="3">
<b-input placeholder="Value..." v-model="secret.value"></b-input>
</b-col>
<b-col cols="3">
<b-button type="submit">Add Secret</b-button>
</b-col>
</b-row>
<b-row style="padding-top: 2em;">
<b-col>
<ul>
<li v-for="key in results" key="key"><a @click="rm(key)" href="#">X</a> {{key}}</li>
</ul>
</b-col>
</b-row>
</b-container>
</b-form>
</div>
<script>
var router = new VueRouter({
mode: 'history',
routes: []
});
var app = new Vue({
el: '#app',
router,
data: {
err: '',
nav: [],
secret: {key: '', value: ''},
results: [],
fields: [
{key: 'key', sortable: true},
]
},
mounted() {
axios.get('/nav')
.then(resp => {
this.nav = resp.data;
})
.catch(err => console.log(err))
this.refresh();
},
methods: {
refresh: function () {
axios.get('/secrets/all')
.then(resp => {
this.results = resp.data
this.err = ''
})
.catch(err => (this.err = err))
},
add: function (evt) {
if (evt) {
evt.preventDefault();
evt.stopPropagation();
}
axios.post('/secrets/add', this.secret)
.then(resp => {
this.results = resp.data;
this.secret.key = '';
this.secret.value = '';
this.refresh();
})
.catch(err => this.err = err)
},
rm: function (key) {
if (confirm("Are you sure you want to delete this meme?")) {
axios.delete('/secrets/remove', {data: {key: key}})
.then(resp => {
this.refresh();
})
.catch(err => this.err = err)
}
}
}
})
</script>
</body>
</html>

View File

@ -1,28 +1,25 @@
package secrets
import (
"embed"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/config"
"io"
"net/http"
"net/url"
)
//go:embed *.html
var embeddedFS embed.FS
type SecretsPlugin struct {
b bot.Bot
c *config.Config
db *sqlx.DB
}
func New(b bot.Bot) *SecretsPlugin {
func New(b bot.Bot) bot.Plugin {
p := &SecretsPlugin{
b: b,
c: b.Config(),
@ -36,14 +33,8 @@ func (p *SecretsPlugin) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/add", p.handleRegister)
r.HandleFunc("/remove", p.handleRemove)
r.HandleFunc("/all", p.handleAll)
r.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
value := r.URL.Query().Get("test")
j, _ := json.Marshal(map[string]string{"value": value})
w.Write(j)
})
r.HandleFunc("/", p.handleIndex)
p.b.RegisterWebName(r, "/secrets", "Secrets")
p.b.GetWeb().RegisterWebName(r, "/secrets", "Secrets")
}
func mkCheckError(w http.ResponseWriter) func(error) bool {
@ -68,20 +59,12 @@ func checkMethod(method string, w http.ResponseWriter, r *http.Request) bool {
return false
}
func (p *SecretsPlugin) keys() []string {
return p.c.SecretKeys()
}
func (p *SecretsPlugin) sendKeys(w http.ResponseWriter, r *http.Request) {
checkError := mkCheckError(w)
log.Debug().Msgf("Keys before refresh: %v", p.c.SecretKeys())
err := p.c.RefreshSecrets()
log.Debug().Msgf("Keys after refresh: %v", p.c.SecretKeys())
if checkError(err) {
return
}
keys, err := json.Marshal(p.c.SecretKeys())
if checkError(err) {
return
}
w.WriteHeader(200)
w.Write(keys)
p.keysList().Render(r.Context(), w)
}
func (p *SecretsPlugin) handleAll(w http.ResponseWriter, r *http.Request) {
@ -89,21 +72,13 @@ func (p *SecretsPlugin) handleAll(w http.ResponseWriter, r *http.Request) {
}
func (p *SecretsPlugin) handleRegister(w http.ResponseWriter, r *http.Request) {
log.Debug().Msgf("handleRegister")
if checkMethod(http.MethodPost, w, r) {
log.Debug().Msgf("failed post %s", r.Method)
return
}
checkError := mkCheckError(w)
decoder := json.NewDecoder(r.Body)
secret := config.Secret{}
err := decoder.Decode(&secret)
log.Debug().Msgf("decoding: %s", err)
if checkError(err) {
return
}
log.Debug().Msgf("Secret: %s", secret)
err = p.c.RegisterSecret(secret.Key, secret.Value)
key, value := r.FormValue("key"), r.FormValue("value")
err := p.c.RegisterSecret(key, value)
if checkError(err) {
return
}
@ -115,13 +90,16 @@ func (p *SecretsPlugin) handleRemove(w http.ResponseWriter, r *http.Request) {
return
}
checkError := mkCheckError(w)
decoder := json.NewDecoder(r.Body)
secret := config.Secret{}
err := decoder.Decode(&secret)
b, err := io.ReadAll(r.Body)
if checkError(err) {
return
}
err = p.c.RemoveSecret(secret.Key)
q, err := url.ParseQuery(string(b))
if checkError(err) {
return
}
secret := q.Get("key")
err = p.c.RemoveSecret(secret)
if checkError(err) {
return
}
@ -129,6 +107,5 @@ func (p *SecretsPlugin) handleRemove(w http.ResponseWriter, r *http.Request) {
}
func (p *SecretsPlugin) handleIndex(w http.ResponseWriter, r *http.Request) {
index, _ := embeddedFS.ReadFile("index.html")
w.Write(index)
p.b.GetWeb().Index("Secrets", p.index()).Render(r.Context(), w)
}

View File

@ -0,0 +1,52 @@
package secrets
import "fmt"
templ (s *SecretsPlugin) index() {
<div class="grid-container">
<form hx-post="/secrets/add" hx-target="#data">
<div class="grid-x">
<h2>Secrets</h2>
</div>
<div class="grid-x">
<div class="cell auto">
<div class="input-group">
<span class="input-group-label">Key</span>
<input class="input-group-field" placeholder="Key..." name="key" />
</div>
</div>
<div class="cell auto">
<div class="input-group">
<span class="input-group-label">Value</span>
<input class="input-group-field" placeholder="Value..." name="value" />
<div class="input-group-button">
<button class="button primary" type="submit">Add Secret</button>
</div>
</div>
</div>
</div>
</form>
<div class="grid-x grid-margin-x">
<div id="data">
@s.keysList()
</div>
</div>
</div>
}
templ (s *SecretsPlugin) keysList() {
<ul class="no-bullet">
for _, key := range s.keys() {
<li>
<button
class="button tiny alert middle"
style="vertical-align: baseline"
hx-delete="/secrets/remove"
hx-confirm={ fmt.Sprintf("Are you sure you want to delete %s?", key) }
hx-target="#data"
hx-include="this"
name="key" value={ key }>X</button>
{ key }</li>
}
</ul>
}

View File

@ -0,0 +1,108 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.543
package secrets
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "fmt"
func (s *SecretsPlugin) index() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid-container\"><form hx-post=\"/secrets/add\" hx-target=\"#data\"><div class=\"grid-x\"><h2>Secrets</h2></div><div class=\"grid-x\"><div class=\"cell auto\"><div class=\"input-group\"><span class=\"input-group-label\">Key</span> <input class=\"input-group-field\" placeholder=\"Key...\" name=\"key\"></div></div><div class=\"cell auto\"><div class=\"input-group\"><span class=\"input-group-label\">Value</span> <input class=\"input-group-field\" placeholder=\"Value...\" name=\"value\"><div class=\"input-group-button\"><button class=\"button primary\" type=\"submit\">Add Secret</button></div></div></div></div></form><div class=\"grid-x grid-margin-x\"><div id=\"data\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = s.keysList().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func (s *SecretsPlugin) keysList() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<ul class=\"no-bullet\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, key := range s.keys() {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><button class=\"button tiny alert middle\" style=\"vertical-align: baseline\" hx-delete=\"/secrets/remove\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(fmt.Sprintf("Are you sure you want to delete %s?", key)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"#data\" hx-include=\"this\" name=\"key\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(key))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">X</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `plugins/secrets/secrets.templ`, Line: 48, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -121,7 +121,7 @@ func (p *SMSPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message, ar
func (p *SMSPlugin) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/new", p.receive)
p.b.RegisterWeb(r, "/sms")
p.b.GetWeb().RegisterWeb(r, "/sms")
}
func (p *SMSPlugin) receive(w http.ResponseWriter, r *http.Request) {

View File

@ -1,7 +1,6 @@
package stock
import (
"github.com/velour/catbase/plugins/cli"
"strings"
"testing"
@ -16,7 +15,7 @@ func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) {
if isCmd {
payload = payload[1:]
}
return &cli.CliPlugin{}, bot.Message, msg.Message{
return nil, bot.Message, msg.Message{
User: &user.User{Name: "tester"},
Channel: "test",
Body: payload,

View File

@ -104,7 +104,7 @@ func (p *TalkerPlugin) message(c bot.Connector, kind bot.Kind, message msg.Messa
line = strings.Replace(line, "{nick}", nick, 1)
output += line + "\n"
}
p.bot.Send(c, bot.Message, channel, output)
p.bot.Send(c, bot.Spoiler, channel, output)
return true
}
@ -186,5 +186,5 @@ func (p *TalkerPlugin) registerWeb(c bot.Connector) {
p.bot.Send(c, bot.Message, channel, msg)
w.WriteHeader(200)
})
p.bot.RegisterWeb(r, "/cowsay")
p.bot.GetWeb().RegisterWeb(r, "/cowsay")
}

View File

@ -3,7 +3,6 @@
package talker
import (
"github.com/velour/catbase/plugins/cli"
"strings"
"testing"
@ -18,7 +17,7 @@ func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) {
if isCmd {
payload = payload[1:]
}
return &cli.CliPlugin{}, bot.Message, msg.Message{
return nil, bot.Message, msg.Message{
User: &user.User{Name: "tester"},
Channel: "test",
Body: payload,
@ -79,6 +78,6 @@ func TestHelp(t *testing.T) {
mb := bot.NewMockBot()
c := New(mb)
assert.NotNil(t, c)
c.help(&cli.CliPlugin{}, bot.Help, msg.Message{Channel: "channel"}, []string{})
c.help(nil, bot.Help, msg.Message{Channel: "channel"}, []string{})
assert.Len(t, mb.Messages, 1)
}

149
plugins/tappd/image.go Normal file
View File

@ -0,0 +1,149 @@
package tappd
import (
"bytes"
"github.com/disintegration/imageorient"
"github.com/fogleman/gg"
"github.com/gabriel-vasile/mimetype"
"github.com/nfnt/resize"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/plugins/meme"
"image"
"image/png"
"net/http"
"net/url"
"path"
)
func (p *Tappd) getImage(url string) (image.Image, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
img, _, err := imageorient.Decode(resp.Body)
if err != nil {
return nil, err
}
r := img.Bounds()
w := r.Dx()
h := r.Dy()
maxSz := p.c.GetFloat64("maxImgSz", 750.0)
if w > h {
scale := maxSz / float64(w)
w = int(float64(w) * scale)
h = int(float64(h) * scale)
} else {
scale := maxSz / float64(h)
w = int(float64(w) * scale)
h = int(float64(h) * scale)
}
log.Debug().Msgf("trynig to resize to %v, %v", w, h)
img = resize.Resize(uint(w), uint(h), img, resize.Lanczos3)
r = img.Bounds()
w = r.Dx()
h = r.Dy()
log.Debug().Msgf("resized to %v, %v", w, h)
return img, nil
}
type textSpec struct {
text string
// percentage location of text center
x float64
y float64
}
func defaultSpec() textSpec {
return textSpec{
x: 0.5,
y: 0.9,
}
}
func (p *Tappd) overlay(img image.Image, texts []textSpec) ([]byte, error) {
font := meme.GetFont(p.c, p.c.Get("meme.font", "impact.ttf"))
fontSizes := []float64{48, 36, 24, 16, 12}
r := img.Bounds()
w := r.Dx()
h := r.Dy()
txts := []string{}
for _, t := range texts {
txts = append(txts, t.text)
}
fontSize := meme.FindFontSize(p.c, txts, font, w, h, fontSizes)
m := gg.NewContext(w, h)
m.DrawImage(img, 0, 0)
for _, spec := range texts {
// write some stuff on the image here
if err := m.LoadFontFace(font, fontSize); err != nil {
return nil, err
}
// Apply black stroke
m.SetHexColor("#000")
strokeSize := 6
for dy := -strokeSize; dy <= strokeSize; dy++ {
for dx := -strokeSize; dx <= strokeSize; dx++ {
// give it rounded corners
if dx*dx+dy*dy >= strokeSize*strokeSize {
continue
}
x := float64(w)*spec.x + float64(dx)
y := float64(h)*spec.y + float64(dy)
m.DrawStringAnchored(spec.text, x, y, 0.5, 0.5)
}
}
m.SetHexColor("#FFF")
x := float64(w) * spec.x
y := float64(h) * spec.y
m.DrawStringAnchored(spec.text, x, y, 0.5, 0.5)
}
i := bytes.Buffer{}
if err := png.Encode(&i, m.Image()); err != nil {
return nil, err
}
return i.Bytes(), nil
}
func (p *Tappd) getAndOverlay(id, srcURL string, texts []textSpec) (imageInfo, error) {
baseURL := p.c.Get("BaseURL", ``)
u, _ := url.Parse(baseURL)
u.Path = path.Join(u.Path, "tappd", id)
img, err := p.getImage(srcURL)
if err != nil {
return imageInfo{}, err
}
data, err := p.overlay(img, texts)
if err != nil {
return imageInfo{}, err
}
bounds := img.Bounds()
mime := mimetype.Detect(data)
info := imageInfo{
ID: id,
SrcURL: srcURL,
BotURL: u.String(),
Img: img,
Repr: data,
W: bounds.Dx(),
H: bounds.Dy(),
MimeType: mime.String(),
FileName: id + mime.Extension(),
}
p.imageMap[id] = info
log.Debug().
Interface("BotURL", info.BotURL).
Str("ID", id).
Msgf("here's some info")
return info, nil
}

207
plugins/tappd/tappd.go Normal file
View File

@ -0,0 +1,207 @@
package tappd
import (
"bytes"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/config"
"github.com/velour/catbase/connectors/discord"
"image"
"os"
"path"
"regexp"
"time"
)
type Tappd struct {
b bot.Bot
c *config.Config
imageMap map[string]imageInfo
}
type imageInfo struct {
ID string
SrcURL string
BotURL string
Img image.Image
Repr []byte
W int
H int
MimeType string
FileName string
}
func New(b bot.Bot) *Tappd {
t := &Tappd{
b: b,
c: b.Config(),
imageMap: make(map[string]imageInfo),
}
t.register()
t.registerWeb()
t.mkDB()
return t
}
func (p *Tappd) mkDB() error {
db := p.b.DB()
tx, err := db.Beginx()
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(`create table if not exists tappd (
id string primary key,
who string,
channel string,
message string,
ts datetime
);`)
if err != nil {
tx.Rollback()
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (p *Tappd) log(id, who, channel, message string) error {
db := p.b.DB()
tx, err := db.Beginx()
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(`insert into tappd (id, who, channel, message, ts) values (?, ?, ?, ? ,?)`,
id, who, channel, message, time.Now())
if err != nil {
tx.Rollback()
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (p *Tappd) registerDiscord(d *discord.Discord) {
cmd := discordgo.ApplicationCommand{
Name: "tap",
Description: "tappd a beer in",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionAttachment,
Name: "image",
Description: "Picture that beer, but on Discord",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "comment",
Description: "Comment on that beer",
Required: true,
},
},
}
if err := d.RegisterSlashCmd(cmd, p.tap); err != nil {
log.Error().Err(err).Msgf("could not register")
}
}
func (p *Tappd) tap(s *discordgo.Session, i *discordgo.InteractionCreate) {
who := i.Interaction.Member.Nick
channel := i.Interaction.ChannelID
shortMsg := i.ApplicationCommandData().Options[1].StringValue()
longMsg := fmt.Sprintf("%s checked in: %s",
i.Interaction.Member.Nick, shortMsg)
attachID := i.ApplicationCommandData().Options[0].Value.(string)
attach := i.ApplicationCommandData().Resolved.Attachments[attachID]
spec := defaultSpec()
spec.text = shortMsg
info, err := p.getAndOverlay(attachID, attach.URL, []textSpec{spec})
if err != nil {
log.Error().Err(err).Msgf("error with interaction")
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error getting the image",
Flags: discordgo.MessageFlagsEphemeral,
},
})
if err != nil {
log.Error().Err(err).Msgf("error with interaction")
}
return
}
embeds := []*discordgo.MessageEmbed{{
Description: longMsg,
Image: &discordgo.MessageEmbedImage{
URL: info.BotURL,
Width: info.W,
Height: info.H,
},
}}
files := []*discordgo.File{{
Name: info.FileName,
ContentType: info.MimeType,
Reader: bytes.NewBuffer(info.Repr),
}}
content := info.BotURL
// Yes, the configs are all stringly typed. Get over it.
useEmbed := p.c.GetBool("tappd.embed", false)
if useEmbed {
files = nil
} else {
embeds = nil
content = ""
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: embeds,
Files: files,
Content: content,
},
})
if err != nil {
log.Error().Err(err).Msgf("error with interaction")
return
}
err = p.log(info.ID, who, channel, shortMsg)
if err != nil {
log.Error().Err(err).Msgf("error recording tap")
}
imgPath := p.c.Get("tappd.imagepath", "tappdimg")
err = os.MkdirAll(imgPath, 0775)
if err != nil {
log.Error().Err(err).Msgf("error creating directory")
return
}
err = os.WriteFile(path.Join(imgPath, info.FileName), info.Repr, 0664)
if err != nil {
log.Error().Err(err).Msgf("error writing file")
}
}
func (p *Tappd) register() {
ht := bot.HandlerTable{
{
Kind: bot.Startup, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: func(r bot.Request) bool {
switch conn := r.Conn.(type) {
case *discord.Discord:
p.registerDiscord(conn)
}
return false
},
},
}
p.b.RegisterTable(p, ht)
}

51
plugins/tappd/web.go Normal file
View File

@ -0,0 +1,51 @@
package tappd
import (
"encoding/json"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"net/http"
"os"
"path"
"strings"
)
func (p *Tappd) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/", p.serveImage)
p.b.GetWeb().RegisterWeb(r, "/tappd/{id}")
}
func (p *Tappd) getImg(id string) ([]byte, error) {
imgData, ok := p.imageMap[id]
if ok {
return imgData.Repr, nil
}
imgPath := p.c.Get("tappd.imagepath", "tappdimg")
entries, err := os.ReadDir(imgPath)
if err != nil {
log.Error().Err(err).Msgf("can't read image path")
return nil, err
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), id) {
return os.ReadFile(path.Join(imgPath, e.Name()))
}
}
log.Error().Msgf("didn't find image")
return nil, fmt.Errorf("file not found")
}
func (p *Tappd) serveImage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
data, err := p.getImg(id)
if err != nil {
log.Error().Err(err).Msgf("error getting image")
w.WriteHeader(404)
out, _ := json.Marshal(struct{ err string }{"could not find ID: " + err.Error()})
w.Write(out)
return
}
w.Write(data)
}

View File

@ -1,8 +1,14 @@
package tldr
import (
"bytes"
"context"
"fmt"
"github.com/andrewstuart/openai"
"github.com/velour/catbase/config"
"regexp"
"strings"
"text/template"
"time"
"github.com/velour/catbase/bot"
@ -13,9 +19,14 @@ import (
"github.com/james-bowman/nlp"
)
const templateKey = "tldr.prompttemplate"
var defaultTemplate = "Summarize the following conversation:\n"
type TLDRPlugin struct {
bot bot.Bot
history []history
b bot.Bot
c *config.Config
history map[string][]history
index int
lastRequest time.Time
}
@ -28,120 +39,165 @@ type history struct {
func New(b bot.Bot) *TLDRPlugin {
plugin := &TLDRPlugin{
bot: b,
history: []history{},
b: b,
c: b.Config(),
history: map[string][]history{},
index: 0,
lastRequest: time.Now().Add(-24 * time.Hour),
}
b.Register(plugin, bot.Message, plugin.message)
b.Register(plugin, bot.Help, plugin.help)
plugin.register()
return plugin
}
func (p *TLDRPlugin) message(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool {
timeLimit := time.Duration(p.bot.Config().GetInt("TLDR.HourLimit", 1))
lowercaseMessage := strings.ToLower(message.Body)
if lowercaseMessage == "tl;dr" && p.lastRequest.After(time.Now().Add(-timeLimit*time.Hour)) {
p.bot.Send(c, bot.Message, message.Channel, "Slow down, cowboy. Read that tiny backlog.")
return true
} else if lowercaseMessage == "tl;dr" {
p.lastRequest = time.Now()
nTopics := p.bot.Config().GetInt("TLDR.Topics", 5)
stopWordSlice := p.bot.Config().GetArray("TLDR.StopWords", []string{})
if len(stopWordSlice) == 0 {
stopWordSlice = THESE_ARE_NOT_THE_WORDS_YOU_ARE_LOOKING_FOR
p.bot.Config().SetArray("TLDR.StopWords", stopWordSlice)
}
vectoriser := nlp.NewCountVectoriser(stopWordSlice...)
lda := nlp.NewLatentDirichletAllocation(nTopics)
pipeline := nlp.NewPipeline(vectoriser, lda)
docsOverTopics, err := pipeline.FitTransform(p.getTopics()...)
if err != nil {
log.Error().Err(err)
return false
}
bestScores := make([][]float64, nTopics)
bestDocs := make([][]history, nTopics)
supportingDocs := p.bot.Config().GetInt("TLDR.Support", 3)
for i := 0; i < nTopics; i++ {
bestScores[i] = make([]float64, supportingDocs)
bestDocs[i] = make([]history, supportingDocs)
}
dr, dc := docsOverTopics.Dims()
for topic := 0; topic < dr; topic++ {
minScore, minIndex := min(bestScores[topic])
for doc := 0; doc < dc; doc++ {
score := docsOverTopics.At(topic, doc)
if score > minScore {
bestScores[topic][minIndex] = score
bestDocs[topic][minIndex] = p.history[doc]
minScore, minIndex = min(bestScores[topic])
}
}
}
topicsOverWords := lda.Components()
tr, tc := topicsOverWords.Dims()
vocab := make([]string, len(vectoriser.Vocabulary))
for k, v := range vectoriser.Vocabulary {
vocab[v] = k
}
response := "Here you go captain 'too good to read backlog':\n"
for topic := 0; topic < tr; topic++ {
bestScore := -1.
bestTopic := ""
for word := 0; word < tc; word++ {
score := topicsOverWords.At(topic, word)
if score > bestScore {
bestScore = score
bestTopic = vocab[word]
}
}
response += fmt.Sprintf("\n*Topic #%d: %s*\n", topic, bestTopic)
for i := range bestDocs[topic] {
response += fmt.Sprintf("<%s>%s\n", bestDocs[topic][i].user, bestDocs[topic][i].body)
}
}
p.bot.Send(c, bot.Message, message.Channel, response)
func (p *TLDRPlugin) register() {
p.b.RegisterTable(p, bot.HandlerTable{
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`old tl;dr`),
HelpText: "Get a rather inaccurate summary of the channel",
Handler: p.tldrCmd,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`tl;?dr-prompt$`),
HelpText: "Get the tl;dr prompt",
Handler: p.squawkTLDR,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`tl;?dr-prompt reset`),
HelpText: "Reset the tl;dr prompt",
Handler: p.resetTLDR,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`tl;?dr-prompt (?P<prompt>.*)`),
HelpText: "Set the tl;dr prompt",
Handler: p.setTLDR,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`tl;?dr`),
HelpText: "Get a summary of the channel",
Handler: p.betterTLDR,
},
{
Kind: bot.Message, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: p.record,
},
})
p.b.Register(p, bot.Help, p.help)
}
func (p *TLDRPlugin) tldrCmd(r bot.Request) bool {
timeLimit := time.Duration(p.b.Config().GetInt("TLDR.HourLimit", 1))
if p.lastRequest.After(time.Now().Add(-timeLimit * time.Hour)) {
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "Slow down, cowboy. Read that tiny backlog.")
return true
}
return false
}
func (p *TLDRPlugin) record(r bot.Request) bool {
hist := history{
body: lowercaseMessage,
user: message.User.Name,
body: strings.ToLower(r.Msg.Body),
user: r.Msg.User.Name,
timestamp: time.Now(),
}
p.addHistory(hist)
p.addHistory(r.Msg.Channel, hist)
return false
}
func (p *TLDRPlugin) addHistory(hist history) {
p.history = append(p.history, hist)
sz := len(p.history)
max := p.bot.Config().GetInt("TLDR.HistorySize", 1000)
keepHrs := time.Duration(p.bot.Config().GetInt("TLDR.KeepHours", 24))
func (p *TLDRPlugin) oldTLDR(r bot.Request) bool {
p.lastRequest = time.Now()
nTopics := p.b.Config().GetInt("TLDR.Topics", 5)
stopWordSlice := p.b.Config().GetArray("TLDR.StopWords", []string{})
if len(stopWordSlice) == 0 {
stopWordSlice = THESE_ARE_NOT_THE_WORDS_YOU_ARE_LOOKING_FOR
p.b.Config().SetArray("TLDR.StopWords", stopWordSlice)
}
vectoriser := nlp.NewCountVectoriser(stopWordSlice...)
lda := nlp.NewLatentDirichletAllocation(nTopics)
pipeline := nlp.NewPipeline(vectoriser, lda)
docsOverTopics, err := pipeline.FitTransform(p.getTopics()...)
if err != nil {
log.Error().Err(err)
return false
}
bestScores := make([][]float64, nTopics)
bestDocs := make([][]history, nTopics)
supportingDocs := p.b.Config().GetInt("TLDR.Support", 3)
for i := 0; i < nTopics; i++ {
bestScores[i] = make([]float64, supportingDocs)
bestDocs[i] = make([]history, supportingDocs)
}
dr, dc := docsOverTopics.Dims()
for topic := 0; topic < dr; topic++ {
minScore, minIndex := min(bestScores[topic])
for doc := 0; doc < dc; doc++ {
score := docsOverTopics.At(topic, doc)
if score > minScore {
bestScores[topic][minIndex] = score
bestDocs[topic][minIndex] = p.history[r.Msg.Channel][doc]
minScore, minIndex = min(bestScores[topic])
}
}
}
topicsOverWords := lda.Components()
tr, tc := topicsOverWords.Dims()
vocab := make([]string, len(vectoriser.Vocabulary))
for k, v := range vectoriser.Vocabulary {
vocab[v] = k
}
response := "Here you go captain 'too good to read backlog':\n"
for topic := 0; topic < tr; topic++ {
bestScore := -1.
bestTopic := ""
for word := 0; word < tc; word++ {
score := topicsOverWords.At(topic, word)
if score > bestScore {
bestScore = score
bestTopic = vocab[word]
}
}
response += fmt.Sprintf("\n*Topic #%d: %s*\n", topic, bestTopic)
for i := range bestDocs[topic] {
response += fmt.Sprintf("<%s>%s\n", bestDocs[topic][i].user, bestDocs[topic][i].body)
}
}
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, response)
return true
}
func (p *TLDRPlugin) addHistory(ch string, hist history) {
p.history[ch] = append(p.history[ch], hist)
sz := len(p.history[ch])
max := p.b.Config().GetInt("TLDR.HistorySize", 1000)
keepHrs := time.Duration(p.b.Config().GetInt("TLDR.KeepHours", 24))
// Clamp the size of the history
if sz > max {
p.history = p.history[len(p.history)-max:]
p.history[ch] = p.history[ch][len(p.history)-max:]
}
// Remove old entries
yesterday := time.Now().Add(-keepHrs * time.Hour)
begin := 0
for i, m := range p.history {
for i, m := range p.history[ch] {
if !m.timestamp.Before(yesterday) {
begin = i - 1 // should keep this message
if begin < 0 {
@ -150,20 +206,22 @@ func (p *TLDRPlugin) addHistory(hist history) {
break
}
}
p.history = p.history[begin:]
p.history[ch] = p.history[ch][begin:]
}
func (p *TLDRPlugin) getTopics() []string {
hist := []string{}
for _, h := range p.history {
hist = append(hist, h.body)
for _, ch := range p.history {
for _, h := range ch {
hist = append(hist, h.body)
}
}
return hist
}
// Help responds to help requests. Every plugin must implement a help function.
func (p *TLDRPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool {
p.bot.Send(c, bot.Message, message.Channel, "tl;dr")
p.b.Send(c, bot.Message, message.Channel, "tl;dr")
return true
}
@ -178,3 +236,69 @@ func min(slice []float64) (float64, int) {
}
return minVal, minIndex
}
func (p *TLDRPlugin) betterTLDR(r bot.Request) bool {
ch := r.Msg.Channel
c, err := p.getClient()
if err != nil {
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "Couldn't fetch an OpenAI client")
return true
}
promptConfig := p.c.Get(templateKey, defaultTemplate)
promptTpl := template.Must(template.New("gptprompt").Parse(promptConfig))
prompt := bytes.Buffer{}
data := p.c.GetMap("tldr.promptdata", map[string]string{})
promptTpl.Execute(&prompt, data)
backlog := ""
maxLen := p.c.GetInt("tldr.maxgpt", 4096)
for i := len(p.history[ch]) - 1; i >= 0; i-- {
h := p.history[ch][i]
str := fmt.Sprintf("%s: %s\n", h.user, h.body)
if len(backlog) > maxLen {
break
}
backlog = str + backlog
}
sess := c.NewChatSession(prompt.String())
completion, err := sess.Complete(context.TODO(), backlog)
if err != nil {
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "Couldn't run the OpenAI request")
return true
}
log.Debug().
Str("prompt", prompt.String()).
Str("backlog", backlog).
Str("completion", completion).
Msgf("tl;dr")
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, completion)
return true
}
func (p *TLDRPlugin) squawkTLDR(r bot.Request) bool {
prompt := p.c.Get(templateKey, defaultTemplate)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf(`Current prompt is: "%s"`,
strings.TrimSpace(prompt)))
return true
}
func (p *TLDRPlugin) resetTLDR(r bot.Request) bool {
p.c.Set(templateKey, defaultTemplate)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf(`Set prompt to: "%s"`,
strings.TrimSpace(defaultTemplate)))
return true
}
func (p *TLDRPlugin) setTLDR(r bot.Request) bool {
prompt := r.Values["prompt"] + "\n"
p.c.Set(templateKey, prompt)
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf(`Set prompt to: "%s"`, strings.TrimSpace(prompt)))
return true
}
func (p *TLDRPlugin) getClient() (*openai.Client, error) {
token := p.c.Get("gpt.token", "")
if token == "" {
return nil, fmt.Errorf("no GPT token given")
}
return openai.NewClient(token)
}

View File

@ -1,7 +1,6 @@
package tldr
import (
"github.com/velour/catbase/plugins/cli"
"os"
"strconv"
"strings"
@ -20,20 +19,26 @@ func init() {
log.Logger = log.Logger.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
func makeMessageBy(payload, by string) (bot.Connector, bot.Kind, msg.Message) {
var ch = "test"
func makeMessageBy(payload, by string) bot.Request {
isCmd := strings.HasPrefix(payload, "!")
if isCmd {
payload = payload[1:]
}
return &cli.CliPlugin{}, bot.Message, msg.Message{
User: &user.User{Name: by},
Channel: "test",
Body: payload,
Command: isCmd,
return bot.Request{
Kind: bot.Message,
Msg: msg.Message{
User: &user.User{Name: by},
Channel: ch,
Body: payload,
Command: isCmd,
},
}
}
func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) {
func makeMessage(payload string) bot.Request {
return makeMessageBy(payload, "tester")
}
@ -43,51 +48,12 @@ func setup(t *testing.T) (*TLDRPlugin, *bot.MockBot) {
return r, mb
}
func Test(t *testing.T) {
c, mb := setup(t)
res := c.message(makeMessage("The quick brown fox jumped over the lazy dog"))
res = c.message(makeMessage("The cow jumped over the moon"))
res = c.message(makeMessage("The little dog laughed to see such fun"))
res = c.message(makeMessage("tl;dr"))
assert.True(t, res)
assert.Len(t, mb.Messages, 1)
}
func TestDoubleUp(t *testing.T) {
c, mb := setup(t)
res := c.message(makeMessage("The quick brown fox jumped over the lazy dog"))
res = c.message(makeMessage("The cow jumped over the moon"))
res = c.message(makeMessage("The little dog laughed to see such fun"))
res = c.message(makeMessage("tl;dr"))
res = c.message(makeMessage("tl;dr"))
assert.True(t, res)
assert.Len(t, mb.Messages, 2)
assert.Contains(t, mb.Messages[1], "Slow down, cowboy.")
}
func TestAddHistoryLimitsMessages(t *testing.T) {
c, _ := setup(t)
max := 1000
c.bot.Config().Set("TLDR.HistorySize", strconv.Itoa(max))
c.bot.Config().Set("TLDR.KeepHours", "24")
t0 := time.Now().Add(-24 * time.Hour)
for i := 0; i < max*2; i++ {
hist := history{
body: "test",
user: "tester",
timestamp: t0.Add(time.Duration(i) * time.Second),
}
c.addHistory(hist)
}
assert.Len(t, c.history, max)
}
func TestAddHistoryLimitsDays(t *testing.T) {
c, _ := setup(t)
hrs := 24
expected := 24
c.bot.Config().Set("TLDR.HistorySize", "100")
c.bot.Config().Set("TLDR.KeepHours", strconv.Itoa(hrs))
c.b.Config().Set("TLDR.HistorySize", "100")
c.b.Config().Set("TLDR.KeepHours", strconv.Itoa(hrs))
t0 := time.Now().Add(-time.Duration(hrs*2) * time.Hour)
for i := 0; i < 48; i++ {
hist := history{
@ -95,7 +61,7 @@ func TestAddHistoryLimitsDays(t *testing.T) {
user: "tester",
timestamp: t0.Add(time.Duration(i) * time.Hour),
}
c.addHistory(hist)
c.addHistory(ch, hist)
}
assert.Len(t, c.history, expected, "%d != %d", len(c.history), expected)
assert.Len(t, c.history[ch], expected, "%d != %d", len(c.history), expected)
}

142
plugins/twitch/bridge.go Normal file
View File

@ -0,0 +1,142 @@
package twitch
import (
"fmt"
"github.com/cenkalti/backoff/v4"
"github.com/rs/zerolog/log"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/connectors/discord"
"strings"
)
func (t *Twitch) mkBridge(r bot.Request) bool {
ircCh := "#" + r.Values["twitchChannel"]
if err := t.startConn(); err != nil {
t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("Could not connect to IRC: %s", err))
}
t.irc.Join(ircCh)
t.bridgeMap[r.Msg.Channel] = ircCh
t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("This post is tracking %s", ircCh))
return true
}
func (t *Twitch) rmBridge(r bot.Request) bool {
ch, ok := t.bridgeMap[r.Msg.Channel]
if ok {
delete(t.bridgeMap, r.Msg.Channel)
t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("No longer tracking %s.", ch))
return true
}
t.b.Send(r.Conn, bot.Message, r.Msg.Channel, "This is not a connected bridge channel.")
return true
}
func (t *Twitch) bridgeMsg(r bot.Request) bool {
if ircCh := t.bridgeMap[r.Msg.Channel]; ircCh != "" {
if t.irc == nil {
if err := t.startConn(); err != nil {
t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("Could not connect to IRC: %s", err))
}
t.irc.Join(ircCh)
}
replaceSet := t.c.Get("twitch.replaceset", "\"'-")
who := r.Msg.User.Name
for _, c := range replaceSet {
who = strings.ReplaceAll(who, string(c), "")
}
who = strings.Split(who, " ")[0][:9]
msg := fmt.Sprintf("%s: %s", who, r.Msg.Body)
t.irc.sendMessage(ircCh, msg)
}
return false
}
func (t *Twitch) ircMsg(channel, who, body string) {
for thread, ircCh := range t.bridgeMap {
if ircCh == channel {
t.b.Send(t.b.DefaultConnector(), bot.Message, thread, fmt.Sprintf("%s: %s", who, body))
}
}
}
func (t *Twitch) startBridgeMsg(threadName, twitchChannel, msg string) error {
if !strings.HasPrefix(twitchChannel, "#") {
twitchChannel = "#" + twitchChannel
}
if err := t.startConn(); err != nil {
return err
}
chID, err := t.mkForumPost(threadName, msg)
if err != nil {
return err
}
log.Debug().Msgf("Opened thread %s", chID)
err = t.irc.Join(twitchChannel)
if err != nil {
return err
}
t.bridgeMap[chID] = twitchChannel
return nil
}
func (t *Twitch) startBridge(threadName, twitchChannel string) error {
if !strings.HasPrefix(twitchChannel, "#") {
twitchChannel = "#" + twitchChannel
}
msg := fmt.Sprintf("This post is tracking %s", twitchChannel)
return t.startBridgeMsg(threadName, twitchChannel, msg)
}
func (t *Twitch) startConn() error {
if t.irc == nil {
err := backoff.Retry(func() error {
err := t.connect()
if err != nil {
log.Error().Err(err).Msg("could not connect to IRC")
return err
}
return nil
}, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 5))
if err != nil {
return err
}
}
return nil
}
func (t *Twitch) connect() error {
t.ircLock.Lock()
defer t.ircLock.Unlock()
twitchServer := t.c.Get("twitch.ircserver", "irc.chat.twitch.tv:6697")
twitchNick := t.c.Get("twitch.nick", "")
twitchPass := t.c.Get("twitch.pass", "")
twitchIRC, err := t.ConnectIRC(twitchServer, twitchNick, twitchPass, t.ircMsg, func() {
t.ircLock.Lock()
defer t.ircLock.Unlock()
t.irc = nil
})
if err != nil {
return err
}
t.irc = twitchIRC
return nil
}
func (t *Twitch) mkForumPost(name, msg string) (string, error) {
forum := t.c.Get("twitch.forum", "")
if forum == "" {
return "", fmt.Errorf("no forum available")
}
switch c := t.b.DefaultConnector().(type) {
case *discord.Discord:
chID, err := c.CreateRoom(name, msg, forum, t.c.GetInt("twitch.threadduration", 60))
if err != nil {
return "", err
}
return chID, nil
default:
return "", fmt.Errorf("non-Discord connectors not supported")
}
}

146
plugins/twitch/demo/main.go Normal file
View File

@ -0,0 +1,146 @@
package main
import (
"encoding/json"
"fmt"
"github.com/nicklaw5/helix"
"io"
"log"
"net/http"
)
func main() {
client, err := helix.NewClient(&helix.Options{
ClientID: "ptwtiuzl9tcrekpf3d26ey3hb7qsge",
ClientSecret: "rpa0w6qemjqp7sgrmidwi4k0kcah82",
})
if err != nil {
log.Printf("Login error: %v", err)
return
}
access, err := client.RequestAppAccessToken([]string{"user:read:email"})
if err != nil {
log.Printf("Login error: %v", err)
return
}
fmt.Printf("%+v\n", access)
// Set the access token on the client
client.SetAppAccessToken(access.Data.AccessToken)
users, err := client.GetUsers(&helix.UsersParams{
Logins: []string{"drseabass"},
})
if err != nil {
log.Printf("Error getting users: %v", err)
return
}
if users.Error != "" {
log.Printf("Users error: %s", users.Error)
return
}
log.Printf("drseabass: %+v", users.Data.Users[0])
return
resp, err := client.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: helix.EventSubTypeStreamOnline,
Version: "1",
Condition: helix.EventSubCondition{
BroadcasterUserID: users.Data.Users[0].ID,
},
Transport: helix.EventSubTransport{
Method: "webhook",
Callback: "https://rathaus.chrissexton.org/live",
Secret: "s3cre7w0rd",
},
})
if err != nil {
log.Printf("Eventsub error: %v", err)
return
}
fmt.Printf("%+v\n", resp)
resp, err = client.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: helix.EventSubTypeStreamOffline,
Version: "1",
Condition: helix.EventSubCondition{
BroadcasterUserID: users.Data.Users[0].ID,
},
Transport: helix.EventSubTransport{
Method: "webhook",
Callback: "https://rathaus.chrissexton.org/offline",
Secret: "s3cre7w0rd",
},
})
if err != nil {
log.Printf("Eventsub error: %v", err)
return
}
fmt.Printf("%+v\n", resp)
http.HandleFunc("/offline", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Println(err)
return
}
defer r.Body.Close()
// verify that the notification came from twitch using the secret.
if !helix.VerifyEventSubNotification("s3cre7w0rd", r.Header, string(body)) {
log.Println("no valid signature on subscription")
return
} else {
log.Println("verified signature for subscription")
}
var vals map[string]any
if err = json.Unmarshal(body, &vals); err != nil {
log.Println(err)
return
}
if challenge, ok := vals["challenge"]; ok {
w.Write([]byte(challenge.(string)))
return
}
log.Printf("got offline webhook: %v\n", vals)
w.WriteHeader(200)
w.Write([]byte("ok"))
})
http.HandleFunc("/live", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Println(err)
return
}
defer r.Body.Close()
// verify that the notification came from twitch using the secret.
if !helix.VerifyEventSubNotification("s3cre7w0rd", r.Header, string(body)) {
log.Println("no valid signature on subscription")
return
} else {
log.Println("verified signature for subscription")
}
var vals map[string]any
if err = json.Unmarshal(body, &vals); err != nil {
log.Println(err)
return
}
if challenge, ok := vals["challenge"]; ok {
w.Write([]byte(challenge.(string)))
return
}
log.Printf("got live webhook: %v\n", vals)
w.WriteHeader(200)
w.Write([]byte("ok"))
})
http.ListenAndServe("0.0.0.0:1337", nil)
}

177
plugins/twitch/irc.go Normal file
View File

@ -0,0 +1,177 @@
package twitch
import (
"github.com/rs/zerolog/log"
"github.com/velour/catbase/config"
"github.com/velour/velour/irc"
"io"
"time"
)
var throttle <-chan time.Time
type eventFunc func(channel, who, body string)
type IRC struct {
t *Twitch
c *config.Config
client *irc.Client
event eventFunc
quit chan bool
}
func (t *Twitch) ConnectIRC(server, user, pass string, handler eventFunc, disconnect func()) (*IRC, error) {
log.Debug().Msgf("Connecting to %s, %s:%s", server, user, pass)
i := &IRC{
t: t,
c: t.c,
event: handler}
wait := make(chan bool)
go i.serve(server, user, pass, wait, disconnect)
<-wait
return i, nil
}
func (i *IRC) Join(channel string) error {
i.client.Out <- irc.Msg{Cmd: irc.JOIN, Args: []string{channel}}
return nil
}
func (i *IRC) Say(channel, body string) error {
if _, err := i.sendMessage(channel, body); err != nil {
return err
}
return nil
}
func (i *IRC) serve(server, user, pass string, wait chan bool, disconnect func()) {
if i.event == nil {
log.Error().Msgf("Missing event handler")
wait <- true
return
}
var err error
i.client, err = irc.DialSSL(server, user, user, pass, true)
if err != nil {
log.Error().
Err(err).
Strs("args", []string{server, user, pass}).
Msgf("Could not connect")
wait <- true
return
}
i.client.Out <- irc.Msg{Cmd: "CAP REQ", Args: []string{":twitch.tv/membership"}}
i.quit = make(chan bool)
go i.handleConnection()
wait <- true
<-i.quit
disconnect()
}
func (i *IRC) handleMsg(msg irc.Msg) {
switch msg.Cmd {
case irc.ERROR:
log.Info().Msgf("Received error: " + msg.Raw)
case irc.PING:
i.client.Out <- irc.Msg{Cmd: irc.PONG}
case irc.PONG:
// OK, ignore
case irc.KICK:
fallthrough
case irc.TOPIC:
fallthrough
case irc.NOTICE:
fallthrough
case irc.PRIVMSG:
if len(msg.Args) < 2 {
break
}
i.event(msg.Args[0], msg.Origin, msg.Args[1])
case irc.QUIT:
log.Debug().
Interface("msg", msg).
Msgf("QUIT")
i.quit <- true
default:
log.Debug().
Interface("msg", msg).
Msgf("IRC EVENT")
}
}
func (i *IRC) sendMessage(channel, message string, args ...any) (string, error) {
for len(message) > 0 {
m := irc.Msg{
Cmd: "PRIVMSG",
Args: []string{channel, message},
}
_, err := m.RawString()
if err != nil {
mtl := err.(irc.MsgTooLong)
m.Args[1] = message[:mtl.NTrunc]
message = message[mtl.NTrunc:]
} else {
message = ""
}
if throttle == nil {
ratePerSec := i.c.GetInt("RatePerSec", 5)
throttle = time.Tick(time.Second / time.Duration(ratePerSec))
}
<-throttle
i.client.Out <- m
}
return "NO_IRC_IDENTIFIERS", nil
}
func (i *IRC) handleConnection() {
pingTime := time.Duration(i.c.GetInt("twitch.pingtime", 60)) * time.Second
t := time.NewTimer(pingTime)
defer func() {
t.Stop()
close(i.client.Out)
for err := range i.client.Errors {
if err != io.EOF {
log.Error().Err(err)
}
}
}()
for {
select {
case msg, ok := <-i.client.In:
if !ok { // disconnect
i.quit <- true
return
}
t.Stop()
t = time.NewTimer(pingTime)
i.handleMsg(msg)
case <-t.C:
i.client.Out <- irc.Msg{Cmd: irc.PING, Args: []string{i.client.Server}}
t = time.NewTimer(pingTime)
case err, ok := <-i.client.Errors:
if ok && err != io.EOF {
log.Error().Err(err)
i.quit <- true
return
}
}
}
}

View File

@ -3,12 +3,15 @@ package twitch
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"github.com/nicklaw5/helix"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"text/template"
"time"
@ -26,21 +29,33 @@ const (
stoppedStreamingTplFallback = "{{.Name}} just stopped streaming"
)
type TwitchPlugin struct {
b bot.Bot
c *config.Config
twitchList map[string]*Twitcher
tbl bot.HandlerTable
type Twitch struct {
b bot.Bot
c *config.Config
twitchList map[string]*Twitcher
tbl bot.HandlerTable
ircConnected bool
irc *IRC
ircLock sync.Mutex
bridgeMap map[string]string
helix *helix.Client
subs map[string]bool
}
type Twitcher struct {
name string
id string
gameID string
online bool
Name string
Game string
URL string
}
func (t Twitcher) URL() string {
func (t Twitcher) url() string {
u, _ := url.Parse("https://twitch.tv/")
u2, _ := url.Parse(t.name)
u2, _ := url.Parse(t.Name)
return u.ResolveReference(u2).String()
}
@ -62,71 +77,35 @@ type stream struct {
} `json:"pagination"`
}
func New(b bot.Bot) *TwitchPlugin {
p := &TwitchPlugin{
func New(b bot.Bot) *Twitch {
p := &Twitch{
b: b,
c: b.Config(),
twitchList: map[string]*Twitcher{},
bridgeMap: map[string]string{},
}
for _, ch := range p.c.GetArray("Twitch.Channels", []string{}) {
for _, twitcherName := range p.c.GetArray("Twitch."+ch+".Users", []string{}) {
twitcherName = strings.ToLower(twitcherName)
if _, ok := p.twitchList[twitcherName]; !ok {
p.twitchList[twitcherName] = &Twitcher{
name: twitcherName,
gameID: "",
}
}
for _, twitcherName := range p.c.GetArray("Twitch.Users", []string{}) {
twitcherName = strings.ToLower(twitcherName)
p.twitchList[twitcherName] = &Twitcher{
Name: twitcherName,
}
go p.twitchChannelLoop(b.DefaultConnector(), ch)
}
go p.twitchAuthLoop(b.DefaultConnector())
p.register()
p.registerWeb()
return p
}
func (p *TwitchPlugin) registerWeb() {
func (p *Twitch) registerWeb() {
r := chi.NewRouter()
r.HandleFunc("/{user}", p.serveStreaming)
p.b.RegisterWeb(r, "/isstreaming")
r.HandleFunc("/online", p.onlineCB)
r.HandleFunc("/offline", p.offlineCB)
p.b.GetWeb().RegisterWeb(r, "/twitch")
}
func (p *TwitchPlugin) serveStreaming(w http.ResponseWriter, r *http.Request) {
userName := strings.ToLower(chi.URLParam(r, "user"))
if userName == "" {
fmt.Fprint(w, "User not found.")
return
}
twitcher := p.twitchList[userName]
if twitcher == nil {
fmt.Fprint(w, "User not found.")
return
}
status := "NO."
if twitcher.gameID != "" {
status = "YES."
}
context := map[string]any{"Name": twitcher.name, "Status": status}
t, err := template.New("streaming").Parse(page)
if err != nil {
log.Error().Err(err).Msg("Could not parse template!")
return
}
err = t.Execute(w, context)
if err != nil {
log.Error().Err(err).Msg("Could not execute template!")
}
}
func (p *TwitchPlugin) register() {
func (p *Twitch) register() {
p.tbl = bot.HandlerTable{
{
Kind: bot.Message, IsCmd: true,
@ -146,21 +125,144 @@ func (p *TwitchPlugin) register() {
HelpText: "Reset the twitch templates",
Handler: p.resetTwitch,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^track (?P<twitchChannel>.+)$`),
HelpText: "Bridge to this channel",
Handler: p.mkBridge,
},
{
Kind: bot.Message, IsCmd: true,
Regex: regexp.MustCompile(`(?i)^untrack$`),
HelpText: "Disconnect a bridge (only in bridged channel)",
Handler: p.rmBridge,
},
{
Kind: bot.Message, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: p.bridgeMsg,
},
{
Kind: bot.Startup, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: p.startup,
},
{
Kind: bot.Shutdown, IsCmd: false,
Regex: regexp.MustCompile(`.*`),
Handler: p.shutdown,
},
}
p.b.Register(p, bot.Help, p.help)
p.b.RegisterTable(p, p.tbl)
}
func (p *TwitchPlugin) twitchStatus(r bot.Request) bool {
func (p *Twitch) shutdown(r bot.Request) bool {
return false
}
func (p *Twitch) startup(r bot.Request) bool {
var err error
clientID := p.c.Get("twitch.clientid", "")
clientSecret := p.c.Get("twitch.secret", "")
if clientID == "" || clientSecret == "" {
log.Info().Msg("No clientID/secret, twitch disabled")
return false
}
p.helix, err = helix.NewClient(&helix.Options{
ClientID: clientID,
ClientSecret: clientSecret,
})
if err != nil {
log.Printf("Login error: %v", err)
return false
}
access, err := p.helix.RequestAppAccessToken([]string{"user:read:email"})
if err != nil {
log.Printf("Login error: %v", err)
return false
}
fmt.Printf("%+v\n", access)
// Set the access token on the client
p.helix.SetAppAccessToken(access.Data.AccessToken)
p.subs = map[string]bool{}
for _, t := range p.twitchList {
if err := p.follow(t); err != nil {
log.Error().Err(err).Msg("")
}
}
return false
}
func (p *Twitch) follow(twitcher *Twitcher) error {
if twitcher.id == "" {
users, err := p.helix.GetUsers(&helix.UsersParams{
Logins: []string{twitcher.Name},
})
if err != nil {
return err
}
if users.Error != "" {
return errors.New(users.Error)
}
twitcher.id = users.Data.Users[0].ID
}
base := p.c.Get("baseurl", "") + "/twitch"
_, err := p.helix.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: helix.EventSubTypeStreamOnline,
Version: "1",
Condition: helix.EventSubCondition{
BroadcasterUserID: twitcher.id,
},
Transport: helix.EventSubTransport{
Method: "webhook",
Callback: base + "/online",
Secret: "s3cre7w0rd",
},
})
if err != nil {
return err
}
_, err = p.helix.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: helix.EventSubTypeStreamOffline,
Version: "1",
Condition: helix.EventSubCondition{
BroadcasterUserID: twitcher.id,
},
Transport: helix.EventSubTransport{
Method: "webhook",
Callback: base + "/offline",
Secret: "s3cre7w0rd",
},
})
if err != nil {
return err
}
return nil
}
func (p *Twitch) twitchStatus(r bot.Request) bool {
channel := r.Msg.Channel
if users := p.c.GetArray("Twitch."+channel+".Users", []string{}); len(users) > 0 {
for _, twitcherName := range users {
twitcherName = strings.ToLower(twitcherName)
// we could re-add them here instead of needing to restart the bot.
if t, ok := p.twitchList[twitcherName]; ok {
err := p.checkTwitch(r.Conn, channel, t, true)
if err != nil {
log.Error().Err(err).Msgf("error in checking twitch")
if t.online {
p.streaming(r.Conn, r.Msg.Channel, t)
}
}
}
@ -168,13 +270,11 @@ func (p *TwitchPlugin) twitchStatus(r bot.Request) bool {
return true
}
func (p *TwitchPlugin) twitchUserStatus(r bot.Request) bool {
func (p *Twitch) twitchUserStatus(r bot.Request) bool {
who := strings.ToLower(r.Values["who"])
if t, ok := p.twitchList[who]; ok {
err := p.checkTwitch(r.Conn, r.Msg.Channel, t, true)
if err != nil {
log.Error().Err(err).Msgf("error in checking twitch")
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "I had trouble with that.")
if t.online {
p.streaming(r.Conn, r.Msg.Channel, t)
}
} else {
p.b.Send(r.Conn, bot.Message, r.Msg.Channel, "I don't know who that is.")
@ -182,7 +282,7 @@ func (p *TwitchPlugin) twitchUserStatus(r bot.Request) bool {
return true
}
func (p *TwitchPlugin) resetTwitch(r bot.Request) bool {
func (p *Twitch) resetTwitch(r bot.Request) bool {
p.c.Set("twitch.istpl", isStreamingTplFallback)
p.c.Set("twitch.nottpl", notStreamingTplFallback)
p.c.Set("twitch.stoppedtpl", stoppedStreamingTplFallback)
@ -190,7 +290,7 @@ func (p *TwitchPlugin) resetTwitch(r bot.Request) bool {
return true
}
func (p *TwitchPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool {
func (p *Twitch) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool {
msg := "You can set the templates for streams with\n"
msg += fmt.Sprintf("twitch.istpl (default: %s)\n", isStreamingTplFallback)
msg += fmt.Sprintf("twitch.nottpl (default: %s)\n", notStreamingTplFallback)
@ -201,224 +301,191 @@ func (p *TwitchPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message,
return true
}
func (p *TwitchPlugin) twitchAuthLoop(c bot.Connector) {
frequency := p.c.GetInt("Twitch.AuthFreq", 60*60)
cid := p.c.Get("twitch.clientid", "")
secret := p.c.Get("twitch.secret", "")
if cid == "" || secret == "" {
log.Info().Msgf("Disabling twitch autoauth.")
return
func (p *Twitch) connectBridge(c bot.Connector, ch string, info *Twitcher) {
msg := fmt.Sprintf("This post is tracking #%s\n<%s>", info.Name, info.url())
err := p.startBridgeMsg(
fmt.Sprintf("%s-%s-%s", info.Name, info.Game, time.Now().Format("2006-01-02-15:04")),
info.Name,
msg,
)
if err != nil {
log.Error().Err(err).Msgf("unable to start bridge")
p.b.Send(c, bot.Message, ch, fmt.Sprintf("Unable to start bridge: %s", err))
}
}
log.Info().Msgf("Checking auth every %d seconds", frequency)
if err := p.validateCredentials(); err != nil {
log.Error().Err(err).Msgf("error checking twitch validity")
}
for {
select {
case <-time.After(time.Duration(frequency) * time.Second):
if err := p.validateCredentials(); err != nil {
log.Error().Err(err).Msgf("error checking twitch validity")
}
func (p *Twitch) disconnectBridge(c bot.Connector, twitcher *Twitcher) {
log.Debug().Msgf("Disconnecting bridge: %s -> %+v", twitcher.Name, p.bridgeMap)
for threadID, ircCh := range p.bridgeMap {
if strings.HasSuffix(ircCh, twitcher.Name) {
delete(p.bridgeMap, threadID)
p.b.Send(c, bot.Message, threadID, "Stopped tracking #"+twitcher.Name)
}
}
}
func (p *TwitchPlugin) twitchChannelLoop(c bot.Connector, channel string) {
frequency := p.c.GetInt("Twitch.Freq", 60)
if p.c.Get("twitch.clientid", "") == "" || p.c.Get("twitch.secret", "") == "" {
log.Info().Msgf("Disabling twitch autochecking.")
return
}
log.Info().Msgf("Checking channels every %d seconds", frequency)
for {
time.Sleep(time.Duration(frequency) * time.Second)
for _, twitcherName := range p.c.GetArray("Twitch."+channel+".Users", []string{}) {
twitcherName = strings.ToLower(twitcherName)
if err := p.checkTwitch(c, channel, p.twitchList[twitcherName], false); err != nil {
log.Error().Err(err).Msgf("error in twitch loop")
}
}
}
}
func getRequest(url, clientID, token string) ([]byte, int, bool) {
bearer := fmt.Sprintf("Bearer %s", token)
var body []byte
var resp *http.Response
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
goto errCase
}
req.Header.Add("Client-ID", clientID)
req.Header.Add("Authorization", bearer)
resp, err = client.Do(req)
if err != nil {
goto errCase
}
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
goto errCase
}
return body, resp.StatusCode, true
errCase:
log.Error().Err(err)
return []byte{}, resp.StatusCode, false
}
func (p *TwitchPlugin) checkTwitch(c bot.Connector, channel string, twitcher *Twitcher, alwaysPrintStatus bool) error {
baseURL, err := url.Parse("https://api.twitch.tv/helix/streams")
if err != nil {
err := fmt.Errorf("error parsing twitch stream URL")
log.Error().Msg(err.Error())
return err
}
query := baseURL.Query()
query.Add("user_login", twitcher.name)
baseURL.RawQuery = query.Encode()
cid := p.c.Get("twitch.clientid", "")
token := p.c.Get("twitch.token", "")
if cid == token && cid == "" {
log.Info().Msgf("Twitch plugin not enabled.")
return nil
}
body, status, ok := getRequest(baseURL.String(), cid, token)
if !ok {
return fmt.Errorf("got status %d: %s", status, string(body))
}
var s stream
err = json.Unmarshal(body, &s)
if err != nil {
log.Error().Err(err).Msgf("error reading twitch data")
return err
}
games := s.Data
gameID, title := "", ""
if len(games) > 0 {
gameID = games[0].GameID
if gameID == "" {
gameID = "unknown"
}
title = games[0].Title
}
notStreamingTpl := p.c.Get("Twitch.NotTpl", notStreamingTplFallback)
isStreamingTpl := p.c.Get("Twitch.IsTpl", isStreamingTplFallback)
stoppedStreamingTpl := p.c.Get("Twitch.StoppedTpl", stoppedStreamingTplFallback)
func (p *Twitch) stopped(c bot.Connector, ch string, info *Twitcher) {
notStreamingTpl := p.c.Get("Twitch.StoppedTpl", stoppedStreamingTplFallback)
buf := bytes.Buffer{}
info := struct {
Name string
Game string
URL string
}{
twitcher.name,
title,
twitcher.URL(),
t, err := template.New("notStreaming").Parse(notStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, ch, err)
t = template.Must(template.New("notStreaming").Parse(stoppedStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, ch, buf.String())
}
if alwaysPrintStatus {
if gameID == "" {
t, err := template.New("notStreaming").Parse(notStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, channel, err)
t = template.Must(template.New("notStreaming").Parse(notStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, channel, buf.String())
} else {
t, err := template.New("isStreaming").Parse(isStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, channel, err)
t = template.Must(template.New("isStreaming").Parse(isStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, channel, buf.String())
}
} else if gameID == "" {
if twitcher.gameID != "" {
t, err := template.New("stoppedStreaming").Parse(stoppedStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, channel, err)
t = template.Must(template.New("stoppedStreaming").Parse(stoppedStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, channel, buf.String())
}
twitcher.gameID = ""
func (p *Twitch) streaming(c bot.Connector, channel string, info *Twitcher) {
isStreamingTpl := p.c.Get("Twitch.IsTpl", isStreamingTplFallback)
buf := bytes.Buffer{}
t, err := template.New("isStreaming").Parse(isStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, channel, err)
t = template.Must(template.New("isStreaming").Parse(isStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, channel, buf.String())
}
func (p *Twitch) notStreaming(c bot.Connector, ch string, info *Twitcher) {
notStreamingTpl := p.c.Get("Twitch.NotTpl", notStreamingTplFallback)
buf := bytes.Buffer{}
t, err := template.New("notStreaming").Parse(notStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, ch, err)
t = template.Must(template.New("notStreaming").Parse(notStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, ch, buf.String())
}
type twitchCB struct {
Challenge string `json:"challenge"`
Subscription struct {
ID string `json:"id"`
Type string `json:"type"`
Version string `json:"version"`
Status string `json:"status"`
Cost int `json:"cost"`
Condition struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
} `json:"condition"`
CreatedAt time.Time `json:"created_at"`
Transport struct {
Method string `json:"method"`
Callback string `json:"callback"`
} `json:"transport"`
} `json:"subscription"`
Event struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
} `json:"event"`
}
func (p *Twitch) offlineCB(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Msg("")
return
}
defer r.Body.Close()
// verify that the notification came from twitch using the secret.
if !helix.VerifyEventSubNotification("s3cre7w0rd", r.Header, string(body)) {
log.Error().Msg("no valid signature on subscription")
return
} else {
if twitcher.gameID != gameID {
t, err := template.New("isStreaming").Parse(isStreamingTpl)
if err != nil {
log.Error().Err(err)
p.b.Send(c, bot.Message, channel, err)
t = template.Must(template.New("isStreaming").Parse(isStreamingTplFallback))
}
t.Execute(&buf, info)
p.b.Send(c, bot.Message, channel, buf.String())
log.Info().Msg("verified signature for subscription")
}
var vals twitchCB
if err = json.Unmarshal(body, &vals); err != nil {
log.Error().Err(err).Msg("")
return
}
if vals.Challenge != "" {
w.Write([]byte(vals.Challenge))
return
}
log.Printf("got offline webhook: %v\n", vals)
w.WriteHeader(200)
w.Write([]byte("ok"))
twitcher := p.twitchList[vals.Event.BroadcasterUserLogin]
if !twitcher.online {
return
}
twitcher.online = false
twitcher.URL = twitcher.url()
if ch := p.c.Get("twitch.channel", ""); ch != "" {
p.stopped(p.b.DefaultConnector(), ch, twitcher)
if p.c.GetBool("twitch.irc", false) {
p.disconnectBridge(p.b.DefaultConnector(), twitcher)
}
twitcher.gameID = gameID
}
return nil
}
func (p *TwitchPlugin) validateCredentials() error {
cid := p.c.Get("twitch.clientid", "")
token := p.c.Get("twitch.token", "")
if token == "" {
return p.reAuthenticate()
func (p *Twitch) onlineCB(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Error().Err(err).Msg("")
return
}
_, status, ok := getRequest("https://id.twitch.tv/oauth2/validate", cid, token)
if !ok || status == http.StatusUnauthorized {
return p.reAuthenticate()
defer r.Body.Close()
// verify that the notification came from twitch using the secret.
if !helix.VerifyEventSubNotification("s3cre7w0rd", r.Header, string(body)) {
log.Error().Msg("no valid signature on subscription")
return
} else {
log.Info().Msg("verified signature for subscription")
}
var vals twitchCB
if err = json.Unmarshal(body, &vals); err != nil {
log.Error().Err(err).Msg("")
return
}
log.Debug().Msgf("checked credentials and they were valid")
return nil
}
func (p *TwitchPlugin) reAuthenticate() error {
cid := p.c.Get("twitch.clientid", "")
secret := p.c.Get("twitch.secret", "")
if cid == "" || secret == "" {
return fmt.Errorf("could not request a new token without config values set")
if vals.Challenge != "" {
w.Write([]byte(vals.Challenge))
return
}
resp, err := http.PostForm("https://id.twitch.tv/oauth2/token", url.Values{
"client_id": {cid},
"client_secret": {secret},
"grant_type": {"client_credentials"},
log.Printf("got online webhook: %v\n", vals)
w.WriteHeader(200)
w.Write([]byte("ok"))
streams, err := p.helix.GetStreams(&helix.StreamsParams{
UserIDs: []string{vals.Event.BroadcasterUserID},
})
if err != nil {
return err
log.Error().Err(err).Msg("")
return
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
twitcher := p.twitchList[vals.Event.BroadcasterUserLogin]
if twitcher.online {
return
}
twitcher.online = true
twitcher.URL = twitcher.url()
if len(streams.Data.Streams) > 0 {
twitcher.gameID = streams.Data.Streams[0].GameID
twitcher.Game = streams.Data.Streams[0].GameName
} else {
twitcher.gameID = "-1"
twitcher.Game = p.c.Get("twitch.unknowngame", "IDK, Twitch didn't tell me")
}
if ch := p.c.Get("twitch.channel", ""); ch != "" {
p.streaming(p.b.DefaultConnector(), ch, twitcher)
if p.c.GetBool("twitch.irc", false) {
p.connectBridge(p.b.DefaultConnector(), ch, twitcher)
}
}
credentials := struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}{}
err = json.Unmarshal(body, &credentials)
log.Debug().Int("expires", credentials.ExpiresIn).Msgf("setting new twitch token")
return p.c.RegisterSecret("twitch.token", credentials.AccessToken)
}

View File

@ -3,7 +3,6 @@
package twitch
import (
"github.com/velour/catbase/plugins/cli"
"strings"
"testing"
@ -29,7 +28,7 @@ func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) {
if isCmd {
payload = payload[1:]
}
return &cli.CliPlugin{}, bot.Message, msg.Message{
return nil, bot.Message, msg.Message{
User: &user.User{Name: "tester"},
Channel: "test",
Body: payload,
@ -37,7 +36,7 @@ func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) {
}
}
func makeTwitchPlugin(t *testing.T) (*TwitchPlugin, *bot.MockBot) {
func makeTwitchPlugin(t *testing.T) (*Twitch, *bot.MockBot) {
mb := bot.NewMockBot()
c := New(mb)
mb.Config().Set("twitch.clientid", "fake")
@ -47,15 +46,9 @@ func makeTwitchPlugin(t *testing.T) (*TwitchPlugin, *bot.MockBot) {
assert.NotNil(t, c)
c.twitchList["drseabass"] = &Twitcher{
name: "drseabass",
Name: "drseabass",
gameID: "",
}
return c, mb
}
func TestTwitch(t *testing.T) {
b, mb := makeTwitchPlugin(t)
b.twitchStatus(makeRequest("!twitch status"))
assert.NotEmpty(t, mb.Messages)
}

View File

@ -1,20 +0,0 @@
package twitch
var page = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Is {{.Name}} streaming?</title>
</head>
<body style="text-align: center; padding-top: 200px;">
<a style="font-weight: bold; font-size: 120pt;
font-family: Arial, sans-serif; text-decoration: none; color: black;"
title="{{.Status}}">{{.Status}}</a>
</body>
</html>
`

View File

@ -6,8 +6,6 @@ import (
"strings"
"testing"
"github.com/velour/catbase/plugins/cli"
"github.com/stretchr/testify/assert"
"github.com/velour/catbase/bot"
"github.com/velour/catbase/bot/msg"
@ -20,7 +18,6 @@ func makeMessage(payload string) bot.Request {
payload = payload[1:]
}
return bot.Request{
Conn: &cli.CliPlugin{},
Kind: bot.Message,
Msg: msg.Message{
User: &user.User{Name: "tester"},

1
util/stats/main.go Normal file
View File

@ -0,0 +1 @@
package stats