Repository: mattn/go-mastodon
Pull request #202: Add multiple features from downstream fork for tut client
Author: login: kchr
Created: 2025-05-10T19:43:13Z | Merged: 2025-05-11T03:19:09Z

This PR includes multiple features and fixes developed by @RasmusLindroth in his fork made for the [tut](https://github.com/RasmusLindroth/tut) client.

His fork is available here:
https://github.com/RasmusLindroth/go-mastodon

He is on hiatus and has stopped working on the fork and the client. However, the client is very popular and in order to fix some bugs reported I have rebased his fork so that the client can be rewritten to use the official upstream (this) module instead.

I have squashed and removed some code from his fork that weren't really needed, and reworded the commit messages to be more clear on what has been added. Please let me know if there are any issues in terms of code style or anything else that would prevent the PR from being merged, and I'll fix it.

Included in this PR:

- Follow tags
- Fetch list of followed tags
- Fetch info for tag
- Fetch timeline with multiple tags
- Fetch notifications with specific notification types excluded
- Search accounts and resolve WebFinger lookup
- Support for filtered statuses
- Bug fix for adding and removing accounts to list
- Additional test cases and minor fixes


Diff:
diff --git a/README.md b/README.md
index cdd02fe..3b4aabb 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,9 @@ This option allows you to create an application that can interact with the Masto
 * [x] GET /api/v1/streaming/list?list=:list_id
 * [x] GET /api/v1/streaming/direct
 * [x] GET /api/v1/endorsements
+* [x] GET /api/v1/tags/:hashtag
+* [x] POST /api/v1/tags/:hashtag/follow
+* [x] POST /api/v1/tags/:hashtag/unfollow
 
 ## Installation
 
diff --git a/accounts.go b/accounts.go
index 33f8e0f..7db4443 100644
--- a/accounts.go
+++ b/accounts.go
@@ -342,6 +342,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{}
diff --git a/accounts_test.go b/accounts_test.go
index 1f73418..54c7920 100644
--- a/accounts_test.go
+++ b/accounts_test.go
@@ -667,6 +667,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) {
diff --git a/cmd/mstdn/README.md b/cmd/mstdn/README.md
deleted file mode 100644
index cc6f01c..0000000
--- a/cmd/mstdn/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# mstdn
-
-command line tool for mstdn.jp
-
-## Usage
-
-```
-NAME:
-   mstdn - mastodon client
-
-USAGE:
-   mstdn [global options] command [command options] [arguments...]
-
-VERSION:
-   0.0.1
-
-COMMANDS:
-     toot          post toot
-     stream        stream statuses
-     timeline      show timeline
-     notification  show notification
-     instance      show instance information
-     account       show account information
-     search        search content
-     follow        follow account
-     followers     show followers
-     upload        upload file
-     delete        delete status
-     init          initialize profile
-     mikami        search mikami
-     xsearch       cross search
-     help, h       Shows a list of commands or help for one command
-
-GLOBAL OPTIONS:
-   --profile value  profile name
-   --help, -h       show help
-   --version, -v    print the version
-```
-
-## Installation
-
-```
-$ go get github.com/mattn/go-mastodon/cmd/mstdn
-```
-
-## License
-
-MIT
-
-## Author
-
-Yasuhiro Matsumoto (a.k.a. mattn)
diff --git a/cmd/mstdn/cmd_stream_test.go b/cmd/mstdn/cmd_stream_test.go
index b3b37c7..62dd655 100644
--- a/cmd/mstdn/cmd_stream_test.go
+++ b/cmd/mstdn/cmd_stream_test.go
@@ -10,7 +10,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mattn/go-mastodon"
+	"github.com/RasmusLindroth/go-mastodon"
 )
 
 func TestCmdStream(t *testing.T) {
diff --git a/filters.go b/filters.go
index 56f1229..c2be553 100644
--- a/filters.go
+++ b/filters.go
@@ -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
diff --git a/instance.go b/instance.go
index a2aa7f4..8d34ebe 100644
--- a/instance.go
+++ b/instance.go
@@ -20,7 +20,7 @@ type Instance struct {
 	Configuration  *InstanceConfig   `json:"configuration"`
 }
 
-type InstanceConfigMap map[string]int
+type InstanceConfigMap map[string]interface{}
 
 // InstanceConfig holds configuration accessible for clients.
 type InstanceConfig struct {
diff --git a/lists.go b/lists.go
index 59610cc..9256f5d 100644
--- a/lists.go
+++ b/lists.go
@@ -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)
diff --git a/lists_test.go b/lists_test.go
index b1c9e3b..b26bc4a 100644
--- a/lists_test.go
+++ b/lists_test.go
@@ -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
 		}
