hasOne(WebSocketDialog::class, 'id', 'dialog_id'); } /** * 阅读占比 * @return int|mixed */ public function getPercentageAttribute() { if (!isset($this->appendattrs['percentage'])) { $this->generatePercentage(); } return $this->appendattrs['percentage']; } /** * 回复消息详情 * @return WebSocketDialogMsg|null */ public function getReplyDataAttribute() { if (!isset($this->appendattrs['reply_data'])) { $this->appendattrs['reply_data'] = null; if ($this->reply_id > 0) { $this->appendattrs['reply_data'] = self::find($this->reply_id, ['id', 'userid', 'type', 'msg'])?->cancelAppend() ?: null; } } return $this->appendattrs['reply_data']; } /** * 转发消息详情 * @return WebSocketDialogMsg|null */ public function getForwardDataAttribute() { if (!isset($this->appendattrs['forward_data'])) { $this->appendattrs['forward_data'] = null; if ($this->forward_id > 0) { $this->appendattrs['forward_data'] = self::find($this->forward_id, ['id', 'userid', 'type', 'msg'])?->cancelAppend() ?: null; } } return $this->appendattrs['forward_data']; } /** * 消息格式化 * @param $value * @return array|mixed */ public function getMsgAttribute($value) { if (is_array($value)) { return $value; } $value = Base::json2array($value); if ($this->type === 'file') { $value['type'] = in_array($value['ext'], ['jpg', 'jpeg', 'webp', 'png', 'gif']) ? 'img' : 'file'; $value['path'] = Base::fillUrl($value['path']); $value['thumb'] = Base::fillUrl($value['thumb'] ?: Base::extIcon($value['ext'])); } else if ($this->type === 'record') { $value['path'] = Base::fillUrl($value['path']); } return $value; } /** * emoji回复格式化 * @param $value * @return array|mixed */ public function getEmojiAttribute($value) { if (is_array($value)) { return $value; } return Base::json2array($value); } /** * 获取占比 * @param bool|int $increment 是否新增阅读数 * @return int */ public function generatePercentage($increment = false) { if ($increment) { $this->increment('read', is_bool($increment) ? 1 : $increment); } if ($this->read > $this->send || empty($this->send)) { return $this->appendattrs['percentage'] = 100; } else { return $this->appendattrs['percentage'] = intval($this->read / $this->send * 100); } } /** * 标记已送达 同时 告诉发送人已送达 * @param $userid * @return bool */ public function readSuccess($userid) { if (empty($userid)) { return false; } self::transaction(function() use ($userid) { $msgRead = WebSocketDialogMsgRead::whereMsgId($this->id)->whereUserid($userid)->lockForUpdate()->first(); if (empty($msgRead)) { $msgRead = WebSocketDialogMsgRead::createInstance([ 'dialog_id' => $this->dialog_id, 'msg_id' => $this->id, 'userid' => $userid, 'after' => 1, ]); if ($msgRead->saveOrIgnore()) { $this->send = WebSocketDialogMsgRead::whereMsgId($this->id)->count(); $this->save(); } else { return; } } if (!$msgRead->read_at) { $msgRead->read_at = Carbon::now(); $msgRead->save(); $this->generatePercentage(true); PushTask::push([ 'userid' => $this->userid, 'msg' => [ 'type' => 'dialog', 'mode' => 'readed', 'data' => [ 'id' => $this->id, 'read' => $this->read, 'percentage' => $this->percentage, ], ] ]); } }); return true; } /** * emoji回复 * @param $symbol * @param int $sender 发送的会员ID * @return mixed */ public function emojiMsg($symbol, $sender) { $exist = false; $array = $this->emoji; foreach ($array as $index => &$item) { if ($item['symbol'] === $symbol) { if (in_array($sender, $item['userids'])) { // 已存在 去除 $item['userids'] = array_values(array_diff($item['userids'], [$sender])); if (empty($item['userids'])) { unset($array[$index]); $array = array_values($array); } } else { // 未存在 添加 array_unshift($item['userids'], $sender); } $exist = true; break; } } if (!$exist) { array_unshift($array, [ 'symbol' => $symbol, 'userids' => [$sender] ]); } // $this->emoji = Base::array2json($array); $this->save(); $resData = [ 'id' => $this->id, 'emoji' => $array, ]; // $dialog = WebSocketDialog::find($this->dialog_id); $dialog?->pushMsg('update', $resData); // return Base::retSuccess('success', $resData); } /** * 标注、取消标注 * @param int $sender 标注的会员ID * @return mixed */ public function toggleTagMsg($sender) { if (in_array($this->type, ['tag', 'todo', 'notice'])) { return Base::retError('此消息不支持标注'); } $before = $this->tag; $this->tag = $before ? 0 : $sender; $this->save(); $resData = [ 'id' => $this->id, 'tag' => $this->tag, ]; // $data = [ 'update' => $resData ]; $res = self::sendMsg(null, $this->dialog_id, 'tag', [ 'action' => $this->tag ? 'add' : 'remove', 'data' => [ 'id' => $this->id, 'type' => $this->type, 'msg' => $this->quoteTextMsg(), ] ], $sender); if (Base::isSuccess($res)) { $data['add'] = $res['data']; $dialog = WebSocketDialog::find($this->dialog_id); $dialog->pushMsg('update', $resData); } else { $this->tag = $before; $this->save(); } // return Base::retSuccess($this->tag ? '标注成功' : '取消成功', $data); } /** * 设待办、取消待办 * @param int $sender 设待办的会员ID * @param array $userids 设置给指定会员 * @return mixed */ public function toggleTodoMsg($sender, $userids = []) { if (in_array($this->type, ['tag', 'todo', 'notice'])) { return Base::retError('此消息不支持设待办'); } if ($this->todo && $this->todo != $sender) { return Base::retError('仅支持设此待办人员【' . User::userid2nickname($this->todo) . '】取消'); } $before = $this->todo; $this->todo = $before ? 0 : $sender; $this->save(); $resData = [ 'id' => $this->id, 'todo' => $this->todo, ]; // $data = [ 'update' => $resData ]; $res = self::sendMsg(null, $this->dialog_id, 'todo', [ 'action' => $this->todo ? 'add' : 'remove', 'data' => [ 'id' => $this->id, 'type' => $this->type, 'msg' => $this->quoteTextMsg(), 'userids' => implode(",", $userids), ] ], $sender); if (Base::isSuccess($res)) { $data['add'] = $res['data']; $dialog = WebSocketDialog::find($this->dialog_id); $dialog->pushMsg('update', array_merge($resData, ['dialog_id' => $this->dialog_id])); // if ($this->todo) { $useridList = $dialog->dialogUser->pluck('userid')->toArray(); foreach ($useridList as $userid) { if ($userids && !in_array($userid, $userids)) { continue; } if (empty($userid)) { continue; } WebSocketDialogMsgTodo::createInstance([ 'dialog_id' => $this->dialog_id, 'msg_id' => $this->id, 'userid' => $userid, ])->saveOrIgnore(); } } else { WebSocketDialogMsgTodo::whereMsgId($this->id)->delete(); } } else { $this->todo = $before; $this->save(); } // return Base::retSuccess($this->todo ? '设置成功' : '取消成功', $data); } /** * 转发消息 * @param array|int $dialogids * @param array|int $userids * @param User $user 发送的会员 * @param int $showSource 是否显示原发送者信息 * @param string $leaveMessage 转发留言 * @return mixed */ public function forwardMsg($dialogids, $userids, $user, $showSource = 1, $leaveMessage = '') { return AbstractModel::transaction(function() use ($dialogids, $user, $userids, $showSource, $leaveMessage) { $originalMsg = Base::json2array($this->getRawOriginal('msg')); $msgs = []; $already = []; if ($dialogids) { if (!is_array($dialogids)) { $dialogids = [$dialogids]; } foreach ($dialogids as $dialogid) { $res = self::sendMsg('forward-'.( $showSource ? 1 : 0).'-'.($this->forward_id ?: $this->id), $dialogid, $this->type, $originalMsg, $user->userid); if (Base::isSuccess($res)) { $msgs[] = $res['data']; $already[] = $dialogid; } if ($leaveMessage) { self::sendMsg(null, $dialogid, 'text', ['text' => $leaveMessage], $user->userid); } } } if ($userids) { if (!is_array($userids)) { $userids = [$userids]; } foreach ($userids as $userid) { if (!User::whereUserid($userid)->exists()) { continue; } $dialog = WebSocketDialog::checkUserDialog($user, $userid); if ($dialog && !in_array($dialog->id, $already)) { $res = self::sendMsg('forward-'.( $showSource ? 1 : 0).'-'.($this->forward_id ?: $this->id), $dialog->id, $this->type, $originalMsg, $user->userid); if (Base::isSuccess($res)) { $msgs[] = $res['data']; } if ($leaveMessage) { self::sendMsg(null, $dialog->id, 'text', ['text' => $leaveMessage], $user->userid); } } } } return Base::retSuccess('转发成功', [ 'msgs' => $msgs ]); }); } /** * 删除消息 * @param array|int $ids * @return void */ public static function deleteMsgs($ids) { $ids = Base::arrayRetainInt(is_array($ids) ? $ids : [$ids], true); AbstractModel::transaction(function() use ($ids) { $dialogIds = WebSocketDialogMsg::select('dialog_id')->whereIn("id", $ids)->distinct()->get()->pluck('dialog_id'); $replyIds = WebSocketDialogMsg::select('reply_id')->whereIn("id", $ids)->distinct()->get()->pluck('reply_id'); // WebSocketDialogMsgRead::whereIn('msg_id', $ids)->whereNull('read_at')->delete(); // 未阅读记录不需要软删除,直接删除即可 WebSocketDialogMsgTodo::whereIn('msg_id', $ids)->delete(); self::whereIn('id', $ids)->delete(); // $dialogDatas = WebSocketDialog::whereIn('id', $dialogIds)->get(); foreach ($dialogDatas as $dialogData) { $dialogData->updateMsgLastAt(); } foreach ($replyIds as $id) { self::whereId($id)->update(['reply_num' => self::whereReplyId($id)->count()]); } }); } /** * 撤回消息 * @return void */ public function withdrawMsg() { $send_dt = Carbon::parse($this->created_at)->addDay(); if ($send_dt->lt(Carbon::now())) { throw new ApiException('已超过24小时,此消息不能撤回'); } AbstractModel::transaction(function() { $deleteRead = WebSocketDialogMsgRead::whereMsgId($this->id)->whereNull('read_at')->delete(); // 未阅读记录不需要软删除,直接删除即可 $this->delete(); // if ($this->reply_id > 0) { self::whereId($this->reply_id)->decrement('reply_num'); } // $dialogData = $this->webSocketDialog; if ($dialogData) { foreach ($dialogData->dialogUser as $dialogUser) { $dialogUser->updated_at = Carbon::now(); $dialogUser->save(); } $userids = $dialogData->dialogUser->pluck('userid')->toArray(); PushTask::push([ 'userid' => $userids, 'msg' => [ 'type' => 'dialog', 'mode' => 'delete', 'data' => [ 'id' => $this->id, 'dialog_id' => $this->dialog_id, 'last_msg' => $dialogData->updateMsgLastAt(), 'update_read' => $deleteRead ? 1 : 0 ], ] ]); } // WebSocketDialogMsgTodo::whereMsgId($this->id)->delete(); }); } /** * 预览消息 * @param bool $preserveHtml 保留html格式 * @param null|array $data * @return string */ public function previewMsg($preserveHtml = false, $data = null) { if ($data === null) { $data = [ 'type' => $this->type, 'msg' => $this->msg, ]; } switch ($data['type']) { case 'text': case 'word-chain': case 'vote': return $this->previewTextMsg($data['msg']['text'], $preserveHtml); case 'record': return "[语音]"; case 'meeting': return "[会议] ${$data['msg']['name']}"; case 'file': if ($data['msg']['type'] == 'img') { return "[图片]"; } return "[文件] {$data['msg']['name']}"; case 'tag': $action = $data['msg']['action'] === 'remove' ? '取消标注' : '标注'; return "[{$action}] {$this->previewMsg(false, $data['msg']['data'])}"; case 'top': $action = $data['msg']['action'] === 'remove' ? '取消置顶' : '置顶'; return "[{$action}] {$this->previewMsg(false, $data['msg']['data'])}"; case 'todo': $action = $data['msg']['action'] === 'remove' ? '取消待办' : ($data['msg']['action'] === 'done' ? '完成' : '设待办'); return "[{$action}] {$this->previewMsg(false, $data['msg']['data'])}"; case 'notice': return $data['msg']['notice']; default: return "[未知的消息]"; } } /** * 生成关键词 * @return string */ public function generateMsgKey() { return match ($this->type) { 'text' => str_replace(" ", " ", strip_tags($this->msg['text'])), 'meeting', 'file' => $this->msg['name'], default => '', }; } /** * 返回引用消息(如果是文本消息则截取) * @param int $strlen * @return array|mixed */ public function quoteTextMsg($strlen = 30) { $msg = $this->msg; if ($this->type === 'text') { $msg['text'] = $this->previewTextMsg($msg['text']); if (mb_strlen($msg['text']) > $strlen) { $msg['text'] = mb_substr($msg['text'], 0, $strlen - 3) . "..."; } } return $msg; } /** * 返回文本预览消息 * @param $text * @param bool $preserveHtml 保留html格式 * @return string|string[]|null */ private function previewTextMsg($text, $preserveHtml = false) { if (!$text) return ''; $text = preg_replace("/]*?alt=\"(\S+)\"[^>]*?>/", "[$1]", $text); $text = preg_replace("/]*?>/", "[动画表情]", $text); $text = preg_replace("/]*?>/", "[图片]", $text); if (!$preserveHtml) { $text = strip_tags($text); $text = str_replace([" ", "&", "<", ">"], [" ", "&", "<", ">"], $text); } return $text; } /** * 处理文本消息内容,用于发送前 * @param $text * @param $dialog_id * @return mixed|string|string[] */ public static function formatMsg($text, $dialog_id) { @ini_set("pcre.backtrack_limit", 999999999); // 基础处理 $text = preg_replace("/<(\/[a-zA-Z]+)\s*>/s", "<$1>", $text); // 图片 [:IMAGE:className:width:height:src:alt:] preg_match_all("/(<\/img>)*/s", $text, $matchs); foreach ($matchs[2] as $key => $base64) { $imagePath = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/"; Base::makeDir(public_path($imagePath)); $imagePath .= md5s($base64) . "." . $matchs[1][$key]; if (Base::saveContentImage(public_path($imagePath), base64_decode($base64))) { $imageSize = getimagesize(public_path($imagePath)); if ($extension = Image::thumbImage(public_path($imagePath), public_path($imagePath) . "_thumb.{*}", 320, 0)) { $imagePath .= "_thumb.{$extension}"; } $text = str_replace($matchs[0][$key], "[:IMAGE:browse:{$imageSize[0]}:{$imageSize[1]}:{$imagePath}::]", $text); } } // 表情图片 preg_match_all("//s", $text, $matchs); foreach ($matchs[1] as $key => $str) { preg_match("/data-asset=\"(.*?)\"/", $str, $matchAsset); preg_match("/data-name=\"(.*?)\"/", $str, $matchName); $imageSize = null; $imagePath = ""; $imageName = ""; if ($matchAsset[1] === "emosearch") { preg_match("/src=\"(.*?)\"/", $str, $matchSrc); if ($matchSrc) { $srcMd5 = md5($matchSrc[1]); $imagePath = "uploads/emosearch/" . substr($srcMd5, 0, 2) . "/" . substr($srcMd5, 32 - 2) . "/"; Base::makeDir(public_path($imagePath)); $imagePath .= md5s($matchSrc[1]); if (file_exists(public_path($imagePath))) { $imageSize = getimagesize(public_path($imagePath)); } else { $image = file_get_contents($matchSrc[1]); if ($image && file_put_contents(public_path($imagePath), $image)) { $imageSize = getimagesize(public_path($imagePath)); // 添加后缀 if ($imageSize && !str_contains($imagePath, '.')) { preg_match("/^image\/(png|jpg|jpeg|webp|gif)$/", $imageSize['mime'], $matchMine); if ($matchMine) { $imageNewPath = $imagePath . "." . $matchMine[1]; if (rename(public_path($imagePath), public_path($imageNewPath))) { $imagePath = $imageNewPath; } } } } } } } elseif (file_exists(public_path($matchAsset[1]))) { $imagePath = $matchAsset[1]; $imageName = $matchName[1]; $imageSize = getimagesize(public_path($matchAsset[1])); } if ($imageSize) { $text = str_replace($matchs[0][$key], "[:IMAGE:emoticon:{$imageSize[0]}:{$imageSize[1]}:{$imagePath}:{$imageName}:]", $text); } else { $text = str_replace($matchs[0][$key], "[:IMAGE:browse:90:90:images/other/imgerr.jpg::]", $text); } } // 其他网络图片 $imageSaveLocal = Base::settingFind("system", "image_save_local"); preg_match_all("/]*?src=([\"'])(.*?(png|jpg|jpeg|webp|gif).*?)\\1[^>]*?>/is", $text, $matchs); foreach ($matchs[2] as $key => $str) { if ($imageSaveLocal === 'close') { $imageSize = getimagesize($str); if ($imageSize === false) { $imageSize = ["auto", "auto"]; } $imagePath = "base64-" . base64_encode($str); $text = str_replace($matchs[0][$key], "[:IMAGE:browse:{$imageSize[0]}:{$imageSize[1]}:{$imagePath}::]", $text); continue; } if (str_starts_with($str, "{{RemoteURL}}")) { $imagePath = Base::leftDelete($str, "{{RemoteURL}}"); $imagePath = Base::thumbRestore($imagePath); } else { $imagePath = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/"; Base::makeDir(public_path($imagePath)); $imagePath .= md5s($str) . "." . $matchs[3][$key]; } if (file_exists(public_path($imagePath))) { $imageSize = getimagesize(public_path($imagePath)); if ($extension = Image::thumbImage(public_path($imagePath), public_path($imagePath) . "_thumb.{*}", 320, 0)) { $imagePath .= "_thumb.{$extension}"; } $text = str_replace($matchs[0][$key], "[:IMAGE:browse:{$imageSize[0]}:{$imageSize[1]}:{$imagePath}::]", $text); } else { $image = file_get_contents($str); if (empty($image)) { $text = str_replace($matchs[0][$key], "[:IMAGE:browse:90:90:images/other/imgerr.jpg::]", $text); } else if (Base::saveContentImage(public_path($imagePath), $image)) { $imageSize = getimagesize(public_path($imagePath)); if ($extension = Image::thumbImage(public_path($imagePath), public_path($imagePath) . "_thumb.{*}", 320, 0)) { $imagePath .= "_thumb.{$extension}"; } $text = str_replace($matchs[0][$key], "[:IMAGE:browse:{$imageSize[0]}:{$imageSize[1]}:{$imagePath}::]", $text); } } } // @成员、#任务、~文件 preg_match_all("/.*?<\/span>.*?<\/span>.*?<\/span>/s", $text, $matchs); foreach ($matchs[1] as $key => $str) { preg_match("/data-denotation-char=\"(.*?)\"/", $str, $matchChar); preg_match("/data-id=\"(.*?)\"/", $str, $matchId); preg_match("/data-value=\"(.*?)\"/s", $str, $matchValye); $keyId = $matchId[1]; if ($matchChar[1] === "~") { if (Base::isNumber($keyId)) { $file = File::permissionFind($keyId, User::auth()); if ($file->type == 'folder') { throw new ApiException('文件夹不支持分享'); } $fileLink = $file->getShareLink(User::userid()); $keyId = $fileLink['code']; } else { preg_match("/\/single\/file\/(.*?)$/i", $keyId, $match); if ($match && strlen($match[1]) >= 8) { $keyId = $match[1]; } else { throw new ApiException('文件分享错误'); } } } $text = str_replace($matchs[0][$key], "[:{$matchChar[1]}:{$keyId}:{$matchValye[1]}:]", $text); } // 处理快捷消息 preg_match_all("/]*?data-quick-key=([\"'])(.*?)\\1[^>]*?>(.*?)<\/span>/is", $text, $matchs); foreach ($matchs[0] as $key => $str) { $quickKey = $matchs[2][$key]; $quickLabel = $matchs[3][$key]; if ($quickKey && $quickLabel) { $quickKey = str_replace(":", "", $quickKey); $quickLabel = str_replace(":", "", $quickLabel); $text = str_replace($str, "[:QUICK:{$quickKey}:{$quickLabel}:]", $text); } } // 处理链接标签 preg_match_all("/]*?href=([\"'])(.*?)\\1[^>]*?>(.*?)<\/a>/is", $text, $matchs); foreach ($matchs[0] as $key => $str) { $herf = $matchs[2][$key]; $title = $matchs[3][$key] ?: $herf; preg_match("/\/single\/file\/(.*?)$/i", strip_tags($title), $match); if ($match && strlen($match[1]) >= 8) { $file = File::select(['files.id', 'files.name', 'files.ext'])->join('file_links as L', 'files.id', '=', 'L.file_id')->where('L.code', $match[1])->first(); if ($file && $file->name) { $name = $file->ext ? "{$file->name}.{$file->ext}" : $file->name; $text = str_replace($str, "[:~:{$match[1]}:{$name}:]", $text); continue; } } $herf = base64_encode($herf); $title = base64_encode($title); $text = str_replace($str, "[:LINK:{$herf}:{$title}:]", $text); } // 文件分享链接 preg_match_all("/(https*:\/\/)((\w|=|\?|\.|\/|&|-|:|\+|%|;|#|@|,|!)+)/i", $text, $matchs); if ($matchs) { foreach ($matchs[0] as $str) { preg_match("/\/single\/file\/(.*?)$/i", $str, $match); if ($match && strlen($match[1]) >= 8) { $file = File::select(['files.id', 'files.name', 'files.ext'])->join('file_links as L', 'files.id', '=', 'L.file_id')->where('L.code', $match[1])->first(); if ($file && $file->name) { $name = $file->ext ? "{$file->name}.{$file->ext}" : $file->name; $text = str_replace($str, "[:~:{$match[1]}:{$name}:]", $text); } } } } // 过滤标签 $text = strip_tags($text, '