Call Flow Script: Play Custom Holiday Message Based On Date

For some reason your attached images are showing blank for me.

3CX is going to tell you feature requests go into https://www.3cx.com/community/forums/3cx-ideas/ which is limited to Gold partners and above IIRC.
Not sure why if I attach them, then come up black. If i insert them in the conversation block, you can see them.

Hoping someone upstream in a higher partner level would take my idea and push it forward...
 
I have been away for the last few days and have only just read this. I still don't really understand the task.

If I can summarize briefly: you want a separate holiday announcement for specific extensions? In other words: it can happen that the same holiday should be set but depending on which extension is called, a different announcement should be played by holiday.cs?

If I have understood correctly: this is generally possible. The problem will be how to store this data in 3CX in a meaningful way.

There is no way to store multiple holiday announcements for one day. if that were possible, the script could change the announcement played based on the name if, for example, the extension number is placed in front of the name.

But you can assign additional audio files to the script.

If, for example, the audio file 123xyz.wav is assigned to the script for extension 123 and the specified holiday named abc, then the script can be modified so that when a active holiday with this name is detected and the target is the extension 123, a file with this name is searched for and if it is available, then this file is played instead of the file in the holiday setting. Is this a possible solution?
 
Afternoon -

If the script is the only automated solution, barring 3cx from restoring the HOL methodology from v18, I was thinking of a script that would:
1. Look to see if a holiday is defined on an incoming call
2. If so, does that holiday have an associated audio file saved with it (uploaded, etc).
3. If true, apply that audio file to a user-specified extension, which in my case is an IVR/Auto-attendant I have set up for a given Department at x9511. Then exit, letting the inbound call flow according to the DID or Ring Group configuration.
4. If false, do nothing, and continue on as if it was a normal call.

In my particular case, I have incoming calls for a particular SIP Trunk for a Department. The Ring Group taking the call has "When on holiday route to" Digital Receptionist on extension 9511.
With the change to v20, If i define a holiday and apply an audio file to it, it does nothing other then tell the system that a holiday is in effect. The audio file sits unused.
I would like that audio file to be applied to x9511 on the fly, if it is not already there, and then 9511 gets the call, playing the audio and allowing the caller to press 1 for x, 2 for y, etc.

The manual way around this is to define the holiday, apply the audio file. Then jump over to the IVR on x9511 and apply the audio file in that place as well. From the below screen snippets, I want GDW-PresidentsDay_2023.wav to be applied to the DR at x9511, on the fly. When the next holiday comes up, that audio file would then be applied to x9511 and so on.

1739911849619.png
1739911886075.png
 
With the change to v20, If i define a holiday and apply an audio file to it, it does nothing other then tell the system that a holiday is in effect. The audio file sits unused.
If you use the holiday.cs correct (trigger on trunk incoming call, call destination extension is bound to a department, in this department the holiday is defind with audio) the holiday audio is played before the call goes to the extension. If the extension is a RG or IVR you can select 'on holiday' there also, but after the holiday.cs is (successful?) executed.
I would like that audio file to be applied to x9511
Is x9511 a DID? The holiday.cs looks for the 3CX entity (and their department for holiday definition) which is the target for this DID.

and then 9511 gets the call, playing the audio and allowing the caller to press 1 for x, 2 for y, etc.
That's an IVR/DR as the default route for the DID 9511 which allows pressing some keys.

