dootask/app/Http/Controllers/IndexController.php
kuaifan 6b54b7b1c5 feat(todo): 聊天待办支持提醒时间(到点引用原消息+@提及)
给消息待办增加可选「提醒时间」,到点由 todo-alert 机器人对原消息发起
reply、正文 @ 仍在群内的被指派成员,完全复用原生回复/提及链路(定向未读、
红点、绕过会话免打扰、App 推送);被指派人全部退群则跳过发送并标记已提醒。
设/改/取消提醒的权限沿用 todo_set_permission 开关与 checkTodoOwnerPermission。

后端:
- 迁移:web_socket_dialog_msg_todos 增加 remind_at/reminded_at 及索引,
  注册为日期字段
- WebSocketDialogMsgTodo::dueReminders() 选取到点(未提醒/未完成)待办(limit 500)
- WebSocketDialogMsg::setTodoRemind() 纯数据写入(改时间重置 reminded_at),
  接入 toggleTodoMsg($remindAt) 与 msg__todo 透传
- 接口 msg__todoremind 设置/修改/取消提醒(权限闸门、消息类型校验、
  pushMsg 同步 todo_done)
- TodoRemindTask 到点按消息发提醒(reminded_at 防重复、迟发补发、原消息/
  会话删除兜底),buildRemindText 生成 <span class="mention user"> 文本,
  接入 crontab;登记 todo-alert 机器人
- msgJoinGroup 从提醒文本中提取被 @ 成员

前端:
- 设待办弹窗新增「提醒时间」(预设 + 自定义 DatePicker)
- 待办详情浮层每条待办可查看/修改/取消提醒:DatePicker on-clear「清空」
  二次确认后取消,无时间时仅关闭面板不发请求
- 待办浮层窄屏(≤500px)改为 待办/完成 tab 切换,宽屏维持双列;列表为空
  展示空状态占位;提醒时间用 Icon 替换 emoji
- 时间读写对齐项目任务时间的时区约定

测试:tests/Feature/TodoRemindTest(数据/选取/写入/权限决策/buildRemindText/
text mention 提取),TodoSetPermissionTest 无回归。

任务 #124 后续增强。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:08:34 +00:00

