Merge branch 'develop' into dtluna/pleroma-feature/unfollow-activity
authorRoger Braun <roger@rogerbraun.net>
Sun, 7 May 2017 17:28:23 +0000 (19:28 +0200)
committerRoger Braun <roger@rogerbraun.net>
Sun, 7 May 2017 17:28:23 +0000 (19:28 +0200)
12 files changed:
1  2 
lib/pleroma/user.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/ostatus/activity_representer.ex
lib/pleroma/web/ostatus/ostatus.ex
lib/pleroma/web/twitter_api/representers/activity_representer.ex
lib/pleroma/web/twitter_api/twitter_api.ex
test/support/factory.ex
test/user_test.exs
test/web/activity_pub/activity_pub_test.exs
test/web/ostatus/activity_representer_test.exs
test/web/twitter_api/twitter_api_controller_test.exs
test/web/twitter_api/twitter_api_test.exs

diff --combined lib/pleroma/user.ex
index e1a7befaa7f0f2e7ae20455c72298ae964c101bd,551c23445067b6e73f2dd2ef3e406ca14df5a7aa..4510be77052129bdf32950d2903fe6847f9171e2
@@@ -1,9 -1,10 +1,11 @@@
  defmodule Pleroma.User do
    use Ecto.Schema
-   import Ecto.Changeset
-   import Ecto.Query
-   alias Pleroma.{Repo, User, Object}
+   import Ecto.{Changeset, Query}
+   alias Pleroma.{Repo, User, Object, Web}
+   alias Comeonin.Pbkdf2
+   alias Pleroma.Web.{OStatus, Websub}
 +  alias Pleroma.Web.ActivityPub.ActivityPub
  
    schema "users" do
      field :bio, :string
      field :password_hash, :string
      field :password, :string, virtual: true
      field :password_confirmation, :string, virtual: true
-     field :following, { :array, :string }, default: []
+     field :following, {:array, :string}, default: []
      field :ap_id, :string
      field :avatar, :map
+     field :local, :boolean, default: true
+     field :info, :map, default: %{}
  
      timestamps()
    end
@@@ -28,7 -31,7 +32,7 @@@
    end
  
    def ap_id(%User{nickname: nickname}) do
-     "#{Pleroma.Web.base_url}/users/#{nickname}"
+     "#{Web.base_url}/users/#{nickname}"
    end
  
    def ap_followers(%User{} = user) do
@@@ -67,7 -70,7 +71,7 @@@
      |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/)
  
      if changeset.valid? do
-       hashed = Comeonin.Pbkdf2.hashpwsalt(changeset.changes[:password])
+       hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
        ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
        followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]})
        changeset
    def follow(%User{} = follower, %User{} = followed) do
      ap_followers = User.ap_followers(followed)
      if following?(follower, followed) do
-       { :error,
-         "Could not follow user: #{followed.nickname} is already on your list." }
+       {:error,
+        "Could not follow user: #{followed.nickname} is already on your list."}
      else
+       if !followed.local do
+         Websub.subscribe(follower, followed)
+       end
        following = [ap_followers | follower.following]
        |> Enum.uniq
  
        following = follower.following
        |> List.delete(ap_followers)
  
 -      follower
 +      { :ok, follower } = follower
        |> follow_changeset(%{following: following})
        |> Repo.update
 +      { :ok, follower, ActivityPub.fetch_latest_follow(follower, followed)}
      else
-       { :error, "Not subscribed!" }
+       {:error, "Not subscribed!"}
      end
    end
  
  
    def get_cached_by_nickname(nickname) do
      key = "nickname:#{nickname}"
-     Cachex.get!(:user_cache, key, fallback: fn(_) -> Repo.get_by(User, nickname: nickname) end)
+     Cachex.get!(:user_cache, key, fallback: fn(_) -> get_or_fetch_by_nickname(nickname) end)
+   end
+   def get_by_nickname(nickname) do
+     Repo.get_by(User, nickname: nickname)
+   end
+   def get_cached_user_info(user) do
+     key = "user_info:#{user.id}"
+     Cachex.get!(:user_cache, key, fallback: fn(_) -> user_info(user) end)
+   end
+   def get_or_fetch_by_nickname(nickname) do
+     with %User{} = user <- get_by_nickname(nickname)  do
+       user
+     else _e ->
+       with [nick, domain] <- String.split(nickname, "@"),
+            {:ok, user} <- OStatus.make_user(nickname) do
+         user
+       else _e -> nil
+       end
+     end
    end
  end
index 5937ec88cf6e69189f1e8e22ea242b92ad2444f1,82aed7ce42a41eb08a39bd0f9bc557c6b419ee42..f3e94b1010c54f978af1ed088f0076ab8038da8a
@@@ -1,9 -1,9 +1,9 @@@
  defmodule Pleroma.Web.ActivityPub.ActivityPub do
-   alias Pleroma.Repo
-   alias Pleroma.{Activity, Object, Upload, User}
+   alias Pleroma.{Activity, Repo, Object, Upload, User, Web}
+   alias Ecto.{Changeset, UUID}
    import Ecto.Query
  
-   def insert(map) when is_map(map) do
+   def insert(map, local \\ true) when is_map(map) do
      map = map
      |> Map.put_new_lazy("id", &generate_activity_id/0)
      |> Map.put_new_lazy("published", &make_date/0)
        map
      end
  
-     Repo.insert(%Activity{data: map})
+     Repo.insert(%Activity{data: map, local: local})
    end
  
-   def like(%User{ap_id: ap_id} = user, %Object{data: %{ "id" => id}} = object) do
+   def create(to, actor, context, object, additional \\ %{}, published \\ nil, local \\ true) do
+     published = published || make_date()
+     activity = %{
+       "type" => "Create",
+       "to" => to |> Enum.uniq,
+       "actor" => actor.ap_id,
+       "object" => object,
+       "published" => published,
+       "context" => context
+     }
+     |> Map.merge(additional)
+     with {:ok, activity} <- insert(activity, local) do
+       if actor.local do
+         Pleroma.Web.Federator.enqueue(:publish, activity)
+        end
+       {:ok, activity}
+     end
+   end
+   def like(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, local \\ true) do
      cond do
        # There's already a like here, so return the original activity.
        ap_id in (object.data["likes"] || []) ->
            "type" => "Like",
            "actor" => ap_id,
            "object" => id,
-           "to" => [User.ap_followers(user), object.data["actor"]]
+           "to" => [User.ap_followers(user), object.data["actor"]],
+           "context" => object.data["context"]
          }
  
-         {:ok, activity} = insert(data)
+         {:ok, activity} = insert(data, local)
  
          likes = [ap_id | (object.data["likes"] || [])] |> Enum.uniq
  
          |> Map.put("like_count", length(likes))
          |> Map.put("likes", likes)
  
-         changeset = Ecto.Changeset.change(object, data: new_data)
+         changeset = Changeset.change(object, data: new_data)
          {:ok, object} = Repo.update(changeset)
  
          update_object_in_activities(object)
  
+         if user.local do
+           Pleroma.Web.Federator.enqueue(:publish, activity)
+         end
          {:ok, activity, object}
      end
    end
@@@ -58,7 -85,7 +85,7 @@@
      relevant_activities = Activity.all_by_object_ap_id(id)
      Enum.map(relevant_activities, fn (activity) ->
        new_activity_data = activity.data |> Map.put("object", object.data)
-       changeset = Ecto.Changeset.change(activity, data: new_activity_data)
+       changeset = Changeset.change(activity, data: new_activity_data)
        Repo.update(changeset)
      end)
    end
        |> Map.put("like_count", length(likes))
        |> Map.put("likes", likes)
  
-       changeset = Ecto.Changeset.change(object, data: new_data)
+       changeset = Changeset.change(object, data: new_data)
        {:ok, object} = Repo.update(changeset)
  
        update_object_in_activities(object)
    end
  
    def generate_object_id do
-     generate_id("objects")
+     Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :object, Ecto.UUID.generate)
    end
  
    def generate_id(type) do
-     "#{Pleroma.Web.base_url()}/#{type}/#{Ecto.UUID.generate}"
+     "#{Web.base_url()}/#{type}/#{UUID.generate}"
    end
  
    def fetch_public_activities(opts \\ %{}) do
      query = from activity in query,
        where: activity.id > ^since_id
  
+     query = if opts["local_only"] do
+       from activity in query, where: activity.local == true
+     else
+       query
+     end
      query = if opts["max_id"] do
        from activity in query, where: activity.id < ^opts["max_id"]
      else
        query
      end
  
-     Repo.all(query)
-     |> Enum.reverse
+     Enum.reverse(Repo.all(query))
    end
  
-   def announce(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object) do
+   def announce(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, local \\ true) do
      data = %{
        "type" => "Announce",
        "actor" => ap_id,
        "object" => id,
-       "to" => [User.ap_followers(user), object.data["actor"]]
+       "to" => [User.ap_followers(user), object.data["actor"]],
+       "context" => object.data["context"]
      }
  