other thing:
If I look at your pictures and you use the holidey.cs correct, the call to the DID with IVR/DR as target (I hope it's the department in which you define the holiday) is startet after the holiday.cs plays the GDW-PresidentsDay_2023.wav.

No need to play this audio twice.

I hope I read that correctly and answered it clearly. Otherwise, excuse the bad translation, I usually speak and write in German and have probably already drunk too much beer, it's already late ;)
 
x9511 is the extension for the DR (Digital Receptionist).
Your existing script plays an audio file immediately on an incoming call, without the functionality of a DR, then, hands off the call flow to the system. I don't need an introduction audio file to play, only allow my system to handle the call based on the options I set. My defined holiday audio file does nothing unless I manually apply it to my DR. I would like the audio file applied programatically to x9511 if a holiday is actually active. *Your English is spot on :-)
 
Your existing script
... not my script ...
The original 3CX holiday.cs does it and this script (my holiday_select.cs) extends it just a little bit.

I think I understand, I hope it so. Oh dear, it's late...

To clarify: the holiday script - whichever one - should not play the audio that is stored in the holiday script for a specific (or all?) DID or targets of the 3CX DID? If that's right, then an exclude is needed, not an include.

The (my) holiday_select.de I linked to only plays the audio for the extensions stored there in the holiday_included_extensions variable. But you need a script that contains a variable that contains extensions for which the holiday script does not play audio but simply continues running, an exclusion - right?

If that's the case: yes, that exists too :D :D
 
  • Like
Reactions: N_G
You've got a PM with a test script that includes a complete replacement for the v18 inbound rules in v20 and the inclusions and exclusions. Hope this helps. Please test and report back, thanks.
 
It sounds like the unique feature here is that you want the IVR recording to also change so it can also include some sort of verbiage referencing the holiday.

As opposed to 3CX's approach which plays two files: (Happy Presidents' Day! We're closed) followed by (Thanks for calling. Press 1 for sales...).

In the latter case the IVR recording does not ever need to change.

If it's a matter of needing different menu options for holiday and not-holiday then I would add "x9512 GDW-Open" with the office-open recording and that set of choices.

Otherwise, I don't really understand why the IVR recording needs to change. Can you provide a short example?

> The audio file sits unused.

...and just to clarify this point, the prompt on the department's holiday entry is unused unless the add-on holiday script is added. In which case it is played as I described.
 
  • Like
Reactions: fxbastler
It sounds like the unique feature here is that you want the IVR recording to also change so it can also include some sort of verbiage referencing the holiday.
Then my interpretation is not completely wrong, unfortunately. That would be bad. But there was never anything like that in v18. But yes, that can probably be changed somehow, it only gets more complicated.

Let's see what the OP answers.
 
Hey guys. The v18 "HOL" Digital Receptionist would automatically use the audio file that was uploaded/recorded/applied to the scheduled holiday. Essentially, I'm looking for a way to get that feature back, but since there is no longer a "HOL" DR in v20, the ability to have a script take whatever audio file is set in a current holiday would play in my DR. The DR gives the user the ability to listen, react and choose an option. Having an introduction message, such as "We're closed for Christmas", followed by another DR is redundant and unnecessary, in my situation.
 
This is the flow that would do the job, essentially acting like the v18 HOL Digtal Receptionist
 

Attachments

This is the flow that would do the job, essentially acting like the v18 HOL Digtal Receptionist
Then you may using another extended holiday.cs, my

holiday_holivr.cs

C#:
#nullable disable
using CallFlow;
using System;
using System.Threading;
using System.Linq;
using System.Threading.Tasks;
using TCX.Configuration;
using TCX.PBXAPI;
using System.Collections.Generic;

namespace dummy
{
    //this handler plays holiday prompt as specified for destination
    public class holiday_HOLIVR : ScriptBase<holiday_HOLIVR>
    {

        private static string hol_ivr_number = "800";                           // extension number of the used HOL IVR, his audio file will change on holiday
        private static bool hol_ivr_department_holidays_for_all_calls = true;   // use HOL IVR department holidays for all incoming calls or only for those of his own department

        //reference implementation of trunk routing.
        DestinationStruct FindDefaultDestination(ExternalLine trunk, string callerID, string DID)
        {
            DestinationStruct retval = new();
            string[] range;
            foreach (var a in trunk.RoutingRules)
            {
                bool match = (a.Conditions.Condition.Type == RuleConditionType.BasedOnDID &&
                (
                    a.Data == DID
                    || (a.Data.StartsWith('*') && DID.EndsWith(a.Data[1..]))
                ))
                ||
                (
                    a.Conditions.Condition.Type == RuleConditionType.BasedOnCallerID &&
                    a.Data.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                    .Any
                    (x =>
                        x == "*"
                        || x == callerID
                        || x.StartsWith('*') && callerID.EndsWith(x[1..])
                        || x.EndsWith('*') && callerID.StartsWith(x[..^1])
                        || x.StartsWith('*') && x.EndsWith('*') && callerID.Contains(x[1..^1])
                        || ((range = x.Split("-", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Length == 2 ?
                        (range[0].Length == callerID.Length && range[1].Length == callerID.Length) && (range[0].CompareTo(callerID) <= 0 && range[1].CompareTo(callerID) >= 0) : false)
                    )
                )
                ||
                (a.Conditions.Condition.Type == RuleConditionType.ForwardAll);

                if (match)
                {
                    retval.CopyFrom(a.ForwardDestinations.OfficeHoursDestination);
                    break;
                }
            }
            return retval;
        }

        public override async Task<bool> StartAsync()
        {
            if(MyCall.Caller.DN is ExternalLine externalLine && MyCall.IsInbound) //only from trunk
            {
                //get the inbound target
                var defaultDestination = FindDefaultDestination(externalLine, MyCall.Caller.CallerID, MyCall.Caller.CalledNumber).Internal;
                //get hol ivr
                IVR hol_ivr_dn = MyCall.PS.GetDNByNumber(hol_ivr_number);
                //if the destination is Internal
                if (defaultDestination != null && hol_ivr_dn != null)
                {
                    //get main department membership from default destination and from hol ivr
                    Group defaultDestinationGroup = defaultDestination.GroupMembership.Where( x => x.IsPrimary == true).FirstOrDefault().Group;
                    Group hol_ivr_dngroup = hol_ivr_dn.GroupMembership.Where( x => x.IsPrimary == true).FirstOrDefault().Group;
                    //if all incoming calls using hol ivr or destination group equals hol ivr group
                    if (hol_ivr_department_holidays_for_all_calls || defaultDestinationGroup.Number == hol_ivr_dngroup.Number )
                    {
                        //set possible call destination to hol ivr (if on holiday)
                        defaultDestination=hol_ivr_dn;
                    }
                    var timeBasedroute = defaultDestination.GetTimeBasedRoutingInfo();

                    //if destination is currently "on holiday" and holiday prompt is defined ...
                    if (timeBasedroute.reason == CallControlAPI.DivertReason.Holiday && !string.IsNullOrWhiteSpace(timeBasedroute.holiday?.HolidayPrompt))
                    {
                        //if destination is not hol ivr, play holiday audio and later route to original destination
                        if (defaultDestination != hol_ivr_dn)
                        {
                            await MyCall.AssureMedia()
                                .ContinueWith(x => MyCall.PlayPrompt(null, [timeBasedroute.holiday?.HolidayPrompt], PlayPromptOptions.Blocked))
                                .Unwrap();
                        } else
                        {
                            //destination is hol ivr, so test if his audio is actual holiday audio
                            if ( ! hol_ivr_dn.PromptFilename.Equals(timeBasedroute.holiday.HolidayPrompt) )
                            {
                                //set hol ivr audio to holiday defined audio
                                hol_ivr_dn.PromptFilename = timeBasedroute.holiday.HolidayPrompt;
                                hol_ivr_dn.Save();
                            }
                            //route call to hol ivr, he plays now the holiday audio
                            await MyCall.RouteToAsync(new RouteRequest
                            {
                                RouteTarget = new DestinationStruct(hol_ivr_dn)
                            });
                            MyCall.Return(true); // no further routing, call ends in hol ivr
                        }
                    }
                }
            }
            return false;//we always return false to continue default trunk routing procedure if not hol ivr
        }
    }
}

Set the variable hol_ivr_number to the extension number of the IVR which is used as HOL IVR like in v18. On holiday he will change his audio and is the call destination for all calls of this trunk.

If you have separate departments and want all calls on this trunk are using the HOL IVR then leave hol_ivr_department_holidays_for_all_calls = true. Otherwise: if it is false then your HOL IVR is only used if the default call destination is using the same department as your HOL IVR.

Hope that helps.
 
Last edited:
FX - Buy yourself a Hacker-Pschorr next time you're out :)
The script worked great. I'm going to run it thru some more testing to ensure it stays isolated to the Department, etc.
 
  • Like
Reactions: N_G and accentlogic
FX - Buy yourself a Hacker-Pschorr next time you're out :)
So, as always: I buy it myself :(
But if I do that, then I'll buy a Radeberger on tap, Hacker-Pschorr has little to say here. ;)
A little bit of national pride is a must and tapped from the barrel it's good, I'm a Radeberger, even though I now live in Dresden.

The script worked great. I'm going to run it thru some more testing to ensure it stays isolated to the Department, etc.
*like
 
  • Haha
  • Like
Reactions: N_G and accentlogic
@fxbastler And we will get you an HB for your excellent work on the forum :)
 
At present I use a CFD for all incoming calls - as I need some exceptions on holidays or office hours.
I made some modifications to the holiday script, with help from @fxbastler, so far I only tested it on a test-trunk, but it should work fine. The script will parse the name of the holiday and depending on the first char apply some modifications, either on the destination department or the destination of the call.
1) "!" will override with ForceOpen (I need departments to open exceptionally on some Saturdays)
2) "/" will override with ForceBreak (allows to schedule calls to follow the break route)
3) ">" will override the holiday destination of the default call destination (allows to schedule calls to different destinations for each holiday)
// required naming of holiday when using ">": ">DestinationType.DestinationNumber|Name of Holiday"
// for example: ">IVR.799|my holiday name" or ">Extension.123|another holiday"

