2
2
3
3
import requests ,os ,sys ,time
4
4
import xmltodict ,json
5
+ import pickle
5
6
sys .path .append ('../' )
6
7
from api import *
7
8
import pypresence
8
9
from typing import Literal ,get_args
9
10
11
+ requests .packages .urllib3 .disable_warnings (requests .packages .urllib3 .exceptions .InsecureRequestWarning )
12
+
10
13
local = False
11
- version = 0.2
14
+ version = 0.21
12
15
13
16
host = 'https://3ds.mi460.dev' # Change the host as you'd wish
14
17
if local :
19
22
## on running your own front and backend.
20
23
convertFriendCodeToPrincipalId (botFC )# A quick verification check
21
24
22
- def getAppPath ():# Credit to @HotaruBlaze
23
- applicationPath = os .path .expanduser ('~/Documents/3DS-RPC' )
24
- # Windows allows you to move your UserProfile subfolders, Such as Documents, Videos, Music etc.
25
- # However os.path.expanduser does not actually check and assumes it's in the default location.
26
- # This tries to correctly resolve the Documents path and fallbacks to default if it fails.
27
- if os .name == 'nt' :
28
- try :
29
- import ctypes .wintypes
30
- CSIDL_PERSONAL = 5 # My Documents
31
- SHGFP_TYPE_CURRENT = 0 # Get current, not default value
32
- buf = ctypes .create_unicode_buffer (ctypes .wintypes .MAX_PATH )
33
- ctypes .windll .shell32 .SHGetFolderPathW (None ,CSIDL_PERSONAL ,None ,SHGFP_TYPE_CURRENT ,buf )
34
- applicationPath = os .path .join (buf .value ,'3DS-RPC' )
35
- except :pass
36
- return applicationPath
37
-
38
- _REGION = Literal ['US' ,'JP' ,'GB' ,'KR' ,'TW' ]
25
+ _REGION = Literal ['US' ,'JP' ,'GB' ,'KR' ,'TW' ,'ALL' ]
39
26
path = getAppPath ()
27
+ privateFile = os .path .join (path ,'private.txt' )
40
28
41
29
class APIException (Exception ):
42
30
pass
@@ -48,11 +36,11 @@ class GameMatchError(Exception):
48
36
pass
49
37
50
38
class Client ():
51
- def __init__ (self ,region :_REGION ,friendCode :str ):
39
+ def __init__ (self ,region :_REGION ,friendCode :str , saveTitleFiles : bool = True ):
52
40
### Maintain typing ###
53
41
assert region in get_args (_REGION ),'\' %s\' does not match _REGION' % region # Region assertion
54
- convertFriendCodeToPrincipalId (friendCode )# Friend Code check
55
- with open (os . path . join ( path , 'private.txt' ) ,'w' )as file :# Save FC to file
42
+ friendCode = str ( convertPrincipalIdtoFriendCode ( convertFriendCodeToPrincipalId (friendCode ))). zfill ( 12 )# Friend Code check
43
+ with open (privateFile ,'w' )as file :# Save FC to file
56
44
file .write (json .dumps ({
57
45
'friendCode' :friendCode ,
58
46
'region' :region ,
@@ -68,14 +56,44 @@ def __init__(self, region: _REGION, friendCode: str):
68
56
self .currentGame = {'@id' :None }
69
57
70
58
# Pull databases
71
- self .titleDatabase = xmltodict .parse (requests .get ('https://samurai.ctr.shop.nintendo.net/samurai/ws/%s/titles?shop_id=1&limit=5000&offset=0' % self .region ,verify = False ).text )
72
- self .titlesToUID = requests .get ('https://raw.githubusercontent.com/hax0kartik/3dsdb/master/jsons/list_%s.json' % self .region ).json ()
73
- ## Warning; the above does not account for games being played by a user who has removed the region lock on their system
74
- ## Please consider fixing this in the future, @MCMi460
59
+ self .region = (self .region ,)
60
+ if self .region [0 ]== 'ALL' :
61
+ self .region = list (get_args (_REGION ))
62
+ del self .region [- 1 ]
63
+ databasePath = os .path .join (path ,'databases.dat' )
64
+ if os .path .isfile (databasePath ):
65
+ with open (databasePath ,'rb' )as file :
66
+ t = pickle .loads (file .read ())
67
+ self .titleDatabase = t [0 ]
68
+ self .titlesToUID = t [1 ]
69
+ else :
70
+ self .titleDatabase = []
71
+ self .titlesToUID = []
72
+
73
+ bar = ProgressBar (40 )# Create progress bar
74
+
75
+ for region in self .region :
76
+ self .titleDatabase .append (
77
+ xmltodict .parse (requests .get ('https://samurai.ctr.shop.nintendo.net/samurai/ws/%s/titles?shop_id=1&limit=5000&offset=0' % region ,verify = False ).text )
78
+ )
79
+ bar .update (.5 / len (self .region ))# Update progress bar
80
+ self .titlesToUID += requests .get ('https://raw.githubusercontent.com/hax0kartik/3dsdb/master/jsons/list_%s.json' % region ,stream = True ).json ()
81
+ bar .update (.5 / len (self .region ))# Update progress bar
82
+
83
+ bar .end ()# End the progress bar
84
+
85
+ # Save databases to file
86
+ if saveTitleFiles and not os .path .isfile (databasePath ):
87
+ with open (databasePath ,'wb' )as file :
88
+ file .write (pickle .dumps (
89
+ (self .titleDatabase ,
90
+ self .titlesToUID )
91
+ ))
92
+ print ('[Saved database to file]' )
75
93
76
94
# Get from API
77
95
def APIget (self ,route :str ,content :dict = {}):
78
- return requests .get (host + '/' + route ,data = content ,headers = {'User-Agent' :'3DS-RPC/%s' % version ,})
96
+ return requests .get (host + '/api/ ' + route ,data = content ,headers = {'User-Agent' :'3DS-RPC/%s' % version ,})
79
97
80
98
# Connect to PyPresence
81
99
def connect (self ):
@@ -97,10 +115,6 @@ def fetch(self):
97
115
def loop (self ):
98
116
userData = self .fetch ()
99
117
presence = userData ['User' ]['Presence' ]
100
- if userData ['User' ]['notifications' ]:
101
- notifications = [json .loads (n )for n in userData ['User' ]['notifications' ].split ('|' ) ]
102
- else :
103
- notifications = []
104
118
105
119
_pass = None
106
120
if userData ['User' ]['online' ]and presence :
@@ -124,10 +138,11 @@ def loop(self):
124
138
# raise TitleIDMatchError('unknown title id: %s' % tid)
125
139
126
140
game = None
127
- for title in self .titleDatabase ['eshop' ]['contents' ]['content' ]:
128
- if title ['title' ]['@id' ]== uid :
129
- game = title ['title' ]
130
- break
141
+ for region in self .titleDatabase :
142
+ for title in region ['eshop' ]['contents' ]['content' ]:
143
+ if title ['title' ]['@id' ]== uid :
144
+ game = title ['title' ]
145
+ break
131
146
if not game :
132
147
_pass = _template
133
148
# raise GameMatchError('unknown game: %s' % uid)
@@ -145,9 +160,9 @@ def loop(self):
145
160
large_image = game ['icon_url' ].replace ('https://kanzashi-ctr.cdn.nintendo.net/i/' ,host + '/cdn/i/' ),
146
161
large_text = game ['name' ],
147
162
start = self .start ,
148
- # buttons = [{'label': 'Add Friend ', 'url':host + '/f/%s' % convertPrincipalIdtoFriendCode(convertFriendCodeToPrincipalId(self.friendCode)) },]
163
+ # buttons = [{'label': 'Label ', 'url':'http://DOMAIN.WHATEVER' },]
149
164
# eShop URL could be https://api.qrserver.com/v1/create-qr-code/?data=ESHOP://{uid}
150
- # But that's dumb so no
165
+ # But... that wouldn't be very convenient. It's unfortunate how Nintendo does not have an eShop website for the 3DS
151
166
)
152
167
else :
153
168
print ('Clear [%s -> %s]' % (self .currentGame ['@id' ],None ))
@@ -161,21 +176,31 @@ def main():
161
176
# Create directory for logging and friend code saving
162
177
if not os .path .isdir (path ):
163
178
os .mkdir (path )
164
- privateFile = os .path .join (path ,'private.txt' )
165
179
if not os .path .isfile (privateFile ):
166
180
print ('Please take this time to add the bot\' s FC to your target 3DS\' friends list.\n Bot FC: %s' % '-' .join (botFC [i :i + 4 ]for i in range (0 ,len (botFC ),4 )))
167
181
input ('[Press enter to continue]' )
168
- friendCode = input ('Please enter your 3DS\' friend code\n > ' )
169
- friendCode = str ( convertPrincipalIdtoFriendCode ( convertFriendCodeToPrincipalId ( friendCode ))). zfill ( 12 ) # Check validity to prevent writing invalid FC
182
+ friendCode = input ('Please enter your 3DS\' friend code\n >\033 [0;35m ' )
183
+ print ( ' \033 [0m' , end = '' )
170
184
else :
171
185
with open (privateFile ,'r' )as file :
172
186
js = json .loads (file .read ())
173
187
friendCode = js ['friendCode' ]
174
188
region = js .get ('region' )
175
189
if not region :
176
- region = input ('Please enter your 3DS\' region [%s]\n > ' % ', ' .join (get_args (_REGION )))
177
-
178
- client = Client (region ,friendCode )
190
+ region = input ('Please enter your 3DS\' region [%s]\n \033 [93m(You may enter\' ALL\' if you are planning to play with multiple regions\' games)\033 [0m\n >\033 [0;35m' % ', ' .join (get_args (_REGION )))
191
+ print ('\033 [0m' ,end = '' )
192
+ if region == 'ALL' :
193
+ r = input ('-\033 [91mEnabling ALL regions may take a few minutes to download. Is this agreeable?\033 [0m\n - >\033 [0;35m' )
194
+ if not r .lower ().startswith ('y' ):
195
+ return
196
+ print ('\033 [0m' )
197
+
198
+ try :
199
+ client = Client (region ,friendCode )
200
+ except (AssertionError ,FriendCodeValidityError )as e :
201
+ if os .path .isfile (privateFile ):
202
+ os .remove (privateFile )
203
+ raise e
179
204
while True :
180
205
client .loop ()
181
206
time .sleep (30 )