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