-     {:ok, activity} = insert(data)
+     {:ok, activity} = insert(data, local)
  
      announcements = [ap_id | (object.data["announcements"] || [])] |> Enum.uniq
  
      |> Map.put("announcement_count", length(announcements))
      |> Map.put("announcements", announcements)
  
-     changeset = Ecto.Changeset.change(object, data: new_data)
+     changeset = Changeset.change(object, data: new_data)
      {:ok, object} = Repo.update(changeset)
  
      update_object_in_activities(object)
  
+     if user.local do
+       Pleroma.Web.Federator.enqueue(:publish, activity)
+     end
      {:ok, activity, object}
    end
  
++  def follow(%User{ap_id: follower_id, local: actor_local}, %User{ap_id: followed_id}, local \\ true) do
++    data = %{
++      "type" => "Follow",
++      "actor" => follower_id,
++      "to" => [followed_id],
++      "object" => followed_id,
++      "published" => make_date()
++    }
++
++    with {:ok, activity} <- insert(data, local) do
++      if actor_local do
++        Pleroma.Web.Federator.enqueue(:publish, activity)
++       end
++
++      {:ok, activity}
++    end
++  end
++
++  def unfollow(follower, followed, local \\ true) do
++    with follow_activity when not is_nil(follow_activity) <- fetch_latest_follow(follower, followed) do
++      data = %{
++        "type" => "Undo",
++        "actor" => follower.ap_id,
++        "to" => [followed.ap_id],
++        "object" => follow_activity.data["id"],
++        "published" => make_date()
++      }
++
++      with {:ok, activity} <- insert(data, local) do
++        if follower.local do
++          Pleroma.Web.Federator.enqueue(:publish, activity)
++        end
++
++        {:ok, activity}
++      end
++    end
++  end
++
    def fetch_activities_for_context(context) do
      query = from activity in Activity,
        where: fragment("? @> ?", activity.data, ^%{ context: context })
      Repo.all(query)
    end
  
 +  def fetch_latest_follow(%User{ap_id: follower_id},
 +                          %User{ap_id: followed_id}) do
 +    query = from activity in Activity,
 +      where: fragment("? @> ?", activity.data, ^%{type: "Follow", actor: follower_id,
 +                                                  object: followed_id}),
 +      order_by: [desc: :inserted_at],
 +      limit: 1
 +    Repo.one(query)
 +  end
 +
    def upload(file) do
      data = Upload.store(file)
      Repo.insert(%Object{data: data})
index 590abc8bbbad3cfc7135f6b9936466a2520671b1,66e9b0ec23e21450e04355ed048329c8c6417be7..02d15ea94a0bdc5c11bbc3f17571403be48fe0f2
@@@ -1,5 -1,31 +1,31 @@@
  defmodule Pleroma.Web.OStatus.ActivityRepresenter do
-   def to_simple_form(%{data: %{"object" => %{"type" => "Note"}}} = activity, user) do
+   alias Pleroma.{Activity, User}
+   alias Pleroma.Web.OStatus.UserRepresenter
+   require Logger
+   defp get_in_reply_to(%{"object" => %{"inReplyTo" => in_reply_to}}) do
+     [{:"thr:in-reply-to", [ref: to_charlist(in_reply_to)], []}]
+   end
+   defp get_in_reply_to(_), do: []
+   defp get_mentions(to) do
+     Enum.map(to, fn (id) ->
+       cond do
+         # Special handling for the AP/Ostatus public collections
+         "https://www.w3.org/ns/activitystreams#Public" == id ->
+           {:link, [rel: "mentioned", "ostatus:object-type": "http://activitystrea.ms/schema/1.0/collection", href: "http://activityschema.org/collection/public"], []}
+         # Ostatus doesn't handle follower collections, ignore these.
+         Regex.match?(~r/^#{Pleroma.Web.base_url}.+followers$/, id) ->
+           []
+         true ->
+           {:link, [rel: "mentioned", "ostatus:object-type": "http://activitystrea.ms/schema/1.0/person", href: id], []}
+       end
+     end)
+   end
+   def to_simple_form(activity, user, with_author \\ false)
+   def to_simple_form(%{data: %{"object" => %{"type" => "Note"}}} = activity, user, with_author) do
      h = fn(str) -> [to_charlist(str)] end
  
      updated_at = activity.updated_at
        {:link, [rel: 'enclosure', href: to_charlist(url["href"]), type: to_charlist(url["mediaType"])], []}
      end)
  
