diff --git a/README.md b/README.md index 641a444..eed6d0a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,6 @@ # openclaw-bms -Python rewrite of the OpenClaw Kaseya BMS skill for ticket and note workflows. - -## Goals - -- reliable ticket CRUD -- reliable ticket note CRUD -- correct account/location relationship handling -- cache stable CRM lookups -- support template-based ticket creation cleanly -- keep a small shell compatibility layer for existing `scripts/*.sh` entrypoints +Python-first OpenClaw skill for Kaseya BMS ticket and note workflows. ## Installation @@ -17,10 +8,16 @@ Python rewrite of the OpenClaw Kaseya BMS skill for ticket and note workflows. python3 -m pip install -e . ``` -Or run directly from the repo: +Primary usage: ```bash -bash scripts/bms.sh --help +bms --help +``` + +Alternative direct module invocation: + +```bash +python3 -m openclaw_bms --help ``` ## Configuration @@ -29,39 +26,35 @@ bash scripts/bms.sh --help export BMS_TENANT="your-tenant" export BMS_USERNAME="your-user" export BMS_PASSWORD="your-password" -export BMS_MFA_CODE="123456" # when needed +export BMS_MFA_CODE="123456" # when needed export BMS_API_BASE="https://api.bms.kaseya.com" export BMS_TOKEN_FILE="$HOME/.bms_token.json" export BMS_CACHE_FILE="$HOME/.cache/openclaw-bms/cache.json" ``` -## Key behavior +## Core behavior ### Accounts and Locations -Locations are account-scoped. +Locations are tied to accounts. -Use: +That means a location named `Main` under one account is a different object from `Main` under another account. +Always resolve locations in the context of an account. + +Commands: ```bash bms accounts -bms locations --account 12345 -``` - -Do not assume a location name like `Main` is globally unique. A location name can exist under multiple accounts with different IDs. - -Cached for 24 hours: -- accounts list -- locations per account - -Refresh explicitly: - -```bash bms accounts --refresh +bms locations --account 12345 bms locations --account 12345 --refresh ``` -### Tickets +Caching: +- accounts cached for 24 hours +- locations cached per account for 24 hours + +### Ticket CRUD List/search: @@ -69,6 +62,12 @@ List/search: bms tickets list --status Open --assignee "Jane Doe" ``` +Get: + +```bash +bms tickets get 12345 +``` + Create: ```bash @@ -85,7 +84,7 @@ bms tickets create \ --open-date 2026-04-07T14:00:00+00:00 ``` -Template-based create: +Create from template: ```bash bms tickets create \ @@ -96,65 +95,123 @@ bms tickets create \ --queue-id 9 ``` -Template logic: -- fetches the template -- merges template defaults with CLI overrides -- CLI values win -- validates required fields before the create call -- requires routing via either `queue-id` or `assignee-id` -- makes exactly one create API call per invocation -- treats create as success only when the response includes success=true and a valid ticket ID +Template create flow: +- fetch template +- merge template defaults with explicit CLI overrides +- validate required fields before the API call +- require either `queue-id` or `assignee-id` +- make exactly one create API call per invocation +- require a valid ticket ID in the response before reporting success -### Notes +Patch: + +```bash +bms tickets patch 12345 /StatusId 6 +``` + +Assign: + +```bash +bms tickets assign 12345 --details "Routing to tier 2" --type-id 1 --status-id 6 --queue-id 7 +``` + +Delete: + +```bash +bms tickets delete 12345 +``` + +### Ticket Note CRUD List notes: ```bash -bms notes list 33919447 +bms notes list 12345 ``` -Add a note with a custom date: +Add note with custom date: ```bash -bms notes add 33919447 --message "Backfilled note" --note-date 2026-04-01T15:00:00+00:00 +bms notes add 12345 --message "Backfilled note" --note-date 2026-04-01T15:00:00+00:00 ``` -Update a note with a custom date: +Update note with custom date: ```bash -bms notes update 33919447 1001 --message "Corrected note" --note-date 2026-04-01T16:00:00+00:00 +bms notes update 12345 1001 --message "Corrected note" --note-date 2026-04-01T16:00:00+00:00 ``` -Delete a note: +Delete note: ```bash -bms notes delete 33919447 1001 +bms notes delete 12345 1001 ``` -## Architectural decisions +### Lookups + +```bash +bms lookup statuses +bms lookup priorities +bms lookup types +bms lookup sources +``` + +Notes: +- `types` maps to issue-type lookup in the public BMS v2 Swagger. +- `sources` is not exposed in the public BMS v2 Swagger; the command fails explicitly and expects tenant-specific source IDs. + +### Templates + +```bash +bms templates tickets list +bms templates tickets get 7 +bms templates notes list +bms templates timelogs list +``` + +Templates are read-only. + +## Audit logging + +Write operations only are logged. +Reads are not logged. + +Logged operations: +- auth login +- auth refresh +- ticket create +- ticket patch +- ticket delete +- ticket assign +- note add +- note update +- note delete + +Log path: + +```text +~/.bms-actions/YYYY-MM-DD.jsonl +``` + +Entry shape: +- `timestamp` +- `command` +- `args_sanitized` +- `result` or `error` +- `status` +- `revert_info` when available + +Secrets are redacted: +- `BMS_PASSWORD` +- `BMS_MFA_CODE` +- tokens +- authorization headers + +## Architecture - Python standard library only - - avoids packaging friction for a personal skill +- console entry point via `pyproject.toml` +- no shell wrappers required - service layer separated from CLI - - easier to audit and extend -- caching stored in a JSON file - - simple, transparent, sufficient for account/location lookups -- shell scripts kept as compatibility wrappers - - existing command habits keep working - -## Audit notes - -Primary audit focus was on: -- ticket create safety -- note CRUD support -- account/location correctness -- template create correctness - -Changes from bash version: -- removed fragile mixed endpoint usage -- fixed account/location handling through CRM endpoints -- added explicit cache for accounts and per-account locations -- added `open-date` support for ticket creation -- added `note-date` support for note create and update -- added full note CRUD in the Python CLI -- reduced duplicate-create risk by validating before create and checking response semantics after create +- file-based cache for stable CRM data +- file-based audit log for write history and rollback context diff --git a/SKILL.md b/SKILL.md index 825ac30..79159bb 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,15 +2,19 @@ Python-based OpenClaw skill for Kaseya BMS ticket and note workflows. -## Scope +## Run -This skill focuses on: -- ticket CRUD -- ticket note CRUD -- CRM account and account-scoped location lookup -- template-assisted ticket creation -- token handling with MFA support -- account/location caching +Preferred: + +```bash +bms --help +``` + +Alternative: + +```bash +python3 -m openclaw_bms --help +``` ## Configuration @@ -18,60 +22,40 @@ This skill focuses on: export BMS_TENANT="your-tenant-name" export BMS_USERNAME="user@example.com" export BMS_PASSWORD="yourpassword" -export BMS_MFA_CODE="123456" # when needed +export BMS_MFA_CODE="123456" export BMS_API_BASE="https://api.bms.kaseya.com" export BMS_TOKEN_FILE="$HOME/.bms_token.json" export BMS_CACHE_FILE="$HOME/.cache/openclaw-bms/cache.json" ``` -## Commands - -Primary entrypoint: - -```bash -bash scripts/bms.sh --help -``` - -### Auth - -```bash -bms auth login -bms auth refresh -bms auth status -``` +## Key functionality ### Accounts and Locations +Use CRM endpoints and respect account/location relationships: + ```bash bms accounts -bms accounts --refresh bms locations --account 12345 -bms locations --account 12345 --refresh ``` Important: -- locations are tied to accounts -- the same location name can exist under multiple accounts with different IDs -- always resolve location IDs in the context of a specific account +- locations are account-scoped +- a location name under one account is not interchangeable with the same location name under another account +- accounts and per-account locations are cached for 24 hours ### Tickets ```bash bms tickets list --status Open --assignee "Jane Doe" bms tickets get 12345 -bms tickets create --title "test" --details "Test" --account-id 1 --location-id 2 --status-id 3 --priority-id 4 --type-id 5 --source-id 6 --queue-id 7 +bms tickets create --title "test" --details "Test" --account-id 1 --location-id 2 --status-id 3 --priority-id 4 --type-id 5 --source-id 6 --queue-id 7 --open-date 2026-04-07T14:00:00+00:00 bms tickets create --template-id 9 --title "Override title" --account-id 1 --location-id 2 --queue-id 7 bms tickets patch 12345 /StatusId 6 bms tickets assign 12345 --details "Routing" --type-id 1 --status-id 6 --queue-id 7 bms tickets delete 12345 ``` -Features: -- `--open-date` supported for ticket creation -- template-based creation merges template defaults with explicit overrides -- create validation requires all required fields plus either `queue-id` or `assignee-id` -- create path makes one API call only and validates response semantics before reporting success - ### Notes ```bash @@ -81,9 +65,14 @@ bms notes update 12345 999 --message "Corrected note" --note-date 2026-04-07T13: bms notes delete 12345 999 ``` -Features: -- custom note dates supported for create and update -- note CRUD exposed directly in the Python CLI +### Lookups + +```bash +bms lookup statuses +bms lookup priorities +bms lookup types +bms lookup sources +``` ### Templates @@ -96,6 +85,27 @@ bms templates timelogs list Templates are read-only. +## Audit logging + +Only write operations are audited. +Read operations are intentionally not logged. + +Audit path: + +```text +~/.bms-actions/YYYY-MM-DD.jsonl +``` + +Entry fields: +- `timestamp` +- `command` +- `args_sanitized` +- `result` or `error` +- `status` +- `revert_info` when available + +Secrets are redacted before logging. + ## Endpoints used Auth: @@ -116,6 +126,7 @@ Tickets: Notes: - `GET /v2/servicedesk/tickets/{ticketId}/notes` +- `GET /v2/servicedesk/tickets/{ticketId}/notes/{noteId}` - `POST /v2/servicedesk/tickets/{ticketId}/notes` - `PUT /v2/servicedesk/tickets/{ticketId}/notes/{noteId}` - `DELETE /v2/servicedesk/tickets/{ticketId}/notes/{noteId}` @@ -125,22 +136,3 @@ Templates: - `GET /v2/servicedesk/templates/tickets/{templateId}` - `GET /v2/servicedesk/templates/notes/lookup` - `GET /v2/servicedesk/templates/timelogs/lookup` - -## Note Type IDs (Grand Portage tenant) - -Known working values from tenant testing: -- `0` — Email Sent -- `1` — Email Received -- `2` — General Notes -- `3` — Phone Call -- `4` — Resolution - -These are tenant-specific and may differ elsewhere. - -## Implementation notes - -- Python standard library only -- shell scripts retained as compatibility wrappers around the Python CLI -- cached lookups reduce repeated account/location API calls -- account/location cache TTL is 24 hours by default -- designed for Daniel’s direct use and BMS operator workflows diff --git a/pyproject.toml b/pyproject.toml index 8f59451..2d58ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openclaw-bms" -version = "0.2.0" +version = "0.2.1" description = "Python-based OpenClaw skill for Kaseya BMS ticket and note workflows." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/bms-accounts.sh b/scripts/bms-accounts.sh deleted file mode 100755 index 83afcc2..0000000 --- a/scripts/bms-accounts.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m openclaw_bms accounts "$@" diff --git a/scripts/bms-auth.sh b/scripts/bms-auth.sh deleted file mode 100755 index ad6221f..0000000 --- a/scripts/bms-auth.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -action="${1:-login}" -exec python3 -m openclaw_bms auth "$action" diff --git a/scripts/bms-locations.sh b/scripts/bms-locations.sh deleted file mode 100755 index 2c1273b..0000000 --- a/scripts/bms-locations.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m openclaw_bms locations "$@" diff --git a/scripts/bms-logging.sh b/scripts/bms-logging.sh deleted file mode 100755 index 01b0896..0000000 --- a/scripts/bms-logging.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# bms-logging.sh — Action logging for BMS skill -# Centralized logging of user-initiated actions for audit/review - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Log directory (can be overridden) -BMS_LOG_DIR="${BMS_LOG_DIR:-$HOME/.bms-actions}" - -# Ensure log directory exists -mkdir -p "$BMS_LOG_DIR" - -# Compute log file dynamically based on current BMS_LOG_DIR - -# Sanitize arguments: strip any sensitive values from a JSON object -# Usage: sanitized=$(sanitize_args '{"password":"secret","token":"abc"}') -sanitize_args() { - local input="$1" - # Remove known sensitive keys; preserve structure; output compact JSON to avoid newline issues - jq -c 'del(.["BMS_PASSWORD"], .["BMS_MFA_CODE"], .["BMS_CLIENT_SECRET"], .["access_token"], .["refresh_token"], .["token"], .["Authorization"])' 2>/dev/null <<<"$input" || echo "$input" -} - -# Log an action -# Arguments: command, args_json, result_json, status (success|error) -log_action() { - local command="$1" - local args_json="${2:-{\}}" - local result_json="${3:-{\}}" - local status="${4:-success}" - - # Ensure we have valid JSON; if pretty-printed, re-compact to a single line - local args_compact result_compact - args_compact=$(echo "$args_json" | jq -c . 2>/dev/null || echo "$args_json") - result_compact=$(echo "$result_json" | jq -c . 2>/dev/null || echo "$result_json") - - local timestamp - timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) - - # Compute log file path dynamically - local log_dir="${BMS_LOG_DIR:-$HOME/.bms-actions}" - mkdir -p "$log_dir" 2>/dev/null - local log_file="$log_dir/$(date -u +%Y-%m-%d).jsonl" - - # Use --arg to pass JSON as string, then parse with fromjson inside jq - local entry - entry=$(jq -nc \ - --arg ts "$timestamp" \ - --arg cmd "$command" \ - --arg args "$args_compact" \ - --arg result "$result_compact" \ - --arg stat "$status" \ - '{timestamp: $ts, command: $cmd, args: ($args|fromjson), result: ($result|fromjson), status: $stat}') - - echo "$entry" >> "$log_file" -} - -# Get current log file path -get_log_path() { - local log_dir="${BMS_LOG_DIR:-$HOME/.bms-actions}" - echo "$log_dir/$(date -u +%Y-%m-%d).jsonl" -} diff --git a/scripts/bms-lookup.sh b/scripts/bms-lookup.sh deleted file mode 100755 index d3482cc..0000000 --- a/scripts/bms-lookup.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -cat >&2 <<'EOF' -Lookup subcommands from the old bash implementation were removed in the Python rewrite. -Use: - bms accounts - bms locations --account - bms templates tickets list - bms templates notes list - bms templates timelogs list -For ticket/status IDs, use your tenant's known IDs or extend the Python service with tenant-specific lookups. -EOF -exit 1 diff --git a/scripts/bms-templates.sh b/scripts/bms-templates.sh deleted file mode 100755 index 4ae310d..0000000 --- a/scripts/bms-templates.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m openclaw_bms templates "$@" diff --git a/scripts/bms-tickets.sh b/scripts/bms-tickets.sh deleted file mode 100755 index 7289958..0000000 --- a/scripts/bms-tickets.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m openclaw_bms tickets "$@" diff --git a/scripts/bms.sh b/scripts/bms.sh deleted file mode 100755 index fa8e567..0000000 --- a/scripts/bms.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m openclaw_bms "$@" diff --git a/src/openclaw_bms/__init__.py b/src/openclaw_bms/__init__.py index 25312d0..dea755d 100644 --- a/src/openclaw_bms/__init__.py +++ b/src/openclaw_bms/__init__.py @@ -1,2 +1,2 @@ __all__ = ["__version__"] -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/openclaw_bms/audit.py b/src/openclaw_bms/audit.py new file mode 100644 index 0000000..d83142c --- /dev/null +++ b/src/openclaw_bms/audit.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import os +from dataclasses import asdict, is_dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +SENSITIVE_KEYS = { + "BMS_PASSWORD", + "BMS_MFA_CODE", + "access_token", + "refresh_token", + "Authorization", + "authorization", + "token", +} + + +class AuditLogger: + def __init__(self, base_dir: str | None = None): + self.base_dir = Path(base_dir or os.path.expanduser("~/.bms-actions")) + + def _sanitize(self, value: Any) -> Any: + if is_dataclass(value): + value = asdict(value) + if isinstance(value, dict): + out = {} + for k, v in value.items(): + if k in SENSITIVE_KEYS: + out[k] = "[REDACTED]" + else: + out[k] = self._sanitize(v) + return out + if isinstance(value, (list, tuple)): + return [self._sanitize(v) for v in value] + if isinstance(value, str) and value.lower().startswith("bearer "): + return "[REDACTED]" + return value + + def log(self, *, command: str, args_sanitized: Any, status: str, result: Any = None, error: Any = None, revert_info: Any = None) -> None: + timestamp = datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + day = timestamp[:10] + path = self.base_dir / f"{day}.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + entry = { + "timestamp": timestamp, + "command": command, + "args_sanitized": self._sanitize(args_sanitized), + "status": status, + } + if result is not None: + entry["result"] = self._sanitize(result) + if error is not None: + entry["error"] = self._sanitize(error) + if revert_info is not None: + entry["revert_info"] = self._sanitize(revert_info) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, sort_keys=True) + "\n") diff --git a/src/openclaw_bms/cli.py b/src/openclaw_bms/cli.py index 2ed9a76..79b1540 100644 --- a/src/openclaw_bms/cli.py +++ b/src/openclaw_bms/cli.py @@ -3,12 +3,27 @@ from __future__ import annotations import argparse import json from dataclasses import asdict +from typing import Any +from .audit import AuditLogger from .client import BmsClient, BmsError from .service import BmsService, CreateTicketInput -def _print(data): +WRITE_COMMANDS = { + "auth.login", + "auth.refresh", + "tickets.create", + "tickets.patch", + "tickets.delete", + "tickets.assign", + "notes.add", + "notes.update", + "notes.delete", +} + + +def _print(data: Any) -> None: print(json.dumps(data, indent=2)) @@ -16,13 +31,28 @@ def _service() -> BmsService: return BmsService(BmsClient()) +def _audit(command: str, args_sanitized: Any, *, result: Any = None, error: Any = None, status: str, revert_info: Any = None) -> None: + if command in WRITE_COMMANDS: + AuditLogger().log(command=command, args_sanitized=args_sanitized, result=result, error=error, status=status, revert_info=revert_info) + + +def _run_write(command: str, args_sanitized: Any, fn): + try: + result, revert_info = fn() + _audit(command, args_sanitized, result=result, status="success", revert_info=revert_info) + return result + except Exception as exc: + _audit(command, args_sanitized, error=str(exc), status="error") + raise + + def cmd_auth(args: argparse.Namespace) -> int: client = BmsClient() if args.action == "login": - client.authenticate() + _run_write("auth.login", {"action": args.action}, lambda: ({"message": client.authenticate() and "Authenticated."}, None)) print("Authenticated.") elif args.action == "refresh": - client.refresh() + _run_write("auth.refresh", {"action": args.action}, lambda: ({"message": client.refresh() and "Refreshed."}, None)) print("Refreshed.") else: token_data = client._load_json(client.config.token_file) @@ -32,13 +62,27 @@ def cmd_auth(args: argparse.Namespace) -> int: def cmd_accounts(args: argparse.Namespace) -> int: for item in _service().list_accounts(refresh=args.refresh): - print(f"{item.get('Id','')} {item.get('Name','')} {item.get('Code','')}") + print(f"{item.get('id', item.get('Id',''))} {item.get('name', item.get('Name',''))} {item.get('code', item.get('Code',''))}") return 0 def cmd_locations(args: argparse.Namespace) -> int: for item in _service().list_locations(args.account, refresh=args.refresh): - print(f"{item.get('Id','')} {item.get('Name','')}") + print(f"{item.get('id', item.get('Id',''))} {item.get('name', item.get('Name',''))}") + return 0 + + +def cmd_lookup(args: argparse.Namespace) -> int: + svc = _service() + if args.kind == "statuses": + data = svc.lookup_statuses() + elif args.kind == "priorities": + data = svc.lookup_priorities() + elif args.kind == "types": + data = svc.lookup_types() + else: + data = svc.lookup_sources() + _print(data) return 0 @@ -82,30 +126,45 @@ def cmd_tickets_create(args: argparse.Namespace) -> int: open_date=args.open_date, template_id=args.template_id, ) - missing = [] - preview = svc._merge_template(data) if data.template_id else data - for field in ["title", "details", "account_id", "location_id", "status_id", "priority_id", "type_id", "source_id"]: - value = getattr(preview, field) - if value in (None, "", 0): - missing.append(field) - if preview.queue_id in (None, "", 0) and preview.assignee_id in (None, "", 0): - missing.append("queue_id_or_assignee_id") - if missing: - raise BmsError("Missing required fields before create: " + ", ".join(missing)) - response = svc.create_ticket(data) - result = response.get("Data", response) - print(f"Created ticket ID: {result.get('Id')} — {result.get('TicketNumber', 'N/A')}") + def _op(): + preview = svc.validate_create_input(data) + response = svc.create_ticket(data) + result = response.get("Data") or response.get("result") or response + revert = {"created_ticket_id": result.get("Id") or result.get("id"), "created_ticket_number": result.get("TicketNumber") or result.get("ticketNumber")} + return response, revert + response = _run_write("tickets.create", asdict(data), _op) + result = response.get("Data") or response.get("result") or response + print(f"Created ticket ID: {result.get('Id') or result.get('id')} — {result.get('TicketNumber') or result.get('ticketNumber') or 'N/A'}") return 0 def cmd_tickets_patch(args: argparse.Namespace) -> int: + svc = _service() value = json.loads(args.value) if args.value.startswith(("{", "[", '"')) else int(args.value) if args.value.isdigit() else args.value - _print(_service().patch_ticket(args.ticket_id, args.path, value)) + def _op(): + original = svc.get_ticket(args.ticket_id) + result = svc.patch_ticket(args.ticket_id, args.path, value) + revert = {"ticket_id": args.ticket_id, "path": args.path, "old_value": _extract_patch_old_value(original, args.path), "new_value": value} + return result, revert + _print(_run_write("tickets.patch", {"ticket_id": args.ticket_id, "path": args.path, "value": value}, _op)) return 0 +def _extract_patch_old_value(ticket_payload: Any, path: str) -> Any: + current = ticket_payload.get("Data") or ticket_payload.get("result") or ticket_payload + key = path.lstrip("/") + if isinstance(current, dict): + return current.get(key) + return None + + def cmd_tickets_delete(args: argparse.Namespace) -> int: - _print(_service().delete_ticket(args.ticket_id)) + svc = _service() + def _op(): + original = svc.get_ticket(args.ticket_id) + result = svc.delete_ticket(args.ticket_id) + return result, {"deleted_ticket": original} + _print(_run_write("tickets.delete", {"ticket_id": args.ticket_id}, _op)) return 0 @@ -115,22 +174,42 @@ def cmd_notes_list(args: argparse.Namespace) -> int: def cmd_notes_add(args: argparse.Namespace) -> int: - _print(_service().add_note(args.ticket_id, message=args.message, type_id=args.type_id, internal=args.internal, status_id=args.status_id, note_date=args.note_date)) + svc = _service() + def _op(): + result = svc.add_note(args.ticket_id, message=args.message, type_id=args.type_id, internal=args.internal, status_id=args.status_id, note_date=args.note_date) + data = result.get("Data") or result.get("result") or result + return result, {"created_note_id": data.get("Id") or data.get("id"), "ticket_id": args.ticket_id} + _print(_run_write("notes.add", {"ticket_id": args.ticket_id, "message": args.message, "type_id": args.type_id, "internal": args.internal, "status_id": args.status_id, "note_date": args.note_date}, _op)) return 0 def cmd_notes_update(args: argparse.Namespace) -> int: - _print(_service().update_note(args.ticket_id, args.note_id, message=args.message, type_id=args.type_id, internal=args.internal, status_id=args.status_id, note_date=args.note_date)) + svc = _service() + def _op(): + original = svc.get_note(args.ticket_id, args.note_id) + result = svc.update_note(args.ticket_id, args.note_id, message=args.message, type_id=args.type_id, internal=args.internal, status_id=args.status_id, note_date=args.note_date) + return result, {"original_note": original, "ticket_id": args.ticket_id, "note_id": args.note_id} + _print(_run_write("notes.update", {"ticket_id": args.ticket_id, "note_id": args.note_id, "message": args.message, "type_id": args.type_id, "internal": args.internal, "status_id": args.status_id, "note_date": args.note_date}, _op)) return 0 def cmd_notes_delete(args: argparse.Namespace) -> int: - _print(_service().delete_note(args.ticket_id, args.note_id)) + svc = _service() + def _op(): + original = svc.get_note(args.ticket_id, args.note_id) + result = svc.delete_note(args.ticket_id, args.note_id) + return result, {"deleted_note": original, "ticket_id": args.ticket_id, "note_id": args.note_id} + _print(_run_write("notes.delete", {"ticket_id": args.ticket_id, "note_id": args.note_id}, _op)) return 0 def cmd_assign(args: argparse.Namespace) -> int: - _print(_service().assign_ticket(args.ticket_id, details=args.details, type_id=args.type_id, status_id=args.status_id, assignee_id=args.assignee_id, queue_id=args.queue_id, internal=args.internal, note_date=args.note_date)) + svc = _service() + def _op(): + original = svc.get_ticket(args.ticket_id) + result = svc.assign_ticket(args.ticket_id, details=args.details, type_id=args.type_id, status_id=args.status_id, assignee_id=args.assignee_id, queue_id=args.queue_id, internal=args.internal, note_date=args.note_date) + return result, {"original_ticket": original, "ticket_id": args.ticket_id} + _print(_run_write("tickets.assign", {"ticket_id": args.ticket_id, "details": args.details, "type_id": args.type_id, "status_id": args.status_id, "assignee_id": args.assignee_id, "queue_id": args.queue_id, "internal": args.internal, "note_date": args.note_date}, _op)) return 0 @@ -143,16 +222,18 @@ def build_parser() -> argparse.ArgumentParser: auth.set_defaults(func=cmd_auth) ac = sub.add_parser("accounts") - ac.add_argument("action", nargs="?", choices=["list"], default="list") ac.add_argument("--refresh", action="store_true") ac.set_defaults(func=cmd_accounts) loc = sub.add_parser("locations") - loc.add_argument("action", nargs="?", choices=["list"], default="list") loc.add_argument("--account", type=int, required=True) loc.add_argument("--refresh", action="store_true") loc.set_defaults(func=cmd_locations) + lookup = sub.add_parser("lookup") + lookup.add_argument("kind", choices=["statuses", "sources", "priorities", "types"]) + lookup.set_defaults(func=cmd_lookup) + tmpl = sub.add_parser("templates") tmpl_sub = tmpl.add_subparsers(dest="resource", required=True) t_tickets = tmpl_sub.add_parser("tickets") @@ -168,7 +249,7 @@ def build_parser() -> argparse.ArgumentParser: tsub = tickets.add_subparsers(dest="ticket_action", required=True) tl = tsub.add_parser("list") for arg in ["status", "assignee", "from_date", "to_date", "priority", "queue", "account"]: - tl.add_argument(f"--{arg.replace('_','-')}") + tl.add_argument(f"--{arg.replace('_', '-')}") tl.add_argument("--page", type=int, default=1) tl.add_argument("--page-size", type=int, default=25) tl.set_defaults(func=cmd_tickets_list) @@ -176,18 +257,10 @@ def build_parser() -> argparse.ArgumentParser: tg.add_argument("ticket_id", type=int) tg.set_defaults(func=cmd_tickets_get) tc = tsub.add_parser("create") - tc.add_argument("--template-id", type=int) + for name, typ in [("template-id", int), ("account-id", int), ("location-id", int), ("status-id", int), ("priority-id", int), ("type-id", int), ("source-id", int), ("assignee-id", int), ("queue-id", int), ("contact-id", int)]: + tc.add_argument(f"--{name}", type=typ) tc.add_argument("--title") tc.add_argument("--details") - tc.add_argument("--account-id", type=int) - tc.add_argument("--location-id", type=int) - tc.add_argument("--status-id", type=int) - tc.add_argument("--priority-id", type=int) - tc.add_argument("--type-id", type=int) - tc.add_argument("--source-id", type=int) - tc.add_argument("--assignee-id", type=int) - tc.add_argument("--queue-id", type=int) - tc.add_argument("--contact-id", type=int) tc.add_argument("--due-date") tc.add_argument("--open-date") tc.set_defaults(func=cmd_tickets_create) @@ -236,7 +309,6 @@ def build_parser() -> argparse.ArgumentParser: nd.add_argument("ticket_id", type=int) nd.add_argument("note_id", type=int) nd.set_defaults(func=cmd_notes_delete) - return p diff --git a/src/openclaw_bms/client.py b/src/openclaw_bms/client.py index 2e836ce..e8b64b8 100644 --- a/src/openclaw_bms/client.py +++ b/src/openclaw_bms/client.py @@ -27,6 +27,7 @@ class Config: client_secret: str | None = os.environ.get("BMS_CLIENT_SECRET") token_file: Path = Path(os.environ.get("BMS_TOKEN_FILE", os.path.expanduser("~/.bms_token.json"))) cache_file: Path = Path(os.environ.get("BMS_CACHE_FILE", os.path.expanduser("~/.cache/openclaw-bms/cache.json"))) + user_agent: str = os.environ.get("BMS_USER_AGENT", "openclaw-bms/0.2.1") class BmsClient: @@ -76,19 +77,28 @@ class BmsClient: refresh_token = result.get("RefreshToken") or result.get("refreshToken") or result.get("refresh_token") or "" expires_in = result.get("ExpiresIn") or result.get("expiresIn") or result.get("expires_in") expires_at = time.time() + float(expires_in) if expires_in else self._decode_exp(access_token) - self._save_json(self.config.token_file, { - "access_token": access_token, - "refresh_token": refresh_token, - "expires_at": expires_at, - }) + self._save_json( + self.config.token_file, + {"access_token": access_token, "refresh_token": refresh_token, "expires_at": expires_at}, + ) return access_token - def _request(self, method: str, path: str, *, headers: dict[str, str] | None = None, query: dict[str, Any] | None = None, json_body: Any = None, form: dict[str, Any] | None = None, auth: bool = True) -> Any: + def _request( + self, + method: str, + path: str, + *, + headers: dict[str, str] | None = None, + query: dict[str, Any] | None = None, + json_body: Any = None, + form: dict[str, Any] | None = None, + auth: bool = True, + ) -> Any: url = self.config.base_url.rstrip("/") + path if query: query = {k: v for k, v in query.items() if v is not None and v != ""} url += "?" + urllib.parse.urlencode(query, doseq=True) - req_headers = {"Accept": "application/json"} + req_headers = {"Accept": "application/json", "User-Agent": self.config.user_agent} if headers: req_headers.update(headers) data = None @@ -152,7 +162,7 @@ class BmsClient: except BmsError: return self.authenticate() - def cache_get(self, key: str, ttl_seconds: int) -> Any | None: + def cache_get(self, key: str) -> Any | None: payload = self._load_json(self.config.cache_file) or {} item = payload.get(key) if not item: diff --git a/src/openclaw_bms/service.py b/src/openclaw_bms/service.py index af0a066..f3f8e3d 100644 --- a/src/openclaw_bms/service.py +++ b/src/openclaw_bms/service.py @@ -6,7 +6,6 @@ from typing import Any from .client import BmsClient, BmsError - ACCOUNTS_TTL = 60 * 60 * 24 LOCATIONS_TTL = 60 * 60 * 24 @@ -36,10 +35,15 @@ class BmsService: def _iso_now(self) -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + def _pick_result(self, payload: Any) -> Any: + if isinstance(payload, dict): + return payload.get("Data", payload.get("data", payload.get("result", payload))) + return payload + def list_accounts(self, refresh: bool = False) -> list[dict[str, Any]]: cache_key = "accounts" if not refresh: - cached = self.client.cache_get(cache_key, ACCOUNTS_TTL) + cached = self.client.cache_get(cache_key) if cached is not None: return cached payload = self.client._request("GET", "/v2/crm/accounts/lookup") @@ -52,7 +56,7 @@ class BmsService: def list_locations(self, account_id: int, refresh: bool = False) -> list[dict[str, Any]]: cache_key = f"locations:{account_id}" if not refresh: - cached = self.client.cache_get(cache_key, LOCATIONS_TTL) + cached = self.client.cache_get(cache_key) if cached is not None: return cached payload = self.client._request("GET", f"/v2/crm/accounts/{account_id}/locations/lookup") @@ -62,6 +66,18 @@ class BmsService: self.client.cache_set(cache_key, data, LOCATIONS_TTL) return data + def lookup_statuses(self) -> Any: + return self.client._request("GET", "/v2/system/statuses/lookup") + + def lookup_priorities(self) -> Any: + return self.client._request("GET", "/v2/system/priorities/lookup") + + def lookup_types(self) -> Any: + return self.client._request("GET", "/v2/system/issuetypes/lookup") + + def lookup_sources(self) -> Any: + raise BmsError("Ticket source lookup is not exposed in the public BMS v2 Swagger. Use tenant-specific source IDs.") + def search_tickets(self, **kwargs: Any) -> Any: filter_obj = {} mapping = { @@ -81,16 +97,17 @@ class BmsService: elif arg == "to_date": val = f"{val}T23:59:59" filter_obj[api_name] = val - page = int(kwargs.get("page", 1)) - page_size = int(kwargs.get("page_size", 25)) - return self.client._request("POST", "/v2/servicedesk/tickets/search", json_body={"Filter": filter_obj, "PageNumber": page, "PageSize": page_size}) + return self.client._request( + "POST", + "/v2/servicedesk/tickets/search", + json_body={"Filter": filter_obj, "PageNumber": int(kwargs.get("page", 1)), "PageSize": int(kwargs.get("page_size", 25))}, + ) def get_ticket(self, ticket_id: int) -> Any: return self.client._request("GET", f"/v2/servicedesk/tickets/{ticket_id}") def get_template(self, template_id: int) -> dict[str, Any]: - payload = self.client._request("GET", f"/v2/servicedesk/templates/tickets/{template_id}") - return payload.get("Data", payload) + return self._pick_result(self.client._request("GET", f"/v2/servicedesk/templates/tickets/{template_id}")) def list_ticket_templates(self) -> Any: return self.client._request("GET", "/v2/servicedesk/templates/tickets/lookup") @@ -122,7 +139,7 @@ class BmsService: template_id=data.template_id, ) - def create_ticket(self, data: CreateTicketInput) -> dict[str, Any]: + def validate_create_input(self, data: CreateTicketInput) -> CreateTicketInput: merged = self._merge_template(data) required_missing = [] for field in ["title", "details", "account_id", "location_id", "status_id", "priority_id", "type_id", "source_id"]: @@ -133,6 +150,10 @@ class BmsService: required_missing.append("queue_id_or_assignee_id") if required_missing: raise BmsError("Missing required fields before create: " + ", ".join(required_missing)) + return merged + + def create_ticket(self, data: CreateTicketInput) -> dict[str, Any]: + merged = self.validate_create_input(data) payload = { "Title": merged.title, "Details": merged.details, @@ -153,9 +174,11 @@ class BmsService: if merged.due_date: payload["DueDate"] = merged.due_date response = self.client._request("POST", "/v2/servicedesk/tickets", json_body=payload) - success = response.get("success", response.get("Success")) - result = response.get("Data", response) - ticket_id = result.get("Id") if isinstance(result, dict) else None + result = self._pick_result(response) + success = response.get("success", response.get("Success", True)) if isinstance(response, dict) else True + ticket_id = None + if isinstance(result, dict): + ticket_id = result.get("Id") or result.get("id") if success is not True or not ticket_id: raise BmsError(f"Create ticket failed or returned ambiguous response: {response}") return response @@ -163,24 +186,13 @@ class BmsService: def add_note(self, ticket_id: int, *, message: str, type_id: int = 1, internal: bool = False, status_id: int | None = None, note_date: str | None = None) -> Any: if not message: raise BmsError("message is required") - payload = { - "Details": message, - "IsInternal": internal, - "TypeId": int(type_id), - "NoteDate": note_date or self._iso_now(), - } + payload = {"Details": message, "IsInternal": internal, "TypeId": int(type_id), "NoteDate": note_date or self._iso_now()} if status_id is not None: payload["StatusId"] = int(status_id) return self.client._request("POST", f"/v2/servicedesk/tickets/{ticket_id}/notes", json_body=payload) def update_note(self, ticket_id: int, note_id: int, *, message: str, type_id: int = 1, internal: bool = False, status_id: int | None = None, note_date: str | None = None) -> Any: - payload = { - "Id": int(note_id), - "Details": message, - "IsInternal": internal, - "TypeId": int(type_id), - "NoteDate": note_date or self._iso_now(), - } + payload = {"Id": int(note_id), "Details": message, "IsInternal": internal, "TypeId": int(type_id), "NoteDate": note_date or self._iso_now()} if status_id is not None: payload["StatusId"] = int(status_id) return self.client._request("PUT", f"/v2/servicedesk/tickets/{ticket_id}/notes/{note_id}", json_body=payload) @@ -191,14 +203,11 @@ class BmsService: def delete_note(self, ticket_id: int, note_id: int) -> Any: return self.client._request("DELETE", f"/v2/servicedesk/tickets/{ticket_id}/notes/{note_id}") + def get_note(self, ticket_id: int, note_id: int) -> Any: + return self.client._request("GET", f"/v2/servicedesk/tickets/{ticket_id}/notes/{note_id}") + def assign_ticket(self, ticket_id: int, *, details: str, type_id: int, status_id: int, assignee_id: int | None = None, queue_id: int | None = None, internal: bool = False, note_date: str | None = None) -> Any: - payload = { - "Details": details, - "IsInternal": internal, - "TypeId": int(type_id), - "StatusId": int(status_id), - "NoteDate": note_date or self._iso_now(), - } + payload = {"Details": details, "IsInternal": internal, "TypeId": int(type_id), "StatusId": int(status_id), "NoteDate": note_date or self._iso_now()} if assignee_id is not None: payload["AssigneeId"] = int(assignee_id) if queue_id is not None: