Skip to content

Commit

Permalink
Added mahjong helper and mhm
Browse files Browse the repository at this point in the history
  • Loading branch information
shinkuan committed Jan 25, 2024
1 parent a2bb07e commit 6cf5dc4
Show file tree
Hide file tree
Showing 18 changed files with 55,732 additions and 14 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@
/players/bot
/players/docker
/players/_docker
/players/bot.zip
/players/bot.zip
/common/proxinject
/log
Binary file added common/endless/mahjong-helper.exe
Binary file not shown.
1 change: 1 addition & 0 deletions config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"majsoul_account_ids":[24201683]}
159 changes: 159 additions & 0 deletions mhm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from rich.console import Console
from rich.logging import RichHandler
from collections import defaultdict
from dataclasses import dataclass, asdict, field
from os.path import exists
from os import environ
from json import load, dump
from logging import getLogger
from pathlib import Path

pRoot = Path(".")

pathConf = pRoot / "mhmp.json"
pathResVer = pRoot / "resver.json"


@dataclass
class ResVer:
version: str = None
emotes: dict[str, list] = None

@classmethod
def fromdict(cls, data: dict):
# purge
if "max_charid" in data:
data.pop("max_charid")
if "emos" in data:
data["emotes"] = data.pop("emos")
return cls(**data)


@dataclass
class Conf:
@dataclass
class Base:
log_level: str = "info"
pure_python_protobuf: bool = False

@dataclass
class Hook:
enable_skins: bool = True
enable_aider: bool = False
enable_chest: bool = False
random_star_char: bool = False
no_cheering_emotes: bool = False

mhm: Base = None
hook: Hook = None
dump: dict = None
mitmdump: dict = None
proxinject: dict = None

@classmethod
def default(cls):
return cls(
mhm=cls.Base(),
hook=cls.Hook(),
dump={"with_dumper": False, "with_termlog": True},
mitmdump={"http2": False, "mode": ["[email protected]:7070"]},
proxinject={"name": "jantama_mahjongsoul", "set-proxy": "127.0.0.1:7070"},
)

@classmethod
def fromdict(cls, data: dict):
# purge
if "server" in data:
data.pop("server")
if "plugin" in data:
data["hook"] = data.pop("plugin")
# to dataclass
for key, struct in [("mhm", cls.Base), ("hook", cls.Hook)]:
if key in data:
data[key] = struct(**data[key])
return cls(**data)


if exists(pathConf):
conf = Conf.fromdict(load(open(pathConf, "r")))
else:
conf = Conf.default()

if exists(pathResVer):
resver = ResVer.fromdict(load(open(pathResVer, "r")))
else:
resver = ResVer()


def fetch_resver():
"""Fetch the latest character id and emojis"""
import requests
import random
import re

rand_a: int = random.randint(0, int(1e9))
rand_b: int = random.randint(0, int(1e9))

ver_url = f"https://game.maj-soul.com/1/version.json?randv={rand_a}{rand_b}"
response = requests.get(ver_url, proxies={"https": None})
response.raise_for_status()
version: str = response.json().get("version")

if resver.version == version:
return

res_url = f"https://game.maj-soul.com/1/resversion{version}.json"
response = requests.get(res_url, proxies={"https": None})
response.raise_for_status()
res_data: dict = response.json()

emotes: defaultdict[str, list[int]] = defaultdict(list)
pattern = rf"en\/extendRes\/emo\/e(\d+)\/(\d+)\.png"

for text in res_data.get("res"):
matches = re.search(pattern, text)

if matches:
charid = matches.group(1)
emo = int(matches.group(2))

if emo == 13:
continue
emotes[charid].append(emo)
for value in emotes.values():
value.sort()

resver.version = version
resver.emotes = {key: value[9:] for key, value in sorted(emotes.items())}

with open(pathResVer, "w") as f:
dump(asdict(resver), f)


def no_cheering_emotes():
exclude = set(range(13, 19))
for emo in resver.emotes.values():
emo[:] = sorted(set(emo) - exclude)


def init():
with console.status("[magenta]Fetch the latest server version") as status:
fetch_resver()
if conf.hook.no_cheering_emotes:
no_cheering_emotes()
if conf.mhm.pure_python_protobuf:
environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"

with open(pathConf, "w") as f:
dump(asdict(conf), f, indent=2)


# console
console = Console()


# logger
logger = getLogger(__name__)
logger.propagate = False
logger.setLevel(conf.mhm.log_level.upper())
logger.addHandler(RichHandler(markup=True, rich_tracebacks=True))
4 changes: 4 additions & 0 deletions mhm/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .common import main

if __name__ == "__main__":
main()
51 changes: 51 additions & 0 deletions mhm/addons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from mitmproxy import http


from . import logger
from .hook import hooks
from .proto import MsgManager


def log(mger: MsgManager):
msg = mger.m
logger.info(f"[i][gold1]& {mger.tag} {msg.type.name} {msg.method} {msg.id}")
logger.debug(f"[cyan3]# {msg.amended} {msg.data}")


class WebSocketAddon:
def __init__(self):
self.manager = MsgManager()

