예제 #1
0
    def undo_follow(self, undo_unwrapped):
        """Replies to an AP Follow request with an Accept request.

        Args:
          undo_unwrapped: dict, AP Undo activity with redirect URLs unwrapped
        """
        logging.info('Undoing Follow')

        follow = undo_unwrapped.get('object', {})
        follower = follow.get('actor')
        followee = follow.get('object')
        if not follower or not followee:
            self.error(
                'Undo of Follow requires object with actor and object. Got: %s'
                % follow)

        # deactivate Follower
        user_domain = util.domain_from_link(followee)
        follower_obj = Follower.get_by_id(Follower._id(user_domain, follower))
        if follower_obj:
            logging.info('Marking %s as inactive' % follower_obj.key)
            follower_obj.status = 'inactive'
            follower_obj.put()
        else:
            logging.warning('No Follower found for %s %s', user_domain,
                            follower)
예제 #2
0
    def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post):
        mock_head.return_value = requests_response(url='https://realize.be/')
        Follower(id=Follower._id('realize.be', 'https://mastodon.social/users/swentel'),
                 status='inactive').put()

        got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
        self.assertEqual(200, got.status_code)
예제 #3
0
    def test_inbox_delete_actor(self, mock_head, mock_get, mock_post):
        follower = Follower.get_or_create('realize.be', DELETE['actor'])
        Follower.get_or_create('snarfed.org', DELETE['actor'])
        # other unrelated follower
        other = Follower.get_or_create('realize.be', 'https://mas.to/users/other')
        self.assertEqual(3, Follower.query().count())

        got = self.client.post('/realize.be/inbox', json=DELETE)
        self.assertEqual(200, got.status_code)
예제 #4
0
    def accept_follow(self, follow, follow_unwrapped):
        """Replies to an AP Follow request with an Accept request.

        Args:
          follow: dict, AP Follow activity
          follow_unwrapped: dict, same, except with redirect URLs unwrapped
        """
        logging.info('Replying to Follow with Accept')

        followee = follow.get('object')
        followee_unwrapped = follow_unwrapped.get('object')
        follower = follow.get('actor')
        if not followee or not followee_unwrapped or not follower:
            common.error(
                self,
                'Follow activity requires object and actor. Got: %s' % follow)

        inbox = follower.get('inbox')
        follower_id = follower.get('id')
        if not inbox or not follower_id:
            common.error(self, 'Follow actor requires id and inbox. Got: %s',
                         follower)

        # store Follower
        user_domain = util.domain_from_link(followee_unwrapped)
        Follower.get_or_create(user_domain,
                               follower_id,
                               last_follow=json.dumps(follow))

        # send AP Accept
        accept = {
            '@context':
            'https://www.w3.org/ns/activitystreams',
            'id':
            util.tag_uri(appengine_config.HOST,
                         'accept/%s/%s' % ((user_domain, follow.get('id')))),
            'type':
            'Accept',
            'actor':
            followee,
            'object': {
                'type': 'Follow',
                'actor': follower_id,
                'object': followee,
            }
        }
        resp = send(accept, inbox, user_domain)
        self.response.status_int = resp.status_code
        self.response.write(resp.text)

        # send webmention
        common.send_webmentions(self,
                                as2.to_as1(follow),
                                proxy=True,
                                protocol='activitypub',
                                source_as2=json.dumps(follow_unwrapped))
예제 #5
0
    def test_inbox_undo_follow(self, mock_head, mock_get, mock_post):
        mock_head.return_value = requests_response(url='https://realize.be/')

        Follower(id=Follower._id('realize.be', FOLLOW['actor'])).put()

        got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
        self.assertEqual(200, got.status_code)

        follower = Follower.get_by_id('realize.be %s' % FOLLOW['actor'])
        self.assertEqual('inactive', follower.status)
