• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

snarfed / bridgy-fed / c2e8973b-582e-4cdf-9091-428da22b112e

01 Feb 2025 07:27PM UTC coverage: 93.103% (-0.02%) from 93.124%
c2e8973b-582e-4cdf-9091-428da22b112e

push

circleci

snarfed
add block DM command

for #1406

42 of 45 new or added lines in 1 file covered. (93.33%)

1 existing line in 1 file now uncovered.

4617 of 4959 relevant lines covered (93.1%)

0.93 hits per line

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

96.27
/dms.py
1
"""Protocol-independent code for sending and receiving DMs aka chat messages.
2

3
TODO: make a command framework, abstract out arg parsing and handling, etc
4
"""
5
from datetime import timedelta
1✔
6
import logging
1✔
7

8
from granary import as1
1✔
9
from oauth_dropins.webutil.flask_util import error
1✔
10
from oauth_dropins.webutil import util
1✔
11

12
from common import create_task, DOMAINS
1✔
13
import ids
1✔
14
import memcache
1✔
15
import models
1✔
16
from models import Object, PROTOCOLS
1✔
17
import protocol
1✔
18

19
logger = logging.getLogger(__name__)
1✔
20

21
REQUESTS_LIMIT_EXPIRE = timedelta(days=1)
1✔
22
REQUESTS_LIMIT_USER = 10
1✔
23

24
COMMANDS = (
1✔
25
    'block',
26
    'did',
27
    'help',
28
    'no',
29
    'ok',
30
    'start',
31
    'stop',
32
    'username',
33
    'yes',
34
)
35

36

37
def maybe_send(*, from_proto, to_user, text, type=None, in_reply_to=None):
1✔
38
    """Sends a DM.
39

40
    Creates a task to send the DM asynchronously.
41

42
    If ``type`` is provided, and we've already sent this user a DM of this type
43
    from this protocol, does nothing.
44

45
    Args:
46
      from_proto (protocol.Protocol)
47
      to_user (models.User)
48
      text (str): message content. May be HTML.
49
      type (str): optional, one of DM.TYPES
50
      in_reply_to (str): optional, ``id`` of a DM to reply to
51
    """
52
    if type:
1✔
53
        dm = models.DM(protocol=from_proto.LABEL, type=type)
1✔
54
        if dm in to_user.sent_dms:
1✔
55
            return
1✔
56

57
    from web import Web
1✔
58
    bot = Web.get_by_id(from_proto.bot_user_id())
1✔
59
    logger.info(f'Sending DM from {bot.key.id()} to {to_user.key.id()} : {text}')
1✔
60

61
    if not to_user.obj or not to_user.obj.as1:
1✔
62
        logger.info("  can't send DM, recipient has no profile obj")
1✔
63
        return
1✔
64

65
    dm_id = f'{bot.profile_id()}#bridgy-fed-dm-{type or "?"}-{to_user.key.id()}-{util.now().isoformat()}'
1✔
66
    dm_as1 = {
1✔
67
        'objectType': 'note',
68
        'id': dm_id,
69
        'author': bot.key.id(),
70
        'content': text,
71
        'inReplyTo': in_reply_to,
72
        'tags': [{
73
            'objectType': 'mention',
74
            'url': to_user.key.id(),
75
        }],
76
        'to': [to_user.key.id()],
77
    }
78
    Object(id=dm_id, our_as1=dm_as1, source_protocol='web').put()
1✔
79

80
    create_id = f'{dm_id}-create'
1✔
81
    create_as1 = {
1✔
82
        'objectType': 'activity',
83
        'verb': 'post',
84
        'id': create_id,
85
        'actor': bot.key.id(),
86
        'object': dm_as1,
87
        'to': [to_user.key.id()],
88
    }
89

90
    target_uri = to_user.target_for(to_user.obj, shared=False)
1✔
91
    target = models.Target(protocol=to_user.LABEL, uri=target_uri)
