go-ttrss

incomplete CLI interface to manage your TTRSS account
git clone https://git.e1e0.net/go-ttrss.git
Log | Files | Refs

commit 612738049ef9d092560a198a09ad8a6fac008d0e
Author: Paco Esteban <paco@e1e0.net>
Date:   Thu, 30 Jul 2020 20:20:16 +0200

initial commit.

it authenticates and lists categories.
I'm now hitting a problem with the way ttrss returns JSON for this,
with a mix of ints and strings that makes json unmarshalling crash.

I've sent a patch for it but I can't do much more without adding a lot
of complexity to the project at this stage.

Diffstat:
Acmd/ttrss-cli/main.go | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 8++++++++
Ago.sum | 15+++++++++++++++
Apkg/ttrss/ttrss.go | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 328 insertions(+), 0 deletions(-)

diff --git a/cmd/ttrss-cli/main.go b/cmd/ttrss-cli/main.go @@ -0,0 +1,87 @@ +// Copyright (c) 2020 Paco Esteban <paco@e1e0.net> + +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. + +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +package main + +import ( + "fmt" + "log" + "os" + + "git.e1e0.net/go-ttrss/pkg/ttrss" + "github.com/urfave/cli/v2" +) + +func main() { + + ttrssCli, err := ttrss.New() + if err != nil { + log.Fatal(err) + } + + app := &cli.App{ + Name: "ttrss-cli", + Usage: "interact with your TTRSS instance from the command line", + Version: "v0.1", + Commands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"ls"}, + Usage: "list feeds", + Action: func(c *cli.Context) error { + ttrssCli.List(42) + return nil + }, + }, + { + Name: "categories", + Aliases: []string{"cat"}, + Usage: "list categories", + Action: func(c *cli.Context) error { + categories, err := ttrssCli.Categories() + if err != nil { + log.Fatal(err) + } + + for _, cat := range categories { + fmt.Printf("%s \n", cat.Title) + } + return nil + }, + }, + { + Name: "subscribe", + Aliases: []string{"sub"}, + Usage: "subscribe to feed", + Action: func(c *cli.Context) error { + ttrssCli.Subscribe() + return nil + }, + }, + { + Name: "unsubscribe", + Aliases: []string{"rm", "unsub"}, + Usage: "unsubscribe from feed", + Action: func(c *cli.Context) error { + ttrssCli.Unsubscribe() + return nil + }, + }, + }, + } + + err = app.Run(os.Args) + if err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod @@ -0,0 +1,8 @@ +module git.e1e0.net/go-ttrss + +go 1.15 + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/urfave/cli/v2 v2.2.0 +) diff --git a/go.sum b/go.sum @@ -0,0 +1,15 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= +github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= +github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/ttrss/ttrss.go b/pkg/ttrss/ttrss.go @@ -0,0 +1,218 @@ +// partialy implemented TTRSS api wrapper +// Copyright (c) 2020 Paco Esteban <paco@e1e0.net> + +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. + +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +package ttrss + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "time" +) + +// Return status values +const ( + API_STATUS_OK = 0 + API_STATUS_ERR = 1 +) + +// Predefined category IDs +const ( + CATEGORY_UNCATEGORIZED = 0 + CATEGORY_SPECIAL = -1 + CATEGORY_LABELS = -2 + CATEGORY_FEEDS_NOT_VIRTUAL = -3 + CATEGORY_FEEDS_ALL = -4 +) + +type Client struct { + BaseUrl string + User string + password string + sessionID string + httpClient *http.Client +} + +type Item struct { + Name string +} + +type Category struct { + Id interface{} `json:"id"` + Title string `json:"title"` + Unread interface{} `json:"unread"` + OrderId int `json:"order_id"` +} + +// JSON response returned by the TTRSS API. +type Resp struct { + Seq int + Status int + Error error + Content json.RawMessage +} + +type apiError struct { + Message string `json:"error"` +} + +type apiLogin struct { + SessionId string `json:"session_id"` + ApiLevel int `json:"api_level"` +} + +func New() (*Client, error) { + if len(os.Getenv("TTRSS_HOST")) == 0 || len(os.Getenv("TTRSS_USER")) == 0 || len(os.Getenv("TTRSS_PWD")) == 0 { + return &Client{}, errors.New("TTRSS_* vars not set") + } + + return &Client{ + BaseUrl: os.Getenv("TTRSS_HOST"), + User: os.Getenv("TTRSS_USER"), + password: os.Getenv("TTRSS_PWD"), + httpClient: &http.Client{ + Timeout: time.Minute, + }, + }, nil +} + +// apiCall issues an API request. +// body should always contain at least "op". +func (c *Client) apiCall(body map[string]interface{}) (resp Resp, err error) { + if c.sessionID != "" { + body["sid"] = c.sessionID + } + + jsonObject, err := json.Marshal(body) + if err != nil { + return + } + + apiUrl := c.BaseUrl + if !strings.HasSuffix(apiUrl, "/") { + apiUrl += "/" + } + apiUrl += "api/" + + httpResp, err := c.httpClient.Post(apiUrl, "application/json", + bytes.NewBuffer(jsonObject)) + if err != nil { + err = fmt.Errorf("api connection error: %v", err) + return + } + defer httpResp.Body.Close() + + bodyBytes, err := ioutil.ReadAll(httpResp.Body) + if err != nil { + log.Fatal(err) + } + + if err = json.Unmarshal(bodyBytes, &resp); err != nil { + err = fmt.Errorf("api JSON response error: %v", err) + return + } + + if resp.Status == API_STATUS_ERR { + var apiE apiError + if err = json.Unmarshal(resp.Content, &apiE); err != nil { + err = fmt.Errorf("api error JSON unmarshall error: %v", err) + return + } + err = fmt.Errorf(apiE.Message) + } + + return +} + +func (c *Client) login() error { + loginMap := map[string]interface{}{ + "op": "login", + "user": c.User, + "password": c.password, + } + resp, err := c.apiCall(loginMap) + if err != nil { + return err + } + + var apiL apiLogin + if err = json.Unmarshal(resp.Content, &apiL); err != nil { + err = fmt.Errorf("api error JSON unmarshall login: %v", err) + return err + } + if len(apiL.SessionId) < 1 { + return fmt.Errorf("login failed as %s", c.User) + } + c.sessionID = apiL.SessionId + + return nil +} + +// List categories +func (c *Client) Categories() ([]Category, error) { + if err := c.login(); err != nil { + return nil, err + } + + var cat []Category + catMap := map[string]interface{}{ + "op": "getCategories", + "unread_only": "false", + "enble_nested": "false", + "include_empty": "true", + } + resp, err := c.apiCall(catMap) + if err != nil { + return cat, err + } + + if err = json.Unmarshal(resp.Content, &cat); err != nil { + err = fmt.Errorf("api error JSON unmarshall categories: %v", err) + return cat, err + } + + return cat, nil +} + +// List feeds in a category +func (c *Client) List(category int) ([]Item, error) { + if err := c.login(); err != nil { + return nil, err + } + + var items []Item + return items, nil +} + +// Subscribe to feed +func (c *Client) Subscribe() error { + if err := c.login(); err != nil { + return err + } + return nil +} + +// Unsubscribe from feed +func (c *Client) Unsubscribe() error { + if err := c.login(); err != nil { + return err + } + return nil +}