예제 #6
0
    def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post):
        mock_head.return_value = requests_response(url='https://realize.be/')
        Follower(id=Follower._id('realize.be',
                                 'https://mastodon.social/users/swentel'),
                 status='inactive').put()

        got = application.get_response(
            '/foo.com/inbox',
            method='POST',
            body=json_dumps(UNDO_FOLLOW_WRAPPED).encode())
        self.assertEqual(200, got.status_int)
예제 #7
0
    def test_inbox_undo_follow(self, mock_head, mock_get, mock_post):
        mock_head.return_value = requests_response(url='https://realize.be/')

        Follower(id=Follower._id('realize.be', FOLLOW['actor'])).put()

        got = application.get_response(
            '/foo.com/inbox',
            method='POST',
            body=json_dumps(UNDO_FOLLOW_WRAPPED).encode())
        self.assertEqual(200, got.status_int)

        follower = Follower.get_by_id('realize.be %s' % FOLLOW['actor'])
        self.assertEqual('inactive', follower.status)
예제 #8
0
def change_follow(request, format=None):
    if request.method == 'POST':
        followForm = ChangeFollowForm(request.POST)
        if followForm.is_valid():
            username = followForm.cleaned_data['username']
            password = followForm.cleaned_data['password']
            target_user = followForm.cleaned_data['target_user']
            if username == target_user:
                return Response({'resMsg': '不能关注自己'})
            s_type = followForm.cleaned_data['s_type']
            user = User.objects.filter(username__exact=username,
                                       password__exact=password)
            target = User.objects.filter(username__exact=target_user)
            if user:
                if target:
                    serializer_user = UserSerializer(user[0])
                    pk_user = serializer_user.data['id']
                    serializer_target = UserSerializer(target[0])
                    pk_target = serializer_target.data['id']
                    # 针对自己的关注
                    if s_type == TYPE_FOLLOW:
                        # 如果有关注就取关,没有就关注,相当于异或运算
                        f = Follower.objects.filter(u1_id__exact=pk_target,
                                                    u2_id__exact=pk_user)
                        if f:
                            f.delete()
                            return Response({'resMsg': '取消关注成功'})
                        else:
                            new_f = Follower(u1_id=pk_target, u2_id=pk_user)
                            new_f.save()
                            return Response({'resMsg': '关注成功'})
                    # 针对自己的粉丝
                    elif s_type == TYPE_FOLLOWING:
                        # 移除粉丝
                        f = Follower.objects.filter(u1_id__exact=pk_user,
                                                    u2_id__exact=pk_target)
                        if f:
                            f.delete()
                            return Response({'resMsg': '移除成功'})
                        else:
                            return Response({'resMsg': 'TA没有关注你'})
                    else:
                        return Response({'resMsg': 'sType参数错误'})
                else:
                    return Response({'resMsg': '没有这个用户'})
            else:
                return Response({'resMsg': '请求用户或密码错误'})
        else:
            return Response({'resMsg': '获取失败'})
