Compare commits

...

117 Commits

Author SHA1 Message Date
Brint E. Kriebel 9faaa4f0dc Add support for edited statuses
ActivityPub supports "status.update" for editing statuses. These should
be made available for streams.
2022-12-28 23:59:18 +09:00
till 6e810f25fa Chore: update actions and go versions
Resolves: #157
2022-12-28 23:55:58 +09:00
till 3203150fd3 Update: support configuration
This exposes settings for clients, so it's nice to be able to
access it directly.
2022-12-28 23:51:48 +09:00
Michal Vyskocil 29bb16009b fix go vet warnings: call to (*T).Fatalf from a non-test goroutine
The goroutine started from test must not call t.Fatal, but t.Error. Adds
a sync.WaitGroup to make sure all goroutines have a chance to report an
error before test stops.
2022-12-13 09:12:56 +09:00
Raffaele Sena ae970802cf Add command timeline-tag to search for statuses matching the tag.
Since `search` only return the list of tags matching the search, this is
helpful to actually see the statuses for a particular tag.
2022-12-01 02:49:06 +09:00
Rasmus Lindroth 98f591c5e2 add comment for GetStatusSource 2022-11-30 08:16:07 +09:00
Rasmus Lindroth 51f9d7f999 add source, history for statuses and option to update a status 2022-11-30 08:16:07 +09:00
Yasuhiro Matsumoto b597f437a9
use urfave/cli/v2 2022-11-27 13:24:42 +09:00
Rasmus Lindroth 5f0c9a21c2 add support for language 2022-11-17 07:16:38 +09:00
Darren O'Connor be61205708 Remove unused variables 2022-11-17 07:15:36 +09:00
Darren O'Connor 9e1af56ceb remove redundent return statements 2022-11-16 10:49:14 +09:00
Darren O'Connor f76d33a68c switch os.ReadFile to ioutil.ReadFile due to Ubuntu 15 test failing 2022-11-15 13:44:33 +09:00
Darren O'Connor e5c082de35 Add UploadMediaFromBytes function 2022-11-15 13:44:33 +09:00
Darren O'Connor 309dce6ff3 minor spelling fixes 2022-11-14 09:04:46 +09:00
Mark Ayers 114537dcc0 Update install instruction 2022-11-12 21:58:04 +09:00
Tyr Mactire 7dfe81e233 docs 2022-08-27 13:35:59 +09:00
Tyr Mactire 2a3ac1d1d5 Client Credentials 2022-08-27 13:35:59 +09:00
Yasuhiro Matsumoto d272534ac7
Separate go.mod 2022-06-04 23:01:37 +09:00
Rasmus Lindroth b2204e0d6a add support for pinned posts 2022-06-04 22:56:14 +09:00
Rasmus Lindroth c6a292132e add Discoverable and Source on Account 2022-06-04 22:56:14 +09:00
Alexander Bakker 87278bda2e Use bufio.Reader instead of bufio.Scanner when streaming
I occasionally run into "bufio.Scanner: token too long" while
streaming. This change should prevent that from happening.
2022-05-03 22:16:38 +09:00
Rasmus Lindroth f436c5397c fix slash 2022-05-01 22:52:28 +09:00
Rasmus Lindroth dfa87f3a80 add support for filters 2022-05-01 22:52:28 +09:00
Marian Steinbach 7745e19ff7 Only set Authorization header when AccessToken is set 2021-12-14 20:55:46 +09:00
Marian Steinbach 45d75e8085 Update badge in README to point to pkg.go.dev 2021-11-23 23:53:31 +09:00
shine 62214db600 document bookmark APIs
follows d39c10b which introduced the bookmark APIs.
2021-11-06 11:01:00 +09:00
Rasmus Lindroth 4cbbf813dc add test for PostStatus 2021-11-06 11:00:42 +09:00
Rasmus Lindroth ae6cc11820 Add support for creating polls 2021-11-06 11:00:42 +09:00
Rasmus Lindroth 58c3891813 Add support to vote on polls. Add more fields to Poll 2021-11-05 00:02:01 +09:00
Yasuhiro Matsumoto a0db0ed8a0
Update dependencies 2021-11-04 23:06:26 +09:00
Yasuhiro Matsumoto 1dd699ecee
Update README.md 2021-11-04 23:05:34 +09:00
Yasuhiro Matsumoto f5813a9d88
Enable GitHub Workflows 2021-11-04 23:04:38 +09:00
Rasmus Lindroth d39c10ba5e Add bookmark support 2021-06-30 00:13:05 +09:00
Masahiro Furudate 86627ec7d6 Fix godoc badge to godev badge 2021-05-15 23:43:04 +09:00
WaybackBot bf42b86b9f Add streaming direct support 2021-05-15 23:42:50 +09:00
WaybackBot eb26687c84 Update README 2021-05-15 23:42:50 +09:00
Hanage999 8c434b5282 Replace deprecated /api/v1/notifications/dismiss with new API 2021-05-15 23:42:24 +09:00
Ollivier Robert 6abe72ddb0 Fix TestCmdTimelone() as well. 2021-04-17 21:32:10 +09:00
Ollivier Robert d6cb307605 Remove leftover Logf() call. 2021-04-17 21:32:10 +09:00
Ollivier Robert 2ae3a80997 Fix tests to use data from Conversation, not Timelines. 2021-04-17 21:32:10 +09:00
Ollivier Robert 932595ebec Fix allocation. 2021-04-17 21:32:10 +09:00
Ollivier Robert 2f161cfa50 Allocate space to prevent SIGSEGV. 2021-04-17 21:32:10 +09:00
Ollivier Robert adff0e83b9 Use Conversations. 2021-04-17 21:32:10 +09:00
Masahiro Furudate 315df7d916 Add Media struct and UploadMediaFromMedia method 2020-07-27 10:41:06 +09:00
Christian Muehlhaeuser 3e91c76504 Added polls entity, available since API 2.8.0 2020-03-02 11:39:13 +09:00
Christian Muehlhaeuser 1c0769492b Fix History struct members 2020-03-02 11:37:33 +09:00
Christian Muehlhaeuser 75578dd249 Update search to use v2 API endpoint
v1 has been disabled on most instances by now.
The change is minor: hash-tags are now reported as proper structs
instead of a simple string-array.
2020-03-02 11:37:25 +09:00
hiromi-mi 021f5d0019 fix CI for DeleteConversation 2020-02-25 22:09:24 +09:00
hiromi-mi fd6533a508 Support conversations API
Support these APIs added in Mastodon 2.6.0.
- GET /api/v1/conversations
- DELETE /api/v1/conversations/:id
- POST /api/v1/conversations/:id/read
2020-02-25 22:09:24 +09:00
mattn 151613575d
Delete renovate.json 2020-01-28 19:41:13 +09:00
Renovate Bot 845b4bd395 Update golang.org/x/net commit hash to 6afb519 2020-01-16 22:12:54 +09:00
Renovate Bot 822b3dfd49 Update module mattn/go-tty to v0.0.3 2020-01-09 19:31:20 +09:00
Renovate Bot 1c963e9982 Update module gorilla/websocket to v1.4.1 2020-01-09 19:30:09 +09:00
Renovate Bot edf591bde6 Update golang.org/x/net commit hash to c0dbc17 2020-01-09 18:56:36 +09:00
Renovate Bot a4693067ab Update module fatih/color to v1.9.0 2020-01-09 18:54:25 +09:00
Renovate Bot 4275f0739f Add renovate.json 2020-01-09 18:51:47 +09:00
buckket e43f2060a8 Modify test fixture to increse test coverage 2019-09-30 17:19:22 +09:00
buckket 2abdb8e37c Add support for /api/v1/push/subscription 2019-09-30 17:19:22 +09:00
Christian Muehlhaeuser 25da74b864 Fix follower pagination in cmd/mstdn
Make sure to reset Pagination's MinID on every iteration, as
discussed in #99.
2019-09-30 17:18:07 +09:00
Ben Lubar c9e2d23df3 Make ScheduledAt a pointer. 2019-09-30 17:17:15 +09:00
Ben Lubar 977e6c550e Add support for creating scheduled posts 2019-09-30 17:17:15 +09:00
Ben Lubar 934e685e7a Add convenience constants for post visibilities 2019-09-30 17:17:15 +09:00
Yasuhiro Matsumoto e24991527b Fix test 2019-09-30 17:15:56 +09:00
Yasuhiro Matsumoto 80c1d52a0d Fix tests 2019-09-30 17:15:56 +09:00
Yasuhiro Matsumoto ef1332c96b Fix contact_account 2019-09-30 17:15:56 +09:00
Christian Muehlhaeuser 1b7f743892 Fix ineffectual assignments
- Don't assign variables we don't end up using
- Added missing error check in test
2019-09-30 17:14:20 +09:00
Christian Muehlhaeuser 1ccf66b8b4 Fix code formatting
Used goimports to fix code formatting.
2019-09-30 17:13:43 +09:00
Christian Muehlhaeuser 20bc690d8d Simplify code
- Removed redundant returns
- Implicitly declare httptest.Server
2019-09-30 17:13:12 +09:00
Christian Muehlhaeuser 24cdbe8c3d Unlambda textContent call
textContent already has the right function signature, no need to wrap it.
2019-09-30 17:12:31 +09:00
dtluna 34e64bb423 Make Client.Config public 2019-08-22 14:21:32 +09:00
mattn 050f1a0a87
Create FUNDING.yml 2019-08-14 15:00:40 +09:00
Christian Muehlhaeuser 8a48862adc Use a slightly more aggressive backoff approach
Doubling the backoff every iteration turned out to be a bit
too relaxed for the common situations where you run into API
throttling. This change gives the API enough room to breathe,
but re-tries requests just a little more often.
2019-08-08 16:47:43 +09:00
Yasuhiro Matsumoto 26fcedc8aa Update badge 2019-06-22 02:17:37 +09:00
Yasuhiro Matsumoto 536597515d Temporary disable test with -race 2019-06-22 02:17:37 +09:00
Yasuhiro Matsumoto 8826198705 Only tip 2019-06-22 02:17:37 +09:00
Yasuhiro Matsumoto 23fc4c7953 Switch to codecov 2019-06-22 02:17:37 +09:00
Yasuhiro Matsumoto 559ed99cdf Add direct
Closes #102
2019-06-22 01:52:45 +09:00
Yasuhiro Matsumoto 3268207afe Set User-Agent 2019-06-22 01:32:52 +09:00
buckket 8f6192e26b Add /api/v1/notifications/dismiss 2019-05-17 10:56:15 +09:00
Christian Muehlhaeuser c09198f7c9 Fixed pagination parsing for non-numeric IDs
Mastodon API's pagination IDs are not guaranteed to be in a numeric format.
This happens to be the case for the tootsuite implementation, but others
use UUIDs or flake IDs for pagination.

This also simplifies the code a bit and luckily shouldn't break backwards
compatibility since they're already of type ID in the Pagination struct.
2019-05-17 10:55:38 +09:00
178inaba f51571807d Add go.mod 2019-05-14 14:57:23 +09:00
buckket e71411ef96 All parameters are now of pointer type and thus can be nil 2019-05-14 14:53:54 +09:00
buckket 636b33ad1c Renamed Source to AccountSource 2019-05-14 14:53:54 +09:00
buckket 68ca31fccd Add support for Field, Source and Locked parameters to AccountUpdate() 2019-05-14 14:53:54 +09:00
Christian Muehlhaeuser bb2662b33c Handle HTTP 429 responses with a request backoff approach
Since it's difficult to wrap all possible go-mastodon API calls in a backoff
algorithm outside of the package itself, I decided to implement a simple
version of it in go-mastodon's doAPI itself.

This works nicely, but could be improved in two ways still:

- Abort sleeping when context gets cancelled
- Make backoff optional / configurable

Personally, I still think this is a good start and probably fits most of
go-mastodon's use-cases. It certainly beats string-grepping for status code
"429" in clients.
2019-05-14 14:52:36 +09:00
Christian Muehlhaeuser e725c81450 Added tests for list API calls 2019-05-12 23:58:10 +09:00
Ben Lubar 9427a55316 Add list timeline support. 2019-05-12 23:58:10 +09:00
Ben Lubar 3e2bdc63c7 Add streaming list support. 2019-05-12 23:58:10 +09:00
Ben Lubar 6f05c48bf6 Add list API support. 2019-05-12 23:58:10 +09:00
Brian C. Lindner e804ee7eb2 Added UploadMediaFromReader 2019-03-11 14:18:07 +09:00
178inaba efa05aa949 Fix TestNewPagination
Add min_id error pattern.
2019-02-15 10:03:18 +09:00
178inaba 460b971f54 Fix TestPaginationSetValues 2019-02-15 10:03:18 +09:00
178inaba 1fcdf9f501 Fix TestPaginationSetValues 2019-02-15 10:03:18 +09:00
178inaba 5fd7d16157 Fix followers command
Set empty for SinceID.
2019-02-15 10:03:18 +09:00
178inaba b8bb5ae68c Add MinID to Pagination 2019-02-15 10:03:18 +09:00
Jessica Paczuski 6bf95fc751 Rename Size -> AttachmentSize 2019-02-06 19:06:00 +09:00
Jessica Paczuski 814e71920d Add Meta field to Attachment 2019-02-06 19:06:00 +09:00
Christian Muehlhaeuser 4def10a243 Updated entities and json structs for API v2.6.0
Added missing fields to various structs and sorted them in the same order as
the original mastodon API documentation does. This should make it easier to
compare go-mastodon's structs with the original documentation.
2018-11-25 23:15:20 +09:00
Christian Muehlhaeuser 3daf61de23 Fixed tests for Go 1.11 2018-11-25 23:09:03 +09:00
Ben Lubar c5945152ec Add convenience function to authenticate using OAuth2.
This is required for users who have 2-factor authentication enabled, and is generally safer because users don't need to give their password to third-party software.
2018-11-22 21:37:40 +09:00
Strubbl 2ebf34adae add info about missing lists APIs in Readme
https://docs.joinmastodon.org/api/rest/lists/
2018-11-14 18:14:05 +09:00
Ben Lubar 61705d1f2b Add missing fields in Account.
See https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#account

Also fixes a spurious pagination-related error.
2018-10-21 21:37:19 +09:00
Spotlight 48920165ef Correct the spelling of sensitive 2018-10-21 21:36:53 +09:00
Yasuhiro Matsumoto 2ccbcfe14d add some fields for instance command 2018-01-29 14:09:10 +09:00
Yasuhiro Matsumoto 5d863ccf79 add tests for instance 2018-01-29 13:55:37 +09:00
Yamagishi Kazutoshi f0445dd4da fix 2018-01-29 13:30:15 +09:00
Yamagishi Kazutoshi faab8cdc27 Add new attributes for instance 2018-01-29 13:30:15 +09:00
Yasuhiro Matsumoto b9e51b2916 fix typo 2018-01-29 13:26:33 +09:00
Yasuhiro Matsumoto 0022a53649 add instance_activity/instance_peers command 2018-01-29 13:26:33 +09:00
mattn 84ffd85539
Merge pull request #73 from mattn/fix-test
fix test
2018-01-29 13:26:00 +09:00
Yasuhiro Matsumoto f505a4f6ae fix test 2018-01-29 13:21:56 +09:00
mattn e946e2a506
Merge pull request #70 from ykzts/instance-activity
Add GetInstanceActivity and GetInstancePeers
2018-01-29 13:13:29 +09:00
Yamagishi Kazutoshi e0de6af209 Add GetInstanceActivity and GetInstancePeers 2018-01-29 12:33:17 +09:00
mattn 0d8819ecaf
Merge pull request #69 from mattn/fix-68
Fix 68
2017-11-30 15:00:12 +09:00
Yasuhiro Matsumoto a98b28c817 add test 2017-11-30 14:55:18 +09:00
Yasuhiro Matsumoto 83242d96ca fix #68 2017-11-30 14:53:54 +09:00
mattn 0a6f156537
Merge pull request #67 from mattn/fix-66
fix #66
2017-11-20 10:11:47 +09:00
67 changed files with 3552 additions and 332 deletions

12
.github/FUNDING.yml vendored 100644
View File

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: mattn # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

27
.github/workflows/test.yaml vendored 100644
View File

@ -0,0 +1,27 @@
name: test
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
go: ["1.16", "1.17", "1.18", "1.19"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go }}
- run: go generate ./...
- run: git diff --cached --exit-code
- run: go test ./... -v -cover -coverprofile coverage.out
- run: go test -bench . -benchmem
- uses: codecov/codecov-action@v2

View File

@ -1,8 +0,0 @@
language: go
go:
- tip
before_install:
- go get github.com/mattn/goveralls
- go get golang.org/x/tools/cmd/cover
script:
- $HOME/gopath/bin/goveralls -repotoken u2dqXvOxbIBr8eGxCjcgTkkN2JOSGx1fy

View File