diff --git a/mastodon_test.go b/mastodon_test.go
index 36cee1b..15ff940 100644
--- a/mastodon_test.go
+++ b/mastodon_test.go
@@ -679,6 +679,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()
diff --git a/notification.go b/notification.go
index c30abf5..ef71a19 100644
--- a/notification.go
+++ b/notification.go
@@ -36,8 +36,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
 	}
diff --git a/notification_test.go b/notification_test.go
index 78cdbdd..efdb6f6 100644
--- a/notification_test.go
+++ b/notification_test.go
@@ -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)
diff --git a/status.go b/status.go
index dc1e90a..810329c 100644
--- a/status.go
+++ b/status.go
@@ -45,6 +45,7 @@ type Status struct {
 	Language           string          `json:"language"`
 	Pinned             interface{}     `json:"pinned"`
 	ScheduledParams    ScheduledParams `json:"params"`
+	Filtered           []FilterResult  `json:"filtered"`
 }
 
 // StatusHistory is a struct to hold status history data.
@@ -117,6 +118,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)
@@ -364,6 +371,32 @@ func (c *Client) GetTimelineHashtag(ctx context.Context, tag string, isLocal boo
 	return statuses, nil
 }
 
+// GetTimelineHashtagMultiple 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
diff --git a/status_test.go b/status_test.go
index 2368299..726f22a 100644
--- a/status_test.go
+++ b/status_test.go
@@ -175,7 +175,16 @@ 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"
+				}
+			]
+		}`)
 	}))
 	defer ts.Close()
 
@@ -210,6 +219,105 @@ func TestGetStatus(t *testing.T) {
 	}
 }
 
+func TestGetStatusFiltered(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/api/v1/statuses/1234567" {
+			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"
+				}
+			],
+			"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()
+
+	client := NewClient(&Config{
+		Server:       ts.URL,
+		ClientID:     "foo",
+		ClientSecret: "bar",
+		AccessToken:  "zoo",
+	})
+	_, err := client.GetStatus(context.Background(), "123")
+	if err == nil {
+		t.Fatalf("should be fail: %v", err)
+	}
+	status, err := client.GetStatus(context.Background(), "1234567")
+	if err != nil {
+		t.Fatalf("should not be fail: %v", err)
+	}
+	if status.Content != "zzz" {
+		t.Fatalf("want %q but %q", "zzz", status.Content)
+	}
+	if len(status.Emojis) != 1 {
+		t.Fatal("should have emojis")
+	}
+	if status.Emojis[0].ShortCode != "💩" {
+		t.Fatalf("want %q but %q", "💩", status.Emojis[0].ShortCode)
+	}
+	if status.Emojis[0].URL != "http://example.com" {
+		t.Fatalf("want %q but %q", "http://example.com", status.Emojis[0].URL)
+	}
+	if status.Emojis[0].StaticURL != "http://example.com/static" {
+		t.Fatalf("want %q but %q", "http://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) {
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if r.URL.Path != "/api/v1/statuses/1234567/card" {
@@ -655,6 +763,53 @@ func TestGetTimelineDirect(t *testing.T) {
 	}
 }
 
+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)
+			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", "yyy", tags[1].Content)
+	}
+}
+
 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" {
diff --git a/streaming.go b/streaming.go
index 2517d3a..779ebb6 100644
--- a/streaming.go
+++ b/streaming.go
@@ -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]))}
 			}
diff --git a/streaming_test.go b/streaming_test.go
index 759e0d0..4b9b325 100644
--- a/streaming_test.go
+++ b/streaming_test.go
@@ -30,10 +30,13 @@ 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
 	wg.Add(1)
+	errs := make(chan error, 1)
 	go func() {
 		defer wg.Done()
 		defer close(q)
@@ -41,6 +44,7 @@ data: {"content": "foo"}
 		if err != nil {
 			t.Errorf("should not be fail: %v", err)
 		}
+		errs <- err
 	}()
 	var passUpdate, passUpdateLarge, passNotification, passDelete, passError bool
 	for e := range q {
@@ -61,6 +65,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" {
@@ -84,6 +93,10 @@ data: {"content": "foo"}
 			passUpdate, passUpdateLarge, passNotification, passDelete, passError)
 	}
 	wg.Wait()
+	err := <-errs
+	if err != nil {
+		t.Fatalf("should not be fail: %v", err)
+	}
 }
 
 func TestStreaming(t *testing.T) {
diff --git a/streaming_ws.go b/streaming_ws.go
index ad9d7c2..76f018e 100644
--- a/streaming_ws.go
+++ b/streaming_ws.go
@@ -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)))}
diff --git a/streaming_ws_test.go b/streaming_ws_test.go
index 8f4b108..bda3e9c 100644
--- a/streaming_ws_test.go
+++ b/streaming_ws_test.go
@@ -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,12 @@ 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[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) {
diff --git a/tags.go b/tags.go
index 54a9b67..d68950b 100644
--- a/tags.go
+++ b/tags.go
@@ -4,8 +4,29 @@ import (
 	"context"
 	"fmt"
 	"net/http"
+	"net/url"
 )
 
+// TagInfo gets statistics and information about a tag
+func (c *Client) TagInfo(ctx context.Context, tag string) (*FollowedTag, error) {
+	var hashtag FollowedTag
+	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) (*FollowedTag, error) {
+	var hashtag FollowedTag
+	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 unfollows a hashtag.
 func (c *Client) TagUnfollow(ctx context.Context, ID string) (*FollowedTag, error) {
 	var tag FollowedTag
@@ -15,3 +36,13 @@ func (c *Client) TagUnfollow(ctx context.Context, ID string) (*FollowedTag, erro
 	}
 	return &tag, nil
 }
+
+// TagsFollowed returns a list of hashtags you follow.
+func (c *Client) TagsFollowed(ctx context.Context, pg *Pagination) ([]*FollowedTag, error) {
+	var hashtags []*FollowedTag
+	err := c.doAPI(ctx, http.MethodGet, "/api/v1/followed_tags", nil, &hashtags, pg)
+	if err != nil {
+		return nil, err
+	}
+	return hashtags, nil
+}
diff --git a/tags_test.go b/tags_test.go
index a2eed27..2f1298f 100644
--- a/tags_test.go
+++ b/tags_test.go
@@ -6,53 +6,250 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"testing"
+	"time"
 )
 
-func TestTagUnfollow(t *testing.T) {
-	t.Parallel()
+func TestTagInfo(t *testing.T) {
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprintln(w, `{
-			"name": "Test",
+		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": "1668556800",
-				"accounts": "0",
-				"uses": "0"
-				},
-				{
-				"day": "1668470400",
+			  {
+				"day": "1668124800",
+				"accounts": "1",
+				"uses": "2"
+			  },
+			  {
+				"day": "1668038400",
 				"accounts": "0",
 				"uses": "0"
-				},
-				{
-				"day": "1668384000",
+			  }
+			]
+		}`)
+	}))
+	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))
+	}
+	uts := UnixTimeString{ time.Unix(1668124800, 0) }
+	if tag.History[0].Day != uts {
+		t.Fatalf("want %q but %q", uts, 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 %v", 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"
-				},
-				{
-				"day": "1668297600",
+			  }
+			],
+			"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))
+	}
+	uts := UnixTimeString{ time.Unix(1668124800, 0) }
+	if tag.History[0].Day != uts {
+		t.Fatalf("want %q but %q", uts, 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 %v", 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": "1"
-				},
-				{
-				"day": "1668211200",
+				"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))
+	}
+	uts := UnixTimeString{ time.Unix(1668124800, 0) }
+	if tag.History[0].Day != uts {
+		t.Fatalf("want %q but %q", uts, 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 %v", 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": false
-		}`)
+			"following": true
+		}]`)
 	}))
 	defer ts.Close()
 
@@ -62,14 +259,37 @@ func TestTagUnfollow(t *testing.T) {
 		ClientSecret: "bar",
 		AccessToken:  "zoo",
 	})
-	tag, err := client.TagUnfollow(context.Background(), "Test")
+	_, 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 tag.Name != "Test" {
-		t.Fatalf("want %q but %q", "Test", tag.Name)
+	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))
+	}
+	uts := UnixTimeString{ time.Unix(1668124800, 0) }
+	if tags[0].History[0].Day != uts {
+		t.Fatalf("want %q but %q", uts, 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 tag.Following {
-		t.Fatalf("want %t but %t", false, tag.Following)
+	if tags[0].Following != true {
+		t.Fatalf("want %v but %v", true, tags[0].Following)
 	}
 }


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR rebases and integrates multiple features and fixes from a downstream fork for the tut client while removing unnecessary code. Key changes include the addition of tag endpoints (info, follow, unfollow, and followed tags), new streaming events including conversation events, and enhanced tests for status filtering, lists, and account search resolve.

### Reviewed Changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

<details>
<summary>Show a summary per file</summary>

| File                           | Description                                                         |
| ------------------------------ | ------------------------------------------------------------------- |
| tags_test.go                   | Updated tests to validate tag endpoints and history responses.      |
| tags.go                        | Added methods for tag information, follow, unfollow, and retrieval.   |
| streaming_ws_test.go           | Added tests for WS list and direct streams and adjusted event checks. |
| streaming_ws.go                | Added direct message WebSocket endpoint and conversation handling.  |
| streaming_test.go              | Updated tests to include conversation event and extra error events.   |
| streaming.go                   | Added conversation event handling in the streaming reader.            |
| status_test.go                 | Enhanced status tests with filtered statuses and hashtag timeline.    |
| notification_test.go            | Extended tests to validate excluded notification types.             |
| lists_test.go & lists.go         | Updated list methods to use array query parameters for account IDs.   |
| instance.go                    | Adjusted InstanceConfigMap type to allow flexible values.             |
| filters.go                     | Added a new struct for filter results.                                |
| accounts_test.go & accounts.go   | Introduced account search resolve functionality.                   |
| README.md & cmd/mstdn/*         | Updated docs and import paths to reflect new tag endpoints and changes.|
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T03:17:11Z

Review comments:
diff_hunk: @@ -210,6 +219,105 @@ func TestGetStatus(t *testing.T) {
 	}
 }
 
+func TestGetStatusFiltered(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/api/v1/statuses/1234567" {
+			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"
+				}
+			],
+			"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()
+
+	client := NewClient(&Config{
+		Server:       ts.URL,
+		ClientID:     "foo",
+		ClientSecret: "bar",
+		AccessToken:  "zoo",
+	})
+	_, err := client.GetStatus(context.Background(), "123")
+	if err == nil {
+		t.Fatalf("should be fail: %v", err)
+	}
+	status, err := client.GetStatus(context.Background(), "1234567")
+	if err != nil {
+		t.Fatalf("should not be fail: %v", err)
+	}
+	if status.Content != "zzz" {
+		t.Fatalf("want %q but %q", "zzz", status.Content)
+	}
+	if len(status.Emojis) != 1 {
+		t.Fatal("should have emojis")
+	}
+	if status.Emojis[0].ShortCode != "💩" {
+		t.Fatalf("want %q but %q", "💩", status.Emojis[0].ShortCode)
+	}
+	if status.Emojis[0].URL != "http://example.com" {
+		t.Fatalf("want %q but %q", "https://example.com", status.Emojis[0].URL)
path: status_test.go
user: login: Copilot
body: The expected URL in the error message is inconsistent with the comparison value; update the error message to expect "http://example.com" instead of "https://example.com".
```suggestion
		t.Fatalf("want %q but %q", "http://example.com", status.Emojis[0].URL)
