diff --git a/args.go b/args.go index baccdfe..6f94f74 100644 --- a/args.go +++ b/args.go @@ -20,6 +20,7 @@ func (b *bot) parseArgs(args []string) error { flags.StringVar(&cliConfig.LogConvIDStr, "log-convid", "", "sets the keybase chat1.ConvIDStr to log debugging to keybase chat.") flags.StringVar(&cliConfig.FeedbackConvIDStr, "feedback-convid", "", "sets the keybase chat1.ConvIDStr to send feedback to.") flags.StringVar(&cliConfig.FeedbackTeamAdvert, "feedback-team-advert", "", "sets the keybase team/channel to advertise feedback. @team#channel") + flags.StringVar(&cliConfig.KVStoreTeam, "kvstore-team", "", "sets the keybase team where kvstore values are stored") if err := flags.Parse(args[1:]); err != nil { return err } @@ -38,6 +39,9 @@ func (b *bot) parseArgs(args []string) error { if cliConfig.FeedbackTeamAdvert != "" { b.config.FeedbackTeamAdvert = cliConfig.FeedbackTeamAdvert } + if cliConfig.KVStoreTeam != "" { + b.config.KVStoreTeam = cliConfig.KVStoreTeam + } } // then print the running options @@ -48,6 +52,9 @@ func (b *bot) parseArgs(args []string) error { if b.config.FeedbackConvIDStr != "" { b.debug("Feedback enabled to %s and advertising %s", b.config.FeedbackConvIDStr, b.config.FeedbackTeamAdvert) } + if b.config.KVStoreTeam != "" { + b.debug("keybase kvstore enabled in @%s", b.config.KVStoreTeam) + } return nil } diff --git a/commands.go b/commands.go index 988cd4f..12c03ab 100644 --- a/commands.go +++ b/commands.go @@ -1,42 +1,211 @@ package main import ( + "bytes" "fmt" - "log" + "net/url" "strings" + "text/tabwriter" "samhofi.us/x/keybase/types/chat1" ) -func (b *bot) setupMeeting(convid chat1.ConvIDStr, sender string, args []string, membersType string) { - b.debug("command recieved in conversation %s", convid) +/* +**** this function is a special case on parameters as it must be called from 2 handlers which +**** get their information from separate types. As a result we're only passing the conversation id. +**** because of this we can't wrap handleWelcome with permissions, not that you'd want to. + */ + +// handleWelcome sends the welcome message to new conversations +func (b *bot) handleWelcome(id chat1.ConvIDStr) { + b.k.SendMessageByConvID(id, "Hello there!! I'm the Jitsi meeting bot, made by @haukened\nI can start Jitsi meetings right here in this chat!\nI can be activated in 2 ways:\n 1. `@jitsibot`\n 2.`!jitsi`\nYou can provide feedback to my humans using:\n 1. `@jitsibot feedback `\n 2. `!jitsibot feedback `\nYou can also join @jitsi_meet to talk about features, enhancements, or talk to live humans! Everyone is welcome!\nI also accept donations to offset hosting costs, just send some XLM to my wallet if you feel like it by typing `+5XLM@jitsibot`\nIf you ever need to see this message again, ask me for help or say hello to me!") +} + +/* +**** all other commands here-below should only accept a single argument of type chat1.MsgSummary +**** in order to be compliant with the permissions wrapper. Anything not should be explicitly notated. + */ + +// handlePayment controls how the bot reacts to wallet payments in chat +func (b *bot) handlePayment(m chat1.MsgSummary) { + // there can be multiple payments on each message, iterate them + for _, payment := range m.Content.Text.Payments { + if strings.Contains(payment.PaymentText, b.k.Username) { + // if the payment is successful put log the payment for wallet closure + if payment.Result.ResultTyp__ == 0 && payment.Result.Error__ == nil { + var replyInfo = botReply{convID: m.ConvID, msgID: m.Id} + b.payments[*payment.Result.Sent__] = replyInfo + b.log("payment recieved %s", payment.PaymentText) + } else { + // if the payment fails, be sad + b.k.ReactByConvID(m.ConvID, m.Id, ":cry:") + } + } + } +} + +// handleMeeting starts a new jitsi meeting +func (b *bot) handleMeeting(m chat1.MsgSummary) { + b.debug("command recieved in conversation %s", m.ConvID) + // check and see if this conversation has a custom URL + opts := ConvOptions{} + err := b.KVStoreGetStruct(m.ConvID, &opts) + if err != nil { + b.debug("unable to get conversation options") + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + b.debug("%+v", opts) + // currently we aren't sending dial-in information, so don't get it just generate the name + // use the simple method meeting, err := newJitsiMeetingSimple() if err != nil { - log.Println(err) - message := fmt.Sprintf("@%s - I'm sorry, i'm not sure what happened... I was unable to set up a new meeting.\nI've written the appropriate logs and notified my humans.", sender) - b.k.SendMessageByConvID(convid, message) + eid := b.logError(err) + message := fmt.Sprintf("@%s - I'm sorry, i'm not sure what happened... I was unable to set up a new meeting.\nI've written the appropriate logs and notified my humans. Please reference Error ID %s", m.Sender.Username, eid) + b.k.SendMessageByConvID(m.ConvID, message) return } - message := fmt.Sprintf("@%s here's your meeting: %s", sender, meeting.getURL()) - b.k.SendMessageByConvID(convid, message) + // then set the Custom server URL, if it exists + if opts.ConvID == string(m.ConvID) && opts.CustomURL != "" { + meeting.CustomServer = opts.CustomURL + } + b.k.SendMessageByConvID(m.ConvID, "@%s here's your meeting: %s", m.Sender.Username, meeting.getURL()) } -func (b *bot) sendFeedback(convid chat1.ConvIDStr, mesgID chat1.MessageID, sender string, args []string) { - b.debug("feedback recieved in %s", convid) +// handleFeedback sends feedback to a keybase chat, if configured +func (b *bot) handleFeedback(m chat1.MsgSummary) { + b.log("feedback recieved in %s", m.ConvID) if b.config.FeedbackConvIDStr != "" { - feedback := strings.Join(args, " ") + args := strings.Fields(m.Content.Text.Body) + feedback := strings.Join(args[2:], " ") fcID := chat1.ConvIDStr(b.config.FeedbackConvIDStr) - if _, err := b.k.SendMessageByConvID(fcID, "Feedback from @%s:\n```%s```", sender, feedback); err != nil { - b.k.ReplyByConvID(convid, mesgID, "I'm sorry, I was unable to send your feedback because my benevolent overlords have not set a destination for feedback. :sad:") - log.Printf("Unable to send feedback: %s", err) + if _, err := b.k.SendMessageByConvID(fcID, "Feedback from @%s:\n```%s```", m.Sender.Username, feedback); err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) } else { - b.k.ReplyByConvID(convid, mesgID, "Thanks! Your feedback has been sent to my human overlords!") + b.k.ReplyByConvID(m.ConvID, m.Id, "Thanks! Your feedback has been sent to my human overlords!") } } else { - b.debug("feedback not enabled. set --feedback-convid or BOT_FEEDBACK_CONVID") + b.k.ReplyByConvID(m.ConvID, m.Id, "I'm sorry, I was unable to send your feedback because my benevolent overlords have not set a destination for feedback. :sob:") + b.log("user tried to send feedback, but feedback is not enabled. set --feedback-convid or BOT_FEEDBACK_CONVID") + } +} + +// handleConfigCommand dispatches config calls +func (b *bot) handleConfigCommand(m chat1.MsgSummary) { + args := strings.Fields(strings.ToLower(m.Content.Text.Body)) + if args[1] != "config" { + return + } + if len(args) >= 3 { + switch args[2] { + case "set": + b.handleConfigSet(m) + return + case "list": + b.handleConfigList(m) + return + case "help": + b.handleConfigHelp(m) + } } } -func (b *bot) sendWelcome(convid chat1.ConvIDStr) { - b.k.SendMessageByConvID(convid, "Hello there!! I'm the Jitsi meeting bot, made by @haukened\nI can start Jitsi meetings right here in this chat!\nI can be activated in 2 ways:\n 1. `@jitsibot`\n 2.`!jitsi`\nYou can provide feedback to my humans using:\n 1. `@jitsibot feedback `\n 2. `!jitsibot feedback `\nYou can also join @jitsi_meet to talk about features, enhancements, or talk to live humans! Everyone is welcome!\nI also accept donations to offset hosting costs, just send some XLM to my wallet if you feel like it by typing `+5XLM@jitsibot`\nIf you ever need to see this message again, ask me for help or say hello to me!") +// handleConfigSet processes all settings SET calls +// this should be called from b.handleConfigCommand() +func (b *bot) handleConfigSet(m chat1.MsgSummary) { + // first normalize the text and extract the arguments + args := strings.Fields(strings.ToLower(m.Content.Text.Body)) + if args[2] != "set" { + return + } + b.debug("config set called by @%s in %s", m.Sender.Username, m.ConvID) + switch len(args) { + case 5: + if args[3] == "url" { + // first validate the URL + u, err := url.ParseRequestURI(args[4]) + if err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + // then make sure its HTTPS + if u.Scheme != "https" { + b.k.ReactByConvID(m.ConvID, m.Id, "ERROR: HTTPS Required") + return + } + // then get the current options + var opts ConvOptions + err = b.KVStoreGetStruct(m.ConvID, &opts) + if err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + // then update the struct using only the scheme and hostname:port + if u.Port() != "" { + opts.CustomURL = fmt.Sprintf("%s://%s:%s", u.Scheme, u.Hostname(), u.Port()) + } else { + opts.CustomURL = fmt.Sprintf("%s://%s", u.Scheme, u.Hostname()) + } + // ensure that the struct has convid filled out (if its new it won't) + if opts.ConvID == "" { + opts.ConvID = string(m.ConvID) + } + // then write that back to kvstore, with revision + err = b.KVStorePutStruct(m.ConvID, opts) + if err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + b.k.ReactByConvID(m.ConvID, m.Id, "OK!") + return + } + default: + return + } +} + +// handleConfigList lists settings for the conversation +// this should be called from b.handleConfigCommand() +func (b *bot) handleConfigList(m chat1.MsgSummary) { + // first normalize the text and extract the arguments + args := strings.Fields(strings.ToLower(m.Content.Text.Body)) + if args[2] != "list" { + return + } + // get the ConvOptions + var opts ConvOptions + err := b.KVStoreGetStruct(m.ConvID, &opts) + if err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + // then reflect the struct to a list + configOpts := structToSlice(opts) + // Then iterate those through a tabWriter + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "Config Options for this channel:\n```") + for _, opt := range configOpts { + fmt.Fprintf(w, "%s\t%v\t\n", opt.Name, opt.Value) + } + fmt.Fprintln(w, "```") + w.Flush() + b.k.ReplyByConvID(m.ConvID, m.Id, buf.String()) +} + +// handleConfigHelp shows config help +// this should be called from b.handleConfigCommand() +func (b *bot) handleConfigHelp(m chat1.MsgSummary) { + // first normalize the text and extract the arguments + args := strings.Fields(strings.ToLower(m.Content.Text.Body)) + if args[2] != "help" { + return + } + b.debug("config help called by @%s in %s", m.Sender.Username, m.ConvID) } diff --git a/go.mod b/go.mod index add3dc6..4185378 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,13 @@ go 1.13 require ( github.com/caarlos0/env v3.5.0+incompatible + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/sethvargo/go-diceware v0.2.0 github.com/stretchr/testify v1.5.1 // indirect + github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf + github.com/ugorji/go/codec v1.1.7 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v2 v2.2.4 // indirect - samhofi.us/x/keybase v0.0.0-20200312153536-07f5168a6a29 + samhofi.us/x/keybase v0.0.0-20200315012740-74fb4a152b35 ) diff --git a/go.sum b/go.sum index 57f6907..e9ad379 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,13 @@ github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yi github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs= -github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= +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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sethvargo/go-diceware v0.2.0 h1:3QzXGqUe0UR9y1XYSz1dxGS+fKtXOxRqqKjy+cG1yTI= @@ -13,10 +16,18 @@ github.com/sethvargo/go-diceware v0.2.0/go.mod h1:II+37A5sTGAtg3zd/JqyVQ8qqAjSm/ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= +github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -samhofi.us/x/keybase v0.0.0-20200312153536-07f5168a6a29 h1:+yj8+O6C56QM8WGZYtDwBFxkdPT4UgVU+O9W+6N85kk= -samhofi.us/x/keybase v0.0.0-20200312153536-07f5168a6a29/go.mod h1:fcva80IUFyWcHtV4bBSzgKg07K6Rvuvi3GtGCLNGkyE= +samhofi.us/x/keybase v0.0.0-20200315012740-74fb4a152b35 h1:pnpx+34wna1ML9JxHEChf3lDSBOOEh91B5IDuNFUZYk= +samhofi.us/x/keybase v0.0.0-20200315012740-74fb4a152b35/go.mod h1:fcva80IUFyWcHtV4bBSzgKg07K6Rvuvi3GtGCLNGkyE= diff --git a/handlers.go b/handlers.go index 303cf91..b159955 100644 --- a/handlers.go +++ b/handlers.go @@ -32,53 +32,48 @@ func (b *bot) chatHandler(m chat1.MsgSummary) { } // if this chat message is a payment, add it to the bot payments if m.Content.Text.Payments != nil { - // there can be multiple payments on each message, iterate them - for _, payment := range m.Content.Text.Payments { - if strings.Contains(payment.PaymentText, b.k.Username) { - // if the payment is successful put log the payment for wallet closure - if payment.Result.ResultTyp__ == 0 && payment.Result.Error__ == nil { - var replyInfo = botReply{convID: m.ConvID, msgID: m.Id} - b.payments[*payment.Result.Sent__] = replyInfo - } else { - // if the payment fails, be sad - b.k.ReactByConvID(m.ConvID, m.Id, ":cry:") - } - } - } + b.handlePayment(m) + return } - // Determine first if this is a command + + // if its not a payment evaluate if this is a command at all if strings.HasPrefix(m.Content.Text.Body, "!") || strings.HasPrefix(m.Content.Text.Body, "@") { - // determine the root command - words := strings.Fields(m.Content.Text.Body) - command := strings.Replace(words[0], "@", "", 1) - command = strings.Replace(command, "!", "", 1) - command = strings.ToLower(command) - // create the args - args := words[1:] - nargs := len(args) - switch command { - case b.k.Username: - fallthrough - case "jitsi": - if nargs == 0 { - b.setupMeeting(m.ConvID, m.Sender.Username, args, m.Channel.MembersType) - } else if nargs >= 1 { - // pop the subcommand off the front of the list - subcommand, args := args[0], args[1:] - switch subcommand { - case "meet": - b.setupMeeting(m.ConvID, m.Sender.Username, args, m.Channel.MembersType) - case "feedback": - b.sendFeedback(m.ConvID, m.Id, m.Sender.Username, args) - case "hello": - fallthrough - case "help": - b.sendWelcome(m.ConvID) - default: - return - } - } - default: + // first return if its not a command for me + if !strings.Contains(m.Content.Text.Body, b.cmd()) && !strings.Contains(m.Content.Text.Body, b.k.Username) { + return + } + // then check if this is the root command + if isRootCommand(m.Content.Text.Body, b.cmd(), b.k.Username) { + b.checkPermissionAndExecute("reader", m, b.handleMeeting) + return + } + + // then check help and welcome (non-permissions) + // help + if hasCommandPrefix(m.Content.Text.Body, b.cmd(), b.k.Username, "help") { + b.handleWelcome(m.ConvID) + return + } + // hello + if hasCommandPrefix(m.Content.Text.Body, b.cmd(), b.k.Username, "hello") { + b.handleWelcome(m.ConvID) + return + } + + // then check sub-command variants (permissions) + // meet + if hasCommandPrefix(m.Content.Text.Body, b.cmd(), b.k.Username, "meet") { + b.checkPermissionAndExecute("reader", m, b.handleMeeting) + return + } + // feedback + if hasCommandPrefix(m.Content.Text.Body, b.cmd(), b.k.Username, "feedback") { + b.checkPermissionAndExecute("reader", m, b.handleFeedback) + return + } + // config commands + if hasCommandPrefix(m.Content.Text.Body, b.cmd(), b.k.Username, "config") { + b.checkPermissionAndExecute("admin", m, b.handleConfigCommand) return } } @@ -95,7 +90,7 @@ func (b *bot) convHandler(m chat1.ConvSummary) { default: b.debug("New convID found %s, sending welcome message.", m.Id) } - b.sendWelcome(m.Id) + b.handleWelcome(m.Id) } // this handles wallet events, like when someone send you money in chat diff --git a/jitsi.go b/jitsi.go index 4524b91..d4ee545 100644 --- a/jitsi.go +++ b/jitsi.go @@ -19,9 +19,10 @@ type phoneNumber struct { } type jitsiMeeting struct { - Name string - ID string - Phone []phoneNumber + Name string + CustomServer string + ID string + Phone []phoneNumber } func (j *jitsiMeeting) getJitsiName() error { @@ -34,7 +35,10 @@ func (j *jitsiMeeting) getJitsiName() error { } func (j *jitsiMeeting) getURL() string { - return fmt.Sprintf("https://meet.jit.si/%s", j.Name) + if j.CustomServer == "" { + return fmt.Sprintf("https://meet.jit.si/%s", j.Name) + } + return fmt.Sprintf("%s/%s", j.CustomServer, j.Name) } func (j *jitsiMeeting) getPIN() string { diff --git a/kvstore.go b/kvstore.go new file mode 100644 index 0000000..d4d3569 --- /dev/null +++ b/kvstore.go @@ -0,0 +1,91 @@ +package main + +import ( + "reflect" + + "github.com/ugorji/go/codec" + "samhofi.us/x/keybase/types/chat1" +) + +// KvStorePutStruct marshals an interface to JSON and sends to kvstore +func (b *bot) KVStorePutStruct(convIDstr chat1.ConvIDStr, v interface{}) error { + // marshal the struct to JSON + kvstoreDataString, err := encodeStructToJSONString(v) + if err != nil { + return err + } + // put the string in kvstore + err = b.KVStorePut(string(convIDstr), getTypeName(v), kvstoreDataString) + if err != nil { + return err + } + return nil +} + +// KVStoreGetStruct gets a string from kvstore and unmarshals the JSON to a struct +func (b *bot) KVStoreGetStruct(convIDstr chat1.ConvIDStr, v interface{}) error { + // get the string from kvstore + result, err := b.KVStoreGet(string(convIDstr), getTypeName(v)) + if err != nil { + return err + } + // if there was no result just return and the struct is unmodified + if result == "" { + return nil + } + // unmarshal the string into JSON + err = decodeJSONStringToStruct(v, result) + if err != nil { + return err + } + return nil +} + +// KVStorePut puts a string into kvstore given a key and namespace +func (b *bot) KVStorePut(namespace string, key string, value string) error { + _, err := b.k.KVPut(&b.config.KVStoreTeam, namespace, key, value) + if err != nil { + return err + } + return nil +} + +// KVStoreGet gets a string from kvstore given a key and namespace +func (b *bot) KVStoreGet(namespace string, key string) (string, error) { + kvResult, err := b.k.KVGet(&b.config.KVStoreTeam, namespace, key) + if err != nil { + return "", err + } + return kvResult.EntryValue, nil +} + +// getTypeName returns the name of a type, regardless of if its a pointer or not +func getTypeName(v interface{}) string { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + return t.Elem().Name() + } + return t.Name() +} + +func encodeStructToJSONString(v interface{}) (string, error) { + jh := codecHandle() + var bytes []byte + err := codec.NewEncoderBytes(&bytes, jh).Encode(v) + if err != nil { + return "", err + } + result := string(bytes) + return result, nil +} + +func decodeJSONStringToStruct(v interface{}, src string) error { + bytes := []byte(src) + jh := codecHandle() + return codec.NewDecoderBytes(bytes, jh).Decode(v) +} + +func codecHandle() *codec.JsonHandle { + var jh codec.JsonHandle + return &jh +} diff --git a/main.go b/main.go index d7c7b6f..260f6f8 100644 --- a/main.go +++ b/main.go @@ -5,14 +5,12 @@ import ( "log" "os" + "github.com/teris-io/shortid" "samhofi.us/x/keybase" "samhofi.us/x/keybase/types/chat1" "samhofi.us/x/keybase/types/stellar1" ) -// this global controls debug printing -var debug bool - // Bot holds the necessary information for the bot to work. type bot struct { k *keybase.Keybase @@ -22,6 +20,11 @@ type bot struct { config botConfig } +// this allows you to change the command the bot listens to (in additon to its username) +func (b *bot) cmd() string { + return "jitsi" +} + // botConfig hold env and cli flags and options // fields must be exported for package env (reflect) to work type botConfig struct { @@ -29,36 +32,37 @@ type botConfig struct { LogConvIDStr string `env:"BOT_LOG_CONVID" envDefault:""` FeedbackConvIDStr string `env:"BOT_FEEDBACK_CONVID" envDefault:""` FeedbackTeamAdvert string `env:"BOT_FEEDBACK_TEAM_ADVERT" envDefault:""` + KVStoreTeam string `env:"BOT_KVSTORE_TEAM" envDefault:""` } -// hold reply information when needed -type botReply struct { - convID chat1.ConvIDStr - msgID chat1.MessageID -} - -// Debug provides printing only when --debug flag is set or BOT_DEBUG env var is set +// debug provides printing only when --debug flag is set or BOT_DEBUG env var is set func (b *bot) debug(s string, a ...interface{}) { if b.config.Debug { - log.Printf(s, a...) - if b.config.LogConvIDStr != "" { - b.logToChat(s, a...) - } + b.log(s, a...) } } -// logToChat will send this message to the keybase chat configured in b.logConv -func (b *bot) logToChat(s string, a ...interface{}) { - // if the ConvIdStr isn't blank try to log +// logError generates an error id and returns it for error reporting, and writes the error to logging locations +func (b *bot) logError(err error) string { + // generate the error id + eid := shortid.MustGenerate() + // send the error to the log + b.log("`%s` - %s", eid, err) + // then return the error id for use + return eid +} + +// logToChat will send this message to the keybase chat configured in b.config.LogConvIDStr +func (b *bot) log(s string, a ...interface{}) { + // if the ConvIdStr isn't blank try to log to chat if b.config.LogConvIDStr != "" { // if you can't send the message, log the error to stdout if _, err := b.k.SendMessageByConvID(chat1.ConvIDStr(b.config.LogConvIDStr), s, a...); err != nil { log.Printf("Unable to log to keybase chat: %s", err) } - } else { - // otherwise (and you shouldn't be here but....) log it to stdout - log.Println("Unable to log to keybase chat, logging ConvIDStr is not set") } + // and then log it to stdout + log.Printf(s, a...) } // newBot returns a new empty bot diff --git a/permissions.go b/permissions.go new file mode 100644 index 0000000..8368a7c --- /dev/null +++ b/permissions.go @@ -0,0 +1,106 @@ +package main + +import ( + "strings" + + "samhofi.us/x/keybase/types/chat1" +) + +// checkPermissionAndExecute will check the minimum required role for the permission and execute the handler function if allowed +func (b *bot) checkPermissionAndExecute(requiredRole string, m chat1.MsgSummary, f func(chat1.MsgSummary)) { + // get the members of the conversation + b.debug("Executing permissions check") + // currently this doesn't work due to a keybase bug unless you're in the role of resticted bot + // the workaround is to check the general channel the old way + // so first check the new way + conversation, err := b.k.ListMembersOfConversation(m.ConvID) + if err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + // **** + // check if the length of the lists are zero + // it'll look like this: + // {"owners":[],"admins":[],"writers":[],"readers":[],"bots":[],"restrictedBots":[]} + if len(conversation.Members.Owners) == 0 && + len(conversation.Members.Admins) == 0 && + len(conversation.Members.Writers) == 0 && + len(conversation.Members.Readers) == 0 && + len(conversation.Members.Bots) == 0 && + len(conversation.Members.RestrictedBots) == 0 { + channel := chat1.ChatChannel{ + Name: m.Channel.Name, + MembersType: m.Channel.MembersType, + TopicName: "general", + } + // re-map the members using the workaround, in case you're not in the restricted bot role + conversation, err = b.k.ListMembersOfChannel(channel) + if err != nil { + eid := b.logError(err) + b.k.ReactByConvID(m.ConvID, m.Id, "Error ID %s", eid) + return + } + } + /// **** + + // create a map of valid roles, according to @dxb struc + memberTypes := make(map[string]struct{}) + memberTypes["owner"] = struct{}{} + memberTypes["admin"] = struct{}{} + memberTypes["writer"] = struct{}{} + memberTypes["reader"] = struct{}{} + + // if the role is not in the map, its an invalid role + if _, ok := memberTypes[strings.ToLower(requiredRole)]; !ok { + // the role passed was not valid, so bail + b.log("ERROR: %s is not a valid permissions level", requiredRole) + return + } + + // then descend permissions from top down + for _, member := range conversation.Members.Owners { + if strings.ToLower(member.Username) == strings.ToLower(m.Sender.Username) { + f(m) + return + } + b.debug("no") + } + // if the required role was owner, return and don't evaluate the rest + if strings.ToLower(requiredRole) == "owner" { + b.debug("user does not have required permission of: owner") + return + } + // admins + for _, member := range conversation.Members.Admins { + if strings.ToLower(member.Username) == strings.ToLower(m.Sender.Username) { + f(m) + return + } + } + if strings.ToLower(requiredRole) == "admin" { + b.debug("user does not have required permission of: admin") + return + } + // writers + for _, member := range conversation.Members.Writers { + if strings.ToLower(member.Username) == strings.ToLower(m.Sender.Username) { + f(m) + return + } + } + if strings.ToLower(requiredRole) == "writer" { + b.debug("user does not have required permission of: writer") + return + } + // readers + for _, member := range conversation.Members.Readers { + if strings.ToLower(member.Username) == strings.ToLower(m.Sender.Username) { + f(m) + return + } + } + // just return - restricted bots shouldn't be able to run commands + b.debug("user does not have required permission of: reader") + return +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..d8c9a82 --- /dev/null +++ b/types.go @@ -0,0 +1,22 @@ +package main + +import "samhofi.us/x/keybase/types/chat1" + +// hold reply information when needed +type botReply struct { + convID chat1.ConvIDStr + msgID chat1.MessageID +} + +// ConvOptions stores team specific options like custom servers +type ConvOptions struct { + ConvID string `json:"converation_id,omitempty"` + //NotificationsEnabled bool `json:"notifications_enabled,omitempty"` + CustomURL string `json:"custom_url,omitempty"` +} + +// reflectStruct holds information about reflected structs! +type reflectStruct struct { + Name string + Value interface{} +} diff --git a/utils.go b/utils.go index 8f3c80e..6dc02dd 100644 --- a/utils.go +++ b/utils.go @@ -3,6 +3,8 @@ package main import ( "encoding/json" "fmt" + "reflect" + "strings" "samhofi.us/x/keybase/types/chat1" ) @@ -13,6 +15,7 @@ func p(b interface{}) string { return string(s) } +// getFeedbackExtendedDescription returns the team name that feedback will be posted to, if configured func getFeedbackExtendedDescription(bc botConfig) *chat1.UserBotExtendedDescription { if bc.FeedbackTeamAdvert != "" { return &chat1.UserBotExtendedDescription{ @@ -27,3 +30,43 @@ func getFeedbackExtendedDescription(bc botConfig) *chat1.UserBotExtendedDescript MobileBody: "Please note: Your feedback will be public!", } } + +// hasCommandPrefix determines if the command matches either command or name variant +func hasCommandPrefix(s string, baseCommand string, botName string, subCommands string) bool { + // if this is actually a command + if strings.HasPrefix(s, "!") || strings.HasPrefix(s, "@") { + // generate the two possible command variants + botCommand := fmt.Sprintf("%s %s", baseCommand, subCommands) + nameCommand := fmt.Sprintf("%s %s", botName, subCommands) + // then remove the ! or @ from the string + s = strings.Replace(s, "!", "", 1) + s = strings.Replace(s, "@", "", 1) + // then check if either command variant is a match to the subCommands sent + if strings.HasPrefix(s, botCommand) || strings.HasPrefix(s, nameCommand) { + return true + } + } + return false +} + +// isRootCommand determines if the command is the root command or name with no arguments +func isRootCommand(s string, baseCommand string, botName string) bool { + // the space after is important because keybase autocompletes ! and @ with a space after + botCommand := fmt.Sprintf("!%s ", baseCommand) + nameCommand := fmt.Sprintf("@%s ", botName) + if s == botCommand || s == nameCommand { + return true + } + return false +} + +// this converts structs to slices of (Name, Value) pairs +func structToSlice(v interface{}) []reflectStruct { + x := reflect.ValueOf(v) + values := make([]reflectStruct, x.NumField()) + for i := 0; i < x.NumField(); i++ { + values[i].Value = x.Field(i).Interface() + values[i].Name = x.Type().Field(i).Name + } + return values +}