Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 178 additions & 11 deletions app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeRedPacketAuto.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package moe.ouom.wekit.hooks.item.chat.risk

import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ContentValues
import android.content.Context
import android.os.Build
import androidx.core.net.toUri
import com.afollestad.materialdialogs.MaterialDialog
import de.robv.android.xposed.XposedHelpers
Expand All @@ -14,22 +17,45 @@ import moe.ouom.wekit.core.dsl.dexMethod
import moe.ouom.wekit.core.model.BaseClickableFunctionHookItem
import moe.ouom.wekit.dexkit.intf.IDexFind
import moe.ouom.wekit.hooks.core.annotation.HookItem
import moe.ouom.wekit.hooks.sdk.api.WeDatabaseApi
import moe.ouom.wekit.hooks.sdk.api.WeDatabaseListener
import moe.ouom.wekit.hooks.sdk.api.WeNetworkApi
import moe.ouom.wekit.ui.creator.dialog.item.chat.risk.WeRedPacketConfigDialog
import moe.ouom.wekit.util.log.WeLogger
import org.json.JSONObject
import org.luckypray.dexkit.DexKitBridge
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random

@SuppressLint("DiscouragedApi")
@HookItem(path = "聊天与消息/自动抢红包", desc = "监听消息并自动拆开红包")
class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.DatabaseInsertListener, IDexFind {

companion object {
// [WeKit-Mod] 限流计数器
private val grabCount = java.util.concurrent.atomic.AtomicInteger(0)
private val lastResetTime = java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis())
private val rng = java.util.Random()

private val rateLimitLock = Any()

private fun canGrab(maxPerMinute: Int): Boolean {
synchronized(rateLimitLock) {
val now = System.currentTimeMillis()
if (now - lastResetTime.get() > 60_000) {
grabCount.set(0)
lastResetTime.set(now)
}
return grabCount.incrementAndGet() <= maxPerMinute
}
}
}

private val dexClsReceiveLuckyMoney by dexClass()
private val dexClsOpenLuckyMoney by dexClass()
private val dexMethodOnGYNetEnd by dexMethod()
private val dexMethodOnOpenGYNetEnd by dexMethod()

private val currentRedPacketMap = ConcurrentHashMap<String, RedPacketInfo>()

Expand All @@ -51,7 +77,11 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data

// Hook 具体的网络回调
hookReceiveCallback()
WeLogger.i("WeRedPacketAuto: 网络回调 Hook 完成")
WeLogger.i("WeRedPacketAuto: 拆包网络回调 Hook 完成")

// Hook 开包回调(用于检测是否抢到和获取金额)
hookOpenCallback()
WeLogger.i("WeRedPacketAuto: 开包网络回调 Hook 完成")
}

