/* * Asterisk -- An open source telephony toolkit. * * Copyright (C) 2026, Aurora Innovation AB * * Daniel Donoghue * * See http://www.asterisk.org for more information about * the Asterisk project. Please do not directly contact * any of the maintainers of this project for assistance; * the project provides a web site, mailing lists and IRC * channels for your use. * * This program is free software, distributed under the terms of * the GNU General Public License Version 2. See the LICENSE file * at the top of the source tree. */ /*! \file * * \brief Stasis broadcast dialplan application * * \author Daniel Donoghue */ /*** MODULEINFO res_stasis res_stasis_broadcast extended ***/ #include "asterisk.h" #include "asterisk/app.h" #include "asterisk/module.h" #include "asterisk/pbx.h" #include "asterisk/stasis_app_broadcast.h" #include "asterisk/stasis_app_impl.h" /*** DOCUMENTATION 20.17.0 22.7.0 23.1.0 Broadcast a channel to multiple ARI applications for claiming, then hand control to the winning application. Timeout in milliseconds to wait for a claim. Valid range: 0 to 60000ms Default: 500ms Regular expression to filter which ARI applications receive the broadcast. Only applications with names matching the regex will be notified. Because arguments are comma-delimited, commas cannot appear in the regex pattern. Use character classes (e.g. [,]) if a literal comma is needed, or omit the filter and handle selection in the ARI application. Default: all connected applications Optional colon-delimited arguments passed to the winning application via the StasisStart event. These are equivalent to the extra arguments in Stasis(). Example: sales:priority-high Whether to send a CallClaimed event to ARI applications when a channel is claimed. When enabled, the CallClaimed event is sent only to applications that matched the app_filter (or all applications if no filter was set). Disabled by default to minimise WebSocket traffic under high load. Losing claimants already receive a 409 HTTP response. Default: no Broadcasts the incoming channel to all connected ARI applications (or a filtered subset) via a CallBroadcast event. ARI applications can respond with a claim request. The first application to claim the channel wins, and subsequent claims are rejected. If an application claims the channel within the timeout, the channel is automatically placed under Stasis control with the winning application, exactly as if Stasis(winner_app) had been called. The winning application receives a StasisStart event and has full channel control until it calls continue or the channel hangs up. If no application claims the channel within the timeout, control returns to the dialplan immediately, allowing fallback handling. This application will set the following channel variables: An application claimed the channel and the Stasis session completed without failures. An application claimed the channel but a failure occurred when executing the Stasis application. No application claimed the channel within the timeout period. ; Broadcast with default timeout (500ms) to all apps ; Channel automatically enters Stasis with the winner exten => _X.,1,StasisBroadcast() same => n,GotoIf($["${STASISSTATUS}"="TIMEOUT"]?no_route) same => n,Hangup() same => n(no_route),Playback(sorry-no-agent) same => n,Hangup() ; Broadcast with custom timeout, app filter, and args for the winner exten => _X.,1,StasisBroadcast(2000,^ivr-.*,sales:priority-high) same => n,GotoIf($["${STASISSTATUS}"="TIMEOUT"]?no_route) same => n,Hangup() same => n(no_route),Playback(sorry-no-agent) same => n,Hangup() ***/ /*! \brief Dialplan application name */ static const char *app = "StasisBroadcast"; /*! \brief Default timeout in milliseconds */ #define DEFAULT_TIMEOUT_MS 500 /*! \brief Maximum timeout in milliseconds */ #define MAX_TIMEOUT_MS 60000 /*! \brief Maximum number of Stasis arguments */ #define MAX_STASIS_ARGS 128 /*! \brief StasisBroadcast dialplan application callback */ static int stasis_broadcast_exec(struct ast_channel *chan, const char *data) { char *parse = NULL; int timeout_ms = DEFAULT_TIMEOUT_MS; const char *app_filter = NULL; const char *stasis_args_raw = NULL; unsigned int flags = STASIS_BROADCAST_FLAG_SUPPRESS_CLAIMED; char *winner = NULL; int result = 0; int stasis_argc = 0; char *stasis_argv[MAX_STASIS_ARGS]; AST_DECLARE_APP_ARGS(args, AST_APP_ARG(timeout); AST_APP_ARG(app_filter); AST_APP_ARG(stasis_args); AST_APP_ARG(notify_claimed); ); ast_assert(chan != NULL); /* Initialize channel variable */ pbx_builtin_setvar_helper(chan, "STASISSTATUS", ""); /* Parse positional arguments if provided */ if (!ast_strlen_zero(data)) { parse = ast_strdupa(data); AST_STANDARD_APP_ARGS(args, parse); if (!ast_strlen_zero(args.timeout)) { if (sscanf(args.timeout, "%d", &timeout_ms) != 1 || timeout_ms < 0 || timeout_ms > MAX_TIMEOUT_MS) { ast_log(LOG_WARNING, "Channel %s: invalid timeout value '%s' (must be 0-%dms), using default %dms\n", ast_channel_name(chan), args.timeout, MAX_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); timeout_ms = DEFAULT_TIMEOUT_MS; } } if (!ast_strlen_zero(args.app_filter)) { app_filter = args.app_filter; } if (!ast_strlen_zero(args.stasis_args)) { stasis_args_raw = args.stasis_args; } if (!ast_strlen_zero(args.notify_claimed) && ast_true(args.notify_claimed)) { flags &= ~STASIS_BROADCAST_FLAG_SUPPRESS_CLAIMED; } } /* * Parse colon-delimited Stasis arguments. stasis_argv[] holds * pointers into the stack-allocated args_copy buffer. This is * safe because stasis_app_exec is called within this same * function scope so the stack frame remains alive. */ if (!ast_strlen_zero(stasis_args_raw)) { char *args_copy = ast_strdupa(stasis_args_raw); char *arg; while ((arg = strsep(&args_copy, ":")) != NULL && stasis_argc < MAX_STASIS_ARGS) { stasis_argv[stasis_argc++] = arg; } } ast_debug(3, "Broadcasting channel %s (timeout=%dms, filter=%s, args=%d)\n", ast_channel_name(chan), timeout_ms, app_filter ? app_filter : "none", stasis_argc); /* Start the broadcast */ result = stasis_app_broadcast_channel(chan, timeout_ms, app_filter, flags); if (result) { ast_log(LOG_ERROR, "Failed to broadcast channel %s: %s\n", ast_channel_name(chan), result == AST_OPTIONAL_API_UNAVAILABLE ? "res_stasis_broadcast not loaded" : "internal error"); pbx_builtin_setvar_helper(chan, "STASISSTATUS", "FAILED"); return 0; } /* Wait for a claim. A late claim can arrive between the timeout * expiring and our cleanup call, so always check for a winner * regardless of the wait result. */ stasis_app_broadcast_wait(chan, timeout_ms); winner = stasis_app_broadcast_winner(ast_channel_uniqueid(chan)); if (winner) { int ret; ast_debug(3, "Channel %s claimed by %s, entering Stasis\n", ast_channel_name(chan), winner); /* Defer cleanup until after Stasis so concurrent claimants can still * find the context (with claimed=1) and receive 409 Conflict instead * of 404 Not Found. */ ret = stasis_app_exec(chan, winner, stasis_argc, stasis_argv); ast_free(winner); /* Clean up now that the Stasis session has ended */ stasis_app_broadcast_cleanup(ast_channel_uniqueid(chan)); if (ret) { pbx_builtin_setvar_helper(chan, "STASISSTATUS", "FAILED"); if (ast_check_hangup(chan)) { return -1; } } else { pbx_builtin_setvar_helper(chan, "STASISSTATUS", "SUCCESS"); } } else { /* No winner: clean up immediately, nothing to race against */ stasis_app_broadcast_cleanup(ast_channel_uniqueid(chan)); ast_log(LOG_WARNING, "Channel %s: not claimed within %dms timeout\n", ast_channel_name(chan), timeout_ms); pbx_builtin_setvar_helper(chan, "STASISSTATUS", "TIMEOUT"); } return 0; } static int load_module(void) { return ast_register_application_xml(app, stasis_broadcast_exec); } static int unload_module(void) { return ast_unregister_application(app); } AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_DEFAULT, "Stasis application broadcast", .support_level = AST_MODULE_SUPPORT_EXTENDED, .load = load_module, .unload = unload_module, .requires = "res_stasis,res_stasis_broadcast", );