dootask/app/Models/UserBot.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

638 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Ihttp;
use App\Module\Timer;
use App\Tasks\JokeSoupTask;
use Cache;
use Carbon\Carbon;
use Throwable;
/**
* App\Models\UserBot
*
* @property int $id
* @property int|null $userid 所属人ID
* @property int|null $bot_id 机器人ID
* @property int|null $clear_day 消息自动清理天数
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
* @property string|null $webhook_url 消息webhook地址
* @property int|null $webhook_num 消息webhook请求次数
* @property array $webhook_events Webhook事件配置
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserBot newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserBot newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserBot query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereBotId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereClearAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereClearDay($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookEvents($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookNum($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserBot whereWebhookUrl($value)
* @mixin \Eloquent
*/
class UserBot extends AbstractModel
{
public const WEBHOOK_EVENT_MESSAGE = 'message';
public const WEBHOOK_EVENT_DIALOG_OPEN = 'dialog_open';
public const WEBHOOK_EVENT_MEMBER_JOIN = 'member_join';
public const WEBHOOK_EVENT_MEMBER_LEAVE = 'member_leave';
protected $casts = [
'webhook_events' => 'array',
];
/**
* 获取 webhook 事件配置
*
* @param mixed $value
* @return array
*/
public function getWebhookEventsAttribute(mixed $value): array
{
if ($value === null || $value === '') {
return self::normalizeWebhookEvents(null, true);
}
return self::normalizeWebhookEvents($value, false);
}
/**
* 设置 webhook 事件配置
*
* @param mixed $value
* @return void
*/
public function setWebhookEventsAttribute(mixed $value): void
{
$useFallback = $value === null;
$this->attributes['webhook_events'] = Base::array2json(self::normalizeWebhookEvents($value, $useFallback));
}
/**
* 判断是否需要触发指定 webhook 事件
*
* @param string $event
* @return bool
*/
public function shouldDispatchWebhook(string $event): bool
{
if (!$this->webhook_url) {
return false;
}
if (!preg_match('/^https?:\/\//', $this->webhook_url)) {
return false;
}
return in_array($event, $this->webhook_events ?? [], true);
}
/**
* 发送 webhook
*
* @param string $event
* @param array $data
* @param int $timeout
* @return array|null
*/
public function dispatchWebhook(string $event, array $data, int $timeout = 30): ?array
{
if (!$this->shouldDispatchWebhook($event)) {
return null;
}
try {
$data['event'] = $event;
$result = Ihttp::ihttp_post($this->webhook_url, $data, $timeout);
$this->increment('webhook_num');
return $result;
} catch (Throwable $th) {
info(Base::array2json([
'webhook_url' => $this->webhook_url,
'data' => $data,
'error' => $th->getMessage(),
]));
return null;
}
}
/**
* 判断是否系统机器人
* @param $email
* @return bool
*/
public static function isSystemBot($email)
{
return str_ends_with($email, '@bot.system') && self::systemBotName($email);
}
/**
* 系统机器人名称
* @param $name string 邮箱 或 邮箱前缀
* @return string
*/
public static function systemBotName($name)
{
if (str_contains($name, "@")) {
$name = explode("@", $name)[0];
}
$name = match ($name) {
'system-msg' => '系统消息',
'task-alert' => '任务提醒',
'todo-alert' => '待办提醒',
'check-in' => '签到打卡',
'anon-msg' => '匿名消息',
'approval-alert' => '审批',
'ai-openai' => 'ChatGPT',
'ai-claude' => 'Claude',
'ai-deepseek' => 'DeepSeek',
'ai-gemini' => 'Gemini',
'ai-grok' => 'Grok',
'ai-ollama' => 'Ollama',
'ai-zhipu' => '智谱清言',
'ai-qianwen' => '通义千问',
'ai-wenxin' => '文心一言',
'bot-manager' => '机器人管理',
'meeting-alert' => '会议通知',
'okr-alert' => 'OKR提醒',
default => '', // 不是系统机器人时返回空(也可以拿来判断是否是系统机器人)
};
return Doo::translate($name);
}
/**
* 机器人菜单
* @param $email
* @return array|array[]
*/
public static function quickMsgs($email)
{
switch ($email) {
case 'check-in@bot.system':
$menu = [];
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
return $menu;
}
if (in_array('locat', $setting['modes']) && Base::isEEUIApp()) {
$mapTypes = [
'baidu' => ['key' => 'locat_bd_lbs_key', 'point' => 'locat_bd_lbs_point', 'msg' => '请填写百度地图AK'],
'amap' => ['key' => 'locat_amap_key', 'point' => 'locat_amap_point', 'msg' => '请填写高德地图Key'],
'tencent' => ['key' => 'locat_tencent_key', 'point' => 'locat_tencent_point', 'msg' => '请填写腾讯地图Key'],
];
$type = $setting['locat_map_type'];
if (isset($mapTypes[$type])) {
$conf = $mapTypes[$type];
$point = $setting[$conf['point']];
$menu[] = [
'key' => 'locat-checkin',
'label' => Doo::translate('定位签到'),
'config' => [
'type' => $type,
'key' => $setting[$conf['key']],
'lng' => $point['lng'],
'lat' => $point['lat'],
'radius' => intval($point['radius']),
]
];
}
}
if (in_array('manual', $setting['modes'])) {
$menu[] = [
'key' => 'manual-checkin',
'label' => Doo::translate('手动签到')
];
}
return $menu;
case 'anon-msg@bot.system':
return [
[
'key' => 'help',
'label' => Doo::translate('使用说明')
], [
'key' => 'privacy',
'label' => Doo::translate('隐私说明')
],
];
case 'meeting-alert@bot.system':
if (!Base::judgeClientVersion('0.39.89')) {
return [];
}
return [
[
'key' => 'meeting-create',
'label' => Doo::translate('新会议')
],
[
'key' => 'meeting-join',
'label' => Doo::translate('加入会议')
],
];
case 'bot-manager@bot.system':
return [
[
'key' => '/help',
'label' => Doo::translate('帮助指令')
], [
'key' => '/api',
'label' => Doo::translate('API接口文档')
], [
'key' => '/list',
'label' => Doo::translate('我的机器人')
],
];
default:
if (preg_match('/^(ai-|user-session-)(.*?)@bot\.system$/', $email, $match)) {
$menus = [
[
'key' => '~ai-session-create',
'label' => Doo::translate('开启新会话'),
],
[
'key' => '~ai-session-history',
'label' => Doo::translate('历史会话'),
]
];
if ($match[1] === "ai-") {
$aibotSetting = Base::setting('aibotSetting');
$aibotModel = $aibotSetting[$match[2] . '_model'];
$aibotModels = Setting::AIBotModels2Array($aibotSetting[$match[2] . '_models']);
if ($aibotModels) {
$menus = array_merge(
[
[
'key' => '~ai-model-select',
'label' => Doo::translate('选择模型'),
'config' => [
'model' => $aibotModel,
'models' => $aibotModels
]
]
],
$menus
);
}
}
return $menus;
}
return [];
}
}
/**
* 签到机器人
* @param $command
* @param $userid
* @param $extra
* @return string
*/
public static function checkinBotQuickMsg($command, $userid, $extra = [])
{
if (Cache::get("UserBot::checkinBotQuickMsg:{$userid}") === "yes") {
return "操作频繁!";
}
Cache::put("UserBot::checkinBotQuickMsg:{$userid}", "yes", Carbon::now()->addSecond());
//
if ($command === 'manual-checkin') {
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
return '暂未开启签到功能。';
}
if (!in_array('manual', $setting['modes'])) {
return '暂未开放手动签到。';
}
UserBot::checkinBotCheckin('manual-' . $userid, Timer::time(), true);
} elseif ($command === 'locat-checkin') {
$setting = Base::setting('checkinSetting');
if ($setting['open'] !== 'open') {
return '暂未开启签到功能。';
}
if (!in_array('locat', $setting['modes'])) {
return '暂未开放定位签到。';
}
if (empty($extra)) {
return '当前客户端版本低所需版本≥v0.39.75)。';
}
if (in_array($extra['type'], ['baidu', 'amap', 'tencent'])) {
// todo 判断距离
} else {
return '错误的定位签到。';
}
UserBot::checkinBotCheckin('locat-' . $userid, Timer::time(), true);
}
return null;
}
/**
* 签到机器人签到
* @param mixed $mac
* - 多个使用,分隔
* - 支持mac地址、(manual|locat|face|checkin)-userid
* @param $time
* @param bool $alreadyTip 签到过是否提示
*/
public static function checkinBotCheckin($mac, $time, $alreadyTip = false)
{
$setting = Base::setting('checkinSetting');
$times = $setting['time'] ? Base::json2array($setting['time']) : ['09:00', '18:00'];
$advance = (intval($setting['advance']) ?: 120) * 60;
$delay = (intval($setting['delay']) ?: 120) * 60;
//
$currentTime = Timer::time();
$nowDate = date("Y-m-d");
$nowTime = date("H:i:s");
$yesterdayDate = date("Y-m-d", strtotime("-1 day"));
//
// 今天的签到窗口
$timeStart = strtotime("{$nowDate} {$times[0]}");
$timeEnd = strtotime("{$nowDate} {$times[1]}");
$timeAdvance = max($timeStart - $advance, strtotime($nowDate));
// 移除 23:59:59 限制,允许跨天
$todayTimeDelay = $timeEnd + $delay;
//
// 昨天的延后窗口(用于判断凌晨打卡归属)
$yesterdayTimeEnd = strtotime("{$yesterdayDate} {$times[1]}");
$yesterdayTimeDelay = $yesterdayTimeEnd + $delay;
//
// 判断签到归属哪天
$targetDate = null;
$checkType = null; // 'up' 或 'down'
//
// 情况1在今天的有效窗口内
if ($currentTime >= $timeAdvance && $currentTime <= $todayTimeDelay) {
$targetDate = $nowDate;
if ($currentTime < $timeEnd) {
$checkType = 'up';
} else {
$checkType = 'down';
}
}
// 情况2凌晨时段检查是否在昨天的延后窗口内
elseif ($currentTime < $timeAdvance && $currentTime <= $yesterdayTimeDelay) {
$targetDate = $yesterdayDate;
$checkType = 'down';
}
//
// 构建错误消息
$errorTime = false;
if (!$targetDate) {
$displayDelay = date("H:i", $todayTimeDelay % 86400);
$nextDay = ($todayTimeDelay > strtotime("{$nowDate} 23:59:59")) ? "(+1)" : "";
$errorTime = "不在有效时间内,有效时间为:" . date("H:i", $timeAdvance) . "-{$displayDelay}{$nextDay}";
}
//
$macs = explode(",", $mac);
$checkins = [];
$array = [];
foreach ($macs as $mac) {
$mac = strtoupper($mac);
if (Base::isMac($mac)) {
// 路由器签到
if ($UserCheckinMac = UserCheckinMac::whereMac($mac)->first()) {
$array[] = [
'userid' => $UserCheckinMac->userid,
'mac' => $UserCheckinMac->mac,
'date' => $targetDate ?: $nowDate,
];
$checkins[] = [
'userid' => $UserCheckinMac->userid,
'remark' => $UserCheckinMac->remark,
];
}
} elseif (preg_match('/^(manual|locat|face|checkin)-(\d+)$/i', $mac, $match)) {
// 机器签到、手动签到、定位签到
$type = str_replace('checkin', 'face', strtolower($match[1]));
$mac = intval($match[2]);
$remark = match ($type) {
'manual' => $setting['manual_remark'] ?: 'Manual',
'locat' => $setting['locat_remark'] ?: 'Location',
'face' => $setting['face_remark'] ?: 'Machine',
default => '',
};
if ($UserInfo = User::whereUserid($mac)->whereBot(0)->first()) {
$array[] = [
'userid' => $UserInfo->userid,
'mac' => '00:00:00:00:00:00',
'date' => $targetDate ?: $nowDate,
];
$checkins[] = [
'userid' => $UserInfo->userid,
'remark' => $remark,
];
}
}
}
if (!$errorTime) {
foreach ($array as $item) {
$record = UserCheckinRecord::where($item)->first();
if (empty($record)) {
$record = UserCheckinRecord::createInstance($item);
}
$record->times = Base::array2json(array_merge($record->times, [$nowTime]));
$record->report_time = $time;
$record->save();
}
}
//
if ($checkins && $botUser = User::botGetOrCreate('check-in')) {
$getJokeSoup = function($type, $userid) {
$pre = $type == "up" ? "每日开心:" : "心灵鸡汤:";
$key = $type == "up" ? "jokes" : "soups";
$array = Base::json2array(Cache::get(JokeSoupTask::keyName($key)));
if ($array) {
$item = $array[array_rand($array)];
if ($item) {
Doo::setLanguage($userid);
return Doo::translate($pre . $item);
}
}
return null;
};
$sendMsg = function($type, $checkin) use ($errorTime, $alreadyTip, $getJokeSoup, $botUser, $targetDate, $nowDate) {
$displayDate = $targetDate ?: $nowDate;
$dialog = WebSocketDialog::checkUserDialog($botUser, $checkin['userid']);
if (!$dialog) {
return;
}
// 判断错误
if ($errorTime) {
if ($alreadyTip) {
$text = $errorTime;
$text .= $checkin['remark'] ? " ({$checkin['remark']})": "";
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'content' => $text,
], $botUser->userid, false, false, true);
}
return;
}
// 判断已打卡(使用目标日期作为缓存键)
$cacheKey = "Checkin::sendMsg-{$displayDate}-{$type}:" . $checkin['userid'];
$typeContent = $type == "up" ? "上班" : "下班";
if (Cache::get($cacheKey) === "yes") {
if ($alreadyTip) {
$dateHint = ($displayDate != $nowDate) ? "({$displayDate}) " : "今日";
$text = "{$dateHint}{$typeContent}打卡,无需重复打卡。";
$text .= $checkin['remark'] ? " ({$checkin['remark']})": "";
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'content' => $text,
], $botUser->userid, false, false, true);
}
return;
}
Cache::put($cacheKey, "yes", Carbon::now()->addDay());
// 打卡成功
$hi = date("H:i");
$remark = $checkin['remark'] ? " ({$checkin['remark']})": "";
$subcontent = $getJokeSoup($type, $checkin['userid']);
$dateInfo = ($displayDate != $nowDate) ? " ({$displayDate})" : "";
$title = "{$typeContent}打卡成功,打卡时间: {$hi}{$remark}{$dateInfo}";
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => 'content',
'title' => $title,
'content' => [
[
'content' => $title
], [
'content' => $subcontent,
'language' => false,
'style' => 'padding-top:4px;opacity:0.6',
]
],
], $botUser->userid, false, false, $type != "up");
};
// 根据打卡类型发送通知
if ($checkType === 'up') {
foreach ($checkins as $checkin) {
$sendMsg('up', $checkin);
}
}
if ($checkType === 'down') {
foreach ($checkins as $checkin) {
$sendMsg('down', $checkin);
}
}
}
}
/**
* 隐私机器人
* @param $command
* @return array
*/
public static function anonBotQuickMsg($command)
{
return match ($command) {
"help" => [
"title" => "匿名消息使用说明",
"content" => "使用说明:打开你想要发匿名消息的个人对话,点击输入框右边的 ⊕ 号,选择「匿名消息」即可输入你想要发送的匿名消息内容。"
],
"privacy" => [
"title" => "匿名消息隐私说明",
"content" => "匿名消息将通过「匿名消息(机器人)」发送给对方,不会记录你的身份信息。"
],
default => [],
};
}
/**
* 创建我的机器人
* @param int $userid 创建人userid
* @param string $botName 机器人名称
* @param bool $sessionSupported 是否支持会话
* @return array
*/
public static function newBot($userid, $botName, $sessionSupported = false)
{
if (User::select(['users.*'])
->join('user_bots', 'users.userid', '=', 'user_bots.bot_id')
->where('users.bot', 1)
->where('user_bots.userid', $userid)
->count() >= 50) {
return Base::retError("超过最大创建数量。");
}
if (strlen($botName) < 2 || strlen($botName) > 20) {
return Base::retError("机器人名称由2-20个字符组成。");
}
$botType = ($sessionSupported ? "user-session-" : "user-normal-") . Base::generatePassword();
$data = User::botGetOrCreate($botType, [
'nickname' => $botName
], $userid);
if (empty($data)) {
return Base::retError("创建失败。");
}
$dialog = WebSocketDialog::checkUserDialog($data, $userid);
if ($dialog) {
if ($sessionSupported) {
$dialogSession = WebSocketDialogSession::create([
'dialog_id' => $dialog->id,
]);
$dialogSession->save();
$dialog->session_id = $dialogSession->id;
$dialog->save();
}
WebSocketDialogMsg::sendMsg(null, $dialog->id, 'template', [
'type' => '/hello',
'title' => '创建成功。',
'data' => $data,
], $data->userid);
}
return Base::retSuccess("创建成功。", $data);
}
/**
* 获取可选的 webhook 事件
*
* @return string[]
*/
public static function webhookEventOptions(): array
{
return [
self::WEBHOOK_EVENT_MESSAGE,
self::WEBHOOK_EVENT_DIALOG_OPEN,
self::WEBHOOK_EVENT_MEMBER_JOIN,
self::WEBHOOK_EVENT_MEMBER_LEAVE,
];
}
/**
* 标准化 webhook 事件配置
*
* @param mixed $events
* @param bool $useFallback
* @return array
*/
public static function normalizeWebhookEvents(mixed $events, bool $useFallback = true): array
{
if (is_string($events)) {
$events = Base::json2array($events);
}
if ($events === null) {
$events = [];
}
if (!is_array($events)) {
$events = [$events];
}
$events = array_filter(array_map('strval', $events));
$events = array_values(array_intersect($events, self::webhookEventOptions()));
return $events ?: ($useFallback ? [self::WEBHOOK_EVENT_MESSAGE] : []);
}
}