Rewrite BMS skill from bash to Python
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
160
README.md
Normal file
160
README.md
Normal 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
232
SKILL.md
@@ -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 Daniel’s 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
16
pyproject.toml
Normal 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"]
|
||||||
@@ -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
154
scripts/bms-auth.sh
Normal file → Executable 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
|
|
||||||
|
|||||||
@@ -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
0
scripts/bms-logging.sh
Normal file → Executable 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"
|
|
||||||
|
|||||||
@@ -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
590
scripts/bms-tickets.sh
Normal file → Executable 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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
2
src/openclaw_bms/__init__.py
Normal file
2
src/openclaw_bms/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__all__ = ["__version__"]
|
||||||
|
__version__ = "0.2.0"
|
||||||
4
src/openclaw_bms/__main__.py
Normal file
4
src/openclaw_bms/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
249
src/openclaw_bms/cli.py
Normal file
249
src/openclaw_bms/cli.py
Normal 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
167
src/openclaw_bms/client.py
Normal 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
212
src/openclaw_bms/service.py
Normal 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}")
|
||||||
Reference in New Issue
Block a user