package glpi import ( "bytes" "context" "encoding/json" "errors" "fmt" "html" "io" "net/http" "net/url" "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) 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) AddFollowup(ctx context.Context, id int64, text string) error { content := "

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

" _, _, err := c.do(ctx, http.MethodPost, "/Assistance/Ticket/"+strconv.FormatInt(id, 10)+"/Timeline/Followup", nil, map[string]any{"content": content, "is_private": false}) 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"])}) } return out, nil } 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"]), DateMod: strVal(r["date_mod"]), StatusID: refID(r["status"]), CategoryID: firstRefID(r, "category", "itil_category", "itilcategory")} t.RequesterIDs = extractRequesterIDs(r) t.Items = extractLinkedItems(r) return t } 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)) }