rhombus-node-mcp 0.1.55 → 0.1.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/access-control-tool-api.js +231 -32
- package/dist/api/alarm-monitoring-tool-api.js +50 -1
- package/dist/api/automated-prompts-tool-api.js +9 -17
- package/dist/api/camera-policy-tool-api.js +122 -0
- package/dist/api/camera-tool-api.js +3 -5
- package/dist/api/camera-uptime-tool-api.js +62 -25
- package/dist/api/clips-tool-api.js +45 -9
- package/dist/api/create-tool-api.js +55 -7
- package/dist/api/door-tool-api.js +74 -13
- package/dist/api/entity-lookup-tool-api.js +8 -1
- package/dist/api/events-tool-api.js +79 -6
- package/dist/api/faces-tool-api.js +112 -7
- package/dist/api/get-accessible-apps.js +68 -6
- package/dist/api/get-entity-tool-api.js +12 -12
- package/dist/api/get-org-information-tool-api.js +2 -2
- package/dist/api/guest-management-tool-api.js +75 -1
- package/dist/api/location-tool-api.js +120 -28
- package/dist/api/lpr-tool-api.js +71 -16
- package/dist/api/policy-alerts-tool-api.js +7 -3
- package/dist/api/report-tool-api.js +4 -1
- package/dist/api/rules-tool-api.js +6 -16
- package/dist/api/schedule-tool-api.js +107 -0
- package/dist/api/search-tool-api.js +5 -13
- package/dist/api/update-tool-api.js +96 -1
- package/dist/api/user-tool-api.js +58 -13
- package/dist/createServer.js +18 -6
- package/dist/filtering-utils.js +421 -29
- package/dist/index.js +1 -1
- package/dist/network/network.js +122 -10
- package/dist/network/org-reference-cache.js +74 -0
- package/dist/telemetry/tracingProxy.js +4 -0
- package/dist/tools/entity-lookup-tool.js +8 -4
- package/dist/tools/get-entity-tool.js +46 -21
- package/dist/tools/get-org-information-tool.js +36 -3
- package/dist/tools/time-conversion-tool.js +65 -37
- package/dist/tools/time-tool.js +18 -4
- package/dist/tools/user-tool.js +101 -1
- package/dist/tools-console/access-anomaly-tool.js +4 -19
- package/dist/tools-console/access-control-tool.js +330 -2
- package/dist/tools-console/alarm-monitoring-tool.js +73 -1
- package/dist/tools-console/analytics-tool.js +4 -2
- package/dist/tools-console/automated-prompts-tool.js +2 -0
- package/dist/tools-console/badge-timeline-tool.js +4 -21
- package/dist/tools-console/camera-policy-tool.js +252 -0
- package/dist/tools-console/camera-tool.js +113 -89
- package/dist/tools-console/camera-uptime-tool.js +2 -0
- package/dist/tools-console/clips-tool.js +23 -31
- package/dist/tools-console/count-tool.js +19 -6
- package/dist/tools-console/create-camera-policy-tool.js +304 -122
- package/dist/tools-console/door-schedule-exception-tool.js +4 -33
- package/dist/tools-console/door-tool.js +131 -1
- package/dist/tools-console/elements-access-anomaly-tool.js +4 -19
- package/dist/tools-console/elements-badge-timeline-tool.js +4 -21
- package/dist/tools-console/elements-lost-badge-tool.js +4 -17
- package/dist/tools-console/elements-tool.js +4 -29
- package/dist/tools-console/events-tool.js +24 -101
- package/dist/tools-console/faces-tool.js +255 -27
- package/dist/tools-console/guest-management-tool.js +128 -1
- package/dist/tools-console/location-tool.js +161 -35
- package/dist/tools-console/lost-badge-tool.js +4 -17
- package/dist/tools-console/lpr-tool.js +90 -22
- package/dist/tools-console/netbox-access-anomaly-tool.js +4 -19
- package/dist/tools-console/netbox-badge-timeline-tool.js +4 -21
- package/dist/tools-console/netbox-lost-badge-tool.js +4 -17
- package/dist/tools-console/netbox-tool.js +4 -29
- package/dist/tools-console/onguard-tool.js +4 -30
- package/dist/tools-console/person-tracking-tool.js +4 -2
- package/dist/tools-console/policy-alerts-tool.js +6 -28
- package/dist/tools-console/reboot-cameras-tool.js +23 -3
- package/dist/tools-console/report-tool.js +12 -40
- package/dist/tools-console/rules-tool.js +2 -0
- package/dist/tools-console/schedule-tool.js +190 -0
- package/dist/tools-console/search-tool.js +2 -0
- package/dist/tools-console/update-tool.js +300 -168
- package/dist/tools-console/user-access-trail-tool.js +2 -0
- package/dist/tools-console/user-audit-tool.js +2 -0
- package/dist/tools-console/video-walls-tool.js +94 -20
- package/dist/types/access-anomaly-tool-types.js +22 -2
- package/dist/types/access-control-tool-types.js +116 -1
- package/dist/types/alarm-monitoring-tool-types.js +42 -1
- package/dist/types/badge-timeline-tool-types.js +18 -3
- package/dist/types/camera-policy-tool-types.js +104 -0
- package/dist/types/camera-tool-types.js +80 -1
- package/dist/types/clips-tool-types.js +27 -3
- package/dist/types/create-camera-policy-tool-types.js +275 -26
- package/dist/types/door-schedule-exception-tool-types.js +20 -6
- package/dist/types/door-tool-types.js +36 -4
- package/dist/types/entity-lookup-tool-types.js +6 -1
- package/dist/types/events-tools-types.js +24 -16
- package/dist/types/faces-tools-types.js +106 -3
- package/dist/types/get-entity-tool-types.js +76 -12
- package/dist/types/guest-management-tool-types.js +103 -1
- package/dist/types/location-tool-types.js +100 -14
- package/dist/types/lost-badge-tool-types.js +17 -2
- package/dist/types/lpr-tool-types.js +47 -8
- package/dist/types/onguard-tool-types.js +21 -1
- package/dist/types/policy-alerts-tool-types.js +1 -1
- package/dist/types/report-tool-types.js +29 -1
- package/dist/types/schedule-tool-types.js +370 -0
- package/dist/types/schema.js +479 -9
- package/dist/types/time-conversion-tool-types.js +3 -4
- package/dist/types/update-tool-types.js +40 -18
- package/dist/types/user-tool-types.js +44 -1
- package/dist/types/video-walls-tool-types.js +30 -2
- package/dist/types/zod-schemas.js +5128 -996
- package/dist/util.js +62 -3
- package/dist/utils/entity-name-match.js +11 -0
- package/package.json +3 -1
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createUuidSchema } from "../types.js";
|
|
3
3
|
import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
|
|
4
|
+
/**
|
|
5
|
+
* Description shared by the three badge-integration event tools
|
|
6
|
+
* (onguard-events-tool / elements-events-tool / netbox-events-tool), which
|
|
7
|
+
* differ only in vendor.
|
|
8
|
+
*
|
|
9
|
+
* Kept deliberately short: tool descriptions are billed on EVERY LLM call even
|
|
10
|
+
* while the tool is deferred behind hosted tool_search, and this text is
|
|
11
|
+
* duplicated across all three siblings. Filter semantics live on the arguments
|
|
12
|
+
* below (unbilled until the tool loads). See PERF_MASTER_PLAN P2 #4a.
|
|
13
|
+
*/
|
|
14
|
+
export function buildBadgeEventsToolDescription(vendor, labelPrefix) {
|
|
15
|
+
return `
|
|
16
|
+
Searches ${vendor} badge / access-control events for the organization. Use this to answer "who entered WHERE and WHEN" questions, e.g. "who entered the back office yesterday". Each event carries cardholderName (the person), deviceUuid (the camera that saw it), timestampMs/datetime, label (e.g. "${labelPrefix}: Badge Authorized" for a grant, or an anomaly label), badgeStatus, badgeType, areaEntering, areaExiting, entryMade, isAnomaly.
|
|
17
|
+
|
|
18
|
+
**Siblings — for a generic "who badged in / did anyone enter" question, call ALL THREE in parallel:** onguard-events-tool, elements-events-tool and netbox-events-tool take identical arguments and return the same shape; an org may run any combination of those integrations, and each returns an empty list when its integration isn't configured. Narrow to one vendor only when the user names it. Native Rhombus ACU doors and Brivo doors are NOT covered here — those badge events come from events-tool (eventType "access-control" / "brivo-access-control"), so include it for generic badge questions too.
|
|
19
|
+
|
|
20
|
+
After results, to show who it was: per event call camera-tool (requestType "image", cameraUuid = the event's deviceUuid, timestampISO = its time) and/or clips-tool (requestType "createClip") for a short window around the timestamp — issue those media calls IN PARALLEL.
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
4
23
|
export const TOOL_ARGS = {
|
|
5
24
|
area: z
|
|
6
25
|
.string()
|
|
@@ -32,7 +51,8 @@ export const TOOL_ARGS = {
|
|
|
32
51
|
.string()
|
|
33
52
|
.datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
|
|
34
53
|
.nullable()
|
|
35
|
-
.describe("Only events at or after this time (inclusive). "
|
|
54
|
+
.describe("Only events at or after this time (inclusive). Resolve relative phrasing like \"yesterday\" with time-tool first, then pass ISO 8601 here. " +
|
|
55
|
+
ISOTimestampFormatDescription),
|
|
36
56
|
endTime: z
|
|
37
57
|
.string()
|
|
38
58
|
.datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
|
|
@@ -4,7 +4,7 @@ import { createEpochSchema, ISOTimestampFormatDescription } from "../utils/times
|
|
|
4
4
|
export const TOOL_ARGS = {
|
|
5
5
|
queryType: z
|
|
6
6
|
.enum(["existing", "expiringSoon", "details", "dismiss", "unhealthy-devices", "alert-groups"])
|
|
7
|
-
.describe('The
|
|
7
|
+
.describe('The operation to perform. "existing" = current policy alerts; "expiringSoon" = policy alerts nearing their expiration date; "details" = full details for one alert (requires alertUuid); "dismiss" = dismiss one alert (requires alertUuid — this is a mutation, confirm with the user first); "unhealthy-devices" = historical device-health alert notifications (NOT live connection status — see the tool description); "alert-groups" = policy alerts clustered into groups.'),
|
|
8
8
|
afterTimestampISO: z
|
|
9
9
|
.string()
|
|
10
10
|
.datetime({ message: "Invalid ISO 8601 date format.", offset: true })
|
|
@@ -29,7 +29,35 @@ export const TOOL_ARGS = z.object({
|
|
|
29
29
|
RequestType.GET_THRESHOLD_CROSSING_EVENTS,
|
|
30
30
|
RequestType.GET_CUSTOM_EVENTS_REPORT,
|
|
31
31
|
RequestType.GET_PEOPLE_COUNT_EVENTS,
|
|
32
|
-
])
|
|
32
|
+
]).describe(`Which report to run.
|
|
33
|
+
|
|
34
|
+
**People / occupancy counting strategy.** When asked to count people on a camera or at a location:
|
|
35
|
+
1. **Always call GET_OCCUPANCY_ENABLED_CAMERAS first** to discover which cameras have occupancy counting enabled.
|
|
36
|
+
2. If the target camera IS in the list, call **GET_OCCUPANCY_COUNT_REPORT** for that device. The response automatically includes a \`faceCountEnrichment\` field with the number of unique individuals identified by face recognition in the same time range. Present both data sources: occupancy estimate and unique face count.
|
|
37
|
+
3. If the target camera is NOT in the list, **tell the user** that camera does not have occupancy counting enabled, and list the cameras that do. You can still call GET_SUMMARY_COUNT_REPORT with PEOPLE type — its response also includes \`faceCountEnrichment\` with unique face data as a fallback. If the PEOPLE count returns zero, the response also includes the list of occupancy-enabled cameras and a hint.
|
|
38
|
+
4. When both occupancy data and face recognition data are available, **synthesize both** in your answer (e.g. "Occupancy estimates ~15 people. Face recognition identified 9 unique individuals during this period.").
|
|
39
|
+
|
|
40
|
+
**PEOPLE type (in GET_SUMMARY_COUNT_REPORT):** not a unique person count; it counts people-detection events, and requires people detection to be enabled on the camera. Use for high-level activity trends, not for deduplicated head counts.
|
|
41
|
+
|
|
42
|
+
**Summary and occupancy**
|
|
43
|
+
- GET_SUMMARY_COUNT_REPORT: aggregated counts (people, faces, motion, vehicles, etc.) over time at device, location, or org scope. Interval: minutely, hourly, daily, weekly, monthly, yearly. With PEOPLE type at DEVICE scope the response is automatically enriched with face recognition data.
|
|
44
|
+
- GET_OCCUPANCY_ENABLED_CAMERAS: cameras with occupancy reporting enabled. **Always call this first** before any people/occupancy counting request to verify camera support.
|
|
45
|
+
- GET_OCCUPANCY_COUNT_REPORT: occupancy count time series for one device over a time range, enriched with face recognition data. If the device does not support occupancy, the response includes a hint and the list of cameras that do.
|
|
46
|
+
|
|
47
|
+
**Line crossing**
|
|
48
|
+
- GET_LINE_CROSSING_ENABLED_CAMERAS: cameras at a location with line crossing enabled, plus their configs. Call first to see which cameras support threshold crossing reports.
|
|
49
|
+
- GET_THRESHOLD_CROSSING_COUNT_REPORT: ingress/egress counts for line crossings over time. Supports human and vehicle detection; bucket size quarter hour, hour, day, or week. Response includes computed metrics: average entries/exits per hour, hour with most entries/exits, busiest hour (with breakdown).
|
|
50
|
+
- GET_THRESHOLD_CROSSING_EVENTS: individual line-crossing events (not aggregated counts).
|
|
51
|
+
|
|
52
|
+
**Custom LLM events**
|
|
53
|
+
- FIND_PROMPT_CONFIGURATIONS: all custom event prompt configurations. Each has prompt text, UUID, and promptType (COUNT, PERCENT, BOOLEAN). Call first to discover available custom events.
|
|
54
|
+
- GET_CUSTOM_LLM_REPORT: **the PRIMARY way to get custom event reports.** Aggregated time series for one custom event by prompt UUID; automatically selects the correct API from promptType — COUNT (numeric counts), PERCENT (percentages), BOOLEAN (true/false). Intervals: minutely, quarter-hourly, hourly, daily, weekly, monthly. **Always use this for custom event reports, trends, and analytics.** Use FIND_PROMPT_CONFIGURATIONS first to get the promptUuid and promptType.
|
|
55
|
+
- GET_CUSTOM_EVENTS_REPORT: raw individual event values only (not aggregated). Use only when you need per-event granularity, not for reports or trends.
|
|
56
|
+
|
|
57
|
+
**Audit and diagnostics**
|
|
58
|
+
- GET_AUDIT_FEED: audit log of all user/admin actions in the org over a time range — who did what and when (principalName, targetName, action, displayText).
|
|
59
|
+
- GET_DIAGNOSTIC_FEED: device diagnostic events over a time range.
|
|
60
|
+
- GET_PEOPLE_COUNT_EVENTS: most recent people count readings for specified devices.`),
|
|
33
61
|
occupancyCountRequest: z
|
|
34
62
|
.object({
|
|
35
63
|
deviceUuid: z
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatMinuteOfWeek } from "../util.js";
|
|
3
|
+
export var ScheduleToolRequestType;
|
|
4
|
+
(function (ScheduleToolRequestType) {
|
|
5
|
+
ScheduleToolRequestType["LIST"] = "list";
|
|
6
|
+
ScheduleToolRequestType["GET"] = "get";
|
|
7
|
+
ScheduleToolRequestType["CREATE"] = "create";
|
|
8
|
+
ScheduleToolRequestType["UPDATE"] = "update";
|
|
9
|
+
ScheduleToolRequestType["DELETE"] = "delete";
|
|
10
|
+
ScheduleToolRequestType["GET_POLICIES_USING"] = "get-policies-using";
|
|
11
|
+
})(ScheduleToolRequestType || (ScheduleToolRequestType = {}));
|
|
12
|
+
/**
|
|
13
|
+
* The only strategy this tool writes. api2's `ScheduleStrategyEnum` also has
|
|
14
|
+
* ABSOLUTE_SECONDS / REALTIME_RELATIVE_SECONDS / RELATIVE_DATETIME_INTERVALS,
|
|
15
|
+
* but those are stored through a different endpoint family
|
|
16
|
+
* (`/schedule/create*`) with a different interval shape, so accepting them here
|
|
17
|
+
* would produce a schedule the write path cannot actually populate.
|
|
18
|
+
*/
|
|
19
|
+
export const WEEKLY_REPEATING_STRATEGY = "WEEKLY_REPEATING_MINUTES";
|
|
20
|
+
export const INTERVALS_EXAMPLE = '[{"days":["MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY"],"startTime":"09:00","endTime":"17:00"}]';
|
|
21
|
+
export const TOOL_ARGS = {
|
|
22
|
+
requestType: z
|
|
23
|
+
.nativeEnum(ScheduleToolRequestType)
|
|
24
|
+
.describe("The type of schedule operation to perform."),
|
|
25
|
+
scheduleUuid: z
|
|
26
|
+
.string()
|
|
27
|
+
.nullable()
|
|
28
|
+
.describe("The UUID of the schedule. Required for 'get', 'update', 'delete', and 'get-policies-using'."),
|
|
29
|
+
name: z
|
|
30
|
+
.string()
|
|
31
|
+
.nullable()
|
|
32
|
+
.describe("The schedule's display name, e.g. \"Business Hours\". Required for 'create'; optional for 'update' (omit to leave the name unchanged)."),
|
|
33
|
+
intervals: z
|
|
34
|
+
.string()
|
|
35
|
+
.nullable()
|
|
36
|
+
.describe(`JSON string describing the weekly windows the schedule is ACTIVE. Required for 'create'; ` +
|
|
37
|
+
`optional for 'update' (omit to leave the windows unchanged — passing it REPLACES all ` +
|
|
38
|
+
`existing windows). Preferred shape is a JSON array of objects with "days" (a list of ` +
|
|
39
|
+
`MONDAY…SUNDAY), "startTime" and "endTime" as 24-hour "HH:MM" — e.g. ${INTERVALS_EXAMPLE}. ` +
|
|
40
|
+
`A window that ends before it starts is treated as crossing midnight into the next day. ` +
|
|
41
|
+
`Raw {"minuteOfWeekStart":N,"minuteOfWeekStop":N} pairs (minutes since Monday 00:00) are ` +
|
|
42
|
+
`also accepted.`),
|
|
43
|
+
confirmDeleteInUse: z
|
|
44
|
+
.boolean()
|
|
45
|
+
.nullable()
|
|
46
|
+
.describe("Only for 'delete'. Deleting a schedule that policies still reference changes those " +
|
|
47
|
+
"policies' behaviour, so 'delete' refuses by default and lists the policies using it. " +
|
|
48
|
+
"Set true to delete anyway, after telling the user which policies are affected."),
|
|
49
|
+
};
|
|
50
|
+
const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
|
|
51
|
+
const INTERVAL_OUTPUT = z.object({
|
|
52
|
+
minuteOfWeekStart: z.number().optional().describe("Minutes since Monday 00:00 when the window opens."),
|
|
53
|
+
minuteOfWeekStop: z.number().optional().describe("Minutes since Monday 00:00 when the window closes."),
|
|
54
|
+
window: z
|
|
55
|
+
.string()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('The same window rendered for reading, e.g. "Monday 09:00 – Monday 17:00". Derived from the two integers above.'),
|
|
58
|
+
});
|
|
59
|
+
const SCHEDULE_OUTPUT = z.object({
|
|
60
|
+
uuid: z.string().optional(),
|
|
61
|
+
name: z.string().optional(),
|
|
62
|
+
strategy: z.string().optional(),
|
|
63
|
+
mutable: z
|
|
64
|
+
.boolean()
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("False for system-managed schedules, which cannot be updated or deleted."),
|
|
67
|
+
oneTimeUse: z.boolean().optional(),
|
|
68
|
+
intervals: z.array(INTERVAL_OUTPUT).optional().describe("The windows in which the schedule is active."),
|
|
69
|
+
});
|
|
70
|
+
export const OUTPUT_SCHEMA = z.object({
|
|
71
|
+
schedules: z.array(SCHEDULE_OUTPUT).optional().describe("All schedules in the organization."),
|
|
72
|
+
schedule: SCHEDULE_OUTPUT.optional().describe("A single schedule."),
|
|
73
|
+
created: z
|
|
74
|
+
.object({ success: z.boolean().optional(), scheduleUuid: z.string().optional() })
|
|
75
|
+
.optional(),
|
|
76
|
+
updated: z
|
|
77
|
+
.object({ success: z.boolean().optional(), scheduleUuid: z.string().optional() })
|
|
78
|
+
.optional(),
|
|
79
|
+
deleted: z
|
|
80
|
+
.object({ success: z.boolean().optional(), scheduleUuid: z.string().optional() })
|
|
81
|
+
.optional(),
|
|
82
|
+
usedBy: z
|
|
83
|
+
.array(z.object({
|
|
84
|
+
uuid: z.string().optional(),
|
|
85
|
+
name: z.string().optional(),
|
|
86
|
+
policyType: z.string().optional(),
|
|
87
|
+
}))
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Policies that reference this schedule."),
|
|
90
|
+
note: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("A caveat about this result that the user needs to be told."),
|
|
94
|
+
warningMsg: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("A warning from the Rhombus API — the call succeeded, but with a caveat."),
|
|
98
|
+
error: z.string().optional().describe("An error message if the request failed."),
|
|
99
|
+
});
|
|
100
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
101
|
+
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY;
|
|
102
|
+
const DAY_INDEX = {
|
|
103
|
+
MONDAY: 0,
|
|
104
|
+
TUESDAY: 1,
|
|
105
|
+
WEDNESDAY: 2,
|
|
106
|
+
THURSDAY: 3,
|
|
107
|
+
FRIDAY: 4,
|
|
108
|
+
SATURDAY: 5,
|
|
109
|
+
SUNDAY: 6,
|
|
110
|
+
};
|
|
111
|
+
const DAY_ALIASES = {
|
|
112
|
+
MON: "MONDAY",
|
|
113
|
+
TUE: "TUESDAY",
|
|
114
|
+
TUES: "TUESDAY",
|
|
115
|
+
WED: "WEDNESDAY",
|
|
116
|
+
WEDS: "WEDNESDAY",
|
|
117
|
+
THU: "THURSDAY",
|
|
118
|
+
THUR: "THURSDAY",
|
|
119
|
+
THURS: "THURSDAY",
|
|
120
|
+
FRI: "FRIDAY",
|
|
121
|
+
SAT: "SATURDAY",
|
|
122
|
+
SUN: "SUNDAY",
|
|
123
|
+
};
|
|
124
|
+
const WEEKDAY_GROUPS = {
|
|
125
|
+
WEEKDAYS: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"],
|
|
126
|
+
WEEKENDS: ["SATURDAY", "SUNDAY"],
|
|
127
|
+
WEEKEND: ["SATURDAY", "SUNDAY"],
|
|
128
|
+
EVERYDAY: Object.keys(DAY_INDEX),
|
|
129
|
+
DAILY: Object.keys(DAY_INDEX),
|
|
130
|
+
ALL: Object.keys(DAY_INDEX),
|
|
131
|
+
};
|
|
132
|
+
const SHAPE_HINT = `Expected a JSON array string, each entry either {"days":[...],"startTime":"HH:MM","endTime":"HH:MM"} ` +
|
|
133
|
+
`or {"minuteOfWeekStart":N,"minuteOfWeekStop":N} — e.g. ${INTERVALS_EXAMPLE}. "days" must be a real ` +
|
|
134
|
+
`JSON array, NOT a string containing one.`;
|
|
135
|
+
function quote(value) {
|
|
136
|
+
const serialized = typeof value === "string" ? value : JSON.stringify(value);
|
|
137
|
+
const text = serialized ?? String(value);
|
|
138
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
139
|
+
}
|
|
140
|
+
function normalizeDay(raw) {
|
|
141
|
+
const key = raw.trim().toUpperCase().replace(/[\s-]+/g, "_");
|
|
142
|
+
return DAY_ALIASES[key] ?? key;
|
|
143
|
+
}
|
|
144
|
+
/** Accept "09:00", "9:00", "0900", "9am", "5:30 PM", and bare "17". */
|
|
145
|
+
function parseTimeOfDay(raw) {
|
|
146
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
147
|
+
// A bare number is read as an hour when it plausibly is one, else as
|
|
148
|
+
// minutes-past-midnight. 930 is not an hour; 9 is.
|
|
149
|
+
const value = Math.trunc(raw);
|
|
150
|
+
if (value >= 0 && value <= 23)
|
|
151
|
+
return value * 60;
|
|
152
|
+
if (value >= 0 && value < MINUTES_PER_DAY)
|
|
153
|
+
return value;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw !== "string")
|
|
157
|
+
return null;
|
|
158
|
+
const text = raw.trim().toUpperCase();
|
|
159
|
+
if (!text)
|
|
160
|
+
return null;
|
|
161
|
+
const meridiem = text.endsWith("AM") ? "AM" : text.endsWith("PM") ? "PM" : null;
|
|
162
|
+
const body = (meridiem ? text.slice(0, -2) : text).trim().replace(/\./g, "");
|
|
163
|
+
let hours;
|
|
164
|
+
let minutes;
|
|
165
|
+
if (body.includes(":")) {
|
|
166
|
+
const [hourPart, minutePart = "0"] = body.split(":");
|
|
167
|
+
hours = Number(hourPart);
|
|
168
|
+
minutes = Number(minutePart);
|
|
169
|
+
}
|
|
170
|
+
else if (/^\d{3,4}$/.test(body)) {
|
|
171
|
+
hours = Number(body.slice(0, body.length - 2));
|
|
172
|
+
minutes = Number(body.slice(-2));
|
|
173
|
+
}
|
|
174
|
+
else if (/^\d{1,2}$/.test(body)) {
|
|
175
|
+
hours = Number(body);
|
|
176
|
+
minutes = 0;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes))
|
|
182
|
+
return null;
|
|
183
|
+
if (minutes < 0 || minutes > 59)
|
|
184
|
+
return null;
|
|
185
|
+
if (meridiem) {
|
|
186
|
+
if (hours < 1 || hours > 12)
|
|
187
|
+
return null;
|
|
188
|
+
if (meridiem === "AM")
|
|
189
|
+
hours = hours === 12 ? 0 : hours;
|
|
190
|
+
else
|
|
191
|
+
hours = hours === 12 ? 12 : hours + 12;
|
|
192
|
+
}
|
|
193
|
+
// 24:00 is a legitimate spelling of "end of day".
|
|
194
|
+
if (hours === 24 && minutes === 0)
|
|
195
|
+
return MINUTES_PER_DAY;
|
|
196
|
+
if (hours < 0 || hours > 23)
|
|
197
|
+
return null;
|
|
198
|
+
return hours * 60 + minutes;
|
|
199
|
+
}
|
|
200
|
+
function coerceDays(raw) {
|
|
201
|
+
if (typeof raw === "string") {
|
|
202
|
+
const trimmed = raw.trim();
|
|
203
|
+
if (!trimmed)
|
|
204
|
+
return null;
|
|
205
|
+
// The model sometimes JSON-encodes the array into the string.
|
|
206
|
+
if (trimmed.startsWith("[")) {
|
|
207
|
+
try {
|
|
208
|
+
return coerceDays(JSON.parse(trimmed));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return trimmed
|
|
215
|
+
.split(",")
|
|
216
|
+
.map(part => part.trim())
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(raw)) {
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const item of raw) {
|
|
222
|
+
if (typeof item !== "string" || !item.trim())
|
|
223
|
+
return null;
|
|
224
|
+
out.push(item.trim());
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
/** Expand "WEEKDAYS" and friends, then map to day indices. */
|
|
231
|
+
function resolveDays(raw) {
|
|
232
|
+
const tokens = coerceDays(raw);
|
|
233
|
+
if (tokens === null)
|
|
234
|
+
return { ok: false, invalid: [] };
|
|
235
|
+
const expanded = [];
|
|
236
|
+
for (const token of tokens) {
|
|
237
|
+
const normalized = normalizeDay(token);
|
|
238
|
+
const group = WEEKDAY_GROUPS[normalized];
|
|
239
|
+
if (group)
|
|
240
|
+
expanded.push(...group);
|
|
241
|
+
else
|
|
242
|
+
expanded.push(normalized);
|
|
243
|
+
}
|
|
244
|
+
const invalid = expanded.filter(day => !(day in DAY_INDEX));
|
|
245
|
+
if (invalid.length > 0)
|
|
246
|
+
return { ok: false, invalid: [...new Set(invalid)] };
|
|
247
|
+
// Dedupe so "WEEKDAYS,MONDAY" doesn't write Monday twice.
|
|
248
|
+
return { ok: true, value: [...new Set(expanded.map(day => DAY_INDEX[day]))].sort((a, b) => a - b) };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Parse the `intervals` JSON string into api2's minute-of-week pairs. Runs
|
|
252
|
+
* BEFORE any mutation, so every failure here is safely retryable.
|
|
253
|
+
*/
|
|
254
|
+
export function parseWeeklyIntervals(raw) {
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(raw);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
message: `intervals is not valid JSON (${error instanceof Error ? error.message : "parse error"}). ` +
|
|
263
|
+
SHAPE_HINT,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// Unwrap double (or triple) encoding rather than failing on it.
|
|
267
|
+
let unwrapAttempts = 0;
|
|
268
|
+
while (typeof parsed === "string" && unwrapAttempts < 3) {
|
|
269
|
+
try {
|
|
270
|
+
parsed = JSON.parse(parsed);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
message: `intervals decoded to a plain string ("${quote(parsed)}") rather than an array. ${SHAPE_HINT}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
unwrapAttempts++;
|
|
279
|
+
}
|
|
280
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
message: `intervals must be a JSON array; received ${parsed === null ? "null" : `a ${typeof parsed}`} (${quote(raw)}). ${SHAPE_HINT}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// A single interval object instead of a one-element array is unambiguous.
|
|
287
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
288
|
+
if (entries.length === 0) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
message: `intervals is empty — a schedule with no windows is never active. ${SHAPE_HINT}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const windows = [];
|
|
295
|
+
const issues = [];
|
|
296
|
+
entries.forEach((entry, index) => {
|
|
297
|
+
const label = `intervals[${index}]`;
|
|
298
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
299
|
+
issues.push(`${label} is not an object (received ${quote(entry)})`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const fields = entry;
|
|
303
|
+
// Raw minute-of-week pairs pass straight through.
|
|
304
|
+
if (fields.minuteOfWeekStart !== undefined || fields.minuteOfWeekStop !== undefined) {
|
|
305
|
+
const start = fields.minuteOfWeekStart;
|
|
306
|
+
const stop = fields.minuteOfWeekStop;
|
|
307
|
+
if (typeof start !== "number" || typeof stop !== "number") {
|
|
308
|
+
issues.push(`${label} has minuteOfWeekStart/minuteOfWeekStop but they are not both numbers (received ${quote(start)} and ${quote(stop)})`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (start < 0 || start >= MINUTES_PER_WEEK || stop < 0 || stop > MINUTES_PER_WEEK) {
|
|
312
|
+
issues.push(`${label} minute-of-week values must be between 0 and ${MINUTES_PER_WEEK} (received ${start} and ${stop})`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
windows.push({ minuteOfWeekStart: Math.trunc(start), minuteOfWeekStop: Math.trunc(stop) });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const days = resolveDays(fields.days ?? fields.day ?? fields.daysOfWeek);
|
|
319
|
+
if (!days.ok) {
|
|
320
|
+
issues.push(days.invalid.length > 0
|
|
321
|
+
? `${label}.days contains ${days.invalid.map(d => `"${d}"`).join(", ")} — use MONDAY…SUNDAY (or "WEEKDAYS"/"WEEKENDS")`
|
|
322
|
+
: `${label}.days must be an array of day names, e.g. ["MONDAY","TUESDAY"] (received ${quote(fields.days ?? fields.day ?? fields.daysOfWeek)})`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (days.value.length === 0) {
|
|
326
|
+
issues.push(`${label}.days is empty — include at least one day`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const startRaw = fields.startTime ?? fields.start ?? fields.from;
|
|
330
|
+
const endRaw = fields.endTime ?? fields.end ?? fields.stop ?? fields.to;
|
|
331
|
+
const startMinute = parseTimeOfDay(startRaw);
|
|
332
|
+
const endMinute = parseTimeOfDay(endRaw);
|
|
333
|
+
if (startMinute === null) {
|
|
334
|
+
issues.push(`${label}.startTime must be a 24-hour "HH:MM" time (received ${quote(startRaw)})`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (endMinute === null) {
|
|
338
|
+
issues.push(`${label}.endTime must be a 24-hour "HH:MM" time (received ${quote(endRaw)})`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (startMinute === endMinute) {
|
|
342
|
+
issues.push(`${label} starts and ends at the same time (${quote(startRaw)}) — a zero-length window is never active`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
for (const day of days.value) {
|
|
346
|
+
const start = day * MINUTES_PER_DAY + startMinute;
|
|
347
|
+
// An end before the start means the window crosses midnight; carry it
|
|
348
|
+
// into the following day rather than rejecting it.
|
|
349
|
+
const stop = endMinute > startMinute
|
|
350
|
+
? day * MINUTES_PER_DAY + endMinute
|
|
351
|
+
: (day + 1) * MINUTES_PER_DAY + endMinute;
|
|
352
|
+
windows.push({
|
|
353
|
+
minuteOfWeekStart: start,
|
|
354
|
+
// A Sunday-night window wraps past the end of the week, which api2
|
|
355
|
+
// stores as minute 0 (Monday 00:00) of the same week.
|
|
356
|
+
minuteOfWeekStop: stop % MINUTES_PER_WEEK,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
if (issues.length > 0) {
|
|
361
|
+
return { ok: false, message: `Invalid intervals: ${issues.join("; ")}.` };
|
|
362
|
+
}
|
|
363
|
+
return { ok: true, value: windows };
|
|
364
|
+
}
|
|
365
|
+
/** Render parsed windows back for the confirmation message. */
|
|
366
|
+
export function describeIntervals(intervals) {
|
|
367
|
+
return intervals
|
|
368
|
+
.map(interval => `${formatMinuteOfWeek(interval.minuteOfWeekStart)} – ${formatMinuteOfWeek(interval.minuteOfWeekStop)}`)
|
|
369
|
+
.join(", ");
|
|
370
|
+
}
|