- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI, or a self-hosted Ollama instance) configured by base URL, optional API key, model, and an SSL-verification toggle. Foundation for the two AI features below and anything else that wants an LLM call in the future. - BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj wszystko ponownie" buttons plus `php artisan bookstack:tag-content` (--dry-run/--force/--limit=N) tag every book/chapter/page with matching helpdesk subcategory names, idempotent by default. - BookStack search refinement: "Przeszukuj" is now three independent checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare subcategory name, matching what auto-tagging writes. - AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles auto-assign/correct category+subcategory, rewrite an unclear subject, and set priority from content, once per ticket in the background; every change is logged in the ticket's history. Separately, an AI summary + suggested action for every ticket, shown to operators only, with an admin-editable prompt. - Operators can now reassign a ticket to any team, not just one they belong to. - The auto-refresh countdown badges (ticket view, operator queue) are now clickable — fetch immediately and reset the countdown. - All 7 "cyclical" intervals (3 browser refresh countdowns, the notification bell poll, and the 4 background scheduled commands) are now configurable from Admin > Konfiguracja instead of fixed in code. - Fixed: an operator viewing a ticket that's deleted or moved outside their team scope mid-session is now redirected to the operator queue instead of hitting an error. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
478 lines
18 KiB
PHP
478 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class BookStackClient
|
|
{
|
|
/**
|
|
* Two independent allow-lists, since which shelves make sense to
|
|
* surface differs by where suggestions show up: 'creation' gates the
|
|
* ticket-wizard suggestions (client/operator/guest), 'ticket_view'
|
|
* gates the separate sidebar shown to an operator on an existing ticket.
|
|
*/
|
|
public const CONTEXT_CREATION = 'creation';
|
|
|
|
public const CONTEXT_TICKET_VIEW = 'ticket_view';
|
|
|
|
protected const CONTEXT_SETTINGS_KEYS = [
|
|
self::CONTEXT_CREATION => 'bookstack_allowed_shelf_ids_creation',
|
|
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
|
];
|
|
|
|
/**
|
|
* Content types selectable via the admin's "Przeszukuj" checkboxes.
|
|
* Deliberately excludes 'bookshelf' — shelves are only ever a filter
|
|
* (dozwolone półki), never a suggestion result in their own right.
|
|
*/
|
|
public const SEARCH_TYPES = ['book', 'page', 'chapter'];
|
|
|
|
/**
|
|
* How the query text is matched, via the admin's "Szukaj po" option —
|
|
* 'name' restricts to the title ({in_name:...}), 'tags' matches a tag
|
|
* whose name equals the query (expected to hold the helpdesk
|
|
* category/subcategory name, e.g. a "Drukarki" tag on the relevant
|
|
* BookStack pages), 'both' runs both and merges the results (BookStack's
|
|
* query syntax ANDs filters together, so there's no single-query way to
|
|
* express "name OR tag").
|
|
*/
|
|
public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both'];
|
|
|
|
/**
|
|
* Content types the bulk-tagging command operates over — same set as
|
|
* SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since
|
|
* the two consts serve different features that happen to share a domain.
|
|
*/
|
|
public const CONTENT_TYPES = self::SEARCH_TYPES;
|
|
|
|
public function enabled(): bool
|
|
{
|
|
return Settings::bool('bookstack_enabled')
|
|
&& Settings::get('bookstack_base_url')
|
|
&& Settings::get('bookstack_token_id');
|
|
}
|
|
|
|
/**
|
|
* Suggested-article lookup, shared by the ticket-creation wizard and the
|
|
* operator ticket-view sidebar — returns [] whenever the integration is
|
|
* off/unconfigured, the query is empty, or no shelf has been allow-listed
|
|
* yet for the given $context (an empty allow-list means "search nothing",
|
|
* not "search everything" — an admin has to opt specific shelves in
|
|
* before any content is ever suggested, independently per context).
|
|
* Cached briefly since the same category/subcategory query repeats
|
|
* across every ticket created/viewed with that combination. Respects the
|
|
* admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
|
|
* BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
|
|
* 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key
|
|
* folds in the allowed-shelf list so changing it in Admin > Konfiguracja
|
|
* is reflected immediately, instead of possibly serving a pre-change
|
|
* result for up to 10 minutes.
|
|
*
|
|
* $tagQuery is the text matched by the 'tags' variant, separate from
|
|
* $query (matched by the 'name' variant) — callers pass the bare
|
|
* subcategory name here (what bookstack:tag-content actually writes as
|
|
* a tag), while $query stays the fuller "Category Subcategory" text
|
|
* that's more useful for a plain title/body search. Defaults to $query
|
|
* so existing call sites that don't pass it keep working.
|
|
*
|
|
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
|
*/
|
|
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array
|
|
{
|
|
$query = trim($query);
|
|
$tagQuery = trim($tagQuery ?? $query);
|
|
$allowedShelfIds = $this->allowedShelfIds($context);
|
|
|
|
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
|
return [];
|
|
}
|
|
|
|
$searchBy = $this->searchBy();
|
|
$bookstackQueries = $searchBy === 'both'
|
|
? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')]
|
|
: [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)];
|
|
|
|
$cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
|
|
|
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) {
|
|
try {
|
|
$shelfMap = $this->shelfBookMap();
|
|
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
|
$bookShelfNames = $this->bookShelfNames($shelfMap);
|
|
|
|
$items = collect();
|
|
|
|
foreach ($bookstackQueries as $bookstackQuery) {
|
|
$response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]);
|
|
|
|
if ($response->successful()) {
|
|
$items = $items->concat($response->json('data', []));
|
|
}
|
|
}
|
|
|
|
return $items
|
|
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
|
$type = $item['type'] ?? null;
|
|
|
|
if ($type === 'bookshelf') {
|
|
return in_array($item['id'] ?? null, $allowedShelfIds, true);
|
|
}
|
|
|
|
if ($type === 'book') {
|
|
return in_array($item['id'] ?? null, $allowedBookIds, true);
|
|
}
|
|
|
|
// pages/chapters carry the id of the book they live in
|
|
return isset($item['book_id']) && in_array($item['book_id'], $allowedBookIds, true);
|
|
})
|
|
->map(function (array $item) use ($bookShelfNames) {
|
|
$bookId = $item['book_id'] ?? (($item['type'] ?? null) === 'book' ? $item['id'] : null);
|
|
|
|
return [
|
|
'name' => $item['name'] ?? '',
|
|
'url' => $item['url'] ?? null,
|
|
'type' => $item['type'] ?? 'page',
|
|
'book' => $item['book']['name'] ?? null,
|
|
'shelf' => $bookId ? ($bookShelfNames[$bookId] ?? null) : null,
|
|
];
|
|
})
|
|
->filter(fn (array $item) => $item['name'] !== '')
|
|
->unique(fn (array $item) => $item['url'] ?? $item['name'])
|
|
->take($limit)
|
|
->values()
|
|
->all();
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds one BookStack search-syntax query string for $query, restricted
|
|
* to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the
|
|
* configured content types (`{type:a|b}`, omitted if all types are
|
|
* allowed since that's equivalent to no filter).
|
|
*/
|
|
protected function buildQuery(string $query, string $by): string
|
|
{
|
|
$parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"];
|
|
|
|
$types = $this->searchTypes();
|
|
|
|
if (array_diff(self::SEARCH_TYPES, $types)) {
|
|
$parts[] = '{type:'.implode('|', $types).'}';
|
|
}
|
|
|
|
return implode(' ', $parts);
|
|
}
|
|
|
|
/**
|
|
* @return string[] non-empty subset of SEARCH_TYPES
|
|
*/
|
|
protected function searchTypes(): array
|
|
{
|
|
return self::normalizeSearchTypes(Settings::get('bookstack_search_types', ''));
|
|
}
|
|
|
|
/**
|
|
* Normalizes bookstack_search_types storage into a non-empty subset of
|
|
* SEARCH_TYPES — shared with Admin\Panel so the checkbox UI and the
|
|
* actual search agree on the same format. Also understands the legacy
|
|
* single-value 'both'/'page'/'book' storage from before the setting
|
|
* became a checkbox list, so existing configuration keeps working.
|
|
*
|
|
* @param string[]|string $raw
|
|
* @return string[]
|
|
*/
|
|
public static function normalizeSearchTypes(array|string $raw): array
|
|
{
|
|
$legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']];
|
|
|
|
if (is_string($raw) && isset($legacy[$raw])) {
|
|
return $legacy[$raw];
|
|
}
|
|
|
|
$types = collect(is_array($raw) ? $raw : explode(',', $raw))
|
|
->map(fn ($v) => trim((string) $v))
|
|
->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true))
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
return $types ?: self::SEARCH_TYPES;
|
|
}
|
|
|
|
protected function searchBy(): string
|
|
{
|
|
$raw = Settings::get('bookstack_search_by', 'both');
|
|
|
|
return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both';
|
|
}
|
|
|
|
/**
|
|
* Drops the cached shelf list and shelf>book membership map — used by
|
|
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
|
|
* removed in BookStack shows up immediately instead of after up to 30
|
|
* minutes. Doesn't touch the per-query search-result cache (10 min TTL,
|
|
* self-invalidates on the next config save via the allow-list in its key).
|
|
*/
|
|
public function clearShelfCache(): void
|
|
{
|
|
Cache::forget('bookstack:shelves');
|
|
Cache::forget('bookstack:shelf-book-map');
|
|
}
|
|
|
|
/**
|
|
* Bookshelves for the admin's two "dozwolone półki" checklists — cached
|
|
* since shelf structure changes rarely and this is fetched on every
|
|
* Admin > Konfiguracja page load while the BookStack section is expanded.
|
|
*
|
|
* @return array<int, array{id: int, name: string}>
|
|
*/
|
|
public function shelves(): array
|
|
{
|
|
if (! $this->enabled()) {
|
|
return [];
|
|
}
|
|
|
|
return Cache::remember('bookstack:shelves', now()->addMinutes(30), function () {
|
|
try {
|
|
$response = $this->client()->get('/api/shelves', ['count' => 200]);
|
|
|
|
if (! $response->successful()) {
|
|
return [];
|
|
}
|
|
|
|
return collect($response->json('data', []))
|
|
->map(fn (array $s) => ['id' => $s['id'], 'name' => $s['name']])
|
|
->values()
|
|
->all();
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Every book/chapter/page of $type across the whole BookStack instance —
|
|
* NOT filtered by the allowed-shelf settings, unlike search(). Those only
|
|
* gate which suggestions are ever shown to a client/operator; the bulk
|
|
* tagger is meant to cover every piece of content regardless. Paginates
|
|
* through BookStack's count/offset list endpoints (count capped at 500,
|
|
* the API's own per-page maximum). Uncached — this is a one-shot batch
|
|
* read, not a repeated request-path lookup.
|
|
*
|
|
* @return array<int, array{id: int, name: string}>
|
|
*/
|
|
public function listAll(string $type): array
|
|
{
|
|
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
|
return [];
|
|
}
|
|
|
|
$items = [];
|
|
$offset = 0;
|
|
|
|
try {
|
|
do {
|
|
$response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]);
|
|
|
|
if (! $response->successful()) {
|
|
break;
|
|
}
|
|
|
|
$page = $response->json('data', []);
|
|
$items = [...$items, ...$page];
|
|
$offset += 500;
|
|
$total = $response->json('total', 0);
|
|
} while (count($page) > 0 && count($items) < $total);
|
|
} catch (\Throwable) {
|
|
return $items;
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Full detail for a single book/chapter/page — its current tags (needed
|
|
* to merge rather than clobber when the tagger writes new ones) and the
|
|
* text used to classify it (a page's markdown source, or a book/
|
|
* chapter's description). Null if $type is invalid or the item can't be
|
|
* fetched.
|
|
*
|
|
* @return array{id: int, name: string, tags: array<int, array{name: string, value: string}>, content: string}|null
|
|
*/
|
|
public function detail(string $type, int $id): ?array
|
|
{
|
|
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$response = $this->client()->get("/api/{$type}s/{$id}");
|
|
|
|
if (! $response->successful()) {
|
|
return null;
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
return [
|
|
'id' => $data['id'],
|
|
'name' => $data['name'] ?? '',
|
|
'tags' => $data['tags'] ?? [],
|
|
'content' => $data['markdown'] ?? $data['description'] ?? '',
|
|
];
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Overwrites just the tags field on a book/chapter/page — BookStack
|
|
* treats every field on its update endpoints as optional, so this never
|
|
* touches the item's name/content/other attributes. Callers are
|
|
* responsible for merging in any tags they want to keep (this replaces
|
|
* the whole array, it doesn't append).
|
|
*
|
|
* @param array<int, array{name: string, value: string}> $tags
|
|
*/
|
|
public function updateTags(string $type, int $id, array $tags): bool
|
|
{
|
|
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful();
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return int[]
|
|
*/
|
|
protected function allowedShelfIds(string $context): array
|
|
{
|
|
$key = self::CONTEXT_SETTINGS_KEYS[$context] ?? self::CONTEXT_SETTINGS_KEYS[self::CONTEXT_CREATION];
|
|
$raw = Settings::get($key, '');
|
|
|
|
return collect(explode(',', (string) $raw))
|
|
->map(fn ($v) => (int) trim($v))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Every shelf's book membership, fetched once and cached — the single
|
|
* source both shelf-exclusion and the "Shelf > Book" breadcrumb are
|
|
* derived from, so there's only one place that talks to /api/shelves/{id}.
|
|
*
|
|
* @return array<int, array{name: string, bookIds: int[]}>
|
|
*/
|
|
protected function shelfBookMap(): array
|
|
{
|
|
return Cache::remember('bookstack:shelf-book-map', now()->addMinutes(30), function () {
|
|
$map = [];
|
|
|
|
foreach ($this->shelves() as $shelf) {
|
|
$bookIds = [];
|
|
|
|
try {
|
|
$response = $this->client()->get("/api/shelves/{$shelf['id']}");
|
|
|
|
if ($response->successful()) {
|
|
$bookIds = collect($response->json('books', []))->pluck('id')->all();
|
|
}
|
|
} catch (\Throwable) {
|
|
// Skip an unreachable/deleted shelf rather than failing the whole search.
|
|
}
|
|
|
|
$map[$shelf['id']] = ['name' => $shelf['name'], 'bookIds' => $bookIds];
|
|
}
|
|
|
|
return $map;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
|
|
* @param int[] $shelfIds
|
|
* @return int[]
|
|
*/
|
|
protected function bookIdsForShelves(array $shelfMap, array $shelfIds): array
|
|
{
|
|
$ids = [];
|
|
|
|
foreach ($shelfIds as $shelfId) {
|
|
$ids = [...$ids, ...($shelfMap[$shelfId]['bookIds'] ?? [])];
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* Book id -> owning shelf name, for the suggestion list's breadcrumb. A
|
|
* book that sits on more than one shelf just shows whichever is last in
|
|
* the map — there's no single "correct" shelf to prefer in that case.
|
|
*
|
|
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
|
|
* @return array<int, string>
|
|
*/
|
|
protected function bookShelfNames(array $shelfMap): array
|
|
{
|
|
$names = [];
|
|
|
|
foreach ($shelfMap as $shelf) {
|
|
foreach ($shelf['bookIds'] as $bookId) {
|
|
$names[$bookId] = $shelf['name'];
|
|
}
|
|
}
|
|
|
|
return $names;
|
|
}
|
|
|
|
/**
|
|
* Tests unsaved admin-form values directly, rather than whatever's
|
|
* currently stored — mirrors testLdapConnection()/testMailConnection()
|
|
* in Admin\Panel. Returns a message alongside the ok/error flag (BookStack's
|
|
* API returns a specific, useful reason — e.g. missing "Access System API"
|
|
* role permission — that a plain boolean would hide from the admin.
|
|
*
|
|
* @return array{ok: bool, message: ?string}
|
|
*/
|
|
public function testConnection(string $baseUrl, string $tokenId, string $tokenSecret, bool $verifySsl = true): array
|
|
{
|
|
try {
|
|
$response = Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
|
|
->withOptions(['verify' => $verifySsl])
|
|
->timeout(6)
|
|
->get(rtrim($baseUrl, '/').'/api/search', ['query' => 'test', 'count' => 1]);
|
|
|
|
if ($response->successful()) {
|
|
return ['ok' => true, 'message' => null];
|
|
}
|
|
|
|
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
protected function client()
|
|
{
|
|
$tokenId = Settings::get('bookstack_token_id');
|
|
$tokenSecret = Settings::get('bookstack_token_secret');
|
|
|
|
return Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
|
|
->withOptions(['verify' => Settings::bool('bookstack_verify_ssl')])
|
|
->timeout(4)
|
|
->baseUrl(rtrim(Settings::get('bookstack_base_url'), '/'));
|
|
}
|
|
}
|