/**
Expand All @@ -72,6 +102,30 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data
val config = WeConfig.getDefaultConfig()
if (values.getAsInteger("isSend") == 1 && !config.getBoolPrek("red_packet_self")) return

// [WeKit-Mod] 私聊红包开关
val talkerRaw = values.getAsString("talker") ?: ""
val isGroupChat = talkerRaw.contains("@chatroom")
if (!isGroupChat && !config.getBoolPrek("red_packet_private_chat")) {
WeLogger.i("WeRedPacketAuto: 私聊红包已跳过(未开启私聊抢红包)")
return
}

// [WeKit-Mod] 随机跳过(模拟“没看到”)
val skipEnabled = config.getBoolPrek("red_packet_random_skip")
val skipRate = config.getStringPrek("red_packet_skip_rate", "15")?.toFloatOrNull() ?: 15f
if (skipEnabled && Random.nextFloat() * 100f < skipRate) {
WeLogger.i("WeRedPacketAuto: 随机跳过本次红包(模拟自然行为)")
return
}

// [WeKit-Mod] 频率限制
val rateLimitEnabled = config.getBoolPrek("red_packet_rate_limit")
val maxPerMin = (config.getStringPrek("red_packet_max_per_min", "3")?.toIntOrNull() ?: 3).coerceAtLeast(1)
if (rateLimitEnabled && !canGrab(maxPerMin)) {
WeLogger.i("WeRedPacketAuto: 频率限制触发,跳过本次红包(已达到每分钟上限 $maxPerMin 个,当前计数 ${grabCount.get()})")
return
}

val content = values.getAsString("content") ?: return
val talker = values.getAsString("talker") ?: ""

Expand Down Expand Up @@ -111,12 +165,13 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data

WeLogger.i("WeRedPacketAuto: 配置读取 - isRandomDelay=$isRandomDelay, customDelay=$customDelay")

// 如果开启随机延迟,在自定义延迟基础上增加随机偏移
// [WeKit-Mod] 高斯分布延迟,更自然的拟人效果
val delayTime = if (isRandomDelay) {
val baseDelay = if (customDelay > 0) customDelay else 1000L
val randomOffset = Random.nextLong(-500, 500)
val finalDelay = (baseDelay + randomOffset).coerceAtLeast(0)
WeLogger.i("WeRedPacketAuto: 随机延迟模式 - baseDelay=$baseDelay, randomOffset=$randomOffset, finalDelay=$finalDelay")
val baseDelay = (if (customDelay > 0) customDelay else 1000L).coerceAtLeast(300)
val gaussian = rng.nextGaussian() // 正态分布 μ=0, σ=1
val offset = (gaussian * (baseDelay * 0.4)).toLong() // 标准差为基础延迟的40%
val finalDelay = (baseDelay + offset).coerceIn(300, baseDelay * 3)
WeLogger.i("WeRedPacketAuto: 高斯延迟模式 - baseDelay=$baseDelay, offset=$offset, finalDelay=$finalDelay")
finalDelay
} else {
WeLogger.i("WeRedPacketAuto: 固定延迟模式 - delayTime=$customDelay")
Expand Down Expand Up @@ -168,12 +223,22 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data
info.headImg, info.nickName, info.talker,
"v1.0", timingIdentifier, ""
)
// 使用 NetworkApi 发送
// 使用 NetworkApi 发送(通知改由 hookOpenCallback 处理)
WeNetworkApi.sendRequest(openReq)

currentRedPacketMap.remove(sendId)
WeLogger.i("WeRedPacketAuto: 开包请求已发送 ($sendId),等待 open 回调")

// 防止极端情况下 open 回调未触发导致 map 泄漏
Thread {
try {
Thread.sleep(5 * 60 * 1000L) // 5 分钟超时
currentRedPacketMap.remove(sendId)?.let {
WeLogger.w("WeRedPacketAuto: open 回调超时,主动清理红包记录 ($sendId)")
}
} catch (_: Throwable) {}
}.start()
} catch (e: Throwable) {
WeLogger.e("WeRedPacketAuto: 开包失败", e)
currentRedPacketMap.remove(sendId)
}
}.start()
}
Expand All @@ -193,6 +258,92 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data
return matchSimple?.groupValues?.get(1) ?: ""
}

/**
* Hook OpenLuckyMoney 的 onGYNetEnd 回调
* 只有在这个回调中才能确认是否真正抢到红包以及金额
*/
private fun hookOpenCallback() {
try {
dexMethodOnOpenGYNetEnd.toDexMethod {
hook {
afterIfEnabled { param ->
val json = param.args[2] as? JSONObject ?: return@afterIfEnabled
val sendId = json.optString("sendId")
if (sendId.isNullOrEmpty()) return@afterIfEnabled

// 打印完整回调日志,方便调试字段名(仅在调试级别输出)
WeLogger.d("WeRedPacketAuto: OpenLuckyMoney 回调 sendId=$sendId, json=$json")

val info = currentRedPacketMap.remove(sendId) ?: return@afterIfEnabled

// 检查是否成功抢到
val retcode = json.optInt("retcode", -1)
if (retcode != 0) {
WeLogger.i("WeRedPacketAuto: 未抢到红包 retcode=$retcode ($sendId)")
return@afterIfEnabled
}

// 获取金额(单位:分)
val recvAmount = json.optInt("recAmount", 0)
if (recvAmount <= 0) {
WeLogger.i("WeRedPacketAuto: 金额为0,跳过通知 ($sendId)")
return@afterIfEnabled
}

val amountYuan = recvAmount / 100.0
WeLogger.i("WeRedPacketAuto: 成功抢到红包 ¥$amountYuan ($sendId)")

// 通知逻辑
val notifyEnabled = WeConfig.getDefaultConfig().getBoolPrek("red_packet_notification")
if (notifyEnabled) {
// 解析来源名称
val sourceName = try {
WeDatabaseApi.INSTANCE?.getDisplayName(info.talker) ?: info.talker
} catch (_: Throwable) {
info.talker
}
val isGroup = info.talker.endsWith("@chatroom")
val sourceLabel = if (isGroup) "群聊" else "私聊"
showNotification(info, sourceName, sourceLabel, amountYuan)
}
}
}
}
} catch (e: Throwable) {
WeLogger.e("WeRedPacketAuto: Hook OpenLuckyMoney onGYNetEnd failed", e)
}
}

