use App\AI\Plugins\EmailPlugin;
use App\AI\Plugins\EmailPlugin;
use App\AI\Plugins\MemoryPlugin;
use App\AI\Plugins\MemoryPlugin;
use App\AI\Plugins\CalendarPlugin;
use App\AI\Plugins\CalendarPlugin;
use App\AI\Plugins\SlackPlugin;
class PluginList
class PluginList
{
{
new EmailPlugin(),
new EmailPlugin(),
new MemoryPlugin(),
new MemoryPlugin(),
new CalendarPlugin(),
new CalendarPlugin(),
new SlackPlugin(),
];
];
}
}
<?php
namespace App\AI\Plugins;
use App\AI\Contracts\ToolResult;
use App\AI\Contracts\ApiConfig;
use App\AI\Contracts\PluginInterface;
use App\AI\Services\SlackService;
class SlackPlugin extends PluginInterface
{
public function __construct()
{
parent::__construct();
}
protected function getApiConfig(): ApiConfig
{
return new ApiConfig();
}
public function getTools()
{
return [
[
'name' => 'list_slack_channels',
'description' => 'List Slack channels, DMs, and group conversations in the workspace',
'parameters' => [
'type' => 'object',
'properties' => [
'types' => [
'type' => 'string',
'description' => 'Comma-separated channel types to include: public_channel, private_channel, mpim (group DMs), im (DMs). Default: all types.',
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum number of channels to return (default: 50, max: 200)',
],
'exclude_archived' => [
'type' => 'boolean',
'description' => 'Exclude archived channels (default: true)',
],
],
'required' => [],
],
],
[
'name' => 'list_slack_users',
'description' => 'List workspace users with their names, emails, and IDs. Use this to find user IDs for sending DMs.',
'parameters' => [
'type' => 'object',
'properties' => [
'limit' => [
'type' => 'integer',
'description' => 'Maximum number of users to return (default: 50, max: 200)',
],
],
'required' => [],
],
],
[
'name' => 'search_slack_messages',
'description' => 'Full-text search across all Slack messages. Supports Slack search modifiers like from:user, in:channel, has:link, before:date, after:date.',
'parameters' => [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Search query. Supports Slack modifiers: from:username, in:channel, has:link, has:reaction, before:YYYY-MM-DD, after:YYYY-MM-DD',
],
'count' => [
'type' => 'integer',
'description' => 'Number of results to return (default: 20, max: 100)',
],
'sort' => [
'type' => 'string',
'description' => 'Sort order: "score" (relevance) or "timestamp" (newest first, default)',
'enum' => ['score', 'timestamp'],
],
],
'required' => ['query'],
],
],
[
'name' => 'get_slack_conversation_history',
'description' => 'Get recent messages from a Slack channel. Provide channel_name (e.g. "backend") to read a channel directly - no need to list channels first. Bot/app messages (Sentry, GitHub, etc.) are excluded by default.',
'parameters' => [
'type' => 'object',
'properties' => [
'channel_name' => [
'type' => 'string',
'description' => 'Channel name without # (e.g. "backend", "general"). Preferred over channel_id.',
],
'channel_id' => [
'type' => 'string',
'description' => 'The Slack channel ID. Use channel_name instead when possible.',
],
'limit' => [
'type' => 'integer',
'description' => 'Number of human messages to retrieve (default: 50, max: 100)',
],
'oldest' => [
'type' => 'string',
'description' => 'Only show messages after this Unix timestamp',
],
'latest' => [
'type' => 'string',
'description' => 'Only show messages before this Unix timestamp',
],
'exclude_bots' => [
'type' => 'boolean',
'description' => 'Exclude bot/app messages like Sentry, GitHub, etc. (default: true)',
],
],
'required' => [],
],
],
[
'name' => 'send_slack_message',
'description' => 'Send a message to a Slack channel or DM. Provide either a channel name/ID or a user_id to send a DM.',
'parameters' => [
'type' => 'object',
'properties' => [
'channel' => [
'type' => 'string',
'description' => 'Channel name (e.g. "general") or channel ID. Use this for posting to channels.',
],
'user_id' => [
'type' => 'string',
'description' => 'User ID for sending a direct message. Opens a DM conversation automatically.',
],
'text' => [
'type' => 'string',
'description' => 'Message text to send. Supports Slack markdown (*bold*, _italic_, `code`, ```code block```, etc.)',
],
'thread_ts' => [
'type' => 'string',
'description' => 'Timestamp of a parent message to reply in a thread',
],
],
'required' => ['text'],
],
],
[
'name' => 'search_slack_files',
'description' => 'Search for files and images shared in Slack. Filter by type, channel, and date range.',
'parameters' => [
'type' => 'object',
'properties' => [
'channel' => [
'type' => 'string',
'description' => 'Channel ID to filter files from a specific channel',
],
'types' => [
'type' => 'string',
'description' => 'Comma-separated file types: images, snippets, pdfs, docs, zips, all (default: all)',
],
'query' => [
'type' => 'string',
'description' => 'Search query to filter files by name or content',
],
'ts_from' => [
'type' => 'string',
'description' => 'Start date filter (YYYY-MM-DD)',
],
'ts_to' => [
'type' => 'string',
'description' => 'End date filter (YYYY-MM-DD)',
],
'count' => [
'type' => 'integer',
'description' => 'Number of results to return (default: 20, max: 100)',
],
],
'required' => [],
],
]
];
}
public function executeTool(string $toolName, array $parameters)
{
return match ($toolName) {
'list_slack_channels' => SlackService::listChannels($parameters),
'list_slack_users' => SlackService::listUsers($parameters),
'search_slack_messages' => SlackService::searchMessages($parameters),
'get_slack_conversation_history' => SlackService::getConversationHistory($parameters),
'send_slack_message' => SlackService::sendMessage($parameters),
'search_slack_files' => SlackService::searchFiles($parameters),
default => ToolResult::failure("Tool '{$toolName}' not found in SlackPlugin"),
};
}
}
<?php
namespace App\AI\Services;
use App\AI\Contracts\ToolResult;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class SlackService
{
private const BASE_URL = 'https://slack.com/api';
private static function api(string $method, array $params = []): array
{
return Http::withToken(config('services.slack.user_oauth_token', ''))
->asForm()
->post(self::BASE_URL . '/' . $method, $params)
->json();
}
private static function openDmChannel(string $userId): ?string
{
$response = self::api('conversations.open', ['users' => $userId]);
return $response['channel']['id'] ?? null;
}
private static function resolveChannelByName(string $channelName): ?string
{
$channelName = ltrim($channelName, '#');
// Check the cached channel map first
$channelMap = self::getChannelMap();
if (isset($channelMap[$channelName])) {
return $channelMap[$channelName];
}
return null;
}
private static function getChannelMap(): array
{
return Cache::remember('slack_channel_map', 3600, function () {
$channelMap = [];
$cursor = null;
do {
$params = [
'types' => 'public_channel,private_channel',
'limit' => 200,
'exclude_archived' => 'true',
];
if ($cursor) {
$params['cursor'] = $cursor;
}
$response = self::api('conversations.list', $params);
if (!$response['ok']) {
break;
}
foreach ($response['channels'] ?? [] as $channel) {
$channelMap[$channel['name']] = $channel['id'];
}
$cursor = $response['response_metadata']['next_cursor'] ?? '';
} while (!empty($cursor));
return $channelMap;
});
}
private static function getUserMap(): array
{
return Cache::remember('slack_user_map', 86400, function () {
$userMap = [];
$response = self::api('users.list', ['limit' => 200]);
if ($response['ok'] ?? false) {
foreach ($response['members'] ?? [] as $member) {
$displayName = $member['profile']['display_name'] ?? '';
$realName = $member['real_name'] ?? '';
$username = $member['name'] ?? '';
$userMap[$member['id']] = $displayName ?: $realName ?: $username ?: $member['id'];
}
}
return $userMap;
});
}
private static function resolveUserName(string $userId): string
{
$userMap = self::getUserMap();
return $userMap[$userId] ?? $userId;
}
private static function formatTimestamp(?string $ts): ?string
{
if (!$ts) {
return null;
}
return date('Y-m-d H:i:s', (int) floatval($ts));
}
private static function compact(array $data): array
{
return array_filter($data, fn($v) => $v !== null && $v !== '' && $v !== []);
}
private static function formatMessage(array $message): array
{
$userId = $message['user'] ?? $message['bot_id'] ?? null;
$text = $message['text'] ?? '';
// Cap message text at 500 chars to prevent context overflow
if (strlen($text) > 500) {
$text = substr($text, 0, 500) . '...';
}
$formatted = [
'user' => $userId ? self::resolveUserName($userId) : null,
'text' => $text,
'time' => self::formatTimestamp($message['ts'] ?? null),
'ts' => $message['ts'] ?? null,
];
// Only include optional fields when present
if (!empty($message['thread_ts'])) {
$formatted['thread_ts'] = $message['thread_ts'];
}
if (!empty($message['reply_count'])) {
$formatted['reply_count'] = $message['reply_count'];
}
if (!empty($message['reactions'])) {
$formatted['reactions'] = array_map(fn($r) => $r['name'] . ':' . $r['count'], $message['reactions']);
}
if (!empty($message['files'])) {
$formatted['files'] = array_map(fn($f) => self::compact([
'id' => $f['id'],
'name' => $f['name'] ?? null,
'filetype' => $f['filetype'] ?? null,
]), $message['files']);
}
return $formatted;
}
private static function formatChannel(array $channel): array
{
// Determine type as a single string instead of multiple booleans
$type = 'channel';
if ($channel['is_im'] ?? false) $type = 'dm';
elseif ($channel['is_mpim'] ?? false) $type = 'group_dm';
elseif ($channel['is_private'] ?? false) $type = 'private';
$formatted = [
'id' => $channel['id'],
'name' => $channel['name'] ?? null,
'type' => $type,
'members' => $channel['num_members'] ?? null,
'topic' => $channel['topic']['value'] ?? null,
];
if ($type === 'dm') {
$userId = $channel['user'] ?? null;
$formatted['user'] = $userId ? self::resolveUserName($userId) : null;
$formatted['user_id'] = $userId;
}
return self::compact($formatted);
}
public static function listChannels(array $params): ToolResult
{
$types = $params['types'] ?? 'public_channel,private_channel,mpim,im';
$limit = min($params['limit'] ?? 50, 200);
$excludeArchived = $params['exclude_archived'] ?? true;
$response = self::api('conversations.list', [
'types' => $types,
'limit' => $limit,
'exclude_archived' => $excludeArchived ? 'true' : 'false',
]);
$rawChannels = $response['channels'] ?? [];
$channels = array_map([self::class, 'formatChannel'], $rawChannels);
// Separate DMs from other channels to prevent context flooding
$dms = array_filter($channels, fn($c) => ($c['type'] ?? '') === 'dm');
$nonDms = array_filter($channels, fn($c) => ($c['type'] ?? '') !== 'dm');
// Limit DMs to 10 most recent to prevent AI from reading all of them
$dmCount = count($dms);
if ($dmCount > 10) {
$dms = array_slice(array_values($dms), 0, 10);
}
$result = array_values(array_merge($nonDms, $dms));
$message = 'Found ' . count($nonDms) . ' channel(s)';
if ($dmCount > 0) {
$message .= ' and ' . $dmCount . ' DM(s)';
if ($dmCount > 10) {
$message .= ' (showing 10 most recent DMs - use search_slack_messages with "is:dm" to search all DMs)';
}
}
return ToolResult::success([
'message' => $message,
'channels' => $result,
]);
}
public static function listUsers(array $params): ToolResult
{
$limit = min($params['limit'] ?? 50, 200);
$response = self::api('users.list', [
'limit' => $limit,
]);
$users = [];
foreach ($response['members'] ?? [] as $member) {
if ($member['deleted'] ?? false) {
continue;
}
$displayName = $member['profile']['display_name'] ?? '';
$realName = $member['real_name'] ?? '';
$username = $member['name'] ?? '';
$users[] = self::compact([
'id' => $member['id'],
'name' => $displayName ?: $realName ?: $username ?: null,
'real_name' => $realName ?: null,
'email' => $member['profile']['email'] ?? null,
'is_bot' => ($member['is_bot'] ?? false) ? true : null,
'timezone' => $member['tz'] ?? null,
]);
}
return ToolResult::success([
'message' => 'Found ' . count($users) . ' user(s)',
'users' => $users,
]);
}
public static function searchMessages(array $params): ToolResult
{
if (empty($params['query'])) {
return ToolResult::failure('query is required for searching messages');
}
$count = min($params['count'] ?? 20, 100);
$sort = $params['sort'] ?? 'timestamp';
$response = self::api('search.messages', [
'query' => $params['query'],
'count' => $count,
'sort' => $sort,
'sort_dir' => $sort === 'timestamp' ? 'desc' : 'desc',
]);
$matches = $response['messages']['matches'] ?? [];
$total = $response['messages']['total'] ?? 0;
$formattedMessages = array_map(function ($match) {
$userId = $match['user'] ?? null;
$text = $match['text'] ?? '';
if (strlen($text) > 500) {
$text = substr($text, 0, 500) . '...';
}
return self::compact([
'text' => $text,
'user' => $userId ? self::resolveUserName($userId) : ($match['username'] ?? null),
'channel' => $match['channel']['name'] ?? null,
'time' => self::formatTimestamp($match['ts'] ?? null),
'ts' => $match['ts'] ?? null,
]);
}, $matches);
return ToolResult::success([
'message' => "Found {$total} message(s) matching \"{$params['query']}\" (showing " . count($formattedMessages) . ")",
'total' => $total,
'messages' => $formattedMessages,
]);
}
public static function getConversationHistory(array $params): ToolResult
{
// Resolve channel: accept channel_name OR channel_id
$channelId = $params['channel_id'] ?? null;
if (empty($channelId) && !empty($params['channel_name'])) {
$channelId = self::resolveChannelByName($params['channel_name']);
}
$excludeBots = $params['exclude_bots'] ?? true;
// When filtering bots, fetch extra messages to compensate for filtered ones
$requestedLimit = min($params['limit'] ?? 50, 100);
$fetchLimit = $excludeBots ? min($requestedLimit * 3, 200) : $requestedLimit;
$apiParams = [
'channel' => $channelId,
'limit' => $fetchLimit,
];
if (!empty($params['oldest'])) {
$apiParams['oldest'] = $params['oldest'];
}
if (!empty($params['latest'])) {
$apiParams['latest'] = $params['latest'];
}
$response = self::api('conversations.history', $apiParams);
$rawMessages = $response['messages'] ?? [];
// Filter out bot messages if requested
if ($excludeBots) {
$rawMessages = array_filter($rawMessages, function ($msg) {
// Exclude messages from bots and apps
if (!empty($msg['bot_id']) || !empty($msg['bot_profile'])) {
return false;
}
if (($msg['subtype'] ?? '') === 'bot_message') {
return false;
}
return true;
});
$rawMessages = array_values($rawMessages);
}
// Apply the requested limit after filtering
$rawMessages = array_slice($rawMessages, 0, $requestedLimit);
$messages = array_map([self::class, 'formatMessage'], $rawMessages);
$channelName = $params['channel_name'] ?? null;
$label = $channelName ? "#{$channelName}" : $channelId;
return ToolResult::success([
'message' => "Retrieved " . count($messages) . " message(s) from {$label}" . ($excludeBots ? ' (bot messages excluded)' : ''),
'channel' => $label,
'messages' => $messages,
'has_more' => $response['has_more'] ?? false,
]);
}
public static function sendMessage(array $params): ToolResult
{
if (empty($params['text'])) {
return ToolResult::failure('text is required');
}
$channelId = null;
// If user_id is provided, open a DM channel first
if (!empty($params['user_id'])) {
$channelId = self::openDmChannel($params['user_id']);
} else (!empty($params['channel'])) {
$channel = $params['channel'];
// If it looks like a channel name (not an ID), resolve it
if (!str_starts_with($channel, 'C') && !str_starts_with($channel, 'D') && !str_starts_with($channel, 'G')) {
$resolved = self::resolveChannelByName($channel);
$channelId = $resolved;
} else {
$channelId = $channel;
}
}
$apiParams = [
'channel' => $channelId,
'text' => $params['text'],
];
if (!empty($params['thread_ts'])) {
$apiParams['thread_ts'] = $params['thread_ts'];
}
$response = self::api('chat.postMessage', $apiParams);
return ToolResult::success([
'message' => 'Message sent successfully',
'channel' => $response['channel'] ?? $channelId,
'ts' => $response['ts'] ?? null,
]);
}
public static function searchFiles(array $params): ToolResult
{
$count = min($params['count'] ?? 20, 100);
$apiParams = [
'count' => $count,
];
if (!empty($params['channel'])) {
$apiParams['channel'] = $params['channel'];
}
if (!empty($params['types'])) {
$apiParams['types'] = $params['types'];
}
if (!empty($params['query'])) {
// files.list doesn't have a query param, so we use search.files instead
$apiParams['query'] = $params['query'];
if (!empty($params['ts_from'])) {
$apiParams['query'] .= ' after:' . $params['ts_from'];
}
if (!empty($params['ts_to'])) {
$apiParams['query'] .= ' before:' . $params['ts_to'];
}
$response = self::api('search.files', $apiParams);
$files = $response['files']['matches'] ?? [];
$total = $response['files']['total'] ?? 0;
} else {
// Use files.list for browsing without search query
if (!empty($params['ts_from'])) {
$apiParams['ts_from'] = strtotime($params['ts_from']);
}
if (!empty($params['ts_to'])) {
$apiParams['ts_to'] = strtotime($params['ts_to']);
}
$response = self::api('files.list', $apiParams);
$files = $response['files'] ?? [];
$total = count($files);
}
$formattedFiles = array_map(function ($file) {
$userId = $file['user'] ?? null;
return self::compact([
'id' => $file['id'],
'name' => $file['name'] ?? null,
'filetype' => $file['filetype'] ?? null,
'size' => $file['size'] ?? null,
'user' => $userId ? self::resolveUserName($userId) : null,
'created' => isset($file['created']) ? date('Y-m-d H:i:s', $file['created']) : null,
]);
}, $files);
return ToolResult::success([
'message' => "Found {$total} file(s)" . (!empty($params['query']) ? " matching \"{$params['query']}\"" : '') . " (showing " . count($formattedFiles) . ")",
'total' => $total,
'files' => $formattedFiles,
]);
}
}
Use memory tools proactively and intelligently. You are not just a chatbot - you are a comprehensive personal knowledge system.
Use memory tools proactively and intelligently. You are not just a chatbot - you are a comprehensive personal knowledge system.
===== SLACK WORKSPACE ACCESS =====
You have full access to the user\'s Slack workspace. You can:
- List channels, DMs, and group conversations
- List workspace users (to find user IDs)
- Search messages across the entire workspace (full-text search with modifiers)
- Read conversation history from any channel or DM
- Send messages to channels or DMs
- Search for and retrieve files/images shared in Slack
**SLACK TOOL USAGE GUIDELINES:**
1. **SPEED-FIRST: USE channel_name DIRECTLY (CRITICAL)**:
When user asks about a channel (e.g. "summarize #backend"), call get_slack_conversation_history with channel_name directly:
- "What happened in #backend today?" → get_slack_conversation_history(channel_name: "backend", oldest: <today_start_unix>)
- "Summarize #frontend" → get_slack_conversation_history(channel_name: "frontend")
- **DO NOT** call list_slack_channels first. The channel name is resolved automatically.
- Bot/app messages (Sentry, GitHub, deploy bots, etc.) are filtered out automatically.
2. **MINIMIZE API CALLS (CRITICAL)**: Keep Slack tool calls to a minimum (1-3 per query). For channel summaries, a single get_slack_conversation_history call is usually sufficient.
3. **DMs - USE SEARCH, NOT CHANNEL READING (CRITICAL)**: When checking DMs or direct messages:
- NEVER call get_slack_conversation_history on multiple DM channels. This floods the context.
- Instead, use search_slack_messages with "is:dm" modifier: search_slack_messages(query: "is:dm on:today")
- To check DMs from a specific person: search_slack_messages(query: "is:dm from:username on:today")
4. **Searching messages**: Use search_slack_messages with Slack modifiers for precise results:
- "from:username" - messages from a specific user
- "in:channel" - messages in a specific channel
- "is:dm" - only direct messages
- "has:link" - messages containing links
- "before:YYYY-MM-DD" / "after:YYYY-MM-DD" - date filters
- "on:YYYY-MM-DD" or "on:today" - messages on a specific date
- Combine modifiers: "from:john in:general after:2026-01-01"
5. **Work hours / Check-ins / Sign-offs**: To find people\'s work hours:
- Read the relevant team channel with get_slack_conversation_history(channel_name: "backend", oldest: <today_start_unix>)
- Look for greeting messages (sign-on) and farewell messages (sign-off)
- Calculate hours worked from first to last message per person
6. **Sending messages**: Use send_slack_message. For DMs, provide user_id. For channels, provide the channel name or ID.
- Always confirm with the user before sending messages.
8. **Cross-referencing memory and Slack**: Combine memory and Slack for richer answers:
- When user asks about a colleague → check memory for their details, then search Slack for their messages
- When user asks about team activity → read the relevant channel directly by name
9. **Storing Slack workspace knowledge**: When the user tells you about workspace organization, store this as notes in memory.
10. **Slack user ↔ entity mapping**: When you encounter Slack users, map them to memory entities with their slack_user_id attribute.
===== GOOGLE CALENDAR MANAGEMENT =====
===== GOOGLE CALENDAR MANAGEMENT =====
You have full access to the user\'s Google Calendar. You can:
You have full access to the user\'s Google Calendar. You can:
- List upcoming events (filter by time range, search query)
- List upcoming events (filter by time range, search query)
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'user_oauth_token' => env('SLACK_USER_OAUTH_TOKEN'),
'default_channel' => env('SLACK_DEFAULT_CHANNEL'),
],
],
'google' => [
'google' => [