@ -1,10 +1,11 @@
# go-mastodon
[![Build Status](https://travis-ci.org/mattn/go-mastodon.svg?branch=master)](https://travis-ci.org/mattn/go-mastodon)
[![Coverage Status](https://coveralls.io/repos/github/mattn/go-mastodon/badge.svg?branch=master)](https://coveralls.io/github/mattn/go-mastodon?branch=master)
[![GoDoc](https://godoc.org/github.com/mattn/go-mastodon?status.svg)](http://godoc.org/github.com/mattn/go-mastodon)
[![Build Status](https://github.com/mattn/go-mastodon/workflows/test/badge.svg?branch=master)](https://github.com/mattn/go-mastodon/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/mattn/go-mastodon/branch/master/graph/badge.svg)](https://codecov.io/gh/mattn/go-mastodon)
[![Go Reference](https://pkg.go.dev/badge/github.com/mattn/go-mastodon.svg)](https://pkg.go.dev/github.com/mattn/go-mastodon)
[![Go Report Card](https://goreportcard.com/badge/github.com/mattn/go-mastodon)](https://goreportcard.com/report/github.com/mattn/go-mastodon)
## Usage
### Application
@ -82,43 +83,81 @@ func main() {
* [x] GET /api/v1/accounts/:id/unblock
* [x] GET /api/v1/accounts/:id/mute
* [x] GET /api/v1/accounts/:id/unmute
* [x] GET /api/v1/accounts/:id/lists
* [x] GET /api/v1/accounts/relationships
* [x] GET /api/v1/accounts/search
* [x] GET /api/v1/apps/verify_credentials
* [x] GET /api/v1/bookmarks
* [x] POST /api/v1/apps
* [x] GET /api/v1/blocks
* [x] GET /api/v1/conversations
* [x] DELETE /api/v1/conversations/:id
* [x] POST /api/v1/conversations/:id/read
* [x] GET /api/v1/favourites
* [x] GET /api/v1/filters
* [x] POST /api/v1/filters
* [x] GET /api/v1/filters/:id
* [x] PUT /api/v1/filters/:id
* [x] DELETE /api/v1/filters/:id
* [x] GET /api/v1/follow_requests
* [x] POST /api/v1/follow_requests/:id/authorize
* [x] POST /api/v1/follow_requests/:id/reject
* [x] POST /api/v1/follows
* [x] GET /api/v1/instance
* [x] GET /api/v1/instance/activity
* [x] GET /api/v1/instance/peers
* [x] GET /api/v1/lists
* [x] GET /api/v1/lists/:id/accounts
* [x] GET /api/v1/lists/:id
* [x] POST /api/v1/lists
* [x] PUT /api/v1/lists/:id
* [x] DELETE /api/v1/lists/:id
* [x] POST /api/v1/lists/:id/accounts
* [x] DELETE /api/v1/lists/:id/accounts
* [x] POST /api/v1/media
* [x] GET /api/v1/mutes
* [x] GET /api/v1/notifications
* [x] GET /api/v1/notifications/:id
* [x] POST /api/v1/notifications/:id/dismiss
* [x] POST /api/v1/notifications/clear
* [x] POST /api/v1/push/subscription
* [x] GET /api/v1/push/subscription
* [x] PUT /api/v1/push/subscription
* [x] DELETE /api/v1/push/subscription
* [x] GET /api/v1/reports
* [x] POST /api/v1/reports
* [x] GET /api/v1/search
* [x] GET /api/v2/search
* [x] GET /api/v1/statuses/:id
* [x] GET /api/v1/statuses/:id/context
* [x] GET /api/v1/statuses/:id/card
* [x] GET /api/v1/statuses/:id/history
* [x] GET /api/v1/statuses/:id/reblogged_by
* [x] GET /api/v1/statuses/:id/source
* [x] GET /api/v1/statuses/:id/favourited_by
* [x] POST /api/v1/statuses
* [x] PUT /api/v1/statuses/:id
* [x] DELETE /api/v1/statuses/:id
* [x] POST /api/v1/statuses/:id/reblog
* [x] POST /api/v1/statuses/:id/unreblog
* [x] POST /api/v1/statuses/:id/favourite
* [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
## Installation
```
$ go get github.com/mattn/go-mastodon
```shell
go install github.com/mattn/go-mastodon@latest
```
## License

View File

@ -5,26 +5,49 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
// Account hold information for mastodon account.
// Account holds information for a mastodon account.
type Account struct {
ID ID `json:"id"`
Username string `json:"username"`
Acct string `json:"acct"`
DisplayName string `json:"display_name"`
Locked bool `json:"locked"`
CreatedAt time.Time `json:"created_at"`
FollowersCount int64 `json:"followers_count"`
FollowingCount int64 `json:"following_count"`
StatusesCount int64 `json:"statuses_count"`
Note string `json:"note"`
URL string `json:"url"`
Avatar string `json:"avatar"`
AvatarStatic string `json:"avatar_static"`
Header string `json:"header"`
HeaderStatic string `json:"header_static"`
ID ID `json:"id"`
Username string `json:"username"`
Acct string `json:"acct"`
DisplayName string `json:"display_name"`
Locked bool `json:"locked"`
CreatedAt time.Time `json:"created_at"`
FollowersCount int64 `json:"followers_count"`
FollowingCount int64 `json:"following_count"`
StatusesCount int64 `json:"statuses_count"`
Note string `json:"note"`
URL string `json:"url"`
Avatar string `json:"avatar"`
AvatarStatic string `json:"avatar_static"`
Header string `json:"header"`
HeaderStatic string `json:"header_static"`
Emojis []Emoji `json:"emojis"`
Moved *Account `json:"moved"`
Fields []Field `json:"fields"`
Bot bool `json:"bot"`
Discoverable bool `json:"discoverable"`
Source *AccountSource `json:"source"`
}
// Field is a Mastodon account profile field.
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
VerifiedAt time.Time `json:"verified_at"`
}
// AccountSource is a Mastodon account profile field.
type AccountSource struct {
Privacy *string `json:"privacy"`
Sensitive *bool `json:"sensitive"`
Language *string `json:"language"`
Note *string `json:"note"`
Fields *[]Field `json:"fields"`
}
// GetAccount return Account.
@ -37,7 +60,7 @@ func (c *Client) GetAccount(ctx context.Context, id ID) (*Account, error) {
return &account, nil
}
// GetAccountCurrentUser return Account of current user.
// GetAccountCurrentUser returns the Account of current user.
func (c *Client) GetAccountCurrentUser(ctx context.Context) (*Account, error) {
var account Account
err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/verify_credentials", nil, &account, nil)
@ -53,6 +76,9 @@ type Profile struct {
// If it is empty, update it with empty.
DisplayName *string
Note *string
Locked *bool
Fields *[]Field
Source *AccountSource
// Set the base64 encoded character string of the image.
Avatar string
@ -68,6 +94,26 @@ func (c *Client) AccountUpdate(ctx context.Context, profile *Profile) (*Account,
if profile.Note != nil {
params.Set("note", *profile.Note)
}
if profile.Locked != nil {
params.Set("locked", strconv.FormatBool(*profile.Locked))
}
if profile.Fields != nil {
for idx, field := range *profile.Fields {
params.Set(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name)
params.Set(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value)
}
}
if profile.Source != nil {
if profile.Source.Privacy != nil {
params.Set("source[privacy]", *profile.Source.Privacy)
}
if profile.Source.Sensitive != nil {
params.Set("source[sensitive]", strconv.FormatBool(*profile.Source.Sensitive))
}
if profile.Source.Language != nil {
params.Set("source[language]", *profile.Source.Language)
}
}
if profile.Avatar != "" {
params.Set("avatar", profile.Avatar)
}
@ -83,7 +129,7 @@ func (c *Client) AccountUpdate(ctx context.Context, profile *Profile) (*Account,
return &account, nil
}
// GetAccountStatuses return statuses by specified accuont.
// GetAccountStatuses return statuses by specified account.
func (c *Client) GetAccountStatuses(ctx context.Context, id ID, pg *Pagination) ([]*Status, error) {
var statuses []*Status
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/statuses", url.PathEscape(string(id))), nil, &statuses, pg)
@ -93,7 +139,19 @@ func (c *Client) GetAccountStatuses(ctx context.Context, id ID, pg *Pagination)
return statuses, nil
}
// GetAccountFollowers return followers list.
// GetAccountPinnedStatuses returns statuses pinned by specified accuont.
func (c *Client) GetAccountPinnedStatuses(ctx context.Context, id ID) ([]*Status, error) {
var statuses []*Status
params := url.Values{}
params.Set("pinned", "true")
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/statuses", url.PathEscape(string(id))), params, &statuses, nil)
if err != nil {
return nil, err
}
return statuses, nil
}
// GetAccountFollowers returns followers list.
func (c *Client) GetAccountFollowers(ctx context.Context, id ID, pg *Pagination) ([]*Account, error) {
var accounts []*Account
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/followers", url.PathEscape(string(id))), nil, &accounts, pg)
@ -103,7 +161,7 @@ func (c *Client) GetAccountFollowers(ctx context.Context, id ID, pg *Pagination)
return accounts, nil
}
// GetAccountFollowing return following list.
// GetAccountFollowing returns following list.
func (c *Client) GetAccountFollowing(ctx context.Context, id ID, pg *Pagination) ([]*Account, error) {
var accounts []*Account
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/following", url.PathEscape(string(id))), nil, &accounts, pg)
@ -113,7 +171,7 @@ func (c *Client) GetAccountFollowing(ctx context.Context, id ID, pg *Pagination)
return accounts, nil
}
// GetBlocks return block list.
// GetBlocks returns block list.
func (c *Client) GetBlocks(ctx context.Context, pg *Pagination) ([]*Account, error) {
var accounts []*Account
err := c.doAPI(ctx, http.MethodGet, "/api/v1/blocks", nil, &accounts, pg)
@ -123,17 +181,21 @@ func (c *Client) GetBlocks(ctx context.Context, pg *Pagination) ([]*Account, err
return accounts, nil
}
// Relationship hold information for relation-ship to the account.
// Relationship holds information for relationship to the account.
type Relationship struct {
ID ID `json:"id"`
Following bool `json:"following"`
FollowedBy bool `json:"followed_by"`
Blocking bool `json:"blocking"`
Muting bool `json:"muting"`
Requested bool `json:"requested"`
ID ID `json:"id"`
Following bool `json:"following"`
FollowedBy bool `json:"followed_by"`
Blocking bool `json:"blocking"`
Muting bool `json:"muting"`
MutingNotifications bool `json:"muting_notifications"`
Requested bool `json:"requested"`
DomainBlocking bool `json:"domain_blocking"`
ShowingReblogs bool `json:"showing_reblogs"`
Endorsed bool `json:"endorsed"`
}
// AccountFollow follow the account.
// AccountFollow follows the account.
func (c *Client) AccountFollow(ctx context.Context, id ID) (*Relationship, error) {
var relationship Relationship
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/follow", url.PathEscape(string(id))), nil, &relationship, nil)
@ -143,7 +205,7 @@ func (c *Client) AccountFollow(ctx context.Context, id ID) (*Relationship, error
return &relationship, nil
}
// AccountUnfollow unfollow the account.
// AccountUnfollow unfollows the account.
func (c *Client) AccountUnfollow(ctx context.Context, id ID) (*Relationship, error) {
var relationship Relationship
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unfollow", url.PathEscape(string(id))), nil, &relationship, nil)
@ -153,7 +215,7 @@ func (c *Client) AccountUnfollow(ctx context.Context, id ID) (*Relationship, err
return &relationship, nil
}
// AccountBlock block the account.
// AccountBlock blocks the account.
func (c *Client) AccountBlock(ctx context.Context, id ID) (*Relationship, error) {
var relationship Relationship
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/block", url.PathEscape(string(id))), nil, &relationship, nil)
@ -163,7 +225,7 @@ func (c *Client) AccountBlock(ctx context.Context, id ID) (*Relationship, error)
return &relationship, nil
}
// AccountUnblock unblock the account.
// AccountUnblock unblocks the account.
func (c *Client) AccountUnblock(ctx context.Context, id ID) (*Relationship, error) {
var relationship Relationship
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unblock", url.PathEscape(string(id))), nil, &relationship, nil)
@ -173,7 +235,7 @@ func (c *Client) AccountUnblock(ctx context.Context, id ID) (*Relationship, erro
return &relationship, nil
}
// AccountMute mute the account.
// AccountMute mutes the account.
func (c *Client) AccountMute(ctx context.Context, id ID) (*Relationship, error) {
var relationship Relationship
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/mute", url.PathEscape(string(id))), nil, &relationship, nil)
@ -183,7 +245,7 @@ func (c *Client) AccountMute(ctx context.Context, id ID) (*Relationship, error)
return &relationship, nil
}
// AccountUnmute unmute the account.
// AccountUnmute unmutes the account.
func (c *Client) AccountUnmute(ctx context.Context, id ID) (*Relationship, error) {
var relationship Relationship
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unmute", url.PathEscape(string(id))), nil, &relationship, nil)
@ -193,7 +255,7 @@ func (c *Client) AccountUnmute(ctx context.Context, id ID) (*Relationship, error
return &relationship, nil
}
// GetAccountRelationships return relationship for the account.
// GetAccountRelationships returns relationship for the account.
func (c *Client) GetAccountRelationships(ctx context.Context, ids []string) ([]*Relationship, error) {
params := url.Values{}
for _, id := range ids {
@ -208,7 +270,7 @@ func (c *Client) GetAccountRelationships(ctx context.Context, ids []string) ([]*
return relationships, nil
}
// AccountsSearch search accounts by query.
// AccountsSearch searches accounts by query.
func (c *Client) AccountsSearch(ctx context.Context, q string, limit int64) ([]*Account, error) {
params := url.Values{}
params.Set("q", q)
@ -222,7 +284,7 @@ func (c *Client) AccountsSearch(ctx context.Context, q string, limit int64) ([]*
return accounts, nil
}
// FollowRemoteUser send follow-request.
// FollowRemoteUser sends follow-request.
func (c *Client) FollowRemoteUser(ctx context.Context, uri string) (*Account, error) {
params := url.Values{}
params.Set("uri", uri)
@ -235,7 +297,7 @@ func (c *Client) FollowRemoteUser(ctx context.Context, uri string) (*Account, er
return &account, nil
}
// GetFollowRequests return follow-requests.
// GetFollowRequests returns follow requests.
func (c *Client) GetFollowRequests(ctx context.Context, pg *Pagination) ([]*Account, error) {
var accounts []*Account
err := c.doAPI(ctx, http.MethodGet, "/api/v1/follow_requests", nil, &accounts, pg)
@ -245,12 +307,12 @@ func (c *Client) GetFollowRequests(ctx context.Context, pg *Pagination) ([]*Acco
return accounts, nil
}
// FollowRequestAuthorize is authorize the follow request of user with id.
// FollowRequestAuthorize authorizes the follow request of user with id.
func (c *Client) FollowRequestAuthorize(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", url.PathEscape(string(id))), nil, nil, nil)
}
// FollowRequestReject is rejects the follow request of user with id.
// FollowRequestReject rejects the follow request of user with id.
func (c *Client) FollowRequestReject(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%s/reject", url.PathEscape(string(id))), nil, nil, nil)
}

View File

@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGetAccount(t *testing.T) {
@ -15,7 +16,6 @@ func TestGetAccount(t *testing.T) {
return
}
fmt.Fprintln(w, `{"username": "zzz"}`)
return
}))
defer ts.Close()
@ -47,7 +47,6 @@ func TestGetAccountCurrentUser(t *testing.T) {
return
}
fmt.Fprintln(w, `{"username": "zzz"}`)
return
}))
defer ts.Close()
@ -79,7 +78,6 @@ func TestAccountUpdate(t *testing.T) {
return
}
fmt.Fprintln(w, `{"username": "zzz"}`)
return
}))
defer ts.Close()
@ -93,9 +91,15 @@ func TestAccountUpdate(t *testing.T) {
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tbool := true
fields := []Field{{"foo", "bar", time.Time{}}, {"dum", "baz", time.Time{}}}
source := AccountSource{Language: String("de"), Privacy: String("public"), Sensitive: &tbool}
a, err := client.AccountUpdate(context.Background(), &Profile{
DisplayName: String("display_name"),
Note: String("note"),
Locked: &tbool,
Fields: &fields,
Source: &source,
Avatar: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUoAAADrCAYAAAA...",
Header: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUoAAADrCAYAAAA...",
})
@ -114,7 +118,6 @@ func TestGetAccountStatuses(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"content": "foo"}, {"content": "bar"}]`)
return
}))
defer ts.Close()
@ -140,6 +143,43 @@ func TestGetAccountStatuses(t *testing.T) {
}
}
func TestGetAccountPinnedStatuses(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/accounts/1234567/statuses" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
pinned := r.URL.Query().Get("pinned")
if pinned != "true" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `[{"content": "foo"}, {"content": "bar"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetAccountPinnedStatuses(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
ss, err := client.GetAccountPinnedStatuses(context.Background(), "1234567")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if ss[0].Content != "foo" {
t.Fatalf("want %q but %q", "foo", ss[0].Content)
}
if ss[1].Content != "bar" {
t.Fatalf("want %q but %q", "bar", ss[1].Content)
}
}
func TestGetAccountFollowers(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/accounts/1234567/followers" {
@ -147,7 +187,6 @@ func TestGetAccountFollowers(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()
@ -183,7 +222,6 @@ func TestGetAccountFollowing(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()
@ -221,7 +259,6 @@ func TestGetBlocks(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()
@ -257,7 +294,6 @@ func TestAccountFollow(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id":1234567,"following":true}`)
return
}))
defer ts.Close()
@ -267,11 +303,11 @@ func TestAccountFollow(t *testing.T) {
ClientSecret: "bar",
AccessToken: "zoo",
})
rel, err := client.AccountFollow(context.Background(), "123")
_, err := client.AccountFollow(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
rel, err = client.AccountFollow(context.Background(), "1234567")
rel, err := client.AccountFollow(context.Background(), "1234567")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
@ -290,7 +326,6 @@ func TestAccountUnfollow(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id":1234567,"following":false}`)
return
}))
defer ts.Close()
@ -300,11 +335,11 @@ func TestAccountUnfollow(t *testing.T) {
ClientSecret: "bar",
AccessToken: "zoo",
})
rel, err := client.AccountUnfollow(context.Background(), "123")
_, err := client.AccountUnfollow(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
rel, err = client.AccountUnfollow(context.Background(), "1234567")
rel, err := client.AccountUnfollow(context.Background(), "1234567")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
@ -323,7 +358,6 @@ func TestAccountBlock(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id":1234567,"blocking":true}`)
return
}))
defer ts.Close()
@ -356,7 +390,6 @@ func TestAccountUnblock(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id":1234567,"blocking":false}`)
return
}))
defer ts.Close()
@ -389,7 +422,6 @@ func TestAccountMute(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id":1234567,"muting":true}`)
return
}))
defer ts.Close()
@ -422,7 +454,6 @@ func TestAccountUnmute(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id":1234567,"muting":false}`)
return
}))
defer ts.Close()
@ -488,7 +519,6 @@ func TestAccountsSearch(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foobar"}, {"username": "barfoo"}]`)
return
}))
defer ts.Close()
@ -524,7 +554,6 @@ func TestFollowRemoteUser(t *testing.T) {
return
}
fmt.Fprintln(w, `{"username": "zzz"}`)
return
}))
defer ts.Close()
@ -556,7 +585,6 @@ func TestGetFollowRequests(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()
@ -642,7 +670,6 @@ func TestGetMutes(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()

42
apps.go
View File

@ -18,19 +18,24 @@ type AppConfig struct {
// Where the user should be redirected after authorization (for no redirect, use urn:ietf:wg:oauth:2.0:oob)
RedirectURIs string
// This can be a space-separated list of the following items: "read", "write" and "follow".
// This can be a space-separated list of items listed on the /settings/applications/new page of any Mastodon
// instance. "read", "write", and "follow" are top-level scopes that include all the permissions of the more
// specific scopes like "read:favourites", "write:statuses", and "write:follows".
Scopes string
// Optional.
Website string
}
// Application is mastodon application.
// Application is a mastodon application.
type Application struct {
ID int64 `json:"id"`
ID ID `json:"id"`
RedirectURI string `json:"redirect_uri"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
// AuthURI is not part of the Mastodon API; it is generated by go-mastodon.
AuthURI string `json:"auth_uri,omitempty"`
}
// RegisterApp returns the mastodon application.
@ -73,5 +78,36 @@ func RegisterApp(ctx context.Context, appConfig *AppConfig) (*Application, error
return nil, err
}
u, err = url.Parse(appConfig.Server)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "/oauth/authorize")
u.RawQuery = url.Values{
"scope": {appConfig.Scopes},
"response_type": {"code"},
"redirect_uri": {app.RedirectURI},
"client_id": {app.ClientID},
}.Encode()
app.AuthURI = u.String()
return &app, nil
}
// ApplicationVerification is mastodon application.
type ApplicationVerification struct {
Name string `json:"name"`
Website string `json:"website"`
VapidKey string `json:"vapid_key"`
}
// VerifyAppCredentials returns the mastodon application.
func (c *Client) VerifyAppCredentials(ctx context.Context) (*ApplicationVerification, error) {
var application ApplicationVerification
err := c.doAPI(ctx, http.MethodGet, "/api/v1/apps/verify_credentials", nil, &application, nil)
if err != nil {
return nil, err
}
return &application, nil
}

View File

@ -26,8 +26,7 @@ func TestRegisterApp(t *testing.T) {
fmt.Fprintln(w, `<html><head><title>Apps</title></head></html>`)
return
}
fmt.Fprintln(w, `{"client_id": "foo", "client_secret": "bar"}`)
return
fmt.Fprintln(w, `{"id": 123, "client_id": "foo", "client_secret": "bar"}`)
}))
defer ts.Close()
@ -64,6 +63,9 @@ func TestRegisterApp(t *testing.T) {
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if string(app.ID) != "123" {
t.Fatalf("want %q but %q", "bar", app.ClientSecret)
}
if app.ClientID != "foo" {
t.Fatalf("want %q but %q", "foo", app.ClientID)
}
@ -76,7 +78,6 @@ func TestRegisterAppWithCancel(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
fmt.Fprintln(w, `{"client_id": "foo", "client_secret": "bar"}`)
return
}))
defer ts.Close()
@ -89,7 +90,53 @@ func TestRegisterAppWithCancel(t *testing.T) {
if err == nil {
t.Fatalf("should be fail: %v", err)
}
if want := "Post " + ts.URL + "/api/v1/apps: context canceled"; want != err.Error() {
if want := fmt.Sprintf("Post %q: context canceled", ts.URL+"/api/v1/apps"); want != err.Error() {
t.Fatalf("want %q but %q", want, err.Error())
}
}
func TestVerifyAppCredentials(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer zoo" {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
if r.URL.Path != "/api/v1/apps/verify_credentials" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"name":"zzz","website":"yyy","vapid_key":"xxx"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zip",
})
_, err := client.VerifyAppCredentials(context.Background())
if err == nil {
t.Fatalf("should be fail: %v", err)
}
client = NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
a, err := client.VerifyAppCredentials(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if a.Name != "zzz" {
t.Fatalf("want %q but %q", "zzz", a.Name)
}
if a.Website != "yyy" {
t.Fatalf("want %q but %q", "yyy", a.Name)
}
if a.VapidKey != "xxx" {
t.Fatalf("want %q but %q", "xxx", a.Name)
}
}

View File

@ -5,7 +5,7 @@ import (
"fmt"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdAccount(c *cli.Context) error {

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdAccount(t *testing.T) {

View File

@ -5,7 +5,7 @@ import (
"errors"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdDelete(c *cli.Context) error {

View File

@ -5,7 +5,7 @@ import (
"net/http"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdDelete(t *testing.T) {

View File

@ -5,7 +5,7 @@ import (
"errors"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdFollow(c *cli.Context) error {

View File

@ -5,7 +5,7 @@ import (
"net/http"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdFollow(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"time"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdFollowers(c *cli.Context) error {
@ -28,6 +28,8 @@ func cmdFollowers(c *cli.Context) error {
if pg.MaxID == "" {
break
}
pg.SinceID = ""
pg.MinID = ""
time.Sleep(10 * time.Second)
}
s := newScreen(config)

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdFollowers(t *testing.T) {

View File

@ -3,9 +3,10 @@ package main
import (
"context"
"fmt"
"sort"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdInstance(c *cli.Context) error {
@ -18,5 +19,26 @@ func cmdInstance(c *cli.Context) error {
fmt.Fprintf(c.App.Writer, "Title : %s\n", instance.Title)
fmt.Fprintf(c.App.Writer, "Description: %s\n", instance.Description)
fmt.Fprintf(c.App.Writer, "EMail : %s\n", instance.EMail)
if instance.Version != "" {
fmt.Fprintf(c.App.Writer, "Version : %s\n", instance.Version)
}
if instance.Thumbnail != "" {
fmt.Fprintf(c.App.Writer, "Thumbnail : %s\n", instance.Thumbnail)
}
if instance.URLs != nil {
var keys []string
for _, k := range instance.URLs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(c.App.Writer, "%s: %s\n", k, instance.URLs[k])
}
}
if instance.Stats != nil {
fmt.Fprintf(c.App.Writer, "User Count : %v\n", instance.Stats.UserCount)
fmt.Fprintf(c.App.Writer, "Status Count : %v\n", instance.Stats.StatusCount)
fmt.Fprintf(c.App.Writer, "Domain Count : %v\n", instance.Stats.DomainCount)
}
return nil
}

View File

@ -0,0 +1,24 @@
package main
import (
"context"
"fmt"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli/v2"
)
func cmdInstanceActivity(c *cli.Context) error {
client := c.App.Metadata["client"].(*mastodon.Client)
activities, err := client.GetInstanceActivity(context.Background())
if err != nil {
return err
}
for _, activity := range activities {
fmt.Fprintf(c.App.Writer, "Logins : %v\n", activity.Logins)
fmt.Fprintf(c.App.Writer, "Registrations : %v\n", activity.Registrations)
fmt.Fprintf(c.App.Writer, "Statuses : %v\n", activity.Statuses)
fmt.Fprintf(c.App.Writer, "Week : %v\n", activity.Week)
}
return nil
}

View File

@ -0,0 +1,21 @@
package main
import (
"context"
"fmt"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli/v2"
)
func cmdInstancePeers(c *cli.Context) error {
client := c.App.Metadata["client"].(*mastodon.Client)
peers, err := client.GetInstancePeers(context.Background())
if err != nil {
return err
}
for _, peer := range peers {
fmt.Fprintln(c.App.Writer, peer)
}
return nil
}

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdInstance(t *testing.T) {

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdMikami(c *cli.Context) error {

View File

@ -7,7 +7,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdMikami(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"github.com/fatih/color"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdNotification(c *cli.Context) error {

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdNotification(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"fmt"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdSearch(c *cli.Context) error {

View File

@ -6,15 +6,15 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdSearch(t *testing.T) {
out := testWithServer(
func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/search":
fmt.Fprintln(w, `{"accounts": [{"id": 234, "acct": "zzz"}], "statuses":[{"id": 345, "content": "yyy"}], "hashtags": ["www", "わろす"]}`)
case "/api/v2/search":
fmt.Fprintln(w, `{"accounts": [{"id": 234, "acct": "zzz"}], "statuses":[{"id": 345, "content": "yyy"}], "hashtags": [{"name": "www"}, {"name": "わろす"}]}`)
return
}
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)

View File

@ -10,7 +10,7 @@ import (
"text/template"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
// SimpleJSON is a struct for output JSON for data to be simple used
@ -44,9 +44,7 @@ func cmdStream(c *cli.Context) error {
"nl": func(s string) string {
return s + "\n"
},
"text": func(s string) string {
return textContent(s)
},
"text": textContent,
}).Parse(asFormat)
if err != nil {
return err
@ -89,7 +87,16 @@ func cmdStream(c *cli.Context) error {
if asJSON {
json.NewEncoder(c.App.Writer).Encode(e)
} else if asSimpleJSON {
if t, ok := e.(*mastodon.UpdateEvent); ok {
switch t := e.(type) {
case *mastodon.UpdateEvent:
json.NewEncoder(c.App.Writer).Encode(&SimpleJSON{
ID: t.Status.ID,
Username: t.Status.Account.Username,
Acct: t.Status.Account.Acct,
Avatar: t.Status.Account.AvatarStatic,
Content: textContent(t.Status.Content),
})
case *mastodon.UpdateEditEvent:
json.NewEncoder(c.App.Writer).Encode(&SimpleJSON{
ID: t.Status.ID,
Username: t.Status.Account.Username,
@ -104,6 +111,8 @@ func cmdStream(c *cli.Context) error {
switch t := e.(type) {
case *mastodon.UpdateEvent:
s.displayStatus(c.App.Writer, t.Status)
case *mastodon.UpdateEditEvent:
s.displayStatus(c.App.Writer, t.Status)
case *mastodon.NotificationEvent:
// TODO s.displayStatus(c.App.Writer, t.Notification.Status)
case *mastodon.ErrorEvent:

View File

@ -14,8 +14,7 @@ import (
)
func TestCmdStream(t *testing.T) {
var ts *httptest.Server
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/streaming/public/local" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return

View File

@ -6,7 +6,7 @@ import (
"net/http/httptest"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func testWithServer(h http.HandlerFunc, testFuncs ...func(*cli.App)) string {

View File

@ -2,9 +2,11 @@ package main
import (
"context"
"errors"
"strings"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdTimeline(c *cli.Context) error {
@ -20,3 +22,69 @@ func cmdTimeline(c *cli.Context) error {
}
return nil
}
func cmdTimelineHome(c *cli.Context) error {
return cmdTimeline(c)
}
func cmdTimelinePublic(c *cli.Context) error {
client := c.App.Metadata["client"].(*mastodon.Client)
config := c.App.Metadata["config"].(*mastodon.Config)
timeline, err := client.GetTimelinePublic(context.Background(), false, nil)
if err != nil {
return err
}
s := newScreen(config)
for i := len(timeline) - 1; i >= 0; i-- {
s.displayStatus(c.App.Writer, timeline[i])
}
return nil
}
func cmdTimelineLocal(c *cli.Context) error {
client := c.App.Metadata["client"].(*mastodon.Client)
config := c.App.Metadata["config"].(*mastodon.Config)
timeline, err := client.GetTimelinePublic(context.Background(), true, nil)
if err != nil {
return err
}
s := newScreen(config)
for i := len(timeline) - 1; i >= 0; i-- {
s.displayStatus(c.App.Writer, timeline[i])
}
return nil
}
func cmdTimelineDirect(c *cli.Context) error {
client := c.App.Metadata["client"].(*mastodon.Client)
config := c.App.Metadata["config"].(*mastodon.Config)
timeline, err := client.GetTimelineDirect(context.Background(), nil)
if err != nil {
return err
}
s := newScreen(config)
for i := len(timeline) - 1; i >= 0; i-- {
s.displayStatus(c.App.Writer, timeline[i])
}
return nil
}
func cmdTimelineHashtag(c *cli.Context) error {
if !c.Args().Present() {
return errors.New("arguments required")
}
local := c.Bool("local")
tag := strings.TrimLeft(argstr(c), "#")
client := c.App.Metadata["client"].(*mastodon.Client)
config := c.App.Metadata["config"].(*mastodon.Config)
timeline, err := client.GetTimelineHashtag(context.Background(), tag, local, nil)
if err != nil {
return err
}
s := newScreen(config)
for i := len(timeline) - 1; i >= 0; i-- {
s.displayStatus(c.App.Writer, timeline[i])
}
return nil
}

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdTimeline(t *testing.T) {
@ -14,7 +14,13 @@ func TestCmdTimeline(t *testing.T) {
func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/timelines/home":
fmt.Fprintln(w, `[{"content": "zzz"}]`)
fmt.Fprintln(w, `[{"content": "home"}]`)
return
case "/api/v1/timelines/public":
fmt.Fprintln(w, `[{"content": "public"}]`)
return
case "/api/v1/conversations":
fmt.Fprintln(w, `[{"id": "4", "unread":false, "last_status" : {"content": "direct"}}]`)
return
}
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
@ -22,9 +28,25 @@ func TestCmdTimeline(t *testing.T) {
},
func(app *cli.App) {
app.Run([]string{"mstdn", "timeline"})
app.Run([]string{"mstdn", "timeline-home"})
app.Run([]string{"mstdn", "timeline-public"})
app.Run([]string{"mstdn", "timeline-local"})
app.Run([]string{"mstdn", "timeline-direct"})
},
)
if !strings.Contains(out, "zzz") {
t.Fatalf("%q should be contained in output of command: %v", "zzz", out)
want := strings.Join([]string{
"@example.com",
"home",
"@example.com",
"home",
"@example.com",
"public",
"@example.com",
"public",
"@example.com",
"direct",
}, "\n") + "\n"
if !strings.Contains(out, want) {
t.Fatalf("%q should be contained in output of command: %v", want, out)
}
}

View File

@ -6,7 +6,7 @@ import (
"fmt"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdToot(c *cli.Context) error {

View File

@ -5,7 +5,7 @@ import (
"net/http"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdToot(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"fmt"
"github.com/mattn/go-mastodon"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdUpload(c *cli.Context) error {

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdUpload(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"net/url"
"github.com/PuerkitoBio/goquery"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func cmdXSearch(c *cli.Context) error {

View File

@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestCmdXSearch(t *testing.T) {

15
cmd/mstdn/go.mod 100644
View File

@ -0,0 +1,15 @@
module github.com/mattn/go-mastodon/cmd/mstdn
go 1.16
replace github.com/mattn/go-mastodon => ../..
require (
github.com/PuerkitoBio/goquery v1.8.0
github.com/fatih/color v1.13.0
github.com/mattn/go-mastodon v0.0.4
github.com/mattn/go-tty v0.0.4
github.com/urfave/cli v1.13.0
github.com/urfave/cli/v2 v2.23.5 // indirect
golang.org/x/net v0.0.0-20220531201128-c960675eff93
)

53
cmd/mstdn/go.sum 100644
View File

@ -0,0 +1,53 @@
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-tty v0.0.4 h1:NVikla9X8MN0SQAqCYzpGyXv0jY7MNl3HOWD2dkle7E=
github.com/mattn/go-tty v0.0.4/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
github.com/urfave/cli v1.13.0 h1:kkpCmfxnnnWIie2rCljcvaVrNYmsFq1ynTJH5kn1Ip4=
github.com/urfave/cli v1.13.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw=
github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -17,7 +17,7 @@ import (
"github.com/fatih/color"
"github.com/mattn/go-mastodon"
"github.com/mattn/go-tty"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
"golang.org/x/net/html"
)
@ -183,23 +183,23 @@ func makeApp() *cli.App {
app.Usage = "mastodon client"
app.Version = "0.0.1"
app.Flags = []cli.Flag{
cli.StringFlag{
&cli.StringFlag{
Name: "profile",
Usage: "profile name",
Value: "",
},
}
app.Commands = []cli.Command{
app.Commands = []*cli.Command{
{
Name: "toot",
Usage: "post toot",
Flags: []cli.Flag{
cli.StringFlag{
&cli.StringFlag{
Name: "ff",
Usage: "post utf-8 string from a file(\"-\" means STDIN)",
Value: "",
},
cli.StringFlag{
&cli.StringFlag{
Name: "i",
Usage: "in-reply-to",
Value: "",
@ -211,19 +211,19 @@ func makeApp() *cli.App {
Name: "stream",
Usage: "stream statuses",
Flags: []cli.Flag{
cli.StringFlag{
&cli.StringFlag{
Name: "type",
Usage: "stream type (public,public/local,user:NAME,hashtag:TAG)",
},
cli.BoolFlag{
&cli.BoolFlag{
Name: "json",
Usage: "output JSON",
},
cli.BoolFlag{
&cli.BoolFlag{
Name: "simplejson",
Usage: "output simple JSON",
},
cli.StringFlag{
&cli.StringFlag{
Name: "template",
Usage: "output with tamplate format",
},
@ -235,6 +235,37 @@ func makeApp() *cli.App {
Usage: "show timeline",
Action: cmdTimeline,
},
{
Name: "timeline-home",
Usage: "show timeline home",
Action: cmdTimelineHome,
},
{
Name: "timeline-local",
Usage: "show timeline local",
Action: cmdTimelineLocal,
},
{
Name: "timeline-public",
Usage: "show timeline public",
Action: cmdTimelinePublic,
},
{
Name: "timeline-direct",
Usage: "show timeline direct",
Action: cmdTimelineDirect,
},
{
Name: "timeline-tag",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "local",
Usage: "local tags only",
},
},
Usage: "show tagged timeline",
Action: cmdTimelineHashtag,
},
{
Name: "notification",
Usage: "show notification",
@ -245,6 +276,16 @@ func makeApp() *cli.App {
Usage: "show instance information",
Action: cmdInstance,
},
{
Name: "instance_activity",
Usage: "show instance activity information",
Action: cmdInstanceActivity,
},
{
Name: "instance_peers",
Usage: "show instance peers information",
Action: cmdInstancePeers,
},
{
Name: "account",
Usage: "show account information",
@ -360,6 +401,7 @@ func run() int {
}
client := mastodon.NewClient(config)
client.UserAgent = "mstdn"
app.Metadata = map[string]interface{}{
"client": client,
"config": config,

View File

@ -7,7 +7,7 @@ import (
"os"
"testing"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
func TestReadFileFile(t *testing.T) {

View File

@ -3,6 +3,7 @@ package mastodon
import (
"encoding/json"
"fmt"
"strconv"
)
type ID string
@ -23,3 +24,26 @@ func (id *ID) UnmarshalJSON(data []byte) error {
*id = ID(fmt.Sprint(n))
return nil
}
type Sbool bool
func (s *Sbool) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
b, err := strconv.ParseBool(str)
if err != nil {
return err
}
*s = Sbool(b)
return nil
}
var b bool
if err := json.Unmarshal(data, &b); err != nil {
return err
}
*s = Sbool(b)
return nil
}

124
filters.go 100644
View File

@ -0,0 +1,124 @@
package mastodon
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"time"
)
// Filter is metadata for a filter of users.
type Filter struct {
ID ID `json:"id"`
Phrase string `json:"phrase"`
Context []string `json:"context"`
WholeWord bool `json:"whole_word"`
ExpiresAt time.Time `json:"expires_at"`
Irreversible bool `json:"irreversible"`
}
// GetFilters returns all the filters on the current account.
func (c *Client) GetFilters(ctx context.Context) ([]*Filter, error) {
var filters []*Filter
err := c.doAPI(ctx, http.MethodGet, "/api/v1/filters", nil, &filters, nil)
if err != nil {
return nil, err
}
return filters, nil
}
// GetFilter retrieves a filter by ID.
func (c *Client) GetFilter(ctx context.Context, id ID) (*Filter, error) {
var filter Filter
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/filters/%s", url.PathEscape(string(id))), nil, &filter, nil)
if err != nil {
return nil, err
}
return &filter, nil
}
// CreateFilter creates a new filter.
func (c *Client) CreateFilter(ctx context.Context, filter *Filter) (*Filter, error) {
if filter == nil {
return nil, errors.New("filter can't be nil")
}
if filter.Phrase == "" {
return nil, errors.New("phrase can't be empty")
}
if len(filter.Context) == 0 {
return nil, errors.New("context can't be empty")
}
params := url.Values{}
params.Set("phrase", filter.Phrase)
for _, c := range filter.Context {
params.Add("context[]", c)
}
if filter.WholeWord {
params.Add("whole_word", "true")
}
if filter.Irreversible {
params.Add("irreversible", "true")
}
if !filter.ExpiresAt.IsZero() {
diff := time.Until(filter.ExpiresAt)
params.Add("expires_in", fmt.Sprintf("%.0f", diff.Seconds()))
}
var f Filter
err := c.doAPI(ctx, http.MethodPost, "/api/v1/filters", params, &f, nil)
if err != nil {
return nil, err
}
return &f, nil
}
// UpdateFilter updates a filter.
func (c *Client) UpdateFilter(ctx context.Context, id ID, filter *Filter) (*Filter, error) {
if filter == nil {
return nil, errors.New("filter can't be nil")
}
if id == ID("") {
return nil, errors.New("ID can't be empty")
}
if filter.Phrase == "" {
return nil, errors.New("phrase can't be empty")
}
if len(filter.Context) == 0 {
return nil, errors.New("context can't be empty")
}
params := url.Values{}
params.Set("phrase", filter.Phrase)
for _, c := range filter.Context {
params.Add("context[]", c)
}
if filter.WholeWord {
params.Add("whole_word", "true")
} else {
params.Add("whole_word", "false")
}
if filter.Irreversible {
params.Add("irreversible", "true")
} else {
params.Add("irreversible", "false")
}
if !filter.ExpiresAt.IsZero() {
diff := time.Until(filter.ExpiresAt)
params.Add("expires_in", fmt.Sprintf("%.0f", diff.Seconds()))
} else {
params.Add("expires_in", "")
}
var f Filter
err := c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/filters/%s", url.PathEscape(string(id))), params, &f, nil)
if err != nil {
return nil, err
}
return &f, nil
}
// DeleteFilter removes a filter.
func (c *Client) DeleteFilter(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/filters/%s", url.PathEscape(string(id))), nil, nil, nil)
}

342
filters_test.go 100644
View File

@ -0,0 +1,342 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
)
func TestGetFilters(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `[{"id": "6191", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": false}, {"id": "5580", "phrase": "@twitter.com", "context": ["home", "notifications", "public", "thread"], "whole_word": false, "expires_at": null, "irreversible": true}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
tf := []Filter{
{
ID: ID("6191"),
Phrase: "rust",
Context: []string{"home"},
WholeWord: true,
ExpiresAt: d,
Irreversible: false,
},
{
ID: ID("5580"),
Phrase: "@twitter.com",
Context: []string{"notifications", "home", "thread", "public"},
WholeWord: false,
ExpiresAt: time.Time{},
Irreversible: true,
},
}
filters, err := client.GetFilters(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(filters) != 2 {
t.Fatalf("result should be two: %d", len(filters))
}
for i, f := range tf {
if filters[i].ID != f.ID {
t.Fatalf("want %q but %q", string(f.ID), filters[i].ID)
}
if filters[i].Phrase != f.Phrase {
t.Fatalf("want %q but %q", f.Phrase, filters[i].Phrase)
}
sort.Strings(filters[i].Context)
sort.Strings(f.Context)
if strings.Join(filters[i].Context, ", ") != strings.Join(f.Context, ", ") {
t.Fatalf("want %q but %q", f.Context, filters[i].Context)
}
if filters[i].ExpiresAt != f.ExpiresAt {
t.Fatalf("want %q but %q", f.ExpiresAt, filters[i].ExpiresAt)
}
if filters[i].WholeWord != f.WholeWord {
t.Fatalf("want %t but %t", f.WholeWord, filters[i].WholeWord)
}
if filters[i].Irreversible != f.Irreversible {
t.Fatalf("want %t but %t", f.Irreversible, filters[i].Irreversible)
}
}
}
func TestGetFilter(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/filters/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"id": "1", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": false}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetFilter(context.Background(), "2")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
tf := Filter{
ID: ID("1"),
Phrase: "rust",
Context: []string{"home"},
WholeWord: true,
ExpiresAt: d,
Irreversible: false,
}
filter, err := client.GetFilter(context.Background(), "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if filter.ID != tf.ID {
t.Fatalf("want %q but %q", string(tf.ID), filter.ID)
}
if filter.Phrase != tf.Phrase {
t.Fatalf("want %q but %q", tf.Phrase, filter.Phrase)
}
sort.Strings(filter.Context)
sort.Strings(tf.Context)
if strings.Join(filter.Context, ", ") != strings.Join(tf.Context, ", ") {
t.Fatalf("want %q but %q", tf.Context, filter.Context)
}
if filter.ExpiresAt != tf.ExpiresAt {
t.Fatalf("want %q but %q", tf.ExpiresAt, filter.ExpiresAt)
}
if filter.WholeWord != tf.WholeWord {
t.Fatalf("want %t but %t", tf.WholeWord, filter.WholeWord)
}
if filter.Irreversible != tf.Irreversible {
t.Fatalf("want %t but %t", tf.Irreversible, filter.Irreversible)
}
}
func TestCreateFilter(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.PostFormValue("phrase") != "rust" && r.PostFormValue("phrase") != "@twitter.com" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if r.PostFormValue("phrase") == "rust" {
fmt.Fprintln(w, `{"id": "1", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": true}`)
return
} else {
fmt.Fprintln(w, `{"id": "2", "phrase": "@twitter.com", "context": ["home", "notifications", "public", "thread"], "whole_word": false, "expires_at": null, "irreversible": false}`)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.CreateFilter(context.Background(), nil)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.CreateFilter(context.Background(), &Filter{Context: []string{"home"}})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.CreateFilter(context.Background(), &Filter{Phrase: "rust"})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.CreateFilter(context.Background(), &Filter{Phrase: "Test", Context: []string{"home"}})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
tf := []Filter{
{
ID: ID("1"),
Phrase: "rust",
Context: []string{"home"},
WholeWord: true,
ExpiresAt: d,
Irreversible: true,
},
{
ID: ID("2"),
Phrase: "@twitter.com",
Context: []string{"notifications", "home", "thread", "public"},
WholeWord: false,
ExpiresAt: time.Time{},
Irreversible: false,
},
}
for _, f := range tf {
filter, err := client.CreateFilter(context.Background(), &f)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if filter.ID != f.ID {
t.Fatalf("want %q but %q", string(f.ID), filter.ID)
}
if filter.Phrase != f.Phrase {
t.Fatalf("want %q but %q", f.Phrase, filter.Phrase)
}
sort.Strings(filter.Context)
sort.Strings(f.Context)
if strings.Join(filter.Context, ", ") != strings.Join(f.Context, ", ") {
t.Fatalf("want %q but %q", f.Context, filter.Context)
}
if filter.ExpiresAt != f.ExpiresAt {
t.Fatalf("want %q but %q", f.ExpiresAt, filter.ExpiresAt)
}
if filter.WholeWord != f.WholeWord {
t.Fatalf("want %t but %t", f.WholeWord, filter.WholeWord)
}
if filter.Irreversible != f.Irreversible {
t.Fatalf("want %t but %t", f.Irreversible, filter.Irreversible)
}
}
}
func TestUpdateFilter(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/filters/1" {
fmt.Fprintln(w, `{"id": "1", "phrase": "rust", "context": ["home"], "whole_word": true, "expires_at": "2019-05-21T13:47:31.333Z", "irreversible": true}`)
return
} else if r.URL.Path == "/api/v1/filters/2" {
fmt.Fprintln(w, `{"id": "2", "phrase": "@twitter.com", "context": ["home", "notifications", "public", "thread"], "whole_word": false, "expires_at": null, "irreversible": false}`)
return
} else {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.UpdateFilter(context.Background(), ID("1"), nil)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.UpdateFilter(context.Background(), ID(""), &Filter{Phrase: ""})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.UpdateFilter(context.Background(), ID("2"), &Filter{Phrase: ""})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.UpdateFilter(context.Background(), ID("2"), &Filter{Phrase: "rust"})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, err = client.UpdateFilter(context.Background(), ID("3"), &Filter{Phrase: "rust", Context: []string{"home"}})
if err == nil {
t.Fatalf("should be fail: %v", err)
}
d, err := time.Parse(time.RFC3339Nano, "2019-05-21T13:47:31.333Z")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
tf := []Filter{
{
ID: ID("1"),
Phrase: "rust",
Context: []string{"home"},
WholeWord: true,
ExpiresAt: d,
Irreversible: true,
},
{
ID: ID("2"),
Phrase: "@twitter.com",
Context: []string{"notifications", "home", "thread", "public"},
WholeWord: false,
ExpiresAt: time.Time{},
Irreversible: false,
},
}
for _, f := range tf {
filter, err := client.UpdateFilter(context.Background(), f.ID, &f)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if filter.ID != f.ID {
t.Fatalf("want %q but %q", string(f.ID), filter.ID)
}
if filter.Phrase != f.Phrase {
t.Fatalf("want %q but %q", f.Phrase, filter.Phrase)
}
sort.Strings(filter.Context)
sort.Strings(f.Context)
if strings.Join(filter.Context, ", ") != strings.Join(f.Context, ", ") {
t.Fatalf("want %q but %q", f.Context, filter.Context)
}
if filter.ExpiresAt != f.ExpiresAt {
t.Fatalf("want %q but %q", f.ExpiresAt, filter.ExpiresAt)
}
if filter.WholeWord != f.WholeWord {
t.Fatalf("want %t but %t", f.WholeWord, filter.WholeWord)
}
if filter.Irreversible != f.Irreversible {
t.Fatalf("want %t but %t", f.Irreversible, filter.Irreversible)
}
}
}
func TestDeleteFilter(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/filters/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
err := client.DeleteFilter(context.Background(), "2")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
err = client.DeleteFilter(context.Background(), "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}

8
go.mod 100644
View File

@ -0,0 +1,8 @@
module github.com/mattn/go-mastodon
go 1.16
require (
github.com/gorilla/websocket v1.5.0
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
)

4
go.sum 100644
View File

@ -0,0 +1,4 @@
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=

12
go.test.sh 100755
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
echo "" > coverage.txt
for d in $(go list ./... | grep -v vendor); do
go test -coverprofile=profile.out -covermode=atomic "$d"
if [ -f profile.out ]; then
cat profile.out >> coverage.txt
rm profile.out
fi
done

View File

@ -12,13 +12,13 @@ const wantBase64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHEAAABxCAYAAA
func TestBase64EncodeFileName(t *testing.T) {
// Error in os.Open.
uri, err := Base64EncodeFileName("fail")
_, err := Base64EncodeFileName("fail")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
// Success.
uri, err = Base64EncodeFileName("testdata/logo.png")
uri, err := Base64EncodeFileName("testdata/logo.png")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
@ -29,7 +29,7 @@ func TestBase64EncodeFileName(t *testing.T) {
func TestBase64Encode(t *testing.T) {
// Error in file.Stat.
uri, err := Base64Encode(nil)
_, err := Base64Encode(nil)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
@ -43,7 +43,7 @@ func TestBase64Encode(t *testing.T) {
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
uri, err = Base64Encode(logo)
_, err = Base64Encode(logo)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
@ -53,7 +53,7 @@ func TestBase64Encode(t *testing.T) {
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
uri, err = Base64Encode(logo)
uri, err := Base64Encode(logo)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}

View File

@ -5,15 +5,39 @@ import (
"net/http"
)
// Instance hold information for mastodon instance.
// Instance holds information for a mastodon instance.
type Instance struct {
URI string `json:"uri"`
Title string `json:"title"`
Description string `json:"description"`
EMail string `json:"email"`
URI string `json:"uri"`
Title string `json:"title"`
Description string `json:"description"`
EMail string `json:"email"`
Version string `json:"version,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
URLs map[string]string `json:"urls,omitempty"`
Stats *InstanceStats `json:"stats,omitempty"`
Languages []string `json:"languages"`
ContactAccount *Account `json:"contact_account"`
Configuration *InstanceConfig `json:"configuration"`
}
// GetInstance return Instance.
type InstanceConfigMap map[string]int
// InstanceConfig holds configuration accessible for clients.
type InstanceConfig struct {
Accounts *InstanceConfigMap `json:"accounts"`
Statuses *InstanceConfigMap `json:"statuses"`
MediaAttachments map[string]interface{} `json:"media_attachments"`
Polls *InstanceConfigMap `json:"polls"`
}
// InstanceStats holds information for mastodon instance stats.
type InstanceStats struct {
UserCount int64 `json:"user_count"`
StatusCount int64 `json:"status_count"`
DomainCount int64 `json:"domain_count"`
}
// GetInstance returns Instance.
func (c *Client) GetInstance(ctx context.Context) (*Instance, error) {
var instance Instance
err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance", nil, &instance, nil)
@ -22,3 +46,36 @@ func (c *Client) GetInstance(ctx context.Context) (*Instance, error) {
}
return &instance, nil
}
// GetConfig returns InstanceConfig.
func (c *Instance) GetConfig() *InstanceConfig {
return c.Configuration
}
// WeeklyActivity holds information for mastodon weekly activity.
type WeeklyActivity struct {
Week Unixtime `json:"week"`
Statuses int64 `json:"statuses,string"`
Logins int64 `json:"logins,string"`
Registrations int64 `json:"registrations,string"`
}
// GetInstanceActivity returns instance activity.
func (c *Client) GetInstanceActivity(ctx context.Context) ([]*WeeklyActivity, error) {
var activity []*WeeklyActivity
err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/activity", nil, &activity, nil)
if err != nil {
return nil, err
}
return activity, nil
}
// GetInstancePeers returns instance peers.
func (c *Client) GetInstancePeers(ctx context.Context) ([]string, error) {
var peers []string
err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/peers", nil, &peers, nil)
if err != nil {
return nil, err
}
return peers, nil
}

View File

@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGetInstance(t *testing.T) {
@ -16,7 +17,7 @@ func TestGetInstance(t *testing.T) {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `{"title": "mastodon"}`)
fmt.Fprintln(w, `{"title": "mastodon", "uri": "http://mstdn.example.com", "description": "test mastodon", "email": "mstdn@mstdn.example.com", "contact_account": {"username": "mattn"}}`)
}))
defer ts.Close()
@ -37,4 +38,151 @@ func TestGetInstance(t *testing.T) {
if ins.Title != "mastodon" {
t.Fatalf("want %q but %q", "mastodon", ins.Title)
}
if ins.URI != "http://mstdn.example.com" {
t.Fatalf("want %q but %q", "http://mstdn.example.com", ins.URI)
}
if ins.Description != "test mastodon" {
t.Fatalf("want %q but %q", "test mastodon", ins.Description)
}
if ins.EMail != "mstdn@mstdn.example.com" {
t.Fatalf("want %q but %q", "mstdn@mstdn.example.com", ins.EMail)
}
if ins.ContactAccount.Username != "mattn" {
t.Fatalf("want %q but %q", "mattn", ins.ContactAccount.Username)
}
}
func TestGetInstanceMore(t *testing.T) {
canErr := true
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if canErr {
canErr = false
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `{"title": "mastodon", "uri": "http://mstdn.example.com", "description": "test mastodon", "email": "mstdn@mstdn.example.com", "version": "0.0.1", "urls":{"foo":"http://stream1.example.com", "bar": "http://stream2.example.com"}, "thumbnail": "http://mstdn.example.com/logo.png", "configuration":{"accounts": {"max_featured_tags": 10}, "statuses": {"max_characters": 500}}, "stats":{"user_count":1, "status_count":2, "domain_count":3}}}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetInstance(context.Background())
if err == nil {
t.Fatalf("should be fail: %v", err)
}
ins, err := client.GetInstance(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if ins.Title != "mastodon" {
t.Fatalf("want %q but %q", "mastodon", ins.Title)
}
if ins.URI != "http://mstdn.example.com" {
t.Fatalf("want %q but %q", "mastodon", ins.URI)
}
if ins.Description != "test mastodon" {
t.Fatalf("want %q but %q", "test mastodon", ins.Description)
}
if ins.EMail != "mstdn@mstdn.example.com" {
t.Fatalf("want %q but %q", "mstdn@mstdn.example.com", ins.EMail)
}
if ins.Version != "0.0.1" {
t.Fatalf("want %q but %q", "0.0.1", ins.Version)
}
if ins.URLs["foo"] != "http://stream1.example.com" {
t.Fatalf("want %q but %q", "http://stream1.example.com", ins.Version)
}
if ins.URLs["bar"] != "http://stream2.example.com" {
t.Fatalf("want %q but %q", "http://stream2.example.com", ins.Version)
}
if ins.Thumbnail != "http://mstdn.example.com/logo.png" {
t.Fatalf("want %q but %q", "http://mstdn.example.com/logo.png", ins.Thumbnail)
}
if ins.Stats == nil {
t.Fatal("stats should not be nil")
}
if ins.Stats.UserCount != 1 {
t.Fatalf("want %v but %v", 1, ins.Stats.UserCount)
}
if ins.Stats.StatusCount != 2 {
t.Fatalf("want %v but %v", 2, ins.Stats.StatusCount)
}
if ins.Stats.DomainCount != 3 {
t.Fatalf("want %v but %v", 3, ins.Stats.DomainCount)
}
cfg := ins.GetConfig()
if cfg.Accounts == nil {
t.Error("expected accounts to be non nil")
}
if cfg.Statuses == nil {
t.Error("expected statuses to be non nil")
}
}
func TestGetInstanceActivity(t *testing.T) {
canErr := true
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if canErr {
canErr = false
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `[{"week":"1516579200","statuses":"1","logins":"1","registrations":"0"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
})
_, err := client.GetInstanceActivity(context.Background())
if err == nil {
t.Fatalf("should be fail: %v", err)
}
activity, err := client.GetInstanceActivity(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if activity[0].Week != Unixtime(time.Unix(1516579200, 0)) {
t.Fatalf("want %v but %v", Unixtime(time.Unix(1516579200, 0)), activity[0].Week)
}
if activity[0].Logins != 1 {
t.Fatalf("want %q but %q", 1, activity[0].Logins)
}
}
func TestGetInstancePeers(t *testing.T) {
canErr := true
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if canErr {
canErr = false
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `["mastodon.social","mstdn.jp"]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
})
_, err := client.GetInstancePeers(context.Background())
if err == nil {
t.Fatalf("should be fail: %v", err)
}
peers, err := client.GetInstancePeers(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if peers[0] != "mastodon.social" {
t.Fatalf("want %q but %q", "mastodon.social", peers[0])
}
if peers[1] != "mstdn.jp" {
t.Fatalf("want %q but %q", "mstdn.jp", peers[1])
}
}

107
lists.go 100644
View File

@ -0,0 +1,107 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/url"
)
// List is metadata for a list of users.
type List struct {
ID ID `json:"id"`
Title string `json:"title"`
}
// GetLists returns all the lists on the current account.
func (c *Client) GetLists(ctx context.Context) ([]*List, error) {
var lists []*List
err := c.doAPI(ctx, http.MethodGet, "/api/v1/lists", nil, &lists, nil)
if err != nil {
return nil, err
}
return lists, nil
}
// GetAccountLists returns the lists containing a given account.
func (c *Client) GetAccountLists(ctx context.Context, id ID) ([]*List, error) {
var lists []*List
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/lists", url.PathEscape(string(id))), nil, &lists, nil)
if err != nil {
return nil, err
}
return lists, nil
}
// GetListAccounts returns the accounts in a given list.
func (c *Client) GetListAccounts(ctx context.Context, id ID) ([]*Account, error) {
var accounts []*Account
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(id))), url.Values{"limit": {"0"}}, &accounts, nil)
if err != nil {
return nil, err
}
return accounts, nil
}
// GetList retrieves a list by ID.
func (c *Client) GetList(ctx context.Context, id ID) (*List, error) {
var list List
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, &list, nil)
if err != nil {
return nil, err
}
return &list, nil
}
// CreateList creates a new list with a given title.
func (c *Client) CreateList(ctx context.Context, title string) (*List, error) {
params := url.Values{}
params.Set("title", title)
var list List
err := c.doAPI(ctx, http.MethodPost, "/api/v1/lists", params, &list, nil)
if err != nil {
return nil, err
}
return &list, nil
}
// RenameList assigns a new title to a list.
func (c *Client) RenameList(ctx context.Context, id ID, title string) (*List, error) {
params := url.Values{}
params.Set("title", title)
var list List
err := c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), params, &list, nil)
if err != nil {
return nil, err
}
return &list, nil
}
// DeleteList removes a list.
func (c *Client) DeleteList(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, nil, nil)
}
// AddToList adds accounts to a list.
//
// Only accounts already followed by the user can be added to a list.
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))
}
return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
}
// RemoveFromList removes accounts from a list.
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))
}
return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
}

