@particle-academy/fancy-connector-core 0.9.0 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ All notable changes to `fancy-connector-core` are documented here, in
6
6
  **This package is pre-1.0, so breaking changes land in MINOR releases.** The
7
7
  version number is not a promise it can keep yet; the entries below are.
8
8
 
9
+ ## [0.10.0] - 2026-09-16
10
+
11
+ Tynn #12 (MOIC's meeting bot): an RFC 5545 invite parser, written from
12
+ scratch in both runtimes (the owner: no third-party code) — no provider
13
+ generates it; it lives beside the other pure helpers. Additive;
14
+ `CONNECTOR_API_VERSION` stays at 1.
15
+
16
+ ### Added
17
+
18
+ - **`parseInvite` / `Ical::parse`** — raw invite text to a structured
19
+ `Invite`: METHOD (REQUEST/CANCEL), UID, SEQUENCE, SUMMARY/DESCRIPTION/
20
+ LOCATION (RFC 5545-unescaped, folded lines joined first), STATUS,
21
+ ORGANIZER and every ATTENDEE (name, email, role, partstat), and an RRULE
22
+ when the event recurs. `DTSTART`/`DTEND` always come back as UTC
23
+ instants: a `Z` suffix passes through, a `TZID` resolves through the
24
+ calendar's own `VTIMEZONE` (its DAYLIGHT/STANDARD sub-components, each
25
+ with a yearly BYMONTH+BYDAY transition rule — an unknown TZID is refused
26
+ BY NAME, never guessed), and a value with neither (RFC 5545's FLOATING
27
+ time) is treated as UTC — a decision named rather than left to look like
28
+ an oversight, because resolving one correctly needs the READER's
29
+ timezone, which nothing in the invite carries.
30
+ - **`nextOccurrence` / `Ical::nextOccurrence`** — the next occurrence at or
31
+ after a given instant, for a recurring or a one-off invite alike; `null`
32
+ once the series (or the single occurrence) has nothing left to offer.
33
+ RRULE support is intentionally narrow: `FREQ` daily/weekly/monthly/yearly
34
+ with `INTERVAL`, `COUNT` and `UNTIL`, and `BYDAY` for WEEKLY only — no
35
+ `BYMONTHDAY`, `BYSETPOS`, `BYWEEKNO`, `BYYEARDAY`, `WKST`, `EXDATE` or
36
+ `RDATE`. A recurring invite using one of those still parses (its RRULE
37
+ fields are read, never dropped); `nextOccurrence` is what does not
38
+ attempt to walk it.
39
+ - **`joinTargets` / `Ical::joinTargets`** — the ALLOW-LISTED places a bot
40
+ can actually join, in the order they appear in the text: Google Meet,
41
+ Zoom (with the meeting id and passcode read out of the URL itself),
42
+ Microsoft Teams, and a dial-in phone number with its own passcode line.
43
+ Anything else — Webex, a generic link, an unrecognised dial-in shape —
44
+ is not returned: a shape this package does not know is a shape a bot
45
+ should not guess at.
46
+ - **`fixtures/ical/`** — six authored cases (the floor, a VTIMEZONE
47
+ resolution, a weekly recurrence with four `nextOccurrence` boundary
48
+ queries, a CANCEL sharing its REQUEST's uid at a higher sequence, an
49
+ allow-listed join-target extraction from a folded/escaped DESCRIPTION,
50
+ and the negative case: a non-allow-listed link ignored). Both runtimes
51
+ read it; `README.md` names the decision each case pins.
52
+
9
53
  ## [0.9.0] - 2026-09-15
10
54
 
11
55
  The verification half of Tynn #11 (Resend inbound email, signed by Svix),
