Migrate BMS skill to Python-only CLI with audit logging

This commit is contained in:
Steve W
2026-04-08 15:53:38 +00:00
parent 59d6e5ba3a
commit f5fb984bc3
16 changed files with 408 additions and 324 deletions

197
README.md
View File

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

@@ -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 Daniels direct use and BMS operator workflows

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

60
src/openclaw_bms/audit.py Normal file
View 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")

View File

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

View File

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

View File

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