280
lists_test.go 100644
View File

@ -0,0 +1,280 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetLists(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `[{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
lists, err := client.GetLists(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(lists) != 2 {
t.Fatalf("result should be two: %d", len(lists))
}
if lists[0].Title != "foo" {
t.Fatalf("want %q but %q", "foo", lists[0].Title)
}
if lists[1].Title != "bar" {
t.Fatalf("want %q but %q", "bar", lists[1].Title)
}
}
func TestGetAccountLists(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/accounts/1/lists" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `[{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetAccountLists(context.Background(), "2")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
lists, err := client.GetAccountLists(context.Background(), "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(lists) != 2 {
t.Fatalf("result should be two: %d", len(lists))
}
if lists[0].Title != "foo" {
t.Fatalf("want %q but %q", "foo", lists[0].Title)
}
if lists[1].Title != "bar" {
t.Fatalf("want %q but %q", "bar", lists[1].Title)
}
}
func TestGetListAccounts(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists/1/accounts" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetListAccounts(context.Background(), "2")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
accounts, err := client.GetListAccounts(context.Background(), "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(accounts) != 2 {
t.Fatalf("result should be two: %d", len(accounts))
}
if accounts[0].Username != "foo" {
t.Fatalf("want %q but %q", "foo", accounts[0].Username)
}
if accounts[1].Username != "bar" {
t.Fatalf("want %q but %q", "bar", accounts[1].Username)
}
}
func TestGetList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"id": "1", "title": "foo"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetList(context.Background(), "2")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
list, err := client.GetList(context.Background(), "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if list.Title != "foo" {
t.Fatalf("want %q but %q", "foo", list.Title)
}
}
func TestCreateList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.PostFormValue("title") != "foo" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `{"id": "1", "title": "foo"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.CreateList(context.Background(), "")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
list, err := client.CreateList(context.Background(), "foo")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if list.Title != "foo" {
t.Fatalf("want %q but %q", "foo", list.Title)
}
}
func TestRenameList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.PostFormValue("title") != "bar" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, `{"id": "1", "title": "bar"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.RenameList(context.Background(), "2", "bar")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
list, err := client.RenameList(context.Background(), "1", "bar")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if list.Title != "bar" {
t.Fatalf("want %q but %q", "bar", list.Title)
}
}
func TestDeleteList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.Method != "DELETE" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
err := client.DeleteList(context.Background(), "2")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
err = client.DeleteList(context.Background(), "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}
func TestAddToList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists/1/accounts" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.PostFormValue("account_ids") != "1" {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
err := client.AddToList(context.Background(), "1", "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}
func TestRemoveFromList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/lists/1/accounts" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.Method != "DELETE" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
err := client.RemoveFromList(context.Background(), "1", "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}

View File

@ -2,20 +2,16 @@
package mastodon
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/tomnomnom/linkheader"
)
@ -31,11 +27,12 @@ type Config struct {
// Client is a API client for mastodon.
type Client struct {
http.Client
config *Config
Config *Config
UserAgent string
}
func (c *Client) doAPI(ctx context.Context, method string, uri string, params interface{}, res interface{}, pg *Pagination) error {
u, err := url.Parse(c.config.Server)
u, err := url.Parse(c.Config.Server)
if err != nil {
return err
}
@ -57,32 +54,18 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in
if err != nil {
return err
}
} else if file, ok := params.(string); ok {
f, err := os.Open(file)
} else if media, ok := params.(*Media); ok {
r, contentType, err := media.bodyAndContentType()
if err != nil {
return err
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", filepath.Base(file))
req, err = http.NewRequest(method, u.String(), r)
if err != nil {
return err
}
_, err = io.Copy(part, f)
if err != nil {
return err
}
err = mw.Close()
if err != nil {
return err
}
req, err = http.NewRequest(method, u.String(), &buf)
if err != nil {
return err
}
ct = mw.FormDataContentType()
ct = contentType
} else {
if method == http.MethodGet && pg != nil {
u.RawQuery = pg.toValues().Encode()
@ -93,16 +76,41 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in
}
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
req.Header.Set("Authorization", "Bearer "+c.Config.AccessToken)
if params != nil {
req.Header.Set("Content-Type", ct)
}
resp, err := c.Do(req)
if err != nil {
return err
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
var resp *http.Response
backoff := time.Second
for {
resp, err = c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// handle status code 429, which indicates the server is throttling
// our requests. Do an exponential backoff and retry the request.
if resp.StatusCode == 429 {
if backoff > time.Hour {
break
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
backoff = time.Duration(1.5 * float64(backoff))
continue
}
break
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return parseAPIError("bad request", resp)
@ -120,25 +128,57 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in
return json.NewDecoder(resp.Body).Decode(&res)
}
// NewClient return new mastodon API client.
// NewClient returns a new mastodon API client.
func NewClient(config *Config) *Client {
return &Client{
Client: *http.DefaultClient,
config: config,
Config: config,
}
}
// Authenticate get access-token to the API.
// Authenticate gets access-token to the API.
func (c *Client) Authenticate(ctx context.Context, username, password string) error {
params := url.Values{}
params.Set("client_id", c.config.ClientID)
params.Set("client_secret", c.config.ClientSecret)
params.Set("grant_type", "password")
params.Set("username", username)
params.Set("password", password)
params.Set("scope", "read write follow")
params := url.Values{
"client_id": {c.Config.ClientID},
"client_secret": {c.Config.ClientSecret},
"grant_type": {"password"},
"username": {username},
"password": {password},
"scope": {"read write follow"},
}
u, err := url.Parse(c.config.Server)
return c.authenticate(ctx, params)
}
// AuthenticateApp logs in using client credentials.
func (c *Client) AuthenticateApp(ctx context.Context) error {
params := url.Values{
"client_id": {c.Config.ClientID},
"client_secret": {c.Config.ClientSecret},
"grant_type": {"client_credentials"},
"redirect_uri": {"urn:ietf:wg:oauth:2.0:oob"},
}
return c.authenticate(ctx, params)
}
// AuthenticateToken logs in using a grant token returned by Application.AuthURI.
//
// redirectURI should be the same as Application.RedirectURI.
func (c *Client) AuthenticateToken(ctx context.Context, authCode, redirectURI string) error {
params := url.Values{
"client_id": {c.Config.ClientID},
"client_secret": {c.Config.ClientSecret},
"grant_type": {"authorization_code"},
"code": {authCode},
"redirect_uri": {redirectURI},
}
return c.authenticate(ctx, params)
}
func (c *Client) authenticate(ctx context.Context, params url.Values) error {
u, err := url.Parse(c.Config.Server)
if err != nil {
return err
}
@ -150,6 +190,9 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) er
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
resp, err := c.Do(req)
if err != nil {
return err
@ -160,25 +203,44 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) er
return parseAPIError("bad authorization", resp)
}
res := struct {
var res struct {
AccessToken string `json:"access_token"`
}{}
}
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
return err
}
c.config.AccessToken = res.AccessToken
c.Config.AccessToken = res.AccessToken
return nil
}
// Toot is struct to post status.
// Convenience constants for Toot.Visibility
const (
VisibilityPublic = "public"
VisibilityUnlisted = "unlisted"
VisibilityFollowersOnly = "private"
VisibilityDirectMessage = "direct"
)
// Toot is a struct to post status.
type Toot struct {
Status string `json:"status"`
InReplyToID ID `json:"in_reply_to_id"`
MediaIDs []ID `json:"media_ids"`
Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"`
Visibility string `json:"visibility"`
Status string `json:"status"`
InReplyToID ID `json:"in_reply_to_id"`
MediaIDs []ID `json:"media_ids"`
Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"`
Visibility string `json:"visibility"`
Language string `json:"language"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
Poll *TootPoll `json:"poll"`
}
// TootPoll holds information for creating a poll in Toot.
type TootPoll struct {
Options []string `json:"options"`
ExpiresInSeconds int64 `json:"expires_in"`
Multiple bool `json:"multiple"`
HideTotals bool `json:"hide_totals"`
}
// Mention hold information for mention.
@ -191,38 +253,64 @@ type Mention struct {
// Tag hold information for tag.
type Tag struct {
Name string `json:"name"`
URL string `json:"url"`
Name string `json:"name"`
URL string `json:"url"`
History []History `json:"history"`
}
// History hold information for history.
type History struct {
Day string `json:"day"`
Uses string `json:"uses"`
Accounts string `json:"accounts"`
}
// Attachment hold information for attachment.
type Attachment struct {
ID ID `json:"id"`
Type string `json:"type"`
URL string `json:"url"`
RemoteURL string `json:"remote_url"`
PreviewURL string `json:"preview_url"`
TextURL string `json:"text_url"`
ID ID `json:"id"`
Type string `json:"type"`
URL string `json:"url"`
RemoteURL string `json:"remote_url"`
PreviewURL string `json:"preview_url"`
TextURL string `json:"text_url"`
Description string `json:"description"`
Meta AttachmentMeta `json:"meta"`
}
// AttachmentMeta holds information for attachment metadata.
type AttachmentMeta struct {
Original AttachmentSize `json:"original"`
Small AttachmentSize `json:"small"`
}
// AttachmentSize holds information for attatchment size.
type AttachmentSize struct {
Width int64 `json:"width"`
Height int64 `json:"height"`
Size string `json:"size"`
Aspect float64 `json:"aspect"`
}
// Emoji hold information for CustomEmoji.
type Emoji struct {
ShortCode string `json:"shortcode"`
URL string `json:"url"`
StaticURL string `json:"static_url"`
ShortCode string `json:"shortcode"`
StaticURL string `json:"static_url"`
URL string `json:"url"`
VisibleInPicker bool `json:"visible_in_picker"`
}
// Results hold information for search result.
type Results struct {
Accounts []*Account `json:"accounts"`
Statuses []*Status `json:"statuses"`
Hashtags []string `json:"hashtags"`
Hashtags []*Tag `json:"hashtags"`
}
// Pagination is a struct for specifying the get range.
type Pagination struct {
MaxID ID
SinceID ID
MinID ID
Limit int64
}
@ -246,6 +334,12 @@ func newPagination(rawlink string) (*Pagination, error) {
return nil, err
}
p.SinceID = sinceID
minID, err := getPaginationID(link.URL, "min_id")
if err != nil {
return nil, err
}
p.MinID = minID
}
}
@ -258,12 +352,7 @@ func getPaginationID(rawurl, key string) (ID, error) {
return "", err
}
id, err := strconv.ParseInt(u.Query().Get(key), 10, 64)
if err != nil {
return "", err
}
return ID(fmt.Sprint(id)), nil
return ID(u.Query().Get(key)), nil
}
func (p *Pagination) toValues() url.Values {
@ -273,9 +362,13 @@ func (p *Pagination) toValues() url.Values {
func (p *Pagination) setValues(params url.Values) url.Values {
if p.MaxID != "" {
params.Set("max_id", string(p.MaxID))
} else if p.SinceID != "" {
}
if p.SinceID != "" {
params.Set("since_id", string(p.SinceID))
}
if p.MinID != "" {
params.Set("min_id", string(p.MinID))
}
if p.Limit > 0 {
params.Set("limit", fmt.Sprint(p.Limit))
}

View File

@ -2,6 +2,7 @@ package mastodon
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@ -95,7 +96,6 @@ func TestAuthenticate(t *testing.T) {
return
}
fmt.Fprintln(w, `{"access_token": "zoo"}`)
return
}))
defer ts.Close()
@ -123,7 +123,6 @@ func TestAuthenticate(t *testing.T) {
func TestAuthenticateWithCancel(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
return
}))
defer ts.Close()
@ -138,11 +137,42 @@ func TestAuthenticateWithCancel(t *testing.T) {
if err == nil {
t.Fatalf("should be fail: %v", err)
}
if want := "Post " + ts.URL + "/oauth/token: context canceled"; want != err.Error() {
if want := fmt.Sprintf("Post %q: context canceled", ts.URL+"/oauth/token"); want != err.Error() {
t.Fatalf("want %q but %q", want, err.Error())
}
}
func TestAuthenticateApp(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.FormValue("client_id") != "foo" || r.FormValue("client_secret") != "bar" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
fmt.Fprintln(w, `{"name":"zzz","website":"yyy","vapid_key":"xxx"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bat",
})
err := client.AuthenticateApp(context.Background())
if err == nil {
t.Fatalf("should be fail: %v", err)
}
client = NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
})
err = client.AuthenticateApp(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}
func TestPostStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer zoo" {
@ -150,7 +180,6 @@ func TestPostStatus(t *testing.T) {
return
}
fmt.Fprintln(w, `{"access_token": "zoo"}`)
return
}))
defer ts.Close()
@ -183,7 +212,6 @@ func TestPostStatus(t *testing.T) {
func TestPostStatusWithCancel(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
return
}))
defer ts.Close()
@ -200,15 +228,309 @@ func TestPostStatusWithCancel(t *testing.T) {
if err == nil {
t.Fatalf("should be fail: %v", err)
}
if want := "Post " + ts.URL + "/api/v1/statuses: context canceled"; want != err.Error() {
if want := fmt.Sprintf("Post %q: context canceled", ts.URL+"/api/v1/statuses"); want != err.Error() {
t.Fatalf("want %q but %q", want, err.Error())
}
}
func TestPostStatusParams(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
r.ParseForm()
if r.FormValue("media_ids[]") != "" && r.FormValue("poll[options][]") != "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
s := Status{
ID: ID("1"),
Content: fmt.Sprintf("<p>%s</p>", r.FormValue("status")),
}
if r.FormValue("in_reply_to_id") != "" {
s.InReplyToID = ID(r.FormValue("in_reply_to_id"))
}
if r.FormValue("visibility") != "" {
s.Visibility = (r.FormValue("visibility"))
}
if r.FormValue("language") != "" {
s.Language = (r.FormValue("language"))
}
if r.FormValue("sensitive") == "true" {
s.Sensitive = true
s.SpoilerText = fmt.Sprintf("<p>%s</p>", r.FormValue("spoiler_text"))
}
if r.FormValue("media_ids[]") != "" {
for _, id := range r.Form["media_ids[]"] {
s.MediaAttachments = append(s.MediaAttachments,
Attachment{ID: ID(id)})
}
}
if r.FormValue("poll[options][]") != "" {
p := Poll{}
for _, opt := range r.Form["poll[options][]"] {
p.Options = append(p.Options, PollOption{
Title: opt,
VotesCount: 0,
})
}
if r.FormValue("poll[multiple]") == "true" {
p.Multiple = true
}
s.Poll = &p
}
json.NewEncoder(w).Encode(s)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
s, err := client.PostStatus(context.Background(), &Toot{
Status: "foobar",
InReplyToID: ID("2"),
Visibility: "unlisted",
Language: "sv",
Sensitive: true,
SpoilerText: "bar",
MediaIDs: []ID{"1", "2"},
Poll: &TootPoll{
Options: []string{"A", "B"},
},
})
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(s.MediaAttachments) > 0 && s.Poll != nil {
t.Fatal("should not fail, can't have both Media and Poll")
}
if s.Content != "<p>foobar</p>" {
t.Fatalf("want %q but %q", "<p>foobar</p>", s.Content)
}
if s.InReplyToID != "2" {
t.Fatalf("want %q but %q", "2", s.InReplyToID)
}
if s.Visibility != "unlisted" {
t.Fatalf("want %q but %q", "unlisted", s.Visibility)
}
if s.Language != "sv" {
t.Fatalf("want %q but %q", "sv", s.Language)
}
if s.Sensitive != true {
t.Fatalf("want %t but %t", true, s.Sensitive)
}
if s.SpoilerText != "<p>bar</p>" {
t.Fatalf("want %q but %q", "<p>bar</p>", s.SpoilerText)
}
s, err = client.PostStatus(context.Background(), &Toot{
Status: "foobar",
Poll: &TootPoll{
Multiple: true,
Options: []string{"A", "B"},
HideTotals: true,
},
})
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if s.Poll == nil {
t.Fatalf("poll should not be %v", s.Poll)
}
if len(s.Poll.Options) != 2 {
t.Fatalf("want %q but %q", 2, len(s.Poll.Options))
}
if s.Poll.Options[0].Title != "A" {
t.Fatalf("want %q but %q", "A", s.Poll.Options[0].Title)
}
if s.Poll.Options[1].Title != "B" {
t.Fatalf("want %q but %q", "B", s.Poll.Options[1].Title)
}
if s.Poll.Multiple != true {
t.Fatalf("want %t but %t", true, s.Poll.Multiple)
}
}
func TestUpdateStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer zoo" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
fmt.Fprintln(w, `{"access_token": "zoo"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
})
_, err := client.UpdateStatus(context.Background(), &Toot{
Status: "foobar",
}, ID("1"))
if err == nil {
t.Fatalf("should be fail: %v", err)
}
client = NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err = client.UpdateStatus(context.Background(), &Toot{
Status: "foobar",
}, ID("1"))
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}
func TestUpdateStatusWithCancel(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.UpdateStatus(ctx, &Toot{
Status: "foobar",
}, ID("1"))
if err == nil {
t.Fatalf("should be fail: %v", err)
}
if want := fmt.Sprintf("Put %q: context canceled", ts.URL+"/api/v1/statuses/1"); want != err.Error() {
t.Fatalf("want %q but %q", want, err.Error())
}
}
func TestUpdateStatusParams(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
r.ParseForm()
if r.FormValue("media_ids[]") != "" && r.FormValue("poll[options][]") != "" {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
s := Status{
ID: ID("1"),
Content: fmt.Sprintf("<p>%s</p>", r.FormValue("status")),
}
if r.FormValue("in_reply_to_id") != "" {
s.InReplyToID = ID(r.FormValue("in_reply_to_id"))
}
if r.FormValue("visibility") != "" {
s.Visibility = (r.FormValue("visibility"))
}
if r.FormValue("language") != "" {
s.Language = (r.FormValue("language"))
}
if r.FormValue("sensitive") == "true" {
s.Sensitive = true
s.SpoilerText = fmt.Sprintf("<p>%s</p>", r.FormValue("spoiler_text"))
}
if r.FormValue("media_ids[]") != "" {
for _, id := range r.Form["media_ids[]"] {
s.MediaAttachments = append(s.MediaAttachments,
Attachment{ID: ID(id)})
}
}
if r.FormValue("poll[options][]") != "" {
p := Poll{}
for _, opt := range r.Form["poll[options][]"] {
p.Options = append(p.Options, PollOption{
Title: opt,
VotesCount: 0,
})
}
if r.FormValue("poll[multiple]") == "true" {
p.Multiple = true
}
s.Poll = &p
}
json.NewEncoder(w).Encode(s)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
s, err := client.UpdateStatus(context.Background(), &Toot{
Status: "foobar",
InReplyToID: ID("2"),
Visibility: "unlisted",
Language: "sv",
Sensitive: true,
SpoilerText: "bar",
MediaIDs: []ID{"1", "2"},
Poll: &TootPoll{
Options: []string{"A", "B"},
},
}, ID("1"))
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(s.MediaAttachments) > 0 && s.Poll != nil {
t.Fatal("should not fail, can't have both Media and Poll")
}
if s.Content != "<p>foobar</p>" {
t.Fatalf("want %q but %q", "<p>foobar</p>", s.Content)
}
if s.InReplyToID != "2" {
t.Fatalf("want %q but %q", "2", s.InReplyToID)
}
if s.Visibility != "unlisted" {
t.Fatalf("want %q but %q", "unlisted", s.Visibility)
}
if s.Language != "sv" {
t.Fatalf("want %q but %q", "sv", s.Language)
}
if s.Sensitive != true {
t.Fatalf("want %t but %t", true, s.Sensitive)
}
if s.SpoilerText != "<p>bar</p>" {
t.Fatalf("want %q but %q", "<p>bar</p>", s.SpoilerText)
}
s, err = client.UpdateStatus(context.Background(), &Toot{
Status: "foobar",
Poll: &TootPoll{
Multiple: true,
Options: []string{"A", "B"},
},
}, ID("1"))
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if s.Poll == nil {
t.Fatalf("poll should not be %v", s.Poll)
}
if len(s.Poll.Options) != 2 {
t.Fatalf("want %q but %q", 2, len(s.Poll.Options))
}
if s.Poll.Options[0].Title != "A" {
t.Fatalf("want %q but %q", "A", s.Poll.Options[0].Title)
}
if s.Poll.Options[1].Title != "B" {
t.Fatalf("want %q but %q", "B", s.Poll.Options[1].Title)
}
if s.Poll.Multiple != true {
t.Fatalf("want %t but %t", true, s.Poll.Multiple)
}
}
func TestGetTimelineHome(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `[{"content": "foo"}, {"content": "bar"}]`)
return
}))
defer ts.Close()
@ -248,7 +570,6 @@ func TestGetTimelineHome(t *testing.T) {
func TestGetTimelineHomeWithCancel(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
return
}))
defer ts.Close()
@ -264,13 +585,14 @@ func TestGetTimelineHomeWithCancel(t *testing.T) {
if err == nil {
t.Fatalf("should be fail: %v", err)
}
if want := "Get " + ts.URL + "/api/v1/timelines/home: context canceled"; want != err.Error() {
if want := fmt.Sprintf("Get %q: context canceled", ts.URL+"/api/v1/timelines/home"); want != err.Error() {
t.Fatalf("want %q but %q", want, err.Error())
}
}
func TestForTheCoverages(t *testing.T) {
(*UpdateEvent)(nil).event()
(*UpdateEditEvent)(nil).event()
(*NotificationEvent)(nil).event()
(*DeleteEvent)(nil).event()
(*ErrorEvent)(nil).event()
@ -293,6 +615,11 @@ func TestNewPagination(t *testing.T) {
t.Fatalf("should be fail: %v", err)
}
_, err = newPagination(`<http://example.com?min_id=abc>; rel="prev"`)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
pg, err := newPagination(`<http://example.com?max_id=123>; rel="next", <http://example.com?since_id=789>; rel="prev"`)
if err != nil {
t.Fatalf("should not be fail: %v", err)
@ -312,8 +639,8 @@ func TestGetPaginationID(t *testing.T) {
}
_, err = getPaginationID("http://example.com?max_id=abc", "max_id")
if err == nil {
t.Fatalf("should be fail: %v", err)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
id, err := getPaginationID("http://example.com?max_id=123", "max_id")
@ -328,7 +655,8 @@ func TestGetPaginationID(t *testing.T) {
func TestPaginationSetValues(t *testing.T) {
p := &Pagination{
MaxID: "123",
SinceID: "789",
SinceID: "456",
MinID: "789",
Limit: 10,
}
before := url.Values{"key": {"value"}}
@ -339,8 +667,11 @@ func TestPaginationSetValues(t *testing.T) {
if after.Get("max_id") != "123" {
t.Fatalf("want %q but %q", "123", after.Get("max_id"))
}
if after.Get("since_id") != "" {
t.Fatalf("result should be empty string: %q", after.Get("since_id"))
if after.Get("since_id") != "456" {
t.Fatalf("want %q but %q", "456", after.Get("since_id"))
}
if after.Get("min_id") != "789" {
t.Fatalf("want %q but %q", "789", after.Get("min_id"))
}
if after.Get("limit") != "10" {
t.Fatalf("want %q but %q", "10", after.Get("limit"))
@ -358,4 +689,7 @@ func TestPaginationSetValues(t *testing.T) {
if after.Get("since_id") != "789" {
t.Fatalf("want %q but %q", "789", after.Get("since_id"))
}
if after.Get("min_id") != "" {
t.Fatalf("result should be empty string: %q", after.Get("min_id"))
}
}

View File

@ -2,12 +2,17 @@ package mastodon
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
// Notification hold information for mastodon notification.
// Notification holds information for a mastodon notification.
type Notification struct {
ID ID `json:"id"`
Type string `json:"type"`
@ -16,7 +21,21 @@ type Notification struct {
Status *Status `json:"status"`
}
// GetNotifications return notifications.
type PushSubscription struct {
ID ID `json:"id"`
Endpoint string `json:"endpoint"`
ServerKey string `json:"server_key"`
Alerts *PushAlerts `json:"alerts"`
}
type PushAlerts struct {
Follow *Sbool `json:"follow"`
Favourite *Sbool `json:"favourite"`
Reblog *Sbool `json:"reblog"`
Mention *Sbool `json:"mention"`
}
// GetNotifications returns notifications.
func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notification, error) {
var notifications []*Notification
err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", nil, &notifications, pg)
@ -26,7 +45,7 @@ func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notif
return notifications, nil
}
// GetNotification return notification.
// GetNotification returns notification.
func (c *Client) GetNotification(ctx context.Context, id ID) (*Notification, error) {
var notification Notification
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/notifications/%v", id), nil, &notification, nil)
@ -36,7 +55,77 @@ func (c *Client) GetNotification(ctx context.Context, id ID) (*Notification, err
return &notification, nil
}
// ClearNotifications clear notifications.
// DismissNotification deletes a single notification.
func (c *Client) DismissNotification(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/notifications/%v/dismiss", id), nil, nil, nil)
}
// ClearNotifications clears notifications.
func (c *Client) ClearNotifications(ctx context.Context) error {
return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil)
}
// AddPushSubscription adds a new push subscription.
func (c *Client) AddPushSubscription(ctx context.Context, endpoint string, public ecdsa.PublicKey, shared []byte, alerts PushAlerts) (*PushSubscription, error) {
var subscription PushSubscription
pk := elliptic.Marshal(public.Curve, public.X, public.Y)
params := url.Values{}
params.Add("subscription[endpoint]", endpoint)
params.Add("subscription[keys][p256dh]", base64.RawURLEncoding.EncodeToString(pk))
params.Add("subscription[keys][auth]", base64.RawURLEncoding.EncodeToString(shared))
if alerts.Follow != nil {
params.Add("data[alerts][follow]", strconv.FormatBool(bool(*alerts.Follow)))
}
if alerts.Favourite != nil {
params.Add("data[alerts][favourite]", strconv.FormatBool(bool(*alerts.Favourite)))
}
if alerts.Reblog != nil {
params.Add("data[alerts][reblog]", strconv.FormatBool(bool(*alerts.Reblog)))
}
if alerts.Mention != nil {
params.Add("data[alerts][mention]", strconv.FormatBool(bool(*alerts.Mention)))
}
err := c.doAPI(ctx, http.MethodPost, "/api/v1/push/subscription", params, &subscription, nil)
if err != nil {
return nil, err
}
return &subscription, nil
}
// UpdatePushSubscription updates which type of notifications are sent for the active push subscription.
func (c *Client) UpdatePushSubscription(ctx context.Context, alerts *PushAlerts) (*PushSubscription, error) {
var subscription PushSubscription
params := url.Values{}
if alerts.Follow != nil {
params.Add("data[alerts][follow]", strconv.FormatBool(bool(*alerts.Follow)))
}
if alerts.Mention != nil {
params.Add("data[alerts][favourite]", strconv.FormatBool(bool(*alerts.Favourite)))
}
if alerts.Reblog != nil {
params.Add("data[alerts][reblog]", strconv.FormatBool(bool(*alerts.Reblog)))
}
if alerts.Mention != nil {
params.Add("data[alerts][mention]", strconv.FormatBool(bool(*alerts.Mention)))
}
err := c.doAPI(ctx, http.MethodPut, "/api/v1/push/subscription", params, &subscription, nil)
if err != nil {
return nil, err
}
return &subscription, nil
}
// RemovePushSubscription deletes the active push subscription.
func (c *Client) RemovePushSubscription(ctx context.Context) error {
return c.doAPI(ctx, http.MethodDelete, "/api/v1/push/subscription", nil, nil, nil)
}
// GetPushSubscription retrieves information about the active push subscription.
func (c *Client) GetPushSubscription(ctx context.Context) (*PushSubscription, error) {
var subscription PushSubscription
err := c.doAPI(ctx, http.MethodGet, "/api/v1/push/subscription", nil, &subscription, nil)
if err != nil {
return nil, err
}
return &subscription, nil
}

View File

@ -2,6 +2,9 @@ package mastodon
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"net/http"
"net/http/httptest"
@ -20,9 +23,11 @@ func TestGetNotifications(t *testing.T) {
case "/api/v1/notifications/clear":
fmt.Fprintln(w, `{}`)
return
case "/api/v1/notifications/123/dismiss":
fmt.Fprintln(w, `{}`)
return
}
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}))
defer ts.Close()
@ -56,4 +61,83 @@ func TestGetNotifications(t *testing.T) {
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
err = client.DismissNotification(context.Background(), "123")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}
func TestPushSubscription(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/push/subscription":
fmt.Fprintln(w, ` {"id":1,"endpoint":"https://example.org","alerts":{"follow":true,"favourite":"true","reblog":"true","mention":"true"},"server_key":"foobar"}`)
return
}
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
enabled := new(Sbool)
*enabled = true
alerts := PushAlerts{Follow: enabled, Favourite: enabled, Reblog: enabled, Mention: enabled}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
shared := make([]byte, 16)
_, err = rand.Read(shared)
if err != nil {
t.Fatal(err)
}
testSub := func(sub *PushSubscription, err error) {
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if sub.ID != "1" {
t.Fatalf("want %v but %v", "1", sub.ID)
}
if sub.Endpoint != "https://example.org" {
t.Fatalf("want %v but %v", "https://example.org", sub.Endpoint)
}
if sub.ServerKey != "foobar" {
t.Fatalf("want %v but %v", "foobar", sub.ServerKey)
}
if *sub.Alerts.Favourite != true {
t.Fatalf("want %v but %v", true, *sub.Alerts.Favourite)
}
if *sub.Alerts.Mention != true {
t.Fatalf("want %v but %v", true, *sub.Alerts.Mention)
}
if *sub.Alerts.Reblog != true {
t.Fatalf("want %v but %v", true, *sub.Alerts.Reblog)
}
if *sub.Alerts.Follow != true {
t.Fatalf("want %v but %v", true, *sub.Alerts.Follow)
}
}
sub, err := client.AddPushSubscription(context.Background(), "http://example.org", priv.PublicKey, shared, alerts)
testSub(sub, err)
sub, err = client.GetPushSubscription(context.Background())
testSub(sub, err)
sub, err = client.UpdatePushSubscription(context.Background(), &alerts)
testSub(sub, err)
err = client.RemovePushSubscription(context.Background())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}

54
polls.go 100644
View File

@ -0,0 +1,54 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
// Poll holds information for mastodon polls.
type Poll struct {
ID ID `json:"id"`
ExpiresAt time.Time `json:"expires_at"`
Expired bool `json:"expired"`
Multiple bool `json:"multiple"`
VotesCount int64 `json:"votes_count"`
VotersCount int64 `json:"voters_count"`
Options []PollOption `json:"options"`
Voted bool `json:"voted"`
OwnVotes []int `json:"own_votes"`
Emojis []Emoji `json:"emojis"`
}
// Poll holds information for a mastodon poll option.
type PollOption struct {
Title string `json:"title"`
VotesCount int64 `json:"votes_count"`
}
// GetPoll returns poll specified by id.
func (c *Client) GetPoll(ctx context.Context, id ID) (*Poll, error) {
var poll Poll
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/polls/%s", id), nil, &poll, nil)
if err != nil {
return nil, err
}
return &poll, nil
}
// PollVote votes on a poll specified by id, choices is the Poll.Options index to vote on
func (c *Client) PollVote(ctx context.Context, id ID, choices ...int) (*Poll, error) {
params := url.Values{}
for _, c := range choices {
params.Add("choices[]", fmt.Sprintf("%d", c))
}
var poll Poll
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/polls/%s/votes", url.PathEscape(string(id))), params, &poll, nil)
if err != nil {
return nil, err
}
return &poll, nil
}

145
polls_test.go 100644
View File

@ -0,0 +1,145 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetPoll(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/polls/1234567" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"id": "1234567", "expires_at": "2019-12-05T04:05:08.302Z", "expired": true, "multiple": false, "votes_count": 10, "voters_count": null, "voted": true, "own_votes": [1], "options": [{"title": "accept", "votes_count": 6}, {"title": "deny", "votes_count": 4}], "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetPoll(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
poll, err := client.GetPoll(context.Background(), "1234567")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if poll.Expired != true {
t.Fatalf("want %t but %t", true, poll.Expired)
}
if poll.Multiple != false {
t.Fatalf("want %t but %t", true, poll.Multiple)
}
if poll.VotesCount != 10 {
t.Fatalf("want %d but %d", 10, poll.VotesCount)
}
if poll.VotersCount != 0 {
t.Fatalf("want %d but %d", 0, poll.VotersCount)
}
if poll.Voted != true {
t.Fatalf("want %t but %t", true, poll.Voted)
}
if len(poll.OwnVotes) != 1 {
t.Fatalf("should have own votes")
}
if poll.OwnVotes[0] != 1 {
t.Fatalf("want %d but %d", 1, poll.OwnVotes[0])
}
if len(poll.Options) != 2 {
t.Fatalf("should have 2 options")
}
if poll.Options[0].Title != "accept" {
t.Fatalf("want %q but %q", "accept", poll.Options[0].Title)
}
if poll.Options[0].VotesCount != 6 {
t.Fatalf("want %q but %q", 6, poll.Options[0].VotesCount)
}
if poll.Options[1].Title != "deny" {
t.Fatalf("want %q but %q", "deny", poll.Options[1].Title)
}
if poll.Options[1].VotesCount != 4 {
t.Fatalf("want %q but %q", 4, poll.Options[1].VotesCount)
}
if len(poll.Emojis) != 1 {
t.Fatal("should have emojis")
}
if poll.Emojis[0].ShortCode != "💩" {
t.Fatalf("want %q but %q", "💩", poll.Emojis[0].ShortCode)
}
if poll.Emojis[0].URL != "http://example.com" {
t.Fatalf("want %q but %q", "https://example.com", poll.Emojis[0].URL)
}
if poll.Emojis[0].StaticURL != "http://example.com/static" {
t.Fatalf("want %q but %q", "https://example.com/static", poll.Emojis[0].StaticURL)
}
}
func TestPollVote(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/polls/1234567/votes" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.Method != "POST" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed)
return
}
fmt.Fprintln(w, `{"id": "1234567", "expires_at": "2019-12-05T04:05:08.302Z", "expired": false, "multiple": false, "votes_count": 10, "voters_count": null, "voted": true, "own_votes": [1], "options": [{"title": "accept", "votes_count": 6}, {"title": "deny", "votes_count": 4}], "emojis":[]}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
poll, err := client.PollVote(context.Background(), ID("1234567"), 1)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if poll.Expired != false {
t.Fatalf("want %t but %t", false, poll.Expired)
}
if poll.Multiple != false {
t.Fatalf("want %t but %t", true, poll.Multiple)
}
if poll.VotesCount != 10 {
t.Fatalf("want %d but %d", 10, poll.VotesCount)
}
if poll.VotersCount != 0 {
t.Fatalf("want %d but %d", 0, poll.VotersCount)
}
if poll.Voted != true {
t.Fatalf("want %t but %t", true, poll.Voted)
}
if len(poll.OwnVotes) != 1 {
t.Fatalf("should have own votes")
}
if poll.OwnVotes[0] != 1 {
t.Fatalf("want %d but %d", 1, poll.OwnVotes[0])
}
if len(poll.Options) != 2 {
t.Fatalf("should have 2 options")
}
if poll.Options[0].Title != "accept" {
t.Fatalf("want %q but %q", "accept", poll.Options[0].Title)
}
if poll.Options[0].VotesCount != 6 {
t.Fatalf("want %q but %q", 6, poll.Options[0].VotesCount)
}
if poll.Options[1].Title != "deny" {
t.Fatalf("want %q but %q", "deny", poll.Options[1].Title)
}
if poll.Options[1].VotesCount != 4 {
t.Fatalf("want %q but %q", 4, poll.Options[1].VotesCount)
}
}

View File

@ -6,13 +6,13 @@ import (
"net/url"
)
// Report hold information for mastodon report.
// Report holds information for a mastodon report.
type Report struct {
ID int64 `json:"id"`
ActionTaken bool `json:"action_taken"`
}
// GetReports return report of the current user.
// GetReports returns report of the current user.
func (c *Client) GetReports(ctx context.Context) ([]*Report, error) {
var reports []*Report
err := c.doAPI(ctx, http.MethodGet, "/api/v1/reports", nil, &reports, nil)

View File

@ -15,7 +15,6 @@ func TestGetReports(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"id": 122, "action_taken": false}, {"id": 123, "action_taken": true}]`)
return
}))
defer ts.Close()
@ -55,7 +54,6 @@ func TestReport(t *testing.T) {
} else {
fmt.Fprintln(w, `{"id": 1234, "action_taken": true}`)
}
return
}))
defer ts.Close()
@ -65,11 +63,11 @@ func TestReport(t *testing.T) {
ClientSecret: "bar",
AccessToken: "zoo",
})
rp, err := client.Report(context.Background(), "121", nil, "")
_, err := client.Report(context.Background(), "121", nil, "")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
rp, err = client.Report(context.Background(), "122", nil, "")
rp, err := client.Report(context.Background(), "122", nil, "")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}

