Files
servicedesk/src/app/Services/BookStackClient.php
Kacper 313e01ad24 v1.2.1
- 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>
2026-07-24 13:38:39 +02:00

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'), '/'));
}
}