OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
/**
* Add a new calendar event.
*/
class AddCalendarEventTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('eventTitle')->description('Title of the event.')
->required()
->string('dateFrom')->description('Start date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required()
->string('dateTo')->description('End date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required()
->boolean('allDay')->description('Whether this is an all-day event or not.');
}
public function name(): string
{
return 'addEvent';
}
public function description(): string
{
return 'Adds a new calendar event.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$result = $this->calendarService->addEvent([
'description' => $arguments['eventTitle'],
'dateFrom' => $arguments['dateFrom'],
'dateTo' => $arguments['dateTo'],
'allDay' => $arguments['allDay'] ?? false,
]);
if ($result) {
return ToolResult::text("Event added successfully with ID: {$result}");
}
return ToolResult::error('Failed to add event.');
}
}

View File

@@ -0,0 +1,289 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Break down a task into multiple subtasks and schedule them.
*/
class BreakdownTaskTool extends Tool
{
public function __construct(
private Calendar $calendarService,
private Tickets $ticketService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('taskId')->description('ID of the task to break down.')
->required()
->raw('subtasks', ['type' => 'array', 'description' => 'Array of subtask definitions. Each should have title, description, and duration (in minutes).'])->required()
->string('startDate')->description('Start date to begin scheduling from in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required()
->string('endDate')->description('End date to finish scheduling by in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).');
}
public function name(): string
{
return 'breakdownTask';
}
public function description(): string
{
return 'Breaks a task down into subtasks and schedules them.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$taskId = (int) ($arguments['taskId'] ?? 0);
$subtasks = ($arguments['subtasks'] ?? []);
$startDate = $arguments['startDate'];
$endDate = ($arguments['endDate'] ?? null);
$task = $this->ticketService->getTicket($taskId);
if (! $task) {
return ToolResult::error("Task with ID {$taskId} not found.");
}
// Create subtasks
$subtaskResults = [];
$subtaskIds = [];
$subtaskDurations = [];
foreach ($subtasks as $subtaskDef) {
$params = [
'headline' => $subtaskDef['title'],
'description' => $subtaskDef['description'] ?? '',
'projectId' => $task->projectId,
'editorId' => session('userdata.id'),
'userId' => session('userdata.id'),
'type' => 'subtask',
'dependingTicketId' => $taskId,
'status' => 3,
];
$result = $this->ticketService->quickAddTicket($params);
if ($result) {
$subtaskResults[] = [
'headline' => $subtaskDef['title'],
'status' => 'success',
'id' => $result,
];
$subtaskIds[] = $result;
$subtaskDurations[$result] = $subtaskDef['duration'] ?? 30;
} else {
$subtaskResults[] = [
'headline' => $subtaskDef['title'],
'status' => 'error',
'message' => 'Failed to create subtask',
];
}
}
$createResult = Str::toMarkdown($subtaskResults);
// Schedule subtasks if we have IDs and a date range
if (! empty($subtaskIds) && ! empty($startDate)) {
$currentDate = dtHelper()->parseUserDateTime($startDate);
$lastDate = $endDate ? dtHelper()->parseUserDateTime($endDate) : $currentDate->addDays(7);
$scheduledResults = [];
while ($currentDate <= $lastDate && ! empty($subtaskIds)) {
$workStart = $currentDate->setTime(9, 0);
$workEnd = $currentDate->setTime(18, 0);
$dayFrom = $currentDate->startOfDay();
$dayTo = $currentDate->endOfDay();
$existingEvents = $this->calendarService->getCalendar(session('userdata.id'), $dayFrom, $dayTo);
$existingTasks = $this->ticketService->getScheduledTasks($dayFrom, $dayTo, session('userdata.id'));
$availableSlots = $this->findAvailableTimeSlots($workStart, $workEnd, $existingEvents, $existingTasks);
$tasksToSchedule = [];
foreach ($subtaskIds as $id) {
$tasksToSchedule[] = [
'id' => $id,
'duration' => $subtaskDurations[$id] ?? 30,
];
}
$scheduledTasks = $this->scheduleTasksInSlots($availableSlots, $tasksToSchedule);
foreach ($scheduledTasks as $scheduledTask) {
$editFrom = is_object($scheduledTask['dateFrom']) ? $scheduledTask['dateFrom']->toIso8601String() : (string) $scheduledTask['dateFrom'];
$editTo = is_object($scheduledTask['dateTo']) ? $scheduledTask['dateTo']->toIso8601String() : (string) $scheduledTask['dateTo'];
$this->ticketService->patch($scheduledTask['id'], [
'editFrom' => $editFrom,
'editTo' => $editTo,
]);
$index = array_search($scheduledTask['id'], $subtaskIds);
if ($index !== false) {
unset($subtaskIds[$index]);
$subtaskIds = array_values($subtaskIds);
}
}
if (! empty($scheduledTasks)) {
$dateStr = $currentDate->format('Y-m-d');
$scheduledResults[] = 'Scheduled '.count($scheduledTasks)." subtask(s) on {$dateStr}";
}
$currentDate = $currentDate->addDay();
}
$schedulingSummary = implode("\n", $scheduledResults);
return ToolResult::text("Task breakdown and scheduling completed.\n\nSubtask Creation:\n{$createResult}\n\nScheduling:\n{$schedulingSummary}");
}
return ToolResult::text("Task breakdown initiated.\n\n{$createResult}");
}
/**
* Find available time slots in a day.
*/
private function findAvailableTimeSlots($workStart, $workEnd, array $existingEvents, array $existingTasks): array
{
$busyTimes = [];
foreach ($existingEvents as $event) {
$busyTimes[] = [
'start' => dtHelper()->parseDbDateTime($event['dateFrom']),
'end' => dtHelper()->parseDbDateTime($event['dateTo']),
];
}
if (isset($existingTasks['totalTasks'])) {
foreach ($existingTasks['totalTasks'] as $task) {
if (! empty($task['editFrom']) && ! empty($task['editTo'])) {
$busyTimes[] = [
'start' => dtHelper()->parseDbDateTime($task['editFrom']),
'end' => dtHelper()->parseDbDateTime($task['editTo']),
];
}
}
}
usort($busyTimes, function ($a, $b) {
return $a['start']->getTimestamp() - $b['start']->getTimestamp();
});
$mergedBusyTimes = [];
foreach ($busyTimes as $busy) {
if (empty($mergedBusyTimes)) {
$mergedBusyTimes[] = $busy;
continue;
}
$lastBusy = &$mergedBusyTimes[count($mergedBusyTimes) - 1];
if ($busy['start'] <= $lastBusy['end']) {
if ($busy['end'] > $lastBusy['end']) {
$lastBusy['end'] = $busy['end'];
}
} else {
$mergedBusyTimes[] = $busy;
}
}
$availableSlots = [];
$currentTime = clone $workStart;
foreach ($mergedBusyTimes as $busy) {
if ($busy['end'] <= $workStart || $busy['start'] >= $workEnd) {
continue;
}
$busyStart = max($busy['start'], $workStart);
$busyEnd = min($busy['end'], $workEnd);
if ($currentTime < $busyStart) {
$availableSlots[] = [
'start' => clone $currentTime,
'end' => clone $busyStart,
];
}
$currentTime = clone $busyEnd;
}
if ($currentTime < $workEnd) {
$availableSlots[] = [
'start' => clone $currentTime,
'end' => clone $workEnd,
];
}
return $availableSlots;
}
/**
* Schedule tasks in available time slots.
*/
private function scheduleTasksInSlots(array $availableSlots, array $tasks): array
{
usort($tasks, function ($a, $b) {
$priorityA = $a['priority'] ?? 3;
$priorityB = $b['priority'] ?? 3;
if ($priorityA !== $priorityB) {
return $priorityB - $priorityA;
}
$durationA = $a['duration'] ?? 30;
$durationB = $b['duration'] ?? 30;
return $durationB - $durationA;
});
$scheduledTasks = [];
foreach ($tasks as $task) {
$durationMinutes = $task['duration'] ?? 30;
$durationSeconds = $durationMinutes * 60;
$taskId = $task['id'];
foreach ($availableSlots as $key => $slot) {
$slotDuration = $slot['end']->getTimestamp() - $slot['start']->getTimestamp();
if ($slotDuration >= $durationSeconds) {
$taskStart = clone $slot['start'];
$taskEnd = clone $taskStart;
$taskEnd = $taskEnd->modify("+{$durationMinutes} minutes");
$scheduledTasks[] = [
'id' => $taskId,
'dateFrom' => $taskStart,
'dateTo' => $taskEnd,
];
$availableSlots[$key]['start'] = $taskEnd;
if ($availableSlots[$key]['end']->getTimestamp() - $availableSlots[$key]['start']->getTimestamp() < 900) {
unset($availableSlots[$key]);
}
break;
}
}
}
return $scheduledTasks;
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
/**
* Add multiple calendar events in a single operation.
*/
class BulkAddCalendarEventsTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->raw('events', ['type' => 'array', 'description' => 'Array of event data. Each element should contain eventTitle, dateFrom, dateTo, and optional allDay.'])->required();
}
public function name(): string
{
return 'bulkAddEvents';
}
public function description(): string
{
return 'Adds multiple calendar events in a single operation.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$events = ($arguments['events'] ?? []);
$results = [];
$successCount = 0;
$failureCount = 0;
$validationErrors = [];
foreach ($events as $index => $eventData) {
if (! isset($eventData['eventTitle']) || ! isset($eventData['dateFrom']) || ! isset($eventData['dateTo'])) {
$validationErrors[] = "Event #{$index} is missing required fields (eventTitle, dateFrom, dateTo)";
Log::error("Event #{$index} is missing required fields (eventTitle, dateFrom, dateTo)");
continue;
}
try {
$dateFrom = ($eventData['dateFrom'] instanceof CarbonImmutable) ? $eventData['dateFrom'] : dtHelper()->parseUserDateTime($eventData['dateFrom']);
$dateTo = ($eventData['dateTo'] instanceof CarbonImmutable) ? $eventData['dateTo'] : dtHelper()->parseUserDateTime($eventData['dateTo']);
$durationSeconds = $dateTo->getTimestamp() - $dateFrom->getTimestamp();
if ($durationSeconds < 900) {
$validationErrors[] = "Event #{$index} is shorter than the minimum 15 minute duration";
}
} catch (\Exception $e) {
$validationErrors[] = "Event #{$index} has invalid date format";
}
}
if (! empty($validationErrors)) {
return ToolResult::error("Validation failed:\n- ".implode("\n- ", $validationErrors));
}
foreach ($events as $eventData) {
try {
$result = $this->calendarService->addEvent([
'description' => $eventData['eventTitle'],
'dateFrom' => $eventData['dateFrom'],
'dateTo' => $eventData['dateTo'],
'allDay' => $eventData['allDay'] ?? false,
'userId' => session('userdata.id'),
]);
if ($result) {
$successCount++;
$results[] = [
'title' => $eventData['eventTitle'],
'status' => 'success',
'id' => $result,
];
} else {
$failureCount++;
$results[] = [
'title' => $eventData['eventTitle'],
'status' => 'error',
'message' => 'Failed to create event',
];
}
} catch (\Exception $e) {
$failureCount++;
$results[] = [
'title' => $eventData['eventTitle'] ?? 'Unknown',
'status' => 'error',
'message' => 'Failed to create event',
];
Log::error($e);
}
}
return ToolResult::text(
"Bulk event creation completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
/**
* Update multiple calendar events in a single operation.
*/
class BulkEditCalendarEventsTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->raw('updates', ['type' => 'array', 'description' => 'Array of updates. Each element must have id and the fields to update.'])->required();
}
public function name(): string
{
return 'bulkEditEvents';
}
public function description(): string
{
return 'Updates multiple calendar events in a single operation.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$updates = ($arguments['updates'] ?? []);
$results = [];
$successCount = 0;
$failureCount = 0;
foreach ($updates as $update) {
if (! isset($update['id'])) {
$failureCount++;
$results[] = ['status' => 'error', 'message' => 'Missing event ID'];
continue;
}
if (isset($update['dateFrom']) && isset($update['dateTo'])) {
$dateFrom = dtHelper()->parseUserDateTime($update['dateFrom']);
$dateTo = dtHelper()->parseUserDateTime($update['dateTo']);
if ($dateFrom->format('Y-m-d') !== $dateTo->format('Y-m-d')) {
$failureCount++;
$results[] = [
'id' => $update['id'],
'status' => 'error',
'message' => 'Event must start and end on the same day',
];
continue;
}
$duration = $dateTo->getTimestamp() - $dateFrom->getTimestamp();
if ($duration < 900) {
$failureCount++;
$results[] = [
'id' => $update['id'],
'status' => 'error',
'message' => 'Event must be at least 15 minutes long',
];
continue;
}
}
$eventData = [
'id' => $update['id'],
'description' => $update['eventTitle'] ?? null,
'dateFrom' => $update['dateFrom'] ?? null,
'dateTo' => $update['dateTo'] ?? null,
'allDay' => $update['allDay'] ?? null,
];
$eventData = array_filter($eventData, function ($value) {
return $value !== null;
});
if ($this->calendarService->editEvent($eventData)) {
$successCount++;
$results[] = ['id' => $update['id'], 'status' => 'success'];
} else {
$failureCount++;
$results[] = ['id' => $update['id'], 'status' => 'error', 'message' => 'Failed to update event'];
}
}
return ToolResult::text(
"Bulk event update completed. Success: {$successCount}, Failed: {$failureCount}\n\n".
Str::toMarkdown($results)
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
/**
* Delete a calendar event.
*/
class DeleteCalendarEventTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('Event ID to delete.')
->required();
}
public function name(): string
{
return 'deleteEvent';
}
public function description(): string
{
return 'Deletes a calendar event.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
$result = $this->calendarService->delEvent($id);
if ($result) {
return ToolResult::text('Event deleted successfully.');
}
return ToolResult::error('Failed to delete event.');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
/**
* Edit an existing calendar event.
*/
class EditCalendarEventTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('Event ID to edit.')
->required()
->string('eventTitle')->description('Title of the event.')
->required()
->string('dateFrom')->description('Start date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required()
->string('dateTo')->description('End date in user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required()
->boolean('allDay')->description('Whether this is an all-day event.');
}
public function name(): string
{
return 'editEvent';
}
public function description(): string
{
return 'Edits an existing calendar event.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$result = $this->calendarService->editEvent([
'id' => (int) ($arguments['id'] ?? 0),
'description' => $arguments['eventTitle'],
'dateFrom' => $arguments['dateFrom'],
'dateTo' => $arguments['dateTo'],
'allDay' => $arguments['allDay'] ?? false,
]);
if ($result) {
return ToolResult::text('Event updated successfully.');
}
return ToolResult::error('Failed to update event.');
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
use Leantime\Domain\Calendar\Support\CalendarEventFormatter;
/**
* Get all calendar events for the current user.
*/
#[IsReadOnly]
class GetCalendarTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('from')->description('Starting date of the date range to look for. In user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required()
->string('until')->description('End date of the date range to look for. In user timezone format ISO8601 (example: 2024-04-30T15:00:00-04:00).')
->required();
}
public function name(): string
{
return 'getCalendar';
}
public function description(): string
{
return 'Gets all calendar events for the current user including tasks with due dates and scheduled work times.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$from = $arguments['from'];
$until = $arguments['until'];
$events = $this->calendarService->getCalendar(session('userdata.id'), $from, $until);
$maxEvents = 100;
$numEvents = 0;
$response = "## Calendar Events (tasks, and events)\n";
foreach ($events as $event) {
if ($numEvents < $maxEvents) {
$enhancedEvent = $event;
$enhancedEvent['dateFrom'] = dtHelper()->parseDbDateTime($event['dateFrom'])->setToUserTimezone()->toIso8601String();
$enhancedEvent['dateTo'] = dtHelper()->parseDbDateTime($event['dateTo'])->setToUserTimezone()->toIso8601String();
$formatter = new CalendarEventFormatter($enhancedEvent);
$response .= $formatter->format()."\n\n";
$numEvents++;
}
}
$externalEvents = $this->calendarService->getExternalCalendarEvents($from, $until);
$response .= "\n## External Calendar Events (ICAL imports)\n";
foreach ($externalEvents as $event) {
if ($numEvents < $maxEvents) {
$enhancedEvent = $event;
$enhancedEvent['dateFrom'] = dtHelper()->parseDbDateTime($event['dateFrom'])->setToUserTimezone()->toIso8601String();
$enhancedEvent['dateTo'] = dtHelper()->parseDbDateTime($event['dateTo'])->setToUserTimezone()->toIso8601String();
$formatter = new CalendarEventFormatter($enhancedEvent);
$response .= $formatter->format()."\n\n";
$numEvents++;
}
}
if ($numEvents === 0) {
return ToolResult::text('No calendar events found for the specified date range.');
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
/**
* Get the iCal URL for the user calendar.
*/
#[IsReadOnly]
class GetICalUrlTool extends Tool
{
public function __construct(
private Calendar $calendarService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema;
}
public function name(): string
{
return 'getICalUrl';
}
public function description(): string
{
return 'Gets the iCal URL for the user calendar.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
try {
$url = $this->calendarService->getICalUrl();
return ToolResult::text($url);
} catch (\Exception $e) {
return ToolResult::error('No iCal URL available. Generate one first using generateIcalHash.');
}
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Carbon\CarbonImmutable;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Calendar\Services\Calendar;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Create a structured day plan with appropriate events and breaks.
*/
class ScheduleDayTool extends Tool
{
public function __construct(
private Calendar $calendarService,
private Tickets $ticketService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('date')->description('Date to schedule in user timezone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
->required()
->raw('events', ['type' => 'array', 'description' => 'Array of events to schedule. Each should have title, duration (in minutes), and optional priority (1-20).'])->required()
->string('workingHoursStart')->description('Start of working hours in 24-hour format (HH:MM), if none available use 09:00.')
->string('workingHoursEnd')->description('End of working hours in 24-hour format (HH:MM), if none available use 18:00.')
->raw('taskIds', ['type' => 'array', 'description' => 'Array of task IDs to schedule instead of creating events. If provided, these tasks will be scheduled rather than creating new events.']);
}
public function name(): string
{
return 'scheduleDay';
}
public function description(): string
{
return 'Creates a structured day plan with events and breaks.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$date = $arguments['date'];
$events = ($arguments['events'] ?? []);
$workingHoursStart = ($arguments['workingHoursStart'] ?? '');
$workingHoursEnd = ($arguments['workingHoursEnd'] ?? '');
$taskIds = ($arguments['taskIds'] ?? []);
$dateObj = dtHelper()->parseUserDateTime($date);
$dateFrom = $dateObj->startOfDay();
$dateTo = $dateObj->endOfDay();
$existingEvents = $this->calendarService->getCalendar(session('userdata.id'), $dateFrom, $dateTo);
$existingTasks = $this->ticketService->getScheduledTasks($dateFrom, $dateTo, session('userdata.id'));
if ($workingHoursStart !== '') {
$timeparts = explode(':', $workingHoursStart);
$workStart = $dateObj->setTime((int) $timeparts[0], (int) ($timeparts[1] ?? 0));
} else {
$workStart = $dateObj->setTime(9, 0);
}
if ($workingHoursEnd !== '') {
$timeparts = explode(':', $workingHoursEnd);
$workEnd = $dateObj->setTime((int) $timeparts[0], (int) ($timeparts[1] ?? 0));
} else {
$workEnd = $dateObj->setTime(18, 0);
}
$availableSlots = $this->findAvailableTimeSlots($workStart, $workEnd, $existingEvents, $existingTasks);
if (! empty($taskIds)) {
$tasksToSchedule = [];
foreach ($taskIds as $taskId) {
$task = $this->ticketService->getTicket($taskId);
if ($task) {
$tasksToSchedule[] = [
'id' => $taskId,
'title' => $task->headline ?? 'Untitled Task',
'duration' => $events[array_search($taskId, array_column($events, 'taskId'))]['duration'] ?? 30,
'priority' => $events[array_search($taskId, array_column($events, 'taskId'))]['priority'] ?? 3,
];
}
}
$scheduledTasks = $this->scheduleItemsInSlots($availableSlots, $tasksToSchedule);
$successCount = 0;
$failureCount = 0;
$results = [];
foreach ($scheduledTasks as $task) {
$editFrom = $task['dateFrom'] instanceof CarbonImmutable ? $task['dateFrom']->toIso8601String() : (string) $task['dateFrom'];
$editTo = $task['dateTo'] instanceof CarbonImmutable ? $task['dateTo']->toIso8601String() : (string) $task['dateTo'];
if ($this->ticketService->patch($task['id'], ['editFrom' => $editFrom, 'editTo' => $editTo])) {
$successCount++;
$results[] = ['taskId' => $task['id'], 'status' => 'success'];
} else {
$failureCount++;
$results[] = ['taskId' => $task['id'], 'status' => 'error', 'message' => 'Failed to schedule task'];
}
}
return ToolResult::text("Day scheduling completed for {$date}.\n\nTask scheduling: Success: {$successCount}, Failed: {$failureCount}");
}
$scheduledEvents = $this->scheduleItemsInSlots($availableSlots, $events);
$successCount = 0;
$failureCount = 0;
foreach ($scheduledEvents as $eventData) {
$eventDateFrom = $eventData['dateFrom'] instanceof CarbonImmutable ? $eventData['dateFrom']->toIso8601String() : (string) $eventData['dateFrom'];
$eventDateTo = $eventData['dateTo'] instanceof CarbonImmutable ? $eventData['dateTo']->toIso8601String() : (string) $eventData['dateTo'];
$result = $this->calendarService->addEvent([
'description' => $eventData['title'] ?? $eventData['eventTitle'] ?? 'Untitled',
'dateFrom' => $eventDateFrom,
'dateTo' => $eventDateTo,
'allDay' => false,
'userId' => session('userdata.id'),
]);
if ($result) {
$successCount++;
} else {
$failureCount++;
}
}
return ToolResult::text("Day scheduling completed for {$date}.\n\nEvent creation: Success: {$successCount}, Failed: {$failureCount}");
}
/**
* Find available time slots in a day.
*
* @param CarbonImmutable $workStart Start of working hours
* @param CarbonImmutable $workEnd End of working hours
* @param array $existingEvents Existing calendar events
* @param array $existingTasks Existing scheduled tasks
* @return array Available time slots as [start, end] pairs
*/
private function findAvailableTimeSlots(CarbonImmutable $workStart, CarbonImmutable $workEnd, array $existingEvents, array $existingTasks): array
{
$busyTimes = [];
foreach ($existingEvents as $event) {
$busyTimes[] = [
'start' => dtHelper()->parseDbDateTime($event['dateFrom']),
'end' => dtHelper()->parseDbDateTime($event['dateTo']),
];
}
if (isset($existingTasks['totalTasks'])) {
foreach ($existingTasks['totalTasks'] as $task) {
if (! empty($task['editFrom']) && ! empty($task['editTo'])) {
$busyTimes[] = [
'start' => dtHelper()->parseDbDateTime($task['editFrom']),
'end' => dtHelper()->parseDbDateTime($task['editTo']),
];
}
}
}
usort($busyTimes, function ($a, $b) {
return $a['start']->getTimestamp() - $b['start']->getTimestamp();
});
$mergedBusyTimes = [];
foreach ($busyTimes as $busy) {
if (empty($mergedBusyTimes)) {
$mergedBusyTimes[] = $busy;
continue;
}
$lastBusy = &$mergedBusyTimes[count($mergedBusyTimes) - 1];
if ($busy['start'] <= $lastBusy['end']) {
if ($busy['end'] > $lastBusy['end']) {
$lastBusy['end'] = $busy['end'];
}
} else {
$mergedBusyTimes[] = $busy;
}
}
$availableSlots = [];
$currentTime = clone $workStart;
foreach ($mergedBusyTimes as $busy) {
if ($busy['end'] <= $workStart || $busy['start'] >= $workEnd) {
continue;
}
$busyStart = max($busy['start'], $workStart);
$busyEnd = min($busy['end'], $workEnd);
if ($currentTime < $busyStart) {
$availableSlots[] = [
'start' => clone $currentTime,
'end' => clone $busyStart,
];
}
$currentTime = clone $busyEnd;
}
if ($currentTime < $workEnd) {
$availableSlots[] = [
'start' => clone $currentTime,
'end' => clone $workEnd,
];
}
return $availableSlots;
}
/**
* Schedule items (events or tasks) in available time slots.
*
* @param array $availableSlots Available time slots
* @param array $items Items to schedule
* @return array Scheduled items with dateFrom and dateTo
*/
private function scheduleItemsInSlots(array $availableSlots, array $items): array
{
usort($items, function ($a, $b) {
$priorityA = $a['priority'] ?? 3;
$priorityB = $b['priority'] ?? 3;
if ($priorityA !== $priorityB) {
return $priorityB - $priorityA;
}
$durationA = $a['duration'] ?? 30;
$durationB = $b['duration'] ?? 30;
return $durationB - $durationA;
});
$scheduledItems = [];
foreach ($items as $item) {
$title = $item['title'] ?? $item['eventTitle'] ?? 'Untitled';
$durationMinutes = $item['duration'] ?? 30;
$durationSeconds = $durationMinutes * 60;
foreach ($availableSlots as $key => $slot) {
$slotDuration = $slot['end']->getTimestamp() - $slot['start']->getTimestamp();
if ($slotDuration >= $durationSeconds) {
$itemStart = $slot['start'];
$itemEnd = $itemStart->modify("+{$durationMinutes} minutes");
$scheduledItem = [
'title' => $title,
'dateFrom' => $itemStart,
'dateTo' => $itemEnd,
];
if (isset($item['id'])) {
$scheduledItem['id'] = $item['id'];
}
$scheduledItems[] = $scheduledItem;
$availableSlots[$key]['start'] = $itemEnd;
if ($availableSlots[$key]['end']->getTimestamp() - $availableSlots[$key]['start']->getTimestamp() < 900) {
unset($availableSlots[$key]);
}
break;
}
}
}
return $scheduledItems;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Leantime\Domain\Calendar\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Schedule a task on the calendar by setting editFrom and editTo fields.
*/
class ScheduleTaskOnCalendarTool extends Tool
{
public function __construct(
private Tickets $ticketService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('id')->description('ID of the task to schedule.')
->required()
->string('editFrom')->description('Date time string of when the task should start in user timezone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
->required()
->string('editTo')->description('Date time string of when the task should end in user timezone in ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
->required();
}
public function name(): string
{
return 'scheduleTaskOnCalendar';
}
public function description(): string
{
return 'Schedules a task by setting the editFrom and editTo fields.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$id = (int) ($arguments['id'] ?? 0);
$editFrom = $arguments['editFrom'];
$editTo = $arguments['editTo'];
if ($this->ticketService->patch($id, ['editFrom' => $editFrom, 'editTo' => $editTo])) {
return ToolResult::text('Task scheduled successfully.');
}
return ToolResult::error('Failed to schedule task.');
}
}