328
status.go
View File

@ -1,53 +1,165 @@
package mastodon
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Status is struct to hold status.
type Status struct {
ID ID `json:"id"`
CreatedAt time.Time `json:"created_at"`
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"`
Application Application `json:"application"`
Account Account `json:"account"`
MediaAttachments []Attachment `json:"media_attachments"`
Emojis []Emoji `json:"emojis"`
Mentions []Mention `json:"mentions"`
Tags []Tag `json:"tags"`
URI string `json:"uri"`
Content string `json:"content"`
URL string `json:"url"`
ReblogsCount int64 `json:"reblogs_count"`
FavouritesCount int64 `json:"favourites_count"`
Reblog *Status `json:"reblog"`
Favourited interface{} `json:"favourited"`
Reblogged interface{} `json:"reblogged"`
Card *Card `json:"card"`
Poll *Poll `json:"poll"`
Application Application `json:"application"`
Language string `json:"language"`
Pinned interface{} `json:"pinned"`
}
// Context hold information for mastodon context.
// StatusHistory is a struct to hold status history data.
type StatusHistory struct {
Content string `json:"content"`
SpoilerText string `json:"spoiler_text"`
Account Account `json:"account"`
Sensitive bool `json:"sensitive"`
CreatedAt time.Time `json:"created_at"`
Emojis []Emoji `json:"emojis"`
MediaAttachments []Attachment `json:"media_attachments"`
}
// Context holds information for a mastodon context.
type Context struct {
Ancestors []*Status `json:"ancestors"`
Descendants []*Status `json:"descendants"`
}
// Card hold information for mastodon card.
// Card holds information for a mastodon card.
type Card struct {
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
Type string `json:"type"`
AuthorName string `json:"author_name"`
AuthorURL string `json:"author_url"`
ProviderName string `json:"provider_name"`
ProviderURL string `json:"provider_url"`
HTML string `json:"html"`
Width int64 `json:"width"`
Height int64 `json:"height"`
}
// GetFavourites return the favorite list of the current user.
// Source holds source properties so a status can be edited.
type Source struct {
ID ID `json:"id"`
Text string `json:"text"`
SpoilerText string `json:"spoiler_text"`
}
// Conversation holds information for a mastodon conversation.
type Conversation struct {
ID ID `json:"id"`
Accounts []*Account `json:"accounts"`
Unread bool `json:"unread"`
LastStatus *Status `json:"last_status"`
}
// Media is struct to hold media.
type Media struct {
File io.Reader
Thumbnail io.Reader
Description string
Focus string
}
func (m *Media) bodyAndContentType() (io.Reader, string, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fileName := "upload"
if f, ok := m.File.(*os.File); ok {
fileName = f.Name()
}
file, err := mw.CreateFormFile("file", fileName)
if err != nil {
return nil, "", err
}
if _, err := io.Copy(file, m.File); err != nil {
return nil, "", err
}
if m.Thumbnail != nil {
thumbName := "upload"
if f, ok := m.Thumbnail.(*os.File); ok {
thumbName = f.Name()
}
thumb, err := mw.CreateFormFile("thumbnail", thumbName)
if err != nil {
return nil, "", err
}
if _, err := io.Copy(thumb, m.Thumbnail); err != nil {
return nil, "", err
}
}
if m.Description != "" {
desc, err := mw.CreateFormField("description")
if err != nil {
return nil, "", err
}
if _, err := io.Copy(desc, strings.NewReader(m.Description)); err != nil {
return nil, "", err
}
}
if m.Focus != "" {
focus, err := mw.CreateFormField("focus")
if err != nil {
return nil, "", err
}
if _, err := io.Copy(focus, strings.NewReader(m.Focus)); err != nil {
return nil, "", err
}
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return &buf, mw.FormDataContentType(), nil
}
// GetFavourites returns the favorite list of the current user.
func (c *Client) GetFavourites(ctx context.Context, pg *Pagination) ([]*Status, error) {
var statuses []*Status
err := c.doAPI(ctx, http.MethodGet, "/api/v1/favourites", nil, &statuses, pg)
@ -57,7 +169,17 @@ func (c *Client) GetFavourites(ctx context.Context, pg *Pagination) ([]*Status,
return statuses, nil
}
// GetStatus return status specified by id.
// GetBookmarks returns the bookmark list of the current user.
func (c *Client) GetBookmarks(ctx context.Context, pg *Pagination) ([]*Status, error) {
var statuses []*Status
err := c.doAPI(ctx, http.MethodGet, "/api/v1/bookmarks", nil, &statuses, pg)
if err != nil {
return nil, err
}
return statuses, nil
}
// GetStatus returns status specified by id.
func (c *Client) GetStatus(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s", id), nil, &status, nil)
@ -67,7 +189,7 @@ func (c *Client) GetStatus(ctx context.Context, id ID) (*Status, error) {
return &status, nil
}
// GetStatusContext return status specified by id.
// GetStatusContext returns status specified by id.
func (c *Client) GetStatusContext(ctx context.Context, id ID) (*Context, error) {
var context Context
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/context", id), nil, &context, nil)
@ -77,7 +199,7 @@ func (c *Client) GetStatusContext(ctx context.Context, id ID) (*Context, error)
return &context, nil
}
// GetStatusCard return status specified by id.
// GetStatusCard returns status specified by id.
func (c *Client) GetStatusCard(ctx context.Context, id ID) (*Card, error) {
var card Card
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/card", id), nil, &card, nil)
@ -87,6 +209,26 @@ func (c *Client) GetStatusCard(ctx context.Context, id ID) (*Card, error) {
return &card, nil
}
// GetStatusSource returns source data specified by id.
func (c *Client) GetStatusSource(ctx context.Context, id ID) (*Source, error) {
var source Source
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/source", id), nil, &source, nil)
if err != nil {
return nil, err
}
return &source, nil
}
// GetStatusHistory returns the status history specified by id.
func (c *Client) GetStatusHistory(ctx context.Context, id ID) ([]*StatusHistory, error) {
var statuses []*StatusHistory
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/history", id), nil, &statuses, nil)
if err != nil {
return nil, err
}
return statuses, nil
}
// GetRebloggedBy returns the account list of the user who reblogged the toot of id.
func (c *Client) GetRebloggedBy(ctx context.Context, id ID, pg *Pagination) ([]*Account, error) {
var accounts []*Account
@ -107,7 +249,7 @@ func (c *Client) GetFavouritedBy(ctx context.Context, id ID, pg *Pagination) ([]
return accounts, nil
}
// Reblog is reblog the toot of id and return status of reblog.
// Reblog reblogs the toot of id and returns status of reblog.
func (c *Client) Reblog(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/reblog", id), nil, &status, nil)
@ -117,7 +259,7 @@ func (c *Client) Reblog(ctx context.Context, id ID) (*Status, error) {
return &status, nil
}
// Unreblog is unreblog the toot of id and return status of the original toot.
// Unreblog unreblogs the toot of id and returns status of the original toot.
func (c *Client) Unreblog(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unreblog", id), nil, &status, nil)
@ -127,7 +269,7 @@ func (c *Client) Unreblog(ctx context.Context, id ID) (*Status, error) {
return &status, nil
}
// Favourite is favourite the toot of id and return status of the favourite toot.
// Favourite favourites the toot of id and returns status of the favourite toot.
func (c *Client) Favourite(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/favourite", id), nil, &status, nil)
@ -137,7 +279,7 @@ func (c *Client) Favourite(ctx context.Context, id ID) (*Status, error) {
return &status, nil
}
// Unfavourite is unfavourite the toot of id and return status of the unfavourite toot.
// Unfavourite unfavourites the toot of id and returns status of the unfavourite toot.
func (c *Client) Unfavourite(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unfavourite", id), nil, &status, nil)
@ -147,6 +289,26 @@ func (c *Client) Unfavourite(ctx context.Context, id ID) (*Status, error) {
return &status, nil
}
// Bookmark bookmarks the toot of id and returns status of the bookmark toot.
func (c *Client) Bookmark(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/bookmark", id), nil, &status, nil)
if err != nil {
return nil, err
}
return &status, nil
}
// Unbookmark is unbookmark the toot of id and return status of the unbookmark toot.
func (c *Client) Unbookmark(ctx context.Context, id ID) (*Status, error) {
var status Status
err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unbookmark", id), nil, &status, nil)
if err != nil {
return nil, err
}
return &status, nil
}
// GetTimelineHome return statuses from home timeline.
func (c *Client) GetTimelineHome(ctx context.Context, pg *Pagination) ([]*Status, error) {
var statuses []*Status
@ -187,6 +349,16 @@ func (c *Client) GetTimelineHashtag(ctx context.Context, tag string, isLocal boo
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
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/list/%s", url.PathEscape(string(id))), nil, &statuses, pg)
if err != nil {
return nil, err
}
return statuses, nil
}
// GetTimelineMedia return statuses from media timeline.
// NOTE: This is an experimental feature of pawoo.net.
func (c *Client) GetTimelineMedia(ctx context.Context, isLocal bool, pg *Pagination) ([]*Status, error) {
@ -206,6 +378,15 @@ func (c *Client) GetTimelineMedia(ctx context.Context, isLocal bool, pg *Paginat
// PostStatus post the toot.
func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) {
return c.postStatus(ctx, toot, false, ID("none"))
}
// UpdateStatus updates the toot.
func (c *Client) UpdateStatus(ctx context.Context, toot *Toot, id ID) (*Status, error) {
return c.postStatus(ctx, toot, true, id)
}
func (c *Client) postStatus(ctx context.Context, toot *Toot, update bool, updateID ID) (*Status, error) {
params := url.Values{}
params.Set("status", toot.Status)
if toot.InReplyToID != "" {
@ -216,18 +397,39 @@ func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) {
params.Add("media_ids[]", string(media))
}
}
// Can't use Media and Poll at the same time.
if toot.Poll != nil && toot.Poll.Options != nil && toot.MediaIDs == nil {
for _, opt := range toot.Poll.Options {
params.Add("poll[options][]", string(opt))
}
params.Add("poll[expires_in]", fmt.Sprintf("%d", toot.Poll.ExpiresInSeconds))
if toot.Poll.Multiple {
params.Add("poll[multiple]", "true")
}
if toot.Poll.HideTotals {
params.Add("poll[hide_totals]", "true")
}
}
if toot.Visibility != "" {
params.Set("visibility", fmt.Sprint(toot.Visibility))
}
if toot.Language != "" {
params.Set("language", fmt.Sprint(toot.Language))
}
if toot.Sensitive {
params.Set("senstitive", "true")
params.Set("sensitive", "true")
}
if toot.SpoilerText != "" {
params.Set("spoiler_text", toot.SpoilerText)
}
var status Status
err := c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status, nil)
var err error
if !update {
err = c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status, nil)
} else {
err = c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/statuses/%s", updateID), params, &status, nil)
}
if err != nil {
return nil, err
}
@ -245,19 +447,81 @@ func (c *Client) Search(ctx context.Context, q string, resolve bool) (*Results,
params.Set("q", q)
params.Set("resolve", fmt.Sprint(resolve))
var results Results
err := c.doAPI(ctx, http.MethodGet, "/api/v1/search", params, &results, nil)
err := c.doAPI(ctx, http.MethodGet, "/api/v2/search", params, &results, nil)
if err != nil {
return nil, err
}
return &results, nil
}
// UploadMedia upload a media attachment.
// UploadMedia upload a media attachment from a file.
func (c *Client) UploadMedia(ctx context.Context, file string) (*Attachment, error) {
var attachment Attachment
err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", file, &attachment, nil)
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
return c.UploadMediaFromMedia(ctx, &Media{File: f})
}
// UploadMediaFromBytes uploads a media attachment from a byte slice.
func (c *Client) UploadMediaFromBytes(ctx context.Context, b []byte) (*Attachment, error) {
return c.UploadMediaFromReader(ctx, bytes.NewReader(b))
}
// UploadMediaFromReader uploads a media attachment from an io.Reader.
func (c *Client) UploadMediaFromReader(ctx context.Context, reader io.Reader) (*Attachment, error) {
return c.UploadMediaFromMedia(ctx, &Media{File: reader})
}
// UploadMediaFromMedia uploads a media attachment from a Media struct.
func (c *Client) UploadMediaFromMedia(ctx context.Context, media *Media) (*Attachment, error) {
var attachment Attachment
if err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", media, &attachment, nil); err != nil {
return nil, err
}
return &attachment, nil
}
// GetTimelineDirect return statuses from direct timeline.
func (c *Client) GetTimelineDirect(ctx context.Context, pg *Pagination) ([]*Status, error) {
params := url.Values{}
var conversations []*Conversation
err := c.doAPI(ctx, http.MethodGet, "/api/v1/conversations", params, &conversations, pg)
if err != nil {
return nil, err
}
var statuses = []*Status{}
for _, c := range conversations {
s := c.LastStatus
statuses = append(statuses, s)
}
return statuses, nil
}
// GetConversations return direct conversations.
func (c *Client) GetConversations(ctx context.Context, pg *Pagination) ([]*Conversation, error) {
params := url.Values{}
var conversations []*Conversation
err := c.doAPI(ctx, http.MethodGet, "/api/v1/conversations", params, &conversations, pg)
if err != nil {
return nil, err
}
return conversations, nil
}
// DeleteConversation delete the conversation specified by id.
func (c *Client) DeleteConversation(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/conversations/%s", id), nil, nil, nil)
}
// MarkConversationAsRead mark the conversation as read.
func (c *Client) MarkConversationAsRead(ctx context.Context, id ID) error {
return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/conversations/%s/read", id), nil, nil, nil)
}

View File

@ -3,15 +3,16 @@ package mastodon
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestGetFavourites(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `[{"content": "foo"}, {"content": "bar"}]`)
return
}))
defer ts.Close()
@ -36,6 +37,33 @@ func TestGetFavourites(t *testing.T) {
}
}
func TestGetBookmarks(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `[{"content": "foo"}, {"content": "bar"}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
books, err := client.GetBookmarks(context.Background(), nil)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(books) != 2 {
t.Fatalf("result should be two: %d", len(books))
}
if books[0].Content != "foo" {
t.Fatalf("want %q but %q", "foo", books[0].Content)
}
if books[1].Content != "bar" {
t.Fatalf("want %q but %q", "bar", books[1].Content)
}
}
func TestGetStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1234567" {
@ -43,7 +71,6 @@ func TestGetStatus(t *testing.T) {
return
}
fmt.Fprintln(w, `{"content": "zzz", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}`)
return
}))
defer ts.Close()
@ -68,7 +95,7 @@ func TestGetStatus(t *testing.T) {
t.Fatal("should have emojis")
}
if status.Emojis[0].ShortCode != "💩" {
t.Fatalf("want %q but %q", "💩", status.Emojis[0])
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)
@ -85,7 +112,6 @@ func TestGetStatusCard(t *testing.T) {
return
}
fmt.Fprintln(w, `{"title": "zzz"}`)
return
}))
defer ts.Close()
@ -115,7 +141,6 @@ func TestGetStatusContext(t *testing.T) {
return
}
fmt.Fprintln(w, `{"ancestors": [{"content": "zzz"},{"content": "bbb"}]}`)
return
}))
defer ts.Close()
@ -147,6 +172,88 @@ func TestGetStatusContext(t *testing.T) {
}
}
func TestGetStatusSource(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1234567/source" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"id":"1234567","text":"Foo","spoiler_text":"Bar"}%`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetStatusSource(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
source, err := client.GetStatusSource(context.Background(), "1234567")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if source.ID != ID("1234567") {
t.Fatalf("want %q but %q", "1234567", source.ID)
}
if source.Text != "Foo" {
t.Fatalf("want %q but %q", "Foo", source.Text)
}
if source.SpoilerText != "Bar" {
t.Fatalf("want %q but %q", "Bar", source.SpoilerText)
}
}
func TestGetStatusHistory(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1234567/history" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `[{"content": "foo", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}, {"content": "bar", "emojis":[{"shortcode":"💩", "url":"http://example.com", "static_url": "http://example.com/static"}]}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.GetStatusHistory(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
statuses, err := client.GetStatusHistory(context.Background(), "1234567")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(statuses) != 2 {
t.Fatalf("want len %q but got %q", "2", len(statuses))
}
if statuses[0].Content != "foo" {
t.Fatalf("want %q but %q", "bar", statuses[0].Content)
}
if statuses[1].Content != "bar" {
t.Fatalf("want %q but %q", "bar", statuses[1].Content)
}
if len(statuses[0].Emojis) != 1 {
t.Fatal("should have emojis")
}
if statuses[0].Emojis[0].ShortCode != "💩" {
t.Fatalf("want %q but %q", "💩", statuses[0].Emojis[0].ShortCode)
}
if statuses[0].Emojis[0].URL != "http://example.com" {
t.Fatalf("want %q but %q", "https://example.com", statuses[0].Emojis[0].URL)
}
if statuses[0].Emojis[0].StaticURL != "http://example.com/static" {
t.Fatalf("want %q but %q", "https://example.com/static", statuses[0].Emojis[0].StaticURL)
}
}
func TestGetRebloggedBy(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1234567/reblogged_by" {
@ -154,7 +261,6 @@ func TestGetRebloggedBy(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()
@ -190,7 +296,6 @@ func TestGetFavouritedBy(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"username": "foo"}, {"username": "bar"}]`)
return
}))
defer ts.Close()
@ -226,7 +331,6 @@ func TestReblog(t *testing.T) {
return
}
fmt.Fprintln(w, `{"content": "zzz"}`)
return
}))
defer ts.Close()
@ -256,7 +360,6 @@ func TestUnreblog(t *testing.T) {
return
}
fmt.Fprintln(w, `{"content": "zzz"}`)
return
}))
defer ts.Close()
@ -286,7 +389,6 @@ func TestFavourite(t *testing.T) {
return
}
fmt.Fprintln(w, `{"content": "zzz"}`)
return
}))
defer ts.Close()
@ -316,7 +418,6 @@ func TestUnfavourite(t *testing.T) {
return
}
fmt.Fprintln(w, `{"content": "zzz"}`)
return
}))
defer ts.Close()
@ -339,6 +440,64 @@ func TestUnfavourite(t *testing.T) {
}
}
func TestBookmark(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1234567/bookmark" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"content": "zzz"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.Bookmark(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
status, err := client.Bookmark(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)
}
}
func TestUnbookmark(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/statuses/1234567/unbookmark" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
fmt.Fprintln(w, `{"content": "zzz"}`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
_, err := client.Unbookmark(context.Background(), "123")
if err == nil {
t.Fatalf("should be fail: %v", err)
}
status, err := client.Unbookmark(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)
}
}
func TestGetTimelinePublic(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("local") == "" {
@ -369,6 +528,28 @@ func TestGetTimelinePublic(t *testing.T) {
}
}
func TestGetTimelineDirect(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `[{"id": "4", "unread":false, "last_status" : {"content": "zzz"}}, {"id": "3", "unread":true, "last_status" : {"content": "bar"}}]`)
}))
defer ts.Close()
client := NewClient(&Config{Server: ts.URL})
tl, err := client.GetTimelineDirect(context.Background(), nil)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(tl) != 2 {
t.Fatalf("result should be two: %d", len(tl))
}
if tl[0].Content != "zzz" {
t.Fatalf("want %q but %q", "foo", tl[0].Content)
}
if tl[1].Content != "bar" {
t.Fatalf("want %q but %q", "bar", tl[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" {
@ -376,7 +557,6 @@ func TestGetTimelineHashtag(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"content": "zzz"},{"content": "yyy"}]`)
return
}))
defer ts.Close()
@ -405,6 +585,41 @@ func TestGetTimelineHashtag(t *testing.T) {
}
}
func TestGetTimelineList(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/timelines/list/1" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
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.GetTimelineList(context.Background(), "notfound", nil)
if err == nil {
t.Fatalf("should be fail: %v", err)
}
tags, err := client.GetTimelineList(context.Background(), "1", 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 TestGetTimelineMedia(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("local") == "" {
@ -412,7 +627,6 @@ func TestGetTimelineMedia(t *testing.T) {
return
}
fmt.Fprintln(w, `[{"content": "zzz"},{"content": "yyy"}]`)
return
}))
defer ts.Close()
@ -451,7 +665,6 @@ func TestDeleteStatus(t *testing.T) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed)
return
}
return
}))
defer ts.Close()
@ -473,11 +686,11 @@ func TestDeleteStatus(t *testing.T) {
func TestSearch(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
if r.URL.Path != "/api/v2/search" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.RequestURI != "/api/v1/search?q=q&resolve=false" {
if r.RequestURI != "/api/v2/search?q=q&resolve=false" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusBadRequest)
return
}
@ -485,9 +698,8 @@ func TestSearch(t *testing.T) {
fmt.Fprintln(w, `
{"accounts":[{"username": "zzz"},{"username": "yyy"}],
"statuses":[{"content": "aaa"}],
"hashtags":["tag","tag2","tag3"]
"hashtags":[{"name": "tag"},{"name": "tag2"},{"name": "tag3"}]
}`)
return
}))
defer ts.Close()
@ -516,7 +728,7 @@ func TestSearch(t *testing.T) {
if len(ret.Hashtags) != 3 {
t.Fatalf("Hashtags have %q entries, but %q", "3", len(ret.Hashtags))
}
if ret.Hashtags[2] != "tag3" {
if ret.Hashtags[2].Name != "tag3" {
t.Fatalf("Hashtags[2] should %q , but %q", "tag3", ret.Hashtags[2])
}
}
@ -532,7 +744,6 @@ func TestUploadMedia(t *testing.T) {
return
}
fmt.Fprintln(w, `{"id": 123}`)
return
}))
defer ts.Close()
@ -549,4 +760,111 @@ func TestUploadMedia(t *testing.T) {
if attachment.ID != "123" {
t.Fatalf("want %q but %q", "123", attachment.ID)
}
file, err := os.Open("testdata/logo.png")
if err != nil {
t.Fatalf("could not open file: %v", err)
}
defer file.Close()
writerAttachment, err := client.UploadMediaFromReader(context.Background(), file)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if writerAttachment.ID != "123" {
t.Fatalf("want %q but %q", "123", attachment.ID)
}
bytes, err := ioutil.ReadFile("testdata/logo.png")
if err != nil {
t.Fatalf("could not open file: %v", err)
}
byteAttachment, err := client.UploadMediaFromBytes(context.Background(), bytes)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if byteAttachment.ID != "123" {
t.Fatalf("want %q but got %q", "123", attachment.ID)
}
}
func TestGetConversations(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/conversations" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
fmt.Fprintln(w, `[{"id": "4", "unread":false, "last_status" : {"content": "zzz"}}, {"id": "3", "unread":true, "last_status" : {"content": "bar"}}]`)
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
convs, err := client.GetConversations(context.Background(), nil)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
if len(convs) != 2 {
t.Fatalf("result should be 2: %d", len(convs))
}
if convs[0].ID != "4" {
t.Fatalf("want %q but %q", "4", convs[0].ID)
}
if convs[0].LastStatus.Content != "zzz" {
t.Fatalf("want %q but %q", "zzz", convs[0].LastStatus.Content)
}
if convs[1].Unread != true {
t.Fatalf("unread should be true: %t", convs[1].Unread)
}
}
func TestDeleteConversation(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/conversations/12345678" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.Method != "DELETE" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusMethodNotAllowed)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "hoge",
})
err := client.DeleteConversation(context.Background(), "12345678")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}
func TestMarkConversationsAsRead(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/conversations/111111/read" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
if r.Method != "POST" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
}))
defer ts.Close()
client := NewClient(&Config{
Server: ts.URL,
ClientID: "foo",
ClientSecret: "bar",
AccessToken: "zoo",
})
err := client.MarkConversationAsRead(context.Background(), "111111")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
}