1✔
92
    create_task(queue='send', id=create_id, our_as1=create_as1, source_protocol='web',
1✔
93
                protocol=to_user.LABEL, url=target.uri, user=bot.key.urlsafe())
94

95
    if type:
1✔
96
        to_user.sent_dms.append(dm)
1✔
97
        to_user.put()
1✔
98

99

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

103
    Args:
104
      from_user (models.User)
105
      obj (Object): DM
106

107
    Returns:
108
      (str, int) tuple: (response body, HTTP status code) Flask response
109
    """
110
    recip = as1.recipient_if_dm(obj.as1)
1✔
111
    assert recip
1✔
112

113
    to_proto = protocol.Protocol.for_bridgy_subdomain(recip)
1✔
114
    assert to_proto  # already checked in check_supported call in Protocol.receive
1✔
115

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

120
    # parse message
121
    soup = util.parse_html(inner_obj.get('content', ''))
1✔
122
    content = soup.get_text().strip().lower()
1✔
123
    tokens = content.split()
1✔
124
    logger.info(f'  tokens: {tokens}')
1✔
125

126
    # remove @-mention of bot, if any
127
    bot_handles = (DOMAINS + ids.BOT_ACTOR_AP_IDS
1✔
128
                   + tuple(h.lstrip('@') for h in ids.BOT_ACTOR_AP_HANDLES))
129
    if tokens and tokens[0].lstrip('@') in bot_handles:
1✔
130
        logger.debug(f'  first token is bot mention, removing')
1✔
131
        tokens = tokens[1:]
1✔
132

133
    if not tokens or len(tokens) > 2:
1✔
134
        return r'¯\_(ツ)_/¯', 204
1✔
135

136
    if tokens[0].lstrip('/') in COMMANDS:
1✔
137
        cmd = tokens[0].lstrip('/')
1✔
138
        arg = tokens[1] if len(tokens) > 1 else None
1✔
139
    else:
140
        cmd = None
1✔
141
        arg = tokens[0]
1✔
142

143
    # handle commands
144
    def reply(text, type=None):
1✔
145
        maybe_send(from_proto=to_proto, to_user=from_user, text=text, type=type,
1✔
146
                   in_reply_to=inner_obj.get('id'))
147
        return 'OK', 200
1✔
148

149
    if cmd in ('?', 'help', 'commands', 'info', 'hi', 'hello'):
1✔
150
        extra = ''
1✔
151
        if to_proto.LABEL == 'atproto':
1✔
152
            extra = """<li><em>did</em>: get your bridged Bluesky account's <a href="https://atproto.com/guides/identity#identifiers">DID</a>"""
×
153
        return reply(f"""\
1✔
154
<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>
155
<ul>
156
<li><em>start</em>: enable bridging for your account
157
<li><em>stop</em>: disable bridging for your account
158
<li><em>username [domain]</em>: set a custom domain username (handle)
159
<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}
160
{extra}
161
<li><em>help</em>: print this message
162
</ul>""")
163

164
    if cmd in ('yes', 'ok', 'start') and not arg:
1✔
165
        from_user.enable_protocol(to_proto)
1✔
166
        to_proto.bot_follow(from_user)
1✔
167
        return 'OK', 200
1✔
168

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

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

177
    if cmd in ('no', 'stop') and not arg:
1✔
178
        from_user.delete(to_proto)
1✔
179
        from_user.disable_protocol(to_proto)
1✔
180
        return 'OK', 200
1✔
181

182
    if cmd in ('username', 'handle') and arg:
1✔
183
        try:
1✔
184
            to_proto.set_username(from_user, arg)
1✔
185
        except NotImplementedError:
1✔
186
            return reply(f"Sorry, Bridgy Fed doesn't support custom usernames for {to_proto.PHRASE} yet.")
1✔
187
        except (ValueError, RuntimeError) as e:
1✔
188
            return reply(str(e))
1✔
189
        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✔
190

191
    if cmd == 'block' and arg:
1✔
192
        handle = arg
1✔
193
        if not to_proto.owns_handle(handle) and handle.startswith('@'):
