@absolutejs/voice 0.0.22-beta.509 → 0.0.22-beta.510

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.
@@ -0,0 +1,38 @@
1
+ export type VoiceCallDispositionTaxonomy = "sales" | "support" | "collections" | "survey" | "custom";
2
+ export type VoiceCallDispositionDefinition = {
3
+ code: string;
4
+ label: string;
5
+ outcome: "positive" | "neutral" | "negative" | "no-contact";
6
+ retryable: boolean;
7
+ taxonomy?: VoiceCallDispositionTaxonomy;
8
+ };
9
+ export declare const DEFAULT_VOICE_CALL_DISPOSITIONS: VoiceCallDispositionDefinition[];
10
+ export type VoiceCallDispositionTag = {
11
+ sessionId: string;
12
+ code: string;
13
+ taggedAt: number;
14
+ note?: string;
15
+ };
16
+ export type CreateVoiceCallDispositionTaggerOptions = {
17
+ taxonomy?: VoiceCallDispositionDefinition[];
18
+ allowMultiple?: boolean;
19
+ now?: () => number;
20
+ };
21
+ export declare const createVoiceCallDispositionTagger: (options?: CreateVoiceCallDispositionTaggerOptions) => {
22
+ definitionFor: (code: string) => VoiceCallDispositionDefinition | null;
23
+ definitions: VoiceCallDispositionDefinition[];
24
+ listAll: () => VoiceCallDispositionTag[];
25
+ listForSession: (sessionId: string) => VoiceCallDispositionTag[];
26
+ summarize: () => {
27
+ byCode: {
28
+ [k: string]: number;
29
+ };
30
+ byOutcome: Record<"negative" | "neutral" | "positive" | "no-contact", number>;
31
+ retryablePct: number;
32
+ totalSessions: number;
33
+ totalTagged: number;
34
+ };
35
+ tag: (sessionId: string, code: string, note?: string) => VoiceCallDispositionTag;
36
+ untag: (sessionId: string, code?: string) => number;
37
+ };
38
+ export type VoiceCallDispositionTagger = ReturnType<typeof createVoiceCallDispositionTagger>;
@@ -0,0 +1,26 @@
1
+ export type VoiceCallingDayKey = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
2
+ export type VoiceCallingTimeRange = {
3
+ start: string;
4
+ end: string;
5
+ };
6
+ export type VoiceCallingWindowOptions = {
7
+ timezone?: string;
8
+ allowedDays?: VoiceCallingDayKey[];
9
+ allowedHours?: VoiceCallingTimeRange | VoiceCallingTimeRange[];
10
+ blockedDates?: string[];
11
+ perDayHours?: Partial<Record<VoiceCallingDayKey, VoiceCallingTimeRange[]>>;
12
+ now?: () => Date;
13
+ };
14
+ export type VoiceCallingWindowVerdict = {
15
+ allowed: boolean;
16
+ reason?: "outside-hours" | "blocked-date" | "outside-day";
17
+ nextWindowAt?: number;
18
+ };
19
+ export declare const createVoiceCallingWindow: (options?: VoiceCallingWindowOptions) => {
20
+ allowedDays: VoiceCallingDayKey[];
21
+ canCallNow(at?: Date): VoiceCallingWindowVerdict;
22
+ nextWindowOpensAt(at?: Date): number;
23
+ timezone: string | undefined;
24
+ };
25
+ export type VoiceCallingWindow = ReturnType<typeof createVoiceCallingWindow>;
26
+ export declare const VOICE_TCPA_DEFAULT_WINDOW: VoiceCallingWindowOptions;
@@ -0,0 +1,16 @@
1
+ export type VoiceCampaignTemplateValue = string | number | boolean | null | undefined;
2
+ export type VoiceCampaignTemplateScope = Record<string, VoiceCampaignTemplateValue | Record<string, VoiceCampaignTemplateValue>>;
3
+ export type VoiceCampaignTemplateFilter = (value: VoiceCampaignTemplateValue, ...args: string[]) => VoiceCampaignTemplateValue;
4
+ export type VoiceCampaignTemplateResolveResult = {
5
+ output: string;
6
+ missingVariables: string[];
7
+ };
8
+ export type ResolveVoiceCampaignTemplateOptions = {
9
+ scope: VoiceCampaignTemplateScope;
10
+ filters?: Record<string, VoiceCampaignTemplateFilter>;
11
+ fallback?: string;
12
+ strict?: boolean;
13
+ };
14
+ export declare const DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS: Record<string, VoiceCampaignTemplateFilter>;
15
+ export declare const resolveVoiceCampaignTemplate: (template: string, options: ResolveVoiceCampaignTemplateOptions) => VoiceCampaignTemplateResolveResult;
16
+ export declare const collectVoiceCampaignTemplateVariables: (template: string) => string[];
@@ -0,0 +1,38 @@
1
+ export type VoiceDNCSource = "internal" | "regulatory" | "imported";
2
+ export type VoiceDNCEntry = {
3
+ phoneNumber: string;
4
+ source: VoiceDNCSource;
5
+ reason?: string;
6
+ addedAt: number;
7
+ expiresAt?: number;
8
+ };
9
+ export type VoiceDNCLookupVerdict = {
10
+ blocked: boolean;
11
+ entry: VoiceDNCEntry | null;
12
+ matchedFromExternal?: boolean;
13
+ };
14
+ export type VoiceDNCExternalLookup = (phoneNumber: string) => Promise<VoiceDNCEntry | null> | VoiceDNCEntry | null;
15
+ export type CreateVoiceDNCRegistryOptions = {
16
+ entries?: VoiceDNCEntry[];
17
+ externalLookup?: VoiceDNCExternalLookup;
18
+ now?: () => number;
19
+ };
20
+ export declare const createVoiceDNCRegistry: (options?: CreateVoiceDNCRegistryOptions) => {
21
+ block: (phoneNumber: string, options?: {
22
+ source?: VoiceDNCSource;
23
+ reason?: string;
24
+ expiresAt?: number;
25
+ }) => VoiceDNCEntry;
26
+ check(phoneNumber: string): Promise<VoiceDNCLookupVerdict>;
27
+ checkSync(phoneNumber: string): VoiceDNCLookupVerdict;
28
+ has(phoneNumber: string): boolean;
29
+ snapshot(): VoiceDNCEntry[];
30
+ unblock: (phoneNumber: string) => boolean;
31
+ };
32
+ export type VoiceDNCRegistry = ReturnType<typeof createVoiceDNCRegistry>;
33
+ export declare const importVoiceDNCFromCSV: (csv: string, options?: {
34
+ phoneColumn?: string;
35
+ reasonColumn?: string;
36
+ source?: VoiceDNCSource;
37
+ now?: () => number;
38
+ }) => VoiceDNCEntry[];
package/dist/index.d.ts CHANGED
@@ -301,4 +301,14 @@ export { createVoicePostCallSurvey, DEFAULT_VOICE_POST_CALL_SURVEY_QUESTIONS, su
301
301
  export type { VoicePostCallSurvey, VoicePostCallSurveyAnswer, VoicePostCallSurveyQuestion, VoicePostCallSurveyResponse, CreateVoicePostCallSurveyOptions, } from "./postCallSurvey";
302
302
  export { collectVoiceDTMFInput, validateVoiceDTMFLuhn, VOICE_DTMF_DIGITS, } from "./dtmfCollector";
303
303
  export type { VoiceDTMFCollector, VoiceDTMFCollectorState, VoiceDTMFDigit, CreateVoiceDTMFCollectorOptions, } from "./dtmfCollector";
304
+ export { createVoiceDNCRegistry, importVoiceDNCFromCSV, } from "./dncRegistry";
305
+ export type { VoiceDNCEntry, VoiceDNCExternalLookup, VoiceDNCLookupVerdict, VoiceDNCRegistry, VoiceDNCSource, CreateVoiceDNCRegistryOptions, } from "./dncRegistry";
306
+ export { createVoiceCallingWindow, VOICE_TCPA_DEFAULT_WINDOW, } from "./callingWindow";
307
+ export type { VoiceCallingDayKey, VoiceCallingTimeRange, VoiceCallingWindow, VoiceCallingWindowOptions, VoiceCallingWindowVerdict, } from "./callingWindow";
308
+ export { createVoiceCallDispositionTagger, DEFAULT_VOICE_CALL_DISPOSITIONS, } from "./callDisposition";
309
+ export type { VoiceCallDispositionDefinition, VoiceCallDispositionTag, VoiceCallDispositionTagger, VoiceCallDispositionTaxonomy, CreateVoiceCallDispositionTaggerOptions, } from "./callDisposition";
310
+ export { createVoiceRetryPolicy } from "./retryPolicy";
311
+ export type { VoiceRetryAttempt, VoiceRetryDecision, VoiceRetryDispositionAction, VoiceRetryDispositionRule, VoiceRetryPolicy, CreateVoiceRetryPolicyOptions, } from "./retryPolicy";
312
+ export { collectVoiceCampaignTemplateVariables, DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS, resolveVoiceCampaignTemplate, } from "./campaignTemplate";
313
+ export type { ResolveVoiceCampaignTemplateOptions, VoiceCampaignTemplateFilter, VoiceCampaignTemplateResolveResult, VoiceCampaignTemplateScope, VoiceCampaignTemplateValue, } from "./campaignTemplate";
304
314
  export * from "./types";
package/dist/index.js CHANGED
@@ -48561,6 +48561,616 @@ var validateVoiceDTMFLuhn = (digits) => {
48561
48561
  }
48562
48562
  return sum % 10 === 0;
48563
48563
  };