View File

@ -2,8 +2,10 @@ package mastodon
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
@ -11,42 +13,66 @@ import (
"strings"
)
// UpdateEvent is struct for passing status event to app.
// UpdateEvent is a struct for passing status event to app.
type UpdateEvent struct {
Status *Status `json:"status"`
}
func (e *UpdateEvent) event() {}
// NotificationEvent is struct for passing notification event to app.
// UpdateEditEvent is a struct for passing status edit event to app.
type UpdateEditEvent struct {
Status *Status `json:"status"`
}
func (e *UpdateEditEvent) event() {}
// NotificationEvent is a struct for passing notification event to app.
type NotificationEvent struct {
Notification *Notification `json:"notification"`
}
func (e *NotificationEvent) event() {}
// DeleteEvent is struct for passing deletion event to app.
// DeleteEvent is a struct for passing deletion event to app.
type DeleteEvent struct{ ID ID }
func (e *DeleteEvent) event() {}
// ErrorEvent is struct for passing errors to app.
// ErrorEvent is a struct for passing errors to app.
type ErrorEvent struct{ err error }
func (e *ErrorEvent) event() {}
func (e *ErrorEvent) Error() string { return e.err.Error() }
// Event is interface passing events to app.
// Event is an interface passing events to app.
type Event interface {
event()
}
func handleReader(q chan Event, r io.Reader) error {
var name string
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
token := strings.SplitN(line, ":", 2)
var lineBuf bytes.Buffer
br := bufio.NewReader(r)
for {
line, isPrefix, err := br.ReadLine()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if isPrefix {
lineBuf.Write(line)
continue
}
if lineBuf.Len() > 0 {
lineBuf.Write(line)
line = lineBuf.Bytes()
lineBuf.Reset()
}
token := strings.SplitN(string(line), ":", 2)
if len(token) != 2 {
continue
}
@ -62,6 +88,12 @@ func handleReader(q chan Event, r io.Reader) error {
if err == nil {
q <- &UpdateEvent{&status}
}
case "status.update":
var status Status
err = json.Unmarshal([]byte(token[1]), &status)
if err == nil {
q <- &UpdateEditEvent{&status}
}
case "notification":
var notification Notification
err = json.Unmarshal([]byte(token[1]), &notification)
@ -76,11 +108,10 @@ func handleReader(q chan Event, r io.Reader) error {
}
}
}
return s.Err()
}
func (c *Client) streaming(ctx context.Context, p string, params url.Values) (chan Event, error) {
u, err := url.Parse(c.config.Server)
u, err := url.Parse(c.Config.Server)
if err != nil {
return nil, err
}
@ -92,7 +123,10 @@ func (c *Client) streaming(ctx context.Context, p string, params url.Values) (ch
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
if c.Config.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.Config.AccessToken)
}
q := make(chan Event)
go func() {
@ -129,12 +163,12 @@ func (c *Client) doStreaming(req *http.Request, q chan Event) {
}
}
// StreamingUser return channel to read events on home.
// StreamingUser returns a channel to read events on home.
func (c *Client) StreamingUser(ctx context.Context) (chan Event, error) {
return c.streaming(ctx, "user", nil)
}
// StreamingPublic return channel to read events on public.
// StreamingPublic returns a channel to read events on public.
func (c *Client) StreamingPublic(ctx context.Context, isLocal bool) (chan Event, error) {
p := "public"
if isLocal {
@ -144,7 +178,7 @@ func (c *Client) StreamingPublic(ctx context.Context, isLocal bool) (chan Event,
return c.streaming(ctx, p, nil)
}
// StreamingHashtag return channel to read events on tagged timeline.
// StreamingHashtag returns a channel to read events on tagged timeline.
func (c *Client) StreamingHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
params := url.Values{}
params.Set("tag", tag)
@ -156,3 +190,16 @@ func (c *Client) StreamingHashtag(ctx context.Context, tag string, isLocal bool)
return c.streaming(ctx, p, params)
}
// StreamingList returns a channel to read events on a list.
func (c *Client) StreamingList(ctx context.Context, id ID) (chan Event, error) {
params := url.Values{}
params.Set("list", string(id))
return c.streaming(ctx, "list", params)
}
// StreamingDirect returns a channel to read events on a direct messages.
func (c *Client) StreamingDirect(ctx context.Context) (chan Event, error) {
return c.streaming(ctx, "direct", nil)
}

View File

@ -1,42 +1,65 @@
package mastodon
import (
"bufio"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestHandleReader(t *testing.T) {
large := "large"
largeContent := strings.Repeat(large, 2*(bufio.MaxScanTokenSize/len(large)))
q := make(chan Event)
r := strings.NewReader(`
r := strings.NewReader(fmt.Sprintf(`
event: update
data: {content: error}
event: update
data: {"content": "foo"}
event: update
data: {"content": "%s"}
event: notification
data: {"type": "mention"}
event: delete
data: 1234567
event: status.update
data: {"content": "foo"}
:thump
`)
`, largeContent))
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer close(q)
err := handleReader(q, r)
if err != nil {
t.Fatalf("should not be fail: %v", err)
t.Errorf("should not be fail: %v", err)
}
}()
var passUpdate, passNotification, passDelete, passError bool
var passUpdate, passUpdateLarge, passNotification, passDelete, passError bool
for e := range q {
switch event := e.(type) {
case *UpdateEvent:
passUpdate = true
if event.Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", event.Status.Content)
if event.Status.Content == "foo" {
passUpdate = true
} else if event.Status.Content == largeContent {
passUpdateLarge = true
} else {
t.Fatalf("bad update content: %q", event.Status.Content)
}
case *UpdateEditEvent:
if event.Status.Content == "foo" {
passUpdate = true
} else if event.Status.Content == largeContent {
passUpdateLarge = true
} else {
t.Fatalf("bad update content: %q", event.Status.Content)
}
case *NotificationEvent:
passNotification = true
@ -55,11 +78,12 @@ data: 1234567
}
}
}
if !passUpdate || !passNotification || !passDelete || !passError {
if !passUpdate || !passUpdateLarge || !passNotification || !passDelete || !passError {
t.Fatalf("have not passed through somewhere: "+
"update %t, notification %t, delete %t, error %t",
passUpdate, passNotification, passDelete, passError)
"update: %t, update (large): %t, notification: %t, delete: %t, error: %t",
passUpdate, passUpdateLarge, passNotification, passDelete, passError)
}
wg.Wait()
}
func TestStreaming(t *testing.T) {
@ -111,6 +135,12 @@ data: {"content": "foo"}
if event.Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", event.Status.Content)
}
case *UpdateEditEvent:
cnt++
passUpdate = true
if event.Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", event.Status.Content)
}
}
}
if cnt != 1 {
@ -139,11 +169,14 @@ func TestDoStreaming(t *testing.T) {
req = req.WithContext(ctx)
q := make(chan Event)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer close(q)
c.doStreaming(req, q)
if err != nil {
t.Fatalf("should not be fail: %v", err)
t.Errorf("should not be fail: %v", err)
}
}()
var passError bool
@ -158,6 +191,7 @@ func TestDoStreaming(t *testing.T) {
if !passError {
t.Fatalf("have not passed through: error %t", passError)
}
wg.Wait()
}
func TestStreamingUser(t *testing.T) {
@ -293,3 +327,83 @@ data: {"content": "foo"}
t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content)
}
}
func TestStreamingList(t *testing.T) {
var isEnd bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isEnd {
return
} else if r.URL.Path != "/api/v1/streaming/list" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
f, _ := w.(http.Flusher)
fmt.Fprintln(w, `
event: update
data: {"content": "foo"}
`)
f.Flush()
isEnd = true
}))
defer ts.Close()
client := NewClient(&Config{Server: ts.URL})
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(time.Second, cancel)
q, err := client.StreamingList(ctx, "1")
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
events := []Event{}
for e := range q {
if _, ok := e.(*ErrorEvent); !ok {
events = append(events, e)
}
}
if len(events) != 1 {
t.Fatalf("result should be one: %d", len(events))
}
if events[0].(*UpdateEvent).Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content)
}
}
func TestStreamingDirect(t *testing.T) {
var isEnd bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isEnd {
return
} else if r.URL.Path != "/api/v1/streaming/direct" {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
f, _ := w.(http.Flusher)
fmt.Fprintln(w, `
event: update
data: {"content": "foo"}
`)
f.Flush()
isEnd = true
}))
defer ts.Close()
client := NewClient(&Config{Server: ts.URL})
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(time.Second, cancel)
q, err := client.StreamingDirect(ctx)
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
events := []Event{}
for e := range q {
if _, ok := e.(*ErrorEvent); !ok {
events = append(events, e)
}
}
if len(events) != 1 {
t.Fatalf("result should be one: %d", len(events))
}
if events[0].(*UpdateEvent).Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content)
}
}

