Skip to content

Add conversation API #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions conversation/conversation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package conversation

import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"path"
"strconv"

"github.com/andreas/podio-go"
)

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 Event 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 podio.Time
}

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 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 ...Selector) ([]Metadata, error) {
u, err := url.Parse("/conversation/")
if err != nil { // should never happen
return nil, err
}
for _, selector := range withOpts {
selector(u)
}

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) 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
}
for _, selector := range withOpts {
selector(u)
}

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) (Event, error) {
path := fmt.Sprintf("/conversation/%d/reply/v2", conversationId)
out := Event{}

buf, err := json.Marshal(map[string]string{"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) Selector {
f := func(u *url.URL) {
q := u.Query()
q.Add("limit", strconv.Itoa(int(limit)))
u.RawQuery = q.Encode()
}
return Selector(f)
}

// WithOffset introduces an offset in the returned list of Conversations or ConversationsEvents.
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 Selector(f)
}

// Unread manipulates the conversation request to only conversations with unread messages.
func Unread() Selector {
f := func(u *url.URL) {
u.Path = path.Join(u.Path, "unread")
}
return Selector(f)
}
232 changes: 232 additions & 0 deletions examples/podiochat/chat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// +build ignore

// This is a small chat client for Podio
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"
"github.com/andreas/podio-go/conversation"
)

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 := &conversation.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 *conversation.Client) {
convs, err := client.GetConversations(conversation.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 *conversation.Client, convId uint) {
var (
eventChan = make(chan conversation.Event, 1)
inputChan = make(chan string)
)

go func() {
last := conversation.Event{}
for {

events, err := client.GetEvents(convId, conversation.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)
}
2 changes: 2 additions & 0 deletions example/main.go → examples/podiols/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Command podiols lists the content of your podio account.
package main

import (
"fmt"

"github.com/andreas/podio-go"
)

Expand Down