// [WeKit-Mod] 抢到红包通知(含来源名称和金额)
private fun showNotification(info: RedPacketInfo, sourceName: String, sourceLabel: String, amountYuan: Double) {
try {
val activity = moe.ouom.wekit.config.RuntimeConfig.getLauncherUIActivity()
val context = activity?.applicationContext ?: activity ?: return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return

val channelId = "wekit_red_packet"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(NotificationChannel(
channelId, "红包通知", NotificationManager.IMPORTANCE_HIGH
))
}

val amountStr = String.format(Locale.US, "%.2f", amountYuan)
val contentText = "来自$sourceLabel【$sourceName】的 ¥$amountStr"

val notification = android.app.Notification.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("\uD83E\uDDE7 抢到红包")
.setContentText(contentText)
.setAutoCancel(true)
.build()

nm.notify(info.sendId.hashCode(), notification)
} catch (t: Throwable) {
WeLogger.e("WeRedPacketAuto: showNotification failed", t)
}
}

override fun unload(classLoader: ClassLoader) {
WeLogger.i("WeRedPacketAuto: unload() 被调用,移除数据库监听")
WeDatabaseListener.removeListener(this)
Expand Down Expand Up @@ -236,7 +387,7 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data
throw RuntimeException("DexKit: Failed to find OpenLuckyMoney class with string 'MicroMsg.NetSceneOpenLuckyMoney'")
}

// 查找 onGYNetEnd 回调方法
// 查找 ReceiveLuckyMoney 的 onGYNetEnd 回调方法
val receiveLuckyMoneyClassName = dexClsReceiveLuckyMoney.getDescriptorString()
if (receiveLuckyMoneyClassName != null) {
val foundMethod = dexMethodOnGYNetEnd.find(dexKit, descriptors,true) {
Expand All @@ -247,11 +398,27 @@ class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.Data
}
}
if (!foundMethod) {
WeLogger.e("WeRedPacketAuto: Failed to find onGYNetEnd method")
WeLogger.e("WeRedPacketAuto: Failed to find ReceiveLuckyMoney onGYNetEnd method")
throw RuntimeException("DexKit: Failed to find onGYNetEnd method in $receiveLuckyMoneyClassName")
}
}

// 查找 OpenLuckyMoney 的 onGYNetEnd 回调方法
val openLuckyMoneyClassName = dexClsOpenLuckyMoney.getDescriptorString()
if (openLuckyMoneyClassName != null) {
val foundOpenMethod = dexMethodOnOpenGYNetEnd.find(dexKit, descriptors, true) {
matcher {
declaredClass = openLuckyMoneyClassName
name = "onGYNetEnd"
paramCount = 3
}
}
if (!foundOpenMethod) {
WeLogger.e("WeRedPacketAuto: Failed to find OpenLuckyMoney onGYNetEnd method")
throw RuntimeException("DexKit: Failed to find onGYNetEnd method in $openLuckyMoneyClassName")
}
}