+     in_reply_to = get_in_reply_to(activity.data)
+     author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
+     mentions = activity.data["to"] |> get_mentions
      [
        {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/note']},
        {:"activity:verb", ['http://activitystrea.ms/schema/1.0/post']},
-       {:id, h.(activity.data["object"]["id"])},
+       {:id, h.(activity.data["object"]["id"])}, # For notes, federate the object id.
        {:title, ['New note by #{user.nickname}']},
        {:content, [type: 'html'], h.(activity.data["object"]["content"])},
        {:published, h.(inserted_at)},
-       {:updated, h.(updated_at)}
-     ] ++ attachments
+       {:updated, h.(updated_at)},
+       {:"ostatus:conversation", [], h.(activity.data["context"])},
+       {:link, [href: h.(activity.data["context"]), rel: 'ostatus:conversation'], []},
+       {:link, [type: ['application/atom+xml'], href: h.(activity.data["object"]["id"]), rel: 'self'], []}
+     ] ++ attachments ++ in_reply_to ++ author ++ mentions
+   end
+   def to_simple_form(%{data: %{"type" => "Like"}} = activity, user, with_author) do
+     h = fn(str) -> [to_charlist(str)] end
+     updated_at = activity.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = activity.inserted_at
+     |> NaiveDateTime.to_iso8601
+     in_reply_to = get_in_reply_to(activity.data)
+     author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
+     mentions = activity.data["to"] |> get_mentions
+     [
+       {:"activity:verb", ['http://activitystrea.ms/schema/1.0/favorite']},
+       {:id, h.(activity.data["id"])},
+       {:title, ['New favorite by #{user.nickname}']},
+       {:content, [type: 'html'], ['#{user.nickname} favorited something']},
+       {:published, h.(inserted_at)},
+       {:updated, h.(updated_at)},
+       {:"activity:object", [
+         {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/note']},
+         {:id, h.(activity.data["object"])}, # For notes, federate the object id.
+       ]},
+       {:"ostatus:conversation", [], h.(activity.data["context"])},
+       {:link, [href: h.(activity.data["context"]), rel: 'ostatus:conversation'], []},
+       {:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
+       {:"thr:in-reply-to", [ref: to_charlist(activity.data["object"])], []}
+     ] ++ author ++ mentions
+   end
+   def to_simple_form(%{data: %{"type" => "Announce"}} = activity, user, with_author) do
+     h = fn(str) -> [to_charlist(str)] end
+     updated_at = activity.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = activity.inserted_at
+     |> NaiveDateTime.to_iso8601
+     in_reply_to = get_in_reply_to(activity.data)
+     author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
+     retweeted_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
+     retweeted_user = User.get_cached_by_ap_id(retweeted_activity.data["actor"])
+     retweeted_xml = to_simple_form(retweeted_activity, retweeted_user, true)
+     mentions = activity.data["to"] |> get_mentions
+     [
+       {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/activity']},
+       {:"activity:verb", ['http://activitystrea.ms/schema/1.0/share']},
+       {:id, h.(activity.data["id"])},
+       {:title, ['#{user.nickname} repeated a notice']},
+       {:content, [type: 'html'], ['RT #{retweeted_activity.data["object"]["content"]}']},
+       {:published, h.(inserted_at)},
+       {:updated, h.(updated_at)},
+       {:"ostatus:conversation", [], h.(activity.data["context"])},
+       {:link, [href: h.(activity.data["context"]), rel: 'ostatus:conversation'], []},
+       {:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
+       {:"activity:object", retweeted_xml}
+     ] ++ mentions ++ author
+   end
+   def to_simple_form(%{data: %{"type" => "Follow"}} = activity, user, with_author) do
+     h = fn(str) -> [to_charlist(str)] end
+     updated_at = activity.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = activity.inserted_at
+     |> NaiveDateTime.to_iso8601
+     author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
+     mentions = (activity.data["to"] || []) |> get_mentions
+     [
+       {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/activity']},
+       {:"activity:verb", ['http://activitystrea.ms/schema/1.0/follow']},
+       {:id, h.(activity.data["id"])},
+       {:title, ['#{user.nickname} started following #{activity.data["object"]}']},
+       {:content, [type: 'html'], ['#{user.nickname} started following #{activity.data["object"]}']},
+       {:published, h.(inserted_at)},
+       {:updated, h.(updated_at)},
+       {:"activity:object", [
+         {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/person']},
+         {:id, h.(activity.data["object"])},
+         {:uri, h.(activity.data["object"])},
+       ]},
+       {:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
+     ] ++ mentions ++ author
+   end
++  # Only undos of follow for now. Will need to get redone once there are more
++  def to_simple_form(%{data: %{"type" => "Undo"}} = activity, user, with_author) do
++    h = fn(str) -> [to_charlist(str)] end
++
++    updated_at = activity.updated_at
++    |> NaiveDateTime.to_iso8601
++    inserted_at = activity.inserted_at
++    |> NaiveDateTime.to_iso8601
++
++    author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
++    follow_activity = Activity.get_by_ap_id(activity.data["object"])
++
++    mentions = (activity.data["to"] || []) |> get_mentions
++    [
++      {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/activity']},
++      {:"activity:verb", ['http://activitystrea.ms/schema/1.0/unfollow']},
++      {:id, h.(activity.data["id"])},
++      {:title, ['#{user.nickname} stopped following #{follow_activity.data["object"]}']},
++      {:content, [type: 'html'], ['#{user.nickname} stopped following #{follow_activity.data["object"]}']},
++      {:published, h.(inserted_at)},
++      {:updated, h.(updated_at)},
++      {:"activity:object", [
++        {:"activity:object-type", ['http://activitystrea.ms/schema/1.0/person']},
++        {:id, h.(follow_activity.data["object"])},
++        {:uri, h.(follow_activity.data["object"])},
++      ]},
++      {:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
++    ] ++ mentions ++ author
++  end
++
+   def wrap_with_entry(simple_form) do
+     [{
+       :entry, [
+         xmlns: 'http://www.w3.org/2005/Atom',
+         "xmlns:thr": 'http://purl.org/syndication/thread/1.0',
+         "xmlns:activity": 'http://activitystrea.ms/spec/1.0/',
+         "xmlns:poco": 'http://portablecontacts.net/spec/1.0',
+         "xmlns:ostatus": 'http://ostatus.org/schema/1.0'
+       ], simple_form
+     }]
    end
  
-   def to_simple_form(_,_), do: nil
+   def to_simple_form(_, _, _), do: nil
  end
index d21b9078f4bc81799c7aee86e648de56171ab177,a6d416b2cc65228053b7b228defff90aba18aa67..842ad0f0199abcd3bb071508a788c273ff634941
@@@ -1,5 -1,13 +1,13 @@@
  defmodule Pleroma.Web.OStatus do
-   alias Pleroma.Web
+   @httpoison Application.get_env(:pleroma, :httpoison)
+   import Ecto.Query
+   import Pleroma.Web.XML
+   require Logger
+   alias Pleroma.{Repo, User, Web, Object, Activity}
+   alias Pleroma.Web.ActivityPub.ActivityPub
+   alias Pleroma.Web.{WebFinger, Websub}
  
    def feed_path(user) do
      "#{user.ap_id}/feed.atom"
      "#{Web.base_url}/push/hub/#{user.nickname}"
    end
  
-   def user_path(user) do
+   def salmon_path(user) do
+     "#{user.ap_id}/salmon"
+   end
+   def handle_incoming(xml_string) do
+     doc = parse_document(xml_string)
+     entries = :xmerl_xpath.string('//entry', doc)
+     activities = Enum.map(entries, fn (entry) ->
+       {:xmlObj, :string, object_type} = :xmerl_xpath.string('string(/entry/activity:object-type[1])', entry)
+       {:xmlObj, :string, verb} = :xmerl_xpath.string('string(/entry/activity:verb[1])', entry)
+       case verb do
+         'http://activitystrea.ms/schema/1.0/share' ->
+           with {:ok, activity, retweeted_activity} <- handle_share(entry, doc), do: [activity, retweeted_activity]
+         'http://activitystrea.ms/schema/1.0/favorite' ->
+           with {:ok, activity, favorited_activity} <- handle_favorite(entry, doc), do: [activity, favorited_activity]
+         _ ->
+           case object_type do
+             'http://activitystrea.ms/schema/1.0/note' ->
+               with {:ok, activity} <- handle_note(entry, doc), do: activity
+             'http://activitystrea.ms/schema/1.0/comment' ->
+               with {:ok, activity} <- handle_note(entry, doc), do: activity
+             _ ->
+               Logger.error("Couldn't parse incoming document")
+               nil
+           end
+       end
+     end)
+     {:ok, activities}
+   end
+   def make_share(_entry, doc, retweeted_activity) do
+     with {:ok, actor} <- find_make_or_update_user(doc),
+          %Object{} = object <- Object.get_cached_by_ap_id(retweeted_activity.data["object"]["id"]),
+          {:ok, activity, _object} = ActivityPub.announce(actor, object, false) do
+       {:ok, activity}
+     end
+   end
+   def handle_share(entry, doc) do
+     with [object] <- :xmerl_xpath.string('/entry/activity:object', entry),
+          {:ok, retweeted_activity} <-  handle_note(object, object),
+          {:ok, activity} <- make_share(entry, doc, retweeted_activity) do
+       {:ok, activity, retweeted_activity}
+     else
+       e -> {:error, e}
+     end
+   end
+   def make_favorite(_entry, doc, favorited_activity) do
+     with {:ok, actor} <- find_make_or_update_user(doc),
+          %Object{} = object <- Object.get_cached_by_ap_id(favorited_activity.data["object"]["id"]),
+          {:ok, activity, _object} = ActivityPub.like(actor, object, false) do
+       {:ok, activity}
+     end
+   end
+   def get_or_try_fetching(entry) do
+     with id when not is_nil(id) <- string_from_xpath("//activity:object[1]/id", entry),
+          %Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
+       {:ok, activity}
+     else _e ->
+         with href when not is_nil(href) <- string_from_xpath("//activity:object[1]/link[@type=\"text/html\"]/@href", entry),
+              {:ok, [favorited_activity]} <- fetch_activity_from_html_url(href) do
+           {:ok, favorited_activity}
+         end
+     end
+   end
+   def handle_favorite(entry, doc) do
+     with {:ok, favorited_activity} <- get_or_try_fetching(entry),
+          {:ok, activity} <- make_favorite(entry, doc, favorited_activity) do
+       {:ok, activity, favorited_activity}
+     else
+       e -> {:error, e}
+     end
+   end
+   def get_attachments(entry) do
+     :xmerl_xpath.string('/entry/link[@rel="enclosure"]', entry)
+     |> Enum.map(fn (enclosure) ->
+       with href when not is_nil(href) <- string_from_xpath("/link/@href", enclosure),
+            type when not is_nil(type) <- string_from_xpath("/link/@type", enclosure) do
+         %{
+           "type" => "Attachment",
+           "url" => [%{
+                        "type" => "Link",
+                        "mediaType" => type,
+                        "href" => href
+                     }]
+         }
+       end
+     end)
+     |> Enum.filter(&(&1))
+   end
+   def handle_note(entry, doc \\ nil) do
+     content_html = string_from_xpath("//content[1]", entry)
+     [author] = :xmerl_xpath.string('//author[1]', doc)
+     {:ok, actor} = find_make_or_update_user(author)
+     inReplyTo = string_from_xpath("//thr:in-reply-to[1]/@ref", entry)
+     if !Object.get_cached_by_ap_id(inReplyTo) do
+       inReplyToHref = string_from_xpath("//thr:in-reply-to[1]/@href", entry)
+       if inReplyToHref do
+         fetch_activity_from_html_url(inReplyToHref)
+       end
+     end
+     context = (string_from_xpath("//ostatus:conversation[1]", entry) || "") |> String.trim
+     attachments = get_attachments(entry)
+     context = with %{data: %{"context" => context}} <- Object.get_cached_by_ap_id(inReplyTo) do
+                 context
+               else _e ->
+                 if String.length(context) > 0 do
+                   context
+                 else
+                   ActivityPub.generate_context_id
+                 end
+               end
+     to = [
+       "https://www.w3.org/ns/activitystreams#Public",
+       User.ap_followers(actor)
+     ]
+     mentions = :xmerl_xpath.string('//link[@rel="mentioned" and @ostatus:object-type="http://activitystrea.ms/schema/1.0/person"]', entry)
+     |> Enum.map(fn(person) -> string_from_xpath("@href", person) end)
+     to = to ++ mentions
+     date = string_from_xpath("//published", entry)
+     id = string_from_xpath("//id", entry)
+     object = %{
+       "id" => id,
+       "type" => "Note",
+       "to" => to,
+       "content" => content_html,
+       "published" => date,
+       "context" => context,
+       "actor" => actor.ap_id,
+       "attachment" => attachments
+     }
+     object = if inReplyTo do
+       Map.put(object, "inReplyTo", inReplyTo)
+     else
+       object
+     end
+     # TODO: Bail out sooner and use transaction.
+     if Object.get_by_ap_id(id) do
+       {:error, "duplicate activity"}
+     else
+       ActivityPub.create(to, actor, context, object, %{}, date, false)
+     end
+   end
+   def find_make_or_update_user(doc) do
+     uri = string_from_xpath("//author/uri[1]", doc)
+     with {:ok, user} <- find_or_make_user(uri) do
+       avatar = make_avatar_object(doc)
 -      if user.avatar != avatar do
++      if !user.local && user.avatar != avatar do
+         change = Ecto.Changeset.change(user, %{avatar: avatar})
+         Repo.update(change)
+       else
+         {:ok, user}
+       end
+     end
+   end
+   def find_or_make_user(uri) do
+     query = from user in User,
+       where: user.ap_id == ^uri
+     user = Repo.one(query)
+     if is_nil(user) do
+       make_user(uri)
+     else
+       {:ok, user}
+     end
+   end
+   def make_user(uri) do
+     with {:ok, info} <- gather_user_info(uri) do
+       data = %{
+         local: false,
+         name: info["name"],
+         nickname: info["nickname"] <> "@" <> info["host"],
+         ap_id: info["uri"],
+         info: info,
+         avatar: info["avatar"]
+       }
+       # TODO: Make remote user changeset
+       # SHould enforce fqn nickname
+       Repo.insert(Ecto.Changeset.change(%User{}, data))
+     end
+   end
+   # TODO: Just takes the first one for now.
+   def make_avatar_object(author_doc) do
+     href = string_from_xpath("//author[1]/link[@rel=\"avatar\"]/@href", author_doc)
+     type = string_from_xpath("//author[1]/link[@rel=\"avatar\"]/@type", author_doc)
+     if href do
+       %{
+         "type" => "Image",
+         "url" =>
+           [%{
+               "type" => "Link",
+               "mediaType" => type,
+               "href" => href
+            }]
+       }
+     else
+       nil
+     end
+   end
+   def gather_user_info(username) do
+     with {:ok, webfinger_data} <- WebFinger.finger(username),
+          {:ok, feed_data} <- Websub.gather_feed_data(webfinger_data["topic"]) do
+       {:ok, Map.merge(webfinger_data, feed_data) |> Map.put("fqn", username)}
+     else e ->
+       Logger.debug(fn -> "Couldn't gather info for #{username}" end)
+       {:error, e}
+     end
+   end
+   # Regex-based 'parsing' so we don't have to pull in a full html parser
+   # It's a hack anyway. Maybe revisit this in the future
+   @mastodon_regex ~r/<link href='(.*)' rel='alternate' type='application\/atom\+xml'>/
+   @gs_regex ~r/<link title=.* href="(.*)" type="application\/atom\+xml" rel="alternate">/
+   @gs_classic_regex ~r/<link rel="alternate" href="(.*)" type="application\/atom\+xml" title=.*>/
+   def get_atom_url(body) do
+     cond do
+       Regex.match?(@mastodon_regex, body) ->
+         [[_, match]] = Regex.scan(@mastodon_regex, body)
+         {:ok, match}
+       Regex.match?(@gs_regex, body) ->
+         [[_, match]] = Regex.scan(@gs_regex, body)
+         {:ok, match}
+       Regex.match?(@gs_classic_regex, body) ->
+         [[_, match]] = Regex.scan(@gs_classic_regex, body)
+         {:ok, match}
+       true ->
+         Logger.debug(fn -> "Couldn't find atom link in #{inspect(body)}" end)
+         {:error, "Couldn't find the atom link"}
+     end
+   end
+   def fetch_activity_from_html_url(url) do
+     with {:ok, %{body: body}} <- @httpoison.get(url, [], follow_redirect: true),
+          {:ok, atom_url} <- get_atom_url(body),
+          {:ok, %{status_code: code, body: body}} when code in 200..299 <- @httpoison.get(atom_url, [], follow_redirect: true) do
+       handle_incoming(body)
+     end
    end
  end
index f2bf93abb26c5ba84879bc653aa36144dd6a7b1d,3fef8eec86653f1a7cc9641c08389b225f905419..affd435771e28d4f792d933b6041cd1bdbe58994
@@@ -1,17 -1,18 +1,19 @@@
  defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
    use Pleroma.Web.TwitterAPI.Representers.BaseRepresenter
    alias Pleroma.Web.TwitterAPI.Representers.{UserRepresenter, ObjectRepresenter}
-   alias Pleroma.Activity
+   alias Pleroma.{Activity, User}
+   alias Calendar.Strftime
+   alias Pleroma.Web.TwitterAPI.TwitterAPI
++  alias Pleroma.Wi
  
    defp user_by_ap_id(user_list, ap_id) do
      Enum.find(user_list, fn (%{ap_id: user_id}) -> ap_id == user_id end)
    end
  
-   def to_map(%Activity{data: %{"type" => "Announce", "actor" => actor}} = activity, %{users: users, announced_activity: announced_activity} = opts) do
+   def to_map(%Activity{data: %{"type" => "Announce", "actor" => actor, "published" => created_at}} = activity,
+              %{users: users, announced_activity: announced_activity} = opts) do
      user = user_by_ap_id(users, actor)
-     created_at = get_in(activity.data, ["published"])
-     |> date_to_asctime
+     created_at = created_at |> date_to_asctime
  
      text = "#{user.nickname} retweeted a status."
  
        "is_post_verb" => false,
        "uri" => "tag:#{activity.data["id"]}:objectType=note",
        "created_at" => created_at,
-       "retweeted_status" => retweeted_status
+       "retweeted_status" => retweeted_status,
+       "statusnet_conversation_id" => conversation_id(announced_activity)
      }
    end
  
-   def to_map(%Activity{data: %{"type" => "Like"}} = activity, %{user: user, liked_activity: liked_activity} = opts) do
-     created_at = get_in(activity.data, ["published"])
-     |> date_to_asctime
+   def to_map(%Activity{data: %{"type" => "Like", "published" => created_at}} = activity,
+              %{user: user, liked_activity: liked_activity} = opts) do
+     created_at = created_at |> date_to_asctime
  
      text = "#{user.nickname} favorited a status."
  
      %{
        "id" => activity.id,
        "user" => UserRepresenter.to_map(user, opts),
-       "statusnet_html" => text,  # TODO: add summary
+       "statusnet_html" => text,
        "text" => text,
        "is_local" => true,
        "is_post_verb" => false,
      }
    end
  
-   def to_map(%Activity{data: %{"type" => "Follow"}} = activity, %{user: user} = opts) do
-     created_at = get_in(activity.data, ["published"])
-     |> date_to_asctime
+   def to_map(%Activity{data: %{"type" => "Follow", "published" => created_at, "object" => followed_id}} = activity, %{user: user} = opts) do
+     created_at = created_at |> date_to_asctime
  
+     followed = User.get_cached_by_ap_id(followed_id)
+     text = "#{user.nickname} started following #{followed.nickname}"
      %{
        "id" => activity.id,
        "user" => UserRepresenter.to_map(user, opts),
        "attentions" => [],
-       "statusnet_html" => "",  # TODO: add summary
-       "text" => "",
+       "statusnet_html" => text,
+       "text" => text,
        "is_local" => true,
        "is_post_verb" => false,
        "created_at" => created_at,
      }
    end
  
-   def to_map(%Activity{} = activity, %{user: user} = opts) do
-     content = get_in(activity.data, ["object", "content"])
-     created_at = get_in(activity.data, ["object", "published"])
-     |> date_to_asctime
-     like_count = get_in(activity.data, ["object", "like_count"]) || 0
-     announcement_count = get_in(activity.data, ["object", "announcement_count"]) || 0
-     favorited = opts[:for] && opts[:for].ap_id in (activity.data["object"]["likes"] || [])
-     repeated = opts[:for] && opts[:for].ap_id in (activity.data["object"]["announcements"] || [])
+   def to_map(%Activity{data: %{"object" => %{"content" => content} = object}} = activity, %{user: user} = opts) do
+     created_at = object["published"] |> date_to_asctime
+     like_count = object["like_count"] || 0
+     announcement_count = object["announcement_count"] || 0
+     favorited = opts[:for] && opts[:for].ap_id in (object["likes"] || [])
+     repeated = opts[:for] && opts[:for].ap_id in (object["announcements"] || [])
  
      mentions = opts[:mentioned] || []
  
@@@ -82,6 -83,8 +84,8 @@@
      |> Enum.filter(&(&1))
      |> Enum.map(fn (user) -> UserRepresenter.to_map(user, opts) end)
  
+     conversation_id = conversation_id(activity)
      %{
        "id" => activity.id,
        "user" => UserRepresenter.to_map(user, opts),
        "is_local" => true,
        "is_post_verb" => true,
        "created_at" => created_at,
-       "in_reply_to_status_id" => activity.data["object"]["inReplyToStatusId"],
-       "statusnet_conversation_id" => activity.data["object"]["statusnetConversationId"],
-       "attachments" => (activity.data["object"]["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts),
+       "in_reply_to_status_id" => object["inReplyToStatusId"],
+       "statusnet_conversation_id" => conversation_id,
+       "attachments" => (object["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts),
        "attentions" => attentions,
        "fave_num" => like_count,
        "repeat_num" => announcement_count,
-       "favorited" => !!favorited,
-       "repeated" => !!repeated,
+       "favorited" => to_boolean(favorited),
+       "repeated" => to_boolean(repeated),
      }
    end
  
+   def conversation_id(activity) do
+     with context when not is_nil(context) <- activity.data["context"] do
+       TwitterAPI.context_to_conversation_id(context)
+     else _e -> nil
+     end
+   end
    defp date_to_asctime(date) do
      with {:ok, date, _offset} <- date |> DateTime.from_iso8601 do
-       Calendar.Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
+       Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
      else _e ->
        ""
      end
    end
+   defp to_boolean(false) do
+     false
+   end
+   defp to_boolean(nil) do
+     false
+   end
+   defp to_boolean(_) do
+     true
+   end
  end
index 8e2cd98ca8c19e4d1b08c9e2defb032f4c956983,3921c0d74e75adbb589695f2d8c81887b47896ea..793a55250ce7ca3af8a571c2a5d84706df8cc8b3
@@@ -5,34 -5,77 +5,77 @@@ defmodule Pleroma.Web.TwitterAPI.Twitte
  
    import Ecto.Query
  
-   def create_status(user = %User{}, data = %{}) do
-     attachments = Enum.map(data["media_ids"] || [], fn (media_id) ->
-       Repo.get(Object, media_id).data
-     end)
+   def to_for_user_and_mentions(user, mentions) do
+     default_to = [
+       User.ap_followers(user),
+       "https://www.w3.org/ns/activitystreams#Public"
+     ]
  
-     context = ActivityPub.generate_context_id
+     default_to ++ Enum.map(mentions, fn ({_, %{ap_id: ap_id}}) -> ap_id end)
+   end
  
-     content = HtmlSanitizeEx.strip_tags(data["status"])
+   def format_input(text, mentions) do
+     HtmlSanitizeEx.strip_tags(text)
      |> String.replace("\n", "<br>")
+     |> add_user_links(mentions)
+   end
  
-     mentions = parse_mentions(content)
+   def attachments_from_ids(ids) do
+     Enum.map(ids || [], fn (media_id) ->
+       Repo.get(Object, media_id).data
+     end)
+   end
  
-     default_to = [
-       User.ap_followers(user),
-       "https://www.w3.org/ns/activitystreams#Public"
-     ]
+   def get_replied_to_activity(id) when not is_nil(id) do
+     Repo.get(Activity, id)
+   end
  
-     to = default_to ++ Enum.map(mentions, fn ({_, %{ap_id: ap_id}}) -> ap_id end)
+   def get_replied_to_activity(_), do: nil
  
-     content_html = add_user_links(content, mentions)
+   def add_attachments(text, attachments) do
+     attachment_text = Enum.map(attachments, fn
+       (%{"url" => [%{"href" => href} | _]}) ->
+         "<a href='#{href}'>#{href}</a>"
+       _ -> ""
+     end)
+     Enum.join([text | attachment_text], "<br>")
+     end
+   def create_status(%User{} = user, %{"status" => status} = data) do
+     attachments = attachments_from_ids(data["media_ids"])
+     context = ActivityPub.generate_context_id
+     mentions = parse_mentions(status)
+     content_html = status
+     |> format_input(mentions)
+     |> add_attachments(attachments)
  
+     to = to_for_user_and_mentions(user, mentions)
      date = make_date()
  
-     activity = %{
-       "type" => "Create",
-       "to" => to,
-       "actor" => user.ap_id,
-       "object" => %{
+     inReplyTo = get_replied_to_activity(data["in_reply_to_status_id"])
+     # Wire up reply info.
+     [to, context, object, additional] =
+       if inReplyTo do
+       context = inReplyTo.data["context"]
+       to = to ++ [inReplyTo.data["actor"]]
+       object = %{
+         "type" => "Note",
+         "to" => to,
+         "content" => content_html,
+         "published" => date,
+         "context" => context,
+         "attachment" => attachments,
+         "actor" => user.ap_id,
+         "inReplyTo" => inReplyTo.data["object"]["id"],
+         "inReplyToStatusId" => inReplyTo.id,
+       }
+       additional = %{}
+       [to, context, object, additional]
+       else
+       object = %{
          "type" => "Note",
          "to" => to,
          "content" => content_html,
          "context" => context,
          "attachment" => attachments,
          "actor" => user.ap_id
-       },
-       "published" => date,
-       "context" => context
-     }
-     # Wire up reply info.
-     activity = with inReplyToId when not is_nil(inReplyToId) <- data["in_reply_to_status_id"],
-                     inReplyTo <- Repo.get(Activity, inReplyToId),
-                     context <- inReplyTo.data["context"]
-                do
-                to = activity["to"] ++ [inReplyTo.data["actor"]]
-                activity
-                |> put_in(["to"], to)
-                |> put_in(["context"], context)
-                |> put_in(["object", "context"], context)
-                |> put_in(["object", "inReplyTo"], inReplyTo.data["object"]["id"])
-                |> put_in(["object", "inReplyToStatusId"], inReplyToId)
-                |> put_in(["statusnetConversationId"], inReplyTo.data["statusnetConversationId"])
-                |> put_in(["object", "statusnetConversationId"], inReplyTo.data["statusnetConversationId"])
-                else _e ->
-                  activity
-                end
-     with {:ok, activity} <- ActivityPub.insert(activity) do
-       {:ok, activity} = add_conversation_id(activity)
-       Pleroma.Web.Websub.publish(Pleroma.Web.OStatus.feed_path(user), user, activity)
-       {:ok, activity}
+       }
+       [to, context, object, %{}]
      end
+     ActivityPub.create(to, user, context, object, additional, data)
    end
  
    def fetch_friend_statuses(user, opts \\ %{}) do
    end
  
    def fetch_public_statuses(user, opts \\ %{}) do
+     opts = Map.put(opts, "local_only", true)
+     ActivityPub.fetch_public_activities(opts)
+     |> activities_to_statuses(%{for: user})
+   end
+   def fetch_public_and_external_statuses(user, opts \\ %{}) do
      ActivityPub.fetch_public_activities(opts)
      |> activities_to_statuses(%{for: user})
    end
    end
  
    def fetch_conversation(user, id) do
-     query = from activity in Activity,
-       where: fragment("? @> ?", activity.data, ^%{ statusnetConversationId: id}),
-       limit: 1
-     with %Activity{} = activity <- Repo.one(query),
-          context <- activity.data["context"],
+     with context when is_binary(context) <- conversation_id_to_context(id),
           activities <- ActivityPub.fetch_activities_for_context(context),
           statuses <- activities |> activities_to_statuses(%{for: user})
      do
        statuses
-     else e ->
-       IO.inspect(e)
+     else _e ->
        []
      end
    end
    end
  
    def follow(%User{} = follower, params) do
-     with { :ok, %User{} = followed } <- get_user(params),
-          { :ok, follower } <- User.follow(follower, followed),
-          { :ok, activity } <- ActivityPub.insert(%{
-            "type" => "Follow",
-            "actor" => follower.ap_id,
-            "object" => followed.ap_id,
-            "published" => make_date()
-          })
+     with {:ok, %User{} = followed} <- get_user(params),
+          {:ok, follower} <- User.follow(follower, followed),
 -         {:ok, activity} <- ActivityPub.insert(%{
 -           "type" => "Follow",
 -           "actor" => follower.ap_id,
 -           "to" => [followed.ap_id],
 -           "object" => followed.ap_id,
 -           "published" => make_date()
 -         })
++         {:ok, activity} <- ActivityPub.follow(follower, followed)
      do
-       { :ok, follower, followed, activity }
 -      # TODO move all this to ActivityPub
 -      Pleroma.Web.Federator.enqueue(:publish, activity)
+       {:ok, follower, followed, activity}
      else
        err -> err
      end
    end
  
- def unfollow(%User{} = follower, params) do
  def unfollow(%User{} = follower, params) do
 -    with {:ok, %User{} = unfollowed} <- get_user(params),
 -         {:ok, follower} <- User.unfollow(follower, unfollowed)
 +    with { :ok, %User{} = unfollowed } <- get_user(params),
 +         { :ok, follower, follow_activity } <- User.unfollow(follower, unfollowed),
 +         { :ok, _activity } <- ActivityPub.insert(%{
 +           "type" => "Undo",
 +           "actor" => follower.ap_id,
-            "object" => follow_activity, # get latest Follow for these users
++           "object" => follow_activity.data["id"], # get latest Follow for these users
 +           "published" => make_date()
 +         })
      do
 -      {:ok, follower, unfollowed}
 +      { :ok, follower, unfollowed }
      else
        err -> err
      end
      Enum.reduce(mentions, text, fn ({match, %User{ap_id: ap_id}}, text) -> String.replace(text, match, "<a href='#{ap_id}'>#{match}</a>") end)
    end
  
-   defp add_conversation_id(activity) do
-     if is_integer(activity.data["statusnetConversationId"]) do
-       {:ok, activity}
-     else
-       data = activity.data
-       |> put_in(["object", "statusnetConversationId"], activity.id)
-       |> put_in(["statusnetConversationId"], activity.id)
-       object = Object.get_by_ap_id(activity.data["object"]["id"])
-       changeset = Ecto.Changeset.change(object, data: data["object"])
-       Repo.update(changeset)
-       changeset = Ecto.Changeset.change(activity, data: data)
-       Repo.update(changeset)
-     end
-   end
    def register_user(params) do
      params = %{
        nickname: params["nickname"],
        {:error, changeset} ->
          errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
        |> Poison.encode!
-         {:error, %{error: errors}}
+       {:error, %{error: errors}}
      end
    end
  
    def get_user(user \\ nil, params) do
      case params do
-       %{ "user_id" => user_id } ->
+       %{"user_id" => user_id} ->
          case target = Repo.get(User, user_id) do
            nil ->
              {:error, "No user with such user_id"}
            _ ->
              {:ok, target}
          end
-       %{ "screen_name" => nickname } ->
+       %{"screen_name" => nickname} ->
          case target = Repo.get_by(User, nickname: nickname) do
            nil ->
              {:error, "No user with such screen_name"}
    defp make_date do
      DateTime.utc_now() |> DateTime.to_iso8601
    end
+   def context_to_conversation_id(context) do
+     with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
+       id
+     else _e ->
+       changeset = Object.context_mapping(context)
+       {:ok, %{id: id}} = Repo.insert(changeset)
+       id
+     end
+   end
+   def conversation_id_to_context(id) do
+     with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
+       context
+     else _e ->
+       {:error, "No such conversation"}
+     end
+   end
  end
diff --combined test/support/factory.ex
index 1662d4cd60f4699770fec4940ca58af7959010b3,ac276567a0f6a1902514d393affd772049f3970a..5c110c72d406ed0d0232c4f061b9bef19471c3d2
@@@ -24,7 -24,8 +24,8 @@@ defmodule Pleroma.Factory d
        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
        "published_at" => DateTime.utc_now() |> DateTime.to_iso8601,
        "likes" => [],
-       "like_count" => 0
+       "like_count" => 0,
+       "context" => "2hu"
      }
  
      %Pleroma.Object{
@@@ -40,7 -41,8 +41,8 @@@
        "actor" => note.data["actor"],
        "to" => note.data["to"],
        "object" => note.data,
-       "published_at" => DateTime.utc_now() |> DateTime.to_iso8601
+       "published_at" => DateTime.utc_now() |> DateTime.to_iso8601,
+       "context" => note.data["context"]
      }
  
      %Pleroma.Activity{
      }
    end
  
 +  def follow_activity_factory do
 +    follower = insert(:user)
 +    followed = insert(:user)
 +
 +    data = %{
 +      "id" => Pleroma.Web.ActivityPub.ActivityPub.generate_activity_id,
 +      "actor" => follower.ap_id,
 +      "type" => "Follow",
 +      "object" => followed.ap_id,
 +      "published_at" => DateTime.utc_now() |> DateTime.to_iso8601
 +    }
 +
 +    %Pleroma.Activity{
 +      data: data
 +    }
 +  end
 +
    def websub_subscription_factory do
      %Pleroma.Web.Websub.WebsubServerSubscription{
        topic: "http://example.org",
        state: "requested"
      }
    end
+   def websub_client_subscription_factory do
+     %Pleroma.Web.Websub.WebsubClientSubscription{
+       topic: "http://example.org",
+       secret: "here's a secret",
+       valid_until: nil,
+       state: "requested",
+       subscribers: []
+     }
+   end
  end
diff --combined test/user_test.exs
index 991ec09725892651baa23be8d79e06fdaaa5d0aa,417282ff9d32df9018f7329ecd7ac0bf164156b0..e6de4a5cd731076d05c4fbff17f9c6c6050b44f5
@@@ -1,9 -1,12 +1,12 @@@
  defmodule Pleroma.UserTest do
    alias Pleroma.Builders.UserBuilder
-   alias Pleroma.User
+   alias Pleroma.{User, Repo}
+   alias Pleroma.Web.OStatus
+   alias Pleroma.Web.Websub.WebsubClientSubscription
    use Pleroma.DataCase
  
    import Pleroma.Factory
+   import Ecto.Query
  
    test "ap_id returns the activity pub id for the user" do
      host =
@@@ -13,7 -16,7 +16,7 @@@
  
      user = UserBuilder.build
  
-     expected_ap_id = "https://#{host}/users/#{user.nickname}"
+     expected_ap_id = "#{Pleroma.Web.base_url}/users/#{user.nickname}"
  
      assert expected_ap_id == User.ap_id(user)
    end
      user = insert(:user)
      followed = insert(:user)
  
-     {:ok, user } = User.follow(user, followed)
+     {:ok, user} = User.follow(user, followed)
  
      user = Repo.get(User, user.id)
  
      assert user.following == [User.ap_followers(followed)]
    end
  
+   test "following a remote user will ensure a websub subscription is present" do
+     user = insert(:user)
+     {:ok, followed} = OStatus.make_user("shp@social.heldscal.la")
+     assert followed.local == false
+     {:ok, user} = User.follow(user, followed)
+     assert user.following == [User.ap_followers(followed)]
+     query = from w in WebsubClientSubscription,
+     where: w.topic == ^followed.info["topic"]
+     websub = Repo.one(query)
+     assert websub
+   end
    test "unfollow takes a user and another user" do
      followed = insert(:user)
      user = insert(:user, %{following: [User.ap_followers(followed)]})
  
 -    {:ok, user } = User.unfollow(user, followed)
 +    {:ok, user, _activity } = User.unfollow(user, followed)
  
      user = Repo.get(User, user.id)
  
        assert changeset.changes[:following] == [User.ap_followers(%User{nickname: @full_user_data.nickname})]
      end
    end
+   describe "fetching a user from nickname or trying to build one" do
+     test "gets an existing user" do
+       user = insert(:user)
+       fetched_user = User.get_or_fetch_by_nickname(user.nickname)
+       assert user == fetched_user
+     end
+     test "fetches an external user via ostatus if no user exists" do
+       fetched_user = User.get_or_fetch_by_nickname("shp@social.heldscal.la")
+       assert fetched_user.nickname == "shp@social.heldscal.la"
+     end
+     test "returns nil if no user could be fetched" do
+       fetched_user = User.get_or_fetch_by_nickname("nonexistant@social.heldscal.la")
+       assert fetched_user == nil
+     end
+     test "returns nil for nonexistant local user" do
+       fetched_user = User.get_or_fetch_by_nickname("nonexistant")
+       assert fetched_user == nil
+     end
+   end
+   test "returns an ap_id for a user" do
+     user = insert(:user)
+     assert User.ap_id(user) == Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, user.nickname)
+   end
+   test "returns an ap_followers link for a user" do
+     user = insert(:user)
+     assert User.ap_followers(user) == Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, user.nickname) <> "/followers"
+   end
  end
index bf9090d2f3dbe75b545e6f6b18f8bf337f2f0a76,dfa73b775bde52c338a8c92e1727b9aa81f32945..a9a6e13647cca2ac78fa680bbb740f044bf98a2a
@@@ -40,6 -40,13 +40,13 @@@ defmodule Pleroma.Web.ActivityPub.Activ
      end
    end
  
+   describe "create activities" do
+     test "removes doubled 'to' recipients" do
+       {:ok, activity} = ActivityPub.create(["user1", "user1", "user2"], %User{ap_id: "1"}, "", %{})
+       assert activity.data["to"] == ["user1", "user2"]
+     end
+   end
    describe "fetch activities for recipients" do
      test "retrieve the activities for certain recipients" do
        {:ok, activity_one} = ActivityBuilder.insert(%{"to" => ["someone"]})
        assert like_activity.data["type"] == "Like"
        assert like_activity.data["object"] == object.data["id"]
        assert like_activity.data["to"] == [User.ap_followers(user), note_activity.data["actor"]]
+       assert like_activity.data["context"] == object.data["context"]
        assert object.data["like_count"] == 1
        assert object.data["likes"] == [user.ap_id]
  
        assert announce_activity.data["to"] == [User.ap_followers(user), note_activity.data["actor"]]
        assert announce_activity.data["object"] == object.data["id"]
        assert announce_activity.data["actor"] == user.ap_id
+       assert announce_activity.data["context"] == object.data["context"]
      end
    end
  
      end
    end
  
 +  describe "fetch the latest Follow" do
 +    test "fetches the latest Follow activity" do
 +      %Activity{data: %{"type" => "Follow"}} = activity = insert(:follow_activity)
 +      follower = Repo.get_by(User, ap_id: activity.data["actor"])
 +      followed = Repo.get_by(User, ap_id: activity.data["object"])
 +
 +      assert activity == ActivityPub.fetch_latest_follow(follower, followed)
 +    end
 +  end
 +
++  describe "following / unfollowing" do
++    test "creates a follow activity" do
++      follower = insert(:user)
++      followed = insert(:user)
++
++      {:ok, activity} = ActivityPub.follow(follower, followed)
++      assert activity.data["type"] == "Follow"
++      assert activity.data["actor"] == follower.ap_id
++      assert activity.data["object"] == followed.ap_id
++    end
++
++    test "creates an undo activity for the last follow" do
++      follower = insert(:user)
++      followed = insert(:user)
++
++      {:ok, follow_activity} = ActivityPub.follow(follower, followed)
++      {:ok, activity} = ActivityPub.unfollow(follower, followed)
++
++      assert activity.data["type"] == "Undo"
++      assert activity.data["actor"] == follower.ap_id
++      assert activity.data["object"] == follow_activity.data["id"]
++    end
++  end
++
    def data_uri do
      "data:image/jpeg;base64,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"
    end
index 61df41a1d56772c014e66c2369130463cba6c6e2,af936b57c59f8e5a66406b45976672dc684e4beb..969b2a854cf193f5880b83b189ad5240045f2662
@@@ -2,7 -2,8 +2,8 @@@ defmodule Pleroma.Web.OStatus.ActivityR
    use Pleroma.DataCase
  
    alias Pleroma.Web.OStatus.ActivityRepresenter
-   alias Pleroma.{User, Activity}
+   alias Pleroma.{User, Activity, Object}
+   alias Pleroma.Web.ActivityPub.ActivityPub
  
    import Pleroma.Factory
  
      <content type="html">#{note_activity.data["object"]["content"]}</content>
      <published>#{inserted_at}</published>
      <updated>#{updated_at}</updated>
+     <ostatus:conversation>#{note_activity.data["context"]}</ostatus:conversation>
+     <link href="#{note_activity.data["context"]}" rel="ostatus:conversation" />
+     <link type="application/atom+xml" href="#{note_activity.data["object"]["id"]}" rel="self" />
+     <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public"/>
      """
  
      tuple = ActivityRepresenter.to_simple_form(note_activity, user)
      assert clean(res) == clean(expected)
    end
  
+   test "a reply note" do
+     note = insert(:note_activity)
+     answer = insert(:note_activity)
+     object = answer.data["object"]
+     object = Map.put(object, "inReplyTo", note.data["object"]["id"])
+     data = %{answer.data | "object" => object}
+     answer = %{answer | data: data}
+     updated_at = answer.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = answer.inserted_at
+     |> NaiveDateTime.to_iso8601
+     user = User.get_cached_by_ap_id(answer.data["actor"])
+     expected = """
+     <activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type>
+     <activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb>
+     <id>#{answer.data["object"]["id"]}</id>
+     <title>New note by #{user.nickname}</title>
+     <content type="html">#{answer.data["object"]["content"]}</content>
+     <published>#{inserted_at}</published>
+     <updated>#{updated_at}</updated>
+     <ostatus:conversation>#{answer.data["context"]}</ostatus:conversation>
+     <link href="#{answer.data["context"]}" rel="ostatus:conversation" />
+     <link type="application/atom+xml" href="#{answer.data["object"]["id"]}" rel="self" />
+     <thr:in-reply-to ref="#{note.data["object"]["id"]}" />
+     <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public"/>
+     """
+     tuple = ActivityRepresenter.to_simple_form(answer, user)
+     res = :xmerl.export_simple_content(tuple, :xmerl_xml) |> IO.iodata_to_binary
+     assert clean(res) == clean(expected)
+   end
+   test "an announce activity" do
+     note = insert(:note_activity)
+     user = insert(:user)
+     object = Object.get_cached_by_ap_id(note.data["object"]["id"])
+     {:ok, announce, object} = ActivityPub.announce(user, object)
+     announce = Repo.get(Activity, announce.id)
+     note_user = User.get_cached_by_ap_id(note.data["actor"])
+     note = Repo.get(Activity, note.id)
+     note_xml = ActivityRepresenter.to_simple_form(note, note_user, true)
+     |> :xmerl.export_simple_content(:xmerl_xml)
+     |> to_string
+     updated_at = announce.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = announce.inserted_at
+     |> NaiveDateTime.to_iso8601
+     expected = """
+     <activity:object-type>http://activitystrea.ms/schema/1.0/activity</activity:object-type>
+     <activity:verb>http://activitystrea.ms/schema/1.0/share</activity:verb>
+     <id>#{announce.data["id"]}</id>
+     <title>#{user.nickname} repeated a notice</title>
+     <content type="html">RT #{note.data["object"]["content"]}</content>
+     <published>#{inserted_at}</published>
+     <updated>#{updated_at}</updated>
+     <ostatus:conversation>#{announce.data["context"]}</ostatus:conversation>
+     <link href="#{announce.data["context"]}" rel="ostatus:conversation" />
+     <link rel="self" type="application/atom+xml" href="#{announce.data["id"]}"/>
+     <activity:object>
+       #{note_xml}
+     </activity:object>
+     <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="#{note.data["actor"]}"/>
+     """
+     announce_xml = ActivityRepresenter.to_simple_form(announce, user)
+     |> :xmerl.export_simple_content(:xmerl_xml)
+     |> to_string
+     assert clean(expected) == clean(announce_xml)
+   end
+   test "a like activity" do
+     note = insert(:note)
+     user = insert(:user)
+     {:ok, like, _note} = ActivityPub.like(user, note)
+     # TODO: Are these the correct dates?
+     updated_at = like.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = like.inserted_at
+     |> NaiveDateTime.to_iso8601
+     tuple = ActivityRepresenter.to_simple_form(like, user)
+     refute is_nil(tuple)
+     res = :xmerl.export_simple_content(tuple, :xmerl_xml) |> IO.iodata_to_binary
+     expected = """
+     <activity:verb>http://activitystrea.ms/schema/1.0/favorite</activity:verb>
+     <id>#{like.data["id"]}</id>
+     <title>New favorite by #{user.nickname}</title>
+     <content type="html">#{user.nickname} favorited something</content>
+     <published>#{inserted_at}</published>
+     <updated>#{updated_at}</updated>
+     <activity:object>
+       <activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type>
+       <id>#{note.data["id"]}</id>
+     </activity:object>
+     <ostatus:conversation>#{like.data["context"]}</ostatus:conversation>
+     <link href="#{like.data["context"]}" rel="ostatus:conversation" />
+     <link rel="self" type="application/atom+xml" href="#{like.data["id"]}"/>
+     <thr:in-reply-to ref="#{note.data["id"]}" />
+     <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="#{note.data["actor"]}"/>
+     """
+     assert clean(res) == clean(expected)
+   end
+   test "a follow activity" do
+     follower = insert(:user)
+     followed = insert(:user)
+     {:ok, activity} = ActivityPub.insert(%{
+           "type" => "Follow",
+           "actor" => follower.ap_id,
+           "object" => followed.ap_id,
+           "to" => [followed.ap_id]
+     })
+     # TODO: Are these the correct dates?
+     updated_at = activity.updated_at
+     |> NaiveDateTime.to_iso8601
+     inserted_at = activity.inserted_at
+     |> NaiveDateTime.to_iso8601
+     tuple = ActivityRepresenter.to_simple_form(activity, follower)
+     refute is_nil(tuple)
+     res = :xmerl.export_simple_content(tuple, :xmerl_xml) |> IO.iodata_to_binary
+     expected = """
+     <activity:object-type>http://activitystrea.ms/schema/1.0/activity</activity:object-type>
+     <activity:verb>http://activitystrea.ms/schema/1.0/follow</activity:verb>
+     <id>#{activity.data["id"]}</id>
+     <title>#{follower.nickname} started following #{activity.data["object"]}</title>
+     <content type="html"> #{follower.nickname} started following #{activity.data["object"]}</content>
+     <published>#{inserted_at}</published>
+     <updated>#{updated_at}</updated>
+     <activity:object>
+       <activity:object-type>http://activitystrea.ms/schema/1.0/person</activity:object-type>
+       <id>#{activity.data["object"]}</id>
+       <uri>#{activity.data["object"]}</uri>
+     </activity:object>
+     <link rel="self" type="application/atom+xml" href="#{activity.data["id"]}"/>
+     <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="#{activity.data["object"]}"/>
+     """
+     assert clean(res) == clean(expected)
+   end
++  test "an unfollow activity" do
++    follower = insert(:user)
++    followed = insert(:user)
++    {:ok, _activity} = ActivityPub.follow(follower, followed)
++    {:ok, activity} = ActivityPub.unfollow(follower, followed)
++
++    # TODO: Are these the correct dates?
++    updated_at = activity.updated_at
++    |> NaiveDateTime.to_iso8601
++    inserted_at = activity.inserted_at
++    |> NaiveDateTime.to_iso8601
++
++    tuple = ActivityRepresenter.to_simple_form(activity, follower)
++
++    refute is_nil(tuple)
++
++    res = :xmerl.export_simple_content(tuple, :xmerl_xml) |> IO.iodata_to_binary
++
++    expected = """
++    <activity:object-type>http://activitystrea.ms/schema/1.0/activity</activity:object-type>
++    <activity:verb>http://activitystrea.ms/schema/1.0/unfollow</activity:verb>
++    <id>#{activity.data["id"]}</id>
++    <title>#{follower.nickname} stopped following #{followed.ap_id}</title>
++    <content type="html"> #{follower.nickname} stopped following #{followed.ap_id}</content>
++    <published>#{inserted_at}</published>
++    <updated>#{updated_at}</updated>
++    <activity:object>
++      <activity:object-type>http://activitystrea.ms/schema/1.0/person</activity:object-type>
++      <id>#{followed.ap_id}</id>
++      <uri>#{followed.ap_id}</uri>
++    </activity:object>
++    <link rel="self" type="application/atom+xml" href="#{activity.data["id"]}"/>
++    <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="#{followed.ap_id}"/>
++    """
++
++    assert clean(res) == clean(expected)
++  end
++
    test "an unknown activity" do
      tuple = ActivityRepresenter.to_simple_form(%Activity{}, nil)
      assert is_nil(tuple)
index 6c249be7d619a0807437b3ce5e795f080cc6e46f,05cd084b4fb9cb2c70837bb403a56a0a089f77fc..f8afbaee52b3d4a6995c8aeebb06bb0c6c6e76e9
@@@ -84,12 -84,13 +84,13 @@@ defmodule Pleroma.Web.TwitterAPI.Contro
    describe "GET /statusnet/conversation/:id.json" do
      test "returns the statuses in the conversation", %{conn: conn} do
        {:ok, _user} = UserBuilder.insert
-       {:ok, _activity} = ActivityBuilder.insert(%{"statusnetConversationId" => 1, "context" => "2hu"})
-       {:ok, _activity_two} = ActivityBuilder.insert(%{"statusnetConversationId" => 1,"context" => "2hu"})
+       {:ok, _activity} = ActivityBuilder.insert(%{"context" => "2hu"})
+       {:ok, _activity_two} = ActivityBuilder.insert(%{"context" => "2hu"})
        {:ok, _activity_three} = ActivityBuilder.insert(%{"context" => "3hu"})
  
+       {:ok, object} = Object.context_mapping("2hu") |> Repo.insert
        conn = conn
-       |> get("/api/statusnet/conversation/1.json")
+       |> get("/api/statusnet/conversation/#{object.id}.json")
  
        response = json_response(conn, 200)
  
  
        {:ok, current_user} = User.follow(current_user, followed)
        assert current_user.following == [User.ap_followers(followed)]
++      ActivityPub.follow(current_user, followed)
  
        conn = conn
        |> with_credentials(current_user.nickname, "test")
      header_content = "Basic " <> Base.encode64("#{username}:#{password}")
      put_req_header(conn, "authorization", header_content)
    end
--
--  setup do
--    Supervisor.terminate_child(Pleroma.Supervisor, ConCache)
--    Supervisor.restart_child(Pleroma.Supervisor, ConCache)
--    :ok
--  end
  end
index 590428423b2e3a23e97476c3cfba61d572be328b,a92440f32feda2ac00b18f3021744d1994894f77..9a7dc48dab42e472cd90e1413f21b8dd8509eb37
@@@ -33,19 -33,18 +33,18 @@@ defmodule Pleroma.Web.TwitterAPI.Twitte
  
      { :ok, activity = %Activity{} } = TwitterAPI.create_status(user, input)
  
-     assert get_in(activity.data, ["object", "content"]) == "Hello again, <a href='shp'>@shp</a>.<br>This is on another line."
+     assert get_in(activity.data, ["object", "content"]) == "Hello again, <a href='shp'>@shp</a>.<br>This is on another line.<br><a href='http://example.org/image.jpg'>http://example.org/image.jpg</a>"
      assert get_in(activity.data, ["object", "type"]) == "Note"
      assert get_in(activity.data, ["object", "actor"]) == user.ap_id
      assert get_in(activity.data, ["actor"]) == user.ap_id
      assert Enum.member?(get_in(activity.data, ["to"]), User.ap_followers(user))
      assert Enum.member?(get_in(activity.data, ["to"]), "https://www.w3.org/ns/activitystreams#Public")
      assert Enum.member?(get_in(activity.data, ["to"]), "shp")
+     assert activity.local == true
  
-     # Add a context + 'statusnet_conversation_id'
+     # Add a context
      assert is_binary(get_in(activity.data, ["context"]))
      assert is_binary(get_in(activity.data, ["object", "context"]))
-     assert get_in(activity.data, ["object", "statusnetConversationId"]) == activity.id
-     assert get_in(activity.data, ["statusnetConversationId"]) == activity.id
  
      assert is_list(activity.data["object"]["attachment"])
  
  
      assert get_in(reply.data, ["context"]) == get_in(activity.data, ["context"])
      assert get_in(reply.data, ["object", "context"]) == get_in(activity.data, ["object", "context"])
-     assert get_in(reply.data, ["statusnetConversationId"]) == get_in(activity.data, ["statusnetConversationId"])
-     assert get_in(reply.data, ["object", "statusnetConversationId"]) == get_in(activity.data, ["object", "statusnetConversationId"])
      assert get_in(reply.data, ["object", "inReplyTo"]) == get_in(activity.data, ["object", "id"])
      assert get_in(reply.data, ["object", "inReplyToStatusId"]) == activity.id
      assert Enum.member?(get_in(reply.data, ["to"]), "some_cool_id")
    end
  
-   test "fetch public statuses" do
+   test "fetch public statuses, excluding remote ones." do
      %{ public: activity, user: user } = ActivityBuilder.public_and_non_public
+     insert(:note_activity, %{local: false})
  
      follower = insert(:user, following: [User.ap_followers(user)])
  
      assert Enum.at(statuses, 0) == ActivityRepresenter.to_map(activity, %{user: user, for: follower})
    end
  
+   test "fetch whole known network statuses" do
+     %{ public: activity, user: user } = ActivityBuilder.public_and_non_public
+     insert(:note_activity, %{local: false})
+     follower = insert(:user, following: [User.ap_followers(user)])
+     statuses = TwitterAPI.fetch_public_and_external_statuses(follower)
+     assert length(statuses) == 2
+     assert Enum.at(statuses, 0) == ActivityRepresenter.to_map(activity, %{user: user, for: follower})
+   end
    test "fetch friends' statuses" do
      user = insert(:user, %{following: ["someguy/followers"]})
      {:ok, activity} = ActivityBuilder.insert(%{"to" => ["someguy/followers"]})
    test "Unfollow another user using user_id" do
      unfollowed = insert(:user)
      user = insert(:user, %{following: [User.ap_followers(unfollowed)]})
++    ActivityPub.follow(user, unfollowed)
  
      {:ok, user, unfollowed } = TwitterAPI.unfollow(user, %{"user_id" => unfollowed.id})
      assert user.following == []
      unfollowed = insert(:user)
      user = insert(:user, %{following: [User.ap_followers(unfollowed)]})
  
++    ActivityPub.follow(user, unfollowed)
++
      {:ok, user, unfollowed } = TwitterAPI.unfollow(user, %{"screen_name" => unfollowed.nickname})
      assert user.following == []
  
  
    test "fetch statuses in a context using the conversation id" do
      {:ok, user} = UserBuilder.insert()
-     {:ok, activity} = ActivityBuilder.insert(%{"statusnetConversationId" => 1, "context" => "2hu"})
-     {:ok, activity_two} = ActivityBuilder.insert(%{"statusnetConversationId" => 1,"context" => "2hu"})
+     {:ok, activity} = ActivityBuilder.insert(%{"context" => "2hu"})
+     {:ok, activity_two} = ActivityBuilder.insert(%{"context" => "2hu"})
      {:ok, _activity_three} = ActivityBuilder.insert(%{"context" => "3hu"})
  
-     statuses = TwitterAPI.fetch_conversation(user, 1)
+     {:ok, object} = Object.context_mapping("2hu") |> Repo.insert
+     statuses = TwitterAPI.fetch_conversation(user, object.id)
  
      assert length(statuses) == 2
      assert Enum.at(statuses, 0)["id"] == activity.id
      refute Repo.get_by(User, nickname: "lain")
    end
  
+   test "it assigns an integer conversation_id" do
+     note_activity = insert(:note_activity)
+     user = User.get_cached_by_ap_id(note_activity.data["actor"])
+     status = ActivityRepresenter.to_map(note_activity, %{user: user})
+     assert is_number(status["statusnet_conversation_id"])
+   end
    setup do
      Supervisor.terminate_child(Pleroma.Supervisor, Cachex)
      Supervisor.restart_child(Pleroma.Supervisor, Cachex)
      :ok
    end
+   describe "context_to_conversation_id" do
+     test "creates a mapping object" do
+       conversation_id = TwitterAPI.context_to_conversation_id("random context")
+       object = Object.get_by_ap_id("random context")
+       assert conversation_id == object.id
+     end
+     test "returns an existing mapping for an existing object" do
+       {:ok, object} = Object.context_mapping("random context") |> Repo.insert
+       conversation_id = TwitterAPI.context_to_conversation_id("random context")
+       assert conversation_id == object.id
+     end
+   end
  end