Migrate BMS skill to Python-only CLI with audit logging
This commit is contained in:
195
README.md
195
README.md
@@ -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
108
SKILL.md
@@ -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 Daniel’s direct use and BMS operator workflows
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__"]
|
||||
__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 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user