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

195
README.md
View File

@@ -1,15 +1,6 @@
# openclaw-bms
Python rewrite of the OpenClaw Kaseya BMS skill for ticket and note workflows.
## Goals
- reliable ticket CRUD
- reliable ticket note CRUD
- correct account/location relationship handling
- cache stable CRM lookups
- support template-based ticket creation cleanly
- keep a small shell compatibility layer for existing `scripts/*.sh` entrypoints
Python-first OpenClaw skill for Kaseya BMS ticket and note workflows.
## Installation
@@ -17,10 +8,16 @@ Python rewrite of the OpenClaw Kaseya BMS skill for ticket and note workflows.
python3 -m pip install -e .
```
Or run directly from the repo:
Primary usage:
```bash
bash scripts/bms.sh --help
bms --help
```
Alternative direct module invocation:
```bash
python3 -m openclaw_bms --help
```
## Configuration
@@ -35,33 +32,29 @@ export BMS_TOKEN_FILE="$HOME/.bms_token.json"
export BMS_CACHE_FILE="$HOME/.cache/openclaw-bms/cache.json"
```
## Key behavior
## Core behavior
### 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
bms accounts
bms locations --account 12345
```
Do not assume a location name like `Main` is globally unique. A location name can exist under multiple accounts with different IDs.
Cached for 24 hours:
- accounts list
- locations per account
Refresh explicitly:
```bash
bms accounts --refresh
bms locations --account 12345
bms locations --account 12345 --refresh
```
### Tickets
Caching:
- accounts cached for 24 hours
- locations cached per account for 24 hours
### Ticket CRUD
List/search:
@@ -69,6 +62,12 @@ List/search:
bms tickets list --status Open --assignee "Jane Doe"
```
Get:
```bash
bms tickets get 12345
```
Create:
```bash
@@ -85,7 +84,7 @@ bms tickets create \
--open-date 2026-04-07T14:00:00+00:00
```
Template-based create:
Create from template:
```bash
bms tickets create \
@@ -96,65 +95,123 @@ bms tickets create \
--queue-id 9
```
Template logic:
- fetches the template
- merges template defaults with CLI overrides
- CLI values win
- validates required fields before the create call
- requires routing via either `queue-id` or `assignee-id`
- makes exactly one create API call per invocation
- treats create as success only when the response includes success=true and a valid ticket ID
Template create flow:
- fetch template
- merge template defaults with explicit CLI overrides
- validate required fields before the API call
- require either `queue-id` or `assignee-id`
- make exactly one create API call per invocation
- require a valid ticket ID in the response before reporting success
### 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:
```bash
bms notes list 33919447
bms notes list 12345
```
Add a note with a custom date:
Add note with custom date:
```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
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
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
- avoids packaging friction for a personal skill
- console entry point via `pyproject.toml`
- no shell wrappers required
- service layer separated from CLI
- easier to audit and extend
- caching stored in a JSON file
- simple, transparent, sufficient for account/location lookups
- shell scripts kept as compatibility wrappers
- existing command habits keep working
## Audit notes
Primary audit focus was on:
- ticket create safety
- note CRUD support
- account/location correctness
- template create correctness
Changes from bash version:
- removed fragile mixed endpoint usage
- fixed account/location handling through CRM endpoints
- added explicit cache for accounts and per-account locations
- added `open-date` support for ticket creation
- added `note-date` support for note create and update
- added full note CRUD in the Python CLI
- reduced duplicate-create risk by validating before create and checking response semantics after create
- file-based cache for stable CRM data
- file-based audit log for write history and rollback context

108
SKILL.md
View File

@@ -2,15 +2,19 @@
Python-based OpenClaw skill for Kaseya BMS ticket and note workflows.
## Scope
## Run
This skill focuses on:
- ticket CRUD
- ticket note CRUD
- CRM account and account-scoped location lookup
- template-assisted ticket creation
- token handling with MFA support
- account/location caching
Preferred:
```bash
bms --help
```
Alternative:
```bash
python3 -m openclaw_bms --help
```
## Configuration
@@ -18,60 +22,40 @@ This skill focuses on:
export BMS_TENANT="your-tenant-name"
export BMS_USERNAME="user@example.com"
export BMS_PASSWORD="yourpassword"
export BMS_MFA_CODE="123456" # when needed
export BMS_MFA_CODE="123456"
export BMS_API_BASE="https://api.bms.kaseya.com"
export BMS_TOKEN_FILE="$HOME/.bms_token.json"
export BMS_CACHE_FILE="$HOME/.cache/openclaw-bms/cache.json"
```
## Commands
Primary entrypoint:
```bash
bash scripts/bms.sh --help
```
### Auth
```bash
bms auth login
bms auth refresh
bms auth status
```
## Key functionality
### Accounts and Locations
Use CRM endpoints and respect account/location relationships:
```bash
bms accounts
bms accounts --refresh
bms locations --account 12345
bms locations --account 12345 --refresh
```
Important:
- locations are tied to accounts
- the same location name can exist under multiple accounts with different IDs
- always resolve location IDs in the context of a specific account
- locations are account-scoped
- a location name under one account is not interchangeable with the same location name under another account
- accounts and per-account locations are cached for 24 hours
### Tickets
```bash
bms tickets list --status Open --assignee "Jane Doe"
bms tickets get 12345
bms tickets create --title "test" --details "Test" --account-id 1 --location-id 2 --status-id 3 --priority-id 4 --type-id 5 --source-id 6 --queue-id 7
bms tickets create --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 patch 12345 /StatusId 6
bms tickets assign 12345 --details "Routing" --type-id 1 --status-id 6 --queue-id 7
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
```bash
@@ -81,9 +65,14 @@ bms notes update 12345 999 --message "Corrected note" --note-date 2026-04-07T13:
bms notes delete 12345 999
```
Features:
- custom note dates supported for create and update
- note CRUD exposed directly in the Python CLI
### Lookups
```bash
bms lookup statuses
bms lookup priorities
bms lookup types
bms lookup sources
```
### Templates
@@ -96,6 +85,27 @@ bms templates timelogs list
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
Auth:
@@ -116,6 +126,7 @@ Tickets:
Notes:
- `GET /v2/servicedesk/tickets/{ticketId}/notes`
- `GET /v2/servicedesk/tickets/{ticketId}/notes/{noteId}`
- `POST /v2/servicedesk/tickets/{ticketId}/notes`
- `PUT /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/notes/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]
name = "openclaw-bms"
version = "0.2.0"
version = "0.2.1"
description = "Python-based OpenClaw skill for Kaseya BMS ticket and note workflows."
readme = "README.md"
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__"]
__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 json
from dataclasses import asdict
from typing import Any
from .audit import AuditLogger
from .client import BmsClient, BmsError
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))
@@ -16,13 +31,28 @@ def _service() -> BmsService:
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:
client = BmsClient()
if args.action == "login":
client.authenticate()
_run_write("auth.login", {"action": args.action}, lambda: ({"message": client.authenticate() and "Authenticated."}, None))
print("Authenticated.")
elif args.action == "refresh":
client.refresh()
_run_write("auth.refresh", {"action": args.action}, lambda: ({"message": client.refresh() and "Refreshed."}, None))
print("Refreshed.")
else:
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:
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
def cmd_locations(args: argparse.Namespace) -> int:
for item in _service().list_locations(args.account, refresh=args.refresh):
print(f"{item.get('Id','')} {item.get('Name','')}")
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
@@ -82,30 +126,45 @@ def cmd_tickets_create(args: argparse.Namespace) -> int:
open_date=args.open_date,
template_id=args.template_id,
)
missing = []
preview = svc._merge_template(data) if data.template_id else data
for field in ["title", "details", "account_id", "location_id", "status_id", "priority_id", "type_id", "source_id"]:
value = getattr(preview, field)
if value in (None, "", 0):
missing.append(field)
if preview.queue_id in (None, "", 0) and preview.assignee_id in (None, "", 0):
missing.append("queue_id_or_assignee_id")
if missing:
raise BmsError("Missing required fields before create: " + ", ".join(missing))
def _op():
preview = svc.validate_create_input(data)
response = svc.create_ticket(data)
result = response.get("Data", response)
print(f"Created ticket ID: {result.get('Id')}{result.get('TicketNumber', 'N/A')}")
result = response.get("Data") or response.get("result") or response
revert = {"created_ticket_id": result.get("Id") or result.get("id"), "created_ticket_number": result.get("TicketNumber") or result.get("ticketNumber")}
return response, revert
response = _run_write("tickets.create", asdict(data), _op)
result = response.get("Data") or response.get("result") or response
print(f"Created ticket ID: {result.get('Id') or result.get('id')}{result.get('TicketNumber') or result.get('ticketNumber') or 'N/A'}")
return 0
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
_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
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:
_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
@@ -115,22 +174,42 @@ def cmd_notes_list(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
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
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
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
@@ -143,16 +222,18 @@ def build_parser() -> argparse.ArgumentParser:
auth.set_defaults(func=cmd_auth)
ac = sub.add_parser("accounts")
ac.add_argument("action", nargs="?", choices=["list"], default="list")
ac.add_argument("--refresh", action="store_true")
ac.set_defaults(func=cmd_accounts)
loc = sub.add_parser("locations")
loc.add_argument("action", nargs="?", choices=["list"], default="list")
loc.add_argument("--account", type=int, required=True)
loc.add_argument("--refresh", action="store_true")
loc.set_defaults(func=cmd_locations)
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 = tmpl.add_subparsers(dest="resource", required=True)
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)
tl = tsub.add_parser("list")
for arg in ["status", "assignee", "from_date", "to_date", "priority", "queue", "account"]:
tl.add_argument(f"--{arg.replace('_','-')}")
tl.add_argument(f"--{arg.replace('_', '-')}")
tl.add_argument("--page", type=int, default=1)
tl.add_argument("--page-size", type=int, default=25)
tl.set_defaults(func=cmd_tickets_list)
@@ -176,18 +257,10 @@ def build_parser() -> argparse.ArgumentParser:
tg.add_argument("ticket_id", type=int)
tg.set_defaults(func=cmd_tickets_get)
tc = tsub.add_parser("create")
tc.add_argument("--template-id", type=int)
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("--details")
tc.add_argument("--account-id", type=int)
tc.add_argument("--location-id", type=int)
tc.add_argument("--status-id", type=int)
tc.add_argument("--priority-id", type=int)
tc.add_argument("--type-id", type=int)
tc.add_argument("--source-id", type=int)
tc.add_argument("--assignee-id", type=int)
tc.add_argument("--queue-id", type=int)
tc.add_argument("--contact-id", type=int)
tc.add_argument("--due-date")
tc.add_argument("--open-date")
tc.set_defaults(func=cmd_tickets_create)
@@ -236,7 +309,6 @@ def build_parser() -> argparse.ArgumentParser:
nd.add_argument("ticket_id", type=int)
nd.add_argument("note_id", type=int)
nd.set_defaults(func=cmd_notes_delete)
return p