예제 #9
0
    def test_activitypub_create_post(self, mock_get, mock_post):
        mock_get.side_effect = [self.create, self.actor]
        mock_post.return_value = requests_response('abc xyz')

        Follower.get_or_create('orig', 'https://mastodon/aaa')
        Follower.get_or_create('orig',
                               'https://mastodon/bbb',
                               last_follow=json.dumps({
                                   'actor': {
                                       'publicInbox': 'https://public/inbox',
                                       'inbox': 'https://unused',
                                   }
                               }))
        Follower.get_or_create('orig',
                               'https://mastodon/ccc',
                               last_follow=json.dumps({
                                   'actor': {
                                       'endpoints': {
                                           'sharedInbox':
                                           'https://shared/inbox',
                                       },
                                   }
                               }))
        Follower.get_or_create('orig',
                               'https://mastodon/ddd',
                               last_follow=json.dumps(
                                   {'actor': {
                                       'inbox': 'https://inbox',
                                   }}))
        self.datastore_stub.Flush()

        got = app.get_response('/webmention',
                               method='POST',
                               body=urllib.urlencode({
                                   'source':
                                   'http://orig/post',
                                   'target':
                                   'https://fed.brid.gy/',
                               }))
        self.assertEquals(200, got.status_int)

        mock_get.assert_has_calls((self.req('http://orig/post'), ))

        inboxes = ('https://public/inbox', 'https://shared/inbox',
                   'https://inbox')
        for call, inbox in zip(mock_post.call_args_list, inboxes):
            self.assertEquals((inbox, ), call[0])
            self.assertEquals(self.create_as2, call[1]['json'])

        for inbox in inboxes:
            resp = Response.get_by_id('http://orig/post %s' % inbox)
            self.assertEqual('out', resp.direction, inbox)
            self.assertEqual('activitypub', resp.protocol, inbox)
            self.assertEqual('complete', resp.status, inbox)
            self.assertEqual(self.create_mf2, json.loads(resp.source_mf2),
                             inbox)
예제 #10
0
    def post(self, domain):
        logging.info('Got: %s', self.request.body)

        # parse and validate AS2 activity
        try:
            activity = json_loads(self.request.body)
            assert activity
        except (TypeError, ValueError, AssertionError):
            self.error("Couldn't parse body as JSON", exc_info=True)

        obj = activity.get('object') or {}
        if isinstance(obj, str):
            obj = {'id': obj}

        type = activity.get('type')
        if type == 'Accept':  # eg in response to a Follow
            return  # noop
        if type == 'Create':
            type = obj.get('type')
        elif type not in SUPPORTED_TYPES:
            self.error('Sorry, %s activities are not supported yet.' % type,
                       status=501)

        # TODO: verify signature if there is one

        if type == 'Undo' and obj.get('type') == 'Follow':
            # skip actor fetch below; we don't need it to undo a follow
            return self.undo_follow(self.redirect_unwrap(activity))
        elif type == 'Delete':
            id = obj.get('id')
            if isinstance(id, str):
                # assume this is an actor
                # https://github.com/snarfed/bridgy-fed/issues/63
                for key in Follower.query().iter(keys_only=True):
                    if key.id().split(' ')[-1] == id:
                        key.delete()
            return

        # fetch actor if necessary so we have name, profile photo, etc
        for elem in obj, activity:
            actor = elem.get('actor')
            if actor and isinstance(actor, str):
                elem['actor'] = common.get_as2(actor).json()

        activity_unwrapped = self.redirect_unwrap(activity)
        if type == 'Follow':
            return self.accept_follow(activity, activity_unwrapped)

        # send webmentions to each target
        as1 = as2.to_as1(activity)
        self.send_webmentions(as1,
                              proxy=True,
                              protocol='activitypub',
                              source_as2=json_dumps(activity_unwrapped))
