Rewrite BMS skill from bash to Python

This commit is contained in:
Steve W
2026-04-08 02:19:50 +00:00
parent 568b825e11
commit 59d6e5ba3a
17 changed files with 938 additions and 1286 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
*.pyc

160
README.md Normal file
View File

@@ -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

232
SKILL.md
View File

@@ -1,190 +1,146 @@
# BMS Skill — Kaseya BMS Ticket Management # 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 ## Configuration
Required environment variables (store in shell profile or a secrets manager):
```bash ```bash
export BMS_TENANT="your-tenant-name" # Your BMS tenant/subdomain export BMS_TENANT="your-tenant-name"
export BMS_USERNAME="user@example.com" # BMS login username export BMS_USERNAME="user@example.com"
export BMS_PASSWORD="yourpassword" # BMS login password export BMS_PASSWORD="yourpassword"
# Or use client credentials (OAuth2): export BMS_MFA_CODE="123456" # when needed
export BMS_CLIENT_ID="your-client-id" export BMS_API_BASE="https://api.bms.kaseya.com"
export BMS_CLIENT_SECRET="your-client-secret" 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 ## Commands
All commands route through `scripts/bms.sh`. Run without arguments for usage. Primary entrypoint:
### Authentication
```bash ```bash
bms auth # Authenticate and cache token bash scripts/bms.sh --help
bms auth --status # Show token status / expiry
``` ```
### Listing Tickets ### Auth
```bash ```bash
bms tickets list # All open tickets (paginated) bms auth login
bms tickets list --status "Open" # Filter by status name bms auth refresh
bms tickets list --assignee "John Smith" # Filter by assignee name bms auth status
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
``` ```
### Getting a Ticket ### Accounts and Locations
```bash ```bash
bms tickets get <ticketId> # Get full ticket details bms accounts
bms tickets get <ticketId> --json # Raw JSON 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 ```bash
bms tickets create \ bms tickets list --status Open --assignee "Jane Doe"
--title "Server is down" \ bms tickets get 12345
--details "The main server stopped responding at 2pm" \ 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
--account-id 123 \ bms tickets create --template-id 9 --title "Override title" --account-id 1 --location-id 2 --queue-id 7
--location-id 456 \ bms tickets patch 12345 /StatusId 6
--status-id 1 \ bms tickets assign 12345 --details "Routing" --type-id 1 --status-id 6 --queue-id 7
--priority-id 2 \ bms tickets delete 12345
--type-id 1 \
--source-id 1 \
--assignee-id 789
``` ```
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 ```bash
bms tickets create --template-id 7 --account-id 123 --location-id 456 bms notes list 12345
# Fields from template 7 are used; only account/location are overridden. bms notes add 12345 --message "Investigating" --note-date 2026-04-07T12:00:00+00:00
# Any required field still missing triggers an interactive prompt. 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): Features:
- custom note dates supported for create and update
```bash - note CRUD exposed directly in the Python CLI
bms tickets create --interactive
```
### Updating Tickets
```bash
bms tickets update <ticketId> --status-id 3 # Change status
bms tickets update <ticketId> --assignee-id 789 # Reassign
bms tickets update <ticketId> --priority-id 1 # Change priority
bms tickets update <ticketId> --title "New title" # Update title
```
### Adding Notes
```bash
bms tickets note <ticketId> --message "Called client, investigating"
bms tickets note <ticketId> --message "Internal update" --internal
bms tickets note <ticketId> --message "Resolved via restart" --status-id 5
```
### Assigning Tickets
```bash
bms tickets assign <ticketId> --assignee-id 789 --note "Routing to tier 2"
bms tickets assign <ticketId> --queue-id 3
```
### Resolving Tickets
```bash
bms tickets resolve <ticketId> --comment "Replaced failed drive, server is back online"
bms tickets resolve <ticketId> --comment "Fixed" --status-id 6 --publish-kb
```
### Deleting Tickets
```bash
bms tickets delete <ticketId> # 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
```
### Templates ### Templates
Browse pre-defined ticket, note, and timelog templates configured in BMS.
```bash ```bash
bms templates tickets list # List all ticket templates (Id, Name, QueueId, PriorityId, etc.) bms templates tickets list
bms templates tickets get <id> # Inspect a specific ticket template (raw JSON) bms templates tickets get 9
bms templates notes list # List all note templates bms templates notes list
bms templates timelogs list # List all timelog templates bms templates timelogs list
# Add --format json to any list command for raw JSON output
bms templates tickets list --format json
``` ```
## Template-Based Ticket Creation Templates are read-only.
`bms tickets create --template-id <N>` does the following: ## Endpoints used
1. Fetches `GET /v2/servicedesk/templates/tickets/{templateId}` to retrieve template defaults. Auth:
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. - `POST /v2/security/authenticate`
3. Prompts interactively (via stdin) for any required field still missing after the merge. - `POST /v2/security/refreshtoken`
4. Posts the final payload to `POST /v2/servicedesk/tickets`.
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`. Notes:
- **Required fields for ticket creation**: Title, Details, AccountId, LocationId, StatusId, PriorityId, TypeId, SourceId, OpenDate — all are required by the API schema. - `GET /v2/servicedesk/tickets/{ticketId}/notes`
- **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. - `POST /v2/servicedesk/tickets/{ticketId}/notes`
- **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. - `PUT /v2/servicedesk/tickets/{ticketId}/notes/{noteId}`
- **Pagination**: Default page size is 25. Use `--page-size` (max appears to be 100) and `--page` for large result sets. - `DELETE /v2/servicedesk/tickets/{ticketId}/notes/{noteId}`
- **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.
## 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 - `0` — Email Sent
- `1` — Email Received - `1` — Email Received
- `2` — General Notes - `2` — General Notes
- `3` — Phone Call - `3` — Phone Call
- `4` — Resolution - `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) - Python standard library only
- [BMS API Swagger JSON](https://api.bms.kaseya.com/swagger/v2/swagger.json) - shell scripts retained as compatibility wrappers around the Python CLI
- `references/key-schemas.md` — TicketInputDto, filter, and note schemas - cached lookups reduce repeated account/location API calls
- `scripts/bms.sh` — Main CLI entrypoint - account/location cache TTL is 24 hours by default
- `scripts/bms-auth.sh` — Auth and token management - designed for Daniels direct use and BMS operator workflows
- `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)