View File

@@ -27,6 +27,7 @@ class Config:
client_secret: str | None = os.environ.get("BMS_CLIENT_SECRET")
token_file: Path = Path(os.environ.get("BMS_TOKEN_FILE", os.path.expanduser("~/.bms_token.json")))
cache_file: Path = Path(os.environ.get("BMS_CACHE_FILE", os.path.expanduser("~/.cache/openclaw-bms/cache.json")))
user_agent: str = os.environ.get("BMS_USER_AGENT", "openclaw-bms/0.2.1")
class BmsClient:
@@ -76,19 +77,28 @@ class BmsClient:
refresh_token = result.get("RefreshToken") or result.get("refreshToken") or result.get("refresh_token") or ""
expires_in = result.get("ExpiresIn") or result.get("expiresIn") or result.get("expires_in")
expires_at = time.time() + float(expires_in) if expires_in else self._decode_exp(access_token)
self._save_json(self.config.token_file, {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
})
self._save_json(
self.config.token_file,
{"access_token": access_token, "refresh_token": refresh_token, "expires_at": expires_at},
)
return access_token
def _request(self, method: str, path: str, *, headers: dict[str, str] | None = None, query: dict[str, Any] | None = None, json_body: Any = None, form: dict[str, Any] | None = None, auth: bool = True) -> Any:
def _request(
self,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
query: dict[str, Any] | None = None,
json_body: Any = None,
form: dict[str, Any] | None = None,
auth: bool = True,
) -> Any:
url = self.config.base_url.rstrip("/") + path
if query:
query = {k: v for k, v in query.items() if v is not None and v != ""}
url += "?" + urllib.parse.urlencode(query, doseq=True)
req_headers = {"Accept": "application/json"}
req_headers = {"Accept": "application/json", "User-Agent": self.config.user_agent}
if headers:
req_headers.update(headers)
data = None
@@ -152,7 +162,7 @@ class BmsClient:
except BmsError:
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 {}
item = payload.get(key)
if not item:

