ConnectWise PSA skills and tools under ops/connectwise (hub-sanitized 2026-11-01: client intel, internal workflow/credential/member/company ids, archived-page apiToken/sessionId stripped to placeholders)
This commit is contained in:
commit
9c96092034
36 files changed
+310875
No files matched your search
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: connectwise-open-tickets-report
|
||||
description: Generate an accurate Open Tickets report from ConnectWise PSA targeting the VistaNet Help Desk board.
|
||||
---
|
||||
|
||||
# ConnectWise Open Tickets Report
|
||||
|
||||
This skill defines the precise requirements for generating the "Open Tickets" report from the ConnectWise PSA API.
|
||||
|
||||
## Trigger Conditions
|
||||
- User requests the "Open Tickets Report".
|
||||
- A cron job is scheduled to monitor open tickets.
|
||||
|
||||
## Technical Specifications
|
||||
- **API Endpoint**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0/service/tickets`
|
||||
- **Authentication**: Credentials (Company ID, Public Key, Private Key, and Client ID) must be read from `~/.secrets/connectwise` at runtime.
|
||||
- **Auth Header Pattern**: The API typically requires `Authorization: Basic base64(clientId:privateKey)`. Ensure the `clientId` is exactly as provided (check for missing characters in environment variables vs secret files) and that `Company-Id` is passed as a separate header.
|
||||
- **Critical Reference Syntax**: Use forward-slash notation for sub-references in the URL (e.g., `owner/identifier='SBrown'` and `board/id=1`). Do NOT use dot-notation (`owner.identifier`), as this returns a 400 Bad Request.
|
||||
- **Client ID**: Use the value found in `connectwise_reference.md` (or `/home/tech/connectwise_reference.md`). The API requires this as a separate header.
|
||||
- **Auth String Format**: Use `Basic base64(companyid+publickey:privatekey)` for the Authorization header. The literal `+` between companyid and publickey is the separator and must be present. If the public key itself begins with `+`, do not add an extra separator (verify the concatenated result does not produce `++`).
|
||||
- **Client ID Header**: The API requires a `clientId` header (distinct from the auth string). Refer to `connectwise_reference.md` for the current Client ID. Ensure the secret file includes the `CLIENT_ID` if required by the runtime endpoint.
|
||||
- **Conditions Logic**:
|
||||
- **Authentication**: Credentials (Company ID, Public Key, Private Key, and Client ID) must be read from `~/.secrets/connectwise` at runtime.
|
||||
- **Auth Header Pattern**: The API typically requires `Authorization: Basic base64(clientId:privateKey)`. Ensure the `clientId` is exactly as provided (check for missing characters in environment variables vs secret files) and that `Company-Id` is passed as a separate header.
|
||||
- **Critical Reference Syntax**: Use forward-slash notation for sub-references in the URL (e.g., `owner/identifier='SBrown'` and `board/id=1`). Do NOT use dot-notation (`owner.identifier`), as this returns a 400 Bad Request.
|
||||
- **Standard Filter**: `closedFlag=false` must be included to isolate open tickets.
|
||||
- **Status Filter**: Exclude tickets where status starts with `>` (e.g., `>Completed`).
|
||||
- `status != '>Completed'` (Filter in Python or URL).
|
||||
|
||||
## Delivery & Formatting
|
||||
- **Himalaya CLI Syntax**: The `himalaya send` command does not exist. Use `himalaya message send` and provide the raw email (including headers) via stdin or a file.
|
||||
- **Critical Pitfall (Himalaya)**: `himalaya message send` frequently panics (`index out of bounds`) when processing raw email files. To avoid this, pipe the raw email string (including `From:`, `To:`, and `Subject:`) directly to the command via stdin.
|
||||
- **Format**: Professional formatted text table or HTML.
|
||||
- **Notification**: Report results to the origin chat upon completion.
|
||||
|
||||
## Execution Workflow
|
||||
1. Construct the API request using `closedFlag=false` and the `owner/identifier` condition.
|
||||
2. Fetch tickets with a sufficiently large `pageSize` (e.g., 1000).
|
||||
3. Perform post-filtering in Python for criteria that cannot be reliably expressed in the API conditions (e.g., specific status name patterns like `NOT LIKE '>Completed'`).
|
||||
4. For SLA evaluation, inspect `isInSla`, `respondMinutes`, `resPlanMinutes`, `resolveMinutes`, and `slaStatus` on each ticket record.
|
||||
5. For ticket status updates, use JSON Patch format (`[{"op": "replace", "path": "/status/id", "value": <id>}]`) against `PATCH /service/tickets/{id}`.
|
||||
6. Format the output into a clean table containing: Ticket ID, Summary, Status, Company, and Date Entered.
|
||||
|
||||
## Ticket Updates
|
||||
- **JSON Patch Format Required**: CW v3 API uses RFC 6902 JSON Patch for `PATCH` requests. Do NOT send a flat JSON body.
|
||||
- Correct: `[{"op": "replace", "path": "/status/id", "value": 425}]`
|
||||
- Wrong: `{"status": {"id": 425}}` (returns 400)
|
||||
- **Status IDs are Board-Scoped**: The same status name may have different IDs on different boards. Always look up the status on the ticket's actual board (`/service/boards/{boardId}/statuses`) before patching.
|
||||
- **Self-Healed Status**: On the "Managed Service Alerts" board (ID 6), status "Self-Healed" = ID 425. Use this for bulk-closing stale RMM alerts.
|
||||
|
||||
## SLA Evaluation
|
||||
Key ticket fields for SLA analysis:
|
||||
- `isInSla` (bool): Whether the ticket is currently within SLA
|
||||
- `slaStatus` (string): Human-readable SLA text (e.g., "Respond by Tue 04/21 3:37 PM UTC-07")
|
||||
- `respondMinutes` (int): Minutes since ticket entered "respond" SLA window
|
||||
- `resPlanMinutes` (int): Minutes since ticket entered "resplan" SLA window
|
||||
- `resolveMinutes` (int): Minutes since ticket entered "resolve" SLA window
|
||||
- `escalationLevel` (int): Escalation tier (higher = more critical)
|
||||
- `priority.level`: Critical/High/Medium/Low
|
||||
- `severity` and `impact`: String values (Critical/High/Medium/Low)
|
||||
|
||||
See `references/sla_evaluation.md` for a full SLA scoring recipe.
|
||||
|
||||
## RMM Alert Noise Pattern
|
||||
- The "Managed Service Alerts" board (ID 6) auto-generates Priority 1 tickets from Sophos Central RMM.
|
||||
- Typical patterns: "Firewall has not contacted Sophos Central", "Gateway 'X' is Down", "Real time protection disabled".
|
||||
- These often create hundreds of unassigned P1s that are never triaged. A periodic bulk review (status `"new from RMM"` >7 days old → `"Self-Healed"`) is standard maintenance.
|
||||
- The single malware detection ticket (`C2/Generic-A`) should NEVER be auto-closed without human review.
|
||||
|
||||
## Execution Workflow
|
||||
1. Construct the API request using `closedFlag=false` and the `owner/identifier` condition.
|
||||
2. Fetch tickets with a sufficiently large `pageSize` (e.g., 1000).
|
||||
3. Perform post-filtering in Python for criteria that cannot be reliably expressed in the API conditions (e.g., specific status name patterns like `NOT LIKE '>Completed'`).
|
||||
4. For SLA evaluation, inspect `isInSla`, `respondMinutes`, `resPlanMinutes`, `resolveMinutes`, and `slaStatus` on each ticket record.
|
||||
5. For ticket status updates, use JSON Patch format (`[{"op": "replace", "path": "/status/id", "value": <id>}]`) against `PATCH /service/tickets/{id}`.
|
||||
6. Format the output into a clean table containing: Ticket ID, Summary, Status, Company, and Date Entered.
|
||||
|
||||
## Ticket Updates
|
||||
- **JSON Patch Format Required**: CW v3 API uses RFC 6902 JSON Patch for `PATCH` requests. Do NOT send a flat JSON body.
|
||||
- Correct: `[{"op": "replace", "path": "/status/id", "value": 425}]`
|
||||
- Wrong: `{"status": {"id": 425}}` (returns 400)
|
||||
- **Status IDs are Board-Scoped**: The same status name may have different IDs on different boards. Always look up the status on the ticket's actual board (`/service/boards/{boardId}/statuses`) before patching.
|
||||
- **Self-Healed Status**: On the "Managed Service Alerts" board (ID 6), status "Self-Healed" = ID 425. Use this for bulk-closing stale RMM alerts.
|
||||
|
||||
## SLA Evaluation
|
||||
Key ticket fields for SLA analysis:
|
||||
- `isInSla` (bool): Whether the ticket is currently within SLA
|
||||
- `slaStatus` (string): Human-readable SLA text (e.g., "Respond by Tue 04/21 3:37 PM UTC-07")
|
||||
- `respondMinutes` (int): Minutes since ticket entered "respond" SLA window
|
||||
- `resPlanMinutes` (int): Minutes since ticket entered "resplan" SLA window
|
||||
- `resolveMinutes` (int): Minutes since ticket entered "resolve" SLA window
|
||||
- `escalationLevel` (int): Escalation tier (higher = more critical)
|
||||
- `priority.level`: Critical/High/Medium/Low
|
||||
- `severity` and `impact`: String values (Critical/High/Medium/Low)
|
||||
|
||||
See `references/sla_evaluation.md` for a full SLA scoring recipe.
|
||||
|
||||
## RMM Alert Noise Pattern
|
||||
- The "Managed Service Alerts" board (ID 6) auto-generates Priority 1 tickets from Sophos Central RMM.
|
||||
- Typical patterns: "Firewall has not contacted Sophos Central", "Gateway 'X' is Down", "Real time protection disabled".
|
||||
- These often create hundreds of unassigned P1s that are never triaged. A periodic bulk review (status `"new from RMM"` >7 days old → `"Self-Healed"`) is standard maintenance.
|
||||
- The single malware detection ticket (`C2/Generic-A`) should NEVER be auto-closed without human review.
|
||||
|
||||
## Pitfalls & Lessons
|
||||
- **URL Condition Syntax**: Use forward-slash notation for sub-references in URL `conditions` (e.g., `owner/identifier='SBrown'`, `board/id=1`) rather than dot notation. Dot notation often triggers 400 Bad Request errors.
|
||||
- **Date Source**: Top-level `dateEntered` on the ticket record may be absent or unreliable. Use `_info.dateEntered` from the embedded `_info` object — this is the canonical timestamp.
|
||||
- **Himalaya CLI Syntax**: The `himalaya send` command does not exist. Use `himalaya message send` and provide the raw email (including headers) via stdin or a file.
|
||||
- **Critical Pitfall (Himalaya)**: `himalaya message send` frequently panics (`index out of bounds`) when processing raw email files. To avoid this, pipe the raw email string (including `From:`, `To:`, and `Subject:`) directly to the command via stdin.
|
||||
- **500 Error "Length cannot be less than zero"**: If this error occurs, it typically indicates an authentication failure or an incorrectly formatted request that bypassed the 401 layer. Verify that the `Authorization` header follows the `companyid+publickey:privatekey` format and a valid `clientId` is present.
|
||||
- **Authorization Header**: Ensure the Base64 string follows the `companyid+publickey:privatekey` format exactly, where `+` is the literal separator. If the public key itself begins with `+`, do not add an extra separator. Simple `companyid:publickey:privatekey` (colon between all three) will result in a 401 Unauthorized error.
|
||||
- **Client ID**: The `clientId` must be passed as a separate header, not just part of the auth string.
|
||||
- **Pandas Dependency**: The helper script `scripts/generate_open_tickets.py` depends on `pandas`. In cron/headless environments where `pandas` is missing, implement manual HTML table generation using standard Python lists to avoid dependency errors.
|
||||
- **RTF Report Extraction**: When processing Nexpose/Qualys RTF reports, standard tools (`striprtf`, `unrtf`, `catdoc`) fail because these files contain embedded binary objects (`\shp`, `\pict`, `\bin`). Use `strings -n 15` plus targeted grep, or a custom byte-level parser that skips destination groups. See the `document-extraction-workflows` skill for a reusable Python extractor.
|
||||
- **SMTP for Rich Email**: When `himalaya` cannot handle HTML attachments or complex MIME, fall back to Python `smtplib` with `email.mime.multipart`. Parse credentials from `~/.config/himalaya/config.toml` if available.
|
||||
@@ -0,0 +1,107 @@
|
||||
# ConnectWise SLA Evaluation Recipe
|
||||
|
||||
Reference: `sla_evaluation.md` — How to evaluate SLA state and score tickets for prioritization using the CW v3 API.
|
||||
|
||||
## Fields to Read
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `isInSla` | bool | False = breached |
|
||||
| `slaStatus` | string | e.g. "Respond by Tue 04/21 3:37 PM UTC-07" |
|
||||
| `respondMinutes` | int | Minutes in "respond" phase |
|
||||
| `resPlanMinutes` | int | Minutes in "resplan" phase |
|
||||
| `resolveMinutes` | int | Minutes in "resolve" phase |
|
||||
| `escalationLevel` | int | Tier (higher = worse) |
|
||||
| `priority.level` | string | Critical / High / Medium / Low |
|
||||
| `severity` | string | Critical / High / Medium / Low |
|
||||
| `impact` | string | Critical / High / Medium / Low |
|
||||
| `_info.dateEntered` | ISO string | Canonical creation time |
|
||||
|
||||
## Composite Scoring Formula
|
||||
|
||||
```python
|
||||
score = 0
|
||||
|
||||
if sla_overdue:
|
||||
score += 1000
|
||||
elif sla_state == 'Due <24h':
|
||||
score += 500
|
||||
elif sla_state == 'Due <72h':
|
||||
score += 200
|
||||
|
||||
score += (escalation or 0) * 50
|
||||
|
||||
prio_map = {
|
||||
'Priority 1 - Critical': 400,
|
||||
'Priority 2 - High': 200,
|
||||
'Priority 3 - Medium': 50,
|
||||
'Priority 4 - Low': 10
|
||||
}
|
||||
score += prio_map.get(priority_name, 0)
|
||||
|
||||
if severity in ('Critical', 'High'):
|
||||
score += 100
|
||||
if impact in ('Critical', 'High'):
|
||||
score += 100
|
||||
|
||||
if date_entered:
|
||||
age_days = (now - date_entered).total_seconds() / 86400
|
||||
score += min(int(age_days) * 5, 300)
|
||||
```
|
||||
|
||||
## SLA State Parsing
|
||||
|
||||
```python
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
def parse_sla(sla_status_text):
|
||||
if not sla_status_text or sla_status_text == 'Resolved':
|
||||
return sla_status_text or 'No SLA'
|
||||
m = re.search(r'(Respond|Resolve)\s+by\s+(.+)', sla_status_text, re.IGNORECASE)
|
||||
if m:
|
||||
due_text = m.group(2).strip()
|
||||
try:
|
||||
dt = datetime.strptime(due_text, '%a %m/%d %I:%M %p %Z%z')
|
||||
if dt < now:
|
||||
return 'Overdue'
|
||||
hours = (dt - now).total_seconds() / 3600
|
||||
if hours < 24:
|
||||
return 'Due <24h'
|
||||
elif hours < 72:
|
||||
return 'Due <72h'
|
||||
return 'On Track'
|
||||
except ValueError:
|
||||
return 'Due (unparsed)'
|
||||
return sla_status_text
|
||||
```
|
||||
|
||||
## RMM Alert Categories (for filtering / triage)
|
||||
|
||||
Keyword-based categorization of common Sophos/RMM-generated summaries:
|
||||
|
||||
| Category | Keywords |
|
||||
|----------|----------|
|
||||
| Firewall Sophos Offline | `firewall` + `sophos` |
|
||||
| Firewall Heartbeat | `not sending heartbeat` |
|
||||
| Gateway Down | `gateway` + `down` |
|
||||
| Malware Detection | `malicious`, `c2/` |
|
||||
| Device Offline | `has not contacted` |
|
||||
| Disk/Storage | `disk`, `drive`, `storage`, `c:` |
|
||||
| CPU Alert | `cpu` |
|
||||
| Memory Alert | `memory`, `ram` |
|
||||
| Reboot Required | `reboot`, `restart` |
|
||||
| AV Alert | `antivirus`, `virus` |
|
||||
|
||||
## Bulk Close Recipe
|
||||
|
||||
Update stale "new from RMM" tickets to "Self-Healed":
|
||||
|
||||
```python
|
||||
# 1. Fetch all open tickets
|
||||
# 2. Filter: status == 'new from RMM' AND _info.dateEntered > 7 days ago
|
||||
# 3. PATCH each: [{"op": "replace", "path": "/status/id", "value": 425}]
|
||||
# Status ID 425 = "Self-Healed" on board 6 (Managed Service Alerts)
|
||||
```
|
||||
|
||||
**Never auto-close malware detections without human review.**
|
||||
@@ -0,0 +1,12 @@
|
||||
# ConnectWise PSA Ticket Report Logic
|
||||
Reference for the `cw_open_tickets.py` script logic.
|
||||
|
||||
## Filtering Logic
|
||||
- **URL Condition**: `closedFlag = false` (Must be URL-encoded).
|
||||
- **Post-Processing**:
|
||||
- `owner.identifier == 'SBrown'`
|
||||
- `board.name == 'VistaNet Help Desk'`
|
||||
- (Optional) `status.name != '>Completed'`
|
||||
|
||||
## Production Notes
|
||||
- Historically, `id >= 110000` was required to avoid the 2011 legacy mirror. Current implementation prioritizes `closedFlag=false`.
|
||||
@@ -0,0 +1,69 @@
|
||||
import base64
|
||||
import requests
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
def generate_report(owner_id, company_id, public_key, private_key, client_id):
|
||||
base_url = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
|
||||
|
||||
# Auth Format: Basic base64(companyidpublickey:privatekey)
|
||||
auth_str = f"{company_id}{public_key}:{private_key}"
|
||||
encoded_auth = base64.b64encode(auth_str.encode()).decode()
|
||||
headers = {
|
||||
'Authorization': f'Basic {encoded_auth}',
|
||||
'clientId': client_id,
|
||||
'Company-Id': company_id,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
conditions = f"closedFlag=false and owner/identifier='{owner_id}'"
|
||||
url = f"{base_url}/service/tickets?conditions={conditions}"
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: API request failed with status {response.status_code}: {response.text}")
|
||||
sys.exit(1)
|
||||
|
||||
tickets = response.json()
|
||||
if not tickets:
|
||||
return f"No open tickets found for {owner_id}."
|
||||
|
||||
data = []
|
||||
for t in tickets:
|
||||
status_name = t.get('status', {}).get('name', 'Unknown') if isinstance(t.get('status'), dict) else t.get('status', 'Unknown')
|
||||
if status_name.startswith('>'):
|
||||
continue
|
||||
|
||||
data.append({
|
||||
'Ticket #': t.get('id', 'N/A'),
|
||||
'Company': t.get('company', {}).get('name', 'N/A') if isinstance(t.get('company'), dict) else t.get('company', 'N/A'),
|
||||
'Owner': t.get('owner', {}).get('identifier', 'N/A') if isinstance(t.get('owner'), dict) else t.get('owner', 'N/A'),
|
||||
'Status': status_name,
|
||||
'Summary': t.get('summary', 'No Summary'),
|
||||
'Date Entered': t.get('dateEntered', 'N/A')
|
||||
})
|
||||
|
||||
if not data:
|
||||
return f"No open tickets found for {owner_id} (excluding completed statuses)."
|
||||
|
||||
df = pd.DataFrame(data).sort_values(by='Date Entered', ascending=False)
|
||||
|
||||
return f'''
|
||||
<div style="font-family: Arial, Helvetica, sans-serif; color: #333;">
|
||||
<h3 style="color: #0056b3;">Open Tickets Report: {owner_id}</h3>
|
||||
<p>Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
{df.to_html(index=False, border=1, justify='left')}
|
||||
</div>
|
||||
'''
|
||||
|
||||
if __name__ == "__main__":
|
||||
# This script is intended to be used by the agent providing credentials from a secret store
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: generate_open_tickets.py <owner_id>")
|
||||
sys.exit(1)
|
||||
|
||||
# Note: Actual credentials should be passed via env or a wrapper;
|
||||
# the agent should read from ~/.secrets/connectwise
|
||||
print("Error: This script requires a wrapper to inject credentials from ~/.secrets/connectwise")
|
||||
sys.exit(1)
|
||||
Reference in new issue
Block a user