```
created_at: 2025-05-11T03:17:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -210,6 +219,105 @@ func TestGetStatus(t *testing.T) {
 	}
 }
 
+func TestGetStatusFiltered(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/api/v1/statuses/1234567" {
+			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"
+				}
+			],
+			"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()
+
+	client := NewClient(&Config{
+		Server:       ts.URL,
+		ClientID:     "foo",
+		ClientSecret: "bar",
+		AccessToken:  "zoo",
+	})
+	_, err := client.GetStatus(context.Background(), "123")
+	if err == nil {
+		t.Fatalf("should be fail: %v", err)
+	}
+	status, err := client.GetStatus(context.Background(), "1234567")
+	if err != nil {
+		t.Fatalf("should not be fail: %v", err)
+	}
+	if status.Content != "zzz" {
+		t.Fatalf("want %q but %q", "zzz", status.Content)
+	}
+	if len(status.Emojis) != 1 {
+		t.Fatal("should have emojis")
+	}
+	if status.Emojis[0].ShortCode != "💩" {
+		t.Fatalf("want %q but %q", "💩", status.Emojis[0].ShortCode)
+	}
+	if status.Emojis[0].URL != "http://example.com" {
+		t.Fatalf("want %q but %q", "https://example.com", status.Emojis[0].URL)
+	}
+	if status.Emojis[0].StaticURL != "http://example.com/static" {
+		t.Fatalf("want %q but %q", "https://example.com/static", status.Emojis[0].StaticURL)
path: status_test.go
user: login: Copilot
body: The expected static URL in the error message does not match the comparison value; update the error message to expect "http://example.com/static".
```suggestion
		t.Fatalf("want %q but %q", "http://example.com/static", status.Emojis[0].StaticURL)