C#:
#nullable disable
using CallFlow;
using System;
using System.Linq;
using System.Threading.Tasks;
using TCX.Configuration;
using TCX.PBXAPI;
using System.Collections.Generic;
namespace dummy
{
    //this handler plays holiday prompt as specified for destination
    public class PlayDestinationHolidayPromptBeforeRouting : ScriptBase<PlayDestinationHolidayPromptBeforeRouting>
    {
        //reference implementation of trunk routing.
        DestinationStruct FindDefaultDestination(ExternalLine trunk, string callerID, string DID)
        {
            DestinationStruct retval = new();
            string[] range;
            foreach (var a in trunk.RoutingRules)
            {
                bool match = (a.Conditions.Condition.Type == RuleConditionType.BasedOnDID &&
                (
                    a.Data == DID
                    || (a.Data.StartsWith('*') && DID.EndsWith(a.Data[1..]))
                ))
                ||
                (
                    a.Conditions.Condition.Type == RuleConditionType.BasedOnCallerID &&
                    a.Data.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                    .Any
                    (x =>
                        x == "*"
                        || x == callerID
                        || x.StartsWith('*') && callerID.EndsWith(x[1..])
                        || x.EndsWith('*') && callerID.StartsWith(x[..^1])
                        || x.StartsWith('*') && x.EndsWith('*') && callerID.Contains(x[1..^1])
                        || ((range = x.Split("-", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Length == 2 ?
                        (range[0].Length == callerID.Length && range[1].Length == callerID.Length) && (range[0].CompareTo(callerID) <= 0 && range[1].CompareTo(callerID) >= 0) : false)
                    )
                )
                ||
                (a.Conditions.Condition.Type == RuleConditionType.ForwardAll);
                if (match)
                {
                    retval.CopyFrom(a.ForwardDestinations.OfficeHoursDestination);
                    break;
                }
            }
            return retval;
        }
        public override async Task<bool> StartAsync()
        {
            var ps = MyCall.PS as PhoneSystem;
            if(MyCall.Caller.DN is ExternalLine externalLine && MyCall.IsInbound) //only from trunk
            {
                //get the inbound target
                var defaultDestination = FindDefaultDestination(externalLine, MyCall.Caller.CallerID, MyCall.Caller.CalledNumber).Internal;
                //if the destination is Internal
                if (defaultDestination != null)
                {
                    var timeBasedroute = defaultDestination.GetTimeBasedRoutingInfo();
                    //if destination is currently "on holiday" and holiday prompt is defined, we play it
                    if (timeBasedroute.reason == CallControlAPI.DivertReason.Holiday)
                    {
                        if (!string.IsNullOrWhiteSpace(timeBasedroute.holiday?.HolidayPrompt))
                        {
                            await MyCall.AssureMedia()
                                .ContinueWith(x => MyCall.PlayPrompt(null, [timeBasedroute.holiday?.HolidayPrompt], PlayPromptOptions.Blocked))
                                .Unwrap();
                            MyCall.Info($"PromptPlayed");
                        }
                        //check the name of active holiday and eventually force the GroupHoursMode
                        char hDayName1st = (timeBasedroute.holiday?.Name)[0];
                        Dictionary<char, GroupHoursMode> holidayMode = new Dictionary<char, GroupHoursMode>
                        {
                            { '!', GroupHoursMode.ForceOpened },
                            { '/', GroupHoursMode.ForceBreak },
                            { '>', GroupHoursMode.ForceHoliday } // holiday is already active, required ???
                            // required naming of holiday when using ">": ">DestinationType.DestinationNumber|Name of Holiday"
                            // for example: ">IVR.799|my holiday name" or ">Extension.123|another holiday"
                        };

                        if (holidayMode.TryGetValue(hDayName1st, out GroupHoursMode forceMode))
                        {
                            // only for the called department
                            string CalledDepartmentNumber = (defaultDestination.GroupMembership.Where( x => x.IsPrimary == true)).First().Group.Number;
                            ps.GetAll<Group>()
                            .Where(x => x.Number.Equals(CalledDepartmentNumber))
                            .Extract(x => x.AllowCallService)
                            .Select(x =>
                            {
                                try
                                {
                                    //force office hours until end of holiday
                                    var groupCurrentTime = x.Now(out var utc, out var timezone, out var groupmode);
                                    DateTime endDateTime = new DateTime(timeBasedroute.holiday.YearEnd, timeBasedroute.holiday.MonthEnd, timeBasedroute.holiday.DayEnd).Add(timeBasedroute.holiday.TimeOfEndDate);
                                    x.OverrideExpiresAt = endDateTime;
                                    x.CurrentGroupHours = forceMode;
                                    if(hDayName1st=='>') {
                                        var custOp = $"{(timeBasedroute.holiday?.Name).Split(new char[] { '>', '|' }, StringSplitOptions.RemoveEmptyEntries)[0]}.";
                                        MyCall.Info($"custOP: {custOp}");
                                        var has_valid_destination = DestinationStruct.TryParse(custOp, out var destination_struct);
                                        if (has_valid_destination) {
                                            MyCall.Info($"valid Destination! {destination_struct.External} {destination_struct.Internal} {destination_struct.To}");
                                            defaultDestination.HolidaysRoute = destination_struct;
                                            defaultDestination.Save();
                                        }
                                    }
                                    MyCall.Info($"{x.Name} set to '{x.CurrentGroupHours}' until {x.OverrideExpiresAt}, timezone={timezone}, groupmode={groupmode}");
                                    return x;
                                }
                                catch (Exception ex)
                                {
                                    string errorMessage = ex.ToString();
                                    MyCall.Info($"Error: {errorMessage}");
                                    //just ignore it
                                    return null;
                                }
                            }).OfType<Group>().OMSave();
                        }
                    }
                }
            }
            return false;//we always return false to continue default trunk routing procedure.
        }
    }
}
 
