Files
IM-web-new/dist/api/send_message.php
T

580 lines
20 KiB
PHP
Raw Normal View History

2026-06-30 10:13:48 +07:00
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
define(
'WEB_MEDIA_ROOT',
webImEnv(
'JC_WEB_IM_MEDIA_ROOT',
is_dir('/var/www/im/public/media')
? '/var/www/im/public/media'
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
)
);
2026-06-30 15:55:24 +07:00
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
2026-06-30 10:13:48 +07:00
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
const WEB_MINIO_PROJECT_CODE = '001';
2026-06-30 10:13:48 +07:00
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->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 nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
{
$statement = $db->prepare(
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $sender]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
2026-06-30 10:13:48 +07:00
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');
2026-06-30 15:55:24 +07:00
$port = (int)WEB_MEDIA_PUBLIC_PORT;
if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) {
return $scheme . '://' . $host;
}
2026-06-30 10:13:48 +07:00
2026-06-30 15:55:24 +07:00
return $scheme . '://' . $host . ':' . $port;
2026-06-30 10:13:48 +07:00
}
function messageStorageConfig(): array
{
$config = ['enabled' => false, 'url' => ''];
foreach (['t_app_system_conf', 'system_conf'] as $table) {
try {
$statement = imAppDb()->query(
'select `conf_name`, `conf_value` from `' . $table . '`'
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
);
$rows = $statement->fetchAll();
if (!$rows) {
continue;
}
foreach ($rows as $row) {
$name = strtolower((string)($row['conf_name'] ?? ''));
if ($name === 'is_minioplus') {
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
} elseif ($name === 'minio_url') {
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
}
}
break;
} catch (Throwable $error) {
continue;
}
}
return $config;
}
function saveMessageAttachmentToMinio(
array $file,
string $attachmentType,
string $sender,
string $baseUrl
): array {
$size = (int)($file['size'] ?? 0);
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$content = file_get_contents($tmpName);
if ($content === false) {
throw new RuntimeException('读取上传文件失败');
}
$requestBody = json_encode([
'projectCode' => WEB_MINIO_PROJECT_CODE,
'fileScene' => 'temp',
'fileName' => $originalName,
'contentType' => $mime,
'base64Content' => base64_encode($content),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($requestBody)) {
throw new RuntimeException('生成图片上传请求失败');
}
$handle = curl_init($baseUrl . '/project-storage/upload');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
],
CURLOPT_POSTFIELDS => $requestBody,
]);
$responseBody = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
$message = trim((string)($response['message'] ?? ''));
throw new RuntimeException(
$message !== ''
? 'MinIO上传失败:' . $message
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
);
}
return [
'type' => $attachmentType,
'url' => $url,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
];
}
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
2026-06-30 10:13:48 +07:00
{
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');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$storage = messageStorageConfig();
if ($storage['enabled']) {
if ($storage['url'] === '') {
throw new RuntimeException('MinIO已开启但未配置服务地址');
}
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
2026-06-30 10:13:48 +07:00
$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, string $sender): ?array
2026-06-30 10:13:48 +07:00
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
}
function messagePayloadBody(string $text, ?array $attachment): array
{
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 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);
}
return $payloadBody;
}
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
{
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
ensureWebImAppTables();
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
return [
'messageUid' => $messageUid !== '' ? $messageUid : null,
'timestamp' => $timestamp,
'attachment' => $attachment,
];
2026-06-30 10:13:48 +07:00
}
$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);
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', '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) {
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
if (!$nativeGroup) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType, $sender);
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
respond(0, 'success', [
...$nativeResult,
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
'native' => true,
]);
2026-06-30 10:13:48 +07:00
}
$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, $sender);
2026-06-30 10:13:48 +07:00
$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 {
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => messagePayloadBody($text, $attachment),
2026-06-30 10:13:48 +07:00
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
}
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $now,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => $nativeError,
]);
}
$attachment = attachmentFromRequest($messageType, $sender);
2026-06-30 10:13:48 +07:00
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 0,
'target' => $target,
'line' => 0,
],
'payload' => messagePayloadBody($text, $attachment),
2026-06-30 10:13:48 +07:00
'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() ?: '发送失败');
}