352 lines
12 KiB
PHP
352 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
|
|
require __DIR__ . '/im_web_bootstrap.php';
|
|
|
|
class UserActionException extends RuntimeException
|
|
{
|
|
}
|
|
|
|
function resolveActionUserId(string $value): string
|
|
{
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$db = imDb();
|
|
$statement = $db->prepare(
|
|
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
|
|
);
|
|
$statement->execute([$value, $value, $value]);
|
|
$uid = $statement->fetchColumn();
|
|
|
|
return is_string($uid) && $uid !== '' ? $uid : $value;
|
|
}
|
|
|
|
function messageTimestampMs(string $messageUid): int
|
|
{
|
|
return (((int)$messageUid) >> 21) + 1514736000000;
|
|
}
|
|
|
|
function nowTimestampMs(): int
|
|
{
|
|
return (int)floor(microtime(true) * 1000);
|
|
}
|
|
|
|
function recallLimitMinutes(): float
|
|
{
|
|
$statement = imAppDb()->prepare(
|
|
'select `conf_value` from `system_conf`'
|
|
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
|
|
);
|
|
$statement->execute(['RetractMessage']);
|
|
$value = trim((string)($statement->fetchColumn() ?: '0'));
|
|
|
|
if ($value === '' || !is_numeric($value)) {
|
|
return 0.0;
|
|
}
|
|
|
|
return max(0.0, (float)$value);
|
|
}
|
|
|
|
function assertRecallWithinLimit(string $messageUid): void
|
|
{
|
|
$limitMinutes = recallLimitMinutes();
|
|
if ($limitMinutes <= 0) {
|
|
return;
|
|
}
|
|
|
|
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
|
|
$limitMs = (int)round($limitMinutes * 60 * 1000);
|
|
if ($messageAgeMs > $limitMs) {
|
|
throw new UserActionException('已超过可撤回时间');
|
|
}
|
|
}
|
|
|
|
function fetchActionMessageRow(string $messageUid): array
|
|
{
|
|
$table = imMessageTable($messageUid);
|
|
$statement = imDb()->prepare(
|
|
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
|
|
);
|
|
$statement->execute([$messageUid]);
|
|
$row = $statement->fetch();
|
|
|
|
return is_array($row) ? $row : [];
|
|
}
|
|
|
|
function ensureMessageEditsTable(): void
|
|
{
|
|
imDb()->exec(
|
|
'create table if not exists `t_web_message_edits` ('
|
|
. '`message_uid` varchar(32) not null,'
|
|
. '`editor_uid` varchar(64) not null,'
|
|
. '`edited_text` text null,'
|
|
. '`edited_at_ms` bigint not null,'
|
|
. '`created_at` datetime not null default current_timestamp,'
|
|
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
|
|
. 'primary key (`message_uid`),'
|
|
. 'key `idx_editor_uid` (`editor_uid`),'
|
|
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
|
. ') engine=InnoDB default charset=utf8mb4'
|
|
);
|
|
}
|
|
|
|
function ensurePinnedMessagesTable(): void
|
|
{
|
|
$db = imAppDb();
|
|
$db->exec(
|
|
'create table if not exists `t_web_pinned_messages` ('
|
|
. '`user_id` varchar(64) not null,'
|
|
. '`conversation_type` varchar(16) not null,'
|
|
. '`target_id` varchar(128) not null,'
|
|
. '`message_uid` varchar(32) not null,'
|
|
. '`message_json` text null,'
|
|
. '`updated_at_ms` bigint not null,'
|
|
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
|
. ') engine=InnoDB default charset=utf8mb4'
|
|
);
|
|
try {
|
|
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
|
} catch (Throwable) {
|
|
// The column already exists.
|
|
}
|
|
}
|
|
|
|
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
|
{
|
|
ensurePinnedMessagesTable();
|
|
$statement = imAppDb()->prepare(
|
|
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
|
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
|
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
|
);
|
|
$statement->execute([
|
|
$userId,
|
|
$conversationType,
|
|
$targetId,
|
|
$messageUid,
|
|
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
nowTimestampMs(),
|
|
]);
|
|
}
|
|
|
|
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
|
{
|
|
ensurePinnedMessagesTable();
|
|
$statement = imAppDb()->prepare(
|
|
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
|
|
);
|
|
$statement->execute([$userId, $conversationType, $targetId]);
|
|
}
|
|
|
|
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
|
{
|
|
$row = fetchActionMessageRow($messageUid);
|
|
if (!$row) {
|
|
return editWebGroupMessage($operatorId, $messageUid, $text);
|
|
}
|
|
if ((string)$row['_from'] !== $operatorId) {
|
|
throw new UserActionException('只能编辑自己发送的消息');
|
|
}
|
|
if ((int)$row['_content_type'] !== 1) {
|
|
throw new UserActionException('只能编辑文字消息');
|
|
}
|
|
|
|
$editedAt = nowTimestampMs();
|
|
$table = imMessageTable($messageUid);
|
|
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
|
|
$update->execute([$text, $messageUid]);
|
|
|
|
ensureMessageEditsTable();
|
|
$upsert = imDb()->prepare(
|
|
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
|
|
. ' values (?, ?, ?, ?)'
|
|
. ' on duplicate key update'
|
|
. ' `editor_uid` = values(`editor_uid`),'
|
|
. ' `edited_text` = values(`edited_text`),'
|
|
. ' `edited_at_ms` = values(`edited_at_ms`),'
|
|
. ' `updated_at` = current_timestamp'
|
|
);
|
|
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
|
|
|
|
return [
|
|
'messageUid' => $messageUid,
|
|
'text' => $text,
|
|
'editedAt' => $editedAt,
|
|
];
|
|
}
|
|
|
|
function fetchWebGroupActionMessage(string $messageUid): array
|
|
{
|
|
ensureWebImAppTables();
|
|
$statement = imAppDb()->prepare(
|
|
'select m.*, g.`category` from `t_web_group_messages` m'
|
|
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
|
|
. ' where m.`message_uid` = ? limit 1'
|
|
);
|
|
$statement->execute([$messageUid]);
|
|
$row = $statement->fetch();
|
|
|
|
return is_array($row) ? $row : [];
|
|
}
|
|
|
|
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
|
|
{
|
|
$row = fetchWebGroupActionMessage($messageUid);
|
|
if (!$row) {
|
|
throw new UserActionException('消息不存在');
|
|
}
|
|
if ((string)$row['sender_uid'] !== $operatorId) {
|
|
throw new UserActionException('只能编辑自己发送的消息');
|
|
}
|
|
if ((int)($row['recalled'] ?? 0) !== 0) {
|
|
throw new UserActionException('已撤回的消息请重新编辑发送');
|
|
}
|
|
|
|
$editedAt = nowTimestampMs();
|
|
$update = imAppDb()->prepare(
|
|
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
|
|
);
|
|
$update->execute([$text, $editedAt, $messageUid]);
|
|
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
|
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
|
|
|
|
return [
|
|
'messageUid' => $messageUid,
|
|
'text' => $text,
|
|
'editedAt' => $editedAt,
|
|
];
|
|
}
|
|
|
|
function webGroupMemberRoleForAction(string $groupId, string $userId): string
|
|
{
|
|
$statement = imAppDb()->prepare(
|
|
'select `role` from `t_web_group_members`'
|
|
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
|
|
);
|
|
$statement->execute([$groupId, $userId]);
|
|
$role = $statement->fetchColumn();
|
|
|
|
return is_string($role) ? $role : '';
|
|
}
|
|
|
|
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
|
|
{
|
|
$row = fetchWebGroupActionMessage($messageUid);
|
|
if (!$row) {
|
|
return false;
|
|
}
|
|
$isSender = (string)$row['sender_uid'] === $operatorId;
|
|
if (!$isSender) {
|
|
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
|
|
if (!in_array($role, ['owner', 'admin'], true)) {
|
|
throw new UserActionException('只有群主或管理员可以撤回他人消息');
|
|
}
|
|
}
|
|
assertRecallWithinLimit($messageUid);
|
|
|
|
$now = nowTimestampMs();
|
|
$update = imAppDb()->prepare(
|
|
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
|
|
);
|
|
$update->execute([$messageUid]);
|
|
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
|
$groupUpdate->execute([$now, (string)$row['group_id']]);
|
|
|
|
return true;
|
|
}
|
|
|
|
$payload = readJsonPayload();
|
|
$action = trim((string)($payload['action'] ?? ''));
|
|
$userId = trim((string)($payload['userId'] ?? ''));
|
|
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
|
|
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
|
|
|
|
if ($action === '' || $messageUid <= 0) {
|
|
respond(400, '缺少操作类型或消息ID');
|
|
}
|
|
|
|
try {
|
|
if ($action === 'pin' || $action === 'unpin') {
|
|
if ($userId === '') respond(400, '缺少操作者ID');
|
|
$targetId = trim((string)($payload['target'] ?? ''));
|
|
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
|
|
if ($targetId === '') respond(400, '缺少会话ID');
|
|
$userId = resolveActionUserId($userId);
|
|
if ($action === 'pin') {
|
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
|
} else {
|
|
removePinnedMessage($userId, $conversationType, $targetId);
|
|
}
|
|
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
|
|
}
|
|
|
|
if ($action === 'recall') {
|
|
if ($userId === '') {
|
|
respond(400, '缺少操作者ID');
|
|
}
|
|
$userId = resolveActionUserId($userId);
|
|
$row = fetchActionMessageRow($messageUidText);
|
|
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
|
|
respond(0, 'success', [
|
|
'messageUid' => (string)$messageUid,
|
|
'action' => $action,
|
|
]);
|
|
}
|
|
if (!$row) {
|
|
throw new UserActionException('消息不存在');
|
|
}
|
|
assertRecallWithinLimit($messageUidText);
|
|
requireImSuccess(imAdminPost('/admin/message/recall', [
|
|
'operator' => $userId,
|
|
'messageUid' => $messageUid,
|
|
'userRecall' => true,
|
|
'isUserRecall' => true,
|
|
]), '撤回失败');
|
|
|
|
respond(0, 'success', [
|
|
'messageUid' => (string)$messageUid,
|
|
'action' => $action,
|
|
]);
|
|
}
|
|
|
|
if ($action === 'edit') {
|
|
if ($userId === '') {
|
|
respond(400, '缺少操作者ID');
|
|
}
|
|
$text = trim((string)($payload['text'] ?? ''));
|
|
if ($text === '') {
|
|
respond(400, '编辑内容不能为空');
|
|
}
|
|
if (mb_strlen($text, 'UTF-8') > 5000) {
|
|
respond(400, '编辑内容过长');
|
|
}
|
|
|
|
$userId = resolveActionUserId($userId);
|
|
respond(0, 'success', [
|
|
'message' => editTextMessage($userId, $messageUidText, $text),
|
|
'action' => $action,
|
|
]);
|
|
}
|
|
|
|
if ($action === 'delete') {
|
|
requireImSuccess(imAdminPost('/admin/message/delete', [
|
|
'messageUid' => $messageUid,
|
|
]), '删除失败');
|
|
|
|
respond(0, 'success', [
|
|
'messageUid' => (string)$messageUid,
|
|
'action' => $action,
|
|
]);
|
|
}
|
|
|
|
respond(400, '不支持的消息操作');
|
|
} catch (Throwable $error) {
|
|
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
|
|
}
|