예제 #11
0
    def test_inbox_follow_accept(self, mock_head, mock_get, mock_post):
        mock_head.return_value = requests_response(url='https://realize.be/')
        mock_get.side_effect = [
            # source actor
            requests_response(FOLLOW_WITH_ACTOR['actor'],
                              content_type=common.CONTENT_TYPE_AS2),
            # target post webmention discovery
            requests_response(
                '<html><head><link rel="webmention" href="/webmention"></html>'
            ),
        ]
        mock_post.return_value = requests_response()

        got = application.get_response(
            '/foo.com/inbox',
            method='POST',
            body=json_dumps(FOLLOW_WRAPPED).encode())
        self.assertEqual(200, got.status_int)

        as2_headers = copy.deepcopy(common.HEADERS)
        as2_headers.update(common.CONNEG_HEADERS_AS2_HTML)
        mock_get.assert_has_calls((call(FOLLOW['actor'],
                                        headers=as2_headers,
                                        stream=True,
                                        timeout=15), ))

        # check AP Accept
        self.assertEqual(2, len(mock_post.call_args_list))
        args, kwargs = mock_post.call_args_list[0]
        self.assertEqual(('http://follower/inbox', ), args)
        self.assertEqual(ACCEPT, kwargs['json'])

        # check webmention
        args, kwargs = mock_post.call_args_list[1]
        self.assertEqual(('https://realize.be/webmention', ), args)
        self.assertEqual(
            {
                'source':
                'http://localhost/render?source=https%3A%2F%2Fmastodon.social%2F6d1a&target=https%3A%2F%2Frealize.be%2F',
                'target': 'https://realize.be/',
            }, kwargs['data'])

        resp = Response.get_by_id(
            'https://mastodon.social/6d1a https://realize.be/')
        self.assertEqual('in', resp.direction)
        self.assertEqual('activitypub', resp.protocol)
        self.assertEqual('complete', resp.status)
        self.assertEqual(FOLLOW_WITH_ACTOR, json_loads(resp.source_as2))

        # check that we stored a Follower object
        follower = Follower.get_by_id('realize.be %s' % (FOLLOW['actor']))
        self.assertEqual('active', follower.status)
        self.assertEqual(FOLLOW_WRAPPED_WITH_ACTOR,
                         json_loads(follower.last_follow))
예제 #12
0
    def test_activitypub_create_with_image(self, mock_get, mock_post):
        create_html = self.create_html.replace(
            '</body>', '<img class="u-photo" src="http://im/age" />\n</body>')
        mock_get.side_effect = [
            requests_response(create_html, content_type=CONTENT_TYPE_HTML),
            self.actor,
        ]
        mock_post.return_value = requests_response('abc xyz ')

        Follower.get_or_create('orig',
                               'https://mastodon/aaa',
                               last_follow=json.dumps(
                                   {'actor': {
                                       'inbox': 'https://inbox'
                                   }}))
        self.datastore_stub.Flush()

        got = app.get_response('/webmention',
                               method='POST',
                               body=urllib.urlencode({
                                   'source':
                                   'http://orig/post',
                                   'target':
                                   'https://fed.brid.gy/',
                               }))
        self.assertEquals(200, got.status_int)

        self.assertEquals(('https://inbox', ), mock_post.call_args[0])
        create = copy.deepcopy(self.create_as2)
        create['object'].update({
            'image': [{
                'url': 'http://im/age',
                'type': 'Image'
            }],
            'attachment': [{
                'url': 'http://im/age',
                'type': 'Image'
            }],
        })
        self.assertEquals(create, mock_post.call_args[1]['json'])
예제 #13
0
    def check_followers_updates(self, screen_name):
        print "Checking for new followers and unfollowers..."
        previous_followers = self.session.query(Follower).filter_by(
            is_following=True).all()
        previous_followers_ids = [
            follower.twitter_id for follower in previous_followers
        ]
        current_followers = self.get_followers(screen_name)

        new_followers = []
        new_unfollowers = []

        for current_follower in current_followers:
            if current_follower not in previous_followers_ids:
                # New follower !
                new_followers.append(current_follower)
                follower = self.api.get_user(current_follower)
                print "[%s] Found a new follower : %s [%s] (#%d)" \
                      % (datetime.today().strftime('%d/%m %H:%M'), follower.name, follower.screen_name, follower.id)

                self.session.add(
                    Follower(
                        name=follower.name,
                        screen_name=follower.screen_name,
                        twitter_id=follower.id,
                        is_following=True,
                        last_following=datetime.now(),
                    ))

        # Discover the unfollowers
        for old_follower in previous_followers_ids:
            if old_follower not in current_followers:
                new_unfollowers.append(old_follower)
                # Unfollower!
                unfollower = self.session.query(Follower).filter_by(
                    twitter_id=old_follower).one()
                follower = self.api.get_user(old_follower)
                print "[%s] Found an unfollower : %s [%s] (#%d)" % \
                      (datetime.today().strftime('%d/%m %H:%M'), unfollower.name, unfollower.screen_name,
                       unfollower.id)
                unfollower.is_following = False

        # Close the session
        self.session.commit()
        self.session.close()

        if len(new_followers) == 0:
            print "no new followers"
        if len(new_unfollowers) == 0:
            print "no new unfollowers"