View File

@ -51,15 +51,20 @@ func (c *WSClient) StreamingWSHashtag(ctx context.Context, tag string, isLocal b
return c.streamingWS(ctx, s, tag)
}
// StreamingWSList return channel to read events on a list using WebSocket.
func (c *WSClient) StreamingWSList(ctx context.Context, id ID) (chan Event, error) {
return c.streamingWS(ctx, "list", string(id))
}
func (c *WSClient) streamingWS(ctx context.Context, stream, tag string) (chan Event, error) {
params := url.Values{}
params.Set("access_token", c.client.config.AccessToken)
params.Set("access_token", c.client.Config.AccessToken)
params.Set("stream", stream)
if tag != "" {
params.Set("tag", tag)
}
u, err := changeWebSocketScheme(c.client.config.Server)
u, err := changeWebSocketScheme(c.client.Config.Server)
if err != nil {
return nil, err
}
@ -122,6 +127,12 @@ func (c *WSClient) handleWS(ctx context.Context, rawurl string, q chan Event) er
if err == nil {
q <- &UpdateEvent{Status: &status}
}
case "status.update":
var status Status
err = json.Unmarshal([]byte(s.Payload.(string)), &status)
if err == nil {
q <- &UpdateEditEvent{Status: &status}
}
case "notification":
var notification Notification
err = json.Unmarshal([]byte(s.Payload.(string)), &notification)

View File

@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@ -79,6 +80,13 @@ func wsMock(w http.ResponseWriter, r *http.Request) {
return
}
err = conn.WriteMessage(websocket.TextMessage,
[]byte(`{"event":"status.update","payload":"{\"content\":\"bar\"}"}`))
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = conn.WriteMessage(websocket.TextMessage,
[]byte(`{"event":"notification","payload":"{\"id\":123}"}`))
if err != nil {
@ -111,20 +119,20 @@ func wsTest(t *testing.T, q chan Event, cancel func()) {
for e := range q {
events = append(events, e)
}
if len(events) != 6 {
t.Fatalf("result should be four: %d", len(events))
if len(events) != 7 {
t.Fatalf("result should be seven: %d", len(events))
}
if events[0].(*UpdateEvent).Status.Content != "foo" {
t.Fatalf("want %q but %q", "foo", events[0].(*UpdateEvent).Status.Content)
}
if events[1].(*NotificationEvent).Notification.ID != "123" {
t.Fatalf("want %q but %q", "123", events[1].(*NotificationEvent).Notification.ID)
if events[1].(*UpdateEditEvent).Status.Content != "bar" {
t.Fatalf("want %q but %q", "bar", events[1].(*UpdateEditEvent).Status.Content)
}
if events[2].(*DeleteEvent).ID != "1234567" {
t.Fatalf("want %q but %q", "1234567", events[2].(*DeleteEvent).ID)
if events[2].(*NotificationEvent).Notification.ID != "123" {
t.Fatalf("want %q but %q", "123", events[2].(*NotificationEvent).Notification.ID)
}
if errorEvent, ok := events[3].(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
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)
@ -132,6 +140,9 @@ func wsTest(t *testing.T, q chan Event, cancel func()) {
if errorEvent, ok := events[5].(*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)
}
}
func TestStreamingWS(t *testing.T) {
@ -151,12 +162,16 @@ func TestStreamingWS(t *testing.T) {
if err != nil {
t.Fatalf("should not be fail: %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
e := <-q
if errorEvent, ok := e.(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
t.Errorf("should be fail: %v", errorEvent.err)
}
}()
wg.Wait()
}
func TestHandleWS(t *testing.T) {
@ -183,10 +198,13 @@ func TestHandleWS(t *testing.T) {
q := make(chan Event)
client := NewClient(&Config{}).NewWSClient()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
e := <-q
if errorEvent, ok := e.(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
t.Errorf("should be fail: %v", errorEvent.err)
}
}()
err := client.handleWS(context.Background(), ":", q)
@ -196,10 +214,12 @@ func TestHandleWS(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
wg.Add(1)
go func() {
defer wg.Done()
e := <-q
if errorEvent, ok := e.(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
t.Errorf("should be fail: %v", errorEvent.err)
}
}()
err = client.handleWS(ctx, "ws://"+ts.Listener.Addr().String(), q)
@ -207,13 +227,17 @@ func TestHandleWS(t *testing.T) {
t.Fatalf("should be fail: %v", err)
}
wg.Add(1)
go func() {
defer wg.Done()
e := <-q
if errorEvent, ok := e.(*ErrorEvent); !ok {
t.Fatalf("should be fail: %v", errorEvent.err)
t.Errorf("should be fail: %v", errorEvent.err)
}
}()
client.handleWS(context.Background(), "ws://"+ts.Listener.Addr().String(), q)
wg.Wait()
}
func TestDialRedirect(t *testing.T) {
@ -243,12 +267,12 @@ func TestDial(t *testing.T) {
t.Fatalf("should be fail: %v", err)
}
_, rawurl, err := client.dial("ws://" + ts.Listener.Addr().String())
_, _, err = client.dial("ws://" + ts.Listener.Addr().String())
if err == nil {
t.Fatalf("should be fail: %v", err)
}
_, rawurl, err = client.dial("ws://" + ts.Listener.Addr().String())
_, rawurl, err := client.dial("ws://" + ts.Listener.Addr().String())
if err != nil {
t.Fatalf("should not be fail: %v", err)
}

20
unixtime.go 100644
View File

@ -0,0 +1,20 @@
package mastodon
import (
"strconv"
"time"
)
type Unixtime time.Time
func (t *Unixtime) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' {
data = data[1 : len(data)-1]
}
ts, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return err
}
*t = Unixtime(time.Unix(ts, 0))
return nil
}