Initial commit
This commit is contained in:
0
helpers/__init__.py
Normal file
0
helpers/__init__.py
Normal file
449
helpers/aeshelper.py
Normal file
449
helpers/aeshelper.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
A pure python (slow) implementation of rijndael with a decent interface
|
||||
|
||||
To include -
|
||||
|
||||
from rijndael import rijndael
|
||||
|
||||
To do a key setup -
|
||||
|
||||
r = rijndael(key, block_size = 16)
|
||||
|
||||
key must be a string of length 16, 24, or 32
|
||||
blocksize must be 16, 24, or 32. Default is 16
|
||||
|
||||
To use -
|
||||
|
||||
ciphertext = r.encrypt(plaintext)
|
||||
plaintext = r.decrypt(ciphertext)
|
||||
|
||||
If any strings are of the wrong length a ValueError is thrown
|
||||
"""
|
||||
|
||||
# ported from the Java reference code by Bram Cohen, April 2001
|
||||
# this code is public domain, unless someone makes
|
||||
# an intellectual property claim against the reference
|
||||
# code, in which case it can be made public domain by
|
||||
# deleting all the comments and renaming all the variables
|
||||
|
||||
import copy
|
||||
import base64
|
||||
|
||||
shifts = [[[0, 0], [1, 3], [2, 2], [3, 1]],
|
||||
[[0, 0], [1, 5], [2, 4], [3, 3]],
|
||||
[[0, 0], [1, 7], [3, 5], [4, 4]]]
|
||||
|
||||
# [keysize][block_size]
|
||||
num_rounds = {16: {16: 10, 24: 12, 32: 14}, 24: {16: 12, 24: 12, 32: 14}, 32: {16: 14, 24: 14, 32: 14}}
|
||||
|
||||
A = [[1, 1, 1, 1, 1, 0, 0, 0],
|
||||
[0, 1, 1, 1, 1, 1, 0, 0],
|
||||
[0, 0, 1, 1, 1, 1, 1, 0],
|
||||
[0, 0, 0, 1, 1, 1, 1, 1],
|
||||
[1, 0, 0, 0, 1, 1, 1, 1],
|
||||
[1, 1, 0, 0, 0, 1, 1, 1],
|
||||
[1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[1, 1, 1, 1, 0, 0, 0, 1]]
|
||||
|
||||
# produce log and alog tables, needed for multiplying in the
|
||||
# field GF(2^m) (generator = 3)
|
||||
alog = [1]
|
||||
for i in range(255):
|
||||
j = (alog[-1] << 1) ^ alog[-1]
|
||||
if j & 0x100 != 0:
|
||||
j ^= 0x11B
|
||||
alog.append(j)
|
||||
|
||||
log = [0] * 256
|
||||
for i in range(1, 255):
|
||||
log[alog[i]] = i
|
||||
|
||||
# multiply two elements of GF(2^m)
|
||||
def mul(a, b):
|
||||
if a == 0 or b == 0:
|
||||
return 0
|
||||
return alog[(log[a & 0xFF] + log[b & 0xFF]) % 255]
|
||||
|
||||
# substitution box based on F^{-1}(x)
|
||||
box = [[0] * 8 for i in range(256)]
|
||||
box[1][7] = 1
|
||||
for i in range(2, 256):
|
||||
j = alog[255 - log[i]]
|
||||
for t in range(8):
|
||||
box[i][t] = (j >> (7 - t)) & 0x01
|
||||
|
||||
B = [0, 1, 1, 0, 0, 0, 1, 1]
|
||||
|
||||
# affine transform: box[i] <- B + A*box[i]
|
||||
cox = [[0] * 8 for i in range(256)]
|
||||
for i in range(256):
|
||||
for t in range(8):
|
||||
cox[i][t] = B[t]
|
||||
for j in range(8):
|
||||
cox[i][t] ^= A[t][j] * box[i][j]
|
||||
|
||||
# S-boxes and inverse S-boxes
|
||||
S = [0] * 256
|
||||
Si = [0] * 256
|
||||
for i in range(256):
|
||||
S[i] = cox[i][0] << 7
|
||||
for t in range(1, 8):
|
||||
S[i] ^= cox[i][t] << (7-t)
|
||||
Si[S[i] & 0xFF] = i
|
||||
|
||||
# T-boxes
|
||||
G = [[2, 1, 1, 3],
|
||||
[3, 2, 1, 1],
|
||||
[1, 3, 2, 1],
|
||||
[1, 1, 3, 2]]
|
||||
|
||||
AA = [[0] * 8 for i in range(4)]
|
||||
|
||||
for i in range(4):
|
||||
for j in range(4):
|
||||
AA[i][j] = G[i][j]
|
||||
AA[i][i+4] = 1
|
||||
|
||||
for i in range(4):
|
||||
pivot = AA[i][i]
|
||||
if pivot == 0:
|
||||
t = i + 1
|
||||
while AA[t][i] == 0 and t < 4:
|
||||
t += 1
|
||||
assert t != 4, 'G matrix must be invertible'
|
||||
for j in range(8):
|
||||
AA[i][j], AA[t][j] = AA[t][j], AA[i][j]
|
||||
pivot = AA[i][i]
|
||||
for j in range(8):
|
||||
if AA[i][j] != 0:
|
||||
AA[i][j] = alog[(255 + log[AA[i][j] & 0xFF] - log[pivot & 0xFF]) % 255]
|
||||
for t in range(4):
|
||||
if i != t:
|
||||
for j in range(i+1, 8):
|
||||
AA[t][j] ^= mul(AA[i][j], AA[t][i])
|
||||
AA[t][i] = 0
|
||||
|
||||
iG = [[0] * 4 for i in range(4)]
|
||||
|
||||
for i in range(4):
|
||||
for j in range(4):
|
||||
iG[i][j] = AA[i][j + 4]
|
||||
|
||||
def mul4(a, bs):
|
||||
if a == 0:
|
||||
return 0
|
||||
r = 0
|
||||
for b in bs:
|
||||
r <<= 8
|
||||
if b != 0:
|
||||
r |= mul(a, b)
|
||||
return r
|
||||
|
||||
T1 = []
|
||||
T2 = []
|
||||
T3 = []
|
||||
T4 = []
|
||||
T5 = []
|
||||
T6 = []
|
||||
T7 = []
|
||||
T8 = []
|
||||
U1 = []
|
||||
U2 = []
|
||||
U3 = []
|
||||
U4 = []
|
||||
|
||||
for t in range(256):
|
||||
s = S[t]
|
||||
T1.append(mul4(s, G[0]))
|
||||
T2.append(mul4(s, G[1]))
|
||||
T3.append(mul4(s, G[2]))
|
||||
T4.append(mul4(s, G[3]))
|
||||
|
||||
s = Si[t]
|
||||
T5.append(mul4(s, iG[0]))
|
||||
T6.append(mul4(s, iG[1]))
|
||||
T7.append(mul4(s, iG[2]))
|
||||
T8.append(mul4(s, iG[3]))
|
||||
|
||||
U1.append(mul4(t, iG[0]))
|
||||
U2.append(mul4(t, iG[1]))
|
||||
U3.append(mul4(t, iG[2]))
|
||||
U4.append(mul4(t, iG[3]))
|
||||
|
||||
# round constants
|
||||
rcon = [1]
|
||||
r = 1
|
||||
for t in range(1, 30):
|
||||
r = mul(2, r)
|
||||
rcon.append(r)
|
||||
|
||||
del A
|
||||
del AA
|
||||
del pivot
|
||||
del B
|
||||
del G
|
||||
del box
|
||||
del log
|
||||
del alog
|
||||
del i
|
||||
del j
|
||||
del r
|
||||
del s
|
||||
del t
|
||||
del mul
|
||||
del mul4
|
||||
del cox
|
||||
del iG
|
||||
|
||||
class rijndael:
|
||||
def __init__(self, key, block_size = 16):
|
||||
if block_size != 16 and block_size != 24 and block_size != 32:
|
||||
raise ValueError('Invalid block size: ' + str(block_size))
|
||||
if len(key) != 16 and len(key) != 24 and len(key) != 32:
|
||||
raise ValueError('Invalid key size: ' + str(len(key)))
|
||||
self.block_size = block_size
|
||||
|
||||
ROUNDS = num_rounds[len(key)][block_size]
|
||||
BC = block_size // 4
|
||||
# encryption round keys
|
||||
Ke = [[0] * BC for i in range(ROUNDS + 1)]
|
||||
# decryption round keys
|
||||
Kd = [[0] * BC for i in range(ROUNDS + 1)]
|
||||
ROUND_KEY_COUNT = (ROUNDS + 1) * BC
|
||||
KC = len(key) // 4
|
||||
|
||||
# copy user material bytes into temporary ints
|
||||
tk = []
|
||||
for i in range(0, KC):
|
||||
tk.append((ord(key[i * 4]) << 24) | (ord(key[i * 4 + 1]) << 16) |
|
||||
(ord(key[i * 4 + 2]) << 8) | ord(key[i * 4 + 3]))
|
||||
|
||||
# copy values into round key arrays
|
||||
t = 0
|
||||
j = 0
|
||||
while j < KC and t < ROUND_KEY_COUNT:
|
||||
Ke[t // BC][t % BC] = tk[j]
|
||||
Kd[ROUNDS - (t // BC)][t % BC] = tk[j]
|
||||
j += 1
|
||||
t += 1
|
||||
tt = 0
|
||||
rconpointer = 0
|
||||
while t < ROUND_KEY_COUNT:
|
||||
# extrapolate using phi (the round key evolution function)
|
||||
tt = tk[KC - 1]
|
||||
tk[0] ^= (S[(tt >> 16) & 0xFF] & 0xFF) << 24 ^ \
|
||||
(S[(tt >> 8) & 0xFF] & 0xFF) << 16 ^ \
|
||||
(S[ tt & 0xFF] & 0xFF) << 8 ^ \
|
||||
(S[(tt >> 24) & 0xFF] & 0xFF) ^ \
|
||||
(rcon[rconpointer] & 0xFF) << 24
|
||||
rconpointer += 1
|
||||
if KC != 8:
|
||||
for i in range(1, KC):
|
||||
tk[i] ^= tk[i-1]
|
||||
else:
|
||||
for i in range(1, KC // 2):
|
||||
tk[i] ^= tk[i-1]
|
||||
tt = tk[KC // 2 - 1]
|
||||
tk[KC // 2] ^= (S[ tt & 0xFF] & 0xFF) ^ \
|
||||
(S[(tt >> 8) & 0xFF] & 0xFF) << 8 ^ \
|
||||
(S[(tt >> 16) & 0xFF] & 0xFF) << 16 ^ \
|
||||
(S[(tt >> 24) & 0xFF] & 0xFF) << 24
|
||||
for i in range(KC // 2 + 1, KC):
|
||||
tk[i] ^= tk[i-1]
|
||||
# copy values into round key arrays
|
||||
j = 0
|
||||
while j < KC and t < ROUND_KEY_COUNT:
|
||||
Ke[t // BC][t % BC] = tk[j]
|
||||
Kd[ROUNDS - (t // BC)][t % BC] = tk[j]
|
||||
j += 1
|
||||
t += 1
|
||||
# inverse MixColumn where needed
|
||||
for r in range(1, ROUNDS):
|
||||
for j in range(BC):
|
||||
tt = Kd[r][j]
|
||||
Kd[r][j] = U1[(tt >> 24) & 0xFF] ^ \
|
||||
U2[(tt >> 16) & 0xFF] ^ \
|
||||
U3[(tt >> 8) & 0xFF] ^ \
|
||||
U4[ tt & 0xFF]
|
||||
self.Ke = Ke
|
||||
self.Kd = Kd
|
||||
|
||||
def encrypt(self, plaintext):
|
||||
if len(plaintext) != self.block_size:
|
||||
raise ValueError('wrong block length, expected ' + str(self.block_size) + ' got ' + str(len(plaintext)))
|
||||
Ke = self.Ke
|
||||
|
||||
BC = self.block_size // 4
|
||||
ROUNDS = len(Ke) - 1
|
||||
if BC == 4:
|
||||
SC = 0
|
||||
elif BC == 6:
|
||||
SC = 1
|
||||
else:
|
||||
SC = 2
|
||||
s1 = shifts[SC][1][0]
|
||||
s2 = shifts[SC][2][0]
|
||||
s3 = shifts[SC][3][0]
|
||||
a = [0] * BC
|
||||
# temporary work array
|
||||
t = []
|
||||
# plaintext to ints + key
|
||||
for i in range(BC):
|
||||
t.append((ord(plaintext[i * 4 ]) << 24 |
|
||||
ord(plaintext[i * 4 + 1]) << 16 |
|
||||
ord(plaintext[i * 4 + 2]) << 8 |
|
||||
ord(plaintext[i * 4 + 3]) ) ^ Ke[0][i])
|
||||
# apply round transforms
|
||||
for r in range(1, ROUNDS):
|
||||
for i in range(BC):
|
||||
a[i] = (T1[(t[ i ] >> 24) & 0xFF] ^
|
||||
T2[(t[(i + s1) % BC] >> 16) & 0xFF] ^
|
||||
T3[(t[(i + s2) % BC] >> 8) & 0xFF] ^
|
||||
T4[ t[(i + s3) % BC] & 0xFF] ) ^ Ke[r][i]
|
||||
t = copy.copy(a)
|
||||
# last round is special
|
||||
result = []
|
||||
for i in range(BC):
|
||||
tt = Ke[ROUNDS][i]
|
||||
result.append((S[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF)
|
||||
result.append((S[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF)
|
||||
result.append((S[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF)
|
||||
result.append((S[ t[(i + s3) % BC] & 0xFF] ^ tt ) & 0xFF)
|
||||
return ''.join(map(chr, result))
|
||||
|
||||
def decrypt(self, ciphertext):
|
||||
if len(ciphertext) != self.block_size:
|
||||
raise ValueError('wrong block length, expected ' + str(self.block_size) + ' got ' + str(len(ciphertext)))
|
||||
Kd = self.Kd
|
||||
|
||||
BC = self.block_size // 4
|
||||
ROUNDS = len(Kd) - 1
|
||||
if BC == 4:
|
||||
SC = 0
|
||||
elif BC == 6:
|
||||
SC = 1
|
||||
else:
|
||||
SC = 2
|
||||
s1 = shifts[SC][1][1]
|
||||
s2 = shifts[SC][2][1]
|
||||
s3 = shifts[SC][3][1]
|
||||
a = [0] * BC
|
||||
# temporary work array
|
||||
t = [0] * BC
|
||||
# ciphertext to ints + key
|
||||
for i in range(BC):
|
||||
t[i] = (ord(ciphertext[i * 4 ]) << 24 |
|
||||
ord(ciphertext[i * 4 + 1]) << 16 |
|
||||
ord(ciphertext[i * 4 + 2]) << 8 |
|
||||
ord(ciphertext[i * 4 + 3]) ) ^ Kd[0][i]
|
||||
# apply round transforms
|
||||
for r in range(1, ROUNDS):
|
||||
for i in range(BC):
|
||||
a[i] = (T5[(t[ i ] >> 24) & 0xFF] ^
|
||||
T6[(t[(i + s1) % BC] >> 16) & 0xFF] ^
|
||||
T7[(t[(i + s2) % BC] >> 8) & 0xFF] ^
|
||||
T8[ t[(i + s3) % BC] & 0xFF] ) ^ Kd[r][i]
|
||||
t = copy.copy(a)
|
||||
# last round is special
|
||||
result = []
|
||||
for i in range(BC):
|
||||
tt = Kd[ROUNDS][i]
|
||||
result.append((Si[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF)
|
||||
result.append((Si[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF)
|
||||
result.append((Si[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF)
|
||||
result.append((Si[ t[(i + s3) % BC] & 0xFF] ^ tt ) & 0xFF)
|
||||
return ''.join(map(chr, result))
|
||||
|
||||
def encrypt(key, block):
|
||||
return rijndael(key, len(block)).encrypt(block)
|
||||
|
||||
def decrypt(key, block):
|
||||
return rijndael(key, len(block)).decrypt(block)
|
||||
|
||||
|
||||
class zeropad:
|
||||
def __init__(self, block_size):
|
||||
assert 0 < block_size < 256
|
||||
self.block_size = block_size
|
||||
|
||||
def pad(self, pt):
|
||||
ptlen = len(pt)
|
||||
padsize = self.block_size - ((ptlen + self.block_size - 1) % self.block_size + 1)
|
||||
return pt + "\0" * padsize
|
||||
|
||||
def unpad(self, ppt):
|
||||
assert len(ppt) % self.block_size == 0
|
||||
offset = len(ppt)
|
||||
if offset == 0:
|
||||
return ''
|
||||
end = offset - self.block_size + 1
|
||||
while offset > end:
|
||||
offset -= 1
|
||||
if ppt[offset] != "\0":
|
||||
return ppt[:offset + 1]
|
||||
assert False
|
||||
|
||||
class cbc:
|
||||
def __init__(self, padding, cipher, iv):
|
||||
assert padding.block_size == cipher.block_size
|
||||
assert len(iv) == cipher.block_size
|
||||
self.padding = padding
|
||||
self.cipher = cipher
|
||||
self.iv = iv
|
||||
|
||||
def encrypt(self, pt):
|
||||
ppt = self.padding.pad(pt)
|
||||
offset = 0
|
||||
ct = ''
|
||||
v = self.iv
|
||||
while offset < len(ppt):
|
||||
block = ppt[offset:offset + self.cipher.block_size]
|
||||
block = self.xorblock(block, v)
|
||||
block = self.cipher.encrypt(block)
|
||||
ct += block
|
||||
offset += self.cipher.block_size
|
||||
v = block
|
||||
return ct
|
||||
|
||||
def decrypt(self, ct):
|
||||
assert len(ct) % self.cipher.block_size == 0
|
||||
ppt = ''
|
||||
offset = 0
|
||||
v = self.iv
|
||||
while offset < len(ct):
|
||||
block = ct[offset:offset + self.cipher.block_size]
|
||||
decrypted = self.cipher.decrypt(block)
|
||||
ppt += self.xorblock(decrypted, v)
|
||||
offset += self.cipher.block_size
|
||||
v = block
|
||||
pt = self.padding.unpad(ppt)
|
||||
return pt
|
||||
|
||||
def xorblock(self, b1, b2):
|
||||
# sorry, not very Pythonesk
|
||||
i = 0
|
||||
r = ''
|
||||
while i < self.cipher.block_size:
|
||||
r += chr(ord(b1[i]) ^ ord(b2[i]))
|
||||
i += 1
|
||||
return r
|
||||
|
||||
|
||||
|
||||
def decryptRinjdael(key, iv, data, areBase64 = False):
|
||||
"""
|
||||
Where the magic happens
|
||||
|
||||
key -- AES key (string)
|
||||
IV -- IV thing (string)
|
||||
data -- data to decrypt (string)
|
||||
areBase64 -- if True, iv and data are passed in base64
|
||||
"""
|
||||
if areBase64:
|
||||
iv = base64.b64decode(iv).decode("latin_1")
|
||||
data = base64.b64decode(data).decode("latin_1")
|
||||
|
||||
r = rijndael(key, 32)
|
||||
p = zeropad(32)
|
||||
c = cbc(p, r, iv)
|
||||
return str(c.decrypt(data))
|
65
helpers/binaryHelper.py
Normal file
65
helpers/binaryHelper.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""That's basically packetHelper.py from pep.py, with some changes to make it work with replay files."""
|
||||
|
||||
from constants import dataTypes
|
||||
import struct
|
||||
|
||||
def uleb128Encode(num):
|
||||
arr = bytearray()
|
||||
length = 0
|
||||
if num == 0:
|
||||
return bytearray(b"\x00")
|
||||
while num > 0:
|
||||
arr.append(num & 127)
|
||||
num >>= 7
|
||||
if num != 0:
|
||||
arr[length] |= 128
|
||||
length+=1
|
||||
return arr
|
||||
|
||||
def packData(__data, __dataType):
|
||||
data = bytes()
|
||||
pack = True
|
||||
packType = "<B"
|
||||
if __dataType == dataTypes.bbytes:
|
||||
pack = False
|
||||
data = __data
|
||||
elif __dataType == dataTypes.string:
|
||||
pack = False
|
||||
if len(__data) == 0:
|
||||
data += b"\x00"
|
||||
else:
|
||||
data += b"\x0B"
|
||||
data += uleb128Encode(len(__data))
|
||||
data += str.encode(__data, "latin_1")
|
||||
elif __dataType == dataTypes.uInt16:
|
||||
packType = "<H"
|
||||
elif __dataType == dataTypes.sInt16:
|
||||
packType = "<h"
|
||||
elif __dataType == dataTypes.uInt32:
|
||||
packType = "<L"
|
||||
elif __dataType == dataTypes.sInt32:
|
||||
packType = "<l"
|
||||
elif __dataType == dataTypes.uInt64:
|
||||
packType = "<Q"
|
||||
elif __dataType == dataTypes.sInt64:
|
||||
packType = "<q"
|
||||
elif __dataType == dataTypes.string:
|
||||
packType = "<s"
|
||||
elif __dataType == dataTypes.ffloat:
|
||||
packType = "<f"
|
||||
elif __dataType == dataTypes.rawReplay:
|
||||
pack = False
|
||||
data += packData(len(__data), dataTypes.uInt32)
|
||||
data += __data
|
||||
if pack:
|
||||
data += struct.pack(packType, __data)
|
||||
return data
|
||||
|
||||
|
||||
def binaryWrite(structure = None):
|
||||
if structure is None:
|
||||
structure = []
|
||||
packetData = bytes()
|
||||
for i in structure:
|
||||
packetData += packData(i[0], i[1])
|
||||
return packetData
|
438
helpers/chatHelper.py
Normal file
438
helpers/chatHelper.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from common.log import logUtils as log
|
||||
from common.ripple import userUtils
|
||||
from constants import exceptions
|
||||
from constants import messageTemplates
|
||||
from constants import serverPackets
|
||||
from events import logoutEvent
|
||||
from objects import fokabot
|
||||
from objects import glob
|
||||
|
||||
|
||||
def joinChannel(userID = 0, channel = "", token = None, toIRC = True, force=False):
|
||||
"""
|
||||
Join a channel
|
||||
|
||||
:param userID: user ID of the user that joins the channel. Optional. token can be used instead.
|
||||
:param token: user token object of user that joins the channel. Optional. userID can be used instead.
|
||||
:param channel: channel name
|
||||
:param toIRC: if True, send this channel join event to IRC. Must be true if joining from bancho. Default: True
|
||||
:param force: whether to allow game clients to join #spect_ and #multi_ channels
|
||||
:return: 0 if joined or other IRC code in case of error. Needed only on IRC-side
|
||||
"""
|
||||
try:
|
||||
# Get token if not defined
|
||||
if token is None:
|
||||
token = glob.tokens.getTokenFromUserID(userID)
|
||||
# Make sure the token exists
|
||||
if token is None:
|
||||
raise exceptions.userNotFoundException
|
||||
else:
|
||||
token = token
|
||||
|
||||
# Normal channel, do check stuff
|
||||
# Make sure the channel exists
|
||||
if channel not in glob.channels.channels:
|
||||
raise exceptions.channelUnknownException()
|
||||
|
||||
# Make sure a game client is not trying to join a #multi_ or #spect_ channel manually
|
||||
channelObject = glob.channels.channels[channel]
|
||||
if channelObject.isSpecial and not token.irc and not force:
|
||||
raise exceptions.channelUnknownException()
|
||||
|
||||
# Add the channel to our joined channel
|
||||
token.joinChannel(channelObject)
|
||||
|
||||
# Send channel joined (IRC)
|
||||
if glob.irc and not toIRC:
|
||||
glob.ircServer.banchoJoinChannel(token.username, channel)
|
||||
|
||||
# Console output
|
||||
log.info("{} joined channel {}".format(token.username, channel))
|
||||
|
||||
# IRC code return
|
||||
return 0
|
||||
except exceptions.channelNoPermissionsException:
|
||||
log.warning("{} attempted to join channel {}, but they have no read permissions".format(token.username, channel))
|
||||
return 403
|
||||
except exceptions.channelUnknownException:
|
||||
log.warning("{} attempted to join an unknown channel ({})".format(token.username, channel))
|
||||
return 403
|
||||
except exceptions.userAlreadyInChannelException:
|
||||
log.warning("User {} already in channel {}".format(token.username, channel))
|
||||
return 403
|
||||
except exceptions.userNotFoundException:
|
||||
log.warning("User not connected to IRC/Bancho")
|
||||
return 403 # idk
|
||||
|
||||
def partChannel(userID = 0, channel = "", token = None, toIRC = True, kick = False, force=False):
|
||||
"""
|
||||
Part a channel
|
||||
|
||||
:param userID: user ID of the user that parts the channel. Optional. token can be used instead.
|
||||
:param token: user token object of user that parts the channel. Optional. userID can be used instead.
|
||||
:param channel: channel name
|
||||
:param toIRC: if True, send this channel join event to IRC. Must be true if joining from bancho. Optional. Default: True
|
||||
:param kick: if True, channel tab will be closed on client. Used when leaving lobby. Optional. Default: False
|
||||
:param force: whether to allow game clients to part #spect_ and #multi_ channels
|
||||
:return: 0 if joined or other IRC code in case of error. Needed only on IRC-side
|
||||
"""
|
||||
try:
|
||||
# Make sure the client is not drunk and sends partChannel when closing a PM tab
|
||||
if not channel.startswith("#"):
|
||||
return
|
||||
|
||||
# Get token if not defined
|
||||
if token is None:
|
||||
token = glob.tokens.getTokenFromUserID(userID)
|
||||
# Make sure the token exists
|
||||
if token is None:
|
||||
raise exceptions.userNotFoundException()
|
||||
else:
|
||||
token = token
|
||||
|
||||
# Determine internal/client name if needed
|
||||
# (toclient is used clientwise for #multiplayer and #spectator channels)
|
||||
channelClient = channel
|
||||
if channel == "#spectator":
|
||||
if token.spectating is None:
|
||||
s = userID
|
||||
else:
|
||||
s = token.spectatingUserID
|
||||
channel = "#spect_{}".format(s)
|
||||
elif channel == "#multiplayer":
|
||||
channel = "#multi_{}".format(token.matchID)
|
||||
elif channel.startswith("#spect_"):
|
||||
channelClient = "#spectator"
|
||||
elif channel.startswith("#multi_"):
|
||||
channelClient = "#multiplayer"
|
||||
|
||||
# Make sure the channel exists
|
||||
if channel not in glob.channels.channels:
|
||||
raise exceptions.channelUnknownException()
|
||||
|
||||
# Make sure a game client is not trying to join a #multi_ or #spect_ channel manually
|
||||
channelObject = glob.channels.channels[channel]
|
||||
if channelObject.isSpecial and not token.irc and not force:
|
||||
raise exceptions.channelUnknownException()
|
||||
|
||||
# Make sure the user is in the channel
|
||||
if channel not in token.joinedChannels:
|
||||
raise exceptions.userNotInChannelException()
|
||||
|
||||
# Part channel (token-side and channel-side)
|
||||
token.partChannel(channelObject)
|
||||
|
||||
# Delete temporary channel if everyone left
|
||||
if "chat/{}".format(channelObject.name) in glob.streams.streams:
|
||||
if channelObject.temp and len(glob.streams.streams["chat/{}".format(channelObject.name)].clients) - 1 == 0:
|
||||
glob.channels.removeChannel(channelObject.name)
|
||||
|
||||
# Force close tab if needed
|
||||
# NOTE: Maybe always needed, will check later
|
||||
if kick:
|
||||
token.enqueue(serverPackets.channelKicked(channelClient))
|
||||
|
||||
# IRC part
|
||||
if glob.irc and toIRC:
|
||||
glob.ircServer.banchoPartChannel(token.username, channel)
|
||||
|
||||
# Console output
|
||||
log.info("{} parted channel {} ({})".format(token.username, channel, channelClient))
|
||||
|
||||
# Return IRC code
|
||||
return 0
|
||||
except exceptions.channelUnknownException:
|
||||
log.warning("{} attempted to part an unknown channel ({})".format(token.username, channel))
|
||||
return 403
|
||||
except exceptions.userNotInChannelException:
|
||||
log.warning("{} attempted to part {}, but he's not in that channel".format(token.username, channel))
|
||||
return 442
|
||||
except exceptions.userNotFoundException:
|
||||
log.warning("User not connected to IRC/Bancho")
|
||||
return 442 # idk
|
||||
|
||||
def sendMessage(fro = "", to = "", message = "", token = None, toIRC = True):
|
||||
"""
|
||||
Send a message to osu!bancho and IRC server
|
||||
|
||||
:param fro: sender username. Optional. token can be used instead
|
||||
:param to: receiver channel (if starts with #) or username
|
||||
:param message: text of the message
|
||||
:param token: sender token object. Optional. fro can be used instead
|
||||
:param toIRC: if True, send the message to IRC. If False, send it to Bancho only. Default: True
|
||||
:return: 0 if joined or other IRC code in case of error. Needed only on IRC-side
|
||||
"""
|
||||
try:
|
||||
#tokenString = ""
|
||||
# Get token object if not passed
|
||||
if token is None:
|
||||
token = glob.tokens.getTokenFromUsername(fro)
|
||||
if token is None:
|
||||
raise exceptions.userNotFoundException()
|
||||
else:
|
||||
# token object alredy passed, get its string and its username (fro)
|
||||
fro = token.username
|
||||
#tokenString = token.token
|
||||
|
||||
# Make sure this is not a tournament client
|
||||
# if token.tournament:
|
||||
# raise exceptions.userTournamentException()
|
||||
|
||||
# Make sure the user is not in restricted mode
|
||||
if token.restricted:
|
||||
raise exceptions.userRestrictedException()
|
||||
|
||||
# Make sure the user is not silenced
|
||||
if token.isSilenced():
|
||||
raise exceptions.userSilencedException()
|
||||
|
||||
# Redirect !report to FokaBot
|
||||
if message.startswith("!report"):
|
||||
to = glob.BOT_NAME
|
||||
|
||||
# Determine internal name if needed
|
||||
# (toclient is used clientwise for #multiplayer and #spectator channels)
|
||||
toClient = to
|
||||
if to == "#spectator":
|
||||
if token.spectating is None:
|
||||
s = token.userID
|
||||
else:
|
||||
s = token.spectatingUserID
|
||||
to = "#spect_{}".format(s)
|
||||
elif to == "#multiplayer":
|
||||
to = "#multi_{}".format(token.matchID)
|
||||
elif to.startswith("#spect_"):
|
||||
toClient = "#spectator"
|
||||
elif to.startswith("#multi_"):
|
||||
toClient = "#multiplayer"
|
||||
|
||||
# Make sure the message is valid
|
||||
if not message.strip():
|
||||
raise exceptions.invalidArgumentsException()
|
||||
|
||||
# Truncate message if > 2048 characters
|
||||
message = message[:2048]+"..." if len(message) > 2048 else message
|
||||
|
||||
# Check for word filters
|
||||
message = glob.chatFilters.filterMessage(message)
|
||||
|
||||
# Build packet bytes
|
||||
packet = serverPackets.sendMessage(token.username, toClient, message)
|
||||
|
||||
# Send the message
|
||||
isChannel = to.startswith("#")
|
||||
if isChannel:
|
||||
# CHANNEL
|
||||
# Make sure the channel exists
|
||||
if to not in glob.channels.channels:
|
||||
raise exceptions.channelUnknownException()
|
||||
|
||||
# Make sure the channel is not in moderated mode
|
||||
if glob.channels.channels[to].moderated and not token.admin:
|
||||
raise exceptions.channelModeratedException()
|
||||
|
||||
# Make sure we are in the channel
|
||||
if to not in token.joinedChannels:
|
||||
# I'm too lazy to put and test the correct IRC error code here...
|
||||
# but IRC is not strict at all so who cares
|
||||
raise exceptions.channelNoPermissionsException()
|
||||
|
||||
# Make sure we have write permissions
|
||||
if not glob.channels.channels[to].publicWrite and not token.admin:
|
||||
raise exceptions.channelNoPermissionsException()
|
||||
|
||||
# Add message in buffer
|
||||
token.addMessageInBuffer(to, message)
|
||||
|
||||
# Everything seems fine, build recipients list and send packet
|
||||
glob.streams.broadcast("chat/{}".format(to), packet, but=[token.token])
|
||||
else:
|
||||
# USER
|
||||
# Make sure recipient user is connected
|
||||
recipientToken = glob.tokens.getTokenFromUsername(to)
|
||||
if recipientToken is None:
|
||||
raise exceptions.userNotFoundException()
|
||||
|
||||
# Make sure the recipient is not a tournament client
|
||||
#if recipientToken.tournament:
|
||||
# raise exceptions.userTournamentException()
|
||||
|
||||
# Make sure the recipient is not restricted or we are FokaBot
|
||||
if recipientToken.restricted and fro.lower() != glob.BOT_NAME:
|
||||
raise exceptions.userRestrictedException()
|
||||
|
||||
# TODO: Make sure the recipient has not disabled PMs for non-friends or he's our friend
|
||||
|
||||
# Away check
|
||||
if recipientToken.awayCheck(token.userID):
|
||||
sendMessage(to, fro, "\x01ACTION is away: {}\x01".format(recipientToken.awayMessage))
|
||||
|
||||
# Check message templates (mods/admins only)
|
||||
if message in messageTemplates.templates and token.admin:
|
||||
sendMessage(fro, to, messageTemplates.templates[message])
|
||||
|
||||
# Everything seems fine, send packet
|
||||
recipientToken.enqueue(packet)
|
||||
|
||||
# Send the message to IRC
|
||||
if glob.irc and toIRC:
|
||||
messageSplitInLines = message.encode("latin-1").decode("utf-8").split("\n")
|
||||
for line in messageSplitInLines:
|
||||
if line == messageSplitInLines[:1] and line == "":
|
||||
continue
|
||||
glob.ircServer.banchoMessage(fro, to, line)
|
||||
|
||||
# Spam protection (ignore FokaBot)
|
||||
if token.userID > 999:
|
||||
token.spamProtection()
|
||||
|
||||
# Fokabot message
|
||||
if isChannel or to == glob.BOT_NAME:
|
||||
fokaMessage = fokabot.fokabotResponse(token.username, to, message)
|
||||
if fokaMessage:
|
||||
sendMessage(glob.BOT_NAME, to if isChannel else fro, fokaMessage)
|
||||
|
||||
# File and discord logs (public chat only) (to make public only, if to.startswith("#") and not)
|
||||
if not (message.startswith("\x01ACTION is playing") and to.startswith("#spect_")):
|
||||
if isChannel:
|
||||
log.chat("[PUBLIC] {fro} @ {to}: {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
|
||||
glob.schiavo.sendChatlog("**{fro} @ {to}:** {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
|
||||
else:
|
||||
log.pm("[PRIVATE] {fro} @ {to}: {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
|
||||
glob.schiavo.sendChatlog("**{fro} @ {to}:** {message}".format(fro=token.username, to=to, message=message.encode("latin-1").decode("utf-8")))
|
||||
return 0
|
||||
except exceptions.userSilencedException:
|
||||
token.enqueue(serverPackets.silenceEndTime(token.getSilenceSecondsLeft()))
|
||||
log.warning("{} tried to send a message during silence".format(token.username))
|
||||
return 404
|
||||
except exceptions.channelModeratedException:
|
||||
log.warning("{} tried to send a message to a channel that is in moderated mode ({})".format(token.username, to))
|
||||
return 404
|
||||
except exceptions.channelUnknownException:
|
||||
log.warning("{} tried to send a message to an unknown channel ({})".format(token.username, to))
|
||||
return 403
|
||||
except exceptions.channelNoPermissionsException:
|
||||
log.warning("{} tried to send a message to channel {}, but they have no write permissions".format(token.username, to))
|
||||
return 404
|
||||
except exceptions.userRestrictedException:
|
||||
log.warning("{} tried to send a message {}, but the recipient is in restricted mode".format(token.username, to))
|
||||
return 404
|
||||
except exceptions.userTournamentException:
|
||||
log.warning("{} tried to send a message {}, but the recipient is a tournament client".format(token.username, to))
|
||||
return 404
|
||||
except exceptions.userNotFoundException:
|
||||
log.warning("User not connected to IRC/Bancho")
|
||||
return 401
|
||||
except exceptions.invalidArgumentsException:
|
||||
log.warning("{} tried to send an invalid message to {}".format(token.username, to))
|
||||
return 404
|
||||
|
||||
|
||||
""" IRC-Bancho Connect/Disconnect/Join/Part interfaces"""
|
||||
def fixUsernameForBancho(username):
|
||||
"""
|
||||
Convert username from IRC format (without spaces) to Bancho format (with spaces)
|
||||
|
||||
:param username: username to convert
|
||||
:return: converted username
|
||||
"""
|
||||
# If there are no spaces or underscores in the name
|
||||
# return it
|
||||
if " " not in username and "_" not in username:
|
||||
return username
|
||||
|
||||
# Exact match first
|
||||
result = glob.db.fetch("SELECT id FROM users WHERE username = %s LIMIT 1", [username])
|
||||
if result is not None:
|
||||
return username
|
||||
|
||||
# Username not found, replace _ with space
|
||||
return username.replace("_", " ")
|
||||
|
||||
def fixUsernameForIRC(username):
|
||||
"""
|
||||
Convert an username from Bancho format to IRC format (underscores instead of spaces)
|
||||
|
||||
:param username: username to convert
|
||||
:return: converted username
|
||||
"""
|
||||
return username.replace(" ", "_")
|
||||
|
||||
def IRCConnect(username):
|
||||
"""
|
||||
Handle IRC login bancho-side.
|
||||
Add token and broadcast login packet.
|
||||
|
||||
:param username: username
|
||||
:return:
|
||||
"""
|
||||
userID = userUtils.getID(username)
|
||||
if not userID:
|
||||
log.warning("{} doesn't exist".format(username))
|
||||
return
|
||||
glob.tokens.deleteOldTokens(userID)
|
||||
glob.tokens.addToken(userID, irc=True)
|
||||
glob.streams.broadcast("main", serverPackets.userPanel(userID))
|
||||
log.info("{} logged in from IRC".format(username))
|
||||
|
||||
def IRCDisconnect(username):
|
||||
"""
|
||||
Handle IRC logout bancho-side.
|
||||
Remove token and broadcast logout packet.
|
||||
|
||||
:param username: username
|
||||
:return:
|
||||
"""
|
||||
token = glob.tokens.getTokenFromUsername(username)
|
||||
if token is None:
|
||||
log.warning("{} doesn't exist".format(username))
|
||||
return
|
||||
logoutEvent.handle(token)
|
||||
log.info("{} disconnected from IRC".format(username))
|
||||
|
||||
def IRCJoinChannel(username, channel):
|
||||
"""
|
||||
Handle IRC channel join bancho-side.
|
||||
|
||||
:param username: username
|
||||
:param channel: channel name
|
||||
:return: IRC return code
|
||||
"""
|
||||
userID = userUtils.getID(username)
|
||||
if not userID:
|
||||
log.warning("{} doesn't exist".format(username))
|
||||
return
|
||||
# NOTE: This should have also `toIRC` = False` tho,
|
||||
# since we send JOIN message later on ircserver.py.
|
||||
# Will test this later
|
||||
return joinChannel(userID, channel)
|
||||
|
||||
def IRCPartChannel(username, channel):
|
||||
"""
|
||||
Handle IRC channel part bancho-side.
|
||||
|
||||
:param username: username
|
||||
:param channel: channel name
|
||||
:return: IRC return code
|
||||
"""
|
||||
userID = userUtils.getID(username)
|
||||
if not userID:
|
||||
log.warning("{} doesn't exist".format(username))
|
||||
return
|
||||
return partChannel(userID, channel)
|
||||
|
||||
def IRCAway(username, message):
|
||||
"""
|
||||
Handle IRC away command bancho-side.
|
||||
|
||||
:param username:
|
||||
:param message: away message
|
||||
:return: IRC return code
|
||||
"""
|
||||
userID = userUtils.getID(username)
|
||||
if not userID:
|
||||
log.warning("{} doesn't exist".format(username))
|
||||
return
|
||||
glob.tokens.getTokenFromUserID(userID).awayMessage = message
|
||||
return 305 if message == "" else 306
|
||||
|
148
helpers/config.py
Normal file
148
helpers/config.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import os
|
||||
import configparser
|
||||
|
||||
class config:
|
||||
"""
|
||||
config.ini object
|
||||
|
||||
config -- list with ini data
|
||||
default -- if true, we have generated a default config.ini
|
||||
"""
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
fileName = "" # config filename
|
||||
default = True
|
||||
|
||||
# Check if config.ini exists and load/generate it
|
||||
def __init__(self, __file):
|
||||
"""
|
||||
Initialize a config object
|
||||
|
||||
__file -- filename
|
||||
"""
|
||||
|
||||
self.fileName = __file
|
||||
if os.path.isfile(self.fileName):
|
||||
# config.ini found, load it
|
||||
self.config.read(self.fileName)
|
||||
self.default = False
|
||||
else:
|
||||
# config.ini not found, generate a default one
|
||||
self.generateDefaultConfig()
|
||||
self.default = True
|
||||
|
||||
|
||||
# Check if config.ini has all needed the keys
|
||||
def checkConfig(self):
|
||||
"""
|
||||
Check if this config has the required keys
|
||||
|
||||
return -- True if valid, False if not
|
||||
"""
|
||||
|
||||
try:
|
||||
# Try to get all the required keys
|
||||
self.config.get("db","host")
|
||||
self.config.get("db","username")
|
||||
self.config.get("db","password")
|
||||
self.config.get("db","database")
|
||||
self.config.get("db","workers")
|
||||
|
||||
self.config.get("redis","host")
|
||||
self.config.get("redis","port")
|
||||
self.config.get("redis","database")
|
||||
self.config.get("redis","password")
|
||||
|
||||
self.config.get("server","host")
|
||||
self.config.get("server","port")
|
||||
self.config.get("server", "debug")
|
||||
self.config.get("server", "beatmapcacheexpire")
|
||||
self.config.get("server", "serverurl")
|
||||
self.config.get("server", "banchourl")
|
||||
self.config.get("server", "threads")
|
||||
self.config.get("server", "apikey")
|
||||
|
||||
self.config.get("sentry","enable")
|
||||
self.config.get("sentry","dsn")
|
||||
|
||||
self.config.get("datadog", "enable")
|
||||
self.config.get("datadog", "apikey")
|
||||
self.config.get("datadog", "appkey")
|
||||
|
||||
self.config.get("osuapi","enable")
|
||||
self.config.get("osuapi","apiurl")
|
||||
self.config.get("osuapi","apikey")
|
||||
|
||||
self.config.get("cheesegull", "apiurl")
|
||||
|
||||
self.config.get("discord","enable")
|
||||
self.config.get("discord","boturl")
|
||||
self.config.get("discord", "devgroup")
|
||||
self.config.get("discord", "secretwebhook")
|
||||
|
||||
self.config.get("cono", "enable")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
# Generate a default config.ini
|
||||
def generateDefaultConfig(self):
|
||||
"""Open and set default keys for that config file"""
|
||||
|
||||
# Open config.ini in write mode
|
||||
f = open(self.fileName, "w")
|
||||
|
||||
# Set keys to config object
|
||||
self.config.add_section("db")
|
||||
self.config.set("db", "host", "localhost")
|
||||
self.config.set("db", "username", "root")
|
||||
self.config.set("db", "password", "")
|
||||
self.config.set("db", "database", "ripple")
|
||||
self.config.set("db", "workers", "16")
|
||||
|
||||
self.config.add_section("redis")
|
||||
self.config.set("redis", "host", "localhost")
|
||||
self.config.set("redis", "port", "6379")
|
||||
self.config.set("redis", "database", "0")
|
||||
self.config.set("redis", "password", "")
|
||||
|
||||
self.config.add_section("server")
|
||||
self.config.set("server", "host", "0.0.0.0")
|
||||
self.config.set("server", "port", "5002")
|
||||
self.config.set("server", "debug", "False")
|
||||
self.config.set("server", "beatmapcacheexpire", "86400")
|
||||
self.config.set("server", "serverurl", "http://127.0.0.1:5002")
|
||||
self.config.set("server", "banchourl", "http://127.0.0.1:5001")
|
||||
self.config.set("server", "threads", "16")
|
||||
self.config.set("server", "apikey", "changeme")
|
||||
|
||||
self.config.add_section("sentry")
|
||||
self.config.set("sentry", "enable", "False")
|
||||
self.config.set("sentry", "dsn", "")
|
||||
|
||||
self.config.add_section("datadog")
|
||||
self.config.set("datadog", "enable", "False")
|
||||
self.config.set("datadog", "apikey", "")
|
||||
self.config.set("datadog", "appkey", "")
|
||||
|
||||
self.config.add_section("osuapi")
|
||||
self.config.set("osuapi", "enable", "True")
|
||||
self.config.set("osuapi", "apiurl", "https://osu.ppy.sh")
|
||||
self.config.set("osuapi", "apikey", "YOUR_OSU_API_KEY_HERE")
|
||||
|
||||
self.config.add_section("cheesegull")
|
||||
self.config.set("cheesegull", "apiurl", "http://cheesegu.ll/api")
|
||||
|
||||
self.config.add_section("discord")
|
||||
self.config.set("discord", "enable", "False")
|
||||
self.config.set("discord", "boturl", "")
|
||||
self.config.set("discord", "devgroup", "")
|
||||
self.config.set("discord", "secretwebhook", "")
|
||||
|
||||
self.config.add_section("cono")
|
||||
self.config.set("cono", "enable", "False")
|
||||
|
||||
# Write ini to file and close
|
||||
self.config.write(f)
|
||||
f.close()
|
99
helpers/consoleHelper.py
Normal file
99
helpers/consoleHelper.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Some console related functions"""
|
||||
|
||||
from common.constants import bcolors
|
||||
from objects import glob
|
||||
|
||||
|
||||
def printServerStartHeader(asciiArt):
|
||||
"""
|
||||
Print server start header with optional ascii art
|
||||
|
||||
asciiArt -- if True, will print ascii art too
|
||||
"""
|
||||
|
||||
if asciiArt:
|
||||
printColored(" ( ( ", bcolors.YELLOW)
|
||||
printColored(" )\\ ) * ) )\\ ) ", bcolors.YELLOW)
|
||||
printColored("(()/( ( ` ) /((()/( ", bcolors.YELLOW)
|
||||
printColored(" /(_)) )\\ ( )(_))/(_)) ", bcolors.YELLOW)
|
||||
printColored("(_)) ((_) (_(_())(_)) ", bcolors.YELLOW)
|
||||
printColored("| | | __||_ _|/ __| ", bcolors.GREEN)
|
||||
printColored("| |__ | _| | | \\__ \\ ", bcolors.GREEN)
|
||||
printColored("|____||___| |_| |___/ \n", bcolors.GREEN)
|
||||
|
||||
printColored("> Welcome to the Latest Essential Tatoe Server v{}".format(glob.VERSION), bcolors.GREEN)
|
||||
printColored("> Made by the Ripple and Akatsuki teams", bcolors.GREEN)
|
||||
printColored("> {}https://github.com/cmyui/lets".format(bcolors.UNDERLINE), bcolors.GREEN)
|
||||
printColored("> Press CTRL+C to exit\n", bcolors.GREEN)
|
||||
|
||||
|
||||
def printNoNl(string):
|
||||
"""
|
||||
Print string without new line at the end
|
||||
|
||||
string -- string to print
|
||||
"""
|
||||
|
||||
print(string, end="")
|
||||
|
||||
|
||||
def printColored(string, color):
|
||||
"""
|
||||
Print colored string
|
||||
|
||||
string -- string to print
|
||||
color -- see bcolors.py
|
||||
"""
|
||||
|
||||
print("{}{}{}".format(color, string, bcolors.ENDC))
|
||||
|
||||
|
||||
def printError():
|
||||
"""Print error text FOR LOADING"""
|
||||
|
||||
printColored("Error", bcolors.RED)
|
||||
|
||||
|
||||
def printDone():
|
||||
"""Print error text FOR LOADING"""
|
||||
|
||||
printColored("Done", bcolors.GREEN)
|
||||
|
||||
|
||||
def printWarning():
|
||||
"""Print error text FOR LOADING"""
|
||||
|
||||
printColored("Warning", bcolors.YELLOW)
|
||||
|
||||
def printGetScoresMessage(message):
|
||||
printColored("[get_scores] {}".format(message), bcolors.PINK)
|
||||
|
||||
def printSubmitModularMessage(message):
|
||||
printColored("[submit_modular] {}".format(message), bcolors.YELLOW)
|
||||
|
||||
def printBanchoConnectMessage(message):
|
||||
printColored("[bancho_connect] {}".format(message), bcolors.YELLOW)
|
||||
|
||||
def printGetReplayMessage(message):
|
||||
printColored("[get_replay] {}".format(message), bcolors.PINK)
|
||||
|
||||
def printMapsMessage(message):
|
||||
printColored("[maps] {}".format(message), bcolors.PINK)
|
||||
|
||||
def printRippMessage(message):
|
||||
printColored("[ripp] {}".format(message), bcolors.GREEN)
|
||||
|
||||
# def printRippoppaiMessage(message):
|
||||
# printColored("[rippoppai] {}".format(message), bcolors.GREEN)
|
||||
|
||||
def printWifiPianoMessage(message):
|
||||
printColored("[wifipiano] {}".format(message), bcolors.GREEN)
|
||||
|
||||
def printDebugMessage(message):
|
||||
printColored("[debug] {}".format(message), bcolors.BLUE)
|
||||
|
||||
def printScreenshotsMessage(message):
|
||||
printColored("[screenshots] {}".format(message), bcolors.YELLOW)
|
||||
|
||||
def printApiMessage(module, message):
|
||||
printColored("[{}] {}".format(module, message), bcolors.GREEN)
|
17
helpers/exceptionsTracker.py
Normal file
17
helpers/exceptionsTracker.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
import traceback
|
||||
from functools import wraps
|
||||
|
||||
from common.log import logUtils as log
|
||||
|
||||
|
||||
def trackExceptions(moduleName=""):
|
||||
def _trackExceptions(func):
|
||||
def _decorator(request, *args, **kwargs):
|
||||
try:
|
||||
response = func(request, *args, **kwargs)
|
||||
return response
|
||||
except:
|
||||
log.error("Unknown error{}!\n```\n{}\n{}```".format(" in "+moduleName if moduleName != "" else "", sys.exc_info(), traceback.format_exc()), True)
|
||||
return wraps(func)(_decorator)
|
||||
return _trackExceptions
|
131
helpers/leaderboardHelper.py
Normal file
131
helpers/leaderboardHelper.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from common.log import logUtils as log
|
||||
from common.ripple import scoreUtils
|
||||
from objects import glob
|
||||
from common.ripple import userUtils
|
||||
|
||||
def rxgetRankInfo(userID, gameMode):
|
||||
"""
|
||||
Get userID's current rank, user above us and pp/score difference
|
||||
|
||||
:param userID: user
|
||||
:param gameMode: gameMode number
|
||||
:return: {"nextUsername": "", "difference": 0, "currentRank": 0}
|
||||
"""
|
||||
data = {"nextUsername": "", "difference": 0, "currentRank": 0}
|
||||
k = "ripple:relaxboard:{}".format(scoreUtils.readableGameMode(gameMode))
|
||||
position = userUtils.rxgetGameRank(userID, gameMode) - 1
|
||||
log.debug("Our position is {}".format(position))
|
||||
if position is not None and position > 0:
|
||||
aboveUs = glob.redis.zrevrange(k, position - 1, position)
|
||||
log.debug("{} is above us".format(aboveUs))
|
||||
if aboveUs is not None and len(aboveUs) > 0 and aboveUs[0].isdigit():
|
||||
# Get our rank, next rank username and pp/score difference
|
||||
myScore = glob.redis.zscore(k, userID)
|
||||
otherScore = glob.redis.zscore(k, aboveUs[0])
|
||||
nextUsername = userUtils.getUsername(aboveUs[0])
|
||||
if nextUsername is not None and myScore is not None and otherScore is not None:
|
||||
data["nextUsername"] = nextUsername
|
||||
data["difference"] = int(myScore) - int(otherScore)
|
||||
else:
|
||||
position = 0
|
||||
|
||||
data["currentRank"] = position + 1
|
||||
return data
|
||||
|
||||
def getRankInfo(userID, gameMode):
|
||||
"""
|
||||
Get userID's current rank, user above us and pp/score difference
|
||||
|
||||
:param userID: user
|
||||
:param gameMode: gameMode number
|
||||
:return: {"nextUsername": "", "difference": 0, "currentRank": 0}
|
||||
"""
|
||||
data = {"nextUsername": "", "difference": 0, "currentRank": 0}
|
||||
k = "ripple:leaderboard:{}".format(scoreUtils.readableGameMode(gameMode))
|
||||
position = userUtils.getGameRank(userID, gameMode) - 1
|
||||
log.debug("Our position is {}".format(position))
|
||||
if position is not None and position > 0:
|
||||
aboveUs = glob.redis.zrevrange(k, position - 1, position)
|
||||
log.debug("{} is above us".format(aboveUs))
|
||||
if aboveUs is not None and len(aboveUs) > 0 and aboveUs[0].isdigit():
|
||||
# Get our rank, next rank username and pp/score difference
|
||||
myScore = glob.redis.zscore(k, userID)
|
||||
otherScore = glob.redis.zscore(k, aboveUs[0])
|
||||
nextUsername = userUtils.getUsername(aboveUs[0])
|
||||
if nextUsername is not None and myScore is not None and otherScore is not None:
|
||||
data["nextUsername"] = nextUsername
|
||||
data["difference"] = int(myScore) - int(otherScore)
|
||||
else:
|
||||
position = 0
|
||||
|
||||
data["currentRank"] = position + 1
|
||||
return data
|
||||
|
||||
def rxupdate(userID, newScore, gameMode):
|
||||
"""
|
||||
Update gamemode's leaderboard.
|
||||
Doesn't do anything if userID is banned/restricted.
|
||||
|
||||
:param userID: user
|
||||
:param newScore: new score or pp
|
||||
:param gameMode: gameMode number
|
||||
"""
|
||||
if userUtils.isAllowed(userID):
|
||||
log.debug("Updating relaxboard...")
|
||||
glob.redis.zadd("ripple:relaxboard:{}".format(scoreUtils.readableGameMode(gameMode)), str(userID), str(newScore))
|
||||
else:
|
||||
log.debug("Relaxboard update for user {} skipped (not allowed)".format(userID))
|
||||
|
||||
def update(userID, newScore, gameMode):
|
||||
"""
|
||||
Update gamemode's leaderboard.
|
||||
Doesn't do anything if userID is banned/restricted.
|
||||
|
||||
:param userID: user
|
||||
:param newScore: new score or pp
|
||||
:param gameMode: gameMode number
|
||||
"""
|
||||
if userUtils.isAllowed(userID):
|
||||
log.debug("Updating leaderboard...")
|
||||
glob.redis.zadd("ripple:leaderboard:{}".format(scoreUtils.readableGameMode(gameMode)), str(userID), str(newScore))
|
||||
else:
|
||||
log.debug("Leaderboard update for user {} skipped (not allowed)".format(userID))
|
||||
|
||||
def rxupdateCountry(userID, newScore, gameMode):
|
||||
"""
|
||||
Update gamemode's country leaderboard.
|
||||
Doesn't do anything if userID is banned/restricted.
|
||||
|
||||
:param userID: user, country is determined by the user
|
||||
:param newScore: new score or pp
|
||||
:param gameMode: gameMode number
|
||||
:return:
|
||||
"""
|
||||
if userUtils.isAllowed(userID):
|
||||
country = userUtils.getCountry(userID)
|
||||
if country is not None and len(country) > 0 and country.lower() != "xx":
|
||||
log.debug("Updating {} country relaxboard...".format(country))
|
||||
k = "ripple:relaxboard:{}:{}".format(scoreUtils.readableGameMode(gameMode), country.lower())
|
||||
glob.redis.zadd(k, str(userID), str(newScore))
|
||||
else:
|
||||
log.debug("Country relaxboard update for user {} skipped (not allowed)".format(userID))
|
||||
|
||||
|
||||
def updateCountry(userID, newScore, gameMode):
|
||||
"""
|
||||
Update gamemode's country leaderboard.
|
||||
Doesn't do anything if userID is banned/restricted.
|
||||
|
||||
:param userID: user, country is determined by the user
|
||||
:param newScore: new score or pp
|
||||
:param gameMode: gameMode number
|
||||
:return:
|
||||
"""
|
||||
if userUtils.isAllowed(userID):
|
||||
country = userUtils.getCountry(userID)
|
||||
if country is not None and len(country) > 0 and country.lower() != "xx":
|
||||
log.debug("Updating {} country leaderboard...".format(country))
|
||||
k = "ripple:leaderboard:{}:{}".format(scoreUtils.readableGameMode(gameMode), country.lower())
|
||||
glob.redis.zadd(k, str(userID), str(newScore))
|
||||
else:
|
||||
log.debug("Country leaderboard update for user {} skipped (not allowed)".format(userID))
|
51
helpers/levbodHelper.py
Normal file
51
helpers/levbodHelper.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
from constants import exceptions
|
||||
from objects import glob
|
||||
|
||||
def levbodRequest(handler, params=None):
|
||||
if params is None:
|
||||
params = {}
|
||||
result = requests.get("{}/{}".format(glob.conf.config["levbod"]["url"], handler), params=params)
|
||||
|
||||
try:
|
||||
data = json.loads(result.text)
|
||||
except (json.JSONDecodeError, ValueError, requests.RequestException, KeyError, exceptions.noAPIDataError):
|
||||
return None
|
||||
|
||||
if result.status_code != 200 or "data" not in data:
|
||||
return None
|
||||
|
||||
return data["data"]
|
||||
|
||||
def getListing(rankedStatus=4, page=0, gameMode=-1, query=""):
|
||||
return levbodRequest("listing", {
|
||||
"mode": gameMode,
|
||||
"status": rankedStatus,
|
||||
"query": query,
|
||||
"page": page,
|
||||
})
|
||||
|
||||
def getBeatmapSet(id):
|
||||
return levbodRequest("beatmapset", {
|
||||
"id": id
|
||||
})
|
||||
|
||||
def getBeatmap(id):
|
||||
return levbodRequest("beatmap", {
|
||||
"id": id
|
||||
})
|
||||
|
||||
def levbodToDirect(data):
|
||||
s = "{beatmapset_id}.osz|{artist}|{title}|{creator}|{ranked_status}|10.00|0|{beatmapset_id}|".format(**data)
|
||||
if len(data["beatmaps"]) > 0:
|
||||
s += "{}|0|0|0||".format(data["beatmaps"][0]["beatmap_id"])
|
||||
for i in data["beatmaps"]:
|
||||
s += "{difficulty_name}@{game_mode},".format(**i)
|
||||
s = s.strip(",")
|
||||
s += "|"
|
||||
return s
|
||||
|
||||
def levbodToDirectNp(data):
|
||||
return "{beatmapset_id}.osz|{artist}|{title}|{creator}|{ranked_status}|10.00|0|{beatmapset_id}|{beatmapset_id}|0|0|0|".format(**data)
|
56
helpers/mapsHelper.py
Normal file
56
helpers/mapsHelper.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
|
||||
from common import generalUtils
|
||||
from common.log import logUtils as log
|
||||
from constants import exceptions
|
||||
from helpers import osuapiHelper
|
||||
|
||||
def isBeatmap(fileName=None, content=None):
|
||||
if fileName is not None:
|
||||
with open(fileName, "rb") as f:
|
||||
firstLine = f.readline().decode("utf-8-sig").strip()
|
||||
elif content is not None:
|
||||
try:
|
||||
firstLine = content.decode("utf-8-sig").split("\n")[0].strip()
|
||||
except IndexError:
|
||||
return False
|
||||
else:
|
||||
raise ValueError("Either `fileName` or `content` must be provided.")
|
||||
return firstLine.lower().startswith("osu file format v")
|
||||
|
||||
def cacheMap(mapFile, _beatmap):
|
||||
# Check if we have to download the .osu file
|
||||
download = False
|
||||
if not os.path.isfile(mapFile):
|
||||
# .osu file doesn't exist. We must download it
|
||||
download = True
|
||||
else:
|
||||
# File exists, check md5
|
||||
if generalUtils.fileMd5(mapFile) != _beatmap.fileMD5 or not isBeatmap(mapFile):
|
||||
# MD5 don't match, redownload .osu file
|
||||
download = True
|
||||
|
||||
# Download .osu file if needed
|
||||
if download:
|
||||
log.debug("maps ~> Downloading {} osu file".format(_beatmap.beatmapID))
|
||||
|
||||
# Get .osu file from osu servers
|
||||
fileContent = osuapiHelper.getOsuFileFromID(_beatmap.beatmapID)
|
||||
|
||||
# Make sure osu servers returned something
|
||||
if fileContent is None or not isBeatmap(content=fileContent):
|
||||
raise exceptions.osuApiFailException("maps")
|
||||
|
||||
# Delete old .osu file if it exists
|
||||
if os.path.isfile(mapFile):
|
||||
os.remove(mapFile)
|
||||
|
||||
# Save .osu file
|
||||
with open(mapFile, "wb+") as f:
|
||||
f.write(fileContent)
|
||||
else:
|
||||
# Map file is already in folder
|
||||
log.debug("maps ~> Beatmap found in cache!")
|
||||
|
||||
def cachedMapPath(beatmap_id):
|
||||
return ".data/beatmaps/{}.osu".format(beatmap_id)
|
83
helpers/osuapiHelper.py
Normal file
83
helpers/osuapiHelper.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from common.log import logUtils as log
|
||||
from common import generalUtils
|
||||
from objects import glob
|
||||
from constants import exceptions
|
||||
|
||||
|
||||
def osuApiRequest(request, params, getFirst=True):
|
||||
"""
|
||||
Send a request to osu!api.
|
||||
|
||||
request -- request type, string (es: get_beatmaps)
|
||||
params -- GET parameters, without api key or trailing ?/& (es: h=a5b99395a42bd55bc5eb1d2411cbdf8b&limit=10)
|
||||
return -- dictionary with json response if success, None if failed or empty response.
|
||||
"""
|
||||
# Make sure osuapi is enabled
|
||||
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
|
||||
log.warning("osu!api is disabled")
|
||||
return None
|
||||
|
||||
# Api request
|
||||
resp = None
|
||||
try:
|
||||
finalURL = "{}/api/{}?k={}&{}".format(glob.conf.config["osuapi"]["apiurl"], request, glob.conf.config["osuapi"]["apikey"], params)
|
||||
log.debug(finalURL)
|
||||
resp = requests.get(finalURL, timeout=5).text
|
||||
data = json.loads(resp)
|
||||
if getFirst:
|
||||
if len(data) >= 1:
|
||||
resp = data[0]
|
||||
else:
|
||||
resp = None
|
||||
else:
|
||||
resp = data
|
||||
finally:
|
||||
glob.dog.increment(glob.DATADOG_PREFIX+".osu_api.requests")
|
||||
log.debug(str(resp).encode("utf-8"))
|
||||
return resp
|
||||
|
||||
def getOsuFileFromName(fileName):
|
||||
"""
|
||||
Send a request to osu! servers to download a .osu file from file name
|
||||
Used to update beatmaps
|
||||
|
||||
fileName -- .osu file name to download
|
||||
return -- .osu file content if success, None if failed
|
||||
"""
|
||||
# Make sure osuapi is enabled
|
||||
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
|
||||
log.warning("osuapi is disabled")
|
||||
return None
|
||||
response = None
|
||||
try:
|
||||
URL = "{}/web/maps/{}".format(glob.conf.config["osuapi"]["apiurl"], quote(fileName))
|
||||
req = requests.get(URL, timeout=20)
|
||||
req.encoding = "utf-8"
|
||||
response = req.content
|
||||
finally:
|
||||
glob.dog.increment(glob.DATADOG_PREFIX+".osu_api.osu_file_requests")
|
||||
return response
|
||||
|
||||
def getOsuFileFromID(beatmapID):
|
||||
"""
|
||||
Send a request to osu! servers to download a .osu file from beatmap ID
|
||||
Used to get .osu files for oppai
|
||||
|
||||
beatmapID -- ID of beatmap (not beatmapset) to download
|
||||
return -- .osu file content if success, None if failed
|
||||
"""
|
||||
# Make sure osuapi is enabled
|
||||
if not generalUtils.stringToBool(glob.conf.config["osuapi"]["enable"]):
|
||||
log.warning("osuapi is disabled")
|
||||
return None
|
||||
response = None
|
||||
try:
|
||||
URL = "{}/osu/{}".format(glob.conf.config["osuapi"]["apiurl"], beatmapID)
|
||||
response = requests.get(URL, timeout=20).content
|
||||
finally:
|
||||
glob.dog.increment(glob.DATADOG_PREFIX+".osu_api.osu_file_requests")
|
||||
return response
|
134
helpers/replayHelper.py
Normal file
134
helpers/replayHelper.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
|
||||
from common import generalUtils
|
||||
from constants import exceptions, dataTypes
|
||||
from helpers import binaryHelper
|
||||
from objects import glob
|
||||
|
||||
def rxbuildFullReplay(scoreID=None, scoreData=None, rawReplay=None):
|
||||
if all(v is None for v in (scoreID, scoreData)) or all(v is not None for v in (scoreID, scoreData)):
|
||||
raise AttributeError("Either scoreID or scoreData must be provided, not neither or both")
|
||||
|
||||
if scoreData is None:
|
||||
scoreData = glob.db.fetch(
|
||||
"SELECT scores_relax.*, users.username FROM scores_relax LEFT JOIN users ON scores_relax.userid = users.id "
|
||||
"WHERE scores_relax.id = %s",
|
||||
[scoreID]
|
||||
)
|
||||
else:
|
||||
scoreID = scoreData["id"]
|
||||
if scoreData is None or scoreID is None:
|
||||
raise exceptions.scoreNotFoundError()
|
||||
|
||||
if rawReplay is None:
|
||||
# Make sure raw replay exists
|
||||
fileName = ".data/replays/replay_{}.osr".format(scoreID)
|
||||
if not os.path.isfile(fileName):
|
||||
raise FileNotFoundError()
|
||||
|
||||
# Read raw replay
|
||||
with open(fileName, "rb") as f:
|
||||
rawReplay = f.read()
|
||||
|
||||
# Calculate missing replay data
|
||||
rank = generalUtils.getRank(int(scoreData["play_mode"]), int(scoreData["mods"]), int(scoreData["accuracy"]),
|
||||
int(scoreData["300_count"]), int(scoreData["100_count"]), int(scoreData["50_count"]),
|
||||
int(scoreData["misses_count"]))
|
||||
magicHash = generalUtils.stringMd5(
|
||||
"{}p{}o{}o{}t{}a{}r{}e{}y{}o{}u{}{}{}".format(int(scoreData["100_count"]) + int(scoreData["300_count"]),
|
||||
scoreData["50_count"], scoreData["gekis_count"],
|
||||
scoreData["katus_count"], scoreData["misses_count"],
|
||||
scoreData["beatmap_md5"], scoreData["max_combo"],
|
||||
"True" if int(scoreData["full_combo"]) == 1 else "False",
|
||||
scoreData["username"], scoreData["score"], rank,
|
||||
scoreData["mods"], "True"))
|
||||
# Add headers (convert to full replay)
|
||||
fullReplay = binaryHelper.binaryWrite([
|
||||
[scoreData["play_mode"], dataTypes.byte],
|
||||
[20150414, dataTypes.uInt32],
|
||||
[scoreData["beatmap_md5"], dataTypes.string],
|
||||
[scoreData["username"], dataTypes.string],
|
||||
[magicHash, dataTypes.string],
|
||||
[scoreData["300_count"], dataTypes.uInt16],
|
||||
[scoreData["100_count"], dataTypes.uInt16],
|
||||
[scoreData["50_count"], dataTypes.uInt16],
|
||||
[scoreData["gekis_count"], dataTypes.uInt16],
|
||||
[scoreData["katus_count"], dataTypes.uInt16],
|
||||
[scoreData["misses_count"], dataTypes.uInt16],
|
||||
[scoreData["score"], dataTypes.uInt32],
|
||||
[scoreData["max_combo"], dataTypes.uInt16],
|
||||
[scoreData["full_combo"], dataTypes.byte],
|
||||
[scoreData["mods"], dataTypes.uInt32],
|
||||
[0, dataTypes.byte],
|
||||
[0, dataTypes.uInt64],
|
||||
[rawReplay, dataTypes.rawReplay],
|
||||
[0, dataTypes.uInt32],
|
||||
[0, dataTypes.uInt32],
|
||||
])
|
||||
|
||||
# Return full replay
|
||||
return fullReplay
|
||||
|
||||
def buildFullReplay(scoreID=None, scoreData=None, rawReplay=None):
|
||||
if all(v is None for v in (scoreID, scoreData)) or all(v is not None for v in (scoreID, scoreData)):
|
||||
raise AttributeError("Either scoreID or scoreData must be provided, not neither or both")
|
||||
|
||||
if scoreData is None:
|
||||
scoreData = glob.db.fetch(
|
||||
"SELECT scores.*, users.username FROM scores LEFT JOIN users ON scores.userid = users.id "
|
||||
"WHERE scores.id = %s",
|
||||
[scoreID]
|
||||
)
|
||||
else:
|
||||
scoreID = scoreData["id"]
|
||||
if scoreData is None or scoreID is None:
|
||||
raise exceptions.scoreNotFoundError()
|
||||
|
||||
if rawReplay is None:
|
||||
# Make sure raw replay exists
|
||||
fileName = ".data/replays/replay_{}.osr".format(scoreID)
|
||||
if not os.path.isfile(fileName):
|
||||
raise FileNotFoundError()
|
||||
|
||||
# Read raw replay
|
||||
with open(fileName, "rb") as f:
|
||||
rawReplay = f.read()
|
||||
|
||||
# Calculate missing replay data
|
||||
rank = generalUtils.getRank(int(scoreData["play_mode"]), int(scoreData["mods"]), int(scoreData["accuracy"]),
|
||||
int(scoreData["300_count"]), int(scoreData["100_count"]), int(scoreData["50_count"]),
|
||||
int(scoreData["misses_count"]))
|
||||
magicHash = generalUtils.stringMd5(
|
||||
"{}p{}o{}o{}t{}a{}r{}e{}y{}o{}u{}{}{}".format(int(scoreData["100_count"]) + int(scoreData["300_count"]),
|
||||
scoreData["50_count"], scoreData["gekis_count"],
|
||||
scoreData["katus_count"], scoreData["misses_count"],
|
||||
scoreData["beatmap_md5"], scoreData["max_combo"],
|
||||
"True" if int(scoreData["full_combo"]) == 1 else "False",
|
||||
scoreData["username"], scoreData["score"], rank,
|
||||
scoreData["mods"], "True"))
|
||||
# Add headers (convert to full replay)
|
||||
fullReplay = binaryHelper.binaryWrite([
|
||||
[scoreData["play_mode"], dataTypes.byte],
|
||||
[20150414, dataTypes.uInt32],
|
||||
[scoreData["beatmap_md5"], dataTypes.string],
|
||||
[scoreData["username"], dataTypes.string],
|
||||
[magicHash, dataTypes.string],
|
||||
[scoreData["300_count"], dataTypes.uInt16],
|
||||
[scoreData["100_count"], dataTypes.uInt16],
|
||||
[scoreData["50_count"], dataTypes.uInt16],
|
||||
[scoreData["gekis_count"], dataTypes.uInt16],
|
||||
[scoreData["katus_count"], dataTypes.uInt16],
|
||||
[scoreData["misses_count"], dataTypes.uInt16],
|
||||
[scoreData["score"], dataTypes.uInt32],
|
||||
[scoreData["max_combo"], dataTypes.uInt16],
|
||||
[scoreData["full_combo"], dataTypes.byte],
|
||||
[scoreData["mods"], dataTypes.uInt32],
|
||||
[0, dataTypes.byte],
|
||||
[0, dataTypes.uInt64],
|
||||
[rawReplay, dataTypes.rawReplay],
|
||||
[0, dataTypes.uInt32],
|
||||
[0, dataTypes.uInt32],
|
||||
])
|
||||
|
||||
# Return full replay
|
||||
return fullReplay
|
Reference in New Issue
Block a user