예제 #14
0
 def get(self, whom_name):
     who = self.current_user
     whom = db.query(User).filter(User.name == whom_name).first()
     if whom is None or whom is who:
         raise tornado.web.HTTPError(404)
     follower = db.query(Follower).filter(
         sa.and_(Follower.who_id == who.id,
                 Follower.whom_id == whom.id)).first()
     if follower is not None:
         raise tornado.web.HTTPError(404)
     db.add(Follower(who_id=who.id, whom_id=whom.id))
     db.commit()
     self.redirect(self.next_url)
     return
예제 #15
0
    def test_inbox_delete_actor(self, mock_head, mock_get, mock_post):
        follower = Follower.get_or_create('realize.be', DELETE['actor'])
        Follower.get_or_create('snarfed.org', DELETE['actor'])
        # other unrelated follower
        other = Follower.get_or_create('realize.be',
                                       'https://mas.to/users/other')
        self.assertEqual(3, Follower.query().count())

        got = application.get_response('/realize.be/inbox',
                                       method='POST',
                                       body=json_dumps(DELETE).encode())
        self.assertEqual(200, got.status_int)

        self.assertEqual([other], Follower.query().fetch())
예제 #16
0
    def update(self):

        previous_followers = self.session.query(Follower).filter_by(
            is_following=True).all()
        previous_followers_ids = [
            follower.twitter_id for follower in previous_followers
        ]

        current_followers = self.api.GetFollowers()
        current_followers_ids = [
            follower.GetId() for follower in current_followers
        ]
        #print "Found %d followers" % len(current_followers)

        # Add the new followers
        for follower in current_followers:
            if follower.GetId() not in previous_followers_ids:
                # New follower !
                print "[%s] Found a new follower : %s [%s] (#%d)" \
                    % (datetime.today().strftime('%d/%m %H:%M'), follower.GetName(), follower.GetScreenName(), follower.GetId())
                self.session.add(
                    Follower(
                        name=follower.GetName(),
                        screen_name=follower.GetScreenName(),
                        twitter_id=follower.GetId(),
                        is_following=True,
                        last_following=datetime.now(),
                    ))

        # Discover the unfollowers
        for old_follower in previous_followers:
            if old_follower.twitter_id not in current_followers_ids:
                # Unfollower !
                print "[%s] Found an unfollower : %s [%s] (#%d)" % \
                    (datetime.today().strftime('%d/%m %H:%M'), old_follower.name, old_follower.screen_name, old_follower.twitter_id)
                old_follower.is_following = False

        # Close the session
        self.session.commit()
        self.session.close()
예제 #17
0
def follow_user(user_id):
    whom_id = user_id
    try:
        whom = db.session.query(User).filter_by(id=whom_id).first().name
        if session['user_id'] != whom_id:
            new_follow = Follower(
                session['user_id'],
                whom_id
            )
            try:
                db.session.add(new_follow)
                db.session.commit()
                flash('You are now following {}'.format(whom))
                return redirect(url_for('tweets.tweet'))
            except IntegrityError:
                flash('You are already following {}'.format(whom))
                return redirect(url_for('tweets.tweet'))
        else:
            flash('No use following yourself. You will still see your 1grams anyway. :)')
            return redirect(url_for('tweets.tweet'))
    except AttributeError:
        flash('That user does not exist.')
        return redirect(url_for('tweets.tweet'))
