An attempt at a new low level keybase interface that prevents each command from re-spawning a new keybase instance on low memory systems.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

266 lines
7.1 KiB

package keybase
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
)
// Options holds... run... options...
type Options struct {
KeybaseLoction string // Optional, but required if keybase is not in your path
HomeDir string // Only use this if you know what you're doing
Username string // Your keybase username
PaperKey string // Your keybase paperkey
EnableTyping bool // Show others a typing notification while the bot is working on a command
BotLiteMode bool // Defaults to true - only disable if you need to.
}
// NewOptions returns a new instance of *Options with sensible defaults
func NewOptions() *Options {
return &Options{
BotLiteMode: true,
}
}
// locateKeybase attempts to find the location of the keybase binary in the following order:
// 1. What the user has specified as the location [user specified]
// 2. Looks up the binary location using exec.LookPath [default]
// 3. Returns "keybase" and hopes its pathed on the users system [fallback]
func (r *Options) locateKeybase() string {
if r.KeybaseLoction != "" {
return r.KeybaseLoction
}
path, err := exec.LookPath("keybase")
if err != nil {
log.Println("INFO: Could not detect keybase in path")
return "keybase"
}
return path
}
// buildBaseCommand adds the homedirectory before the args, when required
func (r *Options) buildBaseCommand(args ...string) []string {
var cmd []string
if r.HomeDir != "" {
cmd = append(cmd, "--home", r.HomeDir)
}
if r.BotLiteMode {
cmd = append(cmd, "--enable-bot-lite-mode")
}
cmd = append(cmd, args...)
return cmd
}
type ChatAPI struct {
opts *Options
Capacity int
Reset int
Gas int
inChan chan string
outChan chan string
errChan chan error
}
type KeybaseAPIResponse struct {
Result struct {
Message string `json:"message"`
ID int `json:"id"`
Ratelimits []struct {
Tank string `json:"tank"`
Capacity int `json:"capacity"`
Reset int `json:"reset"`
Gas int `json:"gas"`
} `json:"ratelimits"`
} `json:"result"`
}
// NewAPI returns a new instance of a ChatAPI after applying the given options
func NewAPI(options *Options) *ChatAPI {
return &ChatAPI{
opts: options,
inChan: make(chan string, 10),
outChan: make(chan string, 10),
errChan: make(chan error, 10),
}
}
// Start starts the message handlers and connects the pipes to the long-running keybase
// api commands
func (a *ChatAPI) Start(ctx context.Context) error {
// setup api commands and start the pipes
kbLoc := a.opts.locateKeybase()
chatAPICmd := a.opts.buildBaseCommand("chat", "api")
chatAPIListenCmd := a.opts.buildBaseCommand("chat", "api-listen")
pipes, startPipes, err := getPipes(ctx, kbLoc, chatAPICmd, chatAPIListenCmd)
if err != nil {
return fmt.Errorf("failed to get pipes: %v", err)
}
a.initPipes(ctx, pipes)
if err = startPipes(); err != nil {
return fmt.Errorf("failed to start pipes: %v", err)
}
return nil
}
// CmdPipe holds the pipes that connect to the long-running keybase api commands
type CmdPipe struct {
Stderr io.ReadCloser
Stdin io.WriteCloser
Stdout io.ReadCloser
}
// Pipes is an interface that holds pointers to the necessary CmdPipes
type Pipes interface {
// ChatAPI returns a pointer to the ChatAPI pipe used to send outgoing requests to the
// keybase chat api
ChatAPI() *CmdPipe
// APIListen returns a pointer to the APIListen pipe used to receive incoming messages
// from the keybase chat api
APIListen() *CmdPipe
}
// defaultPipes satisfies the Pipes interface, and provides standard CmdPipes that should
// be used during normal operation
type defaultPipes struct {
chatAPI *CmdPipe
chatAPIListen *CmdPipe
}
var _ Pipes = &defaultPipes{}
// ChatAPI returns a pointer to the ChatAPI pipe used to send outgoing requests to the
// keybase chat api
func (p *defaultPipes) ChatAPI() *CmdPipe {
return p.chatAPI
}
// APIListen returns a pointer to the APIListen pipe used to receive incoming messages from
// the keybase chat api
func (p *defaultPipes) APIListen() *CmdPipe {
return p.chatAPIListen
}
// getPipes sets up and returns the default pipes
func getPipes(ctx context.Context, kbLoc string, chatAPICmd, chatAPIListenCmd []string) (Pipes, func() error, error) {
var err error
// set up the commands
chatAPI := exec.CommandContext(ctx, kbLoc, chatAPIListenCmd...)
chatAPIListen := exec.CommandContext(ctx, kbLoc, chatAPICmd...)
// create the pipes
emptyStartFunc := func() error { return nil }
chatAPIPipe := CmdPipe{}
if chatAPIPipe.Stderr, err = chatAPI.StderrPipe(); err != nil {
return nil, emptyStartFunc, fmt.Errorf("failed to get pipe: %v", err)
}
if chatAPIPipe.Stdin, err = chatAPI.StdinPipe(); err != nil {
return nil, emptyStartFunc, fmt.Errorf("failed to get pipe: %v", err)
}
if chatAPIPipe.Stdout, err = chatAPI.StdoutPipe(); err != nil {
return nil, emptyStartFunc, fmt.Errorf("failed to get pipe: %v", err)
}
chatAPIListenPipe := CmdPipe{}
if chatAPIListenPipe.Stderr, err = chatAPIListen.StderrPipe(); err != nil {
return nil, emptyStartFunc, fmt.Errorf("failed to get pipe: %v", err)
}
if chatAPIListenPipe.Stdin, err = chatAPIListen.StdinPipe(); err != nil {
return nil, emptyStartFunc, fmt.Errorf("failed to get pipe: %v", err)
}
if chatAPIListenPipe.Stdout, err = chatAPIListen.StdoutPipe(); err != nil {
return nil, emptyStartFunc, fmt.Errorf("failed to get pipe: %v", err)
}
// create an anonymous function to start the commands
startFunc := func() error {
if err := chatAPI.Start(); err != nil {
return fmt.Errorf("failed to start chat api command: %v", err)
}
if err := chatAPIListen.Start(); err != nil {
return fmt.Errorf("failed to start chat api listen command: %v", err)
}
return nil
}
return &defaultPipes{chatAPI: &chatAPIPipe, chatAPIListen: &chatAPIListenPipe}, startFunc, nil
}
// initPipes starts the goroutines that handle getting messages from the apis and sending
// them into the appropriate channels
func (a *ChatAPI) initPipes(ctx context.Context, pipes Pipes) {
var (
keyIn = pipes.ChatAPI().Stdin
keyResp = pipes.ChatAPI().Stdout
keyOut = pipes.APIListen().Stdout
)
// now start read and write goroutines
// reader
go func() {
r := bufio.NewReader(keyOut)
for {
select {
case <-ctx.Done():
return
default:
line, err := r.ReadString('\n')
if err != nil {
a.errChan <- err
} else {
a.outChan <- line
}
}
}
}()
// writer
go func() {
for {
select {
case msg := <-a.inChan:
_, err := keyIn.Write([]byte(msg))
if err != nil {
a.errChan <- err
}
case <-ctx.Done():
return
}
}
}()
// writer responses to fill up gas limits
go func() {
r := bufio.NewReader(keyResp)
for {
select {
case <-ctx.Done():
return
default:
line, err := r.ReadBytes('\n')
if err != nil {
a.errChan <- err
}
var resp *KeybaseAPIResponse
err = json.Unmarshal(line, resp)
if err != nil {
a.errChan <- err
} else {
for _, limit := range resp.Result.Ratelimits {
if limit.Tank == "chat" {
a.Capacity = limit.Capacity
a.Gas = limit.Gas
a.Reset = limit.Reset
}
}
}
}
}
}()
}