All checks were successful
release-tag / release-image (push) Successful in 1m36s
934 lines
28 KiB
Go
934 lines
28 KiB
Go
package glpi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/example/glpi-ai-agent/internal/model"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL, version, clientID, clientSecret, username, password string
|
|
http *http.Client
|
|
mu sync.Mutex
|
|
token string
|
|
tokenExpiry time.Time
|
|
}
|
|
|
|
func New(baseURL, version, clientID, clientSecret, username, password string, timeout time.Duration) *Client {
|
|
return &Client{baseURL: strings.TrimRight(baseURL, "/"), version: version, clientID: clientID, clientSecret: clientSecret, username: username, password: password, http: &http.Client{Timeout: timeout}}
|
|
}
|
|
|
|
func (c *Client) APIBase() string { return c.baseURL + "/api.php/" + c.version }
|
|
func (c *Client) authenticate(ctx context.Context, force bool) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if !force && c.token != "" && time.Until(c.tokenExpiry) > 60*time.Second {
|
|
return c.token, nil
|
|
}
|
|
form := url.Values{"grant_type": {"password"}, "client_id": {c.clientID}, "client_secret": {c.clientSecret}, "username": {c.username}, "password": {c.password}, "scope": {"api"}}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api.php/token", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode/100 != 2 {
|
|
return "", fmt.Errorf("GLPI OAuth failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
var tr struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err := json.Unmarshal(body, &tr); err != nil {
|
|
return "", err
|
|
}
|
|
if tr.AccessToken == "" {
|
|
return "", errors.New("GLPI OAuth response contains no access_token")
|
|
}
|
|
if tr.ExpiresIn <= 0 {
|
|
tr.ExpiresIn = 3600
|
|
}
|
|
c.token = tr.AccessToken
|
|
c.tokenExpiry = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
|
|
return c.token, nil
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) ([]byte, http.Header, error) {
|
|
var payload []byte
|
|
var err error
|
|
if body != nil {
|
|
payload, err = json.Marshal(body)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
tok, err := c.authenticate(ctx, attempt > 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
u := c.APIBase() + path
|
|
if len(query) > 0 {
|
|
u += "?" + query.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
|
|
continue
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
return nil, resp.Header, fmt.Errorf("GLPI %s %s failed: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
return b, resp.Header, nil
|
|
}
|
|
return nil, nil, errors.New("GLPI request failed after token refresh")
|
|
}
|
|
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
_, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", url.Values{"limit": {"1"}}, nil)
|
|
return err
|
|
}
|
|
func (c *Client) FetchOpenAPI(ctx context.Context) (map[string]any, error) {
|
|
tok, err := c.authenticate(ctx, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api.php/doc.json", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
return nil, fmt.Errorf("OpenAPI HTTP %d", resp.StatusCode)
|
|
}
|
|
var v map[string]any
|
|
return v, json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&v)
|
|
}
|
|
func (c *Client) ValidateContract(ctx context.Context) error {
|
|
doc, err := c.FetchOpenAPI(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch GLPI OpenAPI: %w", err)
|
|
}
|
|
paths, ok := doc["paths"].(map[string]any)
|
|
if !ok {
|
|
return errors.New("GLPI OpenAPI document has no paths map")
|
|
}
|
|
required := map[string][]string{
|
|
"/Assistance/Ticket": {http.MethodGet},
|
|
"/Assistance/Ticket/{id}": {http.MethodGet, http.MethodPatch},
|
|
"/Assistance/Ticket/{id}/Timeline/Followup": {http.MethodGet, http.MethodPost},
|
|
"/Dropdowns/ITILCategory": {http.MethodGet},
|
|
}
|
|
for route, methods := range required {
|
|
op, found := openAPIOperations(paths, route)
|
|
if !found {
|
|
return fmt.Errorf("GLPI OpenAPI is missing required route %s; verify GLPI version/permissions", route)
|
|
}
|
|
for _, method := range methods {
|
|
if _, ok := op[strings.ToLower(method)]; !ok {
|
|
return fmt.Errorf("GLPI OpenAPI route %s does not expose %s; verify service-account permissions and API version", route, method)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openAPIOperations(paths map[string]any, route string) (map[string]any, bool) {
|
|
for documented, raw := range paths {
|
|
// Depending on how the installed GLPI renders the schema, documented
|
|
// paths may include the version prefix. Match the canonical route suffix.
|
|
if documented != route && !strings.HasSuffix(documented, route) {
|
|
continue
|
|
}
|
|
ops, ok := raw.(map[string]any)
|
|
return ops, ok
|
|
}
|
|
return nil, false
|
|
}
|
|
func (c *Client) ListRecentTickets(ctx context.Context, limit int, filter string) ([]model.Ticket, error) {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
|
|
if strings.TrimSpace(filter) != "" {
|
|
q.Set("filter", filter)
|
|
}
|
|
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", q, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Ticket, 0, len(arr))
|
|
for _, raw := range arr {
|
|
out = append(out, decodeTicket(raw))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListEscalationCandidates(ctx context.Context, limit int, filter string) ([]model.Ticket, error) {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_creation"}, "order": {"ASC"}}
|
|
if strings.TrimSpace(filter) != "" {
|
|
q.Set("filter", filter)
|
|
}
|
|
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", q, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Ticket, 0, len(arr))
|
|
for _, raw := range arr {
|
|
out = append(out, decodeTicket(raw))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) GetTicket(ctx context.Context, id int64) (model.Ticket, error) {
|
|
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket/"+strconv.FormatInt(id, 10), nil, nil)
|
|
if err != nil {
|
|
return model.Ticket{}, err
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return model.Ticket{}, err
|
|
}
|
|
return decodeTicket(raw), nil
|
|
}
|
|
func (c *Client) GetFollowups(ctx context.Context, id int64) ([]model.Followup, error) {
|
|
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket/"+strconv.FormatInt(id, 10)+"/Timeline/Followup", url.Values{"limit": {"100"}}, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Followup, 0, len(arr))
|
|
for _, raw := range arr {
|
|
out = append(out, decodeFollowup(raw))
|
|
}
|
|
return out, nil
|
|
}
|
|
func (c *Client) SetCategory(ctx context.Context, id, categoryID int64) error {
|
|
_, _, err := c.do(ctx, http.MethodPatch, "/Assistance/Ticket/"+strconv.FormatInt(id, 10), nil, map[string]any{"category": map[string]any{"id": categoryID}})
|
|
return err
|
|
}
|
|
func (c *Client) SetPriority(ctx context.Context, id, priority int64) error {
|
|
_, _, err := c.do(ctx, http.MethodPatch, "/Assistance/Ticket/"+strconv.FormatInt(id, 10), nil, map[string]any{"priority": priority})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) SetAssignedGroups(ctx context.Context, id int64, groupIDs []int64, field string) error {
|
|
return c.setTicketActors(ctx, id, groupIDs, field, "assigned_groups")
|
|
}
|
|
|
|
func (c *Client) SetAssignedUsers(ctx context.Context, id int64, userIDs []int64, field string) error {
|
|
return c.setTicketActors(ctx, id, userIDs, field, "assigned_users")
|
|
}
|
|
|
|
func (c *Client) setTicketActors(ctx context.Context, id int64, actorIDs []int64, field, fallback string) error {
|
|
field = strings.TrimSpace(field)
|
|
if field == "" {
|
|
field = fallback
|
|
}
|
|
ids := uniquePositiveIDs(actorIDs)
|
|
if len(ids) == 0 {
|
|
return errors.New("ticket actor assignment requires at least one positive ID")
|
|
}
|
|
var value any
|
|
switch strings.ToLower(field) {
|
|
case "group", "group_tech", "user", "user_tech":
|
|
value = map[string]any{"id": ids[len(ids)-1]}
|
|
default:
|
|
refs := make([]map[string]any, 0, len(ids))
|
|
for _, actorID := range ids {
|
|
refs = append(refs, map[string]any{"id": actorID})
|
|
}
|
|
value = refs
|
|
}
|
|
_, _, err := c.do(ctx, http.MethodPatch, "/Assistance/Ticket/"+strconv.FormatInt(id, 10), nil, map[string]any{field: value})
|
|
return err
|
|
}
|
|
|
|
func uniquePositiveIDs(ids []int64) []int64 {
|
|
seen := make(map[int64]struct{}, len(ids))
|
|
out := make([]int64, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Client) AddPrivateFollowup(ctx context.Context, id int64, content string, richHTML bool) error {
|
|
return c.addFollowup(ctx, id, content, richHTML, true)
|
|
}
|
|
|
|
func (c *Client) AddFollowup(ctx context.Context, id int64, content string, richHTML bool) error {
|
|
return c.addFollowup(ctx, id, content, richHTML, false)
|
|
}
|
|
|
|
func (c *Client) addFollowup(ctx context.Context, id int64, content string, richHTML, private bool) error {
|
|
content = strings.TrimSpace(content)
|
|
if !richHTML {
|
|
content = "<p>" + strings.ReplaceAll(html.EscapeString(content), "\n", "<br>") + "</p>"
|
|
}
|
|
_, _, err := c.do(ctx, http.MethodPost, "/Assistance/Ticket/"+strconv.FormatInt(id, 10)+"/Timeline/Followup", nil, map[string]any{"content": content, "is_private": private})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) LinkITILObject(ctx context.Context, ticketID, targetTicketID int64, pathTemplate, bodyTemplate string) error {
|
|
if ticketID <= 0 || targetTicketID <= 0 {
|
|
return errors.New("ITIL link requires positive source and target ticket IDs")
|
|
}
|
|
pathTemplate = strings.TrimSpace(pathTemplate)
|
|
bodyTemplate = strings.TrimSpace(bodyTemplate)
|
|
if pathTemplate == "" || bodyTemplate == "" {
|
|
return errors.New("ITIL link path/body template is not configured")
|
|
}
|
|
replacer := strings.NewReplacer(
|
|
"{{ticket_id}}", strconv.FormatInt(ticketID, 10),
|
|
"{{source_ticket_id}}", strconv.FormatInt(ticketID, 10),
|
|
"{{major_incident_id}}", strconv.FormatInt(targetTicketID, 10),
|
|
"{{target_ticket_id}}", strconv.FormatInt(targetTicketID, 10),
|
|
)
|
|
path := replacer.Replace(pathTemplate)
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
var body any
|
|
if err := json.Unmarshal([]byte(replacer.Replace(bodyTemplate)), &body); err != nil {
|
|
return fmt.Errorf("decode GLPI_ESCALATION_ITIL_LINK_BODY: %w", err)
|
|
}
|
|
_, _, err := c.do(ctx, http.MethodPost, path, nil, body)
|
|
return err
|
|
}
|
|
|
|
func (c *Client) GetCategories(ctx context.Context) ([]model.Category, error) {
|
|
b, _, err := c.do(ctx, http.MethodGet, "/Dropdowns/ITILCategory", url.Values{"limit": {"1000"}}, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Category, 0, len(arr))
|
|
for _, r := range arr {
|
|
out = append(out, model.Category{ID: int64Val(r["id"]), Name: strVal(r["name"]), CompleteName: strVal(r["completename"]), KnowbaseCategoryID: firstRefID(r, "knowbase_category", "knowbasecategory")})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DiscoverKnowledgeBasePath finds the read-only KnowbaseItem collection route
|
|
// from the OpenAPI document of the installed GLPI instance. This avoids
|
|
// hard-coding a path that may move between high-level API versions.
|
|
func (c *Client) DiscoverKnowledgeBasePath(ctx context.Context, configured string) (string, error) {
|
|
configured = strings.TrimSpace(configured)
|
|
if configured != "" && !strings.EqualFold(configured, "auto") {
|
|
if err := c.ValidateReadRoutes(ctx, []string{configured}); err != nil {
|
|
return "", err
|
|
}
|
|
return configured, nil
|
|
}
|
|
doc, err := c.FetchOpenAPI(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch GLPI OpenAPI: %w", err)
|
|
}
|
|
paths, ok := doc["paths"].(map[string]any)
|
|
if !ok {
|
|
return "", errors.New("GLPI OpenAPI document has no paths map")
|
|
}
|
|
type candidate struct {
|
|
path string
|
|
score int
|
|
}
|
|
var candidates []candidate
|
|
for path, raw := range paths {
|
|
if strings.Contains(path, "{") {
|
|
continue
|
|
}
|
|
ops, ok := raw.(map[string]any)
|
|
if !ok || ops["get"] == nil {
|
|
continue
|
|
}
|
|
l := strings.ToLower(path)
|
|
score := 0
|
|
if strings.Contains(l, "knowbaseitem") {
|
|
score += 100
|
|
}
|
|
if strings.Contains(l, "knowledge") {
|
|
score += 40
|
|
}
|
|
if strings.Contains(l, "knowbase") {
|
|
score += 40
|
|
}
|
|
if strings.HasSuffix(l, "/knowbaseitem") {
|
|
score += 30
|
|
}
|
|
if score > 0 {
|
|
candidates = append(candidates, candidate{path: path, score: score})
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return "", errors.New("GLPI OpenAPI exposes no readable KnowbaseItem collection route; verify GLPI version and service-account knowledge-base rights")
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
if candidates[i].score == candidates[j].score {
|
|
return candidates[i].path < candidates[j].path
|
|
}
|
|
return candidates[i].score > candidates[j].score
|
|
})
|
|
path := candidates[0].path
|
|
// The OpenAPI document may include /api.php/vX.Y in documented paths while
|
|
// c.do already prepends the configured API base. Keep only the route suffix.
|
|
if i := strings.Index(path, "/api.php/"); i >= 0 {
|
|
rest := path[i+len("/api.php/"):]
|
|
if slash := strings.Index(rest, "/"); slash >= 0 {
|
|
path = rest[slash:]
|
|
}
|
|
}
|
|
versionPrefix := "/" + strings.Trim(c.version, "/")
|
|
if strings.HasPrefix(path, versionPrefix+"/") {
|
|
path = strings.TrimPrefix(path, versionPrefix)
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// ListKnowledgeBaseItems reads only items visible to the authenticated GLPI
|
|
// service account. Visibility is therefore enforced by GLPI itself; an
|
|
// optional filter can further restrict the collection on a per-instance basis.
|
|
func (c *Client) ListKnowledgeBaseItems(ctx context.Context, path string, limit int, filter string) ([]model.GLPIKnowledgeItem, error) {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
|
|
if strings.TrimSpace(filter) != "" {
|
|
q.Set("filter", filter)
|
|
}
|
|
b, _, err := c.do(ctx, http.MethodGet, path, q, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.GLPIKnowledgeItem, 0, len(arr))
|
|
for _, r := range arr {
|
|
id := int64Val(r["id"])
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if firstString(r, "answer", "content", "text", "description") == "" {
|
|
if detail, _, e := c.do(ctx, http.MethodGet, strings.TrimRight(path, "/")+"/"+strconv.FormatInt(id, 10), nil, nil); e == nil {
|
|
var full map[string]any
|
|
if json.Unmarshal(detail, &full) == nil {
|
|
for k, v := range full {
|
|
r[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
title := firstString(r, "name", "title", "subject")
|
|
content := firstString(r, "answer", "content", "text", "description")
|
|
if title == "" || content == "" {
|
|
continue
|
|
}
|
|
out = append(out, model.GLPIKnowledgeItem{
|
|
ID: id,
|
|
Title: title,
|
|
Content: content,
|
|
CategoryIDs: knowledgeCategoryIDs(r),
|
|
Language: firstString(r, "language", "locale"),
|
|
ModifiedAt: firstString(r, "date_mod", "modified_at", "date_creation"),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func knowledgeCategoryIDs(r map[string]any) []int64 {
|
|
seen := map[int64]struct{}{}
|
|
var out []int64
|
|
var add func(any)
|
|
add = func(v any) {
|
|
switch x := v.(type) {
|
|
case []any:
|
|
for _, e := range x {
|
|
add(e)
|
|
}
|
|
case map[string]any:
|
|
if id := int64Val(x["id"]); id > 0 {
|
|
if _, ok := seen[id]; !ok {
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return
|
|
}
|
|
for _, v2 := range x {
|
|
add(v2)
|
|
}
|
|
default:
|
|
if id := int64Val(x); id > 0 {
|
|
if _, ok := seen[id]; !ok {
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, k := range []string{"knowbase_category", "knowbase_categories", "knowbaseitemcategory", "knowbaseitemcategories", "categories", "category"} {
|
|
if v, ok := r[k]; ok {
|
|
add(v)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
func extractArray(b []byte) ([]map[string]any, error) {
|
|
var arr []map[string]any
|
|
if json.Unmarshal(b, &arr) == nil {
|
|
return arr, nil
|
|
}
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &obj); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, k := range []string{"data", "items", "results"} {
|
|
if raw, ok := obj[k]; ok && json.Unmarshal(raw, &arr) == nil {
|
|
return arr, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("unexpected GLPI collection response: %.200s", string(b))
|
|
}
|
|
func decodeTicket(r map[string]any) model.Ticket {
|
|
t := model.Ticket{
|
|
ID: int64Val(r["id"]),
|
|
Name: strVal(r["name"]),
|
|
Content: strVal(r["content"]),
|
|
DateCreation: firstString(r, "date_creation", "date", "created_at"),
|
|
DateMod: firstString(r, "date_mod", "modified_at"),
|
|
StatusID: refID(r["status"]),
|
|
CategoryID: firstRefID(r, "category", "itil_category", "itilcategory"),
|
|
Priority: int64Val(r["priority"]),
|
|
Impact: int64Val(r["impact"]),
|
|
Urgency: int64Val(r["urgency"]),
|
|
EntityID: firstRefID(r, "entity", "entities_id"),
|
|
LocationID: firstRefID(r, "location", "locations_id"),
|
|
TimeToResolve: firstString(r, "time_to_resolve", "sla_deadline"),
|
|
}
|
|
t.RequesterIDs = extractRequesterIDs(r)
|
|
t.AssignedGroups = extractActorIDs(r, "group", "assign")
|
|
t.AssignedUsers = extractActorIDs(r, "user", "assign")
|
|
t.Items = extractLinkedItems(r)
|
|
return t
|
|
}
|
|
|
|
func extractActorIDs(r map[string]any, actorKind, roleNeedle string) []int64 {
|
|
seen := map[int64]struct{}{}
|
|
var out []int64
|
|
add := func(v any) {
|
|
id := refID(v)
|
|
if id <= 0 {
|
|
return
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
return
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
keys := []string{"assigned_" + actorKind + "s", actorKind + "s_assigned", actorKind + "_assigned"}
|
|
for _, key := range keys {
|
|
v, ok := r[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if arr, ok := v.([]any); ok {
|
|
for _, item := range arr {
|
|
add(item)
|
|
}
|
|
} else {
|
|
add(v)
|
|
}
|
|
}
|
|
if actors, ok := r["actors"].([]any); ok {
|
|
for _, raw := range actors {
|
|
m, _ := raw.(map[string]any)
|
|
role := strings.ToLower(firstString(m, "role", "type", "actor_type"))
|
|
kind := strings.ToLower(firstString(m, "itemtype", "actor_kind", "kind"))
|
|
if strings.Contains(role, roleNeedle) && (kind == "" || strings.Contains(kind, actorKind)) {
|
|
if v, ok := m[actorKind]; ok {
|
|
add(v)
|
|
} else {
|
|
add(m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
func extractRequesterIDs(r map[string]any) []int64 {
|
|
seen := map[int64]struct{}{}
|
|
var out []int64
|
|
add := func(v any) {
|
|
switch x := v.(type) {
|
|
case []any:
|
|
for _, e := range x {
|
|
addRequesterID(e, seen, &out)
|
|
}
|
|
default:
|
|
addRequesterID(x, seen, &out)
|
|
}
|
|
}
|
|
for _, k := range []string{"requester", "requesters", "users_requester", "users_requesters", "requester_users"} {
|
|
if v, ok := r[k]; ok {
|
|
add(v)
|
|
}
|
|
}
|
|
if actors, ok := r["actors"].([]any); ok {
|
|
for _, raw := range actors {
|
|
m, _ := raw.(map[string]any)
|
|
role := strings.ToLower(firstString(m, "role", "type", "actor_type"))
|
|
if strings.Contains(role, "request") || strings.Contains(role, "demande") {
|
|
addRequesterID(m, seen, &out)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func addRequesterID(v any, seen map[int64]struct{}, out *[]int64) {
|
|
id := int64(0)
|
|
if m, ok := v.(map[string]any); ok {
|
|
id = firstRefID(m, "user", "requester")
|
|
if id == 0 {
|
|
id = int64Val(m["id"])
|
|
}
|
|
} else {
|
|
id = refID(v)
|
|
}
|
|
if id <= 0 {
|
|
return
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
return
|
|
}
|
|
seen[id] = struct{}{}
|
|
*out = append(*out, id)
|
|
}
|
|
|
|
func extractLinkedItems(r map[string]any) []model.LinkedItem {
|
|
seen := map[string]struct{}{}
|
|
var out []model.LinkedItem
|
|
visit := func(v any) {
|
|
arr, ok := v.([]any)
|
|
if !ok {
|
|
arr = []any{v}
|
|
}
|
|
for _, raw := range arr {
|
|
m, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
typ := firstString(m, "itemtype", "type", "item_type")
|
|
id := int64Val(m["id"])
|
|
if id == 0 {
|
|
id = int64Val(m["items_id"])
|
|
}
|
|
if id == 0 {
|
|
id = firstRefID(m, "item")
|
|
}
|
|
if typ == "" {
|
|
if item, ok := m["item"].(map[string]any); ok {
|
|
typ = firstString(item, "itemtype", "type")
|
|
if id == 0 {
|
|
id = int64Val(item["id"])
|
|
}
|
|
}
|
|
}
|
|
if id <= 0 || typ == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(typ) + ":" + strconv.FormatInt(id, 10)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, model.LinkedItem{ItemType: typ, ID: id, Name: firstString(m, "name", "completename")})
|
|
}
|
|
}
|
|
for _, k := range []string{"items", "assets", "associated_items", "linked_items", "item"} {
|
|
if v, ok := r[k]; ok {
|
|
visit(v)
|
|
}
|
|
}
|
|
if typ := firstString(r, "itemtype"); typ != "" {
|
|
if id := int64Val(r["items_id"]); id > 0 {
|
|
visit(map[string]any{"itemtype": typ, "id": id})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decodeFollowup(r map[string]any) model.Followup {
|
|
return model.Followup{ID: int64Val(r["id"]), Content: strVal(r["content"]), IsPrivate: boolVal(r["is_private"]), UserID: firstRefID(r, "user", "author", "user_editor"), Date: strVal(r["date"])}
|
|
}
|
|
func firstRefID(r map[string]any, keys ...string) int64 {
|
|
for _, k := range keys {
|
|
if v, ok := r[k]; ok {
|
|
if id := refID(v); id != 0 {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
func refID(v any) int64 {
|
|
if m, ok := v.(map[string]any); ok {
|
|
return int64Val(m["id"])
|
|
}
|
|
return int64Val(v)
|
|
}
|
|
func int64Val(v any) int64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int64(x)
|
|
case int:
|
|
return int64(x)
|
|
case int64:
|
|
return x
|
|
case json.Number:
|
|
n, _ := x.Int64()
|
|
return n
|
|
case string:
|
|
n, _ := strconv.ParseInt(x, 10, 64)
|
|
return n
|
|
}
|
|
return 0
|
|
}
|
|
func strVal(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprint(v)
|
|
}
|
|
func boolVal(v any) bool {
|
|
switch x := v.(type) {
|
|
case bool:
|
|
return x
|
|
case float64:
|
|
return x != 0
|
|
case string:
|
|
return x == "1" || strings.EqualFold(x, "true")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateReadRoutes checks optional, read-only context routes against the
|
|
// OpenAPI document exposed by the installed GLPI instance. This keeps optional
|
|
// integrations explicit and catches renamed/unavailable routes at startup.
|
|
func (c *Client) ValidateReadRoutes(ctx context.Context, routes []string) error {
|
|
if len(routes) == 0 {
|
|
return nil
|
|
}
|
|
doc, err := c.FetchOpenAPI(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch GLPI OpenAPI: %w", err)
|
|
}
|
|
paths, ok := doc["paths"].(map[string]any)
|
|
if !ok {
|
|
return errors.New("GLPI OpenAPI document has no paths map")
|
|
}
|
|
for _, route := range routes {
|
|
op, found := openAPIOperations(paths, route)
|
|
if !found {
|
|
return fmt.Errorf("GLPI OpenAPI is missing optional context route %s", route)
|
|
}
|
|
if _, ok := op[strings.ToLower(http.MethodGet)]; !ok {
|
|
return fmt.Errorf("GLPI context route %s does not expose GET", route)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ListChanges(ctx context.Context, path string, limit int, filter string) ([]model.ChangeContext, error) {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
|
|
if strings.TrimSpace(filter) != "" {
|
|
q.Set("filter", filter)
|
|
}
|
|
b, _, err := c.do(ctx, http.MethodGet, path, q, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.ChangeContext, 0, len(arr))
|
|
for _, r := range arr {
|
|
out = append(out, model.ChangeContext{
|
|
ID: int64Val(r["id"]),
|
|
Name: strVal(r["name"]),
|
|
Content: firstString(r, "content", "description"),
|
|
StatusID: firstRefID(r, "status"),
|
|
CategoryID: firstRefID(r, "category", "itil_category", "itilcategory"),
|
|
PlannedBegin: firstString(r, "planned_begin", "planned_start", "date_begin"),
|
|
PlannedEnd: firstString(r, "planned_end", "planned_finish", "date_end"),
|
|
DateMod: firstString(r, "date_mod", "modified_at"),
|
|
Source: "glpi-change",
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListMajorIncidents(ctx context.Context, limit int, filter string) ([]model.MajorIncidentContext, error) {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
|
|
if strings.TrimSpace(filter) != "" {
|
|
q.Set("filter", filter)
|
|
}
|
|
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", q, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.MajorIncidentContext, 0, len(arr))
|
|
for _, r := range arr {
|
|
out = append(out, model.MajorIncidentContext{
|
|
ID: int64Val(r["id"]),
|
|
Name: strVal(r["name"]),
|
|
Content: firstString(r, "content", "description"),
|
|
StatusID: firstRefID(r, "status"),
|
|
CategoryID: firstRefID(r, "category", "itil_category", "itilcategory"),
|
|
Priority: int64Val(r["priority"]),
|
|
Impact: int64Val(r["impact"]),
|
|
Urgency: int64Val(r["urgency"]),
|
|
DateMod: firstString(r, "date_mod", "modified_at"),
|
|
Source: "glpi-major-incident",
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListUserDevices searches one or more read-only asset collection routes for
|
|
// assets assigned to a requester. The filter template is configuration-driven
|
|
// because field aliases can differ with GLPI API versions/plugins.
|
|
func (c *Client) ListUserDevices(ctx context.Context, userID int64, paths []string, filterTemplate string, limit int) ([]model.UserDeviceContext, error) {
|
|
if userID <= 0 {
|
|
return nil, nil
|
|
}
|
|
filter := strings.ReplaceAll(filterTemplate, "{{user_id}}", strconv.FormatInt(userID, 10))
|
|
var out []model.UserDeviceContext
|
|
for _, path := range paths {
|
|
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
|
|
if strings.TrimSpace(filter) != "" {
|
|
q.Set("filter", filter)
|
|
}
|
|
b, _, err := c.do(ctx, http.MethodGet, path, q, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query %s for user %d: %w", path, userID, err)
|
|
}
|
|
arr, err := extractArray(b)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode %s: %w", path, err)
|
|
}
|
|
itemType := strings.TrimPrefix(path[strings.LastIndex(path, "/"):], "/")
|
|
for _, r := range arr {
|
|
out = append(out, model.UserDeviceContext{
|
|
UserID: userID,
|
|
ItemType: itemType,
|
|
ID: int64Val(r["id"]),
|
|
Name: strVal(r["name"]),
|
|
Serial: firstString(r, "serial", "serial_number"),
|
|
InventoryNumber: firstString(r, "otherserial", "inventory_number", "inventory_no"),
|
|
Status: refName(r["status"]),
|
|
Location: refName(r["location"]),
|
|
LastInventoryDate: firstString(r, "last_inventory_update", "last_inventory_date", "date_mod"),
|
|
Source: "glpi-user-device",
|
|
})
|
|
if len(out) >= limit {
|
|
return out[:limit], nil
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func firstString(r map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := r[k]; ok {
|
|
s := strings.TrimSpace(strVal(v))
|
|
if s != "" && s != "<nil>" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func refName(v any) string {
|
|
if m, ok := v.(map[string]any); ok {
|
|
for _, k := range []string{"completename", "name", "label"} {
|
|
if s := strings.TrimSpace(strVal(m[k])); s != "" && s != "<nil>" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return strings.TrimSpace(strVal(v))
|
|
}
|