예제 #18
0
def fetch_followers(username: str, api: tweepy.API):
    """
    Use tweepy to fetch user's followers' ids and then fetch their user objects
    and save to the db.

    params:
        username(str) - username of user to fetch followers for
        api(tweepy.API) - tweepy api instance
    """
    total_followers = api.me().followers_count
    print("Fetching {} followers".format(total_followers))
    db.create_all()
    follower_ids = []
    print("Fetching follower ids!")
    for id in rate_limit_handler(
            tweepy.Cursor(api.followers_ids, count=5000).items()):
        follower_ids.append(id)
    print("Fetching user objects from ids!")
    for list_of_100 in list(divide_into_chunks(follower_ids, 100)):
        for i, follower in enumerate(api.lookup_users(user_ids=list_of_100)):
            follower_dict = dict(
                (k, follower.__dict__[k]) for k in follower_keys)
            user = User.query.filter_by(username=username).first()
            if not user:
                user = User(username=username)
            follower = Follower(**follower_dict)
            user.followers.append(follower)
            db.session.add(user)
            db.session.commit()
            print_progress_bar(
                i + 1,
                total_followers,
                prefix="Fetching {}/{} Followers".format(
                    i + 1, total_followers),
                suffix="Fetched",
            )
    print("Done!")
예제 #19
0
    source = int(session.get("uid"))
    if source == target:
        return make_response(-1, message="禁止关注你自己")

    if (query := db.session.query(Follower).filter_by(source=int(
            session.get("uid")),
                                                      target=target)).count():
        query.delete()
        followed = False
    else:
        if db.session.query(Follower).filter_by(
                source=source).count() >= config.FOLLOWING_COUNT_LIMIT:
            return make_response(
                -1, message=f"你最多只能关注 {config.FOLLOWING_COUNT_LIMIT} 个人")

        db.session.add(Follower(source=session.get('uid'), target=target))
        followed = True
    db.session.commit()
    return make_response(0, message="操作完成", data={"followed": followed})