48564
+ // src/dncRegistry.ts
48565
+ var normalizePhone = (phone) => {
48566
+ const trimmed = phone.trim();
48567
+ if (!trimmed)
48568
+ throw new Error("Phone number is required");
48569
+ const digitsOnly = trimmed.replace(/[\s().-]/gu, "");
48570
+ if (!/^\+?\d+$/u.test(digitsOnly)) {
48571
+ throw new Error(`Invalid phone number: ${phone}`);
48572
+ }
48573
+ return digitsOnly.startsWith("+") ? digitsOnly : `+${digitsOnly}`;
48574
+ };
48575
+ var createVoiceDNCRegistry = (options = {}) => {
48576
+ const now = options.now ?? (() => Date.now());
48577
+ const store = new Map;
48578
+ for (const entry of options.entries ?? []) {
48579
+ store.set(normalizePhone(entry.phoneNumber), {
48580
+ ...entry,
48581
+ phoneNumber: normalizePhone(entry.phoneNumber)
48582
+ });
48583
+ }
48584
+ const isExpired = (entry, at) => entry.expiresAt !== undefined && entry.expiresAt <= at;
48585
+ const block = (phoneNumber, options2 = {}) => {
48586
+ const normalized = normalizePhone(phoneNumber);
48587
+ const entry = {
48588
+ addedAt: now(),
48589
+ phoneNumber: normalized,
48590
+ source: options2.source ?? "internal",
48591
+ ...options2.reason !== undefined ? { reason: options2.reason } : {},
48592
+ ...options2.expiresAt !== undefined ? { expiresAt: options2.expiresAt } : {}
48593
+ };
48594
+ store.set(normalized, entry);
48595
+ return entry;
48596
+ };
48597
+ const unblock = (phoneNumber) => {
48598
+ const normalized = normalizePhone(phoneNumber);
48599
+ return store.delete(normalized);
48600
+ };
48601
+ const localLookup = (phoneNumber) => {
48602
+ const normalized = normalizePhone(phoneNumber);
48603
+ const entry = store.get(normalized);
48604
+ if (!entry)
48605
+ return null;
48606
+ if (isExpired(entry, now())) {
48607
+ store.delete(normalized);
48608
+ return null;
48609
+ }
48610
+ return entry;
48611
+ };
48612
+ return {
48613
+ block,
48614
+ async check(phoneNumber) {
48615
+ const local = localLookup(phoneNumber);
48616
+ if (local)
48617
+ return { blocked: true, entry: local };
48618
+ if (!options.externalLookup) {
48619
+ return { blocked: false, entry: null };
48620
+ }
48621
+ const remote = await options.externalLookup(normalizePhone(phoneNumber));
48622
+ if (!remote)
48623
+ return { blocked: false, entry: null };
48624
+ return { blocked: true, entry: remote, matchedFromExternal: true };
48625
+ },
48626
+ checkSync(phoneNumber) {
48627
+ const local = localLookup(phoneNumber);
48628
+ return local ? { blocked: true, entry: local } : { blocked: false, entry: null };
48629
+ },
48630
+ has(phoneNumber) {
48631
+ return localLookup(phoneNumber) !== null;
48632
+ },
48633
+ snapshot() {
48634
+ const at = now();
48635
+ const live = [];
48636
+ for (const entry of store.values()) {
48637
+ if (!isExpired(entry, at))
48638
+ live.push(entry);
48639
+ }
48640
+ return live;
48641
+ },
48642
+ unblock
48643
+ };
48644
+ };
48645
+ var importVoiceDNCFromCSV = (csv, options = {}) => {
48646
+ const lines = csv.split(/\r?\n/u).filter((line) => line.trim().length > 0);
48647
+ if (lines.length === 0)
48648
+ return [];
48649
+ const header = (lines[0] ?? "").split(",").map((h) => h.trim().toLowerCase());
48650
+ const phoneCol = options.phoneColumn?.toLowerCase() ?? "phone";
48651
+ const reasonCol = options.reasonColumn?.toLowerCase() ?? "reason";
48652
+ const phoneIdx = header.indexOf(phoneCol);
48653
+ const reasonIdx = header.indexOf(reasonCol);
48654
+ if (phoneIdx === -1) {
48655
+ throw new Error(`Phone column not found in CSV header: ${phoneCol}`);
48656
+ }
48657
+ const now = options.now ?? (() => Date.now());
48658
+ const at = now();
48659
+ const entries = [];
48660
+ for (let i = 1;i < lines.length; i++) {
48661
+ const row = (lines[i] ?? "").split(",").map((cell) => cell.trim());
48662
+ const phone = row[phoneIdx];
48663
+ if (!phone)
48664
+ continue;
48665
+ entries.push({
48666
+ addedAt: at,
48667
+ phoneNumber: normalizePhone(phone),
48668
+ source: options.source ?? "imported",
48669
+ ...reasonIdx >= 0 && row[reasonIdx] ? { reason: row[reasonIdx] } : {}
48670
+ });
48671
+ }
48672
+ return entries;
48673
+ };
48674
+ // src/callingWindow.ts
48675
+ var DAY_INDEX = {
48676
+ friday: 5,
48677
+ monday: 1,
48678
+ saturday: 6,
48679
+ sunday: 0,
48680
+ thursday: 4,
48681
+ tuesday: 2,
48682
+ wednesday: 3
48683
+ };
48684
+ var DAY_KEYS = [
48685
+ "sunday",
48686
+ "monday",
48687
+ "tuesday",
48688
+ "wednesday",
48689
+ "thursday",
48690
+ "friday",
48691
+ "saturday"
48692
+ ];
48693
+ var parseTime = (value) => {
48694
+ const match = /^([0-9]{1,2}):([0-9]{2})$/u.exec(value);
48695
+ if (!match)
48696
+ throw new Error(`Invalid time string (expected HH:MM): ${value}`);
48697
+ const hour = Number(match[1]);
48698
+ const minute = Number(match[2]);
48699
+ if (hour < 0 || hour > 23) {
48700
+ throw new RangeError(`Hour out of range: ${value}`);
48701
+ }
48702
+ if (minute < 0 || minute > 59) {
48703
+ throw new RangeError(`Minute out of range: ${value}`);
48704
+ }
48705
+ return { hour, minute };
48706
+ };
48707
+ var minutesOf = (range) => {
48708
+ const start = parseTime(range.start);
48709
+ const end = parseTime(range.end);
48710
+ return {
48711
+ end: end.hour * 60 + end.minute,
48712
+ start: start.hour * 60 + start.minute
48713
+ };
48714
+ };
48715
+ var parts = (date, timezone) => {
48716
+ const formatter = new Intl.DateTimeFormat("en-US", {
48717
+ day: "2-digit",
48718
+ hour: "2-digit",
48719
+ hour12: false,
48720
+ minute: "2-digit",
48721
+ month: "2-digit",
48722
+ timeZone: timezone,
48723
+ weekday: "short",
48724
+ year: "numeric"
48725
+ });
48726
+ const map = {};
48727
+ for (const part of formatter.formatToParts(date)) {
48728
+ if (part.type !== "literal")
48729
+ map[part.type] = part.value;
48730
+ }
48731
+ const weekdayMap = {
48732
+ Fri: 5,
48733
+ Mon: 1,
48734
+ Sat: 6,
48735
+ Sun: 0,
48736
+ Thu: 4,
48737
+ Tue: 2,
48738
+ Wed: 3
48739
+ };
48740
+ const hourValue = map.hour === "24" ? "00" : map.hour ?? "0";
48741
+ return {
48742
+ day: map.day ?? "00",
48743
+ minutes: Number(hourValue) * 60 + Number(map.minute ?? "0"),
48744
+ month: map.month ?? "00",
48745
+ weekday: weekdayMap[map.weekday ?? ""] ?? 0,
48746
+ year: map.year ?? "0000"
48747
+ };
48748
+ };
48749
+ var createVoiceCallingWindow = (options = {}) => {
48750
+ const now = options.now ?? (() => new Date);
48751
+ const allowedDayIndexes = new Set((options.allowedDays ?? [
48752
+ "monday",
48753
+ "tuesday",
48754
+ "wednesday",
48755
+ "thursday",
48756
+ "friday"
48757
+ ]).map((d) => DAY_INDEX[d]));
48758
+ const blockedDates = new Set(options.blockedDates ?? []);
48759
+ const baseRanges = !options.allowedHours ? [{ end: "21:00", start: "08:00" }] : Array.isArray(options.allowedHours) ? options.allowedHours : [options.allowedHours];
48760
+ const baseMinutes = baseRanges.map(minutesOf);
48761
+ const perDayMinutes = new Map;
48762
+ for (const [key, ranges] of Object.entries(options.perDayHours ?? {})) {
48763
+ if (!ranges)
48764
+ continue;
48765
+ perDayMinutes.set(DAY_INDEX[key], ranges.map(minutesOf));
48766
+ }
48767
+ const rangesFor = (weekday) => perDayMinutes.get(weekday) ?? baseMinutes;
48768
+ const isAllowedAt = (date) => {
48769
+ const p = parts(date, options.timezone);
48770
+ const isoDate = `${p.year}-${p.month}-${p.day}`;
48771
+ if (blockedDates.has(isoDate)) {
48772
+ return { allowed: false, reason: "blocked-date" };
48773
+ }
48774
+ if (!allowedDayIndexes.has(p.weekday)) {
48775
+ return { allowed: false, reason: "outside-day" };
48776
+ }
48777
+ const ranges = rangesFor(p.weekday);
48778
+ for (const range of ranges) {
48779
+ if (p.minutes >= range.start && p.minutes < range.end) {
48780
+ return { allowed: true };
48781
+ }
48782
+ }
48783
+ return { allowed: false, reason: "outside-hours" };
48784
+ };
48785
+ const findNextOpening = (from) => {
48786
+ const cursor = new Date(from.getTime());
48787
+ for (let step = 0;step < 14 * 24 * 60; step++) {
48788
+ const verdict = isAllowedAt(cursor);
48789
+ if (verdict.allowed)
48790
+ return cursor.getTime();
48791
+ cursor.setTime(cursor.getTime() + 60000);
48792
+ }
48793
+ return cursor.getTime();
48794
+ };
48795
+ return {
48796
+ allowedDays: [...allowedDayIndexes].map((idx) => DAY_KEYS[idx]),
48797
+ canCallNow(at) {
48798
+ const date = at ?? now();
48799
+ const verdict = isAllowedAt(date);
48800
+ if (verdict.allowed)
48801
+ return verdict;
48802
+ return { ...verdict, nextWindowAt: findNextOpening(date) };
48803
+ },
48804
+ nextWindowOpensAt(at) {
48805
+ const date = at ?? now();
48806
+ const verdict = isAllowedAt(date);
48807
+ if (verdict.allowed)
48808
+ return date.getTime();
48809
+ return findNextOpening(date);
48810
+ },
48811
+ timezone: options.timezone
48812
+ };
48813
+ };
48814
+ var VOICE_TCPA_DEFAULT_WINDOW = {
48815
+ allowedDays: ["monday", "tuesday", "wednesday", "thursday", "friday"],
48816
+ allowedHours: { end: "21:00", start: "08:00" }
48817
+ };
48818
+ // src/callDisposition.ts
48819
+ var DEFAULT_VOICE_CALL_DISPOSITIONS = [
48820
+ {
48821
+ code: "sale",
48822
+ label: "Sale closed",
48823
+ outcome: "positive",
48824
+ retryable: false,
48825
+ taxonomy: "sales"
48826
+ },
48827
+ {
48828
+ code: "qualified-lead",
48829
+ label: "Qualified lead",
48830
+ outcome: "positive",
48831
+ retryable: false,
48832
+ taxonomy: "sales"
48833
+ },
48834
+ {
48835
+ code: "callback-requested",
48836
+ label: "Callback requested",
48837
+ outcome: "neutral",
48838
+ retryable: true,
48839
+ taxonomy: "sales"
48840
+ },
48841
+ {
48842
+ code: "not-interested",
48843
+ label: "Not interested",
48844
+ outcome: "negative",
48845
+ retryable: false,
48846
+ taxonomy: "sales"
48847
+ },
48848
+ {
48849
+ code: "do-not-call",
48850
+ label: "Do not call",
48851
+ outcome: "negative",
48852
+ retryable: false,
48853
+ taxonomy: "sales"
48854
+ },
48855
+ {
48856
+ code: "voicemail-left",
48857
+ label: "Voicemail left",
48858
+ outcome: "no-contact",
48859
+ retryable: true,
48860
+ taxonomy: "sales"
48861
+ },
48862
+ {
48863
+ code: "no-answer",
48864
+ label: "No answer",
48865
+ outcome: "no-contact",
48866
+ retryable: true,
48867
+ taxonomy: "sales"
48868
+ },
48869
+ {
48870
+ code: "busy",
48871
+ label: "Line busy",
48872
+ outcome: "no-contact",
48873
+ retryable: true,
48874
+ taxonomy: "sales"
48875
+ },
48876
+ {
48877
+ code: "wrong-number",
48878
+ label: "Wrong number",
48879
+ outcome: "negative",
48880
+ retryable: false,
48881
+ taxonomy: "sales"
48882
+ },
48883
+ {
48884
+ code: "resolved",
48885
+ label: "Issue resolved",
48886
+ outcome: "positive",
48887
+ retryable: false,
48888
+ taxonomy: "support"
48889
+ },
48890
+ {
48891
+ code: "escalated",
48892
+ label: "Escalated to human",
48893
+ outcome: "neutral",
48894
+ retryable: false,
48895
+ taxonomy: "support"
48896
+ },
48897
+ {
48898
+ code: "payment-promised",
48899
+ label: "Payment promised",
48900
+ outcome: "positive",
48901
+ retryable: false,
48902
+ taxonomy: "collections"
48903
+ },
48904
+ {
48905
+ code: "dispute-raised",
48906
+ label: "Dispute raised",
48907
+ outcome: "negative",
48908
+ retryable: false,
48909
+ taxonomy: "collections"
48910
+ }
48911
+ ];
48912
+ var createVoiceCallDispositionTagger = (options = {}) => {
48913
+ const now = options.now ?? (() => Date.now());
48914
+ const definitions = options.taxonomy ?? DEFAULT_VOICE_CALL_DISPOSITIONS;
48915
+ const byCode = new Map(definitions.map((d) => [d.code, d]));
48916
+ const allowMultiple = options.allowMultiple ?? false;
48917
+ const tags = new Map;
48918
+ const tag = (sessionId, code, note) => {
48919
+ if (!byCode.has(code)) {
48920
+ throw new Error(`Unknown disposition code: ${code}`);
48921
+ }
48922
+ const entry = {
48923
+ code,
48924
+ sessionId,
48925
+ taggedAt: now(),
48926
+ ...note !== undefined ? { note } : {}
48927
+ };
48928
+ const existing = tags.get(sessionId) ?? [];
48929
+ if (!allowMultiple) {
48930
+ tags.set(sessionId, [entry]);
48931
+ } else {
48932
+ tags.set(sessionId, [...existing, entry]);
48933
+ }
48934
+ return entry;
48935
+ };
48936
+ const untag = (sessionId, code) => {
48937
+ const existing = tags.get(sessionId);
48938
+ if (!existing)
48939
+ return 0;
48940
+ if (code === undefined) {
48941
+ tags.delete(sessionId);
48942
+ return existing.length;
48943
+ }
48944
+ const filtered = existing.filter((t) => t.code !== code);
48945
+ const removed = existing.length - filtered.length;
48946
+ if (filtered.length === 0)
48947
+ tags.delete(sessionId);
48948
+ else
48949
+ tags.set(sessionId, filtered);
48950
+ return removed;
48951
+ };
48952
+ const listForSession = (sessionId) => tags.get(sessionId)?.slice() ?? [];
48953
+ const listAll = () => {
48954
+ const flat = [];
48955
+ for (const list of tags.values())
48956
+ flat.push(...list);
48957
+ return flat;
48958
+ };
48959
+ const summarize = () => {
48960
+ const byCodeCount = new Map;
48961
+ const byOutcomeCount = { negative: 0, neutral: 0, "no-contact": 0, positive: 0 };
48962
+ let retryableTagged = 0;
48963
+ let totalTagged = 0;
48964
+ for (const list of tags.values()) {
48965
+ for (const entry of list) {
48966
+ totalTagged += 1;
48967
+ byCodeCount.set(entry.code, (byCodeCount.get(entry.code) ?? 0) + 1);
48968
+ const def = byCode.get(entry.code);
48969
+ if (!def)
48970
+ continue;
48971
+ byOutcomeCount[def.outcome] += 1;
48972
+ if (def.retryable)
48973
+ retryableTagged += 1;
48974
+ }
48975
+ }
48976
+ return {
48977
+ byCode: Object.fromEntries(byCodeCount),
48978
+ byOutcome: byOutcomeCount,
48979
+ retryablePct: totalTagged === 0 ? 0 : retryableTagged / totalTagged,
48980
+ totalSessions: tags.size,
48981
+ totalTagged
48982
+ };
48983
+ };
48984
+ return {
48985
+ definitionFor: (code) => byCode.get(code) ?? null,
48986
+ definitions,
48987
+ listAll,
48988
+ listForSession,
48989
+ summarize,
48990
+ tag,
48991
+ untag
48992
+ };
48993
+ };
48994
+ // src/retryPolicy.ts
48995
+ var DEFAULT_RULES = [
48996
+ { action: "retry", cooldownMs: 4 * 60 * 60 * 1000, disposition: "voicemail-left" },
48997
+ { action: "retry", cooldownMs: 30 * 60 * 1000, disposition: "no-answer" },
48998
+ { action: "retry", cooldownMs: 10 * 60 * 1000, disposition: "busy" },
48999
+ { action: "retry", cooldownMs: 24 * 60 * 60 * 1000, disposition: "callback-requested" },
49000
+ { action: "abandon", disposition: "do-not-call" },
49001
+ { action: "abandon", disposition: "not-interested" },
49002
+ { action: "abandon", disposition: "wrong-number" },
49003
+ { action: "abandon", disposition: "sale" }
49004
+ ];
49005
+ var createVoiceRetryPolicy = (options = {}) => {
49006
+ const now = options.now ?? (() => Date.now());
49007
+ const maxAttempts = options.maxAttempts ?? 3;
49008
+ const baseCooldown = options.defaultCooldownMs ?? 30 * 60 * 1000;
49009
+ const jitter = options.jitterMs ?? 60 * 1000;
49010
+ const backoff = options.backoffMultiplier ?? 1.5;
49011
+ const escalateAfter = options.escalateAfterAttempts ?? Infinity;
49012
+ const rulesByDisposition = new Map;
49013
+ for (const rule of options.rules ?? DEFAULT_RULES) {
49014
+ rulesByDisposition.set(rule.disposition, rule);
49015
+ }
49016
+ const decide = (history, lastDisposition) => {
49017
+ const rule = rulesByDisposition.get(lastDisposition);
49018
+ const attemptCount = history.length;
49019
+ const max = rule?.maxAttemptsOverride ?? maxAttempts;
49020
+ if (attemptCount >= escalateAfter) {
49021
+ return {
49022
+ action: "escalate",
49023
+ reason: `${attemptCount} attempts without contact`
49024
+ };
49025
+ }
49026
+ if (!rule || rule.action === "abandon") {
49027
+ return { action: "abandon", reason: "non-retryable" };
49028
+ }
49029
+ if (rule.action === "escalate") {
49030
+ return { action: "escalate", reason: lastDisposition };
49031
+ }
49032
+ if (attemptCount >= max) {
49033
+ return { action: "abandon", reason: "max-attempts" };
49034
+ }
49035
+ const cooldown = rule.cooldownMs ?? baseCooldown;
49036
+ const exponent = Math.pow(backoff, Math.max(0, attemptCount - 1));
49037
+ const jitterDelta = jitter > 0 ? Math.floor(Math.random() * jitter) : 0;
49038
+ const retryAt = now() + Math.round(cooldown * exponent) + jitterDelta;
49039
+ return {
49040
+ action: "retry",
49041
+ attemptNumber: attemptCount + 1,
49042
+ retryAt
49043
+ };
49044
+ };
49045
+ return {
49046
+ decide,
49047
+ maxAttempts,
49048
+ rules: () => Array.from(rulesByDisposition.values()),
49049
+ updateRule(rule) {
49050
+ rulesByDisposition.set(rule.disposition, rule);
49051
+ }
49052
+ };
49053
+ };
49054
+ // src/campaignTemplate.ts
49055
+ var lookupPath = (scope, path) => {
49056
+ const parts2 = path.split(".");
49057
+ let cursor = scope;
49058
+ for (const part of parts2) {
49059
+ if (cursor === null || cursor === undefined)
49060
+ return;
49061
+ if (typeof cursor !== "object")
49062
+ return;
49063
+ cursor = cursor[part];
49064
+ }
49065
+ return cursor;
49066
+ };
49067
+ var escapeSpeech = (text) => text.replace(/[<&>"]/gu, (char) => {
49068
+ switch (char) {
49069
+ case "&":
49070
+ return "&amp;";
49071
+ case "<":
49072
+ return "&lt;";
49073
+ case ">":
49074
+ return "&gt;";
49075
+ case '"':
49076
+ return "&quot;";
49077
+ default:
49078
+ return char;
49079
+ }
49080
+ });
49081
+ var formatPhone = (phone) => {
49082
+ const digits = phone.replace(/\D/gu, "");
49083
+ if (digits.length === 10) {
49084
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
49085
+ }
49086
+ if (digits.length === 11 && digits.startsWith("1")) {
49087
+ return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
49088
+ }
49089
+ return phone;
49090
+ };
49091
+ var formatDate = (value, locale) => {
49092
+ if (value === null || value === undefined)
49093
+ return "";
49094
+ const date = new Date(typeof value === "number" ? value : String(value));
49095
+ if (Number.isNaN(date.getTime()))
49096
+ return String(value);
49097
+ return date.toLocaleDateString(locale ?? "en-US", {
49098
+ day: "numeric",
49099
+ month: "long",
49100
+ year: "numeric"
49101
+ });
49102
+ };
49103
+ var DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS = {
49104
+ capitalize: (value) => {
49105
+ if (value === null || value === undefined || value === "")
49106
+ return value ?? "";
49107
+ const text = String(value);
49108
+ return text.charAt(0).toUpperCase() + text.slice(1);
49109
+ },
49110
+ currency: (value, currency = "USD") => {
49111
+ if (typeof value !== "number")
49112
+ return String(value ?? "");
49113
+ return new Intl.NumberFormat("en-US", {
49114
+ currency,
49115
+ style: "currency"
49116
+ }).format(value);
49117
+ },
49118
+ date: (value, locale) => formatDate(value, locale),
49119
+ default: (value, fallback) => value === null || value === undefined || value === "" ? fallback ?? "" : value,
49120
+ lower: (value) => String(value ?? "").toLowerCase(),
49121
+ phone: (value) => formatPhone(String(value ?? "")),
49122
+ ssml: (value) => escapeSpeech(String(value ?? "")),
49123
+ upper: (value) => String(value ?? "").toUpperCase()
49124
+ };
49125
+ var renderValue = (value, filters, filterChain) => {
49126
+ let cursor = value;
49127
+ for (const segment of filterChain) {
49128
+ const parts2 = segment.split(":");
49129
+ const name = (parts2[0] ?? "").trim();
49130
+ if (!name)
49131
+ continue;
49132
+ const args = parts2.slice(1).join(":").split(",").map((a) => a.trim()).filter((a) => a.length > 0);
49133
+ const filter = filters[name];
49134
+ if (!filter)
49135
+ throw new Error(`Unknown template filter: ${name}`);
49136
+ cursor = filter(cursor, ...args);
49137
+ }
49138
+ if (cursor === null || cursor === undefined)
49139
+ return "";
49140
+ return String(cursor);
49141
+ };
49142
+ var resolveVoiceCampaignTemplate = (template, options) => {
49143
+ const filters = {
49144
+ ...DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS,
49145
+ ...options.filters ?? {}
49146
+ };
49147
+ const missing = new Set;
49148
+ const output = template.replace(/\{\{([^{}]+)\}\}/gu, (match, expression) => {
49149
+ const segments = expression.split("|").map((part) => part.trim());
49150
+ const path = segments[0] ?? "";
49151
+ const value = lookupPath(options.scope, path);
49152
+ if (value === undefined) {
49153
+ missing.add(path);
49154
+ if (options.strict) {
49155
+ throw new Error(`Missing template variable: ${path}`);
49156
+ }
49157
+ return options.fallback ?? "";
49158
+ }
49159
+ return renderValue(value, filters, segments.slice(1));
49160
+ });
49161
+ return { missingVariables: Array.from(missing), output };
49162
+ };
49163
+ var collectVoiceCampaignTemplateVariables = (template) => {
49164
+ const set = new Set;
49165
+ const matches = template.matchAll(/\{\{([^{}]+)\}\}/gu);
49166
+ for (const match of matches) {
49167
+ const expression = match[1] ?? "";
49168
+ const path = (expression.split("|")[0] ?? "").trim();
49169
+ if (path)
49170
+ set.add(path);
49171
+ }
49172
+ return Array.from(set);
49173
+ };
48564
49174
  export {
48565
49175
  writeVoiceProofPack,
48566
49176
  writeVoiceMediaPipelineArtifacts,
@@ -48668,6 +49278,7 @@ export {
48668
49278
  resolveVoiceMonitorIssue,
48669
49279
  resolveVoiceDiagnosticsTraceFilter,
48670
49280
  resolveVoiceDashboardRenderers,
49281
+ resolveVoiceCampaignTemplate,
48671
49282
  resolveVoiceAuditTrailFilter,
48672
49283
  resolveVoiceAuditDeliveryFilter,
48673
49284
  resolveVoiceAssistantMode,
@@ -48825,6 +49436,7 @@ export {
48825
49436
  isVoiceOpsTaskOverdue,
48826
49437
  isPhoneOnDNC,
48827
49438
  interleaveStereoPcm,
49439
+ importVoiceDNCFromCSV,
48828
49440
  importVoiceCampaignRecipients,
48829
49441
  heartbeatVoiceOpsTask,
48830
49442
  hasVoiceOpsTaskSLABreach,
@@ -49002,6 +49614,7 @@ export {
49002
49614
  createVoiceRoutingDecisionSummary,
49003
49615
  createVoiceRouteAuth,
49004
49616
  createVoiceReviewSavedEvent,
49617
+ createVoiceRetryPolicy,
49005
49618
  createVoiceRetentionScheduler,
49006
49619
  createVoiceResilienceRoutes,
49007
49620
  createVoiceReplayTimelineHTMXRoute,
@@ -49196,6 +49809,7 @@ export {
49196
49809
  createVoiceDeliveryRuntime,
49197
49810
  createVoiceDataControlRoutes,
49198
49811
  createVoiceDTMFTool,
49812
+ createVoiceDNCRegistry,
49199
49813
  createVoiceCostDashboardHTMXRoute,
49200
49814
  createVoiceCostAccountant,
49201
49815
  createVoiceCompetitiveCoverageRoutes,
@@ -49205,11 +49819,13 @@ export {
49205
49819
  createVoiceCampaignTelephonyOutcomeHandler,
49206
49820
  createVoiceCampaignRoutes,
49207
49821
  createVoiceCampaign,
49822
+ createVoiceCallingWindow,
49208
49823
  createVoiceCallerMemoryNamespace,
49209
49824
  createVoiceCallReviewRecorder,
49210
49825
  createVoiceCallReviewFromSession,
49211
49826
  createVoiceCallReviewFromLiveTelephonyReport,
49212
49827
  createVoiceCallPlayer,
49828
+ createVoiceCallDispositionTagger,
49213
49829
  createVoiceCallDebuggerRoutes,
49214
49830
  createVoiceCallCompletedEvent,
49215
49831
  createVoiceCRMActivitySink,
@@ -49285,6 +49901,7 @@ export {
49285
49901
  compareVoiceEvalBaseline,
49286
49902
  compareVoiceCostScenarios,
49287
49903
  collectVoiceDTMFInput,
49904
+ collectVoiceCampaignTemplateVariables,
49288
49905
  claimVoiceOpsTask,
49289
49906
  buildVoiceTraceReplay,
49290
49907
  buildVoiceTraceDeliveryReport,
@@ -49435,6 +50052,7 @@ export {
49435
50052
  acknowledgeVoiceMonitorIssue,
49436
50053
  VOICE_WEBHOOK_TIMESTAMP_HEADER,
49437
50054
  VOICE_WEBHOOK_SIGNATURE_HEADER,
50055
+ VOICE_TCPA_DEFAULT_WINDOW,
49438
50056
  VOICE_LIVE_OPS_ACTIONS,
49439
50057
  VOICE_DTMF_DIGITS,
49440
50058
  VOICE_CALLER_MEMORY_KEY,
@@ -49445,5 +50063,7 @@ export {
49445
50063
  DEFAULT_VOICE_PROMPT_INJECTION_RULES,
49446
50064
  DEFAULT_VOICE_PRICE_BOOK,
49447
50065
  DEFAULT_VOICE_POST_CALL_SURVEY_QUESTIONS,
50066
+ DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS,
50067
+ DEFAULT_VOICE_CALL_DISPOSITIONS,
49448
50068
  BROWSER_NOISE_SUPPRESSOR_PRESETS
49449
50069
  };
@@ -0,0 +1,38 @@
1
+ export type VoiceRetryDispositionAction = "retry" | "abandon" | "escalate";
2
+ export type VoiceRetryDispositionRule = {
3
+ disposition: string;
4
+ action: VoiceRetryDispositionAction;
5
+ cooldownMs?: number;
6
+ maxAttemptsOverride?: number;
7
+ };
8
+ export type VoiceRetryAttempt = {
9
+ disposition: string;
10
+ at: number;
11
+ };
12
+ export type VoiceRetryDecision = {
13
+ action: "retry";
14
+ attemptNumber: number;
15
+ retryAt: number;
16
+ } | {
17
+ action: "abandon";
18
+ reason: "max-attempts" | "non-retryable" | "explicit";
19
+ } | {
20
+ action: "escalate";
21
+ reason: string;
22
+ };
23
+ export type CreateVoiceRetryPolicyOptions = {
24
+ maxAttempts?: number;
25
+ defaultCooldownMs?: number;
26
+ jitterMs?: number;
27
+ backoffMultiplier?: number;
28
+ rules?: VoiceRetryDispositionRule[];
29
+ escalateAfterAttempts?: number;
30
+ now?: () => number;
31
+ };
32
+ export declare const createVoiceRetryPolicy: (options?: CreateVoiceRetryPolicyOptions) => {
33
+ decide: (history: VoiceRetryAttempt[], lastDisposition: string) => VoiceRetryDecision;
34
+ maxAttempts: number;
35
+ rules: () => VoiceRetryDispositionRule[];
36
+ updateRule(rule: VoiceRetryDispositionRule): void;
37
+ };
38
+ export type VoiceRetryPolicy = ReturnType<typeof createVoiceRetryPolicy>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.509",
3
+ "version": "0.0.22-beta.510",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",