commit bbcb88cdcc5dcfa81d85691706844bf51cc42fa4 Author: Milton Mazzarri Date: Sat Apr 8 00:30:17 2017 -0500 :elephant: diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2a20698 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{ex,exs,yml}] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6012c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# The directory Mix will write compiled artifacts to. +/_build + +# If you run "mix test --cover", coverage assets end up here. +/cover + +# The directory Mix downloads your dependencies sources to. +/deps + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3de658b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: elixir +elixir: + - 1.3.0 +otp_release: + - 19.0 + - 18.0 +sudo: false +notifications: + recipients: + - milmazz@gmail.com +env: + - MIX_ENV=test +script: + - "mix do local.hex --force, deps.get, test" +cache: + directories: + - deps diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04c9a05 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2017 Milton Mazzarri + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ab5da8 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Hunter (alpha) + +A Elixir client for [Mastodon](https://github.com/Gargron/mastodon/), a GNU social-compatible micro-blogging service + +## Installation + +```elixir +def deps do + [{:hunter, "~> 0.1.0"}] +end +``` + +## Examples + +Assumming that you already know your *instance* and your *bearer token* you can do +the following: + +```elixir +iex(1)> conn = Hunter.new([base_url: "https://example.com", bearer_token: "123456"]) +%Hunter.Client{base_url: "https://example.com", + bearer_token: "123456"} +``` + +### Getting the current user + +```elixir +iex(2)> Hunter.verify_credentials(conn) +%Hunter.Account{acct: "milmazz", + avatar: "https://social.lou.lt/avatars/original/missing.png", + created_at: "2017-04-06T17:43:55.325Z", display_name: "Milton Mazzarri", + followers_count: 2, following_count: 3, + header: "https://social.lou.lt/headers/original/missing.png", id: 8039, + locked: false, note: "", statuses_count: 1, + url: "https://social.lou.lt/@milmazz", username: "milmazz"} +``` + +### Fetching an account + +```elixir +iex(3)> Hunter.account(conn, 8039) +%Hunter.Account{acct: "milmazz", + avatar: "https://social.lou.lt/avatars/original/missing.png", + created_at: "2017-04-06T17:43:55.325Z", display_name: "Milton Mazzarri", + followers_count: 2, following_count: 3, + header: "https://social.lou.lt/headers/original/missing.png", id: 8039, + locked: false, note: "", statuses_count: 1, + url: "https://social.lou.lt/@milmazz", username: "milmazz"} +``` + +### Getting an account's followers + +Returns a list of `Accounts` + +```elixir +iex(4)> Hunter.followers(conn, 8039) +[%Hunter.Account{acct: "atmantree@mastodon.club", + avatar: "https://social.lou.lt/system/accounts/avatars/000/008/518/original/7715529d4ceb4554.jpg?1491509276", + created_at: "2017-04-06T20:07:57.119Z", display_name: "Carlos Gustavo Ruiz", + followers_count: 2, following_count: 2, + header: "https://social.lou.lt/system/accounts/headers/000/008/518/original/394f31473de7c64a.png?1491509277", + id: 8518, locked: false, + note: "Programmer, Pythonista, Web Creature, Blogger, C++ and Haskell Fan. Never stop learning, because life never stops teaching.", + statuses_count: 1, url: "https://mastodon.club/@atmantree", + username: "atmantree"}, + ... + ] +``` + +### Getting who account is following + +Returns a list of `Accounts` + +```elixir +iex(5)> Hunter.following(conn, 8039) +[%Hunter.Account{acct: "sebasmagri@mastodon.cloud", + avatar: "https://social.lou.lt/system/accounts/avatars/000/007/899/original/19b4d8c1e9d4e68a.jpg?1491498458", + created_at: "2017-04-06T17:07:38.912Z", + display_name: "Sebastián Ramírez Magrí", followers_count: 2, + following_count: 1, + header: "https://social.lou.lt/system/accounts/headers/000/007/899/original/missing.png?1491498458", + id: 7899, locked: false, note: "", statuses_count: 2, + url: "https://mastodon.cloud/@sebasmagri", username: "sebasmagri"}, + ...] + ``` + +### Following a remote user + +```elixir +iex(6)> Hunter.follow_by_uri(conn, "paperswelove@mstdn.io") +%Hunter.Account{acct: "paperswelove@mstdn.io", + avatar: "https://social.lou.lt/system/accounts/avatars/000/007/126/original/60ecc8225809c008.png?1491486258", + created_at: "2017-04-06T13:44:18.281Z", display_name: "Papers We Love", + followers_count: 1, following_count: 0, + header: "https://social.lou.lt/system/accounts/headers/000/007/126/original/missing.png?1491486258", + id: 7126, locked: false, + note: "Building Bridges Between Academia and Industry\r\n\r\nhttp://paperswelove.org\r\nhttp://pwlconf.org", + statuses_count: 1, url: "https://mstdn.io/@paperswelove", + username: "paperswelove"} + ``` + +### Muting/unmuting an account + +```elixir +iex(7)> Hunter.mute(conn, 7899) +%Hunter.Relationship{blocking: false, followed_by: false, following: true, + muting: true, requested: false} +iex(8)> Hunter.unmute(conn, 7899) +%Hunter.Relationship{blocking: false, followed_by: false, following: true, + muting: false, requested: false} +``` + +### Getting an account's statuses + +```elixir +iex(9)> Hunter.statuses(conn, 8039, [limit: 1]) +[%Hunter.Status{account: %{"acct" => "milmazz", + "avatar" => "https://social.lou.lt/avatars/original/missing.png", + "created_at" => "2017-04-06T17:43:55.325Z", + "display_name" => "Milton Mazzarri", "followers_count" => 2, + "following_count" => 4, + "header" => "https://social.lou.lt/headers/original/missing.png", + "id" => 8039, "locked" => false, "note" => "", "statuses_count" => 1, + "url" => "https://social.lou.lt/@milmazz", "username" => "milmazz"}, + application: %{"name" => "Web", "website" => nil}, + content: "

@Shutsumon You should read "How to design programs" book http://htdp.org