package/dist/index.cjs CHANGED
@@ -75,12 +75,15 @@ __export(src_exports, {
75
75
  isSharedTokenScheme: () => isSharedTokenScheme,
76
76
  isSharedTokenSpec: () => isSharedTokenSpec,
77
77
  isUnconditionallyRetryable: () => isUnconditionallyRetryable,
78
+ joinTargets: () => joinTargets,
78
79
  leaseAction: () => leaseAction,
79
80
  leaseFromResponse: () => leaseFromResponse,
80
81
  leaseRenewAt: () => leaseRenewAt,
81
82
  leaseState: () => leaseState,
82
83
  linkRanges: () => linkRanges,
83
84
  measure: () => measure,
85
+ nextOccurrence: () => nextOccurrence,
86
+ parseInvite: () => parseInvite,
84
87
  parseMime: () => parseMime,
85
88
  payloadHash: () => payloadHash,
86
89
  postChain: () => postChain,
@@ -980,6 +983,325 @@ function unchecked(contract) {
980
983
  };
981
984
  }
982
985
 
986
+ // src/ical.ts
987
+ function unfold(text) {
988
+ return text.replace(/\r\n|\r|\n/g, "\n").replace(/\n[ \t]/g, "");
989
+ }
990
+ function unescapeText(value) {
991
+ return value.replace(/\\[nN]/g, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
992
+ }
993
+ function parseLine(line) {
994
+ const colon = findUnquoted(line, ":");
995
+ const head = colon === -1 ? line : line.slice(0, colon);
996
+ const value = colon === -1 ? "" : line.slice(colon + 1);
997
+ const [name = "", ...paramParts] = head.split(";");
998
+ const params = {};
999
+ for (const part of paramParts) {
1000
+ const eq = part.indexOf("=");
1001
+ if (eq === -1) continue;
1002
+ const key = part.slice(0, eq).toUpperCase();
1003
+ let raw = part.slice(eq + 1);
1004
+ if (raw.startsWith('"') && raw.endsWith('"')) raw = raw.slice(1, -1);
1005
+ params[key] = raw;
1006
+ }
1007
+ return { name: name.toUpperCase(), params, value };
1008
+ }
1009
+ function findUnquoted(line, char) {
1010
+ let quoted = false;
1011
+ for (let i = 0; i < line.length; i++) {
1012
+ if (line[i] === '"') quoted = !quoted;
1013
+ else if (line[i] === char && !quoted) return i;
1014
+ }
1015
+ return -1;
1016
+ }
1017
+ function blockLines(lines, name) {
1018
+ const blocks = [];
1019
+ let depth = 0;
1020
+ let current = null;
1021
+ for (const raw of lines) {
1022
+ const upper = raw.toUpperCase();
1023
+ if (upper === `BEGIN:${name}`) {
1024
+ if (depth === 0) current = [];
1025
+ depth++;
1026
+ continue;
1027
+ }
1028
+ if (upper === `END:${name}`) {
1029
+ depth--;
1030
+ if (depth === 0 && current !== null) {
1031
+ blocks.push(current);
1032
+ current = null;
1033
+ }
1034
+ continue;
1035
+ }
1036
+ if (current !== null) current.push(raw);
1037
+ }
1038
+ return blocks;
1039
+ }
1040
+ var WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
1041
+ function parseByDayOrdinal(token) {
1042
+ const match = /^(-?\d+)([A-Z]{2})$/.exec(token);
1043
+ if (!match) throw new Error(`ical: VTIMEZONE rule has an unreadable BYDAY "${token}"`);
1044
+ const [, ordinalText, weekdayText] = match;
1045
+ const weekday = WEEKDAY_CODES.indexOf(weekdayText ?? "");
1046
+ if (weekday === -1) throw new Error(`ical: VTIMEZONE rule names an unknown weekday "${weekdayText}"`);
1047
+ return { ordinal: Number(ordinalText), weekday };
1048
+ }
1049
+ function parseTzTransition(lines) {
1050
+ const props = lines.map(parseLine);
1051
+ const get = (name) => props.find((p) => p.name === name)?.value;
1052
+ const offsetFrom = get("TZOFFSETFROM");
1053
+ const offsetTo = get("TZOFFSETTO");
1054
+ const rrule = get("RRULE");
1055
+ if (!offsetFrom || !offsetTo || !rrule) {
1056
+ throw new Error("ical: a VTIMEZONE sub-component needs TZOFFSETFROM, TZOFFSETTO and an RRULE");
1057
+ }
1058
+ const parts = Object.fromEntries(rrule.split(";").map((p) => p.split("=")));
1059
+ const month = Number(parts.BYMONTH);
1060
+ const { ordinal, weekday } = parseByDayOrdinal(parts.BYDAY ?? "");
1061
+ if (!month || Number.isNaN(ordinal)) {
1062
+ throw new Error(`ical: this package only reads a yearly BYMONTH+BYDAY transition rule, got "${rrule}"`);
1063
+ }
1064
+ return { offsetFrom, offsetTo, month, ordinal, weekday };
1065
+ }
1066
+ function offsetMinutes(offset) {
1067
+ const match = /^([+-])(\d{2}):?(\d{2})/.exec(offset);
1068
+ if (!match) throw new Error(`ical: unreadable UTC offset "${offset}"`);
1069
+ const sign = match[1] === "-" ? -1 : 1;
1070
+ return sign * (Number(match[2]) * 60 + Number(match[3]));
1071
+ }
1072
+ function nthWeekdayOfMonth(year, month1, weekday, ordinal) {
1073
+ if (ordinal > 0) {
1074
+ const first = Date.UTC(year, month1 - 1, 1);
1075
+ const firstWeekday = new Date(first).getUTCDay();
1076
+ const offset2 = (weekday - firstWeekday + 7) % 7;
1077
+ return 1 + offset2 + (ordinal - 1) * 7;
1078
+ }
1079
+ const lastDay = new Date(Date.UTC(year, month1, 0)).getUTCDate();
1080
+ const last = Date.UTC(year, month1 - 1, lastDay);
1081
+ const lastWeekday = new Date(last).getUTCDay();
1082
+ const offset = (lastWeekday - weekday + 7) % 7;
1083
+ return lastDay - offset - (-ordinal - 1) * 7;
1084
+ }
1085
+ function parseTimezones(lines) {
1086
+ const zones = /* @__PURE__ */ new Map();
1087
+ for (const block of blockLines(lines, "VTIMEZONE")) {
1088
+ const tzid = block.map(parseLine).find((p) => p.name === "TZID")?.value;
1089
+ if (!tzid) continue;
1090
+ const standardBlocks = blockLines(block, "STANDARD");
1091
+ const daylightBlocks = blockLines(block, "DAYLIGHT");
1092
+ const standardBlock = standardBlocks[0];
1093
+ if (!standardBlock) {
1094
+ throw new Error(`ical: VTIMEZONE "${tzid}" has no STANDARD component`);
1095
+ }
1096
+ zones.set(tzid, {
1097
+ standard: parseTzTransition(standardBlock),
1098
+ daylight: daylightBlocks[0] ? parseTzTransition(daylightBlocks[0]) : null
1099
+ });
1100
+ }
1101
+ return zones;
1102
+ }
1103
+ function resolveTz(zone, y, mo, d, h, mi, s) {
1104
+ const naiveUtcMs = Date.UTC(y, mo - 1, d, h, mi, s);
1105
+ if (!zone.daylight) {
1106
+ return new Date(naiveUtcMs - offsetMinutes(zone.standard.offsetTo) * 6e4).toISOString();
1107
+ }
1108
+ const springDay = nthWeekdayOfMonth(y, zone.daylight.month, zone.daylight.weekday, zone.daylight.ordinal);
1109
+ const fallDay = nthWeekdayOfMonth(y, zone.standard.month, zone.standard.weekday, zone.standard.ordinal);
1110
+ const spring = Date.UTC(y, zone.daylight.month - 1, springDay);
1111
+ const fall = Date.UTC(y, zone.standard.month - 1, fallDay);
1112
+ const inDaylight = naiveUtcMs >= spring && naiveUtcMs < fall;
1113
+ const offset = inDaylight ? zone.daylight.offsetTo : zone.standard.offsetTo;
1114
+ return new Date(naiveUtcMs - offsetMinutes(offset) * 6e4).toISOString();
1115
+ }
1116
+ function parseDateTimeParts(value) {
1117
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(value);
1118
+ if (!match) throw new Error(`ical: unreadable date-time "${value}"`);
1119
+ return {
1120
+ y: Number(match[1]),
1121
+ mo: Number(match[2]),
1122
+ d: Number(match[3]),
1123
+ h: Number(match[4]),
1124
+ mi: Number(match[5]),
1125
+ s: Number(match[6]),
1126
+ utc: Boolean(match[7])
1127
+ };
1128
+ }
1129
+ function resolveInstant(prop, zones) {
1130
+ const parts = parseDateTimeParts(prop.value);
1131
+ if (parts.utc) return new Date(Date.UTC(parts.y, parts.mo - 1, parts.d, parts.h, parts.mi, parts.s)).toISOString();
1132
+ const tzid = prop.params.TZID;
1133
+ if (!tzid) {
1134
+ return new Date(Date.UTC(parts.y, parts.mo - 1, parts.d, parts.h, parts.mi, parts.s)).toISOString();
1135
+ }
1136
+ const zone = zones.get(tzid);
1137
+ if (!zone) {
1138
+ throw new Error(`ical: DTSTART/DTEND names TZID "${tzid}", which this calendar's VTIMEZONE does not define`);
1139
+ }
1140
+ return resolveTz(zone, parts.y, parts.mo, parts.d, parts.h, parts.mi, parts.s);
1141
+ }
1142
+ function emailOf(value) {
1143
+ return value.replace(/^mailto:/i, "");
1144
+ }
1145
+ function parseAttendee(prop) {
1146
+ return {
1147
+ name: prop.params.CN ?? null,
1148
+ email: emailOf(prop.value),
1149
+ role: prop.params.ROLE ?? null,
1150
+ partstat: prop.params.PARTSTAT ?? null
1151
+ };
1152
+ }
1153
+ var FREQS = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"];
1154
+ function parseRRule(value) {
1155
+ const parts = Object.fromEntries(
1156
+ value.split(";").map((p) => {
1157
+ const eq = p.indexOf("=");
1158
+ return [p.slice(0, eq).toUpperCase(), p.slice(eq + 1)];
1159
+ })
1160
+ );
1161
+ const freq = parts.FREQ;
1162
+ if (!FREQS.includes(freq)) {
1163
+ throw new Error(`ical: RRULE FREQ must be one of ${FREQS.join(", ")}, got "${parts.FREQ}"`);
1164
+ }
1165
+ return {
1166
+ freq,
1167
+ interval: parts.INTERVAL ? Number(parts.INTERVAL) : 1,
1168
+ count: parts.COUNT ? Number(parts.COUNT) : null,
1169
+ until: parts.UNTIL ? resolveInstant({ name: "UNTIL", params: {}, value: parts.UNTIL }, /* @__PURE__ */ new Map()) : null,
1170
+ byDay: parts.BYDAY ? parts.BYDAY.split(",") : null
1171
+ };
1172
+ }
1173
+ function parseInvite(raw) {
1174
+ const lines = unfold(raw).split("\n").filter((l) => l.length > 0);
1175
+ const zones = parseTimezones(lines);
1176
+ const eventBlocks = blockLines(lines, "VEVENT");
1177
+ const firstEvent = eventBlocks[0];
1178
+ if (!firstEvent) throw new Error("ical: no VEVENT in this calendar");
1179
+ const props = firstEvent.map(parseLine);
1180
+ const methodValue = (lines.find((l) => l.toUpperCase().startsWith("METHOD:")) ?? "").split(":")[1];
1181
+ const get = (name) => props.find((p) => p.name === name);
1182
+ const getAll = (name) => props.filter((p) => p.name === name);
1183
+ const text = (name) => {
1184
+ const p = get(name);
1185
+ return p ? unescapeText(p.value) : null;
1186
+ };
1187
+ const method = (methodValue ?? "REQUEST").trim().toUpperCase();
1188
+ if (method !== "REQUEST" && method !== "CANCEL") {
1189
+ throw new Error(`ical: METHOD must be REQUEST or CANCEL, got "${methodValue}"`);
1190
+ }
1191
+ const uidProp = get("UID");
1192
+ if (!uidProp) throw new Error("ical: VEVENT has no UID");
1193
+ const dtstartProp = get("DTSTART");
1194
+ if (!dtstartProp) throw new Error("ical: VEVENT has no DTSTART");
1195
+ const dtendProp = get("DTEND");
1196
+ const organizerProp = get("ORGANIZER");
1197
+ const rruleProp = get("RRULE");
1198
+ return {
1199
+ method,
1200
+ uid: uidProp.value,
1201
+ sequence: get("SEQUENCE") ? Number(get("SEQUENCE").value) : 0,
1202
+ summary: text("SUMMARY"),
1203
+ description: text("DESCRIPTION"),
1204
+ location: text("LOCATION"),
1205
+ status: get("STATUS")?.value ?? null,
1206
+ dtstart: resolveInstant(dtstartProp, zones),
1207
+ dtend: dtendProp ? resolveInstant(dtendProp, zones) : null,
1208
+ organizer: organizerProp ? { name: organizerProp.params.CN ?? null, email: emailOf(organizerProp.value) } : null,
1209
+ attendees: getAll("ATTENDEE").map(parseAttendee),
1210
+ rrule: rruleProp ? parseRRule(rruleProp.value) : null
1211
+ };
1212
+ }
1213
+ var DAY_MS = 864e5;
1214
+ function addCalendarUnit(ms, freq, interval) {
1215
+ const d = new Date(ms);
1216
+ if (freq === "DAILY") return ms + interval * DAY_MS;
1217
+ if (freq === "WEEKLY") return ms + interval * 7 * DAY_MS;
1218
+ const months = freq === "YEARLY" ? interval * 12 : interval;
1219
+ const day = d.getUTCDate();
1220
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
1221
+ const daysInNext = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
1222
+ next.setUTCDate(Math.min(day, daysInNext));
1223
+ return next.getTime();
1224
+ }
1225
+ function* occurrences(rrule, dtstartMs) {
1226
+ const untilMs = rrule.until ? Date.parse(rrule.until) : null;
1227
+ let n = 0;
1228
+ const emit = (ms) => {
1229
+ if (untilMs !== null && ms > untilMs) return false;
1230
+ n++;
1231
+ return true;
1232
+ };
1233
+ if (rrule.freq === "WEEKLY" && rrule.byDay?.length) {
1234
+ const wanted = [...new Set(rrule.byDay.map((d) => WEEKDAY_CODES.indexOf(d)))].sort((a, b) => a - b);
1235
+ const dtstart = new Date(dtstartMs);
1236
+ const timeOfDay = dtstartMs - Date.UTC(dtstart.getUTCFullYear(), dtstart.getUTCMonth(), dtstart.getUTCDate());
1237
+ const startOfWeek = dtstartMs - timeOfDay - dtstart.getUTCDay() * DAY_MS;
1238
+ let weekIndex = 0;
1239
+ while (true) {
1240
+ if (weekIndex % rrule.interval === 0) {
1241
+ for (const weekday of wanted) {
1242
+ const candidate = startOfWeek + weekIndex * 7 * DAY_MS + weekday * DAY_MS + timeOfDay;
1243
+ if (candidate < dtstartMs) continue;
1244
+ if (!emit(candidate)) return;
1245
+ yield candidate;
1246
+ if (rrule.count && n >= rrule.count) return;
1247
+ }
1248
+ }
1249
+ weekIndex++;
1250
+ }
1251
+ }
1252
+ let current = dtstartMs;
1253
+ while (true) {
1254
+ if (!emit(current)) return;
1255
+ yield current;
1256
+ if (rrule.count && n >= rrule.count) return;
1257
+ current = addCalendarUnit(current, rrule.freq, rrule.interval);
1258
+ }
1259
+ }
1260
+ function nextOccurrence(invite, afterIso) {
1261
+ const afterMs = Date.parse(afterIso);
1262
+ const dtstartMs = Date.parse(invite.dtstart);
1263
+ if (!invite.rrule) {
1264
+ return dtstartMs >= afterMs ? invite.dtstart : null;
1265
+ }
1266
+ for (const ms of occurrences(invite.rrule, dtstartMs)) {
1267
+ if (ms >= afterMs) return new Date(ms).toISOString();
1268
+ }
1269
+ return null;
1270
+ }
1271
+ var ZOOM_LINK = /https:\/\/[\w.-]*zoom\.us\/j\/(\d+)(\?[^\s)]*)?/g;
1272
+ var MEET_LINK = /https:\/\/meet\.google\.com\/[a-z0-9-]+/gi;
1273
+ var TEAMS_LINK = /https:\/\/teams\.(?:microsoft|live)\.com\/l\/meetup-join\/[^\s)]+/gi;
1274
+ var DIAL_LINE = /^Dial(?:-in)?[:\s]+([+0-9][0-9\s().-]{6,}\d)/im;
1275
+ var PASSCODE_LINE = /Passcode[:\s]+([A-Za-z0-9]+)/i;
1276
+ function joinTargets(invite) {
1277
+ const text = invite.description ?? "";
1278
+ const targets = [];
1279
+ for (const match of text.matchAll(ZOOM_LINK)) {
1280
+ const query = match[2] ?? "";
1281
+ const passcode = /[?&]pwd=([^&\s]+)/.exec(query)?.[1] ?? null;
1282
+ targets.push({
1283
+ index: match.index ?? 0,
1284
+ target: { kind: "zoom", url: match[0], meetingId: match[1] ?? null, passcode, phone: null }
1285
+ });
1286
+ }
1287
+ for (const match of text.matchAll(MEET_LINK)) {
1288
+ targets.push({ index: match.index ?? 0, target: { kind: "google-meet", url: match[0], meetingId: null, passcode: null, phone: null } });
1289
+ }
1290
+ for (const match of text.matchAll(TEAMS_LINK)) {
1291
+ targets.push({ index: match.index ?? 0, target: { kind: "teams", url: match[0], meetingId: null, passcode: null, phone: null } });
1292
+ }
1293
+ const dial = DIAL_LINE.exec(text);
1294
+ const dialedPhone = dial?.[1];
1295
+ if (dial && dialedPhone !== void 0) {
1296
+ const passcode = PASSCODE_LINE.exec(text)?.[1] ?? null;
1297
+ targets.push({
1298
+ index: dial.index,
1299
+ target: { kind: "dial-in", url: null, meetingId: null, passcode, phone: dialedPhone.trim() }
1300
+ });
1301
+ }
1302
+ return targets.sort((a, b) => a.index - b.index).map((t) => t.target);
1303
+ }
1304
+
983
1305
  // src/idempotency.ts
984
1306
  var DEFAULT_IDEMPOTENCY_WINDOW_SECONDS = 24 * 60 * 60;
985
1307
  var MAX_IDEMPOTENCY_KEY_LENGTH = 255;
@@ -1920,12 +2242,15 @@ function hasDelivery(value) {
1920
2242
  isSharedTokenScheme,
1921
2243
  isSharedTokenSpec,
1922
2244
  isUnconditionallyRetryable,
2245
+ joinTargets,
1923
2246
  leaseAction,
1924
2247
  leaseFromResponse,
1925
2248
  leaseRenewAt,
1926
2249
  leaseState,
1927
2250
  linkRanges,
1928
2251
  measure,
2252
+ nextOccurrence,
2253
+ parseInvite,
1929
2254
  parseMime,
1930
2255
  payloadHash,
1931
2256
  postChain,