def websocket_start(self, flow: http.HTTPFlow):
logger.info(" ".join(["[i][green]Connected", flow.id[:13]]))

def websocket_end(self, flow: http.HTTPFlow):
logger.info(" ".join(["[i][blue]Disconnected", flow.id[:13]]))

def websocket_message(self, flow: http.HTTPFlow):
# make type checker happy
assert flow.websocket is not None

try:
self.manager.parse(flow)
except:
logger.warning(" ".join(["[i][red]Unsupported Message @", flow.id[:13]]))
logger.debug(__import__("traceback").format_exc())

return

if self.manager.member:
for hook in hooks:
try:
hook.hook(self.manager)
except:
logger.warning(" ".join(["[i][red]Error", self.manager.m.method]))
logger.debug(__import__("traceback").format_exc())

if self.manager.m.amended:
self.manager.apply()

log(self.manager)


addons = [WebSocketAddon()]
62 changes: 62 additions & 0 deletions mhm/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import asyncio

from . import pRoot, logger, conf, resver, init


PROXINJECTOR = pRoot / "common/proxinject/proxinjector-cli"


def _cmd(dict):
return [obj for key, value in dict.items() for obj in (f"--{key}", value)]


async def start_proxy():
from mitmproxy.tools.dump import DumpMaster
from mitmproxy.options import Options
from .addons import addons

master = DumpMaster(Options(**conf.mitmdump), **conf.dump)
master.addons.add(*addons)
await master.run()
return master


async def start_inject():
cmd = [PROXINJECTOR, *_cmd(conf.proxinject)]

while True:
process = await asyncio.subprocess.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)

stdout, stderr = await process.communicate()

await asyncio.sleep(0.8)


def main():
async def start():
logger.info(f"[i]log level: {conf.mhm.log_level}")
logger.info(f"[i]pure python protobuf: {conf.mhm.pure_python_protobuf}")

logger.info(f"[i]version: {resver.version}")
logger.info(f"[i]characters: {len(resver.emotes)}")

tasks = set()

if conf.mitmdump:
tasks.add(start_proxy())
logger.info(f"[i]mitmdump launched @ {len(conf.mitmdump.get('mode'))} mode")

# if conf.proxinject:
# tasks.add(start_inject())
# logger.info(f"[i]proxinject launched @ {conf.proxinject.get('set-proxy')}")

await asyncio.gather(*tasks)

init()

try:
asyncio.run(start())
except KeyboardInterrupt:
pass
38 changes: 38 additions & 0 deletions mhm/hook/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from mhm import conf
from mhm.proto import MsgManager, MsgType


class Hook:
def __init__(self) -> None:
self.mapHook = {}

def hook(self, mger: MsgManager):
mKey = (mger.m.type, mger.m.method)
if mKey in self.mapHook:
self.mapHook[mKey](mger)

def bind(self, mType: MsgType, mMethod: str):
def decorator(func):
mKey = (mType, mMethod)
self.mapHook[mKey] = func
return func

return decorator


hooks: list[Hook] = []

if conf.hook.enable_aider:
from .aider import DerHook

hooks.append(DerHook())

if conf.hook.enable_chest:
from .chest import OstHook

hooks.append(OstHook())

if conf.hook.enable_skins:
from .skins import KinHook

hooks.append(KinHook())
55 changes: 55 additions & 0 deletions mhm/hook/aider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os
import requests

from urllib3 import disable_warnings
from urllib3.exceptions import InsecureRequestWarning
from socket import socket, AF_INET, SOCK_STREAM

from mhm import pRoot
from mhm.proto import MsgManager
from mhm.hook import Hook


class DerHook(Hook):
def __init__(self) -> None:
self.pool = dict()

disable_warnings(InsecureRequestWarning)

self.path = pRoot / "common/endless/mahjong-helper"

def hook(self, mger: MsgManager):
if mger.m.isReq():
return

if mger.member not in self.pool:
self.pool[mger.member] = Aider(self.path)

if mger.m.method == ".lq.ActionPrototype":
if mger.data["name"] == "ActionNewRound":
mger.data["data"]["md5"] = mger.data["data"]["sha256"][:32]
send_msg = mger.data["data"]
elif mger.m.method == ".lq.FastTest.syncGame":
for action in mger.data["game_restore"]["actions"]:
if action["name"] == "ActionNewRound":
action["data"]["md5"] = action["data"]["sha256"][:32]
send_msg = {"sync_game_actions": mger.data["game_restore"]["actions"]}
else:
send_msg = mger.data

requests.post(self.pool[mger.member].api, json=send_msg, verify=0)


class Aider:
PORT = 43410

def __init__(self, path: str) -> None:
with socket(AF_INET, SOCK_STREAM) as s:
s.settimeout(0.2)
if s.connect_ex(("127.0.0.1", Aider.PORT)) != 0:
cmd = f'start cmd /c "title Console · 🀄 && {path} -majsoul -p {Aider.PORT}"'
os.system(cmd)

self.api = f"https://127.0.0.1:{Aider.PORT}"

Aider.PORT += 1
Loading

0 comments on commit 6cf5dc4

Please sign in to comment.