517 lines
20 KiB
PHP
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers;
use Arr;
use Cache;
use Request;
use Redirect;
use Response;
use App\Models\File;
use App\Module\Doo;
use App\Module\Base;
use App\Module\Extranet;
use App\Module\RandomColor;
use App\Tasks\LoopTask;
use App\Tasks\AppPushTask;
use App\Tasks\JokeSoupTask;
use App\Tasks\DeleteTmpTask;
use App\Tasks\EmailNoticeTask;
use App\Tasks\AutoArchivedTask;
use App\Tasks\DeleteBotMsgTask;
use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use App\Tasks\TodoRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
/**
* 页面
* Class IndexController
* @package App\Http\Controllers
*/
class IndexController extends InvokeController
{
public function __invoke($method, $action = '', $child = '')
{
$app = $method ?: 'main';
if ($action) {
$app .= "__" . $action;
}
if ($app == 'default') {
return '';
}
if (!method_exists($this, $app)) {
$app = method_exists($this, $method) ? $method : 'main';
}
return $this->$app($child);
}
/**
* 首页
* @return \Illuminate\Http\Response
*/
public function main()
{
$hotFile = public_path('hot');
$manifestFile = public_path('manifest.json');
if (file_exists($hotFile)) {
$array = Base::json2array(file_get_contents($hotFile));
$style = null;
$script = preg_replace("/^(\/\/(.*?))(:\d+)?\//i", "$1:" . $array['APP_DEV_PORT'] . "/", asset_main("resources/assets/js/app.js"));
$proxyUri = Base::liveEnv('VSCODE_PROXY_URI');
if (is_string($proxyUri) && preg_match('/^https?:\/\//i', $proxyUri)) {
$script = preg_replace('/^(https?:\/\/|\/\/)[^\/]+/', rtrim($proxyUri, '/'), $script, 1);
}
} else {
$array = Base::json2array(file_get_contents($manifestFile));
$style = asset_main($array['resources/assets/js/app.js']['css'][0]);
$script = asset_main($array['resources/assets/js/app.js']['file']);
}
return response()->view('main', [
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
'version' => Base::getVersion(),
'style' => $style,
'script' => $script,
]);
}
/**
* 获取版本号
* @return \Illuminate\Http\RedirectResponse
*/
public function version()
{
return Redirect::to(Base::fillUrl('api/system/version'), 301);
}
/**
* 健康检查
* @return string
*/
public function health()
{
return "ok";
}
/**
* 头像
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response|\Symfony\Component\HttpFoundation\BinaryFileResponse
*/
public function avatar()
{
$segment = Request::segment(2);
if ($segment && preg_match('/.*?\.png$/i', $segment)) {
$name = substr($segment, 0, -4);
} else {
$name = Request::input('name', 'D');
}
$size = Request::input('size', 128);
$color = Request::input('color');
$background = Request::input('background');
// 移除各种括号及其内容
$pattern = '/[(\[【{<<『「](.*?)[)\]】}>>』」]/u';
$name = preg_replace($pattern, '', $name) ?: preg_replace($pattern, '$1', $name);
// 移除常见标识词(不区分大小写)
$filterWords = [
// 测试相关
'测试', '测试号', '测试账号', '内测', '体验', '试用', 'test', 'testing', 'beta',
// 账号相关
'账号', '帐号', '账户', '帐户', 'account', 'acc', 'id', 'uid',
// 临时标识
'临时', '暂用', '备用', '主号', '副号', '小号', '大号', 'temp', 'temporary', 'backup',
// 系统相关
'系统', '管理员', 'admin', 'administrator', 'system', 'sys', 'root',
// 用户相关
'用户', 'user', '会员', 'member', 'vip', 'svip', 'mvip', 'premium',
// 官方相关
'官方', '正式', '认证', 'official', 'verified', 'auth',
// 客服相关
'客服', '售后', '服务', 'service', 'support', 'helper', 'assistant',
// 游戏相关
'game', 'gaming', 'player', 'gamer',
// 社交媒体相关
'ins', 'instagram', 'fb', 'facebook', 'tiktok', 'tweet', 'weibo', 'wechat',
// 常见后缀
'official', 'real', 'fake', 'copy', 'channel', 'studio', 'team', 'group',
// 职业相关
'dev', 'developer', 'designer', 'artist', 'writer', 'editor',
// 其他
'bot', 'robot', 'auto', 'anonymous', 'guest', 'default', 'new', 'old'
];
$filterWords = array_map(function ($word) {
return preg_quote($word, '/');
}, $filterWords);
$name = preg_replace('/' . implode('|', $filterWords) . '/iu', '', $name) ?: $name;
// 移除分隔符和特殊字符
$filterSymbols = [
// 常见分隔符
'-', '_', '=', '+', '/', '\\', '|',
'~', '@', '#', '$', '%', '^', '&', '*',
// 空格类字符
' ', ' ', "\t", "\n", "\r",
// 标点符号(中英文)
'。', '', '、', '', '', '', '',
'', '…', '‥', '', '″', '℃',
'.', ',', ';', ':', '?', '!',
// 引号类(修正版)
'"', "'", '', '', '“', '”', '`',
// 特殊符号
'★', '☆', '○', '●', '◎', '◇', '◆',
'□', '■', '△', '▲', '▽', '▼',
'♀', '♂', '♪', '♫', '♯', '♭', '♬',
'→', '←', '↑', '↓', '↖', '↗', '↙', '↘',
'√', '×', '÷', '±', '∵', '∴',
'♠', '♥', '♣', '♦',
// emoji 表情符号范围
'\x{1F300}-\x{1F9FF}',
'\x{2600}-\x{26FF}',
'\x{2700}-\x{27BF}',
'\x{1F900}-\x{1F9FF}',
'\x{1F600}-\x{1F64F}'
];
$filterSymbols = array_map(function ($symbol) {
return preg_quote($symbol, '/');
}, $filterSymbols);
$name = preg_replace('/[' . implode('', $filterSymbols) . ']/u', '', $name) ?: $name;
//
if (preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $name)) {
$name = mb_substr($name, mb_strlen($name) - 2);
}
if (empty($name)) {
$name = 'D';
}
if (empty($color)) {
$color = '#ffffff';
$cacheKey = "avatarBackgroundColor::" . md5($name);
$background = Cache::rememberForever($cacheKey, function () {
return RandomColor::one(['luminosity' => 'dark']);
});
}
//
$path = public_path('uploads/tmp/avatar/' . substr(md5($name), 0, 2));
$file = Base::joinPath($path, md5($name) . '.png');
if (file_exists($file)) {
return response()->file($file, [
'Pragma' => 'public',
'Cache-Control' => 'max-age=1814400',
'Content-type' => 'image/png',
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400),
]);
}
Base::makeDir($path);
//
$avatar = new Avatar([
'shape' => 'square',
'width' => $size,
'height' => $size,
'chars' => 2,
'fontSize' => $size / 2.9,
'uppercase' => true,
'fonts' => [resource_path('assets/statics/fonts/Source_Han_Sans_SC_Regular.otf')],
'foregrounds' => [$color],
'backgrounds' => [$background],
'border' => [
'size' => 0,
'color' => 'foreground',
'radius' => 0,
],
]);
return response($avatar->create($name)->save($file))
->header('Pragma', 'public')
->header('Cache-Control', 'max-age=1814400')
->header('Content-type', 'image/png')
->header('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + 1814400));
}
/**
* 接口文档
* @return \Illuminate\Http\RedirectResponse
*/
public function api()
{
return Redirect::to(Base::fillUrl('docs/index.html'), 301);
}
/**
* 系统定时任务限制内网访问1分钟/次)
* @return string
*/
public function crontab()
{
if (!Base::is_internal_ip(Base::getIp())) {
// 限制内网访问
return "Forbidden Access";
}
// 自动归档
Task::deliver(new AutoArchivedTask());
// 邮件通知
Task::deliver(new EmailNoticeTask());
// App推送
Task::deliver(new AppPushTask());
// 删除过期的临时表数据
Task::deliver(new DeleteTmpTask('tmp_msgs', 1));
Task::deliver(new DeleteTmpTask('tmp'));
Task::deliver(new DeleteTmpTask('task_worker', 12));
Task::deliver(new DeleteTmpTask('file'));
Task::deliver(new DeleteTmpTask('tmp_file', 24));
Task::deliver(new DeleteTmpTask('user_device', 24));
Task::deliver(new DeleteTmpTask('umeng_log', 24 * 3));
// 删除机器人消息
Task::deliver(new DeleteBotMsgTask());
// 周期任务
Task::deliver(new LoopTask());
// 签到提醒
Task::deliver(new CheckinRemindTask());
// 获取笑话/心灵鸡汤
Task::deliver(new JokeSoupTask());
// 未领取任务通知
Task::deliver(new UnclaimedTaskRemindTask());
// 待办提醒
Task::deliver(new TodoRemindTask());
// 关闭会议室
Task::deliver(new CloseMeetingRoomTask());
// Manticore Search 同步
Task::deliver(new ManticoreSyncTask());
// AI 任务建议
Task::deliver(new AiTaskLoopTask());
return "success";
}
/**
* 桌面客户端发布
*/
public function desktop__publish($name = '')
{
$publishVersion = Request::header('publish-version');
$latestFile = public_path("uploads/desktop/latest");
$latestVersion = file_exists($latestFile) ? trim(file_get_contents($latestFile)) : "0.0.1";
if (strtolower($name) === 'latest') {
$name = $latestVersion;
}
// 上传header 中包含 publish-version
if (preg_match("/^\d+\.\d+\.\d+$/", $publishVersion)) {
// 判断密钥
$publishKey = Request::header('publish-key');
if ($publishKey !== env('APP_KEY')) {
return Base::retError("key error");
}
// 判断版本
$action = Request::get('action');
$draftPath = "uploads/desktop-draft/{$publishVersion}/";
if ($action === 'release') {
// 将草稿版本发布为正式版本
$draftPath = public_path($draftPath);
$releasePath = public_path("uploads/desktop/{$publishVersion}/");
if (!file_exists($draftPath)) {
return Base::retError("draft version not exists");
}
if (file_exists($releasePath)) {
Base::deleteDirAndFile($releasePath);
}
Base::copyDirectory($draftPath, $releasePath);
file_put_contents($latestFile, $publishVersion);
// 删除旧版本
Base::deleteDirAndFile(public_path("uploads/desktop-draft"));
$dirs = Base::recursiveDirs(public_path("uploads/desktop"), false);
sort($dirs);
$num = 0;
foreach ($dirs as $dir) {
if (!preg_match("/\/\d+\.\d+\.\d+$/", $dir)) {
continue;
}
$num++;
if ($num < 5) {
continue; // 保留最新的5个版本
}
if (filemtime($dir) > time() - 3600 * 24 * 30) {
continue; // 保留最近30天的版本
}
Base::deleteDirAndFile($dir);
}
return Base::retSuccess('success');
}
// 上传草稿版本
return Base::upload([
"file" => Request::file('file'),
"type" => 'publish',
"path" => $draftPath,
"saveName" => true,
]);
}
// 列表(访问路径 desktop/publish/{version}
if (preg_match("/^v*(\d+\.\d+\.\d+)$/", $name, $match)) {
$paths = [
"uploads/desktop/{$match[1]}/",
"uploads/desktop/v{$match[1]}/",
"uploads/desktop-draft/{$match[1]}/",
"uploads/desktop-draft/v{$match[1]}/",
];
$avaiPath = null;
foreach ($paths as $path) {
$dirPath = public_path($path);
$isDraft = str_contains($path, 'draft');
if (is_dir($dirPath)) {
$avaiPath = $path;
break;
}
}
abort_if(empty($avaiPath), 404);
$lists = Base::recursiveFiles($dirPath, false);
$files = [];
foreach ($lists as $file) {
if (preg_match('/\.(zip|yml|yaml|blockmap)$/i', $file) || str_ends_with($file, '-win.exe')) {
continue;
}
$fileName = basename($file, $dirPath);
$fileSize = filesize($file);
$files[] = [
'name' => $fileName,
'time' => date("Y-m-d H:i:s", filemtime($file)),
'size' => $fileSize > 0 ? Base::readableBytes($fileSize) : 0,
'url' => Base::fillUrl(Base::joinPath($avaiPath, $fileName)),
];
}
$otherVersion = [];
$dirs = Base::recursiveDirs(public_path("uploads/desktop"), false);
foreach ($dirs as $dir) {
if (!preg_match("/\/\d+\.\d+\.\d+$/", $dir)) {
continue;
}
$version = basename($dir);
if ($version === $match[1]) {
continue;
}
$otherVersion[] = [
'version' => $version,
'url' => Base::fillUrl("desktop/publish/{$version}"),
];
}
//
return view('desktop', [
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
'version' => $match[1],
'files' => $files,
'is_draft' => $isDraft,
'latest_version' => $latestVersion,
'other_version' => array_reverse($otherVersion),
]);
}
// 下载Latest 版本内的文件,访问路径 desktop/publish/{fileName}
if ($name) {
$filePath = public_path("uploads/desktop/{$latestVersion}/{$name}");
if (file_exists($filePath)) {
return Response::download($filePath);
}
}
// 404
abort(404);
}
/**
* Drawio 图标搜索
* @return array|mixed
*/
public function drawio__iconsearch()
{
$query = trim(Request::input('q'));
$page = trim(Request::input('p'));
$size = trim(Request::input('c'));
return Extranet::drawioIconSearch($query, $page, $size);
}
/**
* 预览文件
* @return array|mixed
*/
public function online__preview()
{
$key = trim(Request::input('key'));
//
$data = parse_url($key);
$path = Arr::get($data, 'path');
$file = public_path($path);
// 防止 ../ 穿越获取到系统文件
abort_if(!str_starts_with(realpath($file), public_path()), 404);
// 如果文件不存在,直接返回 404
abort_if(!file_exists($file), 404);
//
parse_str($data['query'], $query);
$name = Arr::get($query, 'name');
$ext = strtolower(Arr::get($query, 'ext'));
$userAgent = strtolower(Request::server('HTTP_USER_AGENT'));
if ($ext === 'pdf') {
// 文件超过 10m 不支持在线预览,提示下载
if (filesize($file) > 10 * 1024 * 1024) {
return view('download', [
'system_alias' => Base::settingFind('system', 'system_alias', 'WebPage'),
'name' => $name,
'size' => Base::readableBytes(filesize($file)),
'url' => Base::fillUrl($path),
'button' => Doo::translate('点击下载'),
]);
}
// 浏览器类型
$browser = 'none';
if (str_contains($userAgent, 'chrome') || str_contains($userAgent, 'android_kuaifan_eeui')) {
$browser = str_contains($userAgent, 'android_kuaifan_eeui') ? 'android-mobile' : 'chrome-desktop';
} elseif (str_contains($userAgent, 'safari') || str_contains($userAgent, 'ios_kuaifan_eeui')) {
$browser = str_contains($userAgent, 'ios_kuaifan_eeui') ? 'safari-mobile' : 'safari-desktop';
}
// electron 直接在线预览查看
if (str_contains($userAgent, 'electron') || str_contains($browser, 'desktop')) {
return Response::download($file, $name, [
'Content-Type' => 'application/pdf'
], 'inline');
}
// EEUI App 直接在线预览查看
if (Base::isEEUIApp() && Base::judgeClientVersion("0.34.47")) {
if ($browser === 'safari-mobile') {
$redirectUrl = Base::fillUrl($path);
return <<<EOF
<script>
window.top.postMessage({
action: "eeuiAppSendMessage",
data: [
{
action: 'setPageData', // 设置页面数据
data: {
showProgress: true,
titleFixed: true,
urlFixed: true,
}
},
{
action: 'createTarget', // 创建目标(访问新地址)
url: "{$redirectUrl}",
}
]
}, "*")
</script>
EOF;
}
}
}
//
if (in_array($ext, File::localExt)) {
$url = Base::fillUrl($path);
} else {
$url = 'http://nginx/' . $path;
}
$url = Base::urlAddparameter($url, [
'fullfilename' => Base::rightDelete($name, '.' . $ext) . '_' . filemtime($file) . '.' . $ext
]);
$redirectUrl = Base::fillUrl("fileview/onlinePreview?url=" . urlencode(base64_encode($url)));
return Redirect::to($redirectUrl, 301);
}
}