mirror of
https://github.com/whitechi73/OpenShamrock.git
synced 2024-08-14 13:12:17 +08:00
Shamrock
: support upload resource by NtKernel x2
Signed-off-by: 白池 <whitechi73@outlook.com>
This commit is contained in:
parent
fca66f3259
commit
e9a3a82b68
@ -1,27 +1,44 @@
|
|||||||
package moe.fuqiuluo.qqinterface.servlet.transfile
|
package moe.fuqiuluo.qqinterface.servlet.transfile
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.tencent.mobileqq.qroute.QRoute
|
||||||
|
import com.tencent.qqnt.aio.adapter.api.IAIOPttApi
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.CommonFileInfo
|
import com.tencent.qqnt.kernel.nativeinterface.CommonFileInfo
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.Contact
|
import com.tencent.qqnt.kernel.nativeinterface.Contact
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.IOperateCallback
|
import com.tencent.qqnt.kernel.nativeinterface.FileElement
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.MsgConstant
|
import com.tencent.qqnt.kernel.nativeinterface.MsgConstant
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.MsgElement
|
import com.tencent.qqnt.kernel.nativeinterface.MsgElement
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.PicElement
|
import com.tencent.qqnt.kernel.nativeinterface.PicElement
|
||||||
|
import com.tencent.qqnt.kernel.nativeinterface.PttElement
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.QQNTWrapperUtil
|
import com.tencent.qqnt.kernel.nativeinterface.QQNTWrapperUtil
|
||||||
import com.tencent.qqnt.kernel.nativeinterface.RichMediaFilePathInfo
|
import com.tencent.qqnt.kernel.nativeinterface.RichMediaFilePathInfo
|
||||||
|
import com.tencent.qqnt.kernel.nativeinterface.VideoElement
|
||||||
|
import com.tencent.qqnt.msg.api.IMsgUtilApi
|
||||||
import kotlinx.atomicfu.atomic
|
import kotlinx.atomicfu.atomic
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import moe.fuqiuluo.qqinterface.servlet.BaseSvc
|
import moe.fuqiuluo.qqinterface.servlet.BaseSvc
|
||||||
import moe.fuqiuluo.qqinterface.servlet.TicketSvc
|
import moe.fuqiuluo.qqinterface.servlet.TicketSvc
|
||||||
|
import moe.fuqiuluo.qqinterface.servlet.transfile.data.Private
|
||||||
|
import moe.fuqiuluo.qqinterface.servlet.transfile.data.Troop
|
||||||
import moe.fuqiuluo.qqinterface.servlet.transfile.data.TryUpPicData
|
import moe.fuqiuluo.qqinterface.servlet.transfile.data.TryUpPicData
|
||||||
|
import moe.fuqiuluo.qqinterface.servlet.transfile.data.VideoResource
|
||||||
import moe.fuqiuluo.shamrock.helper.Level
|
import moe.fuqiuluo.shamrock.helper.Level
|
||||||
import moe.fuqiuluo.shamrock.helper.LogCenter
|
import moe.fuqiuluo.shamrock.helper.LogCenter
|
||||||
import moe.fuqiuluo.shamrock.helper.MessageHelper
|
import moe.fuqiuluo.shamrock.helper.MessageHelper
|
||||||
|
import moe.fuqiuluo.shamrock.helper.TransfileHelper
|
||||||
|
import moe.fuqiuluo.shamrock.remote.service.config.ShamrockConfig
|
||||||
import moe.fuqiuluo.shamrock.tools.hex2ByteArray
|
import moe.fuqiuluo.shamrock.tools.hex2ByteArray
|
||||||
import moe.fuqiuluo.shamrock.tools.slice
|
import moe.fuqiuluo.shamrock.tools.slice
|
||||||
|
import moe.fuqiuluo.shamrock.utils.AudioUtils
|
||||||
import moe.fuqiuluo.shamrock.utils.FileUtils
|
import moe.fuqiuluo.shamrock.utils.FileUtils
|
||||||
|
import moe.fuqiuluo.shamrock.utils.MD5
|
||||||
|
import moe.fuqiuluo.shamrock.utils.MediaType
|
||||||
import moe.fuqiuluo.shamrock.xposed.helper.NTServiceFetcher
|
import moe.fuqiuluo.shamrock.xposed.helper.NTServiceFetcher
|
||||||
import moe.fuqiuluo.shamrock.xposed.helper.msgService
|
import moe.fuqiuluo.shamrock.xposed.helper.msgService
|
||||||
import moe.fuqiuluo.symbols.decodeProtobuf
|
import moe.fuqiuluo.symbols.decodeProtobuf
|
||||||
@ -46,7 +63,9 @@ import protobuf.oidb.cmd0x388.Cmd0x388ReqBody
|
|||||||
import protobuf.oidb.cmd0x388.Cmd0x388RspBody
|
import protobuf.oidb.cmd0x388.Cmd0x388RspBody
|
||||||
import protobuf.oidb.cmd0x388.TryUpImgReq
|
import protobuf.oidb.cmd0x388.TryUpImgReq
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.random.nextUInt
|
import kotlin.random.nextUInt
|
||||||
import kotlin.random.nextULong
|
import kotlin.random.nextULong
|
||||||
@ -60,16 +79,19 @@ internal object NtV2RichMediaSvc: BaseSvc() {
|
|||||||
/**
|
/**
|
||||||
* 批量上传图片
|
* 批量上传图片
|
||||||
*/
|
*/
|
||||||
suspend fun tryUploadGroupPicByNt(
|
suspend fun tryUploadResourceByNt(
|
||||||
imageFiles: ArrayList<File>,
|
chatType: Int,
|
||||||
|
elementType: Int,
|
||||||
|
resources: ArrayList<File>,
|
||||||
timeout: Duration
|
timeout: Duration
|
||||||
): Result<MutableList<CommonFileInfo>> {
|
): Result<MutableList<CommonFileInfo>> {
|
||||||
require(imageFiles.size in 1 .. 10) { "imageFiles.size() must be in 1 .. 10" }
|
require(resources.size in 1 .. 10) { "imageFiles.size() must be in 1 .. 10" }
|
||||||
|
|
||||||
val messages = imageFiles.map { file ->
|
val messages = resources.map { file ->
|
||||||
val elem = MsgElement()
|
val elem = MsgElement()
|
||||||
runCatching {
|
elem.elementType = elementType
|
||||||
elem.elementType = MsgConstant.KELEMTYPEPIC
|
when(elementType) {
|
||||||
|
MsgConstant.KELEMTYPEPIC -> {
|
||||||
val pic = PicElement()
|
val pic = PicElement()
|
||||||
pic.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath)
|
pic.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath)
|
||||||
val msgService = NTServiceFetcher.kernelService.msgService!!
|
val msgService = NTServiceFetcher.kernelService.msgService!!
|
||||||
@ -110,48 +132,176 @@ internal object NtV2RichMediaSvc: BaseSvc() {
|
|||||||
pic.original = true
|
pic.original = true
|
||||||
pic.picType = FileUtils.getPicType(file)
|
pic.picType = FileUtils.getPicType(file)
|
||||||
elem.picElement = pic
|
elem.picElement = pic
|
||||||
}.onFailure {
|
}
|
||||||
LogCenter.log(it.stackTraceToString(), Level.WARN)
|
MsgConstant.KELEMTYPEPTT -> {
|
||||||
elem.elementType = 0
|
require(resources.size == 1) // 语音只能单个上传
|
||||||
|
var pttFile = file
|
||||||
|
val ptt = PttElement()
|
||||||
|
when (AudioUtils.getMediaType(pttFile)) {
|
||||||
|
MediaType.Silk -> {
|
||||||
|
ptt.formatType = MsgConstant.KPTTFORMATTYPESILK
|
||||||
|
ptt.duration = QRoute.api(IAIOPttApi::class.java)
|
||||||
|
.getPttFileDuration(pttFile.absolutePath)
|
||||||
|
}
|
||||||
|
MediaType.Amr -> {
|
||||||
|
ptt.duration = AudioUtils.getDurationSec(pttFile)
|
||||||
|
ptt.formatType = MsgConstant.KPTTFORMATTYPEAMR
|
||||||
|
}
|
||||||
|
MediaType.Pcm -> {
|
||||||
|
val result = AudioUtils.pcmToSilk(pttFile)
|
||||||
|
ptt.duration = (result.second * 0.001).roundToInt()
|
||||||
|
pttFile = result.first
|
||||||
|
ptt.formatType = MsgConstant.KPTTFORMATTYPESILK
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val result = AudioUtils.audioToSilk(pttFile)
|
||||||
|
ptt.duration = runCatching {
|
||||||
|
QRoute.api(IAIOPttApi::class.java)
|
||||||
|
.getPttFileDuration(result.second.absolutePath)
|
||||||
|
}.getOrElse {
|
||||||
|
result.first
|
||||||
|
}
|
||||||
|
pttFile = result.second
|
||||||
|
ptt.formatType = MsgConstant.KPTTFORMATTYPESILK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ptt.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(pttFile.absolutePath)
|
||||||
|
val msgService = NTServiceFetcher.kernelService.msgService!!
|
||||||
|
val originalPath = msgService.getRichMediaFilePathForMobileQQSend(
|
||||||
|
RichMediaFilePathInfo(
|
||||||
|
MsgConstant.KELEMTYPEPTT, 0, ptt.md5HexStr, file.name, 1, 0, null, "", true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize(originalPath) != pttFile.length()) {
|
||||||
|
QQNTWrapperUtil.CppProxy.copyFile(pttFile.absolutePath, originalPath)
|
||||||
|
}
|
||||||
|
if (originalPath != null) {
|
||||||
|
ptt.filePath = originalPath
|
||||||
|
} else {
|
||||||
|
ptt.filePath = pttFile.absolutePath
|
||||||
|
}
|
||||||
|
ptt.canConvert2Text = true
|
||||||
|
ptt.fileId = 0
|
||||||
|
ptt.fileUuid = ""
|
||||||
|
ptt.text = ""
|
||||||
|
ptt.voiceType = MsgConstant.KPTTVOICETYPESOUNDRECORD
|
||||||
|
ptt.voiceChangeType = MsgConstant.KPTTVOICECHANGETYPENONE
|
||||||
|
elem.pttElement = ptt
|
||||||
|
}
|
||||||
|
MsgConstant.KELEMTYPEVIDEO -> {
|
||||||
|
require(resources.size == 1) // 视频只能单个上传
|
||||||
|
val video = VideoElement()
|
||||||
|
video.videoMd5 = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath)
|
||||||
|
val msgService = NTServiceFetcher.kernelService.msgService!!
|
||||||
|
val originalPath = msgService.getRichMediaFilePathForMobileQQSend(
|
||||||
|
RichMediaFilePathInfo(
|
||||||
|
5, 2, video.videoMd5, file.name, 1, 0, null, "", true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val thumbPath = msgService.getRichMediaFilePathForMobileQQSend(
|
||||||
|
RichMediaFilePathInfo(
|
||||||
|
5, 1, video.videoMd5, file.name, 2, 0, null, "", true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize(
|
||||||
|
originalPath
|
||||||
|
) != file.length()
|
||||||
|
) {
|
||||||
|
QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, originalPath)
|
||||||
|
AudioUtils.obtainVideoCover(file.absolutePath, thumbPath!!)
|
||||||
|
}
|
||||||
|
video.fileTime = AudioUtils.getVideoTime(file)
|
||||||
|
video.fileSize = file.length()
|
||||||
|
video.fileName = file.name
|
||||||
|
video.fileFormat = FileTransfer.VIDEO_FORMAT_MP4
|
||||||
|
video.thumbSize = QQNTWrapperUtil.CppProxy.getFileSize(thumbPath).toInt()
|
||||||
|
val options = BitmapFactory.Options()
|
||||||
|
BitmapFactory.decodeFile(thumbPath, options)
|
||||||
|
video.thumbWidth = options.outWidth
|
||||||
|
video.thumbHeight = options.outHeight
|
||||||
|
video.thumbMd5 = QQNTWrapperUtil.CppProxy.genFileMd5Hex(thumbPath)
|
||||||
|
video.thumbPath = hashMapOf(0 to thumbPath)
|
||||||
|
elem.videoElement = video
|
||||||
|
}
|
||||||
|
|
||||||
|
/*MsgConstant.KELEMTYPEFILE -> {
|
||||||
|
require(resources.size == 1) // 文件只能单个上传
|
||||||
|
val fileElement = FileElement()
|
||||||
|
fileElement.fileMd5 = ""
|
||||||
|
fileElement.fileName = file.name
|
||||||
|
fileElement.filePath = file.absolutePath
|
||||||
|
fileElement.fileSize = file.length()
|
||||||
|
fileElement.picWidth = 0
|
||||||
|
fileElement.picHeight = 0
|
||||||
|
fileElement.videoDuration = 0
|
||||||
|
fileElement.picThumbPath = HashMap()
|
||||||
|
fileElement.expireTime = 0L
|
||||||
|
fileElement.fileSha = ""
|
||||||
|
fileElement.fileSha3 = ""
|
||||||
|
fileElement.file10MMd5 = ""
|
||||||
|
when (TransfileHelper.getExtensionId(file.name)) {
|
||||||
|
0 -> {
|
||||||
|
val wh = QRoute.api(IMsgUtilApi::class.java)
|
||||||
|
.getPicSizeByPath(file.absolutePath)
|
||||||
|
fileElement.picWidth = wh.first
|
||||||
|
fileElement.picHeight = wh.second
|
||||||
|
fileElement.picThumbPath[750] = file.absolutePath
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val thumbPic = FileUtils.getFileByMd5(MD5.genFileMd5Hex(file.absolutePath))
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val fileOutputStream = FileOutputStream(thumbPic)
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
retriever.setDataSource(fileElement.filePath)
|
||||||
|
retriever.frameAtTime?.compress(Bitmap.CompressFormat.JPEG, 60, fileOutputStream)
|
||||||
|
fileOutputStream.flush()
|
||||||
|
fileOutputStream.close()
|
||||||
|
}
|
||||||
|
val options = BitmapFactory.Options()
|
||||||
|
BitmapFactory.decodeFile(thumbPic.absolutePath, options)
|
||||||
|
fileElement.picHeight = options.outHeight
|
||||||
|
fileElement.picWidth = options.outWidth
|
||||||
|
fileElement.picThumbPath = hashMapOf(750 to thumbPic.absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elem.fileElement = fileElement
|
||||||
|
}*/
|
||||||
|
else -> throw IllegalArgumentException("unsupported elementType: $elementType")
|
||||||
}
|
}
|
||||||
return@map elem
|
return@map elem
|
||||||
}.filter {
|
|
||||||
it.elementType == MsgConstant.KELEMTYPEPIC
|
|
||||||
}
|
}
|
||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
return Result.failure(Exception("no valid image files"))
|
return Result.failure(Exception("no valid image files"))
|
||||||
}
|
}
|
||||||
val result: MutableList<CommonFileInfo> = withTimeoutOrNull(timeout) {
|
val contact = when(chatType) {
|
||||||
suspendCancellableCoroutine {
|
MsgConstant.KCHATTYPEC2C -> MessageHelper.generateContact(chatType, TicketSvc.getUin())
|
||||||
|
else -> Contact(chatType, GROUP_PIC_UPLOAD_TO, GROUP_PIC_UPLOAD_TO)
|
||||||
|
}
|
||||||
val result = mutableListOf<CommonFileInfo>()
|
val result = mutableListOf<CommonFileInfo>()
|
||||||
val uniseq = MessageHelper.generateMsgId(MsgConstant.KCHATTYPEGROUP)
|
withTimeoutOrNull(timeout) {
|
||||||
val contact = Contact(MsgConstant.KCHATTYPEGROUP, GROUP_PIC_UPLOAD_TO, GROUP_PIC_UPLOAD_TO)
|
suspendCancellableCoroutine {
|
||||||
|
val uniseq = MessageHelper.generateMsgId(chatType)
|
||||||
RichMediaUploadHandler.registerListener(uniseq.qqMsgId) upload@{
|
RichMediaUploadHandler.registerListener(uniseq.qqMsgId) upload@{
|
||||||
if (uniseq.qqMsgId == msgId) {
|
if (uniseq.qqMsgId == msgId) {
|
||||||
result.add(commonFileInfo)
|
result.add(commonFileInfo)
|
||||||
}
|
}
|
||||||
|
if (result.size == resources.size) {
|
||||||
|
it.resume(true)
|
||||||
|
return@upload true
|
||||||
|
}
|
||||||
return@upload false
|
return@upload false
|
||||||
}
|
}
|
||||||
MessageHelper.sendMessageWithMsgId(
|
MessageHelper.sendMessageWithMsgId(
|
||||||
contact = contact,
|
contact = contact,
|
||||||
message = ArrayList(messages),
|
message = ArrayList(messages),
|
||||||
uniseq = uniseq.qqMsgId
|
uniseq = uniseq.qqMsgId
|
||||||
) { code, _ ->
|
) { _, _ -> }
|
||||||
NTServiceFetcher.kernelService
|
|
||||||
.wrapperSession.msgService
|
|
||||||
.deleteMsg(contact, arrayListOf(uniseq.qqMsgId), null)
|
|
||||||
RichMediaUploadHandler.removeListener(uniseq.qqMsgId)
|
|
||||||
if (code != 110 && code != 4) {
|
|
||||||
it.resume(null)
|
|
||||||
} else {
|
|
||||||
it.resume(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
it.invokeOnCancellation {
|
it.invokeOnCancellation {
|
||||||
RichMediaUploadHandler.removeListener(uniseq.qqMsgId)
|
RichMediaUploadHandler.removeListener(uniseq.qqMsgId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} ?: return Result.failure(Exception("timeout"))
|
}
|
||||||
return Result.success(result)
|
return Result.success(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package moe.fuqiuluo.shamrock.remote.action.handlers
|
package moe.fuqiuluo.shamrock.remote.action.handlers
|
||||||
|
|
||||||
|
import com.tencent.qqnt.kernel.nativeinterface.MsgConstant
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import moe.fuqiuluo.qqinterface.servlet.structures.CommFileInfo
|
import moe.fuqiuluo.qqinterface.servlet.structures.CommFileInfo
|
||||||
import moe.fuqiuluo.qqinterface.servlet.structures.UploadResult
|
import moe.fuqiuluo.qqinterface.servlet.structures.UploadResult
|
||||||
@ -12,14 +13,29 @@ import moe.fuqiuluo.shamrock.utils.FileUtils
|
|||||||
import moe.fuqiuluo.symbols.OneBotHandler
|
import moe.fuqiuluo.symbols.OneBotHandler
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@OneBotHandler("upload_group_image", ["upload_group_pic"])
|
@OneBotHandler("upload_nt_resource", ["upload_nt_res"])
|
||||||
internal object UploadGroupPic: IActionHandler() {
|
internal object UploadNtResource: IActionHandler() {
|
||||||
override suspend fun internalHandle(session: ActionSession): String {
|
override suspend fun internalHandle(session: ActionSession): String {
|
||||||
val pic = session.getString("file")
|
val pic = session.getString("file")
|
||||||
return invoke(pic, session.echo)
|
val chatType = when(session.getStringOrNull("message_type")) {
|
||||||
|
"group" -> MsgConstant.KCHATTYPEGROUP
|
||||||
|
"guild" -> MsgConstant.KCHATTYPEGUILD
|
||||||
|
"private" -> MsgConstant.KCHATTYPEC2C
|
||||||
|
else -> MsgConstant.KCHATTYPEGROUP
|
||||||
|
}
|
||||||
|
val fileType = when(session.getStringOrNull("file_type")) {
|
||||||
|
"file" -> MsgConstant.KELEMTYPEFILE
|
||||||
|
"image", "pic" -> MsgConstant.KELEMTYPEPIC
|
||||||
|
"video" -> MsgConstant.KELEMTYPEVIDEO
|
||||||
|
"audio", "voice", "record" -> MsgConstant.KELEMTYPEPTT
|
||||||
|
else -> MsgConstant.KELEMTYPEFILE
|
||||||
|
}
|
||||||
|
return invoke(chatType, fileType, pic, session.echo)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
|
chatType: Int,
|
||||||
|
fileType: Int,
|
||||||
picture: String,
|
picture: String,
|
||||||
echo: JsonElement = EmptyJsonString
|
echo: JsonElement = EmptyJsonString
|
||||||
): String {
|
): String {
|
||||||
@ -38,8 +54,10 @@ internal object UploadGroupPic: IActionHandler() {
|
|||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
return logic("picture file is not exists", echo)
|
return logic("picture file is not exists", echo)
|
||||||
}
|
}
|
||||||
NtV2RichMediaSvc.tryUploadGroupPicByNt(
|
NtV2RichMediaSvc.tryUploadResourceByNt(
|
||||||
imageFiles = arrayListOf(file),
|
chatType = chatType,
|
||||||
|
elementType = fileType,
|
||||||
|
resources = arrayListOf(file),
|
||||||
timeout = 30.seconds
|
timeout = 30.seconds
|
||||||
).onSuccess {
|
).onSuccess {
|
||||||
return ok(UploadResult(it.map {
|
return ok(UploadResult(it.map {
|
Loading…
x
Reference in New Issue
Block a user