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 = "

" + strings.ReplaceAll(html.EscapeString(content), "\n", "
") + "

" } _, _, 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 != "" { 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 != "" { return s } } } return strings.TrimSpace(strVal(v)) }