From 59d6e5ba3a5439c8b5e3eecf0c530eb3b4516957 Mon Sep 17 00:00:00 2001 From: Steve W Date: Wed, 8 Apr 2026 02:19:50 +0000 Subject: [PATCH] Rewrite BMS skill from bash to Python --- .gitignore | 2 + README.md | 160 ++++++++++ SKILL.md | 232 ++++++-------- pyproject.toml | 16 + scripts/bms-accounts.sh | 23 +- scripts/bms-auth.sh | 154 +-------- scripts/bms-locations.sh | 33 +- scripts/bms-logging.sh | 0 scripts/bms-lookup.sh | 106 +------ scripts/bms-templates.sh | 185 +---------- scripts/bms-tickets.sh | 590 +---------------------------------- scripts/bms.sh | 89 +----- src/openclaw_bms/__init__.py | 2 + src/openclaw_bms/__main__.py | 4 + src/openclaw_bms/cli.py | 249 +++++++++++++++ src/openclaw_bms/client.py | 167 ++++++++++ src/openclaw_bms/service.py | 212 +++++++++++++ 17 files changed, 938 insertions(+), 1286 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml mode change 100644 => 100755 scripts/bms-auth.sh mode change 100644 => 100755 scripts/bms-logging.sh mode change 100644 => 100755 scripts/bms-tickets.sh create mode 100644 src/openclaw_bms/__init__.py create mode 100644 src/openclaw_bms/__main__.py create mode 100644 src/openclaw_bms/cli.py create mode 100644 src/openclaw_bms/client.py create mode 100644 src/openclaw_bms/service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..641a444 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# 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 + +## Installation + +```bash +python3 -m pip install -e . +``` + +Or run directly from the repo: + +```bash +bash scripts/bms.sh --help +``` + +## Configuration + +```bash +export BMS_TENANT="your-tenant" +export BMS_USERNAME="your-user" +export BMS_PASSWORD="your-password" +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 + +### Accounts and Locations + +Locations are account-scoped. + +Use: + +```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 --refresh +``` + +### Tickets + +List/search: + +```bash +bms tickets list --status Open --assignee "Jane Doe" +``` + +Create: + +```bash +bms tickets create \ + --title "Server down" \ + --details "Main server offline" \ + --account-id 123 \ + --location-id 456 \ + --status-id 1 \ + --priority-id 2 \ + --type-id 3 \ + --source-id 1 \ + --queue-id 9 \ + --open-date 2026-04-07T14:00:00+00:00 +``` + +Template-based create: + +```bash +bms tickets create \ + --template-id 7 \ + --title "Override title" \ + --account-id 123 \ + --location-id 456 \ + --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 + +### Notes + +List notes: + +```bash +bms notes list 33919447 +``` + +Add a note with a custom date: + +```bash +bms notes add 33919447 --message "Backfilled note" --note-date 2026-04-01T15:00:00+00:00 +``` + +Update a note with a custom date: + +```bash +bms notes update 33919447 1001 --message "Corrected note" --note-date 2026-04-01T16:00:00+00:00 +``` + +Delete a note: + +```bash +bms notes delete 33919447 1001 +``` + +## Architectural decisions + +- Python standard library only + - avoids packaging friction for a personal skill +- 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 diff --git a/SKILL.md b/SKILL.md index 58f284d..825ac30 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,190 +1,146 @@ # BMS Skill — Kaseya BMS Ticket Management -Manage service desk tickets in Kaseya BMS (Business Management Solution) via the BMS API v2. +Python-based OpenClaw skill for Kaseya BMS ticket and note workflows. + +## Scope + +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 ## Configuration -Required environment variables (store in shell profile or a secrets manager): - ```bash -export BMS_TENANT="your-tenant-name" # Your BMS tenant/subdomain -export BMS_USERNAME="user@example.com" # BMS login username -export BMS_PASSWORD="yourpassword" # BMS login password -# Or use client credentials (OAuth2): -export BMS_CLIENT_ID="your-client-id" -export BMS_CLIENT_SECRET="your-client-secret" +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_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" ``` -Tokens are cached automatically at `~/.bms_token.json`. - ## Commands -All commands route through `scripts/bms.sh`. Run without arguments for usage. - -### Authentication +Primary entrypoint: ```bash -bms auth # Authenticate and cache token -bms auth --status # Show token status / expiry +bash scripts/bms.sh --help ``` -### Listing Tickets +### Auth ```bash -bms tickets list # All open tickets (paginated) -bms tickets list --status "Open" # Filter by status name -bms tickets list --assignee "John Smith" # Filter by assignee name -bms tickets list --from 2024-01-01 --to 2024-01-31 # Filter by created date range -bms tickets list --priority "High" # Filter by priority -bms tickets list --queue "Support" # Filter by queue -bms tickets list --account "Acme Corp" # Filter by account -bms tickets list --page 2 --page-size 50 # Pagination -bms tickets list --format json # Raw JSON output +bms auth login +bms auth refresh +bms auth status ``` -### Getting a Ticket +### Accounts and Locations ```bash -bms tickets get # Get full ticket details -bms tickets get --json # Raw JSON +bms accounts +bms accounts --refresh +bms locations --account 12345 +bms locations --account 12345 --refresh ``` -### Creating Tickets +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 + +### Tickets ```bash -bms tickets create \ - --title "Server is down" \ - --details "The main server stopped responding at 2pm" \ - --account-id 123 \ - --location-id 456 \ - --status-id 1 \ - --priority-id 2 \ - --type-id 1 \ - --source-id 1 \ - --assignee-id 789 +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 --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 ``` -Create from a **template** (pre-fills fields; CLI overrides take precedence): +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 -bms tickets create --template-id 7 --account-id 123 --location-id 456 -# Fields from template 7 are used; only account/location are overridden. -# Any required field still missing triggers an interactive prompt. +bms notes list 12345 +bms notes add 12345 --message "Investigating" --note-date 2026-04-07T12:00:00+00:00 +bms notes update 12345 999 --message "Corrected note" --note-date 2026-04-07T13:00:00+00:00 +bms notes delete 12345 999 ``` -Or use fully interactive mode (prompts for all required fields): - -```bash -bms tickets create --interactive -``` - -### Updating Tickets - -```bash -bms tickets update --status-id 3 # Change status -bms tickets update --assignee-id 789 # Reassign -bms tickets update --priority-id 1 # Change priority -bms tickets update --title "New title" # Update title -``` - -### Adding Notes - -```bash -bms tickets note --message "Called client, investigating" -bms tickets note --message "Internal update" --internal -bms tickets note --message "Resolved via restart" --status-id 5 -``` - -### Assigning Tickets - -```bash -bms tickets assign --assignee-id 789 --note "Routing to tier 2" -bms tickets assign --queue-id 3 -``` - -### Resolving Tickets - -```bash -bms tickets resolve --comment "Replaced failed drive, server is back online" -bms tickets resolve --comment "Fixed" --status-id 6 --publish-kb -``` - -### Deleting Tickets - -```bash -bms tickets delete # Delete single ticket -bms tickets delete 123 456 789 # Delete multiple tickets -``` - -### Lookup Tables (for getting valid IDs) - -```bash -bms lookup statuses # List all ticket statuses with IDs -bms lookup priorities # List all priorities with IDs -bms lookup queues # List all queues with IDs -bms lookup issue-types # List all issue types -bms lookup assignees # List all assignees/technicians -bms accounts # List CRM accounts (Id, Name, Code) -bms locations --account 123 # List CRM locations for account 123 -bms lookup ticket-types # Not exposed in public BMS v2 Swagger for all tenants -bms lookup sources # Not exposed in public BMS v2 Swagger for all tenants -``` +Features: +- custom note dates supported for create and update +- note CRUD exposed directly in the Python CLI ### Templates -Browse pre-defined ticket, note, and timelog templates configured in BMS. - ```bash -bms templates tickets list # List all ticket templates (Id, Name, QueueId, PriorityId, etc.) -bms templates tickets get # Inspect a specific ticket template (raw JSON) -bms templates notes list # List all note templates -bms templates timelogs list # List all timelog templates - -# Add --format json to any list command for raw JSON output -bms templates tickets list --format json +bms templates tickets list +bms templates tickets get 9 +bms templates notes list +bms templates timelogs list ``` -## Template-Based Ticket Creation +Templates are read-only. -`bms tickets create --template-id ` does the following: +## Endpoints used -1. Fetches `GET /v2/servicedesk/templates/tickets/{templateId}` to retrieve template defaults. -2. Merges them with any CLI overrides you provide (`--title`, `--description`, `--account-id`, `--location-id`, `--status-id`, `--priority-id`, `--type-id`, `--source-id`, `--assignee-id`, `--queue-id`, `--due-date`, `--contact-id`). CLI values always win. -3. Prompts interactively (via stdin) for any required field still missing after the merge. -4. Posts the final payload to `POST /v2/servicedesk/tickets`. +Auth: +- `POST /v2/security/authenticate` +- `POST /v2/security/refreshtoken` -Use `bms templates tickets list` to see available template IDs before creating. +CRM lookup: +- `GET /v2/crm/accounts/lookup` +- `GET /v2/crm/accounts/{accountId}/locations/lookup` -## Notes / Quirks +Tickets: +- `POST /v2/servicedesk/tickets/search` +- `GET /v2/servicedesk/tickets/{ticketId}` +- `POST /v2/servicedesk/tickets` +- `PATCH /v2/servicedesk/tickets/{ticketId}` +- `DELETE /v2/servicedesk/tickets/{ticketId}` +- `POST /v2/servicedesk/tickets/{ticketId}/assignticket` -- **Auth**: BMS uses JWT Bearer tokens obtained via `POST /v2/security/authenticate` with `GrantType=password`. Tokens expire; the skill auto-refreshes using `POST /v2/security/refreshtoken`. -- **Required fields for ticket creation**: Title, Details, AccountId, LocationId, StatusId, PriorityId, TypeId, SourceId, OpenDate — all are required by the API schema. -- **IDs not names**: The API uses integer IDs for status, priority, type, etc. Use `bms lookup`, `bms accounts`, and `bms locations` to find the right IDs for your tenant. -- **Search vs GET list**: For filtered searches, `POST /v2/servicedesk/tickets/search` (with body) is more flexible than `GET /v2/servicedesk/tickets` (with query params); this skill uses the POST search by default. -- **Pagination**: Default page size is 25. Use `--page-size` (max appears to be 100) and `--page` for large result sets. -- **Date format**: Dates should be ISO 8601 strings, e.g. `2024-01-15T00:00:00`. -- **Note TypeId**: Required when posting notes. The public BMS v2 Swagger does not clearly expose a generic note-type lookup endpoint for all tenants, so you may need tenant-specific documentation or known values. -- **Rate limits**: Not documented in the Swagger spec. Treat as standard REST API — avoid tight loops; add a small sleep between bulk operations. +Notes: +- `GET /v2/servicedesk/tickets/{ticketId}/notes` +- `POST /v2/servicedesk/tickets/{ticketId}/notes` +- `PUT /v2/servicedesk/tickets/{ticketId}/notes/{noteId}` +- `DELETE /v2/servicedesk/tickets/{ticketId}/notes/{noteId}` -## Note Type IDs (Grand Portage Tenant) +Templates: +- `GET /v2/servicedesk/templates/tickets/lookup` +- `GET /v2/servicedesk/templates/tickets/{templateId}` +- `GET /v2/servicedesk/templates/notes/lookup` +- `GET /v2/servicedesk/templates/timelogs/lookup` -Based on testing for Grand Portage, the following note type IDs are valid: +## 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 -Note: These are tenant-specific and may differ in other BMS deployments. The public Swagger does not fully expose note type lookups. +These are tenant-specific and may differ elsewhere. -## References +## Implementation notes -- [BMS API Swagger UI](https://api.bms.kaseya.com/swagger/index.html) -- [BMS API Swagger JSON](https://api.bms.kaseya.com/swagger/v2/swagger.json) -- `references/key-schemas.md` — TicketInputDto, filter, and note schemas -- `scripts/bms.sh` — Main CLI entrypoint -- `scripts/bms-auth.sh` — Auth and token management -- `scripts/bms-tickets.sh` — Ticket CRUD operations (includes `--template-id` support) -- `scripts/bms-lookup.sh` — Lookup table helpers -- `scripts/bms-templates.sh` — Template listing commands (tickets, notes, timelogs) +- 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 new file mode 100644 index 0000000..8f59451 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "openclaw-bms" +version = "0.2.0" +description = "Python-based OpenClaw skill for Kaseya BMS ticket and note workflows." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +bms = "openclaw_bms.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/scripts/bms-accounts.sh b/scripts/bms-accounts.sh index 60e395f..83afcc2 100755 --- a/scripts/bms-accounts.sh +++ b/scripts/bms-accounts.sh @@ -1,23 +1,6 @@ #!/usr/bin/env bash set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Get token from bms-auth.sh -if ! token=$(bash "${SCRIPT_DIR}/bms-auth.sh" get-token 2>/dev/null); then - echo "Error: Failed to retrieve BMS token" >&2 - exit 1 -fi - -curl --silent --show-error --fail \ - -H "Authorization: Bearer $token" \ - "https://api.bms.kaseya.com/v2/crm/accounts/lookup" | \ - jq -r '.result[]? // .[]' | \ - while IFS= read -r line; do - id=$(echo "$line" | jq -r '.Id') - name=$(echo "$line" | jq -r '.Name') - code=$(echo "$line" | jq -r '.Code') - printf "%-10s %-40s %-20s\n" "$id" "$name" "$code" - done - -exit 0 +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 old mode 100644 new mode 100755 index 26074d1..ad6221f --- a/scripts/bms-auth.sh +++ b/scripts/bms-auth.sh @@ -1,153 +1,7 @@ #!/usr/bin/env bash -# bms-auth.sh — Kaseya BMS authentication helper -# Obtains and caches JWT tokens. Called by bms.sh. - set -euo pipefail - -# Import logging SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/bms-logging.sh" - -BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" -BMS_TOKEN_FILE="${BMS_TOKEN_FILE:-$HOME/.bms_token.json}" - -# ─── Helpers ──────────────────────────────────────────────────────────────── - -die() { echo "ERROR: $*" >&2; exit 1; } - -require_env() { - local var="$1" - [[ -n "${!var:-}" ]] || die "Environment variable $var is required. See SKILL.md for setup." -} - -token_is_valid() { - # Returns 0 (true) if cached token exists and has not expired (with 60s buffer) - [[ -f "$BMS_TOKEN_FILE" ]] || return 1 - local exp - exp=$(jq -r '.expires_at // 0' "$BMS_TOKEN_FILE" 2>/dev/null) || return 1 - local now - now=$(date +%s) - [[ $((exp - 60)) -gt $now ]] -} - -save_token() { - local response="$1" - local access_token refresh_token expires_in expires_at - access_token=$(echo "$response" | jq -r '.result.AccessToken // .result.accessToken // .result.access_token // empty') - refresh_token=$(echo "$response" | jq -r '.result.RefreshToken // .result.refreshToken // .result.refresh_token // empty') - expires_in=$(echo "$response" | jq -r '.result.ExpiresIn // .result.expires_in // 3600') - expires_at=$(( $(date +%s) + expires_in )) - - [[ -n "$access_token" ]] || die "No access token in auth response: $response" - - jq -n \ - --arg at "$access_token" \ - --arg rt "${refresh_token:-}" \ - --argjson ea "$expires_at" \ - '{access_token: $at, refresh_token: $rt, expires_at: $ea}' \ - > "$BMS_TOKEN_FILE" - chmod 600 "$BMS_TOKEN_FILE" -} - -# ─── Auth Actions ──────────────────────────────────────────────────────────── - -cmd_auth_login() { - require_env BMS_TENANT - - local response - - if [[ -n "${BMS_CLIENT_ID:-}" && -n "${BMS_CLIENT_SECRET:-}" ]]; then - # OAuth2 client credentials flow - echo "Authenticating with client credentials..." >&2 - response=$(curl -sf -X POST "${BMS_API_BASE}/v2/security/authenticate" \ - -F "GrantType=client_credentials" \ - -F "ClientId=${BMS_CLIENT_ID}" \ - -F "ClientSecret=${BMS_CLIENT_SECRET}" \ - -F "Tenant=${BMS_TENANT}") || die "Authentication request failed" - else - # Password flow - require_env BMS_USERNAME - require_env BMS_PASSWORD - echo "Authenticating with username/password..." >&2 - local curl_args=(-s -X POST "${BMS_API_BASE}/v2/security/authenticate" \ - -F "GrantType=password" \ - -F "UserName=${BMS_USERNAME}" \ - -F "Password=${BMS_PASSWORD}" \ - -F "Tenant=${BMS_TENANT}") - [[ -n "${BMS_MFA_CODE:-}" ]] && curl_args+=(-F "MFACode=${BMS_MFA_CODE}") - response=$(curl "${curl_args[@]}") || die "Authentication request failed" - fi - - save_token "$response" - echo "Authenticated successfully. Token cached at $BMS_TOKEN_FILE" >&2 - - # Log successful login - local args_json - args_json=$(jq -n \ - --arg tenant "${BMS_TENANT}" \ - --arg username "${BMS_USERNAME}" \ - '{"tenant": $tenant, "username": $username}') - local result_json='{"success": true}' - log_action "auth.login" "$args_json" "$result_json" "success" -} - -cmd_auth_refresh() { - [[ -f "$BMS_TOKEN_FILE" ]] || die "No cached token. Run: bms auth" - local access_token refresh_token - access_token=$(jq -r '.access_token' "$BMS_TOKEN_FILE") - refresh_token=$(jq -r '.refresh_token // empty' "$BMS_TOKEN_FILE") - - [[ -n "$refresh_token" ]] || { cmd_auth_login; return; } - - local response - response=$(curl -sf -X POST "${BMS_API_BASE}/v2/security/refreshtoken" \ - -F "AccessToken=${access_token}" \ - -F "RefreshToken=${refresh_token}") \ - || { echo "Refresh failed, re-authenticating..." >&2; cmd_auth_login; return; } - - save_token "$response" - echo "Token refreshed." >&2 - - # Log token refresh - local result_json='{"success": true}' - log_action "auth.refresh" "{}" "$result_json" "success" -} - -cmd_auth_status() { - if [[ ! -f "$BMS_TOKEN_FILE" ]]; then - echo "No token cached." - return - fi - local expires_at now remaining - expires_at=$(jq -r '.expires_at // 0' "$BMS_TOKEN_FILE") - now=$(date +%s) - remaining=$((expires_at - now)) - if [[ $remaining -gt 0 ]]; then - echo "Token valid. Expires in ${remaining}s (at $(date -d "@${expires_at}" 2>/dev/null || date -r "${expires_at}" 2>/dev/null || echo "unknown"))" - else - echo "Token expired ${remaining#-}s ago." - fi -} - -# ─── Public: get_token ─────────────────────────────────────────────────────── -# Prints the current access token, refreshing/authenticating as needed. -get_token() { - if ! token_is_valid; then - if [[ -f "$BMS_TOKEN_FILE" ]]; then - cmd_auth_refresh - else - cmd_auth_login - fi - fi - jq -r '.access_token' "$BMS_TOKEN_FILE" -} - -# ─── Dispatch ──────────────────────────────────────────────────────────────── - -case "${1:-}" in - login|"") cmd_auth_login ;; - refresh) cmd_auth_refresh ;; - status) cmd_auth_status ;; - get-token) get_token ;; - *) echo "Usage: bms-auth.sh [login|refresh|status|get-token]" >&2; exit 1 ;; -esac +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 index 33ad840..2c1273b 100755 --- a/scripts/bms-locations.sh +++ b/scripts/bms-locations.sh @@ -1,33 +1,6 @@ #!/usr/bin/env bash set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Get token from bms-auth.sh -if ! token=$(bash "${SCRIPT_DIR}/bms-auth.sh" get-token 2>/dev/null); then - echo "Error: Failed to retrieve BMS token" >&2 - exit 1 -fi - -case "${1}" in - --account|--account=*) - account_id="${1#*=}" || account_id="${2}" - shift $(($# > 1 ? 2 : 1)) - ;; - *) - echo "Usage: $0 --account " >&2 - exit 1 - ;; -esac - -curl --silent --show-error --fail \ - -H "Authorization: Bearer $token" \ - "https://api.bms.kaseya.com/v2/crm/accounts/${account_id}/locations/lookup" | \ - jq -r '.result[]? // .[]' | \ - while IFS= read -r line; do - id=$(echo "$line" | jq -r '.Id') - name=$(echo "$line" | jq -r '.Name') - printf "%-10s %-40s\n" "$id" "$name" - done - -exit 0 +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 old mode 100644 new mode 100755 diff --git a/scripts/bms-lookup.sh b/scripts/bms-lookup.sh index 9890fad..d3482cc 100755 --- a/scripts/bms-lookup.sh +++ b/scripts/bms-lookup.sh @@ -1,96 +1,16 @@ #!/usr/bin/env bash -# bms-lookup.sh — Fetch lookup tables (statuses, priorities, queues, etc.) - set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" - -die() { echo "ERROR: $*" >&2; exit 1; } - -get_token() { - bash "${SCRIPT_DIR}/bms-auth.sh" get-token -} - -bms_curl() { - local path="$1"; shift - local token - token=$(get_token) - curl -sf -X GET \ - "${BMS_API_BASE}${path}" \ - -H "Authorization: Bearer ${token}" \ - -H "Accept: application/json" \ - "$@" -} - -format_lookup() { - jq -r ' - (.Data // .Items // .) | - if type == "array" then .[] - else . - end | - "\(.Id // .id)\t\(.Name // .name // .Text // .text // "(unnamed)")" - ' | sort -n | column -t -s $'\t' -} - -cmd_lookup() { - local table="$1" - case "$table" in - statuses|status) - echo "=== Ticket Statuses ===" >&2 - bms_curl "/v2/system/statuses/lookup" | format_lookup - ;; - priorities|priority) - echo "=== Priorities ===" >&2 - bms_curl "/v2/system/priorities/lookup" | format_lookup - ;; - queues|queue) - echo "=== Queues ===" >&2 - bms_curl "/v2/system/queues/lookup" | format_lookup - ;; - issue-types|issuetypes) - echo "=== Issue Types ===" >&2 - bms_curl "/v2/system/issuetypes/lookup" | format_lookup - ;; - sources|source) - die "Ticket source lookup endpoint is not exposed in the public BMS v2 Swagger. Use tenant-specific documentation or known SourceId values." - ;; - ticket-types|tickettypes) - die "Ticket type lookup endpoint is not exposed in the public BMS v2 Swagger. Use tenant-specific documentation or known TypeId values." - ;; - assignees|assignee|technicians) - echo "=== Assignees / Technicians ===" >&2 - bms_curl "/v2/hr/assignees/lookup" | format_lookup - ;; - slas|sla) - echo "=== SLAs ===" >&2 - bms_curl "/v2/system/slas/lookup" | format_lookup - ;; - work-types|worktypes) - echo "=== Work Types ===" >&2 - bms_curl "/v2/system/worktypes/lookup" | format_lookup - ;; - note-types|notetypes) - die "Note type lookup endpoint is not exposed in the public BMS v2 Swagger. Use tenant-specific documentation or known note TypeId values." - ;; - all) - cmd_lookup statuses - echo - cmd_lookup priorities - echo - cmd_lookup queues - echo - cmd_lookup issue-types - echo - cmd_lookup assignees - ;; - *) - die "Unknown lookup table: $table -Available: statuses, priorities, queues, issue-types, sources, ticket-types, assignees, slas, work-types, note-types, all" - ;; - esac -} - -table="${1:-}" -[[ -n "$table" ]] || die "Usage: bms lookup " -cmd_lookup "$table" +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 index 7693839..4ae310d 100755 --- a/scripts/bms-templates.sh +++ b/scripts/bms-templates.sh @@ -1,185 +1,6 @@ #!/usr/bin/env bash -# bms-templates.sh — Kaseya BMS template lookups (tickets, notes, timelogs) - set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" - -# ─── Helpers ───────────────────────────────────────────────────────────────── - -die() { echo "ERROR: $*" >&2; exit 1; } - -get_token() { - bash "${SCRIPT_DIR}/bms-auth.sh" get-token -} - -bms_curl() { - local method="$1"; shift - local path="$1"; shift - local token - token=$(get_token) - curl -sf -X "$method" \ - "${BMS_API_BASE}${path}" \ - -H "Authorization: Bearer ${token}" \ - -H "Accept: application/json" \ - -H "Content-Type: application/json" \ - "$@" -} - -# ─── Formatters ────────────────────────────────────────────────────────────── - -format_template_list() { - jq -r ' - (.Data // .Items // .) | - if type == "array" then .[] else . end | - [ - (.Id // "-"), - (.Name // "(unnamed)"), - ("Q:" + ((.QueueId // "-") | tostring)), - ("Pri:" + ((.PriorityId // "-") | tostring)), - ("Type:" + ((.IssueTypeId // .TypeId // "-") | tostring)) - ] | @tsv - ' | sort -n | column -t -s $'\t' -} - -format_simple_lookup() { - # Generic: Id + Name for note/timelog templates - jq -r ' - (.Data // .Items // .) | - if type == "array" then .[] else . end | - "\(.Id // "-")\t\(.Name // .Text // "(unnamed)")" - ' | sort -n | column -t -s $'\t' -} - -# ─── Templates: Tickets ─────────────────────────────────────────────────────── - -cmd_templates_tickets_list() { - local format="table" - while [[ $# -gt 0 ]]; do - case "$1" in - --format|-f) format="$2"; shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - echo "=== Ticket Templates ===" >&2 - local response - response=$(bms_curl GET "/v2/servicedesk/templates/tickets/lookup") - - if [[ "$format" == "json" ]]; then - echo "$response" | jq . - else - echo "$response" | format_template_list - fi -} - -cmd_templates_tickets_get() { - local template_id="${1:-}" - [[ -n "$template_id" ]] || die "Usage: bms templates tickets get " - - bms_curl GET "/v2/servicedesk/templates/tickets/${template_id}" | jq . -} - -# ─── Templates: Notes ──────────────────────────────────────────────────────── - -cmd_templates_notes_list() { - local format="table" - while [[ $# -gt 0 ]]; do - case "$1" in - --format|-f) format="$2"; shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - echo "=== Note Templates ===" >&2 - local response - response=$(bms_curl GET "/v2/servicedesk/templates/notes/lookup") - - if [[ "$format" == "json" ]]; then - echo "$response" | jq . - else - echo "$response" | format_simple_lookup - fi -} - -# ─── Templates: Timelogs ───────────────────────────────────────────────────── - -cmd_templates_timelogs_list() { - local format="table" - while [[ $# -gt 0 ]]; do - case "$1" in - --format|-f) format="$2"; shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - echo "=== Timelog Templates ===" >&2 - local response - response=$(bms_curl GET "/v2/servicedesk/templates/timelogs/lookup") - - if [[ "$format" == "json" ]]; then - echo "$response" | jq . - else - echo "$response" | format_simple_lookup - fi -} - -# ─── Dispatch ──────────────────────────────────────────────────────────────── - -usage_templates() { - cat >&2 <<'EOF' -Usage: bms templates [options] - -Resources and subcommands: - tickets list List all ticket templates - tickets get Get full details for a ticket template - notes list List all note templates - timelogs list List all timelog templates - -Options: - --format json Output raw JSON instead of table - -Examples: - bms templates tickets list - bms templates tickets get 42 - bms templates notes list - bms templates timelogs list -EOF - exit 1 -} - -resource="${1:-}" -[[ -n "$resource" ]] || usage_templates -shift - -case "$resource" in - tickets|ticket) - subcmd="${1:-list}" - [[ $# -gt 0 ]] && shift - case "$subcmd" in - list) cmd_templates_tickets_list "$@" ;; - get) cmd_templates_tickets_get "$@" ;; - *) die "Unknown tickets template subcommand: $subcmd (available: list, get)" ;; - esac - ;; - notes|note) - subcmd="${1:-list}" - [[ $# -gt 0 ]] && shift - case "$subcmd" in - list) cmd_templates_notes_list "$@" ;; - *) die "Unknown notes template subcommand: $subcmd (available: list)" ;; - esac - ;; - timelogs|timelog) - subcmd="${1:-list}" - [[ $# -gt 0 ]] && shift - case "$subcmd" in - list) cmd_templates_timelogs_list "$@" ;; - *) die "Unknown timelogs template subcommand: $subcmd (available: list)" ;; - esac - ;; - *) - die "Unknown template resource: $resource (available: tickets, notes, timelogs)" - ;; -esac +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 old mode 100644 new mode 100755 index 5202504..7289958 --- a/scripts/bms-tickets.sh +++ b/scripts/bms-tickets.sh @@ -1,590 +1,6 @@ #!/usr/bin/env bash -# bms-tickets.sh — Kaseya BMS ticket CRUD operations - set -euo pipefail - -# Import logging SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/bms-logging.sh" - -BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" - -# ─── Helpers ───────────────────────────────────────────────────────────────── - -die() { echo "ERROR: $*" >&2; exit 1; } - -get_token() { - bash "${SCRIPT_DIR}/bms-auth.sh" get-token -} - -bms_curl() { - local method="$1"; shift - local path="$1"; shift - local token - token=$(get_token) - - curl -sf -X "$method" \ - "${BMS_API_BASE}${path}" \ - -H "Authorization: Bearer ${token}" \ - -H "Content-Type: application/json" \ - "$@" -} - -# Pretty-print a ticket list -format_ticket_list() { - jq -r ' - (.result // .) | - if type == "array" then .[] else empty end | - "\(.ticketNumber // .Id)\t[\(.statusName // "?")] \(.title // "?")\t| \(.accountName // "?")\t| Assignee: \(.assigneeName // "unassigned")\t| Priority: \(.priorityName // "?")" - ' | sed 's/\t/ /g' -} - -format_ticket_detail() { - jq -r ' - .Data // . | - "Ticket: \(.TicketNumber) (ID: \(.Id)) -Title: \(.Title) -Status: \(.StatusName) (ID: \(.StatusId)) -Priority: \(.PriorityName) (ID: \(.PriorityId)) -Account: \(.AccountName) (ID: \(.AccountId)) -Location: \(.LocationName // "N/A") -Contact: \(.ContactName // "N/A") -Queue: \(.QueueName // "N/A") -Assignee: \(.AssigneeName // "unassigned") -Type: \(.TypeName // "N/A") -Created: \(.CreatedOn) -Modified: \(.ModifiedOn) -Due: \(.DueDate // "none") ---- -\(.Details // "(no details)")" - ' -} - -# ─── Commands ──────────────────────────────────────────────────────────────── - -cmd_list() { - local status="" assignee="" from="" to="" priority="" queue="" account="" - local page=1 page_size=25 format="table" - - while [[ $# -gt 0 ]]; do - case "$1" in - --status) status="$2"; shift 2 ;; - --assignee) assignee="$2"; shift 2 ;; - --from) from="$2"; shift 2 ;; - --to) to="$2"; shift 2 ;; - --priority) priority="$2"; shift 2 ;; - --queue) queue="$2"; shift 2 ;; - --account) account="$2"; shift 2 ;; - --page) page="$2"; shift 2 ;; - --page-size) page_size="$2"; shift 2 ;; - --format) format="$2"; shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - # Build filter JSON - local filter="{" - local sep="" - if [[ "$status" == "open" ]]; then - filter+="\"StatusNames\":\"Escalated,Open,Waiting for Customer,Waiting for Product(s),Waiting for Vendor\"" - sep="," - status="" - fi - [[ -n "$status" ]] && { filter+="${sep}\"StatusNames\":\"${status}\""; sep=","; } - [[ -n "$assignee" ]] && { filter+="${sep}\"AssigneeName\":\"${assignee}\""; sep=","; } - [[ -n "$from" ]] && { filter+="${sep}\"CreatedOnFrom\":\"${from}T00:00:00\""; sep=","; } - [[ -n "$to" ]] && { filter+="${sep}\"CreatedOnTo\":\"${to}T23:59:59\""; sep=","; } - [[ -n "$priority" ]] && { filter+="${sep}\"PriorityNames\":\"${priority}\""; sep=","; } - [[ -n "$queue" ]] && { filter+="${sep}\"QueueNames\":\"${queue}\""; sep=","; } - [[ -n "$account" ]] && { filter+="${sep}\"Account\":\"${account}\""; sep=","; } - filter+="}" - - local body - body=$(jq -n \ - --argjson filter "$filter" \ - --argjson page "$page" \ - --argjson page_size "$page_size" \ - '{Filter: $filter, PageNumber: $page, PageSize: $page_size}') - - local response - response=$(bms_curl POST "/v2/servicedesk/tickets/search" -d "$body") - - if [[ "$format" == "json" ]]; then - echo "$response" | jq . - else - local total - total=$(echo "$response" | jq -r '.TotalCount // .Total // "?"') - echo "Tickets (page ${page}, ${page_size} per page, total: ${total}):" >&2 - echo "$response" | format_ticket_list - fi -} - -cmd_get() { - local ticket_id="${1:-}" - local format="${2:-table}" - [[ -n "$ticket_id" ]] || die "Usage: bms tickets get " - - local response - response=$(bms_curl GET "/v2/servicedesk/tickets/${ticket_id}") - - if [[ "$format" == "json" ]] || [[ "${2:-}" == "--json" ]]; then - echo "$response" | jq . - else - echo "$response" | format_ticket_detail - fi -} - -# Prompt for a value if empty; first arg is field label, second is current value (by nameref) -# Usage: prompt_if_empty "Label" varname -prompt_if_empty() { - local label="$1" - local -n _ref="$2" - if [[ -z "${_ref:-}" ]]; then - read -r -p "${label}: " _ref - fi -} - -cmd_create() { - local title="" details="" account_id="" account_name="" location_id="" location_name="" contact_id="" - local status_id="" priority_id="" type_id="" source_id="" - local assignee_id="" queue_id="" due_date="" open_date="" - local template_id="" - local interactive=false - local response="" ticket_id="" ticket_number="" success="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --template-id) template_id="$2"; shift 2 ;; - --title) title="$2"; shift 2 ;; - --details|--description) details="$2"; shift 2 ;; - --account-id) account_id="$2"; shift 2 ;; - --account-name) account_name="$2"; shift 2 ;; - --location-id) location_id="$2"; shift 2 ;; - --location-name) location_name="$2"; shift 2 ;; - --contact-id) contact_id="$2"; shift 2 ;; - --status-id) status_id="$2"; shift 2 ;; - --priority-id) priority_id="$2"; shift 2 ;; - --type-id) type_id="$2"; shift 2 ;; - --source-id) source_id="$2"; shift 2 ;; - --assignee-id) assignee_id="$2"; shift 2 ;; - --queue-id) queue_id="$2"; shift 2 ;; - --due-date) due_date="$2"; shift 2 ;; - --interactive) interactive=true; shift ;; - *) die "Unknown option: $1" ;; - esac - done - - # ── Template pre-fill ──────────────────────────────────────────────────── - if [[ -n "$template_id" ]]; then - echo "Fetching template ${template_id}..." >&2 - local tmpl - tmpl=$(bms_curl GET "/v2/servicedesk/templates/tickets/${template_id}" | jq '.Data // .') - - # Fill only fields not already set by CLI flags - [[ -z "$title" ]] && title=$(echo "$tmpl" | jq -r '.Title // empty') - [[ -z "$details" ]] && details=$(echo "$tmpl" | jq -r '.Details // empty') - [[ -z "$status_id" ]] && status_id=$(echo "$tmpl" | jq -r '.StatusId // empty') - [[ -z "$priority_id" ]] && priority_id=$(echo "$tmpl" | jq -r '.PriorityId // empty') - [[ -z "$type_id" ]] && type_id=$(echo "$tmpl" | jq -r '(.TypeId // .IssueTypeId) // empty') - [[ -z "$source_id" ]] && source_id=$(echo "$tmpl" | jq -r '.SourceId // empty') - [[ -z "$queue_id" ]] && queue_id=$(echo "$tmpl" | jq -r '.QueueId // empty') - [[ -z "$assignee_id" ]] && assignee_id=$(echo "$tmpl" | jq -r '.AssigneeId // empty') - [[ -z "$account_id" ]] && account_id=$(echo "$tmpl" | jq -r '.AccountId // empty') - [[ -z "$location_id" ]] && location_id=$(echo "$tmpl" | jq -r '.LocationId // empty') - [[ -z "$contact_id" ]] && contact_id=$(echo "$tmpl" | jq -r '.ContactId // empty') - fi - - # ── Interactive prompts ─────────────────────────────────────────────────── - if $interactive; then - prompt_if_empty "Title" title - prompt_if_empty "Details" details - prompt_if_empty "Account ID" account_id - prompt_if_empty "Location ID" location_id - prompt_if_empty "Status ID" status_id - prompt_if_empty "Priority ID" priority_id - prompt_if_empty "Type ID" type_id - prompt_if_empty "Source ID" source_id - prompt_if_empty "Queue ID (optional if Assignee ID provided)" queue_id - if [[ -z "$queue_id" ]]; then - prompt_if_empty "Assignee ID (optional if Queue ID provided)" assignee_id - fi - elif [[ -n "$template_id" ]]; then - # When using a template, prompt only for fields still missing that are required - [[ -n "$title" ]] || { read -r -p "Title: " title; } - [[ -n "$details" ]] || { read -r -p "Details: " details; } - [[ -n "$account_id" ]] || { read -r -p "Account ID: " account_id; } - [[ -n "$location_id" ]] || { read -r -p "Location ID: " location_id; } - [[ -n "$status_id" ]] || { read -r -p "Status ID: " status_id; } - [[ -n "$priority_id" ]] || { read -r -p "Priority ID: " priority_id; } - [[ -n "$type_id" ]] || { read -r -p "Type ID: " type_id; } - [[ -n "$source_id" ]] || { read -r -p "Source ID: " source_id; } - if [[ -z "$queue_id" && -z "$assignee_id" ]]; then - read -r -p "Queue ID (or leave blank to provide Assignee ID): " queue_id - if [[ -z "$queue_id" ]]; then - read -r -p "Assignee ID: " assignee_id - fi - fi - fi - - # ── Resolve account name to ID ───────────────────────────────────────────── - if [[ -n "$account_name" && -z "$account_id" ]]; then - account_search=$(bms_curl POST "/v2/servicedesk/accounts/search" -d "{\"Filter\":{\"AccountName\":\"$account_name\"},\"PageNumber\":1,\"PageSize\":1}") - account_id=$(echo "$account_search" | jq -r '.Data[0].Id // .result[0].id // empty') - [[ -n "$account_id" ]] || die "Account not found: $account_name" - fi - - # ── Resolve location name to ID (scoped to account) ─────────────────────── - if [[ -n "$location_name" && -z "$location_id" ]]; then - [[ -n "$account_id" ]] || die "Must specify --account-id or --account-name before using --location-name" - loc_search=$(bms_curl GET "/v2/crm/accounts/${account_id}/locations/lookup") - location_id=$(echo "$loc_search" | jq -r '.result[]? | select(.Name=="$location_name") | .Id // empty') - [[ -n "$location_id" ]] || die "Location not found: $location_name (account: ${account_id:-unknown})" - fi - - [[ -n "$title" ]] || die "Missing required field: --title" - [[ -n "$details" ]] || die "Missing required field: --details" - [[ -n "$account_id" ]] || die "Missing required field: --account-id" - [[ -n "$location_id" ]] || die "Missing required field: --location-id" - [[ -n "$status_id" ]] || die "Missing required field: --status-id" - [[ -n "$priority_id" ]] || die "Missing required field: --priority-id" - [[ -n "$type_id" ]] || die "Missing required field: --type-id" - [[ -n "$source_id" ]] || die "Missing required field: --source-id" - [[ -n "$queue_id" || -n "$assignee_id" ]] || die "Missing required routing: provide either --queue-id or --assignee-id" - - open_date="${open_date:-$(date -u +%Y-%m-%dT%H:%M:%S)}" - - local body - body=$(jq -n \ - --arg title "$title" \ - --arg details "$details" \ - --argjson account_id "$account_id" \ - --argjson location_id "$location_id" \ - --argjson status_id "$status_id" \ - --argjson priority_id "$priority_id" \ - --argjson type_id "$type_id" \ - --argjson source_id "$source_id" \ - --arg open_date "$open_date" \ - '{ - Title: $title, - Details: $details, - AccountId: $account_id, - LocationId: $location_id, - StatusId: $status_id, - PriorityId: $priority_id, - TypeId: $type_id, - SourceId: $source_id, - OpenDate: $open_date - }') - - # Optionally add non-required fields - [[ -n "$contact_id" ]] && body=$(echo "$body" | jq --argjson v "$contact_id" '. + {ContactId: $v}') - [[ -n "$assignee_id" ]] && body=$(echo "$body" | jq --argjson v "$assignee_id" '. + {AssigneeId: $v}') - [[ -n "$queue_id" ]] && body=$(echo "$body" | jq --argjson v "$queue_id" '. + {QueueId: $v}') - [[ -n "$due_date" ]] && body=$(echo "$body" | jq --arg v "$due_date" '. + {DueDate: $v}') - - # Single create call only. No retries here. - response=$(bms_curl POST "/v2/servicedesk/tickets" -d "$body") - - success=$(echo "$response" | jq -r '.success // .Success // empty') - ticket_id=$(echo "$response" | jq -r '.Data.Id // .Id // .result.id // .result.id // empty') - ticket_number=$(echo "$response" | jq -r '.Data.TicketNumber // .TicketNumber // .result.ticketNumber // empty') - - if [[ "$success" != "true" ]] || [[ -z "$ticket_id" ]] || [[ "$ticket_id" == "null" ]]; then - echo "Create ticket failed or returned ambiguous response:" >&2 - echo "$response" | jq . >&2 - # Log failure - local args_json result_json - args_json=$(jq -n \ - --arg title "$title" \ - --arg details "$details" \ - --arg account_name "${account_name:-}" \ - --arg location_name "${location_name:-}" \ - --argjson account_id "$account_id" \ - --argjson location_id "$location_id" \ - --argjson status_id "$status_id" \ - --argjson priority_id "$priority_id" \ - --argjson type_id "$type_id" \ - --argjson source_id "$source_id" \ - --argjson queue_id "${queue_id:-null}" \ - --argjson assignee_id "${assignee_id:-null}" \ - '{title: $title, details: $details, account_name: $account_name, location_name: $location_name, account_id: $account_id, location_id: $location_id, status_id: $status_id, priority_id: $priority_id, type_id: $type_id, source_id: $source_id, queue_id: $queue_id, assignee_id: $assignee_id}') - result_json=$(jq -n '{error: "creation_failed", response: ("$response" | fromjson? // "$response")}') - log_action "tickets.create" "$args_json" "$result_json" "error" - exit 1 - fi - - echo "Created ticket ID: ${ticket_id} — ${ticket_number:-N/A}" - # Log success - local args_json result_json - args_json=$(jq -n \ - --arg title "$title" \ - --arg details "$details" \ - --arg account_name "${account_name:-}" \ - --arg location_name "${location_name:-}" \ - --argjson account_id "$account_id" \ - --argjson location_id "$location_id" \ - --argjson status_id "$status_id" \ - --argjson priority_id "$priority_id" \ - --argjson type_id "$type_id" \ - --argjson source_id "$source_id" \ - --argjson queue_id "${queue_id:-null}" \ - --argjson assignee_id "${assignee_id:-null}" \ - '{title: $title, details: $details, account_name: $account_name, location_name: $location_name, account_id: $account_id, location_id: $location_id, status_id: $status_id, priority_id: $priority_id, type_id: $type_id, source_id: $source_id, queue_id: $queue_id, assignee_id: $assignee_id}') - result_json=$(jq -n --argjson tid "$ticket_id" --arg tn "${ticket_number:-}" '{ticket_id: $tid, ticket_number: $tn}') - log_action "tickets.create" "$args_json" "$result_json" "success" - -} - -cmd_update() { - local ticket_id="${1:-}" - [[ -n "$ticket_id" ]] || die "Usage: bms tickets update [options]" - shift - - # Fetch current ticket first so we can do a full PUT with changes merged - local current - current=$(bms_curl GET "/v2/servicedesk/tickets/${ticket_id}" | jq '.Data // .result // .') - - local patch="{}" - while [[ $# -gt 0 ]]; do - case "$1" in - --title) patch=$(echo "$patch" | jq --arg v "$2" '. + {Title: $v}'); shift 2 ;; - --details) patch=$(echo "$patch" | jq --arg v "$2" '. + {Details: $v}'); shift 2 ;; - --status-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {StatusId: $v}'); shift 2 ;; - --priority-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {PriorityId: $v}'); shift 2 ;; - --assignee-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {AssigneeId: $v}'); shift 2 ;; - --queue-id) patch=$(echo "$patch" | jq --argjson v "$2" '. + {QueueId: $v}'); shift 2 ;; - --due-date) patch=$(echo "$patch" | jq --arg v "$2" '. + {DueDate: $v}'); shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - # Merge patch onto current (keep required fields from current ticket) - local body - body=$(echo "$current" | jq \ - --argjson patch "$patch" \ - '{ - Title: .Title, - Details: .Details, - AccountId: .AccountId, - LocationId: .LocationId, - StatusId: .StatusId, - PriorityId: .PriorityId, - TypeId: .TypeId, - SourceId: .SourceId, - OpenDate: .OpenDate, - AssigneeId: .AssigneeId, - QueueId: .QueueId, - ContactId: .ContactId - } * $patch') - - local response - response=$(bms_curl PUT "/v2/servicedesk/tickets/${ticket_id}" -d "$body") - echo "$response" | jq -r '"Updated ticket \(.Data.Id // .Id // .result.id // "'"$ticket_id"'")"' - # Log success - local args_json result_json - args_json=$(jq -n --argjson ticket_id "$ticket_id" --argjson patch "$patch" '{ticket_id: $ticket_id, patch: $patch}') - result_json=$(jq -n --argjson tid "$ticket_id" '{ticket_id: $tid}') - log_action "tickets.update" "$args_json" "$result_json" "success" -} - -cmd_note() { - local ticket_id="${1:-}" - [[ -n "$ticket_id" ]] || die "Usage: bms tickets note --message [options]" - shift - - local message="" is_internal=false type_id=1 status_id="" note_date="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --message|-m) message="$2"; shift 2 ;; - --internal) is_internal=true; shift ;; - --type-id) type_id="$2"; shift 2 ;; - --status-id) status_id="$2"; shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - [[ -n "$message" ]] || die "--message is required" - note_date=$(date -u +%Y-%m-%dT%H:%M:%S) - - local body - body=$(jq -n \ - --arg details "$message" \ - --argjson is_internal "$is_internal" \ - --argjson type_id "$type_id" \ - --arg note_date "$note_date" \ - '{ - Details: $details, - IsInternal: $is_internal, - TypeId: $type_id, - NoteDate: $note_date - }') - - [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') - - local response - response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/notes" -d "$body") - local note_id - note_id=$(echo "$response" | jq -r '.Data.Id // .Id // .result.id // .result.id // empty') - if [[ -z "$note_id" || "$note_id" == "null" ]]; then - echo "Note add failed or returned ambiguous response:" >&2 - echo "$response" | jq . >&2 - # Log failure - local args_json result_json - args_json=$(jq -n --argjson ticket_id "$ticket_id" --arg message "$message" --argjson type_id "$type_id" '{ticket_id: $ticket_id, message: $message, type_id: $type_id}') - result_json=$(jq -n '{error: "note_add_failed", response: ("$response" | fromjson? // "$response")}') - log_action "tickets.note" "$args_json" "$result_json" "error" - exit 1 - fi - echo "Note added (ID: ${note_id})" - # Log success - local args_json result_json - args_json=$(jq -n --argjson ticket_id "$ticket_id" --arg message "$message" --argjson type_id "$type_id" '{ticket_id: $ticket_id, message: $message, type_id: $type_id}') - result_json=$(jq -n --argjson note_id "$note_id" '{note_id: $note_id}') - log_action "tickets.note" "$args_json" "$result_json" "success" - -} - -cmd_assign() { - local ticket_id="${1:-}" - [[ -n "$ticket_id" ]] || die "Usage: bms tickets assign [options]" - shift - - local assignee_id="" queue_id="" note="" is_internal=false type_id=1 status_id="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --assignee-id) assignee_id="$2"; shift 2 ;; - --queue-id) queue_id="$2"; shift 2 ;; - --note|-n) note="$2"; shift 2 ;; - --internal) is_internal=true; shift ;; - --type-id) type_id="$2"; shift 2 ;; - --status-id) status_id="$2"; shift 2 ;; - *) die "Unknown option: $1" ;; - esac - done - - [[ -n "$note" ]] || note="Ticket reassigned." - local note_date - note_date=$(date -u +%Y-%m-%dT%H:%M:%S) - - local body - body=$(jq -n \ - --arg details "$note" \ - --argjson is_internal "$is_internal" \ - --argjson type_id "$type_id" \ - --arg note_date "$note_date" \ - '{ - Details: $details, - IsInternal: $is_internal, - TypeId: $type_id, - NoteDate: $note_date - }') - - [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') - [[ -n "$assignee_id" ]] && body=$(echo "$body" | jq --argjson v "$assignee_id" '. + {AssigneeId: $v}') - [[ -n "$queue_id" ]] && body=$(echo "$body" | jq --argjson v "$queue_id" '. + {QueueId: $v}') - - local response - response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/assignticket" -d "$body") - echo "$response" | jq -r '"Ticket \("'"$ticket_id"'") assigned."' - # Log success - local args_json result_json - args_json=$(jq -n --argjson ticket_id "$ticket_id" --argjson assignee_id "${assignee_id:-null}" --argjson queue_id "${queue_id:-null}" '{ticket_id: $ticket_id, assignee_id: $assignee_id, queue_id: $queue_id}') - result_json=$(jq -n --argjson tid "$ticket_id" '{ticket_id: $tid}') - log_action "tickets.assign" "$args_json" "$result_json" "success" -} - -cmd_resolve() { - local ticket_id="${1:-}" - [[ -n "$ticket_id" ]] || die "Usage: bms tickets resolve --comment [options]" - shift - - local comment="" status_id="" publish_kb=false is_internal=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --comment|-c) comment="$2"; shift 2 ;; - --status-id) status_id="$2"; shift 2 ;; - --publish-kb) publish_kb=true; shift ;; - --internal) is_internal=true; shift ;; - *) die "Unknown option: $1" ;; - esac - done - - [[ -n "$comment" ]] || die "--comment is required" - - local body - body=$(jq -n \ - --arg comment "$comment" \ - --argjson publish_kb "$publish_kb" \ - --argjson is_internal "$is_internal" \ - '{Comment: $comment, IsPublishToKnowledgeBase: $publish_kb, IsInternal: $is_internal}') - - [[ -n "$status_id" ]] && body=$(echo "$body" | jq --argjson v "$status_id" '. + {StatusId: $v}') - - local response - response=$(bms_curl POST "/v2/servicedesk/tickets/${ticket_id}/resolve" -d "$body") - echo "$response" | jq -r '"Ticket \("'"$ticket_id"'") resolved."' - # Log success - local args_json result_json - args_json=$(jq -n --argjson ticket_id "$ticket_id" --argjson comment "$comment" '{ticket_id: $ticket_id, comment: $comment}') - result_json=$(jq -n --argjson tid "$ticket_id" '{ticket_id: $tid}') - log_action "tickets.resolve" "$args_json" "$result_json" "success" -} - -cmd_delete() { - local ids=("$@") - [[ ${#ids[@]} -gt 0 ]] || die "Usage: bms tickets delete [id2 ...]" - - local response="" - if [[ ${#ids[@]} -eq 1 ]]; then - response=$(bms_curl DELETE "/v2/servicedesk/tickets/${ids[0]}") - echo "Deleted ticket ${ids[0]}" - else - local body - body=$(printf '%s\n' "${ids[@]}" | jq -Rs 'split("\n") | map(select(. != "")) | map(tonumber) | {Ids: .}') - response=$(bms_curl DELETE "/v2/servicedesk/tickets" -d "$body") - echo "Deleted tickets: ${ids[*]}" - fi - - # Check success (DELETE often returns { success: true } or empty) - local success - success=$(echo "$response" | jq -r '.success // .Success // ""') - if [[ "$success" != "true" ]] && [[ -n "$response" ]] && echo "$response" | jq -e . >/dev/null 2>&1; then - # If response is JSON but not success=true, treat as failure - echo "Delete operation may have failed:" >&2 - echo "$response" | jq . >&2 - local args_json result_json - args_json=$(jq -n --argjson ids "${ids}" '{ids: $ids}') - result_json=$(jq -n '{error: "delete_failed", response: ("$response" | fromjson? // "$response")}') - log_action "tickets.delete" "$args_json" "$result_json" "error" - exit 1 - fi - - # Log success - local args_json result_json - args_json=$(jq -n --argjson ids "${ids}" '{ids: $ids}') - result_json=$(jq -n --argjson deleted_ids "${ids}" '{deleted_ids: $deleted_ids}') - log_action "tickets.delete" "$args_json" "$result_json" "success" -} - -# ─── Dispatch ──────────────────────────────────────────────────────────────── - -subcmd="${1:-list}" -[[ $# -gt 0 ]] && shift - -case "$subcmd" in - list) cmd_list "$@" ;; - get) cmd_get "$@" ;; - create) cmd_create "$@" ;; - update) cmd_update "$@" ;; - note) cmd_note "$@" ;; - assign) cmd_assign "$@" ;; - resolve) cmd_resolve "$@" ;; - delete) cmd_delete "$@" ;; - *) - echo "Usage: bms tickets " >&2 - exit 1 - ;; -esac +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 index 9dccf7f..fa8e567 100755 --- a/scripts/bms.sh +++ b/scripts/bms.sh @@ -1,89 +1,6 @@ #!/usr/bin/env bash -# bms.sh — Kaseya BMS CLI entrypoint -# Usage: bms [subcommand] [options] - set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -usage() { - cat >&2 <<'EOF' -bms — Kaseya BMS CLI - -Usage: - bms auth [login|refresh|status] Authenticate / manage tokens - bms tickets Manage tickets - bms templates Browse ticket/note/timelog templates - bms lookup Fetch lookup tables (IDs for statuses, etc.) - bms accounts List servicedesk accounts - bms locations --account List locations for an account - -Tickets subcommands: - list List/search tickets - get Get a single ticket - create Create a new ticket (supports --template-id) - update Update ticket fields - note Add a note to a ticket - assign Reassign a ticket - resolve Resolve a ticket - delete Delete ticket(s) - -Templates subcommands: - tickets list List all ticket templates - tickets get Show full details for a ticket template - notes list List all note templates - timelogs list List all timelog templates - -Lookup tables: - statuses, priorities, queues, issue-types, sources, ticket-types, - assignees, slas, work-types, note-types, all - -Examples: - bms auth - bms tickets list --status "Open" --assignee "Jane Doe" - bms tickets list --from 2024-01-01 --to 2024-01-31 - bms tickets get 12345 - bms tickets create --title "Server down" --details "..." --account-id 1 \ - --location-id 1 --status-id 1 --priority-id 2 --type-id 1 --source-id 1 - bms tickets create --template-id 7 --title "Override title" --account-id 99 --location-id 5 - bms tickets note 12345 --message "Called client" --internal - bms tickets resolve 12345 --comment "Fixed by replacing NIC" - bms templates tickets list - bms templates tickets get 7 - bms templates notes list - bms templates timelogs list - bms lookup statuses - bms lookup priorities - -Environment variables (required): - BMS_TENANT Your BMS tenant name - BMS_USERNAME BMS username (or use BMS_CLIENT_ID) - BMS_PASSWORD BMS password (or use BMS_CLIENT_SECRET) - -Optional: - BMS_CLIENT_ID OAuth2 client ID - BMS_CLIENT_SECRET OAuth2 client secret - BMS_API_BASE Override API base URL (default: https://api.bms.kaseya.com) - BMS_TOKEN_FILE Token cache path (default: ~/.bms_token.json) - -EOF - exit 1 -} - -cmd="${1:-}" -[[ -n "$cmd" ]] || usage -shift - -case "$cmd" in - auth) exec bash "${SCRIPT_DIR}/bms-auth.sh" "$@" ;; - tickets) exec bash "${SCRIPT_DIR}/bms-tickets.sh" "$@" ;; - templates) exec bash "${SCRIPT_DIR}/bms-templates.sh" "$@" ;; - lookup) exec bash "${SCRIPT_DIR}/bms-lookup.sh" "$@" ;; - accounts) exec bash "${SCRIPT_DIR}/bms-accounts.sh" "$@" ;; - locations) exec bash "${SCRIPT_DIR}/bms-locations.sh" "$@" ;; - help|-h|--help) usage ;; - *) - echo "Unknown command: $cmd" >&2 - usage - ;; -esac +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 new file mode 100644 index 0000000..25312d0 --- /dev/null +++ b/src/openclaw_bms/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.2.0" diff --git a/src/openclaw_bms/__main__.py b/src/openclaw_bms/__main__.py new file mode 100644 index 0000000..bfdcd0c --- /dev/null +++ b/src/openclaw_bms/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/openclaw_bms/cli.py b/src/openclaw_bms/cli.py new file mode 100644 index 0000000..2ed9a76 --- /dev/null +++ b/src/openclaw_bms/cli.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict + +from .client import BmsClient, BmsError +from .service import BmsService, CreateTicketInput + + +def _print(data): + print(json.dumps(data, indent=2)) + + +def _service() -> BmsService: + return BmsService(BmsClient()) + + +def cmd_auth(args: argparse.Namespace) -> int: + client = BmsClient() + if args.action == "login": + client.authenticate() + print("Authenticated.") + elif args.action == "refresh": + client.refresh() + print("Refreshed.") + else: + token_data = client._load_json(client.config.token_file) + _print(token_data or {"status": "no token cached"}) + return 0 + + +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','')}") + 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','')}") + return 0 + + +def cmd_templates(args: argparse.Namespace) -> int: + svc = _service() + if args.resource == "tickets": + data = svc.list_ticket_templates() if args.action == "list" else svc.get_template(args.template_id) + elif args.resource == "notes": + data = svc.list_note_templates() + else: + data = svc.list_timelog_templates() + _print(data) + return 0 + + +def cmd_tickets_list(args: argparse.Namespace) -> int: + _print(_service().search_tickets(status=args.status, assignee=args.assignee, from_date=args.from_date, to_date=args.to_date, priority=args.priority, queue=args.queue, account=args.account, page=args.page, page_size=args.page_size)) + return 0 + + +def cmd_tickets_get(args: argparse.Namespace) -> int: + _print(_service().get_ticket(args.ticket_id)) + return 0 + + +def cmd_tickets_create(args: argparse.Namespace) -> int: + svc = _service() + data = CreateTicketInput( + title=args.title or "", + details=args.details or "", + account_id=args.account_id or 0, + location_id=args.location_id or 0, + status_id=args.status_id or 0, + priority_id=args.priority_id or 0, + type_id=args.type_id or 0, + source_id=args.source_id or 0, + assignee_id=args.assignee_id, + queue_id=args.queue_id, + contact_id=args.contact_id, + due_date=args.due_date, + 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')}") + return 0 + + +def cmd_tickets_patch(args: argparse.Namespace) -> int: + 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)) + return 0 + + +def cmd_tickets_delete(args: argparse.Namespace) -> int: + _print(_service().delete_ticket(args.ticket_id)) + return 0 + + +def cmd_notes_list(args: argparse.Namespace) -> int: + _print(_service().get_notes(args.ticket_id)) + return 0 + + +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)) + 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)) + return 0 + + +def cmd_notes_delete(args: argparse.Namespace) -> int: + _print(_service().delete_note(args.ticket_id, args.note_id)) + 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)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="bms") + sub = p.add_subparsers(dest="command", required=True) + + auth = sub.add_parser("auth") + auth.add_argument("action", nargs="?", choices=["login", "refresh", "status"], default="login") + 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) + + tmpl = sub.add_parser("templates") + tmpl_sub = tmpl.add_subparsers(dest="resource", required=True) + t_tickets = tmpl_sub.add_parser("tickets") + t_tickets.add_argument("action", choices=["list", "get"]) + t_tickets.add_argument("template_id", nargs="?", type=int) + t_tickets.set_defaults(func=cmd_templates) + for name in ["notes", "timelogs"]: + x = tmpl_sub.add_parser(name) + x.add_argument("action", choices=["list"]) + x.set_defaults(func=cmd_templates) + + tickets = sub.add_parser("tickets") + 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("--page", type=int, default=1) + tl.add_argument("--page-size", type=int, default=25) + tl.set_defaults(func=cmd_tickets_list) + tg = tsub.add_parser("get") + 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) + 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) + tp = tsub.add_parser("patch") + tp.add_argument("ticket_id", type=int) + tp.add_argument("path") + tp.add_argument("value") + tp.set_defaults(func=cmd_tickets_patch) + td = tsub.add_parser("delete") + td.add_argument("ticket_id", type=int) + td.set_defaults(func=cmd_tickets_delete) + ta = tsub.add_parser("assign") + ta.add_argument("ticket_id", type=int) + ta.add_argument("--details", required=True) + ta.add_argument("--type-id", type=int, required=True) + ta.add_argument("--status-id", type=int, required=True) + ta.add_argument("--assignee-id", type=int) + ta.add_argument("--queue-id", type=int) + ta.add_argument("--internal", action="store_true") + ta.add_argument("--note-date") + ta.set_defaults(func=cmd_assign) + + notes = sub.add_parser("notes") + nsub = notes.add_subparsers(dest="note_action", required=True) + nl = nsub.add_parser("list") + nl.add_argument("ticket_id", type=int) + nl.set_defaults(func=cmd_notes_list) + na = nsub.add_parser("add") + na.add_argument("ticket_id", type=int) + na.add_argument("--message", required=True) + na.add_argument("--type-id", type=int, default=1) + na.add_argument("--status-id", type=int) + na.add_argument("--internal", action="store_true") + na.add_argument("--note-date") + na.set_defaults(func=cmd_notes_add) + nu = nsub.add_parser("update") + nu.add_argument("ticket_id", type=int) + nu.add_argument("note_id", type=int) + nu.add_argument("--message", required=True) + nu.add_argument("--type-id", type=int, default=1) + nu.add_argument("--status-id", type=int) + nu.add_argument("--internal", action="store_true") + nu.add_argument("--note-date") + nu.set_defaults(func=cmd_notes_update) + nd = nsub.add_parser("delete") + nd.add_argument("ticket_id", type=int) + nd.add_argument("note_id", type=int) + nd.set_defaults(func=cmd_notes_delete) + + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except BmsError as exc: + parser.exit(1, f"ERROR: {exc}\n") diff --git a/src/openclaw_bms/client.py b/src/openclaw_bms/client.py new file mode 100644 index 0000000..2e836ce --- /dev/null +++ b/src/openclaw_bms/client.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import base64 +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class BmsError(RuntimeError): + pass + + +@dataclass +class Config: + base_url: str = os.environ.get("BMS_API_BASE", "https://api.bms.kaseya.com") + tenant: str | None = os.environ.get("BMS_TENANT") + username: str | None = os.environ.get("BMS_USERNAME") + password: str | None = os.environ.get("BMS_PASSWORD") + mfa_code: str | None = os.environ.get("BMS_MFA_CODE") + client_id: str | None = os.environ.get("BMS_CLIENT_ID") + 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"))) + + +class BmsClient: + def __init__(self, config: Config | None = None): + self.config = config or Config() + + def _ensure_parent(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + def _load_json(self, path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except Exception: + return None + + def _save_json(self, path: Path, payload: dict[str, Any]) -> None: + self._ensure_parent(path) + path.write_text(json.dumps(payload, indent=2)) + try: + os.chmod(path, 0o600) + except PermissionError: + pass + + def _token_valid(self, token_data: dict[str, Any] | None) -> bool: + if not token_data: + return False + token = token_data.get("access_token") + expires_at = float(token_data.get("expires_at", 0)) + return bool(token) and expires_at > time.time() + 60 + + def _decode_exp(self, token: str) -> float: + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + data = json.loads(base64.urlsafe_b64decode(payload.encode())) + return float(data.get("exp", time.time() + 3600)) + except Exception: + return time.time() + 3600 + + def _cache_token_response(self, payload: dict[str, Any]) -> str: + result = payload.get("result", payload) + access_token = result.get("AccessToken") or result.get("accessToken") or result.get("access_token") + if not access_token: + raise BmsError(f"No access token in auth response: {json.dumps(payload)}") + 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, + }) + 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: + 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"} + if headers: + req_headers.update(headers) + data = None + if auth: + req_headers["Authorization"] = f"Bearer {self.get_token()}" + if json_body is not None: + data = json.dumps(json_body).encode() + req_headers["Content-Type"] = "application/json" + elif form is not None: + data = urllib.parse.urlencode({k: v for k, v in form.items() if v not in (None, "")}).encode() + req_headers["Content-Type"] = "application/x-www-form-urlencoded" + req = urllib.request.Request(url, data=data, method=method.upper(), headers=req_headers) + try: + with urllib.request.urlopen(req) as response: + raw = response.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as exc: + raw = exc.read().decode(errors="replace") + raise BmsError(f"HTTP {exc.code} {exc.reason}: {raw}") from exc + + def authenticate(self) -> str: + if not self.config.username or not self.config.password: + raise BmsError("BMS_USERNAME and BMS_PASSWORD are required") + payload = self._request( + "POST", + "/v2/security/authenticate", + form={ + "GrantType": "password", + "UserName": self.config.username, + "Password": self.config.password, + "Tenant": self.config.tenant, + "MFACode": self.config.mfa_code, + "ClientId": self.config.client_id, + "ClientSecret": self.config.client_secret, + }, + auth=False, + ) + return self._cache_token_response(payload) + + def refresh(self) -> str: + token_data = self._load_json(self.config.token_file) + if not token_data or not token_data.get("refresh_token"): + return self.authenticate() + payload = self._request( + "POST", + "/v2/security/refreshtoken", + form={ + "AccessToken": token_data.get("access_token"), + "RefreshToken": token_data.get("refresh_token"), + }, + auth=False, + ) + return self._cache_token_response(payload) + + def get_token(self) -> str: + token_data = self._load_json(self.config.token_file) + if self._token_valid(token_data): + return str(token_data["access_token"]) + try: + return self.refresh() + except BmsError: + return self.authenticate() + + def cache_get(self, key: str, ttl_seconds: int) -> Any | None: + payload = self._load_json(self.config.cache_file) or {} + item = payload.get(key) + if not item: + return None + if float(item.get("expires_at", 0)) < time.time(): + return None + return item.get("value") + + def cache_set(self, key: str, value: Any, ttl_seconds: int) -> None: + payload = self._load_json(self.config.cache_file) or {} + payload[key] = {"expires_at": time.time() + ttl_seconds, "value": value} + self._save_json(self.config.cache_file, payload) diff --git a/src/openclaw_bms/service.py b/src/openclaw_bms/service.py new file mode 100644 index 0000000..af0a066 --- /dev/null +++ b/src/openclaw_bms/service.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from .client import BmsClient, BmsError + + +ACCOUNTS_TTL = 60 * 60 * 24 +LOCATIONS_TTL = 60 * 60 * 24 + + +@dataclass +class CreateTicketInput: + title: str + details: str + account_id: int + location_id: int + status_id: int + priority_id: int + type_id: int + source_id: int + assignee_id: int | None = None + queue_id: int | None = None + contact_id: int | None = None + due_date: str | None = None + open_date: str | None = None + template_id: int | None = None + + +class BmsService: + def __init__(self, client: BmsClient | None = None): + self.client = client or BmsClient() + + def _iso_now(self) -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + 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) + if cached is not None: + return cached + payload = self.client._request("GET", "/v2/crm/accounts/lookup") + data = payload.get("result", payload) + if not isinstance(data, list): + raise BmsError(f"Unexpected accounts response: {payload}") + self.client.cache_set(cache_key, data, ACCOUNTS_TTL) + return data + + 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) + if cached is not None: + return cached + payload = self.client._request("GET", f"/v2/crm/accounts/{account_id}/locations/lookup") + data = payload.get("result", payload) + if not isinstance(data, list): + raise BmsError(f"Unexpected locations response: {payload}") + self.client.cache_set(cache_key, data, LOCATIONS_TTL) + return data + + def search_tickets(self, **kwargs: Any) -> Any: + filter_obj = {} + mapping = { + "status": "StatusNames", + "assignee": "AssigneeName", + "priority": "PriorityNames", + "queue": "QueueNames", + "account": "Account", + "from_date": "CreatedOnFrom", + "to_date": "CreatedOnTo", + } + for arg, api_name in mapping.items(): + val = kwargs.get(arg) + if val: + if arg == "from_date": + val = f"{val}T00:00:00" + 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}) + + 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) + + def list_ticket_templates(self) -> Any: + return self.client._request("GET", "/v2/servicedesk/templates/tickets/lookup") + + def list_note_templates(self) -> Any: + return self.client._request("GET", "/v2/servicedesk/templates/notes/lookup") + + def list_timelog_templates(self) -> Any: + return self.client._request("GET", "/v2/servicedesk/templates/timelogs/lookup") + + def _merge_template(self, data: CreateTicketInput) -> CreateTicketInput: + if not data.template_id: + return data + tmpl = self.get_template(data.template_id) + return CreateTicketInput( + title=data.title or tmpl.get("Title") or tmpl.get("Name") or "", + details=data.details or tmpl.get("Details") or tmpl.get("Description") or "", + account_id=data.account_id or tmpl.get("AccountId") or 0, + location_id=data.location_id or tmpl.get("LocationId") or 0, + status_id=data.status_id or tmpl.get("StatusId") or 0, + priority_id=data.priority_id or tmpl.get("PriorityId") or 0, + type_id=data.type_id or tmpl.get("TypeId") or tmpl.get("IssueTypeId") or 0, + source_id=data.source_id or tmpl.get("SourceId") or 0, + assignee_id=data.assignee_id or tmpl.get("AssigneeId"), + queue_id=data.queue_id or tmpl.get("QueueId"), + contact_id=data.contact_id or tmpl.get("ContactId"), + due_date=data.due_date or tmpl.get("DueDate"), + open_date=data.open_date or tmpl.get("OpenDate"), + template_id=data.template_id, + ) + + def create_ticket(self, data: CreateTicketInput) -> dict[str, Any]: + merged = self._merge_template(data) + required_missing = [] + for field in ["title", "details", "account_id", "location_id", "status_id", "priority_id", "type_id", "source_id"]: + value = getattr(merged, field) + if value in (None, "", 0): + required_missing.append(field) + if merged.queue_id in (None, "", 0) and merged.assignee_id in (None, "", 0): + required_missing.append("queue_id_or_assignee_id") + if required_missing: + raise BmsError("Missing required fields before create: " + ", ".join(required_missing)) + payload = { + "Title": merged.title, + "Details": merged.details, + "AccountId": int(merged.account_id), + "LocationId": int(merged.location_id), + "StatusId": int(merged.status_id), + "PriorityId": int(merged.priority_id), + "TypeId": int(merged.type_id), + "SourceId": int(merged.source_id), + "OpenDate": merged.open_date or self._iso_now(), + } + if merged.assignee_id not in (None, "", 0): + payload["AssigneeId"] = int(merged.assignee_id) + if merged.queue_id not in (None, "", 0): + payload["QueueId"] = int(merged.queue_id) + if merged.contact_id not in (None, "", 0): + payload["ContactId"] = int(merged.contact_id) + 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 + if success is not True or not ticket_id: + raise BmsError(f"Create ticket failed or returned ambiguous response: {response}") + return response + + 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(), + } + 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(), + } + 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) + + def get_notes(self, ticket_id: int) -> Any: + return self.client._request("GET", f"/v2/servicedesk/tickets/{ticket_id}/notes") + + 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 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(), + } + if assignee_id is not None: + payload["AssigneeId"] = int(assignee_id) + if queue_id is not None: + payload["QueueId"] = int(queue_id) + return self.client._request("POST", f"/v2/servicedesk/tickets/{ticket_id}/assignticket", json_body=payload) + + def patch_ticket(self, ticket_id: int, path: str, value: Any) -> Any: + return self.client._request("PATCH", f"/v2/servicedesk/tickets/{ticket_id}", json_body=[{"op": "replace", "path": path, "value": value}]) + + def delete_ticket(self, ticket_id: int) -> Any: + return self.client._request("DELETE", f"/v2/servicedesk/tickets/{ticket_id}")