16
pyproject.toml Normal file
View File

@@ -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"]

View File

@@ -1,23 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Get token from bms-auth.sh export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
if ! token=$(bash "${SCRIPT_DIR}/bms-auth.sh" get-token 2>/dev/null); then exec python3 -m openclaw_bms accounts "$@"
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

154
scripts/bms-auth.sh Normal file → Executable file
View File

@@ -1,153 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# bms-auth.sh — Kaseya BMS authentication helper
# Obtains and caches JWT tokens. Called by bms.sh.
set -euo pipefail set -euo pipefail
# Import logging
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/bms-logging.sh" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" action="${1:-login}"
BMS_TOKEN_FILE="${BMS_TOKEN_FILE:-$HOME/.bms_token.json}" exec python3 -m openclaw_bms auth "$action"
# ─── 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

View File

@@ -1,33 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Get token from bms-auth.sh export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
if ! token=$(bash "${SCRIPT_DIR}/bms-auth.sh" get-token 2>/dev/null); then exec python3 -m openclaw_bms locations "$@"
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 <id>" >&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

0
scripts/bms-logging.sh Normal file → Executable file
View File

View File

@@ -1,96 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# bms-lookup.sh — Fetch lookup tables (statuses, priorities, queues, etc.)
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
die() { echo "ERROR: $*" >&2; exit 1; } cat >&2 <<'EOF'
Lookup subcommands from the old bash implementation were removed in the Python rewrite.
get_token() { Use:
bash "${SCRIPT_DIR}/bms-auth.sh" get-token bms accounts
} bms locations --account <id>
bms templates tickets list
bms_curl() { bms templates notes list
local path="$1"; shift bms templates timelogs list
local token For ticket/status IDs, use your tenant's known IDs or extend the Python service with tenant-specific lookups.
token=$(get_token) EOF
curl -sf -X GET \ exit 1
"${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 <statuses|priorities|queues|issue-types|sources|ticket-types|assignees|slas|work-types|note-types|all>"
cmd_lookup "$table"

View File

@@ -1,185 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# bms-templates.sh — Kaseya BMS template lookups (tickets, notes, timelogs)
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
# ─── Helpers ───────────────────────────────────────────────────────────────── exec python3 -m openclaw_bms templates "$@"
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 <templateId>"
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 <resource> <subcommand> [options]
Resources and subcommands:
tickets list List all ticket templates
tickets get <id> 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

590
scripts/bms-tickets.sh Normal file → Executable file
View File

@@ -1,590 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# bms-tickets.sh — Kaseya BMS ticket CRUD operations
set -euo pipefail set -euo pipefail
# Import logging
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/bms-logging.sh" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
BMS_API_BASE="${BMS_API_BASE:-https://api.bms.kaseya.com}" exec python3 -m openclaw_bms tickets "$@"
# ─── 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 <ticketId>"
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 <ticketId> [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 <ticketId> --message <text> [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 <ticketId> [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 <ticketId> --comment <text> [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 <id> [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 <list|get|create|update|note|assign|resolve|delete>" >&2
exit 1
;;
esac

View File

@@ -1,89 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# bms.sh — Kaseya BMS CLI entrypoint
# Usage: bms <command> [subcommand] [options]
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
usage() { export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
cat >&2 <<'EOF' exec python3 -m openclaw_bms "$@"
bms — Kaseya BMS CLI
Usage:
bms auth [login|refresh|status] Authenticate / manage tokens
bms tickets <subcommand> Manage tickets
bms templates <resource> <sub> Browse ticket/note/timelog templates
bms lookup <table> Fetch lookup tables (IDs for statuses, etc.)
bms accounts List servicedesk accounts
bms locations --account <id> 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 <id> 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

View File

@@ -0,0 +1,2 @@
__all__ = ["__version__"]
__version__ = "0.2.0"

View File

@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

249
src/openclaw_bms/cli.py Normal file
View File

@@ -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")

167
src/openclaw_bms/client.py Normal file
View File

@@ -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)

212
src/openclaw_bms/service.py Normal file
View File

@@ -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}")