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:
Hermes Agent committed 2026-11-01 13:34:58 -08:00
commit 9c96092034
36 files changed
+310875

No files matched your search

@@ -0,0 +1,68 @@
# ConnectWise Ticket Fetching Implementation
Implementation details for fetching tickets from the VistaNet ConnectWise instance.
## API Configuration
- **Base URL**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
- **Authentication**: Basic Auth using `companyid+publickey:privatekey`.
- **Required Header**: `clientId` (Crucial: 401 Unauthorized is returned if this is missing or incorrect).
## Implementation Pattern
Since server-side filtering is unreliable, the reliable pattern is:
1. Fetch open tickets using `conditions=id >= [RecentID] AND closedFlag=false` with a large `pageSize`.
2. Filter JSON **locally in Python** for the target company by `company.id`.
3. (Optional) Filter for specific owner, board, or other criteria locally.
4. Filter out statuses starting with `>` (e.g., `>Completed`) to identify truly open tickets.
### Why local filtering is required
The `companyId` query parameter on `/service/tickets` is **silently ignored** by the API and returns tickets from all companies. Server-side `conditions` on identifiers also often return empty lists or incorrect results. Always fetch broad and filter client-side.
## Example: All open tickets for a specific company
```python
import base64, requests, json
BASE_URL = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
CLIENT_ID = '<CLIENT_ID>'
COMPANY_ID = 'VNI'
PUBLIC_KEY = '<PUBLIC_KEY>'
PRIVATE_KEY = '<PRIVATE_KEY>'
auth_b64 = base64.b64encode(f'{COMPANY_ID}+{PUBLIC_KEY}:{PRIVATE_KEY}'.encode()).decode()
headers = {
'Authorization': f'Basic {auth_b64}',
'clientId': CLIENT_ID,
'Content-Type': 'application/json'
}
# 1. Fetch broad — companyId is ignored, so we use id range to avoid legacy mirroring
url = f'{BASE_URL}/service/tickets?pageSize=1000&conditions=id%20%3E%3D%2080000%20AND%20closedFlag%3Dfalse'
resp = requests.get(url, headers=headers, timeout=45)
resp.raise_for_status()
tickets = resp.json()
# 2. Filter client-side for Miller Glass (company.id == 19502)
miller_tickets = [
t for t in tickets
if t.get('company', {}).get('id') == 19502
and not t.get('status', {}).get('name', '').startswith('>')
and t.get('dateResolved') is None
]
```
## "Open" ticket definition
A ticket is considered open when ALL of the following are true:
- `closedFlag` is `false`
- Status name does NOT start with `>` (e.g., `>Closed`, `>Completed`)
- `dateResolved` is `None` or absent
## Searching ticket NOTES for text across a company's tickets
Note text is NOT searchable server-side on this instance: `POST /service/tickets/search` accepts `childconditions` in its FilterValues body but **silently ignores it** — verified that six different `childconditions` values (`notes/text contains '...'`) all returned identical result sets, with no error to warn you. Never conclude a note doesn't exist based on a childconditions query.
Working pattern (validated end-to-end, ~0.25 s/ticket):
1. Resolve the company: `GET /company/companies?conditions=name contains 'Partial'` → cache `company.id`.
2. Size the corpus: `GET /service/tickets/count?conditions=company/id={id}` (the count endpoint honors the condition, unlike the list endpoint's bare `companyId` param). Bound it with `AND dateEntered >= [YYYY-01-01T00:00:00Z]` when the ask is recency-scoped — the date condition is also honored and shrank a 4,301-ticket corpus to 1,086.
3. Page-fetch ticket ids: `GET /service/tickets?conditions=company/id={id} AND dateEntered >= [...]&pageSize=200&page={n}&orderBy=id asc&fields=id,summary,status/name,closedFlag,dateResolved` until a short page. Persist the array to a scratch JSON file between calls — kernel state does not survive a timeout.
4. Per ticket: `GET /service/tickets/{id}/notes?pageSize=1000` and regex the `text` client-side. pageSize=1000 on a single ticket's notes is safe; the corruption concern applies to large LIST fetches only.
5. Triage matches by snippet before reporting: generic keywords ('office', 'field') drown in "out of the office" autoresponder noise from email-logged notes — the signal is usually in `internalFlag=true` notes written by members. Email/contact notes carry `member: null`; read `contact` or the text for attribution.
Budget runtime: 1,000+ tickets of note fetches takes ~4–5 minutes — chunk the scan (~300 tickets per execute_code call) and append matches to a scratch JSON so a timeout loses nothing.