```
created_at: 2025-05-11T03:17:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -655,6 +763,53 @@ func TestGetTimelineDirect(t *testing.T) {
 	}
 }
 
+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)
+			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)
path: status_test.go
user: login: Copilot
body: The failure message incorrectly expects "zzz" for the second tag even though the comparison checks for "yyy"; change the expected error message to "yyy".
```suggestion
		t.Fatalf("want %q but %q", "yyy", tags[1].Content)
```
created_at: 2025-05-11T03:17:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: mattn
created_at: 2025-05-11T03:15:55Z
body: Looks nice.
author_association: OWNER
user: login: mattn
created_at: 2025-05-11T03:19:12Z
body: Thank you
author_association: OWNER
user: login: mattn
created_at: 2025-05-11T03:24:25Z
body: Hmm, tests are failing...
author_association: OWNER
user: login: kchr
created_at: 2025-05-11T11:02:22Z
body: @mattn I got that failure once while rebasing but couldn't get it to reproduce when finished.

```
--- FAIL: TestStreaming (1.00s)
    streaming_test.go:163: have not passed through somewhere: error false, update true
FAIL
exit status 1
FAIL	github.com/mattn/go-mastodon	13.0[5](https://github.com/mattn/go-mastodon/actions/runs/14951795270/job/42002224760#step:7:6)3s
Error: Process completed with exit code 1.
```

Looking at the particular test, it feels a bit strange that the failure condition is that either `passError` or `passUpdate` is `false`. The mock response from the HTTP handler is a single `update` event, which should make the switch statement enter the `*UpdateEvent` case but not the other two cases two, right? If so, only `passUpdate` would be set to true since `passError` is only set to true in the `*ErrorEvent` case?

https://github.com/mattn/go-mastodon/blob/master/streaming_test.go#L137

**UPDATE:** I ran the test suite locally in a loop, and it failed once in 20 runs... I don't understand why :-(
author_association: CONTRIBUTOR
user: login: kchr
created_at: 2025-05-11T11:44:12Z
body: @mattn I just checked out a local working copy of the previous master branch (commit `7315f12d2af8fbae5d95fcb8ece2bae6333814ec`), and the test seems to fail there as well. Not every time, but once in every 10-20 runs...
author_association: CONTRIBUTOR