diff --git a/app/core/config.py b/app/core/config.py index 2e1311a..3aec7a0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -426,11 +426,14 @@ class MaaUserConfig(LQConfig): self.Info_GameId_1 = ConfigItem("Info", "GameId_1", "-") self.Info_GameId_2 = ConfigItem("Info", "GameId_2", "-") self.Info_GameId_Remain = ConfigItem("Info", "GameId_Remain", "-") + self.Info_IfSkland = ConfigItem("Info", "IfSkland", False, BoolValidator()) + self.Info_SklandToken = ConfigItem("Info", "SklandToken", "") self.Data_LastProxyDate = ConfigItem("Data", "LastProxyDate", "2000-01-01") self.Data_LastAnnihilationDate = ConfigItem( "Data", "LastAnnihilationDate", "2000-01-01" ) + self.Data_LastSklandDate = ConfigItem("Data", "LastSklandDate", "2000-01-01") self.Data_ProxyTimes = ConfigItem( "Data", "ProxyTimes", 0, RangeValidator(0, 1024) ) @@ -572,7 +575,7 @@ class MaaPlanConfig(LQConfig): class AppConfig(GlobalConfig): - VERSION = "4.3.9.0" + VERSION = "4.3.10.1" gameid_refreshed = Signal() PASSWORD_refreshed = Signal() @@ -1256,6 +1259,10 @@ class AppConfig(GlobalConfig): user_config.Data_LastAnnihilationDate, info["Config"]["Data"]["LastAnnihilationDate"], ) + user_config.set( + user_config.Data_LastSklandDate, + info["Config"]["Data"]["LastSklandDate"], + ) user_config.set( user_config.Data_ProxyTimes, info["Config"]["Data"]["ProxyTimes"] ) diff --git a/app/models/MAA.py b/app/models/MAA.py index 0ea319e..2d25ea2 100644 --- a/app/models/MAA.py +++ b/app/models/MAA.py @@ -32,14 +32,18 @@ import subprocess import shutil import time import re +import hashlib +import hmac +import requests import win32com.client from datetime import datetime, timedelta from pathlib import Path +from urllib import parse from jinja2 import Environment, FileSystemLoader from typing import Union, List, Dict from app.core import Config, MaaConfig, MaaUserConfig -from app.services import Notify, System +from app.services import Notify, Crypto, System class MaaManager(QObject): @@ -220,6 +224,34 @@ class MaaManager(QObject): user_logs_list = [] user_start_time = datetime.now() + if user_data["Info"]["IfSkland"] and user_data["Info"]["SklandToken"]: + + if user_data["Data"]["LastSklandDate"] != datetime.now().strftime( + "%Y-%m-%d" + ): + + self.update_log_text.emit("正在执行森空岛签到中\n请稍候~") + + skland_result = self.skland_sign( + Crypto.win_decryptor(user_data["Info"]["SklandToken"]) + ) + + if skland_result["total"] > 0 and skland_result["fail"] == 0: + user_data["Data"][ + "LastSklandDate" + ] = datetime.now().strftime("%Y-%m-%d") + self.play_sound.emit("森空岛签到成功") + else: + self.play_sound.emit("森空岛签到失败") + + elif user_data["Info"]["IfSkland"]: + logger.warning( + f"{self.name} | 用户: {user[0]} - 未配置森空岛签到Token,跳过森空岛签到" + ) + self.push_info_bar.emit( + "warning", "森空岛签到失败", "未配置鹰角网络通行证登录凭证", -1 + ) + # 剿灭-日常模式循环 for mode in ["Annihilation", "Routine"]: @@ -1747,6 +1779,216 @@ class MaaManager(QObject): with self.maa_tasks_path.open(mode="w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=4) + def skland_sign(self, token) -> dict: + """森空岛签到""" + + app_code = "4ca99fa6b56cc2ba" + # 用于获取grant code + grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant" + # 用于获取cred + cred_code_url = ( + "https://zonai.skland.com/api/v1/user/auth/generate_cred_by_code" + ) + # 查询角色绑定 + binding_url = "https://zonai.skland.com/api/v1/game/player/binding" + # 签到接口 + sign_url = "https://zonai.skland.com/api/v1/game/attendance" + + # 基础请求头 + header = { + "cred": "", + "User-Agent": "Skland/1.5.1 (com.hypergryph.skland; build:100501001; Android 34;) Okhttp/4.11.0", + "Accept-Encoding": "gzip", + "Connection": "close", + } + header_login = header.copy() + header_for_sign = { + "platform": "1", + "timestamp": "", + "dId": "", + "vName": "1.5.1", + } + + # 生成签名 + def generate_signature(token_for_sign: str, path, body_or_query): + """ + 生成请求签名 + :param token_for_sign: 用于加密的token + :param path: 请求路径(如 /api/v1/game/player/binding) + :param body_or_query: GET用query字符串,POST用body字符串 + :return: (sign, 新的header_for_sign字典) + """ + t = str(int(time.time()) - 2) # 时间戳,-2秒以防服务器时间不一致 + token_bytes = token_for_sign.encode("utf-8") + header_ca = dict(header_for_sign) + header_ca["timestamp"] = t + header_ca_str = json.dumps(header_ca, separators=(",", ":")) + s = path + body_or_query + t + header_ca_str # 拼接原始字符串 + # HMAC-SHA256 + MD5得到最终sign + hex_s = hmac.new(token_bytes, s.encode("utf-8"), hashlib.sha256).hexdigest() + md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest() + return md5, header_ca + + # 获取带签名的header + def get_sign_header(url: str, method, body, old_header, sign_token): + """ + 获取带签名的请求头 + :param url: 请求完整url + :param method: 请求方式 GET/POST + :param body: POST请求体或GET时为None + :param old_header: 原始请求头 + :param sign_token: 当前会话的签名token + :return: 新请求头 + """ + h = json.loads(json.dumps(old_header)) + p = parse.urlparse(url) + if method.lower() == "get": + sign, header_ca = generate_signature(sign_token, p.path, p.query) + else: + sign, header_ca = generate_signature( + sign_token, p.path, json.dumps(body) if body else "" + ) + h["sign"] = sign + for i in header_ca: + h[i] = header_ca[i] + return h + + # 复制请求头并添加cred + def copy_header(cred): + v = json.loads(json.dumps(header)) + v["cred"] = cred + return v + + # 使用token一步步拿到cred和sign_token + def login_by_token(token_code): + """ + :param token_code: 你的skyland token + :return: (cred, sign_token) + """ + try: + # token为json对象时提取data.content + t = json.loads(token_code) + token_code = t["data"]["content"] + except: + pass + grant_code = get_grant_code(token_code) + return get_cred(grant_code) + + # 通过grant code换cred和sign_token + def get_cred(grant): + rsp = requests.post( + cred_code_url, json={"code": grant, "kind": 1}, headers=header_login + ).json() + if rsp["code"] != 0: + raise Exception(f'获得cred失败:{rsp.get("messgae")}') + sign_token = rsp["data"]["token"] + cred = rsp["data"]["cred"] + return cred, sign_token + + # 通过token换grant code + def get_grant_code(token): + rsp = requests.post( + grant_code_url, + json={"appCode": app_code, "token": token, "type": 0}, + headers=header_login, + ).json() + if rsp["status"] != 0: + raise Exception( + f'使用token: {token} 获得认证代码失败:{rsp.get("msg")}' + ) + return rsp["data"]["code"] + + # 获取已绑定的角色列表 + def get_binding_list(cred, sign_token): + """ + 查询绑定的角色 + :param cred: 当前cred + :param sign_token: 当前sign_token + :return: 角色列表 + """ + v = [] + rsp = requests.get( + binding_url, + headers=get_sign_header( + binding_url, "get", None, copy_header(cred), sign_token + ), + ).json() + if rsp["code"] != 0: + logger.error(f"{self.name} | 请求角色列表出现问题:{rsp['message']}") + if rsp.get("message") == "用户未登录": + logger.error(f"{self.name} | 用户登录可能失效了,请重新登录!") + return v + # 只取明日方舟(arknights)的绑定账号 + for i in rsp["data"]["list"]: + if i.get("appCode") != "arknights": + continue + v.extend(i.get("bindingList")) + return v + + # 执行签到 + def do_sign(cred, sign_token): + """ + 对所有绑定的角色进行签到 + :param cred: 当前cred + :param sign_token: 当前sign_token + :return: (签到成功数量, 失败数量) + """ + characters = get_binding_list(cred, sign_token) + success_num = 0 + fail_num = 0 + for i in characters: + body = {"uid": i.get("uid"), "gameId": i.get("channelMasterId")} + rsp = requests.post( + sign_url, + headers=get_sign_header( + sign_url, "post", body, copy_header(cred), sign_token + ), + json=body, + ).json() + if rsp["code"] != 0: + if rsp.get("message") == "请勿重复签到!": + self.push_info_bar.emit( + "warning", + "森空岛重复签到", + f"{i.get("nickName")}({i.get("channelName")})", + -1, + ) + success_num += 1 + continue + else: + self.push_info_bar.emit( + "warning", + "森空岛签到失败", + f"{i.get("nickName")}({i.get("channelName")})", + -1, + ) + fail_num += 1 + continue + awards = rsp["data"]["awards"] + for j in awards: + res = j["resource"] + self.push_info_bar.emit( + "success", + "森空岛签到成功", + f"{i.get("nickName")}({i.get("channelName")})", + 3, + ) + success_num += 1 + return success_num, fail_num + + # 主流程 + try: + # 拿到cred和sign_token + cred, sign_token = login_by_token(token) + self.sleep(1) + # 依次签到 + success, fail = do_sign(cred, sign_token) + self.sleep(3) + return {"success": success, "fail": fail, "total": success + fail} + except Exception as e: + logger.error(f"{self.name} | 森空岛签到失败: {e}") + return {"success": 0, "fail": 0, "total": 0} + def push_notification( self, mode: str, diff --git a/app/ui/Widget.py b/app/ui/Widget.py index 16b40be..2e32620 100644 --- a/app/ui/Widget.py +++ b/app/ui/Widget.py @@ -608,6 +608,83 @@ class PushAndSwitchButtonSettingCard(SettingCard): self.switchButton.setText("开" if isChecked else "关") +class PasswordLineAndSwitchButtonSettingCard(SettingCard): + """Setting card with PasswordLineEdit and SwitchButton""" + + textChanged = Signal() + + def __init__( + self, + icon: Union[str, QIcon, FluentIconBase], + title: str, + content: Union[str, None], + text: str, + algorithm: str, + qconfig: QConfig, + configItem_bool: ConfigItem, + configItem_info: ConfigItem, + parent=None, + ): + + super().__init__(icon, title, content, parent) + self.algorithm = algorithm + self.qconfig = qconfig + self.configItem_bool = configItem_bool + self.configItem_info = configItem_info + self.LineEdit = PasswordLineEdit(self) + self.LineEdit.setMinimumWidth(200) + self.LineEdit.setPlaceholderText(text) + if algorithm == "AUTO": + self.LineEdit.setViewPasswordButtonVisible(False) + self.SwitchButton = SwitchButton(self) + + self.hBoxLayout.addWidget(self.LineEdit, 0, Qt.AlignRight) + self.hBoxLayout.addSpacing(16) + self.hBoxLayout.addWidget(self.SwitchButton, 0, Qt.AlignRight) + self.hBoxLayout.addSpacing(16) + + self.configItem_info.valueChanged.connect(self.setInfo) + self.LineEdit.textChanged.connect(self.__textChanged) + self.configItem_bool.valueChanged.connect(self.SwitchButton.setChecked) + self.SwitchButton.checkedChanged.connect( + lambda isChecked: self.qconfig.set(self.configItem_bool, isChecked) + ) + + self.setInfo(self.qconfig.get(configItem_info)) + self.SwitchButton.setChecked(self.qconfig.get(configItem_bool)) + + def __textChanged(self, content: str): + + self.configItem_info.valueChanged.disconnect(self.setInfo) + if self.algorithm == "DPAPI": + self.qconfig.set(self.configItem_info, Crypto.win_encryptor(content)) + elif self.algorithm == "AUTO": + self.qconfig.set(self.configItem_info, Crypto.AUTO_encryptor(content)) + self.configItem_info.valueChanged.connect(self.setInfo) + + self.textChanged.emit() + + def setInfo(self, content: str): + + self.LineEdit.textChanged.disconnect(self.__textChanged) + if self.algorithm == "DPAPI": + self.LineEdit.setText(Crypto.win_decryptor(content)) + elif self.algorithm == "AUTO": + if Crypto.check_PASSWORD(Config.PASSWORD): + self.LineEdit.setText(Crypto.AUTO_decryptor(content, Config.PASSWORD)) + self.LineEdit.setPasswordVisible(True) + self.LineEdit.setReadOnly(False) + elif Config.PASSWORD: + self.LineEdit.setText("管理密钥错误") + self.LineEdit.setPasswordVisible(True) + self.LineEdit.setReadOnly(True) + else: + self.LineEdit.setText("************") + self.LineEdit.setPasswordVisible(False) + self.LineEdit.setReadOnly(True) + self.LineEdit.textChanged.connect(self.__textChanged) + + class PushAndComboBoxSettingCard(SettingCard): """Setting card with push & combo box""" @@ -1129,6 +1206,13 @@ class UserLableSettingCard(SettingCard): == Config.server_date().isocalendar()[:2] else "本周剿灭未完成" ) + if self.qconfig.get(self.configItems["IfSkland"]): + text_list.append( + "森空岛已签到" + if datetime.now().strftime("%Y-%m-%d") + == self.qconfig.get(self.configItems["LastSklandDate"]) + else "森空岛未签到" + ) self.Lable.setText(" | ".join(text_list)) diff --git a/app/ui/member_manager.py b/app/ui/member_manager.py index fec31d6..ce11825 100644 --- a/app/ui/member_manager.py +++ b/app/ui/member_manager.py @@ -80,6 +80,7 @@ from .Widget import ( EditableComboBoxWithPlanSettingCard, SpinBoxWithPlanSettingCard, PasswordLineEditSettingCard, + PasswordLineAndSwitchButtonSettingCard, UserLableSettingCard, UserTaskSettingCard, ComboBoxSettingCard, @@ -1585,6 +1586,18 @@ class MemberManager(QWidget): parent=self, ) ) + self.card_Skland = PasswordLineAndSwitchButtonSettingCard( + icon=FluentIcon.CERTIFICATE, + title="森空岛签到", + content="此功能具有一定风险,请谨慎使用", + text="鹰角网络通行证登录凭证", + algorithm="DPAPI", + qconfig=self.config, + configItem_bool=self.config.Info_IfSkland, + configItem_info=self.config.Info_SklandToken, + parent=self, + ) + self.card_Skland.LineEdit.setMinimumWidth(250) self.card_UserLable = UserLableSettingCard( icon=FluentIcon.INFO, @@ -1596,6 +1609,8 @@ class MemberManager(QWidget): "LastAnnihilationDate": self.config.Data_LastAnnihilationDate, "ProxyTimes": self.config.Data_ProxyTimes, "IfPassCheck": self.config.Data_IfPassCheck, + "IfSkland": self.config.Info_IfSkland, + "LastSklandDate": self.config.Data_LastSklandDate, }, parent=self, ) @@ -1778,6 +1793,7 @@ class MemberManager(QWidget): Layout.addLayout(h6_layout) Layout.addLayout(h7_layout) Layout.addLayout(h8_layout) + Layout.addWidget(self.card_Skland) Layout.addWidget(self.card_TaskSet) Layout.addWidget(self.card_NotifySet) diff --git a/resources/sounds/noisy/森空岛签到失败.wav b/resources/sounds/noisy/森空岛签到失败.wav new file mode 100644 index 0000000..4ad31d5 Binary files /dev/null and b/resources/sounds/noisy/森空岛签到失败.wav differ diff --git a/resources/sounds/noisy/森空岛签到成功.wav b/resources/sounds/noisy/森空岛签到成功.wav new file mode 100644 index 0000000..fd6319f Binary files /dev/null and b/resources/sounds/noisy/森空岛签到成功.wav differ diff --git a/resources/version.json b/resources/version.json index daee41c..3980e20 100644 --- a/resources/version.json +++ b/resources/version.json @@ -1,26 +1,9 @@ { - "main_version": "4.3.9.0", + "main_version": "4.3.10.1", "version_info": { - "4.3.9.0": { - "修复bug": [ - "修复网络模块子线程未及时销毁导致的程序崩溃" - ] - }, - "4.3.9.2": { - "修复bug": [ - "修复语音包禁忌二重奏" - ] - }, - "4.3.9.1": { + "4.3.10.1": { "新增功能": [ - "语音功能上线" - ], - "修复bug": [ - "网络模块支持并发请求", - "修复中止任务时程序异常卡顿" - ], - "程序优化": [ - "非UI组件转为QObject类" + "森空岛签到功能上线" ] } }