2024-08-18 05:53:14 +00:00
""" Protocol-independent code for sending and receiving DMs aka chat messages. """
2024-08-23 00:12:56 +00:00
from datetime import timedelta
2024-08-18 05:53:14 +00:00
import logging
2024-08-18 15:28:11 +00:00
from granary import as1
2024-09-05 18:56:11 +00:00
from oauth_dropins . webutil . flask_util import error
2024-08-18 05:53:14 +00:00
from oauth_dropins . webutil import util
2025-01-10 00:57:01 +00:00
from common import create_task , DOMAINS
2024-09-11 04:20:44 +00:00
import ids
2025-01-10 00:57:01 +00:00
import memcache
2024-08-18 05:53:14 +00:00
import models
2024-10-29 17:34:04 +00:00
from models import PROTOCOLS
2024-08-18 15:28:11 +00:00
import protocol
2024-08-18 05:53:14 +00:00
logger = logging . getLogger ( __name__ )
2024-08-23 00:12:56 +00:00
REQUESTS_LIMIT_EXPIRE = timedelta ( days = 1 )
REQUESTS_LIMIT_USER = 10
2024-11-14 17:36:11 +00:00
COMMANDS = (
' did ' ,
' help ' ,
' no ' ,
' ok ' ,
' start ' ,
' stop ' ,
' username ' ,
' yes ' ,
)
2024-08-18 05:53:14 +00:00
2024-10-28 15:01:36 +00:00
def maybe_send ( * , from_proto , to_user , text , type = None , in_reply_to = None ) :
2024-08-19 20:55:05 +00:00
""" Sends a DM.
2024-08-18 05:53:14 +00:00
Creates a task to send the DM asynchronously .
2024-08-19 20:55:05 +00:00
If ` ` type ` ` is provided , and we ' ve already sent this user a DM of this type
from this protocol , does nothing .
2024-08-18 05:53:14 +00:00
Args :
from_proto ( protocol . Protocol )
to_user ( models . User )
text ( str ) : message content . May be HTML .
2024-08-19 20:55:05 +00:00
type ( str ) : optional , one of DM . TYPES
2024-10-28 15:01:36 +00:00
in_reply_to ( str ) : optional , ` ` id ` ` of a DM to reply to
2024-08-18 05:53:14 +00:00
"""
2024-08-19 20:55:05 +00:00
if type :
dm = models . DM ( protocol = from_proto . LABEL , type = type )
if dm in to_user . sent_dms :
return
2024-08-18 05:53:14 +00:00
from web import Web
bot = Web . get_by_id ( from_proto . bot_user_id ( ) )
logger . info ( f ' Sending DM from { bot . key . id ( ) } to { to_user . key . id ( ) } : { text } ' )
if not to_user . obj or not to_user . obj . as1 :
logger . info ( " can ' t send DM, recipient has no profile obj " )
return
2024-08-19 20:55:05 +00:00
id = f ' { bot . profile_id ( ) } # { type or " ? " } -dm- { to_user . key . id ( ) } - { util . now ( ) . isoformat ( ) } '
2024-08-18 05:53:14 +00:00
target_uri = to_user . target_for ( to_user . obj , shared = False )
target = models . Target ( protocol = to_user . LABEL , uri = target_uri )
2024-12-15 04:53:47 +00:00
models . Object ( id = id , source_protocol = ' web ' , our_as1 = {
2024-08-18 05:53:14 +00:00
' objectType ' : ' activity ' ,
' verb ' : ' post ' ,
' id ' : f ' { id } -create ' ,
' actor ' : bot . key . id ( ) ,
' object ' : {
' objectType ' : ' note ' ,
' id ' : id ,
' author ' : bot . key . id ( ) ,
' content ' : text ,
2024-10-29 03:35:03 +00:00
' inReplyTo ' : in_reply_to ,
2024-08-18 05:53:14 +00:00
' tags ' : [ {
' objectType ' : ' mention ' ,
' url ' : to_user . key . id ( ) ,
} ] ,
' to ' : [ to_user . key . id ( ) ] ,
} ,
' to ' : [ to_user . key . id ( ) ] ,
} ) . put ( )
2024-10-11 15:01:40 +00:00
create_task ( queue = ' send ' , obj_id = id , protocol = to_user . LABEL ,
2024-08-23 00:12:56 +00:00
url = target . uri , user = bot . key . urlsafe ( ) )
2024-08-18 05:53:14 +00:00
2024-08-19 20:55:05 +00:00
if type :
to_user . sent_dms . append ( dm )
to_user . put ( )
2024-08-18 05:53:14 +00:00
2024-08-18 15:28:11 +00:00
def receive ( * , from_user , obj ) :
""" Handles a DM that a user sent to one of our protocol bot users.
Args :
from_user ( models . User )
obj ( Object ) : DM
Returns :
( str , int ) tuple : ( response body , HTTP status code ) Flask response
"""
recip = as1 . recipient_if_dm ( obj . as1 )
assert recip
to_proto = protocol . Protocol . for_bridgy_subdomain ( recip )
assert to_proto # already checked in check_supported call in Protocol.receive
inner_obj = ( as1 . get_object ( obj . as1 ) if as1 . object_type ( obj . as1 ) == ' post '
else obj . as1 )
logger . info ( f ' got DM from { from_user . key . id ( ) } to { to_proto . LABEL } : { inner_obj . get ( " content " ) } ' )
2024-11-14 18:59:47 +00:00
# parse and handle message
2024-08-18 15:28:11 +00:00
soup = util . parse_html ( inner_obj . get ( ' content ' , ' ' ) )
content = soup . get_text ( ) . strip ( ) . lower ( )
2024-11-12 22:25:58 +00:00
tokens = content . split ( )
2024-11-14 17:36:11 +00:00
logger . info ( f ' tokens: { tokens } ' )
2024-11-12 22:25:58 +00:00
# remove @-mention of bot, if any
bot_handles = ( DOMAINS + ids . BOT_ACTOR_AP_IDS
+ tuple ( h . lstrip ( ' @ ' ) for h in ids . BOT_ACTOR_AP_HANDLES ) )
2024-11-14 18:59:47 +00:00
if tokens and tokens [ 0 ] . lstrip ( ' @ ' ) in bot_handles :
2024-11-14 17:36:11 +00:00
logger . info ( f ' first token is bot mention, removing ' )
2024-11-12 22:25:58 +00:00
tokens = tokens [ 1 : ]
2024-11-14 18:59:47 +00:00
if not tokens :
return r ' ¯ \ _(ツ)_/¯ ' , 204
2024-11-14 17:36:11 +00:00
if tokens [ 0 ] . lstrip ( ' / ' ) in COMMANDS :
cmd = tokens [ 0 ] . lstrip ( ' / ' )
arg = tokens [ 1 ] if len ( tokens ) > 1 else None
else :
cmd = None
arg = tokens [ 0 ]
2024-10-29 17:34:04 +00:00
2024-11-14 17:36:11 +00:00
# handle commands
2024-11-14 18:59:47 +00:00
def reply ( text , type = None ) :
maybe_send ( from_proto = to_proto , to_user = from_user , text = text , type = type ,
in_reply_to = inner_obj . get ( ' id ' ) )
return ' OK ' , 200
2024-10-29 03:35:03 +00:00
if cmd in ( ' ? ' , ' help ' , ' commands ' , ' info ' , ' hi ' , ' hello ' ) :
2024-11-14 17:36:11 +00:00
extra = ' '
if to_proto . LABEL == ' atproto ' :
extra = """ <li><em>did</em>: get your bridged Bluesky account ' s <a href= " https://atproto.com/guides/identity#identifiers " >DID</a> """
2024-10-29 03:35:03 +00:00
return reply ( f """ \
2024-10-29 05:27:01 +00:00
< 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>
2024-10-29 03:35:03 +00:00
< ul >
2024-10-29 05:27:01 +00:00
< li > < em > start < / em > : enable bridging for your account
< li > < em > stop < / em > : disable bridging for your account
< li > < em > username [ domain ] < / em > : set a custom domain username ( handle )
< 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 }
2024-10-29 17:34:04 +00:00
{ extra }
2024-10-29 05:27:01 +00:00
< li > < em > help < / em > : print this message
2024-10-29 03:35:03 +00:00
< / ul > """ )
2024-10-29 03:50:54 +00:00
if cmd in ( ' yes ' , ' ok ' , ' start ' ) and not arg :
2024-08-18 15:28:11 +00:00
from_user . enable_protocol ( to_proto )
to_proto . bot_follow ( from_user )
2024-08-19 20:26:45 +00:00
return ' OK ' , 200
2024-10-29 03:50:54 +00:00
# all other commands require the user to be bridged to this protocol first
if not from_user . is_enabled ( to_proto ) :
return reply ( f " Looks like you ' re not bridged to { to_proto . PHRASE } yet! Please bridge your account first by following this account. " )
2024-10-29 17:34:04 +00:00
if cmd == ' did ' and not arg and to_proto . LABEL == ' atproto ' :
return reply ( f ' Your DID is <code> { from_user . get_copy ( PROTOCOLS [ " atproto " ] ) } </code> ' )
return ' OK ' , 200
2024-10-29 03:50:54 +00:00
if cmd in ( ' no ' , ' stop ' ) and not arg :
2024-09-17 01:27:04 +00:00
from_user . delete ( to_proto )
2024-08-18 15:28:11 +00:00
from_user . disable_protocol ( to_proto )
2024-08-19 20:26:45 +00:00
return ' OK ' , 200
2024-10-29 03:50:54 +00:00
if cmd in ( ' username ' , ' handle ' ) and arg :
2024-10-27 17:50:01 +00:00
try :
2024-10-29 17:34:04 +00:00
to_proto . set_username ( from_user , arg )
2024-10-29 03:50:54 +00:00
except NotImplementedError :
return reply ( f " Sorry, Bridgy Fed doesn ' t support custom usernames for { to_proto . PHRASE } yet. " )
2024-10-27 17:50:01 +00:00
except ( ValueError , RuntimeError ) as e :
return reply ( str ( e ) )
2024-10-28 04:48:22 +00:00
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! " )
2024-10-27 17:50:01 +00:00
2024-09-05 18:56:11 +00:00
# are they requesting a user?
2024-11-14 17:36:11 +00:00
if not cmd :
if not to_proto . owns_handle ( arg ) and arg . startswith ( ' @ ' ) :
logging . info ( f " doesn ' t look like a handle, trying without leading @ " )
arg = arg . removeprefix ( ' @ ' )
if to_proto . owns_handle ( arg ) is not False :
handle = arg
from_proto = from_user . __class__
try :
ids . translate_handle ( handle = handle , from_ = to_proto , to = from_user ,
enhanced = False )
except ValueError as e :
logger . warning ( e )
return reply ( f " Sorry, Bridgy Fed doesn ' t yet support bridging handle { handle } from { to_proto . PHRASE } to { from_proto . PHRASE } . " )
to_id = to_proto . handle_to_id ( handle )
if not to_id :
return reply ( f " Couldn ' t find { to_proto . PHRASE } user { handle } " )
to_user = to_proto . get_or_create ( to_id )
if not to_user :
return reply ( f " Couldn ' t find { to_proto . PHRASE } user { handle } " )
if not to_user . obj :
# doesn't exist
return reply ( f " Couldn ' t find { to_proto . PHRASE } user { handle } " )
elif to_user . is_enabled ( from_proto ) :
# already bridged
return reply ( f ' { to_user . user_link ( proto = from_proto ) } is already bridged into { from_proto . PHRASE } . ' )
elif ( models . DM ( protocol = from_proto . LABEL , type = ' request_bridging ' )
in to_user . sent_dms ) :
# already requested
return reply ( f " We ' ve already sent { to_user . user_link ( ) } a DM. Fingers crossed! " )
# check and update rate limits
attempts_key = f ' dm-user-requests- { from_user . LABEL } - { from_user . key . id ( ) } '
# incr leaves existing expiration as is, doesn't change it
# https://stackoverflow.com/a/4084043/186123
2025-01-10 00:57:01 +00:00
attempts = memcache . memcache . incr ( attempts_key , 1 )
2024-11-14 17:36:11 +00:00
if not attempts :
2025-01-10 00:57:01 +00:00
memcache . memcache . add (
attempts_key , 1 ,
expire = int ( REQUESTS_LIMIT_EXPIRE . total_seconds ( ) ) )
2024-11-14 17:36:11 +00:00
elif attempts > REQUESTS_LIMIT_USER :
return reply ( f " Sorry, you ' ve hit your limit of { REQUESTS_LIMIT_USER } requests per day. Try again tomorrow! " )
# send the DM request!
maybe_send ( from_proto = from_proto , to_user = to_user , type = ' request_bridging ' , text = f """ \
2024-09-11 00:45:08 +00:00
< 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.
2024-08-20 03:36:07 +00:00
< 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 .
2024-08-19 20:26:45 +00:00
< p > Bridgy Fed will only send you this message once . """ )
2024-11-14 17:36:11 +00:00
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! " )
2024-08-23 00:12:56 +00:00
2024-11-14 17:36:11 +00:00
error ( f " Couldn ' t understand DM: { tokens } " , status = 304 )