  • Like
Reactions: N_G
I had to make some modification to the code.
1) "!", "/" manual override will now prevail, if an override is active an incoming call will not change it (but if the manual override expires, the script will trigger an override on an incoming call)
2) ">" holiday destination will now be saved before being modified, and restored once the ">" named holiday is expired

If you play with the attached script, keep in mind that it basically triggers settings and it only acts on incoming (external) calls. So if you make changes to holidays for a department and try one of the "!" or "/" overrides, either wait for the end of the override which was triggered by a call to the department or reset the override manually before trying another setting in your holiday schedule.

PS: thank you @N_G for your Like above!
 

Attachments

  • Like
Reactions: N_G
@GMark - thank you for posting the script! It's helpful for other users but also for us to see how it is being used...
 
1734605301179.png

Sorry to "hijack" this thread - not sure where to ask these questions:
  • Would it be possible to add a selector for multiple trunks when adding/modifying a call flow script?
    (like for "When this DID is called", easier maintenance)

  • Sometimes I would like to rename my call flow scripts - can this be made possible?
    (I know how to add a new script, then go through all places where it is used and remove the old one - it is just... complicated time consuming error prone great to have them!)
 
  • Like
Reactions: fxbastler

Forum statistics

Threads
111,992
Messages
590,171
Members
164,929
Latest member
Cloudstar