", + created_at: "2017-04-06T18:28:59.392Z", favourited: nil, favourites_count: 0, + id: 59144, in_reply_to_account_id: 7742, in_reply_to_id: 59042, + media_attachments: [], + mentions: [%{"acct" => "Shutsumon@tootplanet.space", "id" => 7742, + "url" => "http://tootplanet.space/@Shutsumon", "username" => "Shutsumon"}], + reblog: nil, reblogged: nil, reblogs_count: 0, sensitive: false, + spoiler_text: "", tags: [], + uri: "tag:social.lou.lt,2017-04-06:objectId=59144:objectType=Status", + url: "https://social.lou.lt/@milmazz/59144", visibility: "public"}] +``` + +## TODO + +* OAuth2 authentication + - Register client for token-access + - Token authentication for API usage +* Search for accounts or content +* Getting an account's relationship +* Register an application +* Fetching a user's blocks +* Fetching a user's favourites +* Fetching a list of follow requests +* Authorizing or rejecting follow requests +* Support arrays as parameter types +* Getting instance information +* Uploading media attachment +* Fetching a user's mutes +* Fetching a user's notifications +* Getting a single notification +* Clearing notifications +* Fetching user's reports +* Reporting a user +* Getting status context +* Getting a card associated with a status +* Getting who reblogged/favourited a status + +## License + +Hunter source code is released under Apache 2 License. + +Check the [LICENSE](LICENSE) for more information. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6422d4e --- /dev/null +++ b/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure for your application as: +# +# config :hunter, key: :value +# +# And access this configuration in your application as: +# +# Application.get_env(:hunter, :key) +# +# Or configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +import_config "#{Mix.env}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..c86c33f --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,3 @@ +use Mix.Config + +config :hunter, hunter_api: Hunter.Api.HTTPClient diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..8f31c45 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,3 @@ +use Mix.Config + +config :hunter, hunter_api: Hunter.Api.InMemory diff --git a/lib/hunter.ex b/lib/hunter.ex new file mode 100644 index 0000000..8c1a441 --- /dev/null +++ b/lib/hunter.ex @@ -0,0 +1,384 @@ +defmodule Hunter do + @moduledoc """ + A Elixir client for Mastodon, a GNU Social compatible micro-blogging service + + """ + + @hunter_version Mix.Project.config[:version] + + @doc """ + Retrieve account of authenticated user + + ## Parameters + + * `conn` - connection credentials + + """ + @spec verify_credentials(Hunter.Client.t) :: Hunter.Account.t + def verify_credentials(conn), do: Hunter.Account.verify_credentials(conn) + + @doc """ + Retrieve account + + ## Parameters + + * `conn` - connection credentials + * `id` - account identifier + + """ + @spec account(Hunter.Client.t, non_neg_integer) :: Hunter.Account.t + def account(conn, id), do: Hunter.Account.account(conn, id) + + @doc """ + Get a list of followers + + ## Parameters + + * `conn` - connection credentials + * `id` - account identifier + + """ + @spec followers(Hunter.Client.t, non_neg_integer) :: [Hunter.Account.t] + def followers(conn, id), do: Hunter.Account.followers(conn, id) + + @doc """ + Get a list of followed accounts + + ## Parameters + + * `conn` - connection credentials + * `id` - account identifier + + """ + @spec following(Hunter.Client.t, non_neg_integer) :: [Hunter.Account.t] + def following(conn, id), do: Hunter.Account.following(conn, id) + + @doc """ + Follow a remote user + + ## Parameters + + * `conn` - connection credentials + * `uri` - URI of the remote user, in the format of `username@domain` + + """ + @spec follow_by_uri(Hunter.Client.t, URI.t) :: Hunter.Account.t + def follow_by_uri(conn, uri), do: Hunter.Account.follow_by_uri(conn, uri) + + @doc """ + Register a new OAuth client app on the target instance + + ## Parameters + + * `name` + * `redirect_uri` + * `scopes` + * `website` + + """ + @spec create_app(String.t, URI.t, String.t, String.t) :: Hunter.Application.t + def create_app(name, redirect_uri, scopes \\ "read", website \\ nil) do + Hunter.Application.create_app(name, redirect_uri, scopes, website) + end + + @doc """ + Initializes a client + + ## Options + + * `base_url` - URL of the instance you want to connect to + * `bearer_token` - [String] OAuth access token for your authenticated user + + """ + @spec new(Keyword.t) :: Hunter.Client.t + def new(options \\ []), do: Hunter.Client.new(options) + + @doc """ + User agent of the client + """ + @spec user_agent() :: String.t + def user_agent, do: Hunter.Client.user_agent() + + @doc """ + Upload a media file + + ## Parameters + + * `conn` - connection credentials + * `file` + + """ + @spec upload_media(Hunter.Client.t, Path.t) :: Hunter.Media.t + def upload_media(conn, file), do: Hunter.Media.upload_media(conn, file) + + @doc """ + Get the relationships of authenticated user towards given other users + + ## Parameters + + * `id` - list of relationship IDs + + """ + @spec relationships([non_neg_integer]) :: [Hunter.Relationship.t] + def relationships(ids), do: Hunter.Relationship.relationships(ids) + + @doc """ + Follow a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @spec follow(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def follow(conn, id), do: Hunter.Relationship.follow(conn, id) + + @doc """ + Unfollow a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @spec unfollow(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def unfollow(conn, id), do: Hunter.Relationship.unfollow(conn, id) + + @doc """ + Block a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @spec block(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def block(conn, id), do: Hunter.Relationship.block(conn, id) + + @doc """ + Unblock a user + + * `conn` - connection credentials + * `id` - user identifier + + """ + @spec unblock(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def unblock(conn, id), do: Hunter.Relationship.unblock(conn, id) + + @doc """ + Mute a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @spec mute(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def mute(conn, id), do: Hunter.Relationship.mute(conn, id) + + @doc """ + Unmute a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @spec unmute(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def unmute(conn, id), do: Hunter.Relationship.unmute(conn, id) + + @doc """ + Search for content + + # Parameters + + * `q` - search query + + ## Options + + * `resolve` - whether to resolve non-local accounts + + """ + @spec search(String.t, Keyword.t) :: Hunter.Result.t + def search(query, options \\ []), do: Hunter.Result.search(query, options) + + @doc """ + Create new status + + ## Parameters + + * `conn` - connection credentials + * `text` - [String] + * `in_reply_to_id` - [Integer] + * `media_ids` - [Array] + + """ + @spec create_status(Hunter.Client.t, String.t, non_neg_integer, [non_neg_integer]) :: Hunter.Status.t + def create_status(conn, text, in_reply_to_id \\ nil, media_ids \\ []) do + Hunter.Status.create_status(conn, text, in_reply_to_id, media_ids) + end + + @doc """ + Retrieve status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @spec status(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def status(conn, id), do: Hunter.Status.status(conn, id) + + @doc """ + Destroy status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @spec destroy_status(Hunter.Client.t, non_neg_integer) :: boolean + def destroy_status(conn, id), do: Hunter.Status.destroy_status(conn, id) + + @doc """ + Reblog a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @spec reblog(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def reblog(conn, id), do: Hunter.Status.reblog(conn, id) + + @doc """ + Undo a reblog of a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @spec unreblog(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def unreblog(conn, id), do: Hunter.Status.unreblog(conn, id) + + @doc """ + Favorite a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @spec favourite(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def favourite(conn, id), do: Hunter.Status.favourite(conn, id) + + @doc """ + Undo a favorite of a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @spec unfavourite(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def unfavourite(conn, id), do: Hunter.Status.unfavourite(conn, id) + + @doc """ + Get a list of statuses by a user + + ## Parameters + + * `conn` - connection credentials - + * `account_id` - account identifier + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec statuses(Hunter.Client.t, non_neg_integer, Keyword.t) :: [Hunter.Status.t] + def statuses(conn, account_id, options \\ []) do + Hunter.Status.statuses(conn, account_id, options) + end + + @doc """ + Retrieve statuses from the home timeline + + ## Parameters + + * `conn` - connection credentials + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec home_timeline(Hunter.Client.t, Keyword.t) :: [Hunter.Status.t] + def home_timeline(conn, options \\ []) do + Hunter.Status.home_timeline(conn, options) + end + + @doc """ + Retrieve statuses from the public timeline + + ## Parameters + + * `conn` - connection credentials + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec public_timeline(Hunter.Client.t, Keyword.t) :: [Hunter.Status.t] + def public_timeline(conn, options \\ []) do + Hunter.Status.public_timeline(conn, options) + end + + @doc """ + Retrieve statuses from a hashtag + + ## Parameters + + * `conn` - connection credentials + * `hashtag` - string list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec hashtag_timeline(Hunter.Client.t, Keyword.t) :: [Hunter.Status.t] + def hashtag_timeline(conn, hashtag, options \\ []) do + Hunter.Status.hashtag_timeline(conn, hashtag, options) + end + + @doc """ + Returns Hunter version + """ + @spec version() :: String.t + def version, do: @hunter_version +end diff --git a/lib/hunter/account.ex b/lib/hunter/account.ex new file mode 100644 index 0000000..1305529 --- /dev/null +++ b/lib/hunter/account.ex @@ -0,0 +1,127 @@ +defmodule Hunter.Account do + @moduledoc """ + Account entity + + This module defines a `Hunter.Account` struct and the main functions + for working with Accounts. + + ## Fields + + * `id` - the ID of the account + * `username` - the username of the account + * `acct` - equals `username` for local users, includes `@domain` for remote ones + * `display_name` - the account's display name + * `note` - Biography of user + * `url` - URL of the user's profile page (can be remote) + * `avatar` - URL to the avatar image + * `header` - URL to the header image + * `locked` - boolean for when the account cannot be followed without waiting for approval first + * `created_at` - the time the account was created + * `followers_count` - the number of followers for the account + * `following_count` - the number of accounts the given account is following + * `statuses_count` - the number of statuses the account has made + + """ + @hunter_api Application.get_env(:hunter, :hunter_api) + + @type t :: %__MODULE__{ + id: non_neg_integer, + username: String.t, + acct: String.t, + display_name: String.t, + note: String.t, + url: URI.t, + avatar: URI.t, + header: URI.t, + locked: String.t, + created_at: String.t, + followers_count: non_neg_integer, + following_count: non_neg_integer, + statuses_count: non_neg_integer + } + + @derive [Poison.Encoder] + defstruct [:id, + :username, + :acct, + :display_name, + :note, + :url, + :avatar, + :header, + :locked, + :created_at, + :followers_count, + :following_count, + :statuses_count] + + + @doc """ + Retrieve account of authenticated user + + ## Parameters + + * `conn` - Connection credentials + + """ + @spec verify_credentials(Hunter.Client.t) :: Hunter.Account.t + def verify_credentials(conn) do + @hunter_api.verify_credentials(conn) + end + + @doc """ + Retrieve account + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec account(Hunter.Client.t, non_neg_integer) :: Hunter.Account.t + def account(conn, id) do + @hunter_api.account(conn, id) + end + + @doc """ + Get a list of followers + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec followers(Hunter.Client.t, non_neg_integer) :: [Hunter.Account.t] + def followers(conn, id) do + @hunter_api.followers(conn, id) + end + + @doc """ + Get a list of followed accounts + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec following(Hunter.Client.t, non_neg_integer) :: [Hunter.Account.t] + def following(conn, id) do + @hunter_api.following(conn, id) + end + + @doc """ + Follow a remote user + + ## Parameters + + * `conn` - Connection credentials + * `uri` - URI of the remote user, in the format of username@domain + + """ + @spec follow_by_uri(Hunter.Client.t, URI.t) :: Hunter.Account.t + def follow_by_uri(conn, uri) do + @hunter_api.follow_by_uri(conn, uri) + end +end diff --git a/lib/hunter/api.ex b/lib/hunter/api.ex new file mode 100644 index 0000000..3ef6e83 --- /dev/null +++ b/lib/hunter/api.ex @@ -0,0 +1,329 @@ +defmodule Hunter.Api do + ## Account + + @doc """ + Retrieve account of authenticated user + + ## Parameters + + * `conn` - connection credentials + + """ + @callback verify_credentials(conn :: Hunter.Client.t) :: Hunter.Account.t + + @doc """ + Retrieve account + + ## Parameters + + * `conn` - connection credentials + * `id` - account identifier + + """ + @callback account(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Account.t + + @doc """ + Get a list of followers + + ## Parameters + + * `conn` - connection credentials + * `id` - account identifier + + """ + @callback followers(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Account.t + + @doc """ + Get a list of followed accounts + + ## Parameters + + * `conn` - connection credentials + * `id` - account identifier + + """ + @callback following(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Account.t + + @doc """ + Follow a remote user + + ## Parameters + + * `conn` - connection credentials + * `uri` - URI of the remote user, in the format of `username@domain` + + """ + @callback follow_by_uri(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Account.t + + ## Application + + @doc """ + Register a new OAuth client app on the target instance + + ## Parameters + + * `conn` - connection credentials + * `name` + * `redirect_uri` + * `scopes` + * `website` + + """ + @callback create_app(conn :: Hunter.Client.t, name :: String.t, redirect_uri :: URI.t, scopes :: String.t, website :: String.t) :: Hunter.Application.t + + @doc """ + Upload a media file + + ## Parameters + + * `conn` - connection credentials + * `file` - + + """ + @callback upload_media(conn :: Hunter.Client.t, file :: Path.t) :: Hunter.Media.t + + + ## Relationship + + @doc """ + Get the relationships of authenticated user towards given other users + + ## Parameters + + * `id` - list of relationship IDs + + """ + @callback relationships(ids :: [non_neg_integer]) :: [Hunter.Relationship.t] + + @doc """ + Follow a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user id + + """ + @callback follow(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Relationship.t + + @doc """ + Unfollow a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @callback unfollow(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Relationship.t + + @doc """ + Block a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @callback block(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Relationship.t + + @doc """ + Unblock a user + + * `conn` - connection credentials + * `id` - user identifier + + """ + @callback unblock(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Relationship.t + + @doc """ + Mute a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @callback mute(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Relationship.t + + @doc """ + Unmute a user + + ## Parameters + + * `conn` - connection credentials + * `id` - user identifier + + """ + @callback unmute(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Relationship.t + + ## Result + + @doc """ + Search for content + + ## Parameters + + * `conn` - connection credentials + * `q` - the search query + * `options` - option list + + ## Options + + * `resolve` - whether to resolve non-local accounts + + """ + @callback search(Hunter.Client.t, query :: String.t, options :: Keyword.t) :: Hunter.Result.t + + ## Status + + @doc """ + Create new status + + ## Parameters + + * `conn` - connection credentials + * `text` - [String] + * `in_reply_to_id` - [Integer] + * `media_ids` - [Array] + + """ + @callback create_status(conn :: Hunter.Client.t, text :: String.t, in_reply_to_id :: non_neg_integer, media_ids :: [non_neg_integer]) :: Hunter.Status.t + + @doc """ + Retrieve status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @callback status(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Status + + @doc """ + Destroy status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @callback destroy_status(conn :: Hunter.Client.t, id :: non_neg_integer) :: boolean + + @doc """ + Reblog a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @callback reblog(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Status.t + + @doc """ + Undo a reblog of a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @callback unreblog(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Status.t + + @doc """ + Favorite a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @callback favourite(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Status.t + + @doc """ + Undo a favorite of a status + + ## Parameters + + * `conn` - connection credentials + * `id` - status identifier + + """ + @callback unfavourite(conn :: Hunter.Client.t, id :: non_neg_integer) :: Hunter.Status.t + + @doc """ + Get a list of statuses by a user + + ## Parameters + + * `conn` - connection credentials + * `account_id` - account identifier + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @callback statuses(conn :: Hunter.Client.t, account_id :: non_neg_integer, options :: Keyword.t) :: [Hunter.Status.t] + + @doc """ + Retrieve statuses from the home timeline + + ## Parameters + + * `conn` - connection credentials + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @callback home_timeline(conn :: Hunter.Client.t, options :: Keyword.t) :: [Hunter.Status.t] + + @doc """ + Retrieve statuses from the public timeline + + ## Parameters + + * `conn` - connection credentials + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @callback public_timeline(conn :: Hunter.Client.t, options :: Keyword.t) :: [Hunter.Status.t] + + @doc """ + Retrieve statuses from a hashtag + + ## Parameters + + * `conn` - connection credentials + * `hashtag` - list of strings + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @callback hashtag_timeline(conn :: Hunter.Client.t, hashtag :: [String.t], options :: Keyword.t) :: [Hunter.Status] +end diff --git a/lib/hunter/api/http_client.ex b/lib/hunter/api/http_client.ex new file mode 100644 index 0000000..e8a98fd --- /dev/null +++ b/lib/hunter/api/http_client.ex @@ -0,0 +1,183 @@ +defmodule Hunter.Api.HTTPClient do + @behaviour Hunter.Api + + def verify_credentials(%Hunter.Client{base_url: base_url} = conn) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/accounts/verify_credentials", get_headers(conn)) + Poison.decode!(body, as: %Hunter.Account{}) + end + + def account(%Hunter.Client{base_url: base_url} = conn, id) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/accounts/#{id}", get_headers(conn)) + Poison.decode!(body, as: %Hunter.Account{}) + end + + def followers(%Hunter.Client{base_url: base_url} = conn, id) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/accounts/#{id}/followers", get_headers(conn)) + Poison.decode!(body, as: [%Hunter.Account{}]) + end + + def following(%Hunter.Client{base_url: base_url} = conn, id) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/accounts/#{id}/following", get_headers(conn)) + Poison.decode!(body, as: [%Hunter.Account{}]) + end + + def follow_by_uri(%Hunter.Client{base_url: base_url} = conn, uri) do + payload = Poison.encode!(%{uri: uri}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/follows", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Account{}) + end + + def create_app(%Hunter.Client{base_url: base_url} = conn, name, redirect_uri, scopes, website) do + payload = Poison.encode!(%{client_name: name, redirect_uris: redirect_uri, scopes: scopes, website: website}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/apps", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Application{}) + end + + def upload_media(%Hunter.Client{base_url: base_url} = conn, file) do + payload = Poison.encode!(%{file: file}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/media", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Media{}) + end + + def relationships(_ids) do + # :: [Hunter.Relationship.t] + # @return [Hunter::Collection] + # perform_request_with_collection(:get, '', array_param(:id, ids), Hunter::Relationship) + HTTPoison.get("/api/v1/accounts/relationships") + + # Poison.decode!(body, as: [%Hunter.Relationship{}]) + end + + def follow(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/accounts/#{id}/follow", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Relationship{}) + end + + def unfollow(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/accounts/#{id}/unfollow", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Relationship{}) + end + + def block(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/accounts/#{id}/block", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Relationship{}) + end + + def unblock(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/accounts/#{id}/unblock", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Relationship{}) + end + + def mute(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/accounts/#{id}/mute", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Relationship{}) + end + + def unmute(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/accounts/#{id}/unmute", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Relationship{}) + end + + def search(_conn, _query, _options) do + # :: Hunter.Result.t + + # @return [Hunter::Results] If q is a URL, Hunter will + # attempt to fetch the provided account or status. Otherwise, it + # will do a local account and hashtag search. + + # opts = { + # q: query, + # }.merge(options) + + # perform_request_with_object(:get, '/api/v1/search', opts, Hunter::Results) + end + + def create_status(%Hunter.Client{base_url: base_url} = conn, text, in_reply_to_id, _media_ids) do + payload = Poison.encode!(%{status: text, in_reply_to_id: in_reply_to_id}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/statuses", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Status{}) + end + + def status(%Hunter.Client{base_url: base_url} = conn, id) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/statuses/#{id}", get_headers(conn)) + Poison.decode(body, as: %Hunter.Status{}) + end + + def destroy_status(%Hunter.Client{base_url: base_url} = conn, id) do + case HTTPoison.delete(base_url <> "/api/v1/statuses/#{id}", get_headers(conn)) do + {:ok, %HTTPoison.Response{status_code: 200}} -> + true + _ -> + false + end + end + + def reblog(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/statuses/#{id}/reblog", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Status{}) + end + + def unreblog(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/statuses/#{id}/unreblog", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Status{}) + end + + def favourite(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/statuses/#{id}/favourite", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Status{}) + end + + def unfavourite(%Hunter.Client{base_url: base_url} = conn, id) do + payload = Poison.encode!(%{}) + + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.post(base_url <> "/api/v1/statuses/#{id}/unfavourite", payload, [{"Content-Type", "application/json"} | get_headers(conn)]) + Poison.decode!(body, as: %Hunter.Status{}) + end + + def statuses(%Hunter.Client{base_url: base_url} = conn, account_id, options) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/accounts/#{account_id}/statuses", get_headers(conn), options) + Poison.decode!(body, as: [%Hunter.Status{}]) + end + + def home_timeline(%Hunter.Client{base_url: base_url} = conn, options) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/timelines/home", get_headers(conn), options) + Poison.decode!(body, as: [%Hunter.Status{}]) + end + + def public_timeline(%Hunter.Client{base_url: base_url} = conn, options) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/timelines/public", get_headers(conn), options) + Poison.decode!(body, as: [%Hunter.Status{}]) + end + + def hashtag_timeline(%Hunter.Client{base_url: base_url} = conn, hashtag, options) do + {:ok, %HTTPoison.Response{body: body, status_code: 200}} = HTTPoison.get(base_url <> "/api/v1/timelines/tag/#{hashtag}", get_headers(conn), options) + Poison.decode!(body, as: [%Hunter.Status{}]) + end + + ## Helpers + defp get_headers(%Hunter.Client{bearer_token: token}) do + [{"Authorization", "Bearer #{token}"}] + end +end diff --git a/lib/hunter/application.ex b/lib/hunter/application.ex new file mode 100644 index 0000000..1ac1b70 --- /dev/null +++ b/lib/hunter/application.ex @@ -0,0 +1,52 @@ +defmodule Hunter.Application do + @moduledoc """ + Application entity + + This module defines a `Hunter.Application` struct and the main functions + for working with Applications. + + ## Fields + + * `name` - name of the application + * `website` - homepage URL of the application + * `scope` - access scopes + * `redirect_uri` - + + ## Scopes + + * `read` - read data + * `write` - post statuses and upload media for statuses + * `follow` - follow, unfollow, block, unblock + +Multiple scopes can be requested during the authorization phase with the `scope` query param + + """ + @hunter_api Application.get_env(:hunter, :hunter_api) + + @type t :: %__MODULE__{ + name: String.t, + redirect_uri: URI.t, + scopes: String.t, + website: URI.t + } + + @derive [Poison.Encoder] + defstruct [:name, :redirect_uri, :scopes, :website] + + @doc """ + Register a new OAuth client app on the target instance + + ## Parameters + + * `conn` - connection credentials + * `name` - + * `redirect_uri` - + * `scopes` - + * `website` - + + """ + @spec create_app(Hunter.Client.t, String.t, URI.t, String.t, String.t) :: Hunter.Application.t + def create_app(conn, name, redirect_uri, scopes \\ "read", website \\ nil) do + @hunter_api.create_app(conn, name, redirect_uri, scopes, website) + end +end diff --git a/lib/hunter/attachment.ex b/lib/hunter/attachment.ex new file mode 100644 index 0000000..ad848c8 --- /dev/null +++ b/lib/hunter/attachment.ex @@ -0,0 +1,29 @@ +defmodule Hunter.Attribute do + @moduledoc """ + Attribute entity + + This module defines a `Hunter.Attribute` struct and the main functions + for working with Attributes. + + ## Fields + + * `id` - ID of the attachment + * `type` - One of: "image", "video", "gifv" + * `url` - URL of the locally hosted version of the image + * `remote_url` - For remote images, the remote URL of the original image + * `preview_url` - URL of the preview image + * `text_url` - Shorter URL for the image, for insertion into text (only present on local images) + + """ + @type t :: %__MODULE__{ + id: non_neg_integer, + type: String.t, + url: URI.t, + remote_url: URI.t, + preview_url: URI.t, + text_url: URI.t + } + + @derive [Poison.Encoder] + defstruct [:id, :type, :url, :remote_url, :preview_url, :text_url] +end diff --git a/lib/hunter/card.ex b/lib/hunter/card.ex new file mode 100644 index 0000000..8104485 --- /dev/null +++ b/lib/hunter/card.ex @@ -0,0 +1,28 @@ +defmodule Hunter.Card do + @moduledoc """ + Card entity + + This module defines a `Hunter.Card` struct and the main functions + for working with Cards + + ## Fields + + * `url`- The url associated with the card + * `title` - The title of the card + * `description` - The card description + * `image` - The image associated with the card, if any + + """ + @type t :: %__MODULE__{ + url: URI.t, + title: String.t, + description: String.t, + image: String.t + } + + @derive [Poison.Encoder] + defstruct [:url, :title, :description, :image] + +end + + diff --git a/lib/hunter/client.ex b/lib/hunter/client.ex new file mode 100644 index 0000000..0f28893 --- /dev/null +++ b/lib/hunter/client.ex @@ -0,0 +1,36 @@ +defmodule Hunter.Client do + @moduledoc """ + Defines a `Hunter` client + """ + + @type t :: %__MODULE__{ + base_url: URI.t, + bearer_token: String.t + + } + + @derive [Poison.Encoder] + defstruct [:base_url, :bearer_token] + + @doc """ + Initializes a client + + ## Options + + * `base_url` - URL of the instance you want to connect to + * `bearer_token` - [String] OAuth access token for your authenticated user + + """ + @spec new(Keyword.t) :: Hunter.Client.t + def new(options \\ []) do + struct(Hunter.Client, options) + end + + @doc """ + User agent of the client + """ + @spec user_agent() :: String.t + def user_agent do + "Hunter.Elixir/#{Hunter.version}" + end +end diff --git a/lib/hunter/context.ex b/lib/hunter/context.ex new file mode 100644 index 0000000..d101867 --- /dev/null +++ b/lib/hunter/context.ex @@ -0,0 +1,13 @@ +defmodule Hunter.Context do + @moduledoc """ + Context entity + + ## Fields + + * `ancestors` - The ancestors of the status in the conversation, as a list of Statuses + * `descendants` - The descendants of the status in the conversation, as a list of Statuses + + """ + defstruct [:ancestors, :descendants] + +end \ No newline at end of file diff --git a/lib/hunter/instance.ex b/lib/hunter/instance.ex new file mode 100644 index 0000000..9761124 --- /dev/null +++ b/lib/hunter/instance.ex @@ -0,0 +1,24 @@ +defmodule Hunter.Instance do + @moduledoc """ + Instance entity + + This module defines a `Hunter.Instance` struct and the main functions + for working with Instances. + + ## Fields + + * `uri` - URI of the current instance + * `title` - The instance's title + * `description` - A description for the instance + * `email` - An email address which can be used to contact the instance administrator + + """ + @type t :: %__MODULE__{ + uri: URI.t, + title: String.t, + description: String.t, + email: String.t + } + + defstruct [:uri, :title, :description, :email] +end \ No newline at end of file diff --git a/lib/hunter/media.ex b/lib/hunter/media.ex new file mode 100644 index 0000000..f99d88e --- /dev/null +++ b/lib/hunter/media.ex @@ -0,0 +1,28 @@ +defmodule Hunter.Media do + + @hunter_api Application.get_env(:hunter, :hunter_api) + + @type t :: %__MODULE__{ + id: non_neg_integer, + url: URI.t, + preview_url: URI.t, + type: String.t + } + + @derive [Poison.Encoder] + defstruct [:id, :url, :preview_url, :type] + + @doc """ + Upload a media file + + ## Parameters + + * `conn` - Connection credentials + * `file` - [HTTP::FormData::File] + + """ + @spec upload_media(Hunter.Client.t, Path.t) :: Hunter.Media.t + def upload_media(conn, file) do + @hunter_api.upload_media(conn, file) + end +end diff --git a/lib/hunter/mention.ex b/lib/hunter/mention.ex new file mode 100644 index 0000000..256e400 --- /dev/null +++ b/lib/hunter/mention.ex @@ -0,0 +1,23 @@ +defmodule Hunter.Mention do + @moduledoc """ + Mention entity + + ## Fields + + * `url` - URL of user's profile (can be remote) + * `username` - The username of the account + * `acct` - Equals `username` for local users, includes `@domain` for remote ones + * `id` - Account ID + + """ + @type t :: %__MODULE__{ + url: URI.t, + username: String.t, + acct: String.t, + id: non_neg_integer + } + + @derive [Poison.Encoder] + defstruct [:url, :username, :acct, :id] + +end diff --git a/lib/hunter/notification.ex b/lib/hunter/notification.ex new file mode 100644 index 0000000..4518c35 --- /dev/null +++ b/lib/hunter/notification.ex @@ -0,0 +1,27 @@ +defmodule Hunter.Notification do + @moduledoc """ + Notification entity + + This module defines a `Hunter.Notification` struct and the main functions + for working with Notifications. + + ## Fields + + * `id` - The notification ID + * `type` - One of: "mention", "reblog", "favourite", "follow" + * `created_at` - The time the notification was created + * `account` - The `Hunter.Account` sending the notification to the user + * `status` - The `Hunter.Status` associated with the notification, if applicable + + """ + @type t :: %__MODULE__{ + id: String.t, + type: String.t, + created_at: String.t, + account: Hunter.Account.t, + status: Hunter.Status.t + } + + @derive [Poison.Encoder] + defstruct [:id, :type, :created_at, :account, :status] +end diff --git a/lib/hunter/relationship.ex b/lib/hunter/relationship.ex new file mode 100644 index 0000000..b73a45b --- /dev/null +++ b/lib/hunter/relationship.ex @@ -0,0 +1,124 @@ +defmodule Hunter.Relationship do + @moduledoc """ + Relationship entity + + This module defines a `Hunter.Relationship` struct and the main functions + for working with Relationship. + + ## Fields + + * `following` - Whether the user is currently following the account + * `followed_by` - Whether the user is currently being followed by the account + * `blocking` - Whether the user is currently blocking the account + * `muting` - Whether the user is currently muting the account + * `requested` - Whether the user has requested to follow the account + + """ + @hunter_api Application.get_env(:hunter, :hunter_api) + + @type t :: %__MODULE__{ + following: boolean, + followed_by: boolean, + blocking: boolean, + muting: boolean, + requested: boolean + } + + @derive [Poison.Encoder] + defstruct [:following, :followed_by, :blocking, :muting, :requested] + + @doc """ + Get the relationships of authenticated user towards given other users + + ## Parameters + + * `id` - list of relationship IDs + + """ + @spec relationships([non_neg_integer]) :: [Hunter.Relationship.t] + def relationships(ids) do + @hunter_api.relationships(ids) + end + + @doc """ + Follow a user + + ## Parameters + + * `conn` - Connection credentials + * `id` - user id + + """ + @spec follow(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def follow(conn, id) do + @hunter_api.follow(conn, id) + end + + @doc """ + Unfollow a user + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec unfollow(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def unfollow(conn, id) do + @hunter_api.unfollow(conn, id) + end + + @doc """ + Block a user + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec block(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def block(conn, id) do + @hunter_api.block(conn, id) + end + + @doc """ + Unblock a user + + * `conn` - Connection credentials + * `id` + + """ + @spec unblock(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def unblock(conn, id) do + @hunter_api.unblock(conn, id) + end + + @doc """ + Mute a user + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec mute(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def mute(conn, id) do + @hunter_api.mute(conn, id) + end + + @doc """ + Unmute a user + + ## Parameters + + * `conn` - Connection credentials + * `id` + + """ + @spec unmute(Hunter.Client.t, non_neg_integer) :: Hunter.Relationship.t + def unmute(conn, id) do + @hunter_api.unmute(conn, id) + end +end diff --git a/lib/hunter/report.ex b/lib/hunter/report.ex new file mode 100644 index 0000000..ee194cf --- /dev/null +++ b/lib/hunter/report.ex @@ -0,0 +1,15 @@ +defmodule Hunter.Report do + @moduledoc """ + Report entity + + This module defines a `Hunter.Report` struct and the main functions + for working with Reports. + + ## Fields + + * `id` - The ID of the report + * `action_taken` - The action taken in response to the report + + """ + defstruct [:id, :action_taken] +end diff --git a/lib/hunter/result.ex b/lib/hunter/result.ex new file mode 100644 index 0000000..0cb948c --- /dev/null +++ b/lib/hunter/result.ex @@ -0,0 +1,42 @@ +defmodule Hunter.Result do + @moduledoc """ + Result entity + + ## Fields + + * `accounts` - list of matched `Hunter.Account` + * `statuses` - list of matched `Hunter.Status` + * `hashtags` - list of matched hashtags, as strings + + """ + @hunter_api Application.get_env(:hunter, :hunter_api) + + @type t :: %__MODULE__{ + accounts: [Hunter.Account.t], + statuses: [Hunter.Status.t], + hashtags: [String.t] + } + + @derive [Poison.Encoder] + defstruct accounts: [], + statuses: [], + hashtags: [] + + @doc """ + Search for content + + # Parameters + + * `conn` - Connection credentials + * `q` - [String] The search query + + ## Options + + * `resolve` - [Boolean] Whether to resolve non-local accounts + + """ + @spec search(Hunter.Client.t, String.t, Keyword.t) :: Hunter.Result.t + def search(conn, query, options \\ []) do + @hunter_api.search(conn, query, options) + end +end diff --git a/lib/hunter/status.ex b/lib/hunter/status.ex new file mode 100644 index 0000000..5b478ee --- /dev/null +++ b/lib/hunter/status.ex @@ -0,0 +1,255 @@ +defmodule Hunter.Status do + @moduledoc """ + Status entity + + ## Fields + + * `id` - The ID of the status + * `uri` - A Fediverse-unique resource ID + * `url` - URL to the status page (can be remote) + * `account` - The `Hunter.Account` which posted the status + * `in_reply_to_id` - `nil` or the ID of the status it replies to + * `in_reply_to_account_id` - `nil` or the ID of the account it replies to + * `reblog` - `nil` or the reblogged `Hunter.Status` + * `content` - Body of the status; this will contain HTML (remote HTML already sanitized) + * `created_at` - The time the status was created + * `reblogs_count` - The number of reblogs for the status + * `favourites_count` - The number of favourites for the status + * `reblogged` - Whether the authenticated user has reblogged the status + * `favourited` - Whether the authenticated user has favourited the status + * `sensitive` - Whether media attachments should be hidden by default + * `spoiler_text` - If not empty, warning text that should be displayed before the actual content + * `visibility` - One of: `public`, `unlisted`, `private`, `direct` + * `media_attachments` - A list of `Hunter.Attachment` + * `mentions` - A list of `Hunter.Mention` + * `tags` - A list of `Hunter.Tag` + * `application` - `Hunter.Application` from which the status was posted + + """ + @hunter_api Application.get_env(:hunter, :hunter_api) + + @type t :: %__MODULE__{ + id: non_neg_integer, + uri: URI.t, + url: URI.t, + account: Hunter.Account.t, + in_reply_to_id: non_neg_integer, + reblog: Hunter.Status.t | nil, + content: String.t, + created_at: String.t, + reblogs_count: non_neg_integer, + favourites_count: non_neg_integer, + reblogged: boolean, + favourited: boolean, + sensitive: boolean, + spoiler_text: String.t, + media_attachments: [Hunter.Attachment.t], + mentions: [Hunter.Mention.t], + tags: [Hunter.Tag.t], + application: Hunter.Application.t + } + + @derive [Poison.Encoder] + defstruct [:id, + :uri, + :url, + :account, + :in_reply_to_id, + :in_reply_to_account_id, + :reblog, + :content, + :created_at, + :reblogs_count, + :favourites_count, + :reblogged, + :favourited, + :sensitive, + :spoiler_text, + :visibility, + :media_attachments, + :mentions, + :tags, + :application] + + @doc """ + Create new status + + ## Parameters + + * `conn` - connection credentials + * `text` - [String] + * `in_reply_to_id` - [Integer] + * `media_ids` - [Array] + + """ + @spec create_status(Hunter.Client.t, String.t, non_neg_integer, [non_neg_integer]) :: Hunter.Status.t + def create_status(conn, text, in_reply_to_id \\ nil, media_ids \\ []) do + @hunter_api.create_status(conn, text, in_reply_to_id, media_ids) + end + + @doc """ + Retrieve status + + ## Parameters + + * `conn` - Connection credentials + * `id` [Integer] + + """ + @spec status(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def status(conn, id) do + @hunter_api.status(conn, id) + end + + @doc """ + Destroy status + + ## Parameters + + * `conn` - Connection credentials + * `id` [Integer] + + """ + @spec destroy_status(Hunter.Client.t, non_neg_integer) :: boolean + def destroy_status(conn, id) do + @hunter_api.destroy_status(conn, id) + end + + @doc """ + Reblog a status + + ## Parameters + + * `conn` - Connection credentials + * `id` - [Integer] + + """ + @spec reblog(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def reblog(conn, id) do + @hunter_api.reblog(conn, id) + end + + @doc """ + Undo a reblog of a status + + ## Parameters + + * `conn` - Connection credentials + * `id` - [Integer] + + """ + @spec unreblog(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def unreblog(conn, id) do + @hunter_api.unreblog(conn, id) + end + + @doc """ + Favorite a status + + ## Parameters + + * `conn` - Connection credentials + * `id` - [Integer] + + """ + @spec favourite(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def favourite(conn, id) do + @hunter_api.favourite(conn, id) + end + + @doc """ + Undo a favorite of a status + + ## Parameters + + * `conn` - Connection credentials + * `id` - [Integer] + + """ + @spec unfavourite(Hunter.Client.t, non_neg_integer) :: Hunter.Status.t + def unfavourite(conn, id) do + @hunter_api.unfavourite(conn, id) + end + + @doc """ + Get a list of statuses by a user + + ## Parameters + + * `conn` - Connection credentials + * `account_id` [Integer] + * `options` - options + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec statuses(Hunter.Client.t, non_neg_integer, Keyword.t) :: [Hunter.Status.t] + def statuses(conn, account_id, options \\ []) do + @hunter_api.statuses(conn, account_id, options) + end + + @doc """ + Retrieve statuses from the home timeline + + ## Parameters + + * `conn` - Connection credentials + * `options` - option list + + ## Options + + * `conn` - Connection credentials + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec home_timeline(Hunter.Client.t, Keyword.t) :: [Hunter.Status.t] + def home_timeline(conn, options \\ []) do + @hunter_api.home_timeline(conn, options) + end + + @doc """ + Retrieve statuses from the public timeline + + ## Parametes + + * `conn` - Connection credentials + * `options` - option list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec public_timeline(Hunter.Client.t, Keyword.t) :: [Hunter.Status.t] + def public_timeline(conn, options \\ []) do + @hunter_api.public_timeline(conn, options) + end + + @doc """ + Retrieve statuses from a hashtag + + ## Parameters + + * `conn` - connection credentials + * `hashtag` - string list + + ## Options + + * `max_id` - [Integer] + * `since_id` - [Integer] + * `limit` - [Integer] + + """ + @spec hashtag_timeline(Hunter.Client.t, Keyword.t) :: [Hunter.Status.t] + def hashtag_timeline(conn, hashtag, options \\ []) do + @hunter_api.hashtag_timeline(conn, hashtag, options) + end +end diff --git a/lib/hunter/tag.ex b/lib/hunter/tag.ex new file mode 100644 index 0000000..449b1cf --- /dev/null +++ b/lib/hunter/tag.ex @@ -0,0 +1,18 @@ +defmodule Hunter.Tag do + @moduledoc """ + Tag entity + + ## Fields + + * `name` - The hashtag, not including the preceding `#` + * `url` - The URL of the hashtag + + """ + + @type t :: %__MODULE__{ + name: String.t, + url: URI.t + } + + defstruct [:name, :url] +end \ No newline at end of file diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..8c65170 --- /dev/null +++ b/mix.exs @@ -0,0 +1,45 @@ +defmodule Hunter.Mixfile do + use Mix.Project + + def project do + [app: :hunter, + version: "0.1.0", + elixir: "~> 1.3", + docs: docs(), + package: package(), + source_url: "https://github.com/milmazz/hunter", + description: "Elixir wrapper for Mastodon API", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + elixirc_paths: elixirc_paths(Mix.env), + deps: deps()] + end + + # Configuration for the OTP application + # + # Type "mix help compile.app" for more information + def application do + # Specify extra applications you'll use from Erlang/Elixir + [extra_applications: [:logger, :httpoison]] + end + + defp deps do + [{:httpoison, "~> 0.10.0"}, + {:poison, "~> 3.0"}, + {:ex_doc, "~> 0.14", only: :dev, runtime: false}] + end + + defp package do + [licenses: ["Apache 2.0"], + maintainers: ["Milton Mazzarri"], + links: %{"GitHub" => "https://github.com/milmazz/hunter"}] + end + + defp docs do + [extras: ["README.md"], main: "readme"] + end + + # Specifies which paths to compile per environment + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..df452bf --- /dev/null +++ b/mix.lock @@ -0,0 +1,10 @@ +%{"certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], []}, + "earmark": {:hex, :earmark, "1.2.0", "bf1ce17aea43ab62f6943b97bd6e3dc032ce45d4f787504e3adf738e54b42f3a", [:mix], []}, + "ex_doc": {:hex, :ex_doc, "0.15.0", "e73333785eef3488cf9144a6e847d3d647e67d02bd6fdac500687854dd5c599f", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}, + "hackney": {:hex, :hackney, "1.6.6", "5564b4695d48fd87859e9df77a7fa4b4d284d24519f0cd7cc898f09e8fbdc8a3", [:rebar3], [{:certifi, "1.0.0", [hex: :certifi, optional: false]}, {:idna, "4.0.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, optional: false]}]}, + "httpoison": {:hex, :httpoison, "0.10.0", "4727b3a5e57e9a4ff168a3c2883e20f1208103a41bccc4754f15a9366f49b676", [:mix], [{:hackney, "~> 1.6.3", [hex: :hackney, optional: false]}]}, + "idna": {:hex, :idna, "4.0.0", "10aaa9f79d0b12cf0def53038547855b91144f1bfcc0ec73494f38bb7b9c4961", [:rebar3], []}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], []}} diff --git a/test/support/in_memory.ex b/test/support/in_memory.ex new file mode 100644 index 0000000..441e507 --- /dev/null +++ b/test/support/in_memory.ex @@ -0,0 +1,104 @@ +defmodule Hunter.Api.InMemory do + @behaviour Hunter.Api + + def verify_credentials(_) do + %Hunter.Account{} + end + + def account(_, _) do + %Hunter.Account{} + end + + def followers(_, _) do + [%Hunter.Account{}] + end + + def following(_, _) do + [%Hunter.Account{}] + end + + def follow_by_uri(_, _) do + %Hunter.Account{} + end + + def create_app(_, _, _, _, _) do + %Hunter.Application{} + end + + def upload_media(_, _) do + %Hunter.Media{} + end + + def relationships(_) do + end + + def follow(_, _) do + %Hunter.Relationship{} + end + + def unfollow(_, _) do + %Hunter.Relationship{} + end + + def block(_, _) do + %Hunter.Relationship{} + end + + def unblock(_, _) do + %Hunter.Relationship{} + end + + def mute(_, _) do + %Hunter.Relationship{} + end + + def unmute(_, _) do + %Hunter.Relationship{} + end + + def search(_, _, _) do + end + + def create_status(_, _, _, _) do + end + + def status(_, _) do + %Hunter.Status{} + end + + def destroy_status(_, _) do + true + end + + def reblog(_, _) do + %Hunter.Status{} + end + + def unreblog(_, _) do + %Hunter.Status{} + end + + def favourite(_, _) do + %Hunter.Status{} + end + + def unfavourite(_, _) do + %Hunter.Status{} + end + + def statuses(_, _, _) do + [%Hunter.Status{}] + end + + def home_timeline(_, _) do + [%Hunter.Status{}] + end + + def public_timeline(_, _) do + + end + + def hashtag_timeline(_, _, _) do + + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()