From e595fecf8ea7bdfee4279bdf77e074cecd58dc00 Mon Sep 17 00:00:00 2001 From: Brian Stengaard Date: Mon, 11 May 2015 15:40:32 +0200 Subject: [PATCH 1/6] add basic chat support --- conversation.go | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 conversation.go diff --git a/conversation.go b/conversation.go new file mode 100644 index 0000000..a6b2e43 --- /dev/null +++ b/conversation.go @@ -0,0 +1,131 @@ +package podio + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "path" + "strconv" +) + +// Conversation holds meta-data about a group or direct chat session +type Conversation struct { + ConversationId uint `json:"conversation_id"` + Reference Reference `json:"ref"` + CreatedOn Time `json:"created_on"` + CreatedBy ByLine `json:"created_by"` + + Excerpt string `json:"excerpt"` + Starred bool `json:"starred"` + Unread bool `json:"unread"` + UnreadCount uint `json:"unread_count"` + LastEvent Time `json:"last_event_on"` + Subject string `json:"subject"` + Participants []ByLine `json:"participants"` + Type string `json:"type"` // direct or group +} + +// ConversationEvent is a single message from a sender to a conversation +type ConversationEvent struct { + EventID uint `json:"event_id"` + + Action string `json:"action"` + Data struct { + MessageID uint `json:"message_id"` + Files []interface{} `json:"files"` // TODO: add structure + Text string `json:"text"` + EmbedFile interface{} `json:"embed_file"` // TODO: add structure + Embed interface{} `json:"embed"` // TODO: add structure + CreatedOn Time + } + + CreatedVia struct { + ID uint `json:"id"` + URL string `json:"url"` + AuthClient uint `json:"auth_client"` + Display bool `json:"display"` + Name string `json:"name"` + } `json:"created_via"` + CreatedBy ByLine `json:"created_by"` + CreatedOn Time `json:"created_on"` +} + +// ConversationSelector can modify the scope of a conversations lookup request - see WithLimit and WithOffset for examples. +type ConversationSelector func(uri *url.URL) + +// GetConversation returns all conversations that the client has access to (max 200). Use WithLimit and WithOffset +// to do pagination if that is what you want. +func (client *Client) GetConversations(withOpts ...ConversationSelector) ([]Conversation, error) { + u, err := url.Parse("/conversation/") + if err != nil { // should never happen + return nil, err + } + for _, selector := range withOpts { + selector(u) + } + + convs := []Conversation{} + err = client.Request("GET", u.RequestURI(), nil, nil, &convs) + return convs, err +} + +// GetConversationEvents returns all events for the conversation with id conversationId. WithLimit and WithOffset can be used to do +// pagination. +func (client *Client) GetConversationEvents(conversationId uint, withOpts ...ConversationSelector) ([]ConversationEvent, error) { + u, err := url.Parse(fmt.Sprintf("/conversation/%d/event", conversationId)) + if err != nil { // should never happen + return nil, err + } + for _, selector := range withOpts { + selector(u) + } + + convs := []ConversationEvent{} + err = client.Request("GET", u.RequestURI(), nil, nil, &convs) + return convs, err +} + +// Reply sends a (string) message to the conversation identified by conversationId. Only text strings are supported (that is +// no embedding for now). +func (client *Client) Reply(conversationId uint, reply string) (ConversationEvent, error) { + path := fmt.Sprintf("/conversation/%d/reply/v2", conversationId) + out := ConversationEvent{} + + buf, err := json.Marshal(struct { + Text string `json:"text"` + }{Text: reply}) + if err != nil { + return out, err + } + err = client.Request("POST", path, map[string]string{"content-type": "application/json"}, bytes.NewReader(buf), &out) + return out, err +} + +// WithLimit sets a limit on the returned list of Conversations or ConversationEvents. limit must be in the range (0-200]. +func WithLimit(limit uint) ConversationSelector { + f := func(u *url.URL) { + q := u.Query() + q.Add("limit", strconv.Itoa(int(limit))) + u.RawQuery = q.Encode() + } + return ConversationSelector(f) +} + +// WithOffset introduces an offset in the returned list of Conversations or ConversationsEvents. +func WithOffset(offset uint) ConversationSelector { + f := func(u *url.URL) { + q := u.Query() + q.Add("offset", strconv.Itoa(int(offset))) + u.RawQuery = q.Encode() + } + return ConversationSelector(f) +} + +// Unread manipulates the conversation request to only conversations with unread messages. +func Unread() ConversationSelector { + f := func(u *url.URL) { + u.Path = path.Join(u.Path, "unread") + } + return ConversationSelector(f) +} From a750b50ce24d411601789fee4c0465b4bf8ec6e1 Mon Sep 17 00:00:00 2001 From: Brian Stengaard Date: Mon, 11 May 2015 15:41:05 +0200 Subject: [PATCH 2/6] add example chat cli app --- example/chat.go | 232 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 example/chat.go diff --git a/example/chat.go b/example/chat.go new file mode 100644 index 0000000..93ba18f --- /dev/null +++ b/example/chat.go @@ -0,0 +1,232 @@ +// +build ignore + +// This is a small chat client + +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "time" + + "github.com/andreas/podio-go" +) + +var ( + podioClient string + podioSecret string + + defaultCacheFile = filepath.Join(os.Getenv("HOME"), ".chat-cli-request-token") + + cacheFile = flag.String("cachefile", defaultCacheFile, "Authentication token cache file") +) + +func main() { + flag.Parse() + + podioClient = envDefault("PODIO_CLIENT", "chatcli") + podioSecret = envDefault("PODIO_SECRET", "4qCaud5yZTt56w6WWbsKp1ldoq0egbEqzTuq7kIU6X6IKy9f9Gjp4K9M9zttXJul") + + token, err := readToken(*cacheFile) + if err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Err reading token cache: %s\n", err) + } + if token == nil { + authcode, err := getOauthToken() + if err != nil { + fmt.Fprintln(os.Stderr, "Error getting auth from podio:", err) + os.Exit(1) + } + token, err = podio.AuthWithAuthCode( + podioClient, podioSecret, + authcode, "http://127.0.0.1/", + ) + } + err = writeToken(*cacheFile, token) + if err != nil { + fmt.Fprintf(os.Stderr, "Err writing token file: %s\n", err) + } + + client := podio.NewClient(token) + + id, err := strconv.Atoi(flag.Arg(0)) + if err != nil { + fmt.Fprintf(os.Stderr, "Bad or no conversation ID given. Listing conversations\n") + listConversations(client) + } else { + talkTo(client, uint(id)) + } + +} + +func prompt(q string) string { + fmt.Printf("%s: ", q) + defer fmt.Println("") + + s := bufio.NewScanner(os.Stdin) + s.Scan() + out := s.Text() + return out +} + +func envDefault(key, deflt string) string { + val := os.Getenv(key) + if val == "" { + return deflt + } + return val +} + +func listConversations(client *podio.Client) { + convs, err := client.GetConversations(podio.WithLimit(200)) + if err != nil { + fmt.Fprintln(os.Stderr, "Error getting conversation list:", err) + return + } + for _, conv := range convs { + if conv.Type == "direct" { + fmt.Println("Id:", conv.ConversationId, "direct", conv.Participants[0].Name) + } else { + fmt.Println("Id:", conv.ConversationId, "group", len(conv.Participants), "colleagues on", conv.Subject) + } + } +} + +func talkTo(client *podio.Client, convId uint) { + var ( + eventChan = make(chan podio.ConversationEvent, 1) + inputChan = make(chan string) + ) + + go func() { + last := podio.ConversationEvent{} + for { + + events, err := client.GetConversationEvents(convId, podio.WithLimit(1)) + if err != nil { + fmt.Fprintln(os.Stderr, "Err getting update:", err) + time.Sleep(800 * time.Millisecond) + continue + } + + if len(events) == 0 || last.EventID == events[0].EventID { + time.Sleep(500 * time.Millisecond) + continue + } + + last = events[0] + eventChan <- last + time.Sleep(500 * time.Millisecond) + } + }() + + go func() { + s := bufio.NewScanner(os.Stdin) + for s.Scan() { + inputChan <- s.Text() + } + + if s.Err() != nil { + fmt.Fprintln(os.Stderr, "Error scanning for input:", s.Err()) + } + }() + + lastTalker := uint(0) + + for { + select { + case t := <-inputChan: + _, err := client.Reply(convId, t) + if err != nil { + fmt.Fprintln(os.Stderr, "Error replying to Podio:", err) + } + + case event := <-eventChan: + if event.CreatedBy.Id != lastTalker { + fmt.Println(event.CreatedBy.Name, "said:") + } + lastTalker = event.CreatedBy.Id + fmt.Println(" > ", event.Data.Text) + } + } + +} + +// Inspired by how github.com/nf/streak uses Google Oauth to avoid having user type in password +// Requires that the redirect_url of the client is set to 127.0.0.1 +// TODO: part of podio-go? + +func getOauthToken() (string, error) { + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + return "", err + } + defer l.Close() + + code := make(chan string) + go http.Serve(l, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + fmt.Fprintf(rw, "You can close this window now") + fmt.Println(req) + code <- req.FormValue("code") + })) + + u, _ := url.Parse("https://podio.com/oauth/authorize") + params := url.Values{} + + params.Add("client_id", podioClient) + params.Add("redirect_uri", fmt.Sprintf("http://%s/", l.Addr())) + u.RawQuery = params.Encode() + openURL(u.String()) + + return <-code, nil +} + +func openURL(url string) error { + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("Cannot open URL %s on this platform", url) + } + return err + +} + +func readToken(f string) (*podio.AuthToken, error) { + b, err := ioutil.ReadFile(f) + if err != nil { + return nil, err + } + + var t podio.AuthToken + if err := json.Unmarshal(b, &t); err != nil { + return nil, err + } + + return &t, nil +} + +func writeToken(f string, t *podio.AuthToken) error { + b, err := json.Marshal(t) + if err != nil { + return err + } + + return ioutil.WriteFile(f, b, 0600) +} From b7b9caa6b6e31598911dbdf5319dc7964e3c32ce Mon Sep 17 00:00:00 2001 From: Brian Stengaard Date: Sun, 17 May 2015 12:24:11 +0200 Subject: [PATCH 3/6] re-use podio.Via --- conversation.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/conversation.go b/conversation.go index a6b2e43..f49d539 100644 --- a/conversation.go +++ b/conversation.go @@ -40,15 +40,9 @@ type ConversationEvent struct { CreatedOn Time } - CreatedVia struct { - ID uint `json:"id"` - URL string `json:"url"` - AuthClient uint `json:"auth_client"` - Display bool `json:"display"` - Name string `json:"name"` - } `json:"created_via"` - CreatedBy ByLine `json:"created_by"` - CreatedOn Time `json:"created_on"` + CreatedVia Via `json:"created_via"` + CreatedBy ByLine `json:"created_by"` + CreatedOn Time `json:"created_on"` } // ConversationSelector can modify the scope of a conversations lookup request - see WithLimit and WithOffset for examples. From 78fb23e36df95a9fbb20a3e642a9c861b214b124 Mon Sep 17 00:00:00 2001 From: Brian Stengaard Date: Sun, 17 May 2015 12:29:51 +0200 Subject: [PATCH 4/6] simplify JSON marshalling --- conversation.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/conversation.go b/conversation.go index f49d539..15a7d80 100644 --- a/conversation.go +++ b/conversation.go @@ -86,9 +86,7 @@ func (client *Client) Reply(conversationId uint, reply string) (ConversationEven path := fmt.Sprintf("/conversation/%d/reply/v2", conversationId) out := ConversationEvent{} - buf, err := json.Marshal(struct { - Text string `json:"text"` - }{Text: reply}) + buf, err := json.Marshal(map[string]string{"text": reply}) if err != nil { return out, err } From 5fb3ce803784487b33d611e57c85ebccacf256c2 Mon Sep 17 00:00:00 2001 From: Brian Stengaard Date: Sun, 17 May 2015 12:43:59 +0200 Subject: [PATCH 5/6] create conversation package --- .../conversation.go | 74 ++++++++++--------- example/chat.go | 20 +++-- 2 files changed, 49 insertions(+), 45 deletions(-) rename conversation.go => conversation/conversation.go (64%) diff --git a/conversation.go b/conversation/conversation.go similarity index 64% rename from conversation.go rename to conversation/conversation.go index 15a7d80..96cc27c 100644 --- a/conversation.go +++ b/conversation/conversation.go @@ -1,4 +1,4 @@ -package podio +package conversation import ( "bytes" @@ -7,27 +7,33 @@ import ( "net/url" "path" "strconv" + + "github.com/andreas/podio-go" ) -// Conversation holds meta-data about a group or direct chat session -type Conversation struct { - ConversationId uint `json:"conversation_id"` - Reference Reference `json:"ref"` - CreatedOn Time `json:"created_on"` - CreatedBy ByLine `json:"created_by"` - - Excerpt string `json:"excerpt"` - Starred bool `json:"starred"` - Unread bool `json:"unread"` - UnreadCount uint `json:"unread_count"` - LastEvent Time `json:"last_event_on"` - Subject string `json:"subject"` - Participants []ByLine `json:"participants"` - Type string `json:"type"` // direct or group +type Client struct { + *podio.Client +} + +// Metadata holds meta-data about a group or direct chat session +type Metadata struct { + ConversationId uint `json:"conversation_id"` + Reference podio.Reference `json:"ref"` + CreatedOn podio.Time `json:"created_on"` + CreatedBy podio.ByLine `json:"created_by"` + + Excerpt string `json:"excerpt"` + Starred bool `json:"starred"` + Unread bool `json:"unread"` + UnreadCount uint `json:"unread_count"` + LastEvent podio.Time `json:"last_event_on"` + Subject string `json:"subject"` + Participants []podio.ByLine `json:"participants"` + Type string `json:"type"` // direct or group } // ConversationEvent is a single message from a sender to a conversation -type ConversationEvent struct { +type Event struct { EventID uint `json:"event_id"` Action string `json:"action"` @@ -37,20 +43,20 @@ type ConversationEvent struct { Text string `json:"text"` EmbedFile interface{} `json:"embed_file"` // TODO: add structure Embed interface{} `json:"embed"` // TODO: add structure - CreatedOn Time + CreatedOn podio.Time } - CreatedVia Via `json:"created_via"` - CreatedBy ByLine `json:"created_by"` - CreatedOn Time `json:"created_on"` + CreatedVia podio.Via `json:"created_via"` + CreatedBy podio.ByLine `json:"created_by"` + CreatedOn podio.Time `json:"created_on"` } // ConversationSelector can modify the scope of a conversations lookup request - see WithLimit and WithOffset for examples. -type ConversationSelector func(uri *url.URL) +type Selector func(uri *url.URL) // GetConversation returns all conversations that the client has access to (max 200). Use WithLimit and WithOffset // to do pagination if that is what you want. -func (client *Client) GetConversations(withOpts ...ConversationSelector) ([]Conversation, error) { +func (client *Client) GetConversations(withOpts ...Selector) ([]Metadata, error) { u, err := url.Parse("/conversation/") if err != nil { // should never happen return nil, err @@ -59,14 +65,14 @@ func (client *Client) GetConversations(withOpts ...ConversationSelector) ([]Conv selector(u) } - convs := []Conversation{} + convs := []Metadata{} err = client.Request("GET", u.RequestURI(), nil, nil, &convs) return convs, err } // GetConversationEvents returns all events for the conversation with id conversationId. WithLimit and WithOffset can be used to do // pagination. -func (client *Client) GetConversationEvents(conversationId uint, withOpts ...ConversationSelector) ([]ConversationEvent, error) { +func (client *Client) GetEvents(conversationId uint, withOpts ...Selector) ([]Event, error) { u, err := url.Parse(fmt.Sprintf("/conversation/%d/event", conversationId)) if err != nil { // should never happen return nil, err @@ -75,16 +81,16 @@ func (client *Client) GetConversationEvents(conversationId uint, withOpts ...Con selector(u) } - convs := []ConversationEvent{} + convs := []Event{} err = client.Request("GET", u.RequestURI(), nil, nil, &convs) return convs, err } // Reply sends a (string) message to the conversation identified by conversationId. Only text strings are supported (that is // no embedding for now). -func (client *Client) Reply(conversationId uint, reply string) (ConversationEvent, error) { +func (client *Client) Reply(conversationId uint, reply string) (Event, error) { path := fmt.Sprintf("/conversation/%d/reply/v2", conversationId) - out := ConversationEvent{} + out := Event{} buf, err := json.Marshal(map[string]string{"text": reply}) if err != nil { @@ -95,29 +101,29 @@ func (client *Client) Reply(conversationId uint, reply string) (ConversationEven } // WithLimit sets a limit on the returned list of Conversations or ConversationEvents. limit must be in the range (0-200]. -func WithLimit(limit uint) ConversationSelector { +func WithLimit(limit uint) Selector { f := func(u *url.URL) { q := u.Query() q.Add("limit", strconv.Itoa(int(limit))) u.RawQuery = q.Encode() } - return ConversationSelector(f) + return Selector(f) } // WithOffset introduces an offset in the returned list of Conversations or ConversationsEvents. -func WithOffset(offset uint) ConversationSelector { +func WithOffset(offset uint) Selector { f := func(u *url.URL) { q := u.Query() q.Add("offset", strconv.Itoa(int(offset))) u.RawQuery = q.Encode() } - return ConversationSelector(f) + return Selector(f) } // Unread manipulates the conversation request to only conversations with unread messages. -func Unread() ConversationSelector { +func Unread() Selector { f := func(u *url.URL) { u.Path = path.Join(u.Path, "unread") } - return ConversationSelector(f) + return Selector(f) } diff --git a/example/chat.go b/example/chat.go index 93ba18f..e27ed36 100644 --- a/example/chat.go +++ b/example/chat.go @@ -1,7 +1,4 @@ -// +build ignore - -// This is a small chat client - +// This is a small chat client for Podio package main import ( @@ -21,6 +18,7 @@ import ( "time" "github.com/andreas/podio-go" + "github.com/andreas/podio-go/conversation" ) var ( @@ -58,7 +56,7 @@ func main() { fmt.Fprintf(os.Stderr, "Err writing token file: %s\n", err) } - client := podio.NewClient(token) + client := &conversation.Client{podio.NewClient(token)} id, err := strconv.Atoi(flag.Arg(0)) if err != nil { @@ -88,8 +86,8 @@ func envDefault(key, deflt string) string { return val } -func listConversations(client *podio.Client) { - convs, err := client.GetConversations(podio.WithLimit(200)) +func listConversations(client *conversation.Client) { + convs, err := client.GetConversations(conversation.WithLimit(200)) if err != nil { fmt.Fprintln(os.Stderr, "Error getting conversation list:", err) return @@ -103,17 +101,17 @@ func listConversations(client *podio.Client) { } } -func talkTo(client *podio.Client, convId uint) { +func talkTo(client *conversation.Client, convId uint) { var ( - eventChan = make(chan podio.ConversationEvent, 1) + eventChan = make(chan conversation.Event, 1) inputChan = make(chan string) ) go func() { - last := podio.ConversationEvent{} + last := conversation.Event{} for { - events, err := client.GetConversationEvents(convId, podio.WithLimit(1)) + events, err := client.GetEvents(convId, conversation.WithLimit(1)) if err != nil { fmt.Fprintln(os.Stderr, "Err getting update:", err) time.Sleep(800 * time.Millisecond) From dc3496cdf2a0490c77077b2ec7c186811ce7b458 Mon Sep 17 00:00:00 2001 From: Brian Stengaard Date: Sun, 17 May 2015 12:45:24 +0200 Subject: [PATCH 6/6] move examples --- {example => examples/podiochat}/chat.go | 2 ++ {example => examples/podiols}/main.go | 2 ++ 2 files changed, 4 insertions(+) rename {example => examples/podiochat}/chat.go (99%) rename {example => examples/podiols}/main.go (94%) diff --git a/example/chat.go b/examples/podiochat/chat.go similarity index 99% rename from example/chat.go rename to examples/podiochat/chat.go index e27ed36..c72b4ba 100644 --- a/example/chat.go +++ b/examples/podiochat/chat.go @@ -1,3 +1,5 @@ +// +build ignore + // This is a small chat client for Podio package main diff --git a/example/main.go b/examples/podiols/main.go similarity index 94% rename from example/main.go rename to examples/podiols/main.go index c24ff3c..fa5fbac 100644 --- a/example/main.go +++ b/examples/podiols/main.go @@ -1,7 +1,9 @@ +// Command podiols lists the content of your podio account. package main import ( "fmt" + "github.com/andreas/podio-go" )