return descriptors
}

Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,39 @@ class WeDatabaseApi : ApiHookItem(), IDexFind {
}
}

/**
* 根据 wxid 获取显示名称
* 群聊:返回群名称
* 联系人:优先备注名 > 昵称 > wxid
*/
fun getDisplayName(wxid: String): String {
if (wxid.isEmpty()) return ""
try {
val escapedWxid = wxid.replace("'", "''")
val isGroup = wxid.endsWith("@chatroom")
val sql = if (isGroup) {
"SELECT r.nickname FROM rcontact r WHERE r.username = '$escapedWxid'"
} else {
"SELECT r.conRemark, r.nickname FROM rcontact r WHERE r.username = '$escapedWxid'"
}
val result = executeQuery(sql)
if (result.isEmpty()) return wxid

val row = result[0]
return if (isGroup) {
val nickname = row.str("nickname")
nickname.ifEmpty { wxid }
} else {
val conRemark = row.str("conRemark")
val nickname = row.str("nickname")
conRemark.ifEmpty { nickname.ifEmpty { wxid } }
}
} catch (e: Exception) {
WeLogger.e(TAG, "getDisplayName 查询失败: $wxid", e)
return wxid
}
}

private fun mapToContact(data: List<Map<String, Any?>>): List<WeContact> {
return data.map { row ->
WeContact(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ class WeRedPacketConfigDialog(context: Context) : BaseRikkaDialog(context, "自

addSwitchPreference(
key = "red_packet_notification",
title = "抢到后通知(没写)",
summary = "在通知栏显示抢到的金额"
title = "抢到后通知",
summary = "在通知栏显示抢到的红包信息"
)

addCategory("高级选项")
Expand All @@ -23,6 +23,12 @@ class WeRedPacketConfigDialog(context: Context) : BaseRikkaDialog(context, "自
summary = "默认情况下不抢自己发出的"
)

addSwitchPreference(
key = "red_packet_private_chat",
title = "抢私聊红包",
summary = "关闭后仅抢群聊红包,不抢私聊(1对1)的红包"
)

addEditTextPreference(
key = "red_packet_delay_custom",
title = "基础延迟",
Expand All @@ -38,8 +44,48 @@ class WeRedPacketConfigDialog(context: Context) : BaseRikkaDialog(context, "自

addSwitchPreference(
key = "red_packet_delay_random",
title = "随机延时",
summary = "在基础延迟上增加 ±500ms 随机偏移,防止风控"
title = "高斯随机延时",
summary = "在基础延迟上按正态分布浮动,更自然地模拟人类反应"
)

addCategory("防检测")

addSwitchPreference(
key = "red_packet_rate_limit",
title = "频率限制",
summary = "每分钟最多抢 N 个红包,防止高频触发风控"
)

addEditTextPreference(
key = "red_packet_max_per_min",
title = "每分钟上限",
summary = "最大抢包次数",
defaultValue = "3",
hint = "请输入每分钟最大次数",
inputType = InputType.TYPE_CLASS_NUMBER,
maxLength = 2,
summaryFormatter = { value ->
if (value.isEmpty()) "3 次/分钟" else "$value 次/分钟"
}
)

addSwitchPreference(
key = "red_packet_random_skip",
title = "随机跳过",
summary = "一定概率不抢,模拟\"没注意到\"的自然行为"
)

addEditTextPreference(
key = "red_packet_skip_rate",
title = "跳过概率",
summary = "跳过红包的百分比",
defaultValue = "15",
hint = "请输入跳过概率(%)",
inputType = InputType.TYPE_CLASS_NUMBER,
maxLength = 2,
summaryFormatter = { value ->
if (value.isEmpty()) "15%" else "$value%"
}
)
}
}