• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

snarfed / bridgy-fed / 2187ef8e-92da-4d74-80b0-23b33490231f

29 Oct 2024 05:43PM UTC coverage: 92.75% (+0.007%) from 92.743%
2187ef8e-92da-4d74-80b0-23b33490231f

push

circleci

snarfed
docs: DM commands, how to set a custom Bluesky handle, other revisions

for #826

4286 of 4621 relevant lines covered (92.75%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.11
/dms.py
1
"""Protocol-independent code for sending and receiving DMs aka chat messages."""
2
from datetime import timedelta
1✔
3
import logging
1✔
4

5
from granary import as1
1✔
6
from oauth_dropins.webutil.flask_util import error
1✔
7
from oauth_dropins.webutil import util
1✔
8

9
from common import create_task, memcache, memcache_key
1✔
10
import ids
1✔
11
import models
1✔
12
from models import PROTOCOLS
1✔
13
import protocol
1✔
14

15
logger = logging.getLogger(__name__)
1✔
16

17
REQUESTS_LIMIT_EXPIRE = timedelta(days=1)
1✔
18
REQUESTS_LIMIT_USER = 10
1✔
19

20

21
def maybe_send(*, from_proto, to_user, text, type=None, in_reply_to=None):
1✔
22
    """Sends a DM.
23

24
    Creates a task to send the DM asynchronously.
25

26
    If ``type`` is provided, and we've already sent this user a DM of this type
27
    from this protocol, does nothing.
28

29
    Args:
30
      from_proto (protocol.Protocol)
31
      to_user (models.User)
32
      text (str): message content. May be HTML.
33
      type (str): optional, one of DM.TYPES
34
      in_reply_to (str): optional, ``id`` of a DM to reply to
35
    """
36
    if type:
1✔
37
        dm = models.DM(protocol=from_proto.LABEL, type=type)
1✔
38
        if dm in to_user.sent_dms:
1✔
39
            return
1✔
40

41
    from web import Web
1✔
42
    bot = Web.get_by_id(from_proto.bot_user_id())
1✔
43
    logger.info(f'Sending DM from {bot.key.id()} to {to_user.key.id()} : {text}')
1✔
44

45
    if not to_user.obj or not to_user.obj.as1:
1✔
46
        logger.info("  can't send DM, recipient has no profile obj")
1✔
47
        return
1✔
48

49
    id = f'{bot.profile_id()}#{type or "?"}-dm-{to_user.key.id()}-{util.now().isoformat()}'
1✔
50
    target_uri = to_user.target_for(to_user.obj, shared=False)
1✔
51
    target = models.Target(protocol=to_user.LABEL, uri=target_uri)
1✔
52
    models.Object(id=id, source_protocol='web', undelivered=[target], our_as1={
1✔
53
        'objectType': 'activity',
54
        'verb': 'post',
55
        'id': f'{id}-create',
56
        'actor': bot.key.id(),
57
        'object': {
58
            'objectType': 'note',
59
            'id': id,
60
            'author': bot.key.id(),
61
            'content': text,
62
            'inReplyTo': in_reply_to,
63
            'tags': [{
64
                'objectType': 'mention',
65
                'url': to_user.key.id(),
66
            }],
67
            'to': [to_user.key.id()],
68
        },
69
        'to': [to_user.key.id()],
70
    }).put()
71

72
    create_task(queue='send', obj_id=id, protocol=to_user.LABEL,
1✔
73
                url=target.uri, user=bot.key.urlsafe())
74

75
    if type:
1✔
76
        to_user.sent_dms.append(dm)
1✔
77
        to_user.put()
1✔
78

79

80
def receive(*, from_user, obj):
1✔
81
    """Handles a DM that a user sent to one of our protocol bot users.
82

83
    Args:
84
      from_user (models.User)
85
      obj (Object): DM
86

87
    Returns:
88
      (str, int) tuple: (response body, HTTP status code) Flask response
89
    """
90
    recip = as1.recipient_if_dm(obj.as1)
1✔
91
    assert recip
1✔
92

93
    to_proto = protocol.Protocol.for_bridgy_subdomain(recip)
1✔
94
    assert to_proto  # already checked in check_supported call in Protocol.receive
1✔
95

96
    inner_obj = (as1.get_object(obj.as1) if as1.object_type(obj.as1) == 'post'
1✔
97
                 else obj.as1)
98
    logger.info(f'got DM from {from_user.key.id()} to {to_proto.LABEL}: {inner_obj.get("content")}')
1✔
99

100
    # remove @-mentions in HTML links
101
    soup = util.parse_html(inner_obj.get('content', ''))
1✔
102
    for link in soup.find_all('a'):
1✔
103
        link.extract()
1✔
104
    content = soup.get_text().strip().lower()
1✔
105

106
    def reply(text, type=None):
1✔
107
        maybe_send(from_proto=to_proto, to_user=from_user, text=text, type=type,
1✔
108
                   in_reply_to=inner_obj.get('id'))
109
        return 'OK', 200
1✔
110

111
    # parse and handle message
112
    split = content.split(maxsplit=1)
1✔
113
    cmd = split[0].lstrip('/')
1✔
114
    arg = split[1] if len(split) > 1 else None
1✔
115

116
    extra = ''
1✔
117
    if to_proto.LABEL == 'atproto':
1✔
118
        extra = """<li><em>did</em>: get your bridged Bluesky account's <a href="https://atproto.com/guides/identity#identifiers">DID</a>"""
1✔
119

120
    if cmd in ('?', 'help', 'commands', 'info', 'hi', 'hello'):
1✔
121
        return reply(f"""\
1✔
122
<p>Hi! I'm a friendly bot that can help you bridge your account into {to_proto.PHRASE}. Here are some commands I respond to:</p>
123
<ul>
124
<li><em>start</em>: enable bridging for your account
125
<li><em>stop</em>: disable bridging for your account
126
<li><em>username [domain]</em>: set a custom domain username (handle)
127
<li><em>[handle]</em>: ask me to DM a user on {to_proto.PHRASE} to request that they bridge their account into {from_user.PHRASE}
128
{extra}
129
<li><em>help</em>: print this message
130
</ul>""")
131

132
    if cmd in ('yes', 'ok', 'start') and not arg:
1✔
133
        from_user.enable_protocol(to_proto)
1✔
134
        to_proto.bot_follow(from_user)
1✔
135
        return 'OK', 200
1✔
136

137
    # all other commands require the user to be bridged to this protocol first
138
    if not from_user.is_enabled(to_proto):
1✔
139
        return reply(f"Looks like you're not bridged to {to_proto.PHRASE} yet! Please bridge your account first by following this account.")
1✔
140

141
    if cmd == 'did' and not arg and to_proto.LABEL == 'atproto':
1✔
142
        return reply(f'Your DID is <code>{from_user.get_copy(PROTOCOLS["atproto"])}</code>')
1✔
143
        return 'OK', 200
144

145
    if cmd in ('no', 'stop') and not arg:
1✔
146
        from_user.delete(to_proto)
1✔
147
        from_user.disable_protocol(to_proto)
1✔
148
        return 'OK', 200
1✔
149

150
    if cmd in ('username', 'handle') and arg:
1✔
151
        try:
1✔
152
            to_proto.set_username(from_user, arg)
1✔
153
        except NotImplementedError:
1✔
154
            return reply(f"Sorry, Bridgy Fed doesn't support custom usernames for {to_proto.PHRASE} yet.")
1✔
155
        except (ValueError, RuntimeError) as e:
1✔
156
            return reply(str(e))
1✔
157
        return reply(f"Your username in {to_proto.PHRASE} has been set to {from_user.user_link(proto=to_proto, name=False, handle=True)}. It should appear soon!")
1✔
158

159
    # are they requesting a user?
160
    if not to_proto.owns_handle(content) and content.startswith('@'):
1✔
161
        logging.info("doesn't look like a handle, trying without leading @")
1✔
162
        content = content.removeprefix('@')
1✔
163

164
    if to_proto.owns_handle(content) is not False:
1✔
165
        handle = content
1✔
166
        from_proto = from_user.__class__
1✔
167

168
        try:
1✔
169
            ids.translate_handle(handle=handle, from_=to_proto, to=from_user,
1✔
170
                                 enhanced=False)
171
        except ValueError as e:
1✔
172
            logger.warning(e)
1✔
173
            return reply(f"Sorry, Bridgy Fed doesn't yet support bridging handle {handle} from {to_proto.PHRASE} to {from_proto.PHRASE}.")
1✔
174

175
        to_id = to_proto.handle_to_id(handle)
1✔
176
        if not to_id:
1✔
177
            return reply(f"Couldn't find {to_proto.PHRASE} user {handle}")
×
178

179
        to_user = to_proto.get_or_create(to_id)
1✔
180
        if not to_user:
1✔
181
            return reply(f"Couldn't find {to_proto.PHRASE} user {handle}")
×
182

183
        if not to_user.obj:
1✔
184
            # doesn't exist
185
            return reply(f"Couldn't find {to_proto.PHRASE} user {handle}")
1✔
186

187
        elif to_user.is_enabled(from_proto):
1✔
188
            # already bridged
189
            return reply(f'{to_user.user_link(proto=from_proto)} is already bridged into {from_proto.PHRASE}.')
1✔
190

191
        elif (models.DM(protocol=from_proto.LABEL, type='request_bridging')
1✔
192
              in to_user.sent_dms):
193
            # already requested
194
            return reply(f"We've already sent {to_user.user_link()} a DM. Fingers crossed!")
1✔
195

196
        # check and update rate limits
197
        attempts_key = f'dm-user-requests-{from_user.LABEL}-{from_user.key.id()}'
1✔
198
        # incr leaves existing expiration as is, doesn't change it
199
        # https://stackoverflow.com/a/4084043/186123
200
        attempts = memcache.incr(attempts_key, 1)
1✔
201
        if not attempts:
1✔
202
            memcache.add(attempts_key, 1,
1✔
203
                         expire=int(REQUESTS_LIMIT_EXPIRE.total_seconds()))
204
        elif attempts > REQUESTS_LIMIT_USER:
1✔
205
            return reply(f"Sorry, you've hit your limit of {REQUESTS_LIMIT_USER} requests per day. Try again tomorrow!")
1✔
206

207
        # send the DM request!
208
        maybe_send(from_proto=from_proto, to_user=to_user, type='request_bridging', text=f"""\
1✔
209
<p>Hi! {from_user.user_link(proto=to_proto, proto_fallback=True)} is using Bridgy Fed to bridge their account from {from_proto.PHRASE} into {to_proto.PHRASE}, and they'd like to follow you. You can bridge your account into {from_proto.PHRASE} by following this account. <a href="https://fed.brid.gy/docs">See the docs</a> for more information.
210
<p>If you do nothing, your account won't be bridged, and users on {from_proto.PHRASE} won't be able to see or interact with you.
211
<p>Bridgy Fed will only send you this message once.""")
212
        return reply(f"Got it! We'll send {to_user.user_link()} a message and say that you hope they'll enable the bridge. Fingers crossed!")
1✔
213

214
    error(f"Couldn't understand DM: {content}", status=304)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc