setTimezone(new DateTimeZone(IM_DB_TIMEZONE)); $currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i'); [$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart)); [$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd)); $startMinutes = $startHour * 60 + $startMinute; $endMinutes = $endHour * 60 + $endMinute; if ($startMinutes === $endMinutes) { return true; } if ($startMinutes < $endMinutes) { return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes; } return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes; } function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool { if ($role === 'owner') { return false; } $statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1'); $statement->execute([$groupId]); $rule = $statement->fetch(); if (!is_array($rule)) { return false; } $mode = (string)$rule['mode']; if ($mode === 'off') { return false; } if ($mode === 'all_except_admins' && $role === 'admin') { return false; } if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) { return false; } $scheduleType = (string)$rule['schedule_type']; if ($scheduleType === 'manual') { return true; } if ($scheduleType === 'daily') { return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs); } $startsAtMs = (int)$rule['starts_at_ms']; $endsAtMs = (int)$rule['ends_at_ms']; return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs; } function messageRequestValue(array $payload, string $key, string $default = ''): string { return trim((string)($_POST[$key] ?? $payload[$key] ?? $default)); } function safeUploadName(string $fileName): string { $name = basename(str_replace('\\', '/', $fileName)); $name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file'; $name = trim($name, " .\t\n\r\0\x0B"); return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file'; } function uploadExtension(string $fileName, string $mime, string $attachmentType): string { $extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)); $extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: ''; if ($extension === 'jpeg') { $extension = 'jpg'; } if ($attachmentType === 'image') { if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) { return $extension; } return match ($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/bmp', 'image/x-ms-bmp' => 'bmp', 'image/svg+xml' => 'svg', 'image/avif' => 'avif', 'image/heic' => 'heic', 'image/heif' => 'heif', default => 'jpg', }; } if ($extension !== '') { return $extension; } return 'bin'; } function detectedUploadMime(string $tmpName): string { $mime = mime_content_type($tmpName) ?: ''; if ($mime !== '' && $mime !== 'application/octet-stream') { return $mime; } $imageInfo = @getimagesize($tmpName); if (is_array($imageInfo) && !empty($imageInfo['mime'])) { return (string)$imageInfo['mime']; } return $mime !== '' ? $mime : 'application/octet-stream'; } function normalizedImageUploadMime(string $tmpName, string $mime): string { if (str_starts_with($mime, 'image/')) { return $mime; } $imageInfo = @getimagesize($tmpName); $imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : ''; if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) { return $imageMime; } throw new RuntimeException('请选择图片文件'); } function mediaPublicBaseUrl(): string { $isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off'; $scheme = $isHttps ? 'https' : 'http'; $host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1'); return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT; } function saveMessageAttachmentFile(array $file, string $attachmentType): array { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new RuntimeException('文件上传失败'); } $size = (int)($file['size'] ?? 0); $maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES; if ($size <= 0 || $size > $maxSize) { throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB'); } if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) { throw new RuntimeException('媒体存储目录不可写'); } $tmpName = (string)($file['tmp_name'] ?? ''); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new RuntimeException('文件上传失败'); } $mime = detectedUploadMime($tmpName); if ($attachmentType === 'image') { $mime = normalizedImageUploadMime($tmpName, $mime); } $mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE; $datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s'); $targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath; if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { throw new RuntimeException('媒体存储目录创建失败'); } $originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file'))); $extension = uploadExtension($originalName, $mime, $attachmentType); $targetName = bin2hex(random_bytes(12)) . '.' . $extension; $targetPath = $targetDir . '/' . $targetName; if (!move_uploaded_file($tmpName, $targetPath)) { throw new RuntimeException('文件保存失败'); } @chmod($targetPath, 0644); return [ 'type' => $attachmentType, 'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName, 'name' => $originalName, 'mime' => $mime, 'size' => $size, 'mediaType' => $mediaType, ]; } function storeMessageAttachment( PDO $appDb, string $messageUid, string $conversationType, string $target, string $sender, array $attachment, int $timestamp ): void { if ($messageUid === '' || empty($attachment['url'])) { return; } $statement = $appDb->prepare( 'insert into `t_web_message_attachments`' . ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)' . ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' . ' on duplicate key update' . ' `attachment_type` = values(`attachment_type`),' . ' `url` = values(`url`),' . ' `name` = values(`name`),' . ' `mime` = values(`mime`),' . ' `size_bytes` = values(`size_bytes`)' ); $statement->execute([ $messageUid, $conversationType, $target, $sender, (string)$attachment['type'], (string)$attachment['url'], (string)$attachment['name'], (string)$attachment['mime'], (int)$attachment['size'], $timestamp, ]); } function attachmentFromRequest(string $messageType): ?array { if (!in_array($messageType, ['image', 'video', 'file'], true)) { return null; } $forwardUrl = trim((string)($_POST['attachmentUrl'] ?? '')); if ($forwardUrl !== '') { $parts = parse_url($forwardUrl); $scheme = strtolower((string)($parts['scheme'] ?? '')); if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) { respond(400, '媒体地址不合法'); } $mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream')); $name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件')))); return [ 'type' => $messageType, 'url' => $forwardUrl, 'name' => $name, 'mime' => $mime, 'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)), 'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE), ]; } $attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null; if (!is_array($attachmentFile)) { respond(400, '缺少上传文件'); } return saveMessageAttachmentFile($attachmentFile, $messageType); } $contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? ''); $isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false; $payload = $isMultipart ? [] : readJsonPayload(); $sender = messageRequestValue($payload, 'sender'); $target = messageRequestValue($payload, 'target'); $text = messageRequestValue($payload, 'text'); $messageType = messageRequestValue($payload, 'messageType', 'text'); $conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0; $isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true); $mentionedUserIds = $payload['mentionedUserIds'] ?? []; $mentionedUserIds = is_array($mentionedUserIds) ? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds)))) : []; $attachment = null; if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) { respond(400, '缺少发送者、接收者或消息内容'); } try { if ($isGroupConversation) { ensureWebImAppTables(); $appDb = imAppDb(); $memberStatement = $appDb->prepare( 'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`' . ' from `t_web_groups` g' . ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0' . ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?' . ' limit 1' ); $memberStatement->execute([$target, $sender]); $group = $memberStatement->fetch(); if (!$group) { // 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。 $imDb = imDb(); $nativeGroupStatement = $imDb->prepare( 'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1' ); $nativeGroupStatement->execute([$target]); $nativeMemberStatement = $imDb->prepare( 'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1' ); $nativeMemberStatement->execute([$target, $sender]); $nativeMemberType = $nativeMemberStatement->fetchColumn(); if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) { respond(403, '你不在该群组中'); } $attachment = attachmentFromRequest($messageType); $pushContent = $text !== '' ? $text : ($attachment ? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name'])) : $text); $payloadBody = [ 'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1, 'searchableContent' => $pushContent, 'pushContent' => $pushContent, 'content' => $text, 'persistFlag' => 3, ]; if ($mentionedUserIds) { $payloadBody['mentionedType'] = 1; $payloadBody['mentionedTarget'] = $mentionedUserIds; } if ($attachment) { $payloadBody['mediaType'] = (int)$attachment['mediaType']; $payloadBody['remoteMediaUrl'] = (string)$attachment['url']; $payloadBody['extra'] = json_encode([ 'name' => (string)$attachment['name'], 'mime' => (string)$attachment['mime'], 'size' => (int)$attachment['size'], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [ 'sender' => $sender, 'conv' => ['type' => 1, 'target' => $target, 'line' => 0], 'payload' => $payloadBody, 'userMessage' => true, ]), '原生群消息发送失败'); $messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : ''; $timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs()); if ($attachment && $messageUid !== '') { storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp); } $role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member'); respond(0, 'success', [ 'messageUid' => $messageUid, 'timestamp' => $timestamp, 'senderRole' => $role, 'attachment' => $attachment, 'nativeError' => '', ]); } $category = (string)($group['category'] ?? 'group'); $role = (string)($group['role'] ?? 'member'); if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) { respond(403, '公告群仅管理员可发言'); } $now = webImNowMs(); if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) { respond(403, '当前群组处于禁言时段'); } $attachment = attachmentFromRequest($messageType); $messageUid = webImMessageUid(); $insert = $appDb->prepare( 'insert into `t_web_group_messages`' . ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)' . ' values (?, ?, ?, ?, ?, 0, 0)' ); $insert->execute([$messageUid, $target, $sender, $text, $now]); if ($attachment) { storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now); } $updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?'); $updateGroup->execute([$now, $target]); $nativeError = ''; try { $pushContent = $text !== '' ? $text : ($attachment ? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name'])) : $text); $payloadBody = [ 'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1, 'searchableContent' => $pushContent, 'pushContent' => $pushContent, 'content' => $text, 'persistFlag' => 3, ]; if ($mentionedUserIds) { $payloadBody['mentionedType'] = 1; $payloadBody['mentionedTarget'] = $mentionedUserIds; } if ($attachment) { $payloadBody['mediaType'] = (int)$attachment['mediaType']; $payloadBody['remoteMediaUrl'] = (string)$attachment['url']; $payloadBody['extra'] = json_encode([ 'name' => (string)$attachment['name'], 'mime' => (string)$attachment['mime'], 'size' => (int)$attachment['size'], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } requireImSuccess(imAdminPost('/admin/message/send', [ 'sender' => $sender, 'conv' => [ 'type' => 1, 'target' => $target, 'line' => 0, ], 'payload' => $payloadBody, 'userMessage' => true, ]), '原生群消息发送失败'); } catch (Throwable $error) { $nativeError = $error->getMessage() ?: '原生群消息发送失败'; } respond(0, 'success', [ 'messageUid' => $messageUid, 'timestamp' => $now, 'senderRole' => $role, 'attachment' => $attachment, 'nativeError' => $nativeError, ]); } $attachment = attachmentFromRequest($messageType); $pushContent = $text !== '' ? $text : ($attachment ? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name'])) : $text); $payloadBody = [ 'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1, 'searchableContent' => $pushContent, 'pushContent' => $pushContent, 'content' => $text, 'persistFlag' => 3, ]; if ($attachment) { $payloadBody['mediaType'] = (int)$attachment['mediaType']; $payloadBody['remoteMediaUrl'] = (string)$attachment['url']; $payloadBody['extra'] = json_encode([ 'name' => (string)$attachment['name'], 'mime' => (string)$attachment['mime'], 'size' => (int)$attachment['size'], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $result = requireImSuccess(imAdminPost('/admin/message/send', [ 'sender' => $sender, 'conv' => [ 'type' => 0, 'target' => $target, 'line' => 0, ], 'payload' => $payloadBody, 'userMessage' => true, ]), '发送失败'); $messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : ''; $timestamp = (int)($result['timestamp'] ?? webImNowMs()); if ($attachment && $messageUid !== '') { ensureWebImAppTables(); storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp); } respond(0, 'success', [ 'messageUid' => $messageUid !== '' ? $messageUid : null, 'timestamp' => $timestamp, 'attachment' => $attachment, ]); } catch (Throwable $error) { respond(500, $error->getMessage() ?: '发送失败'); }