1✔
NEW
194
            logging.info(f"doesn't look like a handle, trying without leading @")
×
NEW
195
            handle = handle.removeprefix('@')
×
196

197
        to_user = load_user(to_proto, arg)
1✔
198
        if not to_user:
1✔
NEW
199
            return reply(f"Couldn't find {to_proto.PHRASE} user {handle}")
×
200

201
        block_id = f'{from_user.key.id()}#bridgy-fed-block-{util.now().isoformat()}'
1✔
202
        obj = Object(id=block_id, our_as1={
1✔
203
            'id': block_id,
204
            'objectType': 'activity',
205
            'verb': 'block',
206
            'actor': from_user.key.id(),
207
            'object': to_user.key.id(),
208
        })
209
        obj.put()
1✔
210
        from_user.deliver(obj, from_user=from_user)
1✔
211
        return reply(f"""OK, you're now blocking {to_user.user_link()} in {to_proto.PHRASE}.""")
1✔
212

213
    # are they requesting a user?
214
    if not cmd:
1✔
215
        handle = arg
1✔
216
        if not to_proto.owns_handle(handle) and handle.startswith('@'):
1✔
217
            logging.info(f"doesn't look like a handle, trying without leading @")
1✔
218
            handle = handle.removeprefix('@')
1✔
219

220
        to_user = load_user(to_proto, handle)
1✔
221
        if not to_user:
1✔
222
            return reply(f"Couldn't find {to_proto.PHRASE} user {handle}")
1✔
223

224
        from_proto = from_user.__class__
1✔
225
        try:
1✔
226
            ids.translate_handle(handle=handle, from_=to_proto, to=from_user,
1✔
227
                                 enhanced=False)
228
        except ValueError as e:
1✔
229
            logger.warning(e)
1✔
230
            return reply(f"Sorry, Bridgy Fed doesn't yet support bridging handle {handle} from {to_proto.PHRASE} to {from_proto.PHRASE}.")
1✔
231

232
        if to_user.is_enabled(from_proto):
1✔
233
            # already bridged
234
            return reply(f'{to_user.user_link(proto=from_proto)} is already bridged into {from_proto.PHRASE}.')
1✔
235

236
        elif (models.DM(protocol=from_proto.LABEL, type='request_bridging')
1✔
237
              in to_user.sent_dms):
238
            # already requested
239
            return reply(f"We've already sent {to_user.user_link()} a DM. Fingers crossed!")
1✔
240

241
        # check and update rate limits
242
        attempts_key = f'dm-user-requests-{from_user.LABEL}-{from_user.key.id()}'
1✔
243
        # incr leaves existing expiration as is, doesn't change it
244
        # https://stackoverflow.com/a/4084043/186123
245
        attempts = memcache.memcache.incr(attempts_key, 1)
1✔
246
        if not attempts:
1✔
247
            memcache.memcache.add(
1✔
248
                attempts_key, 1,
249
                expire=int(REQUESTS_LIMIT_EXPIRE.total_seconds()))
250
        elif attempts > REQUESTS_LIMIT_USER:
1✔
251
            return reply(f"Sorry, you've hit your limit of {REQUESTS_LIMIT_USER} requests per day. Try again tomorrow!")
1✔
252

253
        # send the DM request!
254
        maybe_send(from_proto=from_proto, to_user=to_user, type='request_bridging', text=f"""\
1✔
255
<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.
256
<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.
257
<p>Bridgy Fed will only send you this message once.""")
258
        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✔
259

UNCOV
260
    error(f"Couldn't understand DM: {tokens}", status=304)
×
261

262

263
def load_user(proto, handle):
1✔
264
    """
265
    Args:
266
      proto (protocol.Protocol)
267
      handle (str)
268

269
    Returns:
270
      models.User or None
271
    """
272
    if proto.owns_handle(handle) is False:
1✔
273
        return None
1✔
274

275
    if id := proto.handle_to_id(handle):
1✔
276
        if user := proto.get_or_create(id):
1✔
277
            if user.obj:
1✔
278
                return user
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