View File

@@ -6,7 +6,6 @@ from typing import Any
from .client import BmsClient, BmsError
ACCOUNTS_TTL = 60 * 60 * 24
LOCATIONS_TTL = 60 * 60 * 24
@@ -36,10 +35,15 @@ class BmsService:
def _iso_now(self) -> str:
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]]:
cache_key = "accounts"
if not refresh:
cached = self.client.cache_get(cache_key, ACCOUNTS_TTL)
cached = self.client.cache_get(cache_key)
if cached is not None:
return cached
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]]:
cache_key = f"locations:{account_id}"
if not refresh:
cached = self.client.cache_get(cache_key, LOCATIONS_TTL)
cached = self.client.cache_get(cache_key)
if cached is not None:
return cached
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)
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:
filter_obj = {}
mapping = {
@@ -81,16 +97,17 @@ class BmsService:
elif arg == "to_date":
val = f"{val}T23:59:59"
filter_obj[api_name] = val
page = int(kwargs.get("page", 1))
page_size = int(kwargs.get("page_size", 25))
return self.client._request("POST", "/v2/servicedesk/tickets/search", json_body={"Filter": filter_obj, "PageNumber": page, "PageSize": page_size})
return self.client._request(
"POST",
"/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:
return self.client._request("GET", f"/v2/servicedesk/tickets/{ticket_id}")
def get_template(self, template_id: int) -> dict[str, Any]:
payload = self.client._request("GET", f"/v2/servicedesk/templates/tickets/{template_id}")
return payload.get("Data", payload)
return self._pick_result(self.client._request("GET", f"/v2/servicedesk/templates/tickets/{template_id}"))
def list_ticket_templates(self) -> Any:
return self.client._request("GET", "/v2/servicedesk/templates/tickets/lookup")
@@ -122,7 +139,7 @@ class BmsService:
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)
required_missing = []
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")
if 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 = {
"Title": merged.title,
"Details": merged.details,
@@ -153,9 +174,11 @@ class BmsService:
if merged.due_date:
payload["DueDate"] = merged.due_date
response = self.client._request("POST", "/v2/servicedesk/tickets", json_body=payload)
success = response.get("success", response.get("Success"))
result = response.get("Data", response)
ticket_id = result.get("Id") if isinstance(result, dict) else None
result = self._pick_result(response)
success = response.get("success", response.get("Success", True)) if isinstance(response, dict) else True
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:
raise BmsError(f"Create ticket failed or returned ambiguous response: {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:
if not message:
raise BmsError("message is required")
payload = {
"Details": message,
"IsInternal": internal,
"TypeId": int(type_id),
"NoteDate": note_date or self._iso_now(),
}
payload = {"Details": message, "IsInternal": internal, "TypeId": int(type_id), "NoteDate": note_date or self._iso_now()}
if status_id is not None:
payload["StatusId"] = int(status_id)
return self.client._request("POST", f"/v2/servicedesk/tickets/{ticket_id}/notes", json_body=payload)
def update_note(self, ticket_id: int, note_id: int, *, message: str, type_id: int = 1, internal: bool = False, status_id: int | None = None, note_date: str | None = None) -> Any:
payload = {
"Id": int(note_id),
"Details": message,
"IsInternal": internal,
"TypeId": int(type_id),
"NoteDate": note_date or self._iso_now(),
}
payload = {"Id": int(note_id), "Details": message, "IsInternal": internal, "TypeId": int(type_id), "NoteDate": note_date or self._iso_now()}
if status_id is not None:
payload["StatusId"] = int(status_id)
return self.client._request("PUT", f"/v2/servicedesk/tickets/{ticket_id}/notes/{note_id}", json_body=payload)
@@ -191,14 +203,11 @@ class BmsService:
def delete_note(self, ticket_id: int, note_id: int) -> Any:
return self.client._request("DELETE", f"/v2/servicedesk/tickets/{ticket_id}/notes/{note_id}")
def 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:
payload = {
"Details": details,
"IsInternal": internal,
"TypeId": int(type_id),
"StatusId": int(status_id),
"NoteDate": note_date or self._iso_now(),
}
payload = {"Details": details, "IsInternal": internal, "TypeId": int(type_id), "StatusId": int(status_id), "NoteDate": note_date or self._iso_now()}
if assignee_id is not None:
payload["AssigneeId"] = int(assignee_id)
if queue_id is not None: