Starting Web Server work
Fixed some issues with stat adding.
This commit is contained in:
parent
12ae393998
commit
d5947f745f
11
assets/css/pure-min.css
vendored
Normal file
11
assets/css/pure-min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
231
assets/css/statbot.css
Normal file
231
assets/css/statbot.css
Normal file
@ -0,0 +1,231 @@
|
||||
body {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Stuff for the Side Menu */
|
||||
/* Add transition to containers so they can push in and out. */
|
||||
#layout,
|
||||
#menu,
|
||||
.menu-link {
|
||||
-webkit-transition: all 0.2s ease-out;
|
||||
-moz-transition: all 0.2s ease-out;
|
||||
-ms-transition: all 0.2s ease-out;
|
||||
-o-transition: all 0.2s ease-out;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* This is the parent `<div>` that contains the menu and the content area. */
|
||||
#layout {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
}
|
||||
#layout.active #menu {
|
||||
left: 150px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
#layout.active .menu-link {
|
||||
left: 150px;
|
||||
}
|
||||
|
||||
/* The content `<div>` is where all the content goes. */
|
||||
.content {
|
||||
margin: 0 auto;
|
||||
padding: 0 2em;
|
||||
max-width: 800px;
|
||||
margin-bottom: 50px;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
/* Content header */
|
||||
.header {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
padding: 2.5em 2em 0;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0.2em 0;
|
||||
font-size: 3em;
|
||||
font-weight: 300;
|
||||
}
|
||||
.header h2 {
|
||||
font-weight: 300;
|
||||
color: #ccc;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-subhead {
|
||||
margin: 50px 0 20px 0;
|
||||
font-weight: 300;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/*
|
||||
The `#menu` `<div>` is the parent `<div>` that contains the `.pure-menu` that
|
||||
appears on the left side of the page.
|
||||
*/
|
||||
|
||||
#menu {
|
||||
margin-left: -150px; /* "#menu" width */
|
||||
width: 150px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000; /* so the menu or its navicon stays above all content */
|
||||
background: #191818;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* All anchors inside the menu should be styled like this. */
|
||||
#menu a {
|
||||
color: #999;
|
||||
border: none;
|
||||
padding: 0.6em 0 0.6em 0.6em;
|
||||
}
|
||||
|
||||
/* Remove all background/borders, since we are applying them to #menu. */
|
||||
#menu .pure-menu,
|
||||
#menu .pure-menu ul {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Add that light border to separate items into groups. */
|
||||
#menu .pure-menu ul,
|
||||
#menu .pure-menu .menu-item-divided {
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
/* Change color of the anchor links on hover/focus. */
|
||||
#menu .pure-menu li a:hover,
|
||||
#menu .pure-menu li a:focus {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
/* This styles the selected menu item `<li>`. */
|
||||
#menu .pure-menu-selected {
|
||||
background: #0b4992;
|
||||
}
|
||||
#menu .pure-menu-heading {
|
||||
background: #1f8dd6;
|
||||
}
|
||||
|
||||
/* This styles a link within a selected menu item `<li>`. */
|
||||
#menu .pure-menu-selected a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
This styles the menu heading.
|
||||
*/
|
||||
#menu .pure-menu-heading {
|
||||
font-size: 110%;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* -- Dynamic Button For Responsive Menu -------------------------------------*/
|
||||
|
||||
/*
|
||||
The button to open/close the Menu is custom-made and not part of Pure. Here's
|
||||
how it works:
|
||||
*/
|
||||
|
||||
/*
|
||||
`.menu-link` represents the responsive menu toggle that shows/hides on
|
||||
small screens.
|
||||
*/
|
||||
.menu-link {
|
||||
position: fixed;
|
||||
display: block; /* show this only on small screens */
|
||||
top: 0;
|
||||
left: 0; /* "#menu width" */
|
||||
background: #000;
|
||||
background: rgba(0,0,0,0.7);
|
||||
font-size: 10px; /* change this value to increase/decrease button size */
|
||||
z-index: 10;
|
||||
width: 2em;
|
||||
height: auto;
|
||||
padding: 2.1em 1.6em;
|
||||
}
|
||||
|
||||
.menu-link:hover,
|
||||
.menu-link:focus {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.menu-link span {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-link span,
|
||||
.menu-link span:before,
|
||||
.menu-link span:after {
|
||||
background-color: #fff;
|
||||
width: 100%;
|
||||
height: 0.2em;
|
||||
}
|
||||
|
||||
.menu-link span:before,
|
||||
.menu-link span:after {
|
||||
position: absolute;
|
||||
margin-top: -0.6em;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
.menu-link span:after {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
|
||||
ul.menu-list-dropped {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
/* -- Responsive Styles (Media Queries) ------------------------------------- */
|
||||
|
||||
/*
|
||||
Hides the menu at `48em`, but modify this based on your app's needs.
|
||||
*/
|
||||
@media (min-width: 48em) {
|
||||
.header,
|
||||
.content {
|
||||
padding-left: 2em;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
#layout {
|
||||
padding-left: 150px; /* left col width "#menu" */
|
||||
left: 0;
|
||||
}
|
||||
#menu {
|
||||
left: 150px;
|
||||
}
|
||||
|
||||
.menu-link {
|
||||
position: fixed;
|
||||
left: 150px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#layout.active .menu-link {
|
||||
left: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
/* Only apply this when the window is small. Otherwise, the following
|
||||
case results in extra padding on the left:
|
||||
* Make the window small.
|
||||
* Tap the menu to trigger the active state.
|
||||
* Make the window large again.
|
||||
*/
|
||||
#layout.active {
|
||||
position: relative;
|
||||
left: 150px;
|
||||
}
|
||||
}
|
110
processor_general.go
Normal file
110
processor_general.go
Normal file
@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
* General Message Processor
|
||||
*/
|
||||
type generalProcessor struct{}
|
||||
|
||||
func (p *generalProcessor) GetName() string {
|
||||
return "stat_bot General Processor"
|
||||
}
|
||||
|
||||
func (p *generalProcessor) GetHelp() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *generalProcessor) ProcessAdminMessage(slack *Slack, m *Message) {}
|
||||
func (p *generalProcessor) ProcessMessage(slack *Slack, m *Message) {}
|
||||
func (p *generalProcessor) ProcessUserMessage(slack *Slack, m *Message) {}
|
||||
|
||||
func (p *generalProcessor) ProcessAdminUserMessage(slack *Slack, m *Message) {
|
||||
// Check if we were mentioned
|
||||
if strings.HasPrefix(m.Text, "<@"+slack.id+">") {
|
||||
parts := strings.Fields(m.Text)
|
||||
var action, target string
|
||||
|
||||
if len(parts) >= 2 {
|
||||
action = parts[1]
|
||||
if len(parts) >= 3 {
|
||||
target = parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
if action == "mkadmin" && target != "" {
|
||||
// Make a user an admin
|
||||
if strings.HasPrefix(target, "<@") && strings.HasSuffix(target, ">") {
|
||||
target = strings.Trim(target, "<@>")
|
||||
if e := addAdmin(target); e == nil {
|
||||
m.Text = "User <@" + target + "> has been made an admin"
|
||||
} else {
|
||||
m.Text = fmt.Sprintf("%s", e)
|
||||
}
|
||||
} else {
|
||||
m.Text = "Please specify an existing user starting with a '@'"
|
||||
}
|
||||
slack.postMessage(*m)
|
||||
} else if action == "rmadmin" && target != "" {
|
||||
// Revoke a user as an admin
|
||||
if strings.HasPrefix(target, "<@") && strings.HasSuffix(target, ">") {
|
||||
target = strings.Trim(target, "<@>")
|
||||
if e := removeAdmin(target); e == nil {
|
||||
m.Text = "Admin privileges revoked from <@" + target + ">"
|
||||
} else {
|
||||
m.Text = fmt.Sprintf("%s", e)
|
||||
}
|
||||
} else {
|
||||
m.Text = "Please specify an existing user starting with a '@'"
|
||||
}
|
||||
slack.postMessage(*m)
|
||||
} else {
|
||||
// huh?
|
||||
m.Text = fmt.Sprintf("Beep boop beep, that does not compute\n")
|
||||
slack.postMessage(*m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *generalProcessor) ProcessChannelMessage(slack *Slack, m *Message) {}
|
||||
func (p *generalProcessor) ProcessAdminChannelMessage(slack *Slack, m *Message) {}
|
||||
|
||||
/*
|
||||
*General Statistics Processor
|
||||
*/
|
||||
type generalStatProcessor struct{}
|
||||
|
||||
func (p *generalStatProcessor) GetName() string {
|
||||
return "General Statistics"
|
||||
}
|
||||
|
||||
func (p *generalStatProcessor) GetStatKeys() []string {
|
||||
return []string{
|
||||
"bot-message",
|
||||
"channel-message",
|
||||
"message-hour-*",
|
||||
"message-dow-*",
|
||||
"message-dom-*",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *generalStatProcessor) ProcessMessage(m *Message) {
|
||||
incrementUserStat(m.User, "message-hour-"+m.Time.Format("15"))
|
||||
incrementUserStat(m.User, "message-dow-"+m.Time.Format("Mon"))
|
||||
incrementUserStat(m.User, "message-dom-"+m.Time.Format("_2"))
|
||||
}
|
||||
|
||||
func (p *generalStatProcessor) ProcessUserMessage(m *Message) {
|
||||
incrementUserStat(m.User, "bot-message")
|
||||
}
|
||||
|
||||
func (p *generalStatProcessor) ProcessChannelMessage(m *Message) {
|
||||
incrementUserStat(m.User, "channel-message")
|
||||
|
||||
incrementChannelStat(m.User, "message-hour-"+m.Time.Format("15"))
|
||||
incrementChannelStat(m.User, "message-dow-"+m.Time.Format("Mon"))
|
||||
incrementChannelStat(m.User, "message-dom-"+m.Time.Format("_2"))
|
||||
}
|
35
processor_levelup.go
Normal file
35
processor_levelup.go
Normal file
@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
type levelUpStatProcessor struct{}
|
||||
|
||||
func (p *levelUpStatProcessor) GetName() string {
|
||||
return "LevelUp Statistics"
|
||||
}
|
||||
|
||||
func (p *levelUpStatProcessor) GetStatKeys() []string {
|
||||
return []string{
|
||||
"levelup-*",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *levelUpStatProcessor) ProcessMessage(m *Message) {}
|
||||
|
||||
func (p *levelUpStatProcessor) ProcessUserMessage(m *Message) {}
|
||||
|
||||
func (p *levelUpStatProcessor) ProcessChannelMessage(m *Message) {
|
||||
// levelup XP, for now, is awarded like this:
|
||||
// Message in #random: 1 xp
|
||||
// Message in #general: 2 xp
|
||||
// Message in any other channel: 3 xp + 1 xp for that channel
|
||||
chnl, err := getChannelInfo(m.Channel)
|
||||
if err == nil {
|
||||
if chnl.Name == "random" {
|
||||
addUserStat(m.User, "levelup-xp", 1)
|
||||
} else if chnl.Name == "general" {
|
||||
addUserStat(m.User, "levelup-xp", 2)
|
||||
} else {
|
||||
addUserStat(m.User, "levelup-xp", 3)
|
||||
addUserStat(m.User, "levelup-xp-"+chnl.Name, 1)
|
||||
}
|
||||
}
|
||||
}
|
34
slack.go
34
slack.go
@ -112,9 +112,6 @@ func (s *Slack) getUserInfo(uid string) (*User, error) {
|
||||
}
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
writeToLog("User Lookup: \n")
|
||||
writeToLog(string(body))
|
||||
writeToLog("\n\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -143,9 +140,6 @@ func (s *Slack) getChannelInfo(cid string) (*Channel, error) {
|
||||
}
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
writeToLog("Channel Lookup: \n")
|
||||
writeToLog(string(body))
|
||||
writeToLog("\n\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -162,6 +156,34 @@ func (s *Slack) getChannelInfo(cid string) (*Channel, error) {
|
||||
return respObj.Channel, nil
|
||||
}
|
||||
|
||||
func (s *Slack) joinChannel(c *Channel) error {
|
||||
url := fmt.Sprintf("https://slack.com/api/channels.join?token=%s&name=%s", s.apiToken, c.Name)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
err = fmt.Errorf("API request failed with code %d", resp.StatusCode)
|
||||
return err
|
||||
}
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var respObj responseChannelLookup
|
||||
err = json.Unmarshal(body, &respObj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !respObj.Ok {
|
||||
err = fmt.Errorf("Slack error: %s", respObj.Error)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// These structures represent the response of the Slack API events
|
||||
// Only some fields are included. The rest are ignored by json.Unmarshal.
|
||||
type responseRtmStart struct {
|
||||
|
126
statbot.go
126
statbot.go
@ -3,23 +3,44 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const programName = "statbot"
|
||||
|
||||
type messageProcessor interface {
|
||||
GetName() string
|
||||
GetHelp() string
|
||||
ProcessMessage(s *Slack, m *Message)
|
||||
ProcessAdminMessage(s *Slack, m *Message)
|
||||
ProcessUserMessage(s *Slack, m *Message)
|
||||
ProcessAdminUserMessage(s *Slack, m *Message)
|
||||
ProcessChannelMessage(s *Slack, m *Message)
|
||||
ProcessAdminChannelMessage(s *Slack, m *Message)
|
||||
}
|
||||
|
||||
var messageProcessors []messageProcessor
|
||||
|
||||
type statProcessor interface {
|
||||
GetName() string
|
||||
GetStatKeys() []string
|
||||
ProcessMessage(m *Message)
|
||||
ProcessUserMessage(m *Message)
|
||||
ProcessChannelMessage(m *Message)
|
||||
}
|
||||
|
||||
var statProcessors []statProcessor
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: statbot slack-bot-token\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
writeToLog("\n\n\n== " + time.Now().Format(time.RFC3339) + " ==\n")
|
||||
|
||||
// start a websocket-based Real Time API session
|
||||
var slack *Slack
|
||||
var err error
|
||||
var slack *Slack
|
||||
|
||||
if err = initDatabase(); err != nil {
|
||||
panic(err)
|
||||
@ -29,16 +50,30 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// For now, we're not running the web server
|
||||
//statWebMain(slack)
|
||||
statBotMain(slack)
|
||||
}
|
||||
|
||||
// This is the main function for the statbot
|
||||
func statBotMain(slack *Slack) {
|
||||
// start a websocket-based Real Time API session
|
||||
registerStatProcessor(new(levelUpStatProcessor))
|
||||
registerStatProcessor(new(generalStatProcessor))
|
||||
|
||||
registerMessageProcessor(new(generalProcessor))
|
||||
|
||||
fmt.Println("statbot ready, ^C exits")
|
||||
|
||||
writeToLog("== " + time.Now().Format(time.RFC3339) + " - Bot Started ==\n")
|
||||
for {
|
||||
// read each incoming message
|
||||
m, err := slack.getMessage()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
if err == nil {
|
||||
processMessage(slack, &m)
|
||||
}
|
||||
processMessage(slack, &m)
|
||||
}
|
||||
writeToLog("== " + time.Now().Format(time.RFC3339) + " - Bot Stopped ==\n\n")
|
||||
}
|
||||
|
||||
func processMessage(slack *Slack, m *Message) {
|
||||
@ -50,53 +85,88 @@ func processMessage(slack *Slack, m *Message) {
|
||||
// TODO: Handle reaction_removed messages
|
||||
|
||||
if m.Type == "message" {
|
||||
for _, proc := range statProcessors {
|
||||
proc.ProcessMessage(m)
|
||||
}
|
||||
for _, proc := range messageProcessors {
|
||||
if isAdmin(m.User) {
|
||||
proc.ProcessAdminMessage(slack, m)
|
||||
}
|
||||
proc.ProcessMessage(slack, m)
|
||||
}
|
||||
|
||||
// Check if we know who the user is
|
||||
usr, err := getUserInfo(m.User)
|
||||
// If the user information hasn't been updated in the past day, update it.
|
||||
// If the user information hasn't been updated in the last day, update it.
|
||||
if err != nil || usr.LastUpdated.IsZero() || time.Since(usr.LastUpdated) > (time.Hour*24) {
|
||||
if u, ue := slack.getUserInfo(m.User); ue == nil {
|
||||
saveUserInfo(u)
|
||||
}
|
||||
}
|
||||
// If 'channel' is defined, save the message to the 'channels' bucket
|
||||
if m.Channel != "" {
|
||||
// Check if we know what the channel is
|
||||
chnl, err := getChannelInfo(m.Channel)
|
||||
// If the channel information hasn't been updated in the past day, update it.
|
||||
// If the channel information hasn't been updated in the last day, update it.
|
||||
if err != nil || chnl.LastUpdated.IsZero() || time.Since(chnl.LastUpdated) > (time.Hour*24) {
|
||||
// Either we don't have this channel, or it's a direct message
|
||||
if c, ce := slack.getChannelInfo(m.Channel); ce != nil {
|
||||
// Invalid channel, save as a direct message
|
||||
saveUserMessage(m.User, m)
|
||||
for _, proc := range statProcessors {
|
||||
proc.ProcessUserMessage(m)
|
||||
}
|
||||
for _, proc := range messageProcessors {
|
||||
if isAdmin(m.User) {
|
||||
proc.ProcessAdminUserMessage(slack, m)
|
||||
}
|
||||
proc.ProcessUserMessage(slack, m)
|
||||
}
|
||||
} else {
|
||||
// Save channel info
|
||||
saveChannelInfo(c)
|
||||
// And save the channel message
|
||||
saveChannelMessage(m.Channel, m)
|
||||
for _, proc := range statProcessors {
|
||||
proc.ProcessChannelMessage(m)
|
||||
}
|
||||
for _, proc := range messageProcessors {
|
||||
proc.ProcessChannelMessage(slack, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// check if we're mentioned
|
||||
/*
|
||||
if m.Type == "message" && strings.HasPrefix(m.Text, "<@"+slack.id+">") {
|
||||
parts := strings.Fields(m.Text)
|
||||
if len(parts) == 3 && parts[1] == "stock" {
|
||||
// looks good, get the quote and reply with the result
|
||||
go func(m Message) {
|
||||
m.Text = getQuote(parts[2])
|
||||
postMessage(ws, m)
|
||||
}(m)
|
||||
// NOTE: the Message object is copied, this is intentional
|
||||
} else {
|
||||
// huh?
|
||||
m.Text = fmt.Sprintf("Beep boop beep, that does not compute\n")
|
||||
postMessage(ws, m)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
func registerMessageProcessor(b messageProcessor) {
|
||||
// Register a Message Processor
|
||||
// Make sure that we haven't already registered it
|
||||
for _, proc := range messageProcessors {
|
||||
if proc.GetName() == b.GetName() {
|
||||
panic(fmt.Errorf("Attempted to Re-register Message Processor %s", b.GetName()))
|
||||
}
|
||||
}
|
||||
messageProcessors = append(messageProcessors, b)
|
||||
}
|
||||
|
||||
func registerStatProcessor(b statProcessor) {
|
||||
// Register a Statistic Processor
|
||||
// First make sure that we don't have any 'key' collisions
|
||||
for _, proc := range statProcessors {
|
||||
for _, testKey := range proc.GetStatKeys() {
|
||||
for _, k := range b.GetStatKeys() {
|
||||
if strings.Replace(testKey, "*", "", -1) == strings.Replace(k, "*", "", -1) {
|
||||
panic(fmt.Errorf("Stat Key Collision (%s=>%s and %s=>%s)",
|
||||
b.GetName(), k,
|
||||
proc.GetName(), testKey,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statProcessors = append(statProcessors, b)
|
||||
}
|
||||
|
||||
func writeToLog(d string) {
|
||||
f, err := os.OpenFile("statbot.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0664)
|
||||
if err != nil {
|
||||
|
261
statbot_model.go
261
statbot_model.go
@ -12,17 +12,25 @@ const databaseFile = programName + ".db"
|
||||
|
||||
var db *bolt.DB
|
||||
|
||||
func openDatabase() error {
|
||||
var err error
|
||||
db, err = bolt.Open(databaseFile, 0600, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var dbOpened bool
|
||||
|
||||
func openDatabase() error {
|
||||
if !dbOpened {
|
||||
var err error
|
||||
db, err = bolt.Open(databaseFile, 0600, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbOpened = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeDatabase() error {
|
||||
if !dbOpened {
|
||||
return nil
|
||||
}
|
||||
dbOpened = false
|
||||
return db.Close()
|
||||
}
|
||||
|
||||
@ -33,7 +41,11 @@ func initDatabase() error {
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
|
||||
// The config bucket holds config info for statbot
|
||||
_, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||
cB, err := tx.CreateBucketIfNotExists([]byte("config"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cB.CreateBucketIfNotExists([]byte("admins"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -57,6 +69,91 @@ func initDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func isAdmin(uid string) bool {
|
||||
var foundUser bool
|
||||
openDatabase()
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
var cB, caB *bolt.Bucket
|
||||
cB = tx.Bucket([]byte("config"))
|
||||
if cB == nil {
|
||||
return fmt.Errorf("Error opening 'config' bucket")
|
||||
}
|
||||
if caB = cB.Bucket([]byte("admins")); caB == nil {
|
||||
return fmt.Errorf("Error opening 'config/admins' bucket")
|
||||
}
|
||||
_, err := bktGetInt(caB, uid)
|
||||
return err
|
||||
})
|
||||
closeDatabase()
|
||||
foundUser = err == nil
|
||||
return foundUser
|
||||
}
|
||||
|
||||
func addAdmin(uid string) error {
|
||||
if !isAdmin(uid) {
|
||||
openDatabase()
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
var err error
|
||||
var cB, caB *bolt.Bucket
|
||||
cB = tx.Bucket([]byte("config"))
|
||||
if cB == nil {
|
||||
return fmt.Errorf("Error opening 'config' bucket")
|
||||
}
|
||||
if caB = cB.Bucket([]byte("admins")); caB == nil {
|
||||
return fmt.Errorf("Error opening 'config/admins' bucket")
|
||||
}
|
||||
err = bktPutInt(caB, uid, 1)
|
||||
return err
|
||||
})
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("User is already an admin")
|
||||
}
|
||||
|
||||
func removeAdmin(uid string) error {
|
||||
if isAdmin(uid) {
|
||||
openDatabase()
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
var err error
|
||||
var cB, caB *bolt.Bucket
|
||||
cB = tx.Bucket([]byte("config"))
|
||||
if cB == nil {
|
||||
return fmt.Errorf("Error opening 'config' bucket")
|
||||
}
|
||||
if caB = cB.Bucket([]byte("admins")); caB == nil {
|
||||
return fmt.Errorf("Error opening 'config/admins' bucket")
|
||||
}
|
||||
// Find the admin level for the user to be removed
|
||||
// (assume it's a normal admin)
|
||||
adminLevel := 1
|
||||
if adminLevel, err = bktGetInt(caB, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
if adminLevel > 0 {
|
||||
return caB.Delete([]byte(uid))
|
||||
}
|
||||
return fmt.Errorf("Unable to remove privileges. User's admin level is too high.")
|
||||
})
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("User is not an admin")
|
||||
}
|
||||
|
||||
// Message We save the message struct into the db,
|
||||
// it's also what we send/receive from slack
|
||||
type Message struct {
|
||||
ID uint64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text"`
|
||||
Ts string `json:"ts"`
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// Channel object
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
@ -174,7 +271,7 @@ func getChannelInfo(chnl string) (*Channel, error) {
|
||||
func saveChannelInfo(chnl *Channel) error {
|
||||
openDatabase()
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("channel"))
|
||||
b := tx.Bucket([]byte("channels"))
|
||||
var err error
|
||||
var chB, chIB *bolt.Bucket
|
||||
if chB, err = b.CreateBucketIfNotExists([]byte(chnl.ID)); err != nil {
|
||||
@ -273,44 +370,6 @@ func saveChannelStat(channel string, key string, val string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func saveChannelMessage(channel string, message *Message) error {
|
||||
openDatabase()
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("channels"))
|
||||
var err error
|
||||
var chB, chMB, msgBkt *bolt.Bucket
|
||||
if chB, err = b.CreateBucketIfNotExists([]byte(channel)); err != nil {
|
||||
return err
|
||||
}
|
||||
if chMB, err = chB.CreateBucketIfNotExists([]byte("messages")); err != nil {
|
||||
return err
|
||||
}
|
||||
idx, _ := chMB.NextSequence()
|
||||
idxKey := []byte(strconv.FormatUint(idx, 10))
|
||||
if msgBkt, err = chMB.CreateBucketIfNotExists(idxKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "type", message.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "user", message.User); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "text", message.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "name", message.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutTime(msgBkt, "time", message.Time); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
|
||||
func incrementChannelStat(channel string, key string) error {
|
||||
openDatabase()
|
||||
strRet, err := getChannelStat(channel, key)
|
||||
@ -353,17 +412,42 @@ func getChannelStat(channel string, key string) (string, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// Message We save the message struct into the db,
|
||||
// it's also what we send/receive from slack
|
||||
type Message struct {
|
||||
ID uint64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text"`
|
||||
Ts string `json:"ts"`
|
||||
Time time.Time
|
||||
func saveChannelMessage(channel string, message *Message) error {
|
||||
openDatabase()
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("channels"))
|
||||
var err error
|
||||
var chB, chMB, msgBkt *bolt.Bucket
|
||||
if chB, err = b.CreateBucketIfNotExists([]byte(channel)); err != nil {
|
||||
return err
|
||||
}
|
||||
if chMB, err = chB.CreateBucketIfNotExists([]byte("messages")); err != nil {
|
||||
return err
|
||||
}
|
||||
idx, _ := chMB.NextSequence()
|
||||
idxKey := []byte(strconv.FormatUint(idx, 10))
|
||||
if msgBkt, err = chMB.CreateBucketIfNotExists(idxKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "type", message.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "user", message.User); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "text", message.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutString(msgBkt, "name", message.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = bktPutTime(msgBkt, "time", message.Time); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
|
||||
// User object
|
||||
@ -554,6 +638,69 @@ func saveUserMessage(user string, message *Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func getUserStat(user string, key string) (int, error) {
|
||||
openDatabase()
|
||||
var ret int
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
var b, uB, uSB *bolt.Bucket
|
||||
var err error
|
||||
|
||||
b = tx.Bucket([]byte("users"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("Unable to open 'users' bucket")
|
||||
}
|
||||
if uB, err = b.CreateBucketIfNotExists([]byte(user)); err == nil {
|
||||
if uSB, err = uB.CreateBucketIfNotExists([]byte("stats")); err == nil {
|
||||
uSB.ForEach(func(k, v []byte) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
ret, err = bktGetInt(uSB, key)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
closeDatabase()
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func saveUserStat(user string, key string, val int) error {
|
||||
openDatabase()
|
||||
err := db.Update(func(tx *bolt.Tx) error {
|
||||
var b, uB, uSB *bolt.Bucket
|
||||
var err error
|
||||
|
||||
b = tx.Bucket([]byte("users"))
|
||||
if uB, err = b.CreateBucketIfNotExists([]byte(user)); err == nil {
|
||||
if uSB, err = uB.CreateBucketIfNotExists([]byte("stats")); err == nil {
|
||||
if err = bktPutInt(uSB, key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
|
||||
func addUserStat(user string, key string, addVal int) error {
|
||||
openDatabase()
|
||||
v, err := getUserStat(user, key)
|
||||
err = saveUserStat(user, key, v+addVal)
|
||||
closeDatabase()
|
||||
return err
|
||||
}
|
||||
|
||||
func incrementUserStat(user string, key string) error {
|
||||
return addUserStat(user, key, 1)
|
||||
}
|
||||
|
||||
func decrementUserStat(user string, key string) error {
|
||||
return addUserStat(user, key, -1)
|
||||
}
|
||||
|
||||
func bktGetBucket(b *bolt.Bucket, key string) (*bolt.Bucket, error) {
|
||||
bkt := b.Bucket([]byte(key))
|
||||
if bkt != nil {
|
||||
|
155
statbotweb.go
Normal file
155
statbotweb.go
Normal file
@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"text/template"
|
||||
|
||||
"github.com/gorilla/context"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
// SiteData is the basic data needed for the site/pages
|
||||
type SiteData struct {
|
||||
Title string
|
||||
SubTitle string
|
||||
Port int
|
||||
SessionName string
|
||||
|
||||
Stylesheets []string
|
||||
Scripts []string
|
||||
|
||||
Flash flashMessage // Quick message at top of page
|
||||
Menu []menuItem // Top-aligned menu items
|
||||
BottomMenu []menuItem // Bottom-aligned menu items
|
||||
|
||||
// Any other template data
|
||||
TemplateData interface{}
|
||||
}
|
||||
|
||||
type flashMessage struct {
|
||||
Message string
|
||||
Status string
|
||||
}
|
||||
|
||||
type menuItem struct {
|
||||
Text string
|
||||
Link string
|
||||
Active bool
|
||||
}
|
||||
|
||||
var site SiteData
|
||||
|
||||
var sessionStore = sessions.NewCookieStore([]byte("gostatbot secret cookie nobody will guess"))
|
||||
var r *mux.Router
|
||||
|
||||
// This is the main function for the web server
|
||||
func statWebMain(slack *Slack) {
|
||||
site.Title = "stat_bot"
|
||||
site.SubTitle = ""
|
||||
site.Port = 3000
|
||||
site.SessionName = "statbot"
|
||||
|
||||
r = mux.NewRouter()
|
||||
r.StrictSlash(true)
|
||||
|
||||
assetHandler := http.FileServer(http.Dir("./assets/"))
|
||||
http.Handle("/assets/", http.StripPrefix("/assets/", assetHandler))
|
||||
r.HandleFunc("/", handleStats)
|
||||
http.Handle("/", r)
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", site.Port), context.ClearHandler(http.DefaultServeMux)))
|
||||
}()
|
||||
}
|
||||
|
||||
func initRequest(w http.ResponseWriter, req *http.Request) {
|
||||
site.SubTitle = ""
|
||||
site.Stylesheets = make([]string, 0, 0)
|
||||
site.Stylesheets = append(site.Stylesheets, "/assets/css/pure-min.css")
|
||||
site.Stylesheets = append(site.Stylesheets, "/assets/css/statbot.css")
|
||||
site.Stylesheets = append(site.Stylesheets, "https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css")
|
||||
|
||||
site.Scripts = make([]string, 0, 0)
|
||||
|
||||
site.Menu = make([]menuItem, 0, 0)
|
||||
site.Menu = append(site.Menu, menuItem{Text: "Stats", Link: "/stats/"})
|
||||
|
||||
site.BottomMenu = make([]menuItem, 0, 0)
|
||||
site.BottomMenu = append(site.BottomMenu, menuItem{Text: "Admin", Link: "/admin/"})
|
||||
}
|
||||
|
||||
func handleStats(w http.ResponseWriter, req *http.Request) {
|
||||
initRequest(w, req)
|
||||
|
||||
setMenuItemActive("Stats")
|
||||
|
||||
showPage("stats.html", site, w)
|
||||
}
|
||||
|
||||
// showPage
|
||||
// Load a template and all of the surrounding templates
|
||||
func showPage(tmplName string, tmplData interface{}, w http.ResponseWriter) error {
|
||||
for _, tmpl := range []string{
|
||||
"htmlheader.html",
|
||||
"menu.html",
|
||||
"header.html",
|
||||
tmplName,
|
||||
"footer.html",
|
||||
"htmlfooter.html",
|
||||
} {
|
||||
if err := outputTemplate(tmpl, tmplData, w); err != nil {
|
||||
writeToLog(fmt.Sprintf("%s\n", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outputTemplate
|
||||
// Spit out a template
|
||||
func outputTemplate(tmplName string, tmplData interface{}, w http.ResponseWriter) error {
|
||||
_, err := os.Stat("templates/" + tmplName)
|
||||
if err == nil {
|
||||
t := template.New(tmplName)
|
||||
t, _ = t.ParseFiles("templates/" + tmplName)
|
||||
return t.Execute(w, tmplData)
|
||||
}
|
||||
return fmt.Errorf("WebServer: Cannot load template (templates/%s): File not found", tmplName)
|
||||
}
|
||||
|
||||
// setMenuItemActive
|
||||
// Sets a menu item to active, all others to inactive
|
||||
func setMenuItemActive(which string) {
|
||||
for i := range site.Menu {
|
||||
if site.Menu[i].Text == which {
|
||||
site.Menu[i].Active = true
|
||||
} else {
|
||||
site.Menu[i].Active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getSessionStringValue(key string, w http.ResponseWriter, req *http.Request) (string, error) {
|
||||
session, err := sessionStore.Get(req, site.SessionName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
val := session.Values[key]
|
||||
var retVal string
|
||||
var ok bool
|
||||
if retVal, ok = val.(string); !ok {
|
||||
return "", fmt.Errorf("Unable to create string from %s", key)
|
||||
}
|
||||
return retVal, nil
|
||||
}
|
||||
|
||||
func assertError(err error, w http.ResponseWriter) bool {
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
1
templates/footer.html
Normal file
1
templates/footer.html
Normal file
@ -0,0 +1 @@
|
||||
</div>
|
8
templates/header.html
Normal file
8
templates/header.html
Normal file
@ -0,0 +1,8 @@
|
||||
<div class="content">
|
||||
<aside class="center {{ .Flash.Status }}">
|
||||
{{ .Flash.Message }}
|
||||
</aside>
|
||||
<div class="header">
|
||||
<h1>{{.Title}}</h1>
|
||||
<h2>{{.SubTitle}}</h2>
|
||||
</div>
|
7
templates/htmlfooter.html
Normal file
7
templates/htmlfooter.html
Normal file
@ -0,0 +1,7 @@
|
||||
</div>
|
||||
<!-- TODO: Add Dynamic Scripts -->
|
||||
{{ range $i, $v := .Scripts }}
|
||||
<script src="{{ $v }}"></script>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>
|
21
templates/htmlheader.html
Normal file
21
templates/htmlheader.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="">
|
||||
<meta http-equiv="Cache-control" content="No-Cache">
|
||||
|
||||
<title>{{.Title}}</title>
|
||||
|
||||
<!--[if lt IE 9]>
|
||||
<script src="http://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7/html5shiv.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
{{ range $i, $v := .Stylesheets }}
|
||||
<link rel="stylesheet" href="{{ $v }}">
|
||||
{{ end }}
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="layout">
|
24
templates/menu.html
Normal file
24
templates/menu.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!-- Menu Toggle -->
|
||||
<a id="menuLink" class="menu-link" href="#menu">
|
||||
<!-- Hamburger icon -->
|
||||
<span></span>
|
||||
</a>
|
||||
<div id="menu">
|
||||
<div class="pure-menu">
|
||||
<a class="pure-menu-heading" href="/">{{.Title}}</a>
|
||||
<ul class="pure-menu-list">
|
||||
{{ range $i, $v := .Menu }}
|
||||
<li class="pure-menu-item {{ if $v.Active }} pure-menu-selected {{ end }}">
|
||||
<a class="pure-menu-link" href="{{ $v.Link }}">{{ $v.Text }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul class="pure-menu-list menu-list-dropped">
|
||||
{{ range $i, $v := .BottomMenu }}
|
||||
<li class="pure-menu-item {{ if $v.Active }} pure-menu-selected {{ end }}">
|
||||
<a class="pure-menu-link" href="{{ $v.Link }}">{{ $v.Text }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
3
templates/stats.html
Normal file
3
templates/stats.html
Normal file
@ -0,0 +1,3 @@
|
||||
<div>
|
||||
devICT Slack Statistics!
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user