From 71955bb43c5515823bc9192278f1ad40874a3bd6 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 6 Apr 2020 13:31:49 -0400 Subject: [PATCH] Version 2.0.0-alpha1 --- v2/chat.go | 556 +++++++++++++++++++++++++++++ v2/docs.go | 66 ++++ v2/docs_test.go | 20 ++ v2/go.mod | 3 + v2/go.sum | 0 v2/keybase.go | 160 +++++++++ v2/kvstore.go | 137 +++++++ v2/team.go | 189 ++++++++++ v2/types.go | 926 ++++++++++++++++++++++++++++++++++++++++++++++++ v2/wallet.go | 111 ++++++ 10 files changed, 2168 insertions(+) create mode 100644 v2/chat.go create mode 100644 v2/docs.go create mode 100644 v2/docs_test.go create mode 100644 v2/go.mod create mode 100644 v2/go.sum create mode 100644 v2/keybase.go create mode 100644 v2/kvstore.go create mode 100644 v2/team.go create mode 100644 v2/types.go create mode 100644 v2/wallet.go diff --git a/v2/chat.go b/v2/chat.go new file mode 100644 index 0000000..bf8a428 --- /dev/null +++ b/v2/chat.go @@ -0,0 +1,556 @@ +package keybase + +import ( + "bufio" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "os/exec" + "strings" + "time" +) + +// Returns a string representation of a message id suitable for use in a +// pagination struct +func getID(id uint) string { + var b []byte + switch { + case id < 128: + // 7-bit int + b = make([]byte, 1) + b = []byte{byte(id)} + + case id <= 255: + // uint8 + b = make([]byte, 2) + b = []byte{204, byte(id)} + + case id > 255 && id <= 65535: + // uint16 + b = make([]byte, 2) + binary.BigEndian.PutUint16(b, uint16(id)) + b = append([]byte{205}, b...) + + case id > 65535 && id <= 4294967295: + // uint32 + b = make([]byte, 4) + binary.BigEndian.PutUint32(b, uint32(id)) + b = append([]byte{206}, b...) + } + return base64.StdEncoding.EncodeToString(b) +} + +// Creates a string of a json-encoded channel to pass to keybase chat api-listen --filter-channel +func createFilterString(channel Channel) string { + if channel.Name == "" { + return "" + } + jsonBytes, _ := json.Marshal(channel) + return string(jsonBytes) +} + +// Creates a string of json-encoded channels to pass to keybase chat api-listen --filter-channels +func createFiltersString(channels []Channel) string { + if len(channels) == 0 { + return "" + } + jsonBytes, _ := json.Marshal(channels) + return string(jsonBytes) +} + +// Run `keybase chat api-listen` to get new messages coming into keybase and send them into the channel +func getNewMessages(k *Keybase, c chan<- ChatAPI, execOptions []string) { + execString := []string{"chat", "api-listen"} + if len(execOptions) > 0 { + execString = append(execString, execOptions...) + } + for { + execCmd := exec.Command(k.Path, execString...) + stdOut, _ := execCmd.StdoutPipe() + execCmd.Start() + scanner := bufio.NewScanner(stdOut) + go func(scanner *bufio.Scanner, c chan<- ChatAPI) { + for scanner.Scan() { + var jsonData ChatAPI + json.Unmarshal([]byte(scanner.Text()), &jsonData) + if jsonData.ErrorRaw != nil { + var errorListen = string(*jsonData.ErrorRaw) + jsonData.ErrorListen = &errorListen + } + c <- jsonData + } + }(scanner, c) + execCmd.Wait() + } +} + +// Run runs `keybase chat api-listen`, and passes incoming messages to the message handler func +func (k *Keybase) Run(handler func(ChatAPI), options ...RunOptions) { + var heartbeatFreq int64 + var channelCapacity = 100 + + runOptions := make([]string, 0) + if len(options) > 0 { + if options[0].Capacity > 0 { + channelCapacity = options[0].Capacity + } + if options[0].Heartbeat > 0 { + heartbeatFreq = options[0].Heartbeat + } + if options[0].Local { + runOptions = append(runOptions, "--local") + } + if options[0].HideExploding { + runOptions = append(runOptions, "--hide-exploding") + } + if options[0].Dev { + runOptions = append(runOptions, "--dev") + } + if len(options[0].FilterChannels) > 0 { + runOptions = append(runOptions, "--filter-channels") + runOptions = append(runOptions, createFiltersString(options[0].FilterChannels)) + + } + if options[0].FilterChannel.Name != "" { + runOptions = append(runOptions, "--filter-channel") + runOptions = append(runOptions, createFilterString(options[0].FilterChannel)) + } + } + c := make(chan ChatAPI, channelCapacity) + defer close(c) + if heartbeatFreq > 0 { + go heartbeat(c, time.Duration(heartbeatFreq)*time.Minute) + } + go getNewMessages(k, c, runOptions) + for { + go handler(<-c) + } +} + +// heartbeat sends a message through the channel with a message type of `heartbeat` +func heartbeat(c chan<- ChatAPI, freq time.Duration) { + m := ChatAPI{ + Type: "heartbeat", + } + count := 0 + for { + time.Sleep(freq) + m.Msg.ID = count + c <- m + count++ + } +} + +// chatAPIOut sends JSON requests to the chat API and returns its response. +func chatAPIOut(k *Keybase, c ChatAPI) (ChatAPI, error) { + jsonBytes, _ := json.Marshal(c) + + cmdOut, err := k.Exec("chat", "api", "-m", string(jsonBytes)) + if err != nil { + return ChatAPI{}, err + } + + var r ChatAPI + if err := json.Unmarshal(cmdOut, &r); err != nil { + return ChatAPI{}, err + } + if r.ErrorRaw != nil { + var errorRead Error + json.Unmarshal([]byte(*r.ErrorRaw), &errorRead) + r.ErrorRead = &errorRead + return r, errors.New(r.ErrorRead.Message) + } + + return r, nil +} + +// Send sends a chat message +func (c Chat) Send(message ...string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Message: &mesg{}, + } + + m.Method = "send" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Message.Body = strings.Join(message, " ") + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// SendEphemeral sends an exploding chat message, with specified duration +func (c Chat) SendEphemeral(duration time.Duration, message ...string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Message: &mesg{}, + } + m.Params.Options.ExplodingLifetime.Duration = duration + m.Method = "send" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Message.Body = strings.Join(message, " ") + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Reply sends a reply to a chat message +func (c Chat) Reply(replyTo int, message ...string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Message: &mesg{}, + } + + m.Method = "send" + m.Params.Options.Channel = &c.Channel + m.Params.Options.ReplyTo = replyTo + m.Params.Options.Message.Body = strings.Join(message, " ") + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Edit edits a previously sent chat message +func (c Chat) Edit(messageID int, message ...string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Message: &mesg{}, + } + m.Method = "edit" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Message.Body = strings.Join(message, " ") + m.Params.Options.MessageID = messageID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// React sends a reaction to a message. +func (c Chat) React(messageID int, reaction string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Message: &mesg{}, + } + m.Method = "reaction" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Message.Body = reaction + m.Params.Options.MessageID = messageID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Delete deletes a chat message +func (c Chat) Delete(messageID int) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "delete" + m.Params.Options.Channel = &c.Channel + m.Params.Options.MessageID = messageID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// ChatList returns a list of all conversations. +// You can pass a Channel to use as a filter here, but you'll probably want to +// leave the TopicName empty. +func (k *Keybase) ChatList(opts ...Channel) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + + if len(opts) > 0 { + m.Params.Options.Name = opts[0].Name + m.Params.Options.Public = opts[0].Public + m.Params.Options.MembersType = opts[0].MembersType + m.Params.Options.TopicType = opts[0].TopicType + m.Params.Options.TopicName = opts[0].TopicName + } + m.Method = "list" + + r, err := chatAPIOut(k, m) + return r, err +} + +// ReadMessage fetches the chat message with the specified message id from a conversation. +func (c Chat) ReadMessage(messageID int) (*ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Pagination: &pagination{}, + } + + m.Method = "read" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Pagination.Num = 1 + + m.Params.Options.Pagination.Previous = getID(uint(messageID - 1)) + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return &r, err + } + r.keybase = *c.keybase + return &r, nil +} + +// Read fetches chat messages from a conversation. By default, 10 messages will +// be fetched at a time. However, if count is passed, then that is the number of +// messages that will be fetched. +func (c Chat) Read(count ...int) (*ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Pagination: &pagination{}, + } + + m.Method = "read" + m.Params.Options.Channel = &c.Channel + if len(count) == 0 { + m.Params.Options.Pagination.Num = 10 + } else { + m.Params.Options.Pagination.Num = count[0] + } + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return &r, err + } + r.keybase = *c.keybase + return &r, nil +} + +// Next fetches the next page of chat messages that were fetched with Read. By +// default, Next will fetch the same amount of messages that were originally +// fetched with Read. However, if count is passed, then that is the number of +// messages that will be fetched. +func (c *ChatAPI) Next(count ...int) (*ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Pagination: &pagination{}, + } + + m.Method = "read" + m.Params.Options.Channel = &c.Result.Messages[0].Msg.Channel + if len(count) == 0 { + m.Params.Options.Pagination.Num = c.Result.Pagination.Num + } else { + m.Params.Options.Pagination.Num = count[0] + } + m.Params.Options.Pagination.Next = c.Result.Pagination.Next + + result, err := chatAPIOut(&c.keybase, m) + if err != nil { + return &result, err + } + k := c.keybase + *c = result + c.keybase = k + return c, nil +} + +// Previous fetches the previous page of chat messages that were fetched with Read. +// By default, Previous will fetch the same amount of messages that were +// originally fetched with Read. However, if count is passed, then that is the +// number of messages that will be fetched. +func (c *ChatAPI) Previous(count ...int) (*ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Params.Options = options{ + Pagination: &pagination{}, + } + + m.Method = "read" + m.Params.Options.Channel = &c.Result.Messages[0].Msg.Channel + if len(count) == 0 { + m.Params.Options.Pagination.Num = c.Result.Pagination.Num + } else { + m.Params.Options.Pagination.Num = count[0] + } + m.Params.Options.Pagination.Previous = c.Result.Pagination.Previous + + result, err := chatAPIOut(&c.keybase, m) + if err != nil { + return &result, err + } + k := c.keybase + *c = result + c.keybase = k + return c, nil +} + +// Upload attaches a file to a conversation +// The filepath must be an absolute path +func (c Chat) Upload(title string, filepath string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "attach" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Filename = filepath + m.Params.Options.Title = title + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Download downloads a file from a conversation +func (c Chat) Download(messageID int, filepath string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "download" + m.Params.Options.Channel = &c.Channel + m.Params.Options.Output = filepath + m.Params.Options.MessageID = messageID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// LoadFlip returns the results of a flip +// If the flip is still in progress, this can be expected to change if called again +func (c Chat) LoadFlip(messageID int, conversationID string, flipConversationID string, gameID string) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "loadflip" + m.Params.Options.Channel = &c.Channel + m.Params.Options.MsgID = messageID + m.Params.Options.ConversationID = conversationID + m.Params.Options.FlipConversationID = flipConversationID + m.Params.Options.GameID = gameID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Pin pins a message to a channel +func (c Chat) Pin(messageID int) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "pin" + m.Params.Options.Channel = &c.Channel + m.Params.Options.MessageID = messageID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Unpin clears any pinned messages from a channel +func (c Chat) Unpin() (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "unpin" + m.Params.Options.Channel = &c.Channel + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Mark marks a conversation as read up to a specified message +func (c Chat) Mark(messageID int) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "mark" + m.Params.Options.Channel = &c.Channel + m.Params.Options.MessageID = messageID + + r, err := chatAPIOut(c.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// ClearCommands clears bot advertisements +func (k *Keybase) ClearCommands() (ChatAPI, error) { + m := ChatAPI{} + m.Method = "clearcommands" + + r, err := chatAPIOut(k, m) + if err != nil { + return r, err + } + return r, nil +} + +// AdvertiseCommands sets up bot command advertisements +// This method allows you to set up multiple different types of advertisements at once. +// Use this method if you have commands whose visibility differs from each other. +func (k *Keybase) AdvertiseCommands(advertisements []BotAdvertisement) (ChatAPI, error) { + m := ChatAPI{ + Params: ¶ms{}, + } + m.Method = "advertisecommands" + m.Params.Options.BotAdvertisements = advertisements + + r, err := chatAPIOut(k, m) + if err != nil { + return r, err + } + return r, nil +} + +// AdvertiseCommand sets up bot command advertisements +// This method allows you to set up one type of advertisement. +// Use this method if you have commands whose visibility should all be the same. +func (k *Keybase) AdvertiseCommand(advertisement BotAdvertisement) (ChatAPI, error) { + return k.AdvertiseCommands([]BotAdvertisement{ + advertisement, + }) +} diff --git a/v2/docs.go b/v2/docs.go new file mode 100644 index 0000000..d6cee83 --- /dev/null +++ b/v2/docs.go @@ -0,0 +1,66 @@ +/* +The keybase package implements an interface for interacting with the Keybase Chat, Team, and Wallet APIs + +I've tried to follow Keybase's JSON API as closely as possible, so if you're stuck on anything, or wondering +why things are organized in a certain way, it's most likely due to that. It may be helpful to look at the +Keybase JSON API docs by running some of the following commands in your terminal: + // Chat API + keybase chat api -h + + // Chat Message Stream + keybase chat api-listen -h + + // Team API + keybase team api -h + + // Wallet API + keybase wallet api -h + +The git repo for this code is hosted on Keybase. You can contact me directly (https://keybase.io/dxb), +or join the mkbot team (https://keybase.io/team/mkbot) if you need assistance, or if you'd like to contribute. + +Basic Example + +Here's a quick example of a bot that will attach a reaction with the sender's device name to every message sent +in @mkbot#test1: + + package main + + import ( + "fmt" + + "samhofi.us/x/keybase" + ) + + var k = keybase.NewKeybase() + + func main() { + channel := keybase.Channel{ + Name: "mkbot", + TopicName: "test1", + MembersType: keybase.TEAM, + } + opts := keybase.RunOptions{ + FilterChannel: channel, + } + fmt.Println("Running...") + k.Run(handler, opts) + } + + func handler(m keybase.ChatAPI) { + if m.ErrorListen != nil { + fmt.Printf("Error: %s\n", *m.ErrorListen) + return + } + + msgType := m.Msg.Content.Type + msgID := m.Msg.ID + deviceName := m.Msg.Sender.DeviceName + + if msgType == "text" { + chat := k.NewChat(m.Msg.Channel) + chat.React(msgID, deviceName) + } + } +*/ +package keybase diff --git a/v2/docs_test.go b/v2/docs_test.go new file mode 100644 index 0000000..e92ece6 --- /dev/null +++ b/v2/docs_test.go @@ -0,0 +1,20 @@ +package keybase + +func ExampleKeybase_AdvertiseCommand() { + var k = NewKeybase() + + // Clear out any previously advertised commands + k.ClearCommands() + + // Create BotAdvertisement + c := BotAdvertisement{ + Type: "public", + BotCommands: []BotCommand{ + NewBotCommand("help", "Get help using this bot", "!help "), + NewBotCommand("hello", "Say hello", "!hello"), + }, + } + + // Send advertisement + k.AdvertiseCommand(c) +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..05f8684 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,3 @@ +module samhofi.us/x/keybase + +go 1.13 diff --git a/v2/go.sum b/v2/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/v2/keybase.go b/v2/keybase.go new file mode 100644 index 0000000..95c7b53 --- /dev/null +++ b/v2/keybase.go @@ -0,0 +1,160 @@ +package keybase + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// Possible MemberTypes +const ( + TEAM string = "team" + USER string = "impteamnative" +) + +// Possible TopicTypes +const ( + DEV string = "dev" + CHAT string = "chat" +) + +// NewKeybase returns a new Keybase. Optionally, you can pass a string containing the path to the Keybase executable as the first argument. +func NewKeybase(path ...string) *Keybase { + k := &Keybase{} + if len(path) < 1 { + k.Path = "keybase" + } else { + k.Path = path[0] + } + + s := k.status() + k.Version = k.version() + k.LoggedIn = s.LoggedIn + if k.LoggedIn { + k.Username = s.Username + k.Device = s.Device.Name + } + return k +} + +// NewBotCommand returns a new BotCommand instance +func NewBotCommand(name, description, usage string, extendedDescription ...BotCommandExtendedDescription) BotCommand { + result := BotCommand{ + Name: name, + Description: description, + Usage: usage, + } + + if len(extendedDescription) > 0 { + result.ExtendedDescription = &extendedDescription[0] + } + + return result +} + +// NewBotCommandExtendedDescription +func NewBotCommandExtendedDescription(title, desktopBody, mobileBody string) BotCommandExtendedDescription { + return BotCommandExtendedDescription{ + Title: title, + DesktopBody: desktopBody, + MobileBody: mobileBody, + } +} + +// Exec executes the given Keybase command +func (k *Keybase) Exec(command ...string) ([]byte, error) { + out, err := exec.Command(k.Path, command...).Output() + if err != nil { + return []byte{}, err + } + return out, nil +} + +// NewChat returns a new Chat instance +func (k *Keybase) NewChat(channel Channel) Chat { + return Chat{ + keybase: k, + Channel: channel, + } +} + +// NewTeam returns a new Team instance +func (k *Keybase) NewTeam(name string) Team { + return Team{ + keybase: k, + Name: name, + } +} + +// NewKV returns a new KV instance +func (k *Keybase) NewKV(team string) KV { + return KV{ + keybase: k, + Team: team, + } +} + +// NewWallet returns a new Wallet instance +func (k *Keybase) NewWallet() Wallet { + return Wallet{ + keybase: k, + } +} + +// status returns the results of the `keybase status` command, which includes +// information about the client, and the currently logged-in Keybase user. +func (k *Keybase) status() status { + cmdOut, err := k.Exec("status", "-j") + if err != nil { + return status{} + } + + var s status + json.Unmarshal(cmdOut, &s) + + return s +} + +// version returns the version string of the client. +func (k *Keybase) version() string { + cmdOut, err := k.Exec("version", "-S", "-f", "s") + if err != nil { + return "" + } + + return string(cmdOut) +} + +// UserLookup pulls information about users. +// The following fields are currently returned: basics, profile, proofs_summary, devices -- See https://keybase.io/docs/api/1.0/call/user/lookup for more info. +func (k *Keybase) UserLookup(users ...string) (UserAPI, error) { + var fields = []string{"basics", "profile", "proofs_summary", "devices"} + + cmdOut, err := k.Exec("apicall", "--arg", fmt.Sprintf("usernames=%s", strings.Join(users, ",")), "--arg", fmt.Sprintf("fields=%s", strings.Join(fields, ",")), "user/lookup") + if err != nil { + return UserAPI{}, err + } + + var r UserAPI + if err := json.Unmarshal(cmdOut, &r); err != nil { + return UserAPI{}, err + } + + return r, nil +} + +// UserCard pulls the information that is typically displayed when you open a user's profile. +func (k *Keybase) UserCard(user string) (UserCardAPI, error) { + cmdOut, err := k.Exec("apicall", "--arg", "username="+user, "user/card") + if err != nil { + return UserCardAPI{}, err + } + + var r UserCardAPI + if err := json.Unmarshal(cmdOut, &r); err != nil { + return UserCardAPI{}, err + } + + return r, nil +} diff --git a/v2/kvstore.go b/v2/kvstore.go new file mode 100644 index 0000000..ac91b25 --- /dev/null +++ b/v2/kvstore.go @@ -0,0 +1,137 @@ +package keybase + +import ( + "encoding/json" + "errors" +) + +// kvAPIOut sends a JSON request to the kvstore API and returns its response. +func kvAPIOut(k *Keybase, kv KVAPI) (KVAPI, error) { + jsonBytes, _ := json.Marshal(kv) + + cmdOut, err := k.Exec("kvstore", "api", "-m", string(jsonBytes)) + if err != nil { + return KVAPI{}, err + } + + var r KVAPI + if err := json.Unmarshal(cmdOut, &r); err != nil { + return KVAPI{}, err + } + + if r.Error != nil { + return KVAPI{}, errors.New(r.Error.Message) + } + + return r, nil +} + +// Namespaces returns all namespaces for a team +func (kv KV) Namespaces() (KVAPI, error) { + m := KVAPI{ + Params: &kvParams{}, + } + m.Params.Options = kvOptions{ + Team: kv.Team, + } + + m.Method = "list" + + r, err := kvAPIOut(kv.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Keys returns all non-deleted keys for a namespace +func (kv KV) Keys(namespace string) (KVAPI, error) { + m := KVAPI{ + Params: &kvParams{}, + } + m.Params.Options = kvOptions{ + Team: kv.Team, + Namespace: namespace, + } + + m.Method = "list" + + r, err := kvAPIOut(kv.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Get returns an entry +func (kv KV) Get(namespace string, key string, revision ...uint) (KVAPI, error) { + m := KVAPI{ + Params: &kvParams{}, + } + m.Params.Options = kvOptions{ + Team: kv.Team, + Namespace: namespace, + EntryKey: key, + } + + if len(revision) > 0 { + m.Params.Options.Revision = revision[0] + } + + m.Method = "get" + + r, err := kvAPIOut(kv.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Put adds an entry +func (kv KV) Put(namespace string, key string, value string, revision ...uint) (KVAPI, error) { + m := KVAPI{ + Params: &kvParams{}, + } + m.Params.Options = kvOptions{ + Team: kv.Team, + Namespace: namespace, + EntryKey: key, + EntryValue: value, + } + + if len(revision) > 0 { + m.Params.Options.Revision = revision[0] + } + + m.Method = "put" + + r, err := kvAPIOut(kv.keybase, m) + if err != nil { + return r, err + } + return r, nil +} + +// Delete removes an entry +func (kv KV) Delete(namespace string, key string, revision ...uint) (KVAPI, error) { + m := KVAPI{ + Params: &kvParams{}, + } + m.Params.Options = kvOptions{ + Team: kv.Team, + Namespace: namespace, + EntryKey: key, + } + + if len(revision) > 0 { + m.Params.Options.Revision = revision[0] + } + + m.Method = "del" + + r, err := kvAPIOut(kv.keybase, m) + if err != nil { + return r, err + } + return r, nil +} diff --git a/v2/team.go b/v2/team.go new file mode 100644 index 0000000..a011ace --- /dev/null +++ b/v2/team.go @@ -0,0 +1,189 @@ +package keybase + +import ( + "encoding/json" + "errors" + "fmt" +) + +// teamAPIOut sends JSON requests to the team API and returns its response. +func teamAPIOut(k *Keybase, t TeamAPI) (TeamAPI, error) { + jsonBytes, _ := json.Marshal(t) + + cmdOut, err := k.Exec("team", "api", "-m", string(jsonBytes)) + if err != nil { + return TeamAPI{}, err + } + + var r TeamAPI + if err := json.Unmarshal(cmdOut, &r); err != nil { + return TeamAPI{}, err + } + if r.Error != nil { + return TeamAPI{}, errors.New(r.Error.Message) + } + + return r, nil +} + +// AddUser adds a member to a team by username +func (t Team) AddUser(user, role string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "add-members" + m.Params.Options.Team = t.Name + m.Params.Options.Usernames = []usernames{ + { + Username: user, + Role: role, + }, + } + + r, err := teamAPIOut(t.keybase, m) + if err == nil && r.Error == nil { + r, err = t.MemberList() + } + return r, err +} + +// RemoveUser removes a member from a team +func (t Team) RemoveUser(user string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "remove-member" + m.Params.Options.Team = t.Name + m.Params.Options.Username = user + + r, err := teamAPIOut(t.keybase, m) + return r, err +} + +// AddReaders adds members to a team by username, and sets their roles to Reader +func (t Team) AddReaders(users ...string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "add-members" + m.Params.Options.Team = t.Name + addUsers := []usernames{} + for _, u := range users { + addUsers = append(addUsers, usernames{Username: u, Role: "reader"}) + } + m.Params.Options.Usernames = addUsers + + r, err := teamAPIOut(t.keybase, m) + if err == nil && r.Error == nil { + r, err = t.MemberList() + } + return r, err +} + +// AddWriters adds members to a team by username, and sets their roles to Writer +func (t Team) AddWriters(users ...string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "add-members" + m.Params.Options.Team = t.Name + addUsers := []usernames{} + for _, u := range users { + addUsers = append(addUsers, usernames{Username: u, Role: "writer"}) + } + m.Params.Options.Usernames = addUsers + + r, err := teamAPIOut(t.keybase, m) + if err == nil && r.Error == nil { + r, err = t.MemberList() + } + return r, err +} + +// AddAdmins adds members to a team by username, and sets their roles to Writer +func (t Team) AddAdmins(users ...string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "add-members" + m.Params.Options.Team = t.Name + addUsers := []usernames{} + for _, u := range users { + addUsers = append(addUsers, usernames{Username: u, Role: "admin"}) + } + m.Params.Options.Usernames = addUsers + + r, err := teamAPIOut(t.keybase, m) + if err == nil && r.Error == nil { + r, err = t.MemberList() + } + return r, err +} + +// AddOwners adds members to a team by username, and sets their roles to Writer +func (t Team) AddOwners(users ...string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "add-members" + m.Params.Options.Team = t.Name + addUsers := []usernames{} + for _, u := range users { + addUsers = append(addUsers, usernames{Username: u, Role: "owner"}) + } + m.Params.Options.Usernames = addUsers + + r, err := teamAPIOut(t.keybase, m) + if err == nil && r.Error == nil { + r, err = t.MemberList() + } + return r, err +} + +// MemberList returns a list of a team's members +func (t Team) MemberList() (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "list-team-memberships" + m.Params.Options.Team = t.Name + + r, err := teamAPIOut(t.keybase, m) + return r, err +} + +// CreateSubteam creates a subteam +func (t Team) CreateSubteam(name string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "create-team" + m.Params.Options.Team = fmt.Sprintf("%s.%s", t.Name, name) + + r, err := teamAPIOut(t.keybase, m) + return r, err +} + +// CreateTeam creates a new team +func (k *Keybase) CreateTeam(name string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "create-team" + m.Params.Options.Team = name + + r, err := teamAPIOut(k, m) + return r, err +} + +// ListUserMemberships returns information about a given user's team memberships +func (k *Keybase) ListUserMemberships(user string) (TeamAPI, error) { + m := TeamAPI{ + Params: &tParams{}, + } + m.Method = "list-user-memberships" + m.Params.Options.Username = user + + r, err := teamAPIOut(k, m) + return r, err +} diff --git a/v2/types.go b/v2/types.go new file mode 100644 index 0000000..7a10120 --- /dev/null +++ b/v2/types.go @@ -0,0 +1,926 @@ +package keybase + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// RunOptions holds a set of options to be passed to Run +type RunOptions struct { + Capacity int // Channel capacity for the buffered channel that holds messages. Defaults to 100 if not set + Heartbeat int64 // Send a heartbeat through the channel every X minutes (0 = off) + Local bool // Subscribe to local messages + HideExploding bool // Ignore exploding messages + Dev bool // Subscribe to dev channel messages + Wallet bool // Subscribe to wallet events + FilterChannel Channel // Only subscribe to messages from specified channel + FilterChannels []Channel // Only subscribe to messages from specified channels +} + +// ChatAPI holds information about a message received by the `keybase chat api-listen` command +type ChatAPI struct { + Type string `json:"type,omitempty"` + Source string `json:"source,omitempty"` + Msg *msg `json:"msg,omitempty"` + Method string `json:"method,omitempty"` + Params *params `json:"params,omitempty"` + Message string `json:"message,omitempty"` + ID int `json:"id,omitempty"` + Ratelimits []rateLimits `json:"ratelimits,omitempty"` + Notification *notification `json:"notification,omitempty"` + Result *result `json:"result,omitempty"` + Pagination *pagination `json:"pagination,omitempty"` + ErrorRaw *json.RawMessage `json:"error,omitempty"` // Raw JSON string containing any errors returned + ErrorRead *Error `json:"-"` // Errors returned by any outgoing chat functions such as Read(), Edit(), etc + ErrorListen *string `json:"-"` // Errors returned by the api-listen command (used in the Run() function) + keybase Keybase // Some methods will need this, so I'm passing it but keeping it unexported +} + +type sender struct { + UID string `json:"uid"` + Username string `json:"username"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` +} + +type addedtoteam struct { + Team string `json:"team"` + Adder string `json:"adder"` + Addee string `json:"addee"` + Owners []string `json:"owners"` + Admins []string `json:"admins"` + Writers []string `json:"writers"` + Readers []string `json:"readers"` +} + +type bulkaddtoconv struct { + Usernames []string `json:"usernames"` +} + +type commits struct { + CommitHash string `json:"commitHash"` + Message string `json:"message"` + AuthorName string `json:"authorName"` + AuthorEmail string `json:"authorEmail"` + Ctime int `json:"ctime"` +} + +type refs struct { + RefName string `json:"refName"` + Commits []commits `json:"commits"` + MoreCommitsAvailable bool `json:"moreCommitsAvailable"` + IsDelete bool `json:"isDelete"` +} + +type gitpush struct { + Team string `json:"team"` + Pusher string `json:"pusher"` + RepoName string `json:"repoName"` + RepoID string `json:"repoID"` + Refs []refs `json:"refs"` + PushType int `json:"pushType"` + PreviousRepoName string `json:"previousRepoName"` +} + +type system struct { + SystemType int `json:"systemType"` + Addedtoteam addedtoteam `json:"addedtoteam"` + Bulkaddtoconv bulkaddtoconv `json:"bulkaddtoconv"` + Gitpush gitpush `json:"gitpush"` +} + +type paymentsResult struct { + ResultTyp int `json:"resultTyp"` + Sent string `json:"sent"` +} + +type payments struct { + Username string `json:"username"` + PaymentText string `json:"paymentText"` + Result paymentsResult `json:"result"` +} + +type userMentions struct { + Text string `json:"text"` + UID string `json:"uid"` +} + +type teamMentions struct { + Name string `json:"name"` + Channel string `json:"channel"` +} + +type reaction struct { + M int `json:"m"` + B string `json:"b"` +} + +type delete struct { + MessageIDs []int `json:"messageIDs"` +} + +type edit struct { + MessageID int `json:"messageID"` + Body string `json:"body"` + Payments []payments `json:"payments"` + UserMentions []userMentions `json:"userMentions"` + TeamMentions []teamMentions `json:"teamMentions"` +} + +type text struct { + Body string `json:"body"` + Payments []payments `json:"payments"` + ReplyTo int `json:"replyTo"` + ReplyToUID string `json:"replyToUID"` + UserMentions []userMentions `json:"userMentions"` + TeamMentions []teamMentions `json:"teamMentions"` +} + +type flip struct { + Text string `json:"text"` + GameID string `json:"game_id"` + FlipConvID string `json:"flip_conv_id"` + UserMentions interface{} `json:"user_mentions"` + TeamMentions interface{} `json:"team_mentions"` +} + +type image struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type metadata struct { + AssetType int `json:"assetType"` + Image image `json:"image"` +} + +type preview struct { + Filename string `json:"filename"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Path string `json:"path"` + Size int `json:"size"` + MimeType string `json:"mimeType"` + EncHash string `json:"encHash"` + Key string `json:"key"` + VerifyKey string `json:"verifyKey"` + Title string `json:"title"` + Nonce string `json:"nonce"` + Metadata metadata `json:"metadata"` + Tag int `json:"tag"` +} + +type previews struct { + Filename string `json:"filename"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Path string `json:"path"` + Size int `json:"size"` + MimeType string `json:"mimeType"` + EncHash string `json:"encHash"` + Key string `json:"key"` + VerifyKey string `json:"verifyKey"` + Title string `json:"title"` + Nonce string `json:"nonce"` + Metadata metadata `json:"metadata"` + Tag int `json:"tag"` +} + +type object struct { + Filename string `json:"filename"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Path string `json:"path"` + Size int `json:"size"` + MimeType string `json:"mimeType"` + EncHash string `json:"encHash"` + Key string `json:"key"` + VerifyKey string `json:"verifyKey"` + Title string `json:"title"` + Nonce string `json:"nonce"` + Metadata metadata `json:"metadata"` + Tag int `json:"tag"` +} + +type attachment struct { + Object object `json:"object"` + Preview preview `json:"preview"` + Previews []previews `json:"previews"` + Metadata metadata `json:"metadata"` + Uploaded bool `json:"uploaded"` +} + +type content struct { + Type string `json:"type"` + Attachment attachment `json:"attachment"` + Delete delete `json:"delete"` + Edit edit `json:"edit"` + Reaction reaction `json:"reaction"` + System system `json:"system"` + Text text `json:"text"` + SendPayment SendPayment `json:"send_payment"` + RequestPayment RequestPayment `json:"request_payment"` + Flip flip `json:"flip"` +} + +type msg struct { + ID int `json:"id"` + ConversationID string `json:"conversation_id"` + Channel Channel `json:"channel"` + Sender sender `json:"sender"` + SentAt int `json:"sent_at"` + SentAtMs int64 `json:"sent_at_ms"` + Content content `json:"content"` + Unread bool `json:"unread"` + AtMentionUsernames []string `json:"at_mention_usernames"` + IsEphemeral bool `json:"is_ephemeral"` + Etime int64 `json:"etime"` + HasPairwiseMacs bool `json:"has_pairwise_macs"` + ChannelMention string `json:"channel_mention"` +} + +type summary struct { + ID string `json:"id"` + TxID string `json:"txID"` + Time int64 `json:"time"` + StatusSimplified int `json:"statusSimplified"` + StatusDescription string `json:"statusDescription"` + StatusDetail string `json:"statusDetail"` + ShowCancel bool `json:"showCancel"` + AmountDescription string `json:"amountDescription"` + Delta int `json:"delta"` + Worth string `json:"worth"` + WorthAtSendTime string `json:"worthAtSendTime"` + IssuerDescription string `json:"issuerDescription"` + FromType int `json:"fromType"` + ToType int `json:"toType"` + AssetCode string `json:"assetCode"` + FromAccountID string `json:"fromAccountID"` + FromAccountName string `json:"fromAccountName"` + FromUsername string `json:"fromUsername"` + ToAccountID string `json:"toAccountID"` + ToAccountName string `json:"toAccountName"` + ToUsername string `json:"toUsername"` + ToAssertion string `json:"toAssertion"` + OriginalToAssertion string `json:"originalToAssertion"` + Note string `json:"note"` + NoteErr string `json:"noteErr"` + SourceAmountMax string `json:"sourceAmountMax"` + SourceAmountActual string `json:"sourceAmountActual"` + SourceAsset sourceAsset `json:"sourceAsset"` + SourceConvRate string `json:"sourceConvRate"` + IsAdvanced bool `json:"isAdvanced"` + SummaryAdvanced string `json:"summaryAdvanced"` + Operations interface{} `json:"operations"` + Unread bool `json:"unread"` + BatchID string `json:"batchID"` + FromAirdrop bool `json:"fromAirdrop"` + IsInflation bool `json:"isInflation"` +} + +type details struct { + PublicNote string `json:"publicNote"` + PublicNoteType string `json:"publicNoteType"` + ExternalTxURL string `json:"externalTxURL"` + FeeChargedDescription string `json:"feeChargedDescription"` + PathIntermediate interface{} `json:"pathIntermediate"` +} + +type notification struct { + Summary summary `json:"summary"` + Details details `json:"details"` +} + +// Channel holds information about a conversation +type Channel struct { + Name string `json:"name,omitempty"` + Public bool `json:"public,omitempty"` + MembersType string `json:"members_type,omitempty"` + TopicType string `json:"topic_type,omitempty"` + TopicName string `json:"topic_name,omitempty"` +} + +type BotCommand struct { + Name string `json:"name"` + Description string `json:"description"` + Usage string `json:"usage"` + ExtendedDescription *BotCommandExtendedDescription `json:"extended_description,omitempty"` +} + +type BotCommandExtendedDescription struct { + Title string `json:"title"` + DesktopBody string `json:"desktop_body"` + MobileBody string `json:"mobile_body"` +} + +type BotAdvertisement struct { + Type string `json:"type"` // "public", "teamconvs", "teammembers" + TeamName string `json:"team_name,omitempty"` // required if Type is not "public" + BotCommands []BotCommand `json:"commands"` +} + +type mesg struct { + Body string `json:"body"` +} + +type duration struct { + time.Duration +} + +func (d *duration) UnmarshalJSON(b []byte) (err error) { + d.Duration, err = time.ParseDuration(strings.Trim(string(b), `"`)) + return +} + +func (d *duration) MarshalJSON() (b []byte, err error) { + return []byte(fmt.Sprintf(`"%s"`, d.String())), nil +} + +type options struct { + Channel *Channel `json:"channel,omitempty"` + MessageID int `json:"message_id,omitempty"` + Message *mesg `json:"message,omitempty"` + Pagination *pagination `json:"pagination,omitempty"` + Filename string `json:"filename,omitempty,omitempty"` + Title string `json:"title,omitempty,omitempty"` + Output string `json:"output,omitempty,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + FlipConversationID string `json:"flip_conversation_id,omitempty"` + MsgID int `json:"msg_id,omitempty"` + ReplyTo int `json:"reply_to,omitempty"` + GameID string `json:"game_id,omitempty"` + Alias string `json:"alias,omitempty"` + BotAdvertisements []BotAdvertisement `json:"advertisements,omitempty"` + ExplodingLifetime duration `json:"exploding_lifetime,omitempty"` + + Name string `json:"name,omitempty"` + Public bool `json:"public,omitempty"` + MembersType string `json:"members_type,omitempty"` + TopicType string `json:"topic_type,omitempty"` + TopicName string `json:"topic_name,omitempty"` +} + +type params struct { + Options options `json:"options"` +} + +type pagination struct { + Next string `json:"next"` + Previous string `json:"previous"` + Num int `json:"num"` + Last bool `json:"last,omitempty"` + ForceFirstPage bool `json:"forceFirstPage,omitempty"` +} + +type participants struct { + UID string `json:"uid"` + DeviceID string `json:"deviceID"` + Username string `json:"username"` + DeviceName string `json:"deviceName"` + Commitment string `json:"commitment"` + Reveal string `json:"reveal"` +} + +type dupreg struct { + User string `json:"user"` + Device string `json:"device"` +} + +type errorInfo struct { + Typ int `json:"typ"` + Dupreg dupreg `json:"dupreg"` +} + +type resultInfo struct { + Typ int `json:"typ"` + Coin bool `json:"coin"` +} + +type flipStatus struct { + GameID string `json:"gameID"` + Phase int `json:"phase"` + ProgressText string `json:"progressText"` + ResultText string `json:"resultText"` + CommitmentVisualization string `json:"commitmentVisualization"` + RevealVisualization string `json:"revealVisualization"` + Participants []participants `json:"participants"` + ResultInfo *resultInfo `json:"resultInfo"` + ErrorInfo *errorInfo `json:"errorInfo"` +} + +type result struct { + Messages []messages `json:"messages,omitempty"` + Pagination pagination `json:"pagination"` + Message string `json:"message"` + ID int `json:"id"` + Ratelimits []rateLimits `json:"ratelimits"` + Conversations []conversation `json:"conversations,omitempty"` + Offline bool `json:"offline,omitempty"` + Status flipStatus `json:"status,omitempty"` + IdentifyFailures interface{} `json:"identifyFailures,omitempty"` +} + +type messages struct { + Msg msg `json:"msg,omitempty"` +} + +type rateLimits struct { + Tank string `json:"tank,omitempty"` + Capacity int `json:"capacity,omitempty"` + Reset int `json:"reset,omitempty"` + Gas int `json:"gas,omitempty"` +} + +type conversation struct { + ID string `json:"id"` + Channel Channel `json:"channel"` + Unread bool `json:"unread"` + ActiveAt int `json:"active_at"` + ActiveAtMs int64 `json:"active_at_ms"` + MemberStatus string `json:"member_status"` +} + +type SendPayment struct { + PaymentID string `json:"paymentID"` +} + +type RequestPayment struct { + RequestID string `json:"requestID"` + Note string `json:"note"` +} + +// WalletAPI holds data for sending to API +type WalletAPI struct { + Method string `json:"method,omitempty"` + Params *wParams `json:"params,omitempty"` + Result *wResult `json:"result,omitempty"` + Error *Error `json:"error"` +} + +type wOptions struct { + Name string `json:"name"` + Txid string `json:"txid"` + Recipient string `json:"recipient"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Message string `json:"message"` +} + +type wParams struct { + Options wOptions `json:"options"` +} + +type asset struct { + Type string `json:"type"` + Code string `json:"code"` + Issuer string `json:"issuer"` + VerifiedDomain string `json:"verifiedDomain"` + IssuerName string `json:"issuerName"` + Desc string `json:"desc"` + InfoURL string `json:"infoUrl"` +} + +type sourceAsset struct { + Type string `json:"type"` + Code string `json:"code"` + Issuer string `json:"issuer"` + VerifiedDomain string `json:"verifiedDomain"` + IssuerName string `json:"issuerName"` + Desc string `json:"desc"` + InfoURL string `json:"infoUrl"` + InfoURLText string `json:"infoUrlText"` +} + +type balance struct { + Asset asset `json:"asset"` + Amount string `json:"amount"` + Limit string `json:"limit"` +} + +type exchangeRate struct { + Currency string `json:"currency"` + Rate string `json:"rate"` +} + +type wResult struct { + AccountID string `json:"accountID"` + IsPrimary bool `json:"isPrimary"` + Name string `json:"name"` + Balance []balance `json:"balance"` + ExchangeRate exchangeRate `json:"exchangeRate"` + AccountMode int `json:"accountMode"` + TxID string `json:"txID"` + Time int64 `json:"time"` + Status string `json:"status"` + StatusDetail string `json:"statusDetail"` + Amount string `json:"amount"` + Asset asset `json:"asset"` + DisplayAmount string `json:"displayAmount"` + DisplayCurrency string `json:"displayCurrency"` + SourceAmountMax string `json:"sourceAmountMax"` + SourceAmountActual string `json:"sourceAmountActual"` + SourceAsset sourceAsset `json:"sourceAsset"` + FromStellar string `json:"fromStellar"` + ToStellar string `json:"toStellar"` + FromUsername string `json:"fromUsername"` + ToUsername string `json:"toUsername"` + Note string `json:"note"` + NoteErr string `json:"noteErr"` + Unread bool `json:"unread"` + Username string `json:"username"` +} + +// TeamAPI holds information sent and received to/from the team api +type TeamAPI struct { + Method string `json:"method,omitempty"` + Params *tParams `json:"params,omitempty"` + Result *tResult `json:"result,omitempty"` + Error *Error `json:"error"` +} + +type emails struct { + Email string `json:"email"` + Role string `json:"role"` +} + +type usernames struct { + Username string `json:"username"` + Role string `json:"role"` +} + +type user struct { + UID string `json:"uid"` + Username string `json:"username"` +} + +type uv struct { + UID string `json:"uid"` + EldestSeqno int `json:"eldestSeqno"` +} + +type member struct { + Uv uv `json:"uv"` + Username string `json:"username"` + FullName string `json:"fullName"` + NeedsPUK bool `json:"needsPUK"` + Status int `json:"status"` +} + +type members struct { + Owners []member `json:"owners"` + Admins []member `json:"admins"` + Writers []member `json:"writers"` + Readers []member `json:"readers"` +} + +type annotatedActiveInvites struct { +} + +type settings struct { + Open bool `json:"open"` + JoinAs int `json:"joinAs"` +} + +type showcase struct { + IsShowcased bool `json:"is_showcased"` + AnyMemberShowcase bool `json:"any_member_showcase"` +} + +type tOptions struct { + Team string `json:"team"` + Emails []emails `json:"emails"` + Usernames []usernames `json:"usernames"` + Username string `json:"username"` +} + +type tParams struct { + Options tOptions `json:"options"` +} + +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type tResult struct { + ChatSent bool `json:"chatSent"` + CreatorAdded bool `json:"creatorAdded"` + Invited bool `json:"invited"` + User user `json:"user"` + EmailSent bool `json:"emailSent"` + ChatSending bool `json:"chatSending"` + Members members `json:"members"` + KeyGeneration int `json:"keyGeneration"` + AnnotatedActiveInvites annotatedActiveInvites `json:"annotatedActiveInvites"` + Settings settings `json:"settings"` + Showcase showcase `json:"showcase"` + Teams []teamInfo `json:"teams"` +} + +type implicit struct { + Role int `json:"role"` + Ancestor string `json:"ancestor"` +} + +type teamInfo struct { + UID string `json:"uid"` + TeamID string `json:"team_id"` + Username string `json:"username"` + FullName string `json:"full_name"` + FqName string `json:"fq_name"` + IsImplicitTeam bool `json:"is_implicit_team"` + ImplicitTeamDisplayName string `json:"implicit_team_display_name"` + IsOpenTeam bool `json:"is_open_team"` + Role int `json:"role"` + NeedsPUK bool `json:"needsPUK"` + MemberCount int `json:"member_count"` + MemberEldestSeqno int `json:"member_eldest_seqno"` + AllowProfilePromote bool `json:"allow_profile_promote"` + IsMemberShowcased bool `json:"is_member_showcased"` + Status int `json:"status"` + Implicit implicit `json:"implicit,omitempty"` +} + +// KVAPI holds information sent and received to/from the kvstore api +type KVAPI struct { + Method string `json:"method,omitempty"` + Params *kvParams `json:"params,omitempty"` + Result *kvResult `json:"result,omitempty"` + Error *Error `json:"error"` + keybase Keybase +} + +type kvOptions struct { + Team string `json:"team,omitempty"` + Namespace string `json:"namespace,omitempty"` + EntryKey string `json:"entryKey,omitempty"` + Revision uint `json:"revision,omitempty"` + EntryValue string `json:"entryValue,omitempty"` +} + +type kvParams struct { + Options kvOptions `json:"options,omitempty"` +} + +type entryKey struct { + EntryKey string `json:"entryKey"` + Revision uint `json:"revision"` +} + +type kvResult struct { + TeamName string `json:"teamName"` + Namespaces []string `json:"namespaces"` + EntryKeys []entryKey `json:"entryKeys"` + EntryKey string `json:"entryKey"` + EntryValue string `json:"entryValue"` + Revision uint `json:"revision"` +} + +// UserAPI holds information received from the user/lookup api +type UserAPI struct { + Status uStatus `json:"status"` + Them []them `json:"them"` +} + +type uStatus struct { + Code int `json:"code"` + Name string `json:"name"` +} + +type basics struct { + Ctime int `json:"ctime"` + EldestSeqno int `json:"eldest_seqno"` + IDVersion int `json:"id_version"` + LastIDChange int `json:"last_id_change"` + Mtime int `json:"mtime"` + PassphraseGeneration int `json:"passphrase_generation"` + RandomPw bool `json:"random_pw"` + Salt string `json:"salt"` + Status int `json:"status"` + TrackVersion int `json:"track_version"` + Username string `json:"username"` + UsernameCased string `json:"username_cased"` +} + +type profile struct { + Bio string `json:"bio"` + FullName string `json:"full_name"` + Location string `json:"location"` + Mtime int `json:"mtime"` +} + +type proof struct { + HumanURL string `json:"human_url"` + Nametag string `json:"nametag"` + PresentationGroup string `json:"presentation_group"` + PresentationTag string `json:"presentation_tag"` + ProofID string `json:"proof_id"` + ProofType string `json:"proof_type"` + ProofURL string `json:"proof_url"` + ServiceURL string `json:"service_url"` + SigID string `json:"sig_id"` + State int `json:"state"` +} + +type proofsSummary struct { + All []proof `json:"all"` + HasWeb bool `json:"has_web"` +} + +type key struct { + KeyRole int `json:"key_role"` + Kid string `json:"kid"` + SigID string `json:"sig_id"` +} + +type uDevice struct { + Ctime int `json:"ctime"` + Keys []key `json:"keys"` + Mtime int `json:"mtime"` + Name string `json:"name"` + Status int `json:"status"` + Type string `json:"type"` +} + +type them struct { + Basics basics `json:"basics,omitempty"` + ID string `json:"id"` + Profile profile `json:"profile,omitempty"` + ProofsSummary proofsSummary `json:"proofs_summary"` + Devices map[string]uDevice `json:"devices,omitempty"` +} + +// UserCardAPI holds information received from the user/card api +type UserCardAPI struct { + AirdropRegistered bool `json:"airdrop_registered"` + Blocked bool `json:"blocked"` + FollowSummary followSummary `json:"follow_summary"` + Profile cardProfile `json:"profile"` + Status uStatus `json:"status"` + TeamShowcase []teamShowcase `json:"team_showcase"` + TheyFollowYou bool `json:"they_follow_you"` + UserBlocks userBlocks `json:"user_blocks"` + YouFollowThem bool `json:"you_follow_them"` +} + +type followSummary struct { + Followers int `json:"followers"` + Following int `json:"following"` +} + +type cardProfile struct { + Bio string `json:"bio"` + Comment string `json:"comment"` + CrimeAll int `json:"crime_all"` + CrimeChat int `json:"crime_chat"` + CrimeFollow int `json:"crime_follow"` + CrimeIllegal int `json:"crime_illegal"` + CrimeLegacyAll int `json:"crime_legacy_all"` + CrimeLegacyPorn int `json:"crime_legacy_porn"` + CrimeLegacyStellar int `json:"crime_legacy_stellar"` + CrimePorn int `json:"crime_porn"` + CrimeSmurfing int `json:"crime_smurfing"` + CrimeSpacedrop int `json:"crime_spacedrop"` + CrimeStellarDust int `json:"crime_stellar_dust"` + CrimeStellarPaymentReq int `json:"crime_stellar_payment_req"` + CrimeTeam int `json:"crime_team"` + Ctime time.Time `json:"ctime"` + FullName string `json:"full_name"` + IsAdmin int `json:"is_admin"` + Location string `json:"location"` + Mtime time.Time `json:"mtime"` + Reporter string `json:"reporter"` + Status int `json:"status"` + Twitter string `json:"twitter"` + UID string `json:"uid"` + Website string `json:"website"` +} + +type teamShowcase struct { + Description string `json:"description"` + FqName string `json:"fq_name"` + NumMembers int `json:"num_members"` + Open bool `json:"open"` + PublicAdmins []string `json:"public_admins"` + Role int `json:"role"` + TeamID string `json:"team_id"` + TeamIsShowcased bool `json:"team_is_showcased"` +} + +type userBlocks struct { + Chat bool `json:"chat"` + Ctime time.Time `json:"ctime"` + Follow bool `json:"follow"` + Mtime time.Time `json:"mtime"` +} + +// Keybase holds basic information about the local Keybase executable +type Keybase struct { + Path string + Username string + LoggedIn bool + Version string + Device string +} + +// Chat holds basic information about a specific conversation +type Chat struct { + keybase *Keybase + Channel Channel +} + +type chat interface { + Delete(messageID int) (ChatAPI, error) + Edit(messageID int, message ...string) (ChatAPI, error) + React(messageID int, reaction string) (ChatAPI, error) + Send(message ...string) (ChatAPI, error) + Reply(replyTo int, message ...string) (ChatAPI, error) + Upload(title string, filepath string) (ChatAPI, error) + Download(messageID int, filepath string) (ChatAPI, error) + LoadFlip(messageID int, conversationID string, flipConversationID string, gameID string) (ChatAPI, error) + Pin(messageID int) (ChatAPI, error) + Unpin() (ChatAPI, error) + Mark(messageID int) (ChatAPI, error) +} + +type chatAPI interface { + Next(count ...int) (*ChatAPI, error) + Previous(count ...int) (*ChatAPI, error) +} + +// Team holds basic information about a team +type Team struct { + keybase *Keybase + Name string +} + +type team interface { + AddAdmins(users ...string) (TeamAPI, error) + AddOwners(users ...string) (TeamAPI, error) + AddReaders(users ...string) (TeamAPI, error) + AddUser(user, role string) (TeamAPI, error) + AddWriters(users ...string) (TeamAPI, error) + CreateSubteam(name string) (TeamAPI, error) + MemberList() (TeamAPI, error) +} + +// Wallet holds basic information about a wallet +type Wallet struct { + keybase *Keybase +} + +type wallet interface { + CancelRequest(requestID string) error + RequestPayment(user string, amount float64, memo ...string) + Send(recipient string, amount string, currency string, message ...string) (WalletAPI, error) + SendXLM(recipient string, amount string, message ...string) (WalletAPI, error) + StellarAddress(user string) (string, error) + TxDetail(txid string) (WalletAPI, error) +} + +// KV holds basic information about a KVStore +type KV struct { + keybase *Keybase + Team string +} + +type kvInterface interface { + Namespaces() (KVAPI, error) + Keys(namespace string) (KVAPI, error) + Get(namespace string, key string) (KVAPI, error) + Put(namespace string, key string, value string) (KVAPI, error) + Delete(namespace string, key string) (KVAPI, error) +} + +type keybase interface { + AdvertiseCommand(advertisement BotAdvertisement) (ChatAPI, error) + AdvertiseCommands(advertisements []BotAdvertisement) (ChatAPI, error) + ChatList(opts ...Channel) (ChatAPI, error) + ClearCommands() (ChatAPI, error) + CreateTeam(name string) (TeamAPI, error) + NewChat(channel Channel) Chat + NewTeam(name string) Team + NewKV(team string) KV + NewWallet() Wallet + Run(handler func(ChatAPI), options ...RunOptions) + status() status + version() string + UserLookup(users ...string) (UserAPI, error) + ListUserMemberships(user string) (TeamAPI, error) + UserCard(user string) (UserCardAPI, error) +} + +type status struct { + Username string `json:"Username"` + LoggedIn bool `json:"LoggedIn"` + Device device `json:"Device"` +} + +type device struct { + Name string `json:"name"` +} diff --git a/v2/wallet.go b/v2/wallet.go new file mode 100644 index 0000000..27e2399 --- /dev/null +++ b/v2/wallet.go @@ -0,0 +1,111 @@ +package keybase + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// walletAPIOut sends JSON requests to the wallet API and returns its response. +func walletAPIOut(k *Keybase, w WalletAPI) (WalletAPI, error) { + jsonBytes, _ := json.Marshal(w) + + cmdOut, err := k.Exec("wallet", "api", "-m", string(jsonBytes)) + if err != nil { + return WalletAPI{}, err + } + + var r WalletAPI + json.Unmarshal(cmdOut, &r) + if r.Error != nil { + return WalletAPI{}, errors.New(r.Error.Message) + } + return r, nil +} + +// TxDetail returns details of a stellar transaction +func (w Wallet) TxDetail(txid string) (WalletAPI, error) { + m := WalletAPI{ + Params: &wParams{}, + } + m.Method = "details" + m.Params.Options.Txid = txid + + r, err := walletAPIOut(w.keybase, m) + return r, err +} + +// StellarAddress returns the primary stellar address of a given user +func (w Wallet) StellarAddress(user string) (string, error) { + m := WalletAPI{ + Params: &wParams{}, + } + m.Method = "lookup" + m.Params.Options.Name = user + + r, err := walletAPIOut(w.keybase, m) + if err != nil { + return "", err + } + return r.Result.AccountID, err +} + +// StellarUser returns the keybase username of a given wallet address +func (w Wallet) StellarUser(wallet string) (string, error) { + m := WalletAPI{ + Params: &wParams{}, + } + m.Method = "lookup" + m.Params.Options.Name = wallet + + r, err := walletAPIOut(w.keybase, m) + if err != nil { + return "", err + } + return r.Result.Username, err +} + +// RequestPayment sends a request for payment to a user +func (w Wallet) RequestPayment(user string, amount float64, memo ...string) error { + k := w.keybase + if len(memo) > 0 { + _, err := k.Exec("wallet", "request", user, fmt.Sprintf("%f", amount), "-m", memo[0]) + return err + } + _, err := k.Exec("wallet", "request", user, fmt.Sprintf("%f", amount)) + return err +} + +// CancelRequest cancels a request for payment previously sent to a user +func (w Wallet) CancelRequest(requestID string) error { + k := w.keybase + _, err := k.Exec("wallet", "cancel-request", requestID) + return err +} + +// Send sends the specified amount of the specified currency to a user +func (w Wallet) Send(recipient string, amount string, currency string, message ...string) (WalletAPI, error) { + m := WalletAPI{ + Params: &wParams{}, + } + m.Method = "send" + m.Params.Options.Recipient = recipient + m.Params.Options.Amount = amount + m.Params.Options.Currency = currency + if len(message) > 0 { + m.Params.Options.Message = strings.Join(message, " ") + } + + r, err := walletAPIOut(w.keybase, m) + if err != nil { + return WalletAPI{}, err + } + return r, err +} + +// SendXLM sends the specified amount of XLM to a user +func (w Wallet) SendXLM(recipient string, amount string, message ...string) (WalletAPI, error) { + result, err := w.Send(recipient, amount, "XLM", message...) + return result, err +}