This commit is contained in:
Rasmus Lindroth 2022-12-30 11:39:20 +00:00 committed by GitHub
commit 4fd6f6cc4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 658 additions and 50 deletions

View file

@ -102,6 +102,7 @@ func main() {
* [x] GET /api/v1/follow_requests
* [x] POST /api/v1/follow_requests/:id/authorize
* [x] POST /api/v1/follow_requests/:id/reject
* [x] GET /api/v1/followed_tags
* [x] POST /api/v1/follows
* [x] GET /api/v1/instance
* [x] GET /api/v1/instance/activity
@ -143,16 +144,19 @@ func main() {
* [x] POST /api/v1/statuses/:id/unfavourite
* [x] POST /api/v1/statuses/:id/bookmark
* [x] POST /api/v1/statuses/:id/unbookmark
* [x] GET /api/v1/timelines/home
* [x] GET /api/v1/timelines/public
* [x] GET /api/v1/timelines/tag/:hashtag
* [x] GET /api/v1/timelines/list/:id
* [x] GET /api/v1/streaming/user
* [x] GET /api/v1/streaming/public
* [x] GET /api/v1/streaming/hashtag?tag=:hashtag
* [x] GET /api/v1/streaming/hashtag/local?tag=:hashtag
* [x] GET /api/v1/streaming/list?list=:list_id
* [x] GET /api/v1/streaming/direct
* [x] GET /api/v1/tags/:hashtag
* [x] POST /api/v1/tags/:hashtag/follow
* [x] POST /api/v1/tags/:hashtag/unfollow
* [x] GET /api/v1/timelines/home
* [x] GET /api/v1/timelines/public
* [x] GET /api/v1/timelines/tag/:hashtag
* [x] GET /api/v1/timelines/list/:id
## Installation

View file

@ -284,6 +284,20 @@ func (c *Client) AccountsSearch(ctx context.Context, q string, limit int64) ([]*
return accounts, nil
}
func (c *Client) AccountsSearchResolve(ctx context.Context, q string, limit int64, resolve bool) ([]*Account, error) {
params := url.Values{}
params.Set("q", q)
params.Set("limit", fmt.Sprint(limit))
params.Set("resolve", fmt.Sprint(resolve))
var accounts []*Account
err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/search", params, &accounts, nil)
if err != nil {
return nil, err
}
return accounts, nil
}
// FollowRemoteUser sends follow-request.
func (c *Client) FollowRemoteUser(ctx context.Context, uri string) (*Account, error) {
params := url.Values{}

View file

@ -546,6 +546,44 @@ func TestAccountsSearch(t *testing.T) {
t.Fatalf("want %q but %q", "barfoo", res[1].Username)
}
}
func TestAccountsSearchResolve(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query()["q"][0] != "foo" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if r.FormValue("resolve") != "true" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `[{"username": "foobar"}, {"username": "barfoo"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.AccountsSearchResolve(context.Background(), "zzz", 2, false)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
res, err := client.AccountsSearchResolve(context.Background(), "foo", 2, true)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(res) != 2 {
t.Fatalf("result should be two: %d", len(res))
}
if res[0].Username != "foobar" {
t.Fatalf("want %q but %q", "foobar", res[0].Username)
}
if res[1].Username != "barfoo" {
t.Fatalf("want %q but %q", "barfoo", res[1].Username)
}
}
func TestFollowRemoteUser(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -19,6 +19,18 @@ type Filter struct {
Irreversible bool `json:"irreversible"`
}
type FilterResult struct {
Filter struct {
ID string `json:"id"`
Title string `json:"title"`
Context []string `json:"context"`
ExpiresAt time.Time `json:"expires_at"`
FilterAction string `json:"filter_action"`
} `json:"filter"`
KeywordMatches []string `json:"keyword_matches"`
StatusMatches []string `json:"status_matches"`
}
// GetFilters returns all the filters on the current account.
func (c *Client) GetFilters(ctx context.Context) ([]*Filter, error) {
var filters []*Filter

View file

@ -90,7 +90,7 @@ func (c *Client) DeleteList(ctx context.Context, id ID) error {
func (c *Client) AddToList(ctx context.Context, list ID, accounts ...ID) error {
params := url.Values{}
for _, acct := range accounts {
params.Add("account_ids", string(acct))
params.Add("account_ids[]", string(acct))
}
return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
@ -100,7 +100,7 @@ func (c *Client) AddToList(ctx context.Context, list ID, accounts ...ID) error {
func (c *Client) RemoveFromList(ctx context.Context, list ID, accounts ...ID) error {
params := url.Values{}
for _, acct := range accounts {
params.Add("account_ids", string(acct))
params.Add("account_ids[]", string(acct))
}
return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)

View file

@ -235,7 +235,7 @@ func TestAddToList(t *testing.T) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.PostFormValue("account_ids") != "1" {
if r.PostFormValue("account_ids[]") != "1" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

View file

@ -251,13 +251,6 @@ type Mention struct {
ID ID `json:"id"`
}
// Tag hold information for tag.
type Tag struct {
Name string `json:"name"`
URL string `json:"url"`
History []History `json:"history"`
}
// History hold information for history.
type History struct {
Day string `json:"day"`

View file

@ -594,6 +594,7 @@ func TestForTheCoverages(t *testing.T) {
(*UpdateEvent)(nil).event()
(*UpdateEditEvent)(nil).event()
(*NotificationEvent)(nil).event()
(*ConversationEvent)(nil).event()
(*DeleteEvent)(nil).event()
(*ErrorEvent)(nil).event()
_ = (&ErrorEvent{io.EOF}).Error()

View file

@ -37,8 +37,19 @@ type PushAlerts struct {
// GetNotifications returns notifications.
func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notification, error) {
return c.GetNotificationsExclude(ctx, nil, pg)
}
// GetNotificationsExclude returns notifications with excluded notifications
func (c *Client) GetNotificationsExclude(ctx context.Context, exclude *[]string, pg *Pagination) ([]*Notification, error) {
var notifications []*Notification
err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", nil, &notifications, pg)
params := url.Values{}
if exclude != nil {
for _, ex := range *exclude {
params.Add("exclude_types[]", ex)
}
}
err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", params, &notifications, pg)
if err != nil {
return nil, err
}

View file

@ -15,7 +15,11 @@ func TestGetNotifications(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/notifications":
fmt.Fprintln(w, `[{"id": 122, "action_taken": false}, {"id": 123, "action_taken": true}]`)
if r.URL.Query().Get("exclude_types[]") == "follow" {
fmt.Fprintln(w, `[{"id": 321, "action_taken": true}]`)
} else {
fmt.Fprintln(w, `[{"id": 122, "action_taken": false}, {"id": 123, "action_taken": true}]`)
}
return
case "/api/v1/notifications/123":
fmt.Fprintln(w, `{"id": 123, "action_taken": true}`)
@ -50,6 +54,16 @@ func TestGetNotifications(t *testing.T) {
if ns[1].ID != "123" {
t.Fatalf("want %v but %v", "123", ns[1].ID)
}
nse, err := client.GetNotificationsExclude(context.Background(), &[]string{"follow"}, nil)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(nse) != 1 {
t.Fatalf("result should be one: %d", len(nse))
}
if nse[0].ID != "321" {
t.Fatalf("want %v but %v", "321", nse[0].ID)
}
n, err := client.GetNotification(context.Background(), "123")
if err != nil {
t.Fatalf("should not be fail: %v", err)

View file

@ -15,35 +15,36 @@ import (
// Status is struct to hold status.
type Status struct {
ID ID `json:"id"`
URI string `json:"uri"`
URL string `json:"url"`
Account Account `json:"account"`
InReplyToID interface{} `json:"in_reply_to_id"`
InReplyToAccountID interface{} `json:"in_reply_to_account_id"`
Reblog *Status `json:"reblog"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
EditedAt time.Time `json:"edited_at"`
Emojis []Emoji `json:"emojis"`
RepliesCount int64 `json:"replies_count"`
ReblogsCount int64 `json:"reblogs_count"`
FavouritesCount int64 `json:"favourites_count"`
Reblogged interface{} `json:"reblogged"`
Favourited interface{} `json:"favourited"`
Bookmarked interface{} `json:"bookmarked"`
Muted interface{} `json:"muted"`
Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"`
Visibility string `json:"visibility"`
MediaAttachments []Attachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Tags []Tag `json:"tags"`
Card *Card `json:"card"`
Poll *Poll `json:"poll"`
Application Application `json:"application"`
Language string `json:"language"`
Pinned interface{} `json:"pinned"`
ID ID `json:"id"`
URI string `json:"uri"`
URL string `json:"url"`
Account Account `json:"account"`
InReplyToID interface{} `json:"in_reply_to_id"`
InReplyToAccountID interface{} `json:"in_reply_to_account_id"`
Reblog *Status `json:"reblog"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
EditedAt time.Time `json:"edited_at"`
Emojis []Emoji `json:"emojis"`
RepliesCount int64 `json:"replies_count"`
ReblogsCount int64 `json:"reblogs_count"`
FavouritesCount int64 `json:"favourites_count"`
Reblogged interface{} `json:"reblogged"`
Favourited interface{} `json:"favourited"`
Bookmarked interface{} `json:"bookmarked"`
Muted interface{} `json:"muted"`
Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"`
Visibility string `json:"visibility"`
MediaAttachments []Attachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Tags []Tag `json:"tags"`
Card *Card `json:"card"`
Poll *Poll `json:"poll"`
Application Application `json:"application"`
Language string `json:"language"`
Pinned interface{} `json:"pinned"`
Filtered []FilterResult `json:"filtered"`
}
// StatusHistory is a struct to hold status history data.
@ -102,6 +103,12 @@ type Media struct {
Focus string
}
type TagData struct {
Any []string
All []string
None []string
}
func (m *Media) bodyAndContentType() (io.Reader, string, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
@ -349,6 +356,32 @@ func (c *Client) GetTimelineHashtag(ctx context.Context, tag string, isLocal boo
return statuses, nil
}
// GetTimelineHashtag return statuses from tagged timeline.
func (c *Client) GetTimelineHashtagMultiple(ctx context.Context, tag string, isLocal bool, td *TagData, pg *Pagination) ([]*Status, error) {
params := url.Values{}
if isLocal {
params.Set("local", "t")
}
if td != nil {
for _, v := range td.Any {
params.Add("any[]", v)
}
for _, v := range td.All {
params.Add("all[]", v)
}
for _, v := range td.None {
params.Add("none[]", v)
}
}
var statuses []*Status
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/tag/%s", url.PathEscape(tag)), params, &statuses, pg)
if err != nil {
return nil, err
}
return statuses, nil
}
// GetTimelineList return statuses from a list timeline.
func (c *Client) GetTimelineList(ctx context.Context, id ID, pg *Pagination) ([]*Status, error) {
var statuses []*Status

View file

@ -70,7 +70,7 @@ func TestGetStatus(t *testing.T) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"content": "zzz", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}`)
fmt.Fprintln(w, `{"content": "zzz", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}], "filtered": [{"filter": {"id": "3", "title": "Hide completely", "context": ["home"], "expires_at": "2022-09-20T17:27:39.296Z", "filter_action": "hide"}, "keyword_matches": ["bad word"], "status_matches": ["109031743575371913"]}]}`)
}))
defer ts.Close()
@ -103,6 +103,36 @@ func TestGetStatus(t *testing.T) {
if status.Emojis[0].StaticURL != "http://example.com/static" {
t.Fatalf("want %q but %q", "https://example.com/static", status.Emojis[0].StaticURL)
}
if len(status.Filtered) != 1 {
t.Fatal("should have filtered")
}
if status.Filtered[0].Filter.ID != "3" {
t.Fatalf("want %q but %q", "3", status.Filtered[0].Filter.ID)
}
if status.Filtered[0].Filter.Title != "Hide completely" {
t.Fatalf("want %q but %q", "Hide completely", status.Filtered[0].Filter.Title)
}
if len(status.Filtered[0].Filter.Context) != 1 {
t.Fatal("should have one context")
}
if status.Filtered[0].Filter.Context[0] != "home" {
t.Fatalf("want %q but %q", "home", status.Filtered[0].Filter.Context[0])
}
if status.Filtered[0].Filter.FilterAction != "hide" {
t.Fatalf("want %q but %q", "hide", status.Filtered[0].Filter.FilterAction)
}
if len(status.Filtered[0].KeywordMatches) != 1 {
t.Fatal("should have one matching keyword")
}
if status.Filtered[0].KeywordMatches[0] != "bad word" {
t.Fatalf("want %q but %q", "bad word", status.Filtered[0].KeywordMatches[0])
}
if len(status.Filtered[0].StatusMatches) != 1 {
t.Fatal("should have one matching status")
}
if status.Filtered[0].StatusMatches[0] != "109031743575371913" {
t.Fatalf("want %q but %q", "109031743575371913", status.Filtered[0].StatusMatches[0])
}
}
func TestGetStatusCard(t *testing.T) {
@ -551,6 +581,53 @@ func TestGetTimelineDirect(t *testing.T) {
}
func TestGetTimelineHashtag(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/timelines/tag/zzz" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.FormValue("any[]") != "aaa" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if r.FormValue("all[]") != "bbb" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if r.FormValue("none[]") != "ccc" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
fmt.Fprintln(w, `[{"content": "zzz"},{"content": "yyy"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetTimelineHashtagMultiple(context.Background(), "notfound", false, &TagData{}, nil)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tags, err := client.GetTimelineHashtagMultiple(context.Background(), "zzz", true, &TagData{Any: []string{"aaa"}, All: []string{"bbb"}, None: []string{"ccc"}}, nil)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(tags) != 2 {
t.Fatalf("should have %q entries but %q", "2", len(tags))
}
if tags[0].Content != "zzz" {
t.Fatalf("want %q but %q", "zzz", tags[0].Content)
}
if tags[1].Content != "yyy" {
t.Fatalf("want %q but %q", "zzz", tags[1].Content)
}
}
func TestGetTimelineHashtagMultiple(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/timelines/tag/zzz" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)

View file

@ -39,6 +39,13 @@ type DeleteEvent struct{ ID ID }
func (e *DeleteEvent) event() {}
// ConversationEvent is a struct for passing conversationevent to app.
type ConversationEvent struct {
Conversation *Conversation `json:"conversation"`
}
func (e *ConversationEvent) event() {}
// ErrorEvent is a struct for passing errors to app.
type ErrorEvent struct{ err error }
@ -100,6 +107,12 @@ func handleReader(q chan Event, r io.Reader) error {
if err == nil {
q <- &NotificationEvent{&notification}
}
case "conversation":
var conversation Conversation
err = json.Unmarshal([]byte(token[1]), &conversation)
if err == nil {
q <- &ConversationEvent{&conversation}
}
case "delete":
q <- &DeleteEvent{ID: ID(strings.TrimSpace(token[1]))}
}

View file

@ -30,6 +30,8 @@ event: delete
data: 1234567
event: status.update
data: {"content": "foo"}
event: conversation
data: {"id":"819516","unread":true,"accounts":[{"id":"108892712797543112","username":"a","acct":"a@pl.nulled.red","display_name":"a","locked":false,"bot":true,"discoverable":false,"group":false,"created_at":"2022-08-27T00:00:00.000Z","note":"a (pleroma edition)","url":"https://pl.nulled.red/users/a","avatar":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","avatar_static":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","header":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","header_static":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","followers_count":0,"following_count":0,"statuses_count":362,"last_status_at":"2022-11-13","emojis":[],"fields":[]}],"last_status":{"id":"109346889330629417","created_at":"2022-11-15T08:31:57.476Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"direct","language":null,"uri":"https://pl.nulled.red/objects/c869c5be-c184-4706-a45d-3459d9aa711c","url":"https://pl.nulled.red/objects/c869c5be-c184-4706-a45d-3459d9aa711c","replies_count":0,"reblogs_count":0,"favourites_count":0,"edited_at":null,"favourited":false,"reblogged":false,"muted":false,"bookmarked":false,"content":"test <span class=\"h-card\"><a class=\"u-url mention\" href=\"https://mastodon.social/@trwnh\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>trwnh</span></a></span>","filtered":[],"reblog":null,"account":{"id":"108892712797543112","username":"a","acct":"a@pl.nulled.red","display_name":"a","locked":false,"bot":true,"discoverable":false,"group":false,"created_at":"2022-08-27T00:00:00.000Z","note":"a (pleroma edition)","url":"https://pl.nulled.red/users/a","avatar":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","avatar_static":"https://files.mastodon.social/cache/accounts/avatars/108/892/712/797/543/112/original/975674b2caa61034.png","header":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","header_static":"https://files.mastodon.social/cache/accounts/headers/108/892/712/797/543/112/original/f61d0382356caa0e.png","followers_count":0,"following_count":0,"statuses_count":362,"last_status_at":"2022-11-13","emojis":[],"fields":[]},"media_attachments":[],"mentions":[{"id":"14715","username":"trwnh","url":"https://mastodon.social/@trwnh","acct":"trwnh"}],"tags":[],"emojis":[],"card":null,"poll":null}}
:thump
`, largeContent))
var wg sync.WaitGroup
@ -61,6 +63,11 @@ data: {"content": "foo"}
} else {
t.Fatalf("bad update content: %q", event.Status.Content)
}
case *ConversationEvent:
passNotification = true
if event.Conversation.ID != "819516" {
t.Fatalf("want %q but %q", "819516", event.Conversation.ID)
}
case *NotificationEvent:
passNotification = true
if event.Notification.Type != "mention" {

View file

@ -56,6 +56,11 @@ func (c *WSClient) StreamingWSList(ctx context.Context, id ID) (chan Event, erro
return c.streamingWS(ctx, "list", string(id))
}
// StreamingWSDirect return channel to read events on a direct messages using WebSocket.
func (c *WSClient) StreamingWSDirect(ctx context.Context) (chan Event, error) {
return c.streamingWS(ctx, "direct", "")
}
func (c *WSClient) streamingWS(ctx context.Context, stream, tag string) (chan Event, error) {
params := url.Values{}
params.Set("access_token", c.client.Config.AccessToken)
@ -139,6 +144,12 @@ func (c *WSClient) handleWS(ctx context.Context, rawurl string, q chan Event) er
if err == nil {
q <- &NotificationEvent{Notification: &notification}
}
case "conversation":
var conversation Conversation
err = json.Unmarshal([]byte(s.Payload.(string)), &conversation)
if err == nil {
q <- &ConversationEvent{Conversation: &conversation}
}
case "delete":
if f, ok := s.Payload.(float64); ok {
q <- &DeleteEvent{ID: ID(fmt.Sprint(int64(f)))}

View file

@ -59,6 +59,32 @@ func TestStreamingWSHashtag(t *testing.T) {
wsTest(t, q, cancel)
}
func TestStreamingWSList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(wsMock))
defer ts.Close()
client := NewClient(&Config{Server: ts.URL}).NewWSClient()
ctx, cancel := context.WithCancel(context.Background())
q, err := client.StreamingWSList(ctx, "123")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
wsTest(t, q, cancel)
}
func TestStreamingWSDirect(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(wsMock))
defer ts.Close()
client := NewClient(&Config{Server: ts.URL}).NewWSClient()
ctx, cancel := context.WithCancel(context.Background())
q, err := client.StreamingWSDirect(ctx)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
wsTest(t, q, cancel)
}
func wsMock(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/streaming" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
@ -101,6 +127,13 @@ func wsMock(w http.ResponseWriter, r *http.Request) {
return
}
err = conn.WriteMessage(websocket.TextMessage,
[]byte(`{"event":"conversation","payload":"{\"id\":819516}"}`))
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = conn.WriteMessage(websocket.TextMessage,
[]byte(`{"event":"update","payload":"<html></html>"}`))
if err != nil {
@ -119,8 +152,8 @@ func wsTest(t *testing.T, q chan Event, cancel func()) {
for e := range q {
events = append(events, e)
}
if len(events) != 7 {
t.Fatalf("result should be seven: %d", len(events))
if len(events) != 8 {
t.Fatalf("result should be 8: %d", len(events))
}
if events[0].(*UpdateEvent).Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content)
@ -134,8 +167,8 @@ func wsTest(t *testing.T, q chan Event, cancel func()) {
if events[3].(*DeleteEvent).ID != "1234567" {
t.Fatalf("want %q but %q", "1234567", events[3].(*DeleteEvent).ID)
}
if errorEvent, ok := events[4].(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
if events[4].(*ConversationEvent).Conversation.ID != "819516" {
t.Fatalf("want %q but %q", "819516", events[4].(*ConversationEvent).Conversation.ID)
}
if errorEvent, ok := events[5].(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
@ -143,6 +176,9 @@ func wsTest(t *testing.T, q chan Event, cancel func()) {
if errorEvent, ok := events[6].(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
}
if errorEvent, ok := events[7].(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
}
}
func TestStreamingWS(t *testing.T) {

55
tags.go Normal file
View file

@ -0,0 +1,55 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/url"
)
// Tag hold information for tag.
type Tag struct {
Name string `json:"name"`
URL string `json:"url"`
History []History `json:"history"`
Following interface{} `json:"following"`
}
// TagInfo gets statistics and information about a tag
func (c *Client) TagInfo(ctx context.Context, tag string) (*Tag, error) {
var hashtag Tag
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/tags/%s", url.PathEscape(string(tag))), nil, &hashtag, nil)
if err != nil {
return nil, err
}
return &hashtag, nil
}
// TagFollow lets you follow a hashtag
func (c *Client) TagFollow(ctx context.Context, tag string) (*Tag, error) {
var hashtag Tag
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/tags/%s/follow", url.PathEscape(string(tag))), nil, &hashtag, nil)
if err != nil {
return nil, err
}
return &hashtag, nil
}
// TagUnfollow lets you unfollow a hashtag
func (c *Client) TagUnfollow(ctx context.Context, tag string) (*Tag, error) {
var hashtag Tag
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/tags/%s/unfollow", url.PathEscape(string(tag))), nil, &hashtag, nil)
if err != nil {
return nil, err
}
return &hashtag, nil
}
func (c *Client) TagsFollowed(ctx context.Context, pg *Pagination) ([]*Tag, error) {
var hashtags []*Tag
err := c.doAPI(ctx, http.MethodGet, "/api/v1/followed_tags", nil, &hashtags, pg)
if err != nil {
return nil, err
}
return hashtags, nil
}

289
tags_test.go Normal file
View file

@ -0,0 +1,289 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestTagInfo(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/tags/test" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `
{
"name": "test",
"url": "http://mastodon.example/tags/test",
"history": [
{
"day": "1668124800",
"accounts": "1",
"uses": "2"
},
{
"day": "1668038400",
"accounts": "0",
"uses": "0"
}
]
}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.TagInfo(context.Background(), "foo")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tag, err := client.TagInfo(context.Background(), "test")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if tag.Name != "test" {
t.Fatalf("want %q but %q", "test", tag.Name)
}
if tag.URL != "http://mastodon.example/tags/test" {
t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tag.URL)
}
if len(tag.History) != 2 {
t.Fatalf("result should be two: %d", len(tag.History))
}
if tag.History[0].Day != "1668124800" {
t.Fatalf("want %q but %q", "1668124800", tag.History[0].Day)
}
if tag.History[0].Accounts != "1" {
t.Fatalf("want %q but %q", "1", tag.History[0].Accounts)
}
if tag.History[0].Uses != "2" {
t.Fatalf("want %q but %q", "2", tag.History[0].Uses)
}
if tag.Following != nil {
t.Fatalf("want %v but %q", nil, tag.Following)
}
}
func TestTagFollow(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/tags/test/follow" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `
{
"name": "test",
"url": "http://mastodon.example/tags/test",
"history": [
{
"day": "1668124800",
"accounts": "1",
"uses": "2"
},
{
"day": "1668038400",
"accounts": "0",
"uses": "0"
}
],
"following": true
}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.TagFollow(context.Background(), "foo")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tag, err := client.TagFollow(context.Background(), "test")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if tag.Name != "test" {
t.Fatalf("want %q but %q", "test", tag.Name)
}
if tag.URL != "http://mastodon.example/tags/test" {
t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tag.URL)
}
if len(tag.History) != 2 {
t.Fatalf("result should be two: %d", len(tag.History))
}
if tag.History[0].Day != "1668124800" {
t.Fatalf("want %q but %q", "1668124800", tag.History[0].Day)
}
if tag.History[0].Accounts != "1" {
t.Fatalf("want %q but %q", "1", tag.History[0].Accounts)
}
if tag.History[0].Uses != "2" {
t.Fatalf("want %q but %q", "2", tag.History[0].Uses)
}
if tag.Following != true {
t.Fatalf("want %v but %q", true, tag.Following)
}
}
func TestTagUnfollow(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/tags/test/unfollow" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `
{
"name": "test",
"url": "http://mastodon.example/tags/test",
"history": [
{
"day": "1668124800",
"accounts": "1",
"uses": "2"
},
{
"day": "1668038400",
"accounts": "0",
"uses": "0"
}
],
"following": false
}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.TagUnfollow(context.Background(), "foo")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tag, err := client.TagUnfollow(context.Background(), "test")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if tag.Name != "test" {
t.Fatalf("want %q but %q", "test", tag.Name)
}
if tag.URL != "http://mastodon.example/tags/test" {
t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tag.URL)
}
if len(tag.History) != 2 {
t.Fatalf("result should be two: %d", len(tag.History))
}
if tag.History[0].Day != "1668124800" {
t.Fatalf("want %q but %q", "1668124800", tag.History[0].Day)
}
if tag.History[0].Accounts != "1" {
t.Fatalf("want %q but %q", "1", tag.History[0].Accounts)
}
if tag.History[0].Uses != "2" {
t.Fatalf("want %q but %q", "2", tag.History[0].Uses)
}
if tag.Following != false {
t.Fatalf("want %v but %q", false, tag.Following)
}
}
func TestTagsFollowed(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/followed_tags" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.FormValue("limit") == "1" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `
[{
"name": "test",
"url": "http://mastodon.example/tags/test",
"history": [
{
"day": "1668124800",
"accounts": "1",
"uses": "2"
},
{
"day": "1668038400",
"accounts": "0",
"uses": "0"
}
],
"following": true
},
{
"name": "foo",
"url": "http://mastodon.example/tags/foo",
"history": [
{
"day": "1668124800",
"accounts": "1",
"uses": "2"
},
{
"day": "1668038400",
"accounts": "0",
"uses": "0"
}
],
"following": true
}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.TagsFollowed(context.Background(), &Pagination{Limit: 1})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tags, err := client.TagsFollowed(context.Background(), nil)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(tags) != 2 {
t.Fatalf("want %q but %q", 2, len(tags))
}
if tags[0].Name != "test" {
t.Fatalf("want %q but %q", "test", tags[0].Name)
}
if tags[0].URL != "http://mastodon.example/tags/test" {
t.Fatalf("want %q but %q", "http://mastodon.example/tags/test", tags[0].URL)
}
if len(tags[0].History) != 2 {
t.Fatalf("result should be two: %d", len(tags[0].History))
}
if tags[0].History[0].Day != "1668124800" {
t.Fatalf("want %q but %q", "1668124800", tags[0].History[0].Day)
}
if tags[0].History[0].Accounts != "1" {
t.Fatalf("want %q but %q", "1", tags[0].History[0].Accounts)
}
if tags[0].History[0].Uses != "2" {
t.Fatalf("want %q but %q", "2", tags[0].History[0].Uses)
}
if tags[0].Following != true {
t.Fatalf("want %v but %q", nil, tags[0].Following)
}
}