|
1 /* |
|
2 Copyright 2017-2018 Mikael Berthe |
|
3 |
|
4 Licensed under the MIT license. Please see the LICENSE file is this directory. |
|
5 */ |
|
6 |
|
7 package madon |
|
8 |
|
9 import ( |
|
10 "bytes" |
|
11 "encoding/json" |
|
12 "fmt" |
|
13 "mime/multipart" |
|
14 "os" |
|
15 "path/filepath" |
|
16 "strconv" |
|
17 |
|
18 "github.com/pkg/errors" |
|
19 "github.com/sendgrid/rest" |
|
20 ) |
|
21 |
|
22 // getAccountsOptions contains option fields for POST and DELETE API calls |
|
23 type getAccountsOptions struct { |
|
24 // The ID is used for most commands |
|
25 ID int64 |
|
26 |
|
27 // Following can be set to true to limit a search to "following" accounts |
|
28 Following bool |
|
29 |
|
30 // The Q field (query) is used when searching for accounts |
|
31 Q string |
|
32 |
|
33 Limit *LimitParams |
|
34 } |
|
35 |
|
36 // UpdateAccountParams contains option fields for the UpdateAccount command |
|
37 type UpdateAccountParams struct { |
|
38 DisplayName *string |
|
39 Note *string |
|
40 AvatarImagePath *string |
|
41 HeaderImagePath *string |
|
42 Locked *bool |
|
43 Bot *bool |
|
44 FieldsAttributes *[]Field |
|
45 Source *SourceParams |
|
46 } |
|
47 |
|
48 // updateRelationship returns a Relationship entity |
|
49 // The operation 'op' can be "follow", "unfollow", "block", "unblock", |
|
50 // "mute", "unmute". |
|
51 // The id is optional and depends on the operation. |
|
52 func (mc *Client) updateRelationship(op string, id int64, params apiCallParams) (*Relationship, error) { |
|
53 var endPoint string |
|
54 method := rest.Post |
|
55 strID := strconv.FormatInt(id, 10) |
|
56 |
|
57 switch op { |
|
58 case "follow", "unfollow", "block", "unblock", "mute", "unmute", "pin", "unpin": |
|
59 endPoint = "accounts/" + strID + "/" + op |
|
60 default: |
|
61 return nil, ErrInvalidParameter |
|
62 } |
|
63 |
|
64 var rel Relationship |
|
65 if err := mc.apiCall("v1/"+endPoint, method, params, nil, nil, &rel); err != nil { |
|
66 return nil, err |
|
67 } |
|
68 return &rel, nil |
|
69 } |
|
70 |
|
71 // getSingleAccount returns an account entity |
|
72 // The operation 'op' can be "account", "verify_credentials", |
|
73 // "follow_requests/authorize" or // "follow_requests/reject". |
|
74 // The id is optional and depends on the operation. |
|
75 func (mc *Client) getSingleAccount(op string, id int64) (*Account, error) { |
|
76 var endPoint string |
|
77 method := rest.Get |
|
78 strID := strconv.FormatInt(id, 10) |
|
79 |
|
80 switch op { |
|
81 case "account": |
|
82 endPoint = "accounts/" + strID |
|
83 case "verify_credentials": |
|
84 endPoint = "accounts/verify_credentials" |
|
85 case "follow_requests/authorize", "follow_requests/reject": |
|
86 // The documentation is incorrect, the endpoint actually |
|
87 // is "follow_requests/:id/{authorize|reject}" |
|
88 endPoint = op[:16] + strID + "/" + op[16:] |
|
89 method = rest.Post |
|
90 default: |
|
91 return nil, ErrInvalidParameter |
|
92 } |
|
93 |
|
94 var account Account |
|
95 if err := mc.apiCall("v1/"+endPoint, method, nil, nil, nil, &account); err != nil { |
|
96 return nil, err |
|
97 } |
|
98 return &account, nil |
|
99 } |
|
100 |
|
101 // getMultipleAccounts returns a list of account entities |
|
102 // If lopt.All is true, several requests will be made until the API server |
|
103 // has nothing to return. |
|
104 func (mc *Client) getMultipleAccounts(endPoint string, params apiCallParams, lopt *LimitParams) ([]Account, error) { |
|
105 var accounts []Account |
|
106 var links apiLinks |
|
107 if err := mc.apiCall("v1/"+endPoint, rest.Get, params, lopt, &links, &accounts); err != nil { |
|
108 return nil, err |
|
109 } |
|
110 if lopt != nil { // Fetch more pages to reach our limit |
|
111 var accountSlice []Account |
|
112 for (lopt.All || lopt.Limit > len(accounts)) && links.next != nil { |
|
113 newlopt := links.next |
|
114 links = apiLinks{} |
|
115 if err := mc.apiCall("v1/"+endPoint, rest.Get, params, newlopt, &links, &accountSlice); err != nil { |
|
116 return nil, err |
|
117 } |
|
118 accounts = append(accounts, accountSlice...) |
|
119 accountSlice = accountSlice[:0] // Clear struct |
|
120 } |
|
121 } |
|
122 return accounts, nil |
|
123 } |
|
124 |
|
125 // getMultipleAccountsHelper returns a list of account entities |
|
126 // The operation 'op' can be "followers", "following", "search", "blocks", |
|
127 // "mutes", "follow_requests". |
|
128 // The id is optional and depends on the operation. |
|
129 // If opts.All is true, several requests will be made until the API server |
|
130 // has nothing to return. |
|
131 func (mc *Client) getMultipleAccountsHelper(op string, opts *getAccountsOptions) ([]Account, error) { |
|
132 var endPoint string |
|
133 var lopt *LimitParams |
|
134 |
|
135 if opts != nil { |
|
136 lopt = opts.Limit |
|
137 } |
|
138 |
|
139 switch op { |
|
140 case "followers", "following": |
|
141 if opts == nil || opts.ID < 1 { |
|
142 return []Account{}, ErrInvalidID |
|
143 } |
|
144 endPoint = "accounts/" + strconv.FormatInt(opts.ID, 10) + "/" + op |
|
145 case "follow_requests", "blocks", "mutes": |
|
146 endPoint = op |
|
147 case "search": |
|
148 if opts == nil || opts.Q == "" { |
|
149 return []Account{}, ErrInvalidParameter |
|
150 } |
|
151 endPoint = "accounts/" + op |
|
152 case "reblogged_by", "favourited_by": |
|
153 if opts == nil || opts.ID < 1 { |
|
154 return []Account{}, ErrInvalidID |
|
155 } |
|
156 endPoint = "statuses/" + strconv.FormatInt(opts.ID, 10) + "/" + op |
|
157 default: |
|
158 return nil, ErrInvalidParameter |
|
159 } |
|
160 |
|
161 // Handle target-specific query parameters |
|
162 params := make(apiCallParams) |
|
163 if op == "search" { |
|
164 params["q"] = opts.Q |
|
165 if opts.Following { |
|
166 params["following"] = "true" |
|
167 } |
|
168 } |
|
169 |
|
170 return mc.getMultipleAccounts(endPoint, params, lopt) |
|
171 } |
|
172 |
|
173 // GetAccount returns an account entity |
|
174 // The returned value can be nil if there is an error or if the |
|
175 // requested ID does not exist. |
|
176 func (mc *Client) GetAccount(accountID int64) (*Account, error) { |
|
177 account, err := mc.getSingleAccount("account", accountID) |
|
178 if err != nil { |
|
179 return nil, err |
|
180 } |
|
181 if account != nil && account.ID == 0 { |
|
182 return nil, ErrEntityNotFound |
|
183 } |
|
184 return account, nil |
|
185 } |
|
186 |
|
187 // GetCurrentAccount returns the current user account |
|
188 func (mc *Client) GetCurrentAccount() (*Account, error) { |
|
189 account, err := mc.getSingleAccount("verify_credentials", 0) |
|
190 if err != nil { |
|
191 return nil, err |
|
192 } |
|
193 if account != nil && account.ID == 0 { |
|
194 return nil, ErrEntityNotFound |
|
195 } |
|
196 return account, nil |
|
197 } |
|
198 |
|
199 // GetAccountFollowers returns the list of accounts following a given account |
|
200 func (mc *Client) GetAccountFollowers(accountID int64, lopt *LimitParams) ([]Account, error) { |
|
201 o := &getAccountsOptions{ID: accountID, Limit: lopt} |
|
202 return mc.getMultipleAccountsHelper("followers", o) |
|
203 } |
|
204 |
|
205 // GetAccountFollowing returns the list of accounts a given account is following |
|
206 func (mc *Client) GetAccountFollowing(accountID int64, lopt *LimitParams) ([]Account, error) { |
|
207 o := &getAccountsOptions{ID: accountID, Limit: lopt} |
|
208 return mc.getMultipleAccountsHelper("following", o) |
|
209 } |
|
210 |
|
211 // FollowAccount follows an account |
|
212 // 'reblogs' can be used to specify if boots should be displayed or hidden. |
|
213 func (mc *Client) FollowAccount(accountID int64, reblogs *bool) (*Relationship, error) { |
|
214 var params apiCallParams |
|
215 if reblogs != nil { |
|
216 params = make(apiCallParams) |
|
217 if *reblogs { |
|
218 params["reblogs"] = "true" |
|
219 } else { |
|
220 params["reblogs"] = "false" |
|
221 } |
|
222 } |
|
223 rel, err := mc.updateRelationship("follow", accountID, params) |
|
224 if err != nil { |
|
225 return nil, err |
|
226 } |
|
227 if rel == nil { |
|
228 return nil, ErrEntityNotFound |
|
229 } |
|
230 return rel, nil |
|
231 } |
|
232 |
|
233 // UnfollowAccount unfollows an account |
|
234 func (mc *Client) UnfollowAccount(accountID int64) (*Relationship, error) { |
|
235 rel, err := mc.updateRelationship("unfollow", accountID, nil) |
|
236 if err != nil { |
|
237 return nil, err |
|
238 } |
|
239 if rel == nil { |
|
240 return nil, ErrEntityNotFound |
|
241 } |
|
242 return rel, nil |
|
243 } |
|
244 |
|
245 // FollowRemoteAccount follows a remote account |
|
246 // The parameter 'uri' is a URI (e.g. "username@domain"). |
|
247 func (mc *Client) FollowRemoteAccount(uri string) (*Account, error) { |
|
248 if uri == "" { |
|
249 return nil, ErrInvalidID |
|
250 } |
|
251 |
|
252 params := make(apiCallParams) |
|
253 params["uri"] = uri |
|
254 |
|
255 var account Account |
|
256 if err := mc.apiCall("v1/follows", rest.Post, params, nil, nil, &account); err != nil { |
|
257 return nil, err |
|
258 } |
|
259 if account.ID == 0 { |
|
260 return nil, ErrEntityNotFound |
|
261 } |
|
262 return &account, nil |
|
263 } |
|
264 |
|
265 // BlockAccount blocks an account |
|
266 func (mc *Client) BlockAccount(accountID int64) (*Relationship, error) { |
|
267 rel, err := mc.updateRelationship("block", accountID, nil) |
|
268 if err != nil { |
|
269 return nil, err |
|
270 } |
|
271 if rel == nil { |
|
272 return nil, ErrEntityNotFound |
|
273 } |
|
274 return rel, nil |
|
275 } |
|
276 |
|
277 // UnblockAccount unblocks an account |
|
278 func (mc *Client) UnblockAccount(accountID int64) (*Relationship, error) { |
|
279 rel, err := mc.updateRelationship("unblock", accountID, nil) |
|
280 if err != nil { |
|
281 return nil, err |
|
282 } |
|
283 if rel == nil { |
|
284 return nil, ErrEntityNotFound |
|
285 } |
|
286 return rel, nil |
|
287 } |
|
288 |
|
289 // MuteAccount mutes an account |
|
290 // Note that with current Mastodon API, muteNotifications defaults to true |
|
291 // when it is not provided. |
|
292 func (mc *Client) MuteAccount(accountID int64, muteNotifications *bool) (*Relationship, error) { |
|
293 var params apiCallParams |
|
294 |
|
295 if muteNotifications != nil { |
|
296 params = make(apiCallParams) |
|
297 if *muteNotifications { |
|
298 params["notifications"] = "true" |
|
299 } else { |
|
300 params["notifications"] = "false" |
|
301 } |
|
302 } |
|
303 |
|
304 rel, err := mc.updateRelationship("mute", accountID, params) |
|
305 if err != nil { |
|
306 return nil, err |
|
307 } |
|
308 if rel == nil { |
|
309 return nil, ErrEntityNotFound |
|
310 } |
|
311 return rel, nil |
|
312 } |
|
313 |
|
314 // UnmuteAccount unmutes an account |
|
315 func (mc *Client) UnmuteAccount(accountID int64) (*Relationship, error) { |
|
316 rel, err := mc.updateRelationship("unmute", accountID, nil) |
|
317 if err != nil { |
|
318 return nil, err |
|
319 } |
|
320 if rel == nil { |
|
321 return nil, ErrEntityNotFound |
|
322 } |
|
323 return rel, nil |
|
324 } |
|
325 |
|
326 // SearchAccounts returns a list of accounts matching the query string |
|
327 // The lopt parameter is optional (can be nil) or can be used to set a limit. |
|
328 func (mc *Client) SearchAccounts(query string, following bool, lopt *LimitParams) ([]Account, error) { |
|
329 o := &getAccountsOptions{Q: query, Limit: lopt, Following: following} |
|
330 return mc.getMultipleAccountsHelper("search", o) |
|
331 } |
|
332 |
|
333 // GetBlockedAccounts returns the list of blocked accounts |
|
334 // The lopt parameter is optional (can be nil). |
|
335 func (mc *Client) GetBlockedAccounts(lopt *LimitParams) ([]Account, error) { |
|
336 o := &getAccountsOptions{Limit: lopt} |
|
337 return mc.getMultipleAccountsHelper("blocks", o) |
|
338 } |
|
339 |
|
340 // GetMutedAccounts returns the list of muted accounts |
|
341 // The lopt parameter is optional (can be nil). |
|
342 func (mc *Client) GetMutedAccounts(lopt *LimitParams) ([]Account, error) { |
|
343 o := &getAccountsOptions{Limit: lopt} |
|
344 return mc.getMultipleAccountsHelper("mutes", o) |
|
345 } |
|
346 |
|
347 // GetAccountFollowRequests returns the list of follow requests accounts |
|
348 // The lopt parameter is optional (can be nil). |
|
349 func (mc *Client) GetAccountFollowRequests(lopt *LimitParams) ([]Account, error) { |
|
350 o := &getAccountsOptions{Limit: lopt} |
|
351 return mc.getMultipleAccountsHelper("follow_requests", o) |
|
352 } |
|
353 |
|
354 // GetAccountRelationships returns a list of relationship entities for the given accounts |
|
355 func (mc *Client) GetAccountRelationships(accountIDs []int64) ([]Relationship, error) { |
|
356 if len(accountIDs) < 1 { |
|
357 return nil, ErrInvalidID |
|
358 } |
|
359 |
|
360 params := make(apiCallParams) |
|
361 for i, id := range accountIDs { |
|
362 if id < 1 { |
|
363 return nil, ErrInvalidID |
|
364 } |
|
365 qID := fmt.Sprintf("[%d]id", i) |
|
366 params[qID] = strconv.FormatInt(id, 10) |
|
367 } |
|
368 |
|
369 var rl []Relationship |
|
370 if err := mc.apiCall("v1/accounts/relationships", rest.Get, params, nil, nil, &rl); err != nil { |
|
371 return nil, err |
|
372 } |
|
373 return rl, nil |
|
374 } |
|
375 |
|
376 // GetAccountStatuses returns a list of status entities for the given account |
|
377 // If onlyMedia is true, returns only statuses that have media attachments. |
|
378 // If onlyPinned is true, returns only statuses that have been pinned. |
|
379 // If excludeReplies is true, skip statuses that reply to other statuses. |
|
380 // If lopt.All is true, several requests will be made until the API server |
|
381 // has nothing to return. |
|
382 // If lopt.Limit is set (and not All), several queries can be made until the |
|
383 // limit is reached. |
|
384 func (mc *Client) GetAccountStatuses(accountID int64, onlyPinned, onlyMedia, excludeReplies bool, lopt *LimitParams) ([]Status, error) { |
|
385 if accountID < 1 { |
|
386 return nil, ErrInvalidID |
|
387 } |
|
388 |
|
389 endPoint := "accounts/" + strconv.FormatInt(accountID, 10) + "/" + "statuses" |
|
390 params := make(apiCallParams) |
|
391 if onlyMedia { |
|
392 params["only_media"] = "true" |
|
393 } |
|
394 if onlyPinned { |
|
395 params["pinned"] = "true" |
|
396 } |
|
397 if excludeReplies { |
|
398 params["exclude_replies"] = "true" |
|
399 } |
|
400 |
|
401 return mc.getMultipleStatuses(endPoint, params, lopt) |
|
402 } |
|
403 |
|
404 // FollowRequestAuthorize authorizes or rejects an account follow-request |
|
405 func (mc *Client) FollowRequestAuthorize(accountID int64, authorize bool) error { |
|
406 endPoint := "follow_requests/reject" |
|
407 if authorize { |
|
408 endPoint = "follow_requests/authorize" |
|
409 } |
|
410 _, err := mc.getSingleAccount(endPoint, accountID) |
|
411 return err |
|
412 } |
|
413 |
|
414 // UpdateAccount updates the connected user's account data |
|
415 // |
|
416 // The fields avatar & headerImage are considered as file paths |
|
417 // and their content will be uploaded. |
|
418 // Please note that currently Mastodon leaks the avatar file name: |
|
419 // https://github.com/tootsuite/mastodon/issues/5776 |
|
420 // |
|
421 // All fields can be nil, in which case they are not updated. |
|
422 // 'DisplayName' and 'Note' can be set to "" to delete previous values. |
|
423 // Setting 'Locked' to true means all followers should be approved. |
|
424 // You can set 'Bot' to true to indicate this is a service (automated) account. |
|
425 // I'm not sure images can be deleted -- only replaced AFAICS. |
|
426 func (mc *Client) UpdateAccount(cmdParams UpdateAccountParams) (*Account, error) { |
|
427 const endPoint = "accounts/update_credentials" |
|
428 params := make(apiCallParams) |
|
429 |
|
430 if cmdParams.DisplayName != nil { |
|
431 params["display_name"] = *cmdParams.DisplayName |
|
432 } |
|
433 if cmdParams.Note != nil { |
|
434 params["note"] = *cmdParams.Note |
|
435 } |
|
436 if cmdParams.Locked != nil { |
|
437 if *cmdParams.Locked { |
|
438 params["locked"] = "true" |
|
439 } else { |
|
440 params["locked"] = "false" |
|
441 } |
|
442 } |
|
443 if cmdParams.Bot != nil { |
|
444 if *cmdParams.Bot { |
|
445 params["bot"] = "true" |
|
446 } else { |
|
447 params["bot"] = "false" |
|
448 } |
|
449 } |
|
450 if cmdParams.FieldsAttributes != nil { |
|
451 if len(*cmdParams.FieldsAttributes) > 4 { |
|
452 return nil, errors.New("too many fields (max=4)") |
|
453 } |
|
454 for i, attr := range *cmdParams.FieldsAttributes { |
|
455 qName := fmt.Sprintf("fields_attributes[%d][name]", i) |
|
456 qValue := fmt.Sprintf("fields_attributes[%d][value]", i) |
|
457 params[qName] = attr.Name |
|
458 params[qValue] = attr.Value |
|
459 } |
|
460 } |
|
461 if cmdParams.Source != nil { |
|
462 s := cmdParams.Source |
|
463 |
|
464 if s.Privacy != nil { |
|
465 params["source[privacy]"] = *s.Privacy |
|
466 } |
|
467 if s.Language != nil { |
|
468 params["source[language]"] = *s.Language |
|
469 } |
|
470 if s.Sensitive != nil { |
|
471 params["source[sensitive]"] = fmt.Sprintf("%v", *s.Sensitive) |
|
472 } |
|
473 } |
|
474 |
|
475 var err error |
|
476 var avatar, headerImage []byte |
|
477 |
|
478 avatar, err = readFile(cmdParams.AvatarImagePath) |
|
479 if err != nil { |
|
480 return nil, err |
|
481 } |
|
482 |
|
483 headerImage, err = readFile(cmdParams.HeaderImagePath) |
|
484 if err != nil { |
|
485 return nil, err |
|
486 } |
|
487 |
|
488 var formBuf bytes.Buffer |
|
489 w := multipart.NewWriter(&formBuf) |
|
490 |
|
491 if avatar != nil { |
|
492 formWriter, err := w.CreateFormFile("avatar", filepath.Base(*cmdParams.AvatarImagePath)) |
|
493 if err != nil { |
|
494 return nil, errors.Wrap(err, "avatar upload") |
|
495 } |
|
496 formWriter.Write(avatar) |
|
497 } |
|
498 if headerImage != nil { |
|
499 formWriter, err := w.CreateFormFile("header", filepath.Base(*cmdParams.HeaderImagePath)) |
|
500 if err != nil { |
|
501 return nil, errors.Wrap(err, "header upload") |
|
502 } |
|
503 formWriter.Write(headerImage) |
|
504 } |
|
505 w.Close() |
|
506 |
|
507 // Prepare the request |
|
508 req, err := mc.prepareRequest("v1/"+endPoint, rest.Patch, params) |
|
509 if err != nil { |
|
510 return nil, errors.Wrap(err, "prepareRequest failed") |
|
511 } |
|
512 req.Headers["Content-Type"] = w.FormDataContentType() |
|
513 req.Body = formBuf.Bytes() |
|
514 |
|
515 // Make API call |
|
516 r, err := restAPI(req) |
|
517 if err != nil { |
|
518 return nil, errors.Wrap(err, "account update failed") |
|
519 } |
|
520 |
|
521 // Check for error reply |
|
522 var errorResult Error |
|
523 if err := json.Unmarshal([]byte(r.Body), &errorResult); err == nil { |
|
524 // The empty object is not an error |
|
525 if errorResult.Text != "" { |
|
526 return nil, errors.New(errorResult.Text) |
|
527 } |
|
528 } |
|
529 |
|
530 // Not an error reply; let's unmarshal the data |
|
531 var account Account |
|
532 if err := json.Unmarshal([]byte(r.Body), &account); err != nil { |
|
533 return nil, errors.Wrap(err, "cannot decode API response") |
|
534 } |
|
535 return &account, nil |
|
536 } |
|
537 |
|
538 // readFile is a helper function to read a file's contents. |
|
539 func readFile(filename *string) ([]byte, error) { |
|
540 if filename == nil || *filename == "" { |
|
541 return nil, nil |
|
542 } |
|
543 |
|
544 file, err := os.Open(*filename) |
|
545 if err != nil { |
|
546 return nil, err |
|
547 } |
|
548 defer file.Close() |
|
549 |
|
550 fStat, err := file.Stat() |
|
551 if err != nil { |
|
552 return nil, err |
|
553 } |
|
554 |
|
555 buffer := make([]byte, fStat.Size()) |
|
556 _, err = file.Read(buffer) |
|
557 if err != nil { |
|
558 return nil, err |
|
559 } |
|
560 |
|
561 return buffer, nil |
|
562 } |