@app.route("/api/user/get_follower_list", methods=["POST"])
@unpack_argument
def api_user_get_follower_list(target: int, page: int = 1):
    """
    {
        "code":
        "data":[
            {
                "uid":"",
                "username":"",
예제 #20
0
    def _activitypub_targets(self):
        """
        Returns: list of (Response, string inbox URL)
        """
        # if there's in-reply-to, like-of, or repost-of, they're the targets.
        # otherwise, it's all followers' inboxes.
        targets = self._targets()

        if not targets:
            # interpret this as a Create or Update, deliver it to followers
            inboxes = []
            for follower in Follower.query().filter(
                    Follower.key > Key('Follower', self.source_domain + ' '),
                    Follower.key < Key(
                        'Follower', self.source_domain + chr(ord(' ') + 1))):
                if follower.status != 'inactive' and follower.last_follow:
                    actor = json_loads(follower.last_follow).get('actor')
                    if actor and isinstance(actor, dict):
                        inboxes.append(
                            actor.get('endpoints', {}).get('sharedInbox')
                            or actor.get('publicInbox') or actor.get('inbox'))
            return [(Response.get_or_create(source=self.source_url,
                                            target=inbox,
                                            direction='out',
                                            protocol='activitypub',
                                            source_mf2=json_dumps(
                                                self.source_mf2)), inbox)
                    for inbox in inboxes if inbox]

        resps_and_inbox_urls = []
        for target in targets:
            # fetch target page as AS2 object
            try:
                self.target_resp = common.get_as2(target)
            except (requests.HTTPError, exc.HTTPBadGateway) as e:
                self.target_resp = getattr(e, 'response', None)
                if self.target_resp and self.target_resp.status_code // 100 == 2:
                    content_type = common.content_type(self.target_resp) or ''
                    if content_type.startswith('text/html'):
                        # TODO: pass e.response to try_salmon()'s target_resp
                        continue  # give up
                raise
            target_url = self.target_resp.url or target

            resp = Response.get_or_create(source=self.source_url,
                                          target=target_url,
                                          direction='out',
                                          protocol='activitypub',
                                          source_mf2=json_dumps(
                                              self.source_mf2))

            # find target's inbox
            target_obj = self.target_resp.json()
            resp.target_as2 = json_dumps(target_obj)
            inbox_url = target_obj.get('inbox')

            if not inbox_url:
                # TODO: test actor/attributedTo and not, with/without inbox
                actor = (util.get_first(target_obj, 'actor')
                         or util.get_first(target_obj, 'attributedTo'))
                if isinstance(actor, dict):
                    inbox_url = actor.get('inbox')
                    actor = actor.get('url') or actor.get('id')
                if not inbox_url and not actor:
                    self.error(
                        'Target object has no actor or attributedTo with URL or id.'
                    )
                elif not isinstance(actor, str):
                    self.error(
                        'Target actor or attributedTo has unexpected url or id object: %r'
                        % actor)

            if not inbox_url:
                # fetch actor as AS object
                actor = common.get_as2(actor).json()
                inbox_url = actor.get('inbox')

            if not inbox_url:
                # TODO: probably need a way to save errors like this so that we can
                # return them if ostatus fails too.
                # self.error('Target actor has no inbox')
                continue

            inbox_url = urllib.parse.urljoin(target_url, inbox_url)
            resps_and_inbox_urls.append((resp, inbox_url))

        return resps_and_inbox_urls
예제 #21
0
    def test_activitypub_create_post(self, mock_get, mock_post):
        mock_get.side_effect = [self.create, self.actor]
        mock_post.return_value = requests_response('abc xyz')

        Follower.get_or_create('orig', 'https://mastodon/aaa')
        Follower.get_or_create('orig',
                               'https://mastodon/bbb',
                               last_follow=json_dumps({
                                   'actor': {
                                       'publicInbox': 'https://public/inbox',
                                       'inbox': 'https://unused',
                                   }
                               }))
        Follower.get_or_create('orig',
                               'https://mastodon/ccc',
                               last_follow=json_dumps({
                                   'actor': {
                                       'endpoints': {
                                           'sharedInbox':
                                           'https://shared/inbox',
                                       },
                                   }
                               }))
        Follower.get_or_create('orig',
                               'https://mastodon/ddd',
                               last_follow=json_dumps(
                                   {'actor': {
                                       'inbox': 'https://inbox',
                                   }}))
        Follower.get_or_create('orig',
                               'https://mastodon/eee',
                               status='inactive',
                               last_follow=json_dumps(
                                   {'actor': {
                                       'inbox': 'https://unused/2',
                                   }}))
        Follower.get_or_create(
            'orig',
            'https://mastodon/fff',
            last_follow=json_dumps({
                'actor': {
                    # dupe of eee; should be de-duped
                    'inbox': 'https://inbox',
                }
            }))

        got = self.client.post('/webmention',
                               data={
                                   'source': 'http://orig/post',
                                   'target': 'https://fed.brid.gy/',
                               })
        self.assertEqual(200, got.status_code)

        mock_get.assert_has_calls((self.req('http://orig/post'), ))

        inboxes = ('https://inbox', 'https://public/inbox',
                   'https://shared/inbox')
        self.assertEqual(len(inboxes), len(mock_post.call_args_list))
        for call, inbox in zip(mock_post.call_args_list, inboxes):
            self.assertEqual((inbox, ), call[0])
            self.assertEqual(self.create_as2, json_loads(call[1]['data']))

        for inbox in inboxes:
            resp = Response.get_by_id('http://orig/post %s' % inbox)
            self.assertEqual('out', resp.direction, inbox)
            self.assertEqual('activitypub', resp.protocol, inbox)
            self.assertEqual('complete', resp.status, inbox)
            self.assertEqual(self.create_mf2, json_loads(resp.source_mf2),
                             inbox)