Starting Web Server work

Fixed some issues with stat adding.
This commit is contained in:
Brian Buller 2015-10-29 11:16:25 -05:00
parent 12ae393998
commit d5947f745f
14 changed files with 936 additions and 91 deletions

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

View File

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

View File

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

View File

@ -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
View 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
View File

@ -0,0 +1 @@
</div>

8
templates/header.html Normal file
View 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>

View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
<div>
devICT Slack Statistics!
</div>