]> git.ipfire.org Git - thirdparty/asterisk.git/commitdiff
chan_websocket: Add capability for JSON control messages and events. 23
authorGeorge Joseph <gjoseph@sangoma.com>
Wed, 22 Oct 2025 12:23:31 +0000 (06:23 -0600)
committerGeorge Joseph <gjoseph@sangoma.com>
Tue, 4 Nov 2025 19:27:51 +0000 (19:27 +0000)
With recent enhancements to chan_websocket, the original plain-text
implementation of control messages and events is now too limiting.  We
probably should have used JSON initially but better late than never.  Going
forward, enhancements that require control message or event changes will
only be done to the JSON variants and the plain-text variants are now
deprecated but not yet removed.

* Added the chan_websocket.conf config file that allows setting which control
message format to use globally: "json" or "plain-text".  "plain-text" is the
default for now to preserve existing behavior.

* Added a dialstring option `f(json|plain-text)` to allow the format to be
overridden on a call-by-call basis.  Again, 'plain-text' is the default for
now to preserve existing behavior.

The JSON for commands sent by the app to Asterisk must be...
`{ "command": "<command>" ... }` where `<command>` is one of `ANSWER`, `HANGUP`,
`START_MEDIA_BUFFERING`, etc.  The `STOP_MEDIA_BUFFERING` command takes an
additional, optional parameter to be returned in the corresponding
`MEDIA_BUFFERING_COMPLETED` event:
`{ "command": "STOP_MEDIA_BUFFERING", "correlation_id": "<correlation id>" }`.

The JSON for events sent from Asterisk to the app will be...
`{ "event": "<event>", "channel_id": "<channel_id>" ... }`.
The `MEDIA_START` event will now look like...

```
{
  "event": "MEDIA_START",
  "connection_id": "media_connection1",
  "channel": "WebSocket/media_connection1/0x5140001a0040",
  "channel_id": "1761245643.1",
  "format": "ulaw",
  "optimal_frame_size": 160,
  "ptime": 20,
  "channel_variables": {
    "DIALEDPEERNUMBER": "media_connection1/c(ulaw)",
    "MEDIA_WEBSOCKET_CONNECTION_ID": "media_connection1",
    "MEDIA_WEBSOCKET_OPTIMAL_FRAME_SIZE": "160"
  }
}
```

Note the addition of the channel variables which can't be supported
with the plain-text formatting.

The documentation will be updated with the exact formats for all commands
and events.

Resolves: #1546
Resolves: #1563

DeveloperNote: The chan_websocket plain-text control and event messages are now
deprecated (but remain the default) in favor of JSON formatted messages.
See https://docs.asterisk.org/Configuration/Channel-Drivers/WebSocket for
more information.

DeveloperNote: A "transport_data" parameter has been added to the
channels/externalMedia ARI endpoint which, for websocket, allows the caller
to specify parameters to be added to the dialstring for the channel.  For
instance, `"transport_data": "f(json)"`.

channels/chan_websocket.c
channels/chan_websocket_doc.xml [new file with mode: 0644]
configs/samples/chan_websocket.conf.sample [new file with mode: 0644]
res/ari/resource_channels.c
res/ari/resource_channels.h
res/res_ari_channels.c
rest-api/api-docs/channels.json

index 4057aca4771e37c0217076a02762a5fe26bc89ae..4c5f92cc65bc50eaa69ad2b0757e7e364fdc170b 100644 (file)
        <support_level>core</support_level>
  ***/
 
-/*** DOCUMENTATION
-       <info name="Dial_Resource" language="en_US" tech="WebSocket">
-               <para>WebSocket Dial Strings:</para>
-               <para><literal>Dial(WebSocket/connectionid[/websocket_options])</literal></para>
-               <para>WebSocket Parameters:</para>
-               <enumlist>
-                       <enum name="connectionid">
-                               <para>For outgoing WebSockets, this is the ID of the connection
-                               in websocket_client.conf to use for the call.  To accept incoming
-                               WebSocket connections use the literal <literal>INCOMING</literal></para>
-                       </enum>
-                       <enum name="websocket_options">
-                               <para>Options to control how the WebSocket channel behaves.</para>
-                               <enumlist>
-                                       <enum name="c(codec) - Specify the codec to use in the channel">
-                                               <para></para>
-                                               <para> If not specified, the first codec from the caller's channel will be used.
-                                               </para>
-                                       </enum>
-                                       <enum name="n - Don't auto answer">
-                                               <para>Normally, the WebSocket channel will be answered when
-                                               connection is established with the remote app.  If this
-                                               option is specified however, the channel will not be
-                                               answered until the <literal>ANSWER</literal> command is
-                                               received from the remote app or the remote app calls the
-                                               /channels/answer ARI endpoint.
-                                               </para>
-                                       </enum>
-                                       <enum name="p - Passthrough mode">
-                                               <para>In passthrough mode, the channel driver won't attempt
-                                               to re-frame or re-time media coming in over the websocket from
-                                               the remote app.  This can be used for any codec but MUST be used
-                                               for codecs that use packet headers or whose data stream can't be
-                                               broken up on arbitrary byte boundaries. In this case, the remote
-                                               app is fully responsible for correctly framing and timing media
-                                               sent to Asterisk and the MEDIA text commands that could be sent
-                                               over the websocket are disabled.  Currently, passthrough mode is
-                                               automatically set for the opus, speex and g729 codecs.
-                                               </para>
-                                       </enum>
-                                       <enum name="v(uri_parameters) - Add parameters to the outbound URI">
-                                               <para>This option allows you to add additional parameters to the
-                                               outbound URI.  The format is:
-                                               <literal>v(param1=value1,param2=value2...)</literal>
-                                               </para>
-                                               <para>You must ensure that no parameter name or value contains
-                                               characters not valid in a URL.  The easiest way to do this is to
-                                               use the URIENCODE() dialplan function to encode them.  Be aware
-                                               though that each name and value must be encoded separately.  You
-                                               can't simply encode the whole string.</para>
-                                       </enum>
-                               </enumlist>
-                       </enum>
-               </enumlist>
-               <para>Examples:
-               </para>
-               <example title="Make an outbound WebSocket connection using connection 'connection1' and the 'sln16' codec.">
-               same => n,Dial(WebSocket/connection1/c(sln16))
-               </example>
-               <example title="Make an outbound WebSocket connection using connection 'connection1' and the 'opus' codec. Passthrough mode will automatically be set.">
-               same => n,Dial(WebSocket/connection1/c(opus))
-               </example>
-               <example title="Listen for an incoming WebSocket connection and don't auto-answer it.">
-               same => n,Dial(WebSocket/INCOMING/n)
-               </example>
-               <example title="Add URI parameters.">
-               same => n,Dial(WebSocket/connection1/v(${URIENCODE(vari able)}=${URIENCODE(${CHANNEL})},variable2=$(URIENCODE(${EXTEN})}))
-               </example>
-       </info>
-***/
 #include "asterisk.h"
 
 #include "asterisk/app.h"
 #include "asterisk/http_websocket.h"
 #include "asterisk/format_cache.h"
 #include "asterisk/frame.h"
+#include "asterisk/json.h"
 #include "asterisk/lock.h"
 #include "asterisk/mod_format.h"
 #include "asterisk/module.h"
 #include "asterisk/timing.h"
 #include "asterisk/translate.h"
 #include "asterisk/websocket_client.h"
+#include "asterisk/sorcery.h"
+
+static struct ast_sorcery *sorcery = NULL;
+
+enum webchan_control_msg_format {
+       WEBCHAN_CONTROL_MSG_FORMAT_PLAIN = 0,
+       WEBCHAN_CONTROL_MSG_FORMAT_JSON,
+       WEBCHAN_CONTROL_MSG_FORMAT_INVALID,
+};
+
+static const char *msg_format_map[] = {
+       [WEBCHAN_CONTROL_MSG_FORMAT_PLAIN] = "plain-text",
+       [WEBCHAN_CONTROL_MSG_FORMAT_JSON] = "json",
+       [WEBCHAN_CONTROL_MSG_FORMAT_INVALID] = "invalid",
+};
+
+struct webchan_conf_global {
+       SORCERY_OBJECT(details);
+       enum webchan_control_msg_format control_msg_format;
+};
 
 static struct ast_websocket_server *ast_ws_server;
 
@@ -141,6 +92,7 @@ struct websocket_pvt {
        size_t leftover_len;
        char *uri_params;
        char *leftover_data;
+       enum webchan_control_msg_format control_msg_format;
        int no_auto_answer;
        int passthrough;
        int optimal_frame_size;
@@ -166,6 +118,7 @@ struct websocket_pvt {
 #define PAUSE_MEDIA "PAUSE_MEDIA"
 #define CONTINUE_MEDIA "CONTINUE_MEDIA"
 
+#if 0
 #define MEDIA_START "MEDIA_START"
 #define MEDIA_XON "MEDIA_XON"
 #define MEDIA_XOFF "MEDIA_XOFF"
@@ -173,6 +126,7 @@ struct websocket_pvt {
 #define DRIVER_STATUS "STATUS"
 #define MEDIA_BUFFERING_COMPLETED "MEDIA_BUFFERING_COMPLETED"
 #define DTMF_END "DTMF_END"
+#endif
 
 #define QUEUE_LENGTH_MAX 1000
 #define QUEUE_LENGTH_XOFF_LEVEL 900
@@ -198,6 +152,250 @@ static struct ast_channel_tech websocket_tech = {
        .send_digit_end = webchan_send_dtmf_text,
 };
 
+static enum webchan_control_msg_format control_msg_format_from_str(const char *value)
+{
+       if (ast_strlen_zero(value)) {
+               return WEBCHAN_CONTROL_MSG_FORMAT_INVALID;
+       } else if (strcasecmp(value, msg_format_map[WEBCHAN_CONTROL_MSG_FORMAT_PLAIN]) == 0) {
+               return WEBCHAN_CONTROL_MSG_FORMAT_PLAIN;
+       } else if (strcasecmp(value, msg_format_map[WEBCHAN_CONTROL_MSG_FORMAT_JSON]) == 0) {
+               return WEBCHAN_CONTROL_MSG_FORMAT_JSON;
+       } else {
+               return WEBCHAN_CONTROL_MSG_FORMAT_INVALID;
+       }
+}
+
+static const char *control_msg_format_to_str(enum webchan_control_msg_format value)
+{
+       if (!ARRAY_IN_BOUNDS(value, msg_format_map)) {
+               return NULL;
+       }
+       return msg_format_map[value];
+}
+
+/*!
+ * \internal
+ * \brief Catch-all to print events that don't have any data.
+ * \warning Do not call directly.
+ */
+static char *_create_event_nodata(struct websocket_pvt *instance, char *event)
+{
+       char *payload = NULL;
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json * msg = ast_json_pack("{ s:s s:s }",
+                       "event", event,
+                       "channel_id", ast_channel_uniqueid(instance->channel));
+               if (!msg) {
+                       return NULL;
+               }
+               payload = ast_json_dump_string_format(msg, AST_JSON_COMPACT);
+               ast_json_unref(msg);
+       } else {
+               payload = ast_strdup(event);
+       }
+
+       return payload;
+}
+
+#define _create_event_MEDIA_XON(_instance) _create_event_nodata(_instance, "MEDIA_XON");
+#define _create_event_MEDIA_XOFF(_instance) _create_event_nodata(_instance, "MEDIA_XOFF");
+#define _create_event_QUEUE_DRAINED(_instance) _create_event_nodata(_instance, "QUEUE_DRAINED");
+
+/*!
+ * \internal
+ * \brief Print the MEDIA_START event.
+ * \warning Do not call directly.
+ */
+static char *_create_event_MEDIA_START(struct websocket_pvt *instance)
+{
+       char *payload = NULL;
+
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json *msg = ast_json_pack("{s:s, s:s, s:s, s:s, s:s, s:i, s:i, s:o }",
+                       "event", "MEDIA_START",
+                       "connection_id", instance->connection_id,
+                       "channel", ast_channel_name(instance->channel),
+                       "channel_id", ast_channel_uniqueid(instance->channel),
+                       "format", ast_format_get_name(instance->native_format),
+                       "optimal_frame_size", instance->optimal_frame_size,
+                       "ptime", instance->native_codec->default_ms,
+                       "channel_variables", ast_json_channel_vars(ast_channel_varshead(
+                                               instance->channel))
+                       );
+               if (!msg) {
+                       return NULL;
+               }
+               payload = ast_json_dump_string_format(msg, AST_JSON_COMPACT);
+               ast_json_unref(msg);
+       } else {
+               ast_asprintf(&payload, "%s %s:%s %s:%s %s:%s %s:%s %s:%d %s:%d",
+                       "MEDIA_START",
+                       "connection_id", instance->connection_id,
+                       "channel", ast_channel_name(instance->channel),
+                       "channel_id", ast_channel_uniqueid(instance->channel),
+                       "format", ast_format_get_name(instance->native_format),
+                       "optimal_frame_size", instance->optimal_frame_size,
+                       "ptime", instance->native_codec->default_ms
+                       );
+       }
+
+       return payload;
+}
+
+/*!
+ * \internal
+ * \brief Print the MEDIA_BUFFERING_COMPLETED event.
+ * \warning Do not call directly.
+ */
+static char *_create_event_MEDIA_BUFFERING_COMPLETED(struct websocket_pvt *instance,
+       const char *id)
+{
+       char *payload = NULL;
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json *msg = ast_json_pack("{s:s, s:s, s:s}",
+                       "event", "MEDIA_BUFFERING_COMPLETED",
+                       "channel_id", ast_channel_uniqueid(instance->channel),
+                       "correlation_id", S_OR(id, "")
+                       );
+               if (!msg) {
+                       return NULL;
+               }
+               payload = ast_json_dump_string_format(msg, AST_JSON_COMPACT);
+               ast_json_unref(msg);
+       } else {
+               ast_asprintf(&payload, "%s%s%s",
+                       "MEDIA_BUFFERING_COMPLETED",
+                       S_COR(id, " ",""), S_OR(id, ""));
+
+       }
+
+       return payload;
+}
+
+/*!
+ * \internal
+ * \brief Print the DTMF_END event.
+ * \warning Do not call directly.
+ */
+static char *_create_event_DTMF_END(struct websocket_pvt *instance,
+       const char digit)
+{
+       char *payload = NULL;
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json *msg = ast_json_pack("{s:s, s:s, s:s#}",
+                       "event", "DTMF_END",
+                       "channel_id", ast_channel_uniqueid(instance->channel),
+                       "digit", digit, 1
+                       );
+               if (!msg) {
+                       return NULL;
+               }
+               payload = ast_json_dump_string_format(msg, AST_JSON_COMPACT);
+               ast_json_unref(msg);
+       } else {
+               ast_asprintf(&payload, "%s digit:%c channel_id:%s",
+                       "DTMF_END", digit, ast_channel_uniqueid(instance->channel));
+       }
+
+       return payload;
+}
+
+/*!
+ * \internal
+ * \brief Print the STATUS event.
+ * \warning Do not call directly.
+ */
+static char *_create_event_STATUS(struct websocket_pvt *instance)
+{
+       char *payload = NULL;
+
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json *msg = ast_json_pack("{s:s, s:s, s:i, s:i, s:i, s:b, s:b, s:b }",
+                       "event", "STATUS",
+                       "channel_id", ast_channel_uniqueid(instance->channel),
+                       "queue_length", instance->frame_queue_length,
+                       "xon_level", QUEUE_LENGTH_XON_LEVEL,
+                       "xoff_level", QUEUE_LENGTH_XOFF_LEVEL,
+                       "queue_full", instance->queue_full,
+                       "bulk_media", instance->bulk_media_in_progress,
+                       "media_paused", instance->queue_paused
+                       );
+               if (!msg) {
+                       return NULL;
+               }
+               payload = ast_json_dump_string_format(msg, AST_JSON_COMPACT);
+               ast_json_unref(msg);
+       } else {
+               ast_asprintf(&payload, "%s channel_id:%s queue_length:%d xon_level:%d xoff_level:%d queue_full:%s bulk_media:%s media_paused:%s",
+                       "STATUS",
+                       ast_channel_uniqueid(instance->channel),
+                       instance->frame_queue_length, QUEUE_LENGTH_XON_LEVEL,
+                       QUEUE_LENGTH_XOFF_LEVEL,
+                       S_COR(instance->queue_full, "true", "false"),
+                       S_COR(instance->bulk_media_in_progress, "true", "false"),
+                       S_COR(instance->queue_paused, "true", "false")
+                       );
+       }
+
+       return payload;
+}
+
+/*!
+ * \internal
+ * \brief Print the ERROR event.
+ * \warning Do not call directly.
+ */
+static char *_create_event_ERROR(struct websocket_pvt *instance,
+       const char *error_text)
+{
+       char *payload = NULL;
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json *msg = ast_json_pack("{s:s, s:s, s:s}",
+                       "event", "ERROR",
+                       "channel_id", ast_channel_uniqueid(instance->channel),
+                       "error_text", error_text);
+               if (!msg) {
+                       return NULL;
+               }
+               payload = ast_json_dump_string_format(msg, AST_JSON_COMPACT);
+               ast_json_unref(msg);
+       } else {
+               ast_asprintf(&payload, "%s channel_id:%s error_text:%s",
+                       "ERROR", ast_channel_uniqueid(instance->channel), error_text);
+       }
+
+       return payload;
+}
+
+/*!
+ * \def create_event
+ * \brief Use this macro to create events passing in any event-specific parameters.
+ */
+#define create_event(_instance, _event, ...) \
+       _create_event_ ## _event(_instance, ##__VA_ARGS__)
+
+/*!
+ * \def send_event
+ * \brief Use this macro to create and send events passing in any event-specific parameters.
+ */
+#define send_event(_instance, _event, ...) \
+({ \
+       int _res = -1; \
+       char *_payload = _create_event_ ## _event(_instance, ##__VA_ARGS__); \
+       if (_payload) { \
+               _res = ast_websocket_write_string(_instance->websocket, _payload); \
+               if (_res != 0) { \
+                       ast_log(LOG_ERROR, "%s: Unable to send event %s\n", \
+                               ast_channel_name(instance->channel), _payload); \
+               } else { \
+                       ast_debug(4, "%s: Sent %s\n", \
+                               ast_channel_name(instance->channel), _payload); \
+               }\
+               ast_free(_payload); \
+       } \
+       (_res); \
+})
+
 static void set_channel_format(struct websocket_pvt * instance,
        struct ast_format *fmt)
 {
@@ -240,7 +438,7 @@ static struct ast_frame *dequeue_frame(struct websocket_pvt *instance)
                instance->queue_full = 0;
                ast_debug(4, "%s: WebSocket sending MEDIA_XON\n",
                        ast_channel_name(instance->channel));
-               ast_websocket_write_string(instance->websocket, MEDIA_XON);
+               send_event(instance, MEDIA_XON);
        }
 
        queued_frame = AST_LIST_REMOVE_HEAD(&instance->frame_queue, frame_list);
@@ -256,7 +454,7 @@ static struct ast_frame *dequeue_frame(struct websocket_pvt *instance)
                        instance->report_queue_drained = 0;
                        ast_debug(4, "%s: WebSocket sending QUEUE_DRAINED\n",
                                ast_channel_name(instance->channel));
-                       ast_websocket_write_string(instance->websocket, QUEUE_DRAINED);
+                       send_event(instance, QUEUE_DRAINED);
                }
                return NULL;
        }
@@ -284,7 +482,7 @@ static struct ast_frame *dequeue_frame(struct websocket_pvt *instance)
                         */
                        ast_websocket_write_string(instance->websocket,
                                queued_frame->data.ptr);
-                       ast_debug(4, "%s: WebSocket sending %s\n",
+                       ast_debug(4, "%s: Sent %s\n",
                                ast_channel_name(instance->channel), (char *)queued_frame->data.ptr);
                }
                /*
@@ -445,9 +643,7 @@ static int queue_frame_from_buffer(struct websocket_pvt *instance,
                instance->frame_queue_length++;
                if (!instance->queue_full && instance->frame_queue_length >= QUEUE_LENGTH_XOFF_LEVEL) {
                        instance->queue_full = 1;
-                       ast_debug(4, "%s: WebSocket sending %s\n",
-                               ast_channel_name(instance->channel), MEDIA_XOFF);
-                       ast_websocket_write_string(instance->websocket, MEDIA_XOFF);
+                       send_event(instance, MEDIA_XOFF);
                }
        }
 
@@ -484,30 +680,39 @@ static int queue_option_frame(struct websocket_pvt *instance,
        return 0;
 }
 
-static int process_text_message(struct websocket_pvt *instance,
-       char *payload, uint64_t payload_len)
+/*!
+ * \internal
+ * \brief Handle commands from the websocket
+ *
+ * \param instance
+ * \param buffer Allocated by caller so don't free.
+ * \retval 0 Success
+ * \retval -1 Failure
+ */
+static int handle_command(struct websocket_pvt *instance, char *buffer)
 {
        int res = 0;
-       char *command;
-
-       if (payload_len > MAX_TEXT_MESSAGE_LEN) {
-               ast_log(LOG_WARNING, "%s: WebSocket TEXT message of length %d exceeds maximum length of %d\n",
-                       ast_channel_name(instance->channel), (int)payload_len, MAX_TEXT_MESSAGE_LEN);
-               return 0;
-       }
+       RAII_VAR(struct ast_json *, json, NULL, ast_json_unref);
+       const char *command = NULL;
+       char *data = NULL;
 
-       /*
-        * Unfortunately, payload is not NULL terminated even when it's
-        * a TEXT frame so we need to allocate a new buffer, copy
-        * the data into it, and NULL terminate it.
-        */
-       command = ast_alloca(payload_len + 1);
-       memcpy(command, payload, payload_len); /* Safe */
-       command[payload_len] = '\0';
-       command = ast_strip(command);
+       if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+               struct ast_json_error json_error;
 
-       ast_debug(4, "%s: WebSocket %s command received\n",
-               ast_channel_name(instance->channel), command);
+               json = ast_json_load_buf(buffer, strlen(buffer), &json_error);
+               if (!json) {
+                       send_event(instance, ERROR, "Unable to parse JSON command");
+                       return -1;
+               }
+               command = ast_json_object_string_get(json, "command");
+       } else {
+               command = buffer;
+               data = strchr(buffer, ' ');
+               if (data) {
+                       *data = '\0';
+                       data++;
+               }
+       }
 
        if (ast_strings_equal(command, ANSWER_CHANNEL)) {
                ast_queue_control(instance->channel, AST_CONTROL_ANSWER);
@@ -525,13 +730,17 @@ static int process_text_message(struct websocket_pvt *instance,
                instance->bulk_media_in_progress = 1;
                AST_LIST_UNLOCK(&instance->frame_queue);
 
-       } else if (ast_begins_with(command, STOP_MEDIA_BUFFERING)) {
-               char *id;
+       } else if (ast_strings_equal(command, STOP_MEDIA_BUFFERING)) {
+               const char *id;
                char *option;
                SCOPED_LOCK(frame_queue_lock, &instance->frame_queue, AST_LIST_LOCK,
                        AST_LIST_UNLOCK);
 
-               id = ast_strip(command + strlen(STOP_MEDIA_BUFFERING));
+               if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_JSON) {
+                       id = ast_json_object_string_get(json, "correlation_id");
+               } else {
+                       id = data;
+               }
 
                if (instance->passthrough) {
                        ast_debug(4, "%s: WebSocket in passthrough mode. Ignoring %s command.\n",
@@ -551,10 +760,9 @@ static int process_text_message(struct websocket_pvt *instance,
                        }
                }
                instance->leftover_len = 0;
-               res = ast_asprintf(&option, "%s%s%s", MEDIA_BUFFERING_COMPLETED,
-                       S_COR(!ast_strlen_zero(id), " ", ""), S_OR(id, ""));
-               if (res <= 0 || !option) {
-                       return res;
+               option = create_event(instance, MEDIA_BUFFERING_COMPLETED, id);
+               if (!option) {
+                       return -1;
                }
                res = queue_option_frame(instance, option);
                ast_free(option);
@@ -589,26 +797,7 @@ static int process_text_message(struct websocket_pvt *instance,
                AST_LIST_UNLOCK(&instance->frame_queue);
 
        } else if (ast_strings_equal(command, GET_DRIVER_STATUS)) {
-               char *status = NULL;
-
-               res = ast_asprintf(&status, "%s channel_id:%s queue_length:%d xon_level:%d xoff_level:%d queue_full:%s bulk_media:%s media_paused:%s",
-                       DRIVER_STATUS,
-                       ast_channel_uniqueid(instance->channel),
-                       instance->frame_queue_length, QUEUE_LENGTH_XON_LEVEL,
-                       QUEUE_LENGTH_XOFF_LEVEL,
-                       S_COR(instance->queue_full, "true", "false"),
-                       S_COR(instance->bulk_media_in_progress, "true", "false"),
-                       S_COR(instance->queue_paused, "true", "false")
-                       );
-               if (res <= 0 || !status) {
-                       ast_free(status);
-                       res = -1;
-               } else {
-                       ast_debug(4, "%s: WebSocket status: %s\n",
-                               ast_channel_name(instance->channel), status);
-                       res = ast_websocket_write_string(instance->websocket, status);
-                       ast_free(status);
-               }
+               return send_event(instance, STATUS);
 
        } else if (ast_strings_equal(command, PAUSE_MEDIA)) {
                if (instance->passthrough) {
@@ -638,6 +827,39 @@ static int process_text_message(struct websocket_pvt *instance,
        return res;
 }
 
+static int process_text_message(struct websocket_pvt *instance,
+       char *payload, uint64_t payload_len)
+{
+       char *command;
+
+       if (payload_len == 0) {
+               ast_log(LOG_WARNING, "%s: WebSocket TEXT message has 0 length\n",
+                       ast_channel_name(instance->channel));
+               return 0;
+       }
+
+       if (payload_len > MAX_TEXT_MESSAGE_LEN) {
+               ast_log(LOG_WARNING, "%s: WebSocket TEXT message of length %d exceeds maximum length of %d\n",
+                       ast_channel_name(instance->channel), (int)payload_len, MAX_TEXT_MESSAGE_LEN);
+               return 0;
+       }
+
+       /*
+        * Unfortunately, payload is not NULL terminated even when it's
+        * a TEXT frame so we need to allocate a new buffer, copy
+        * the data into it, and NULL terminate it.
+        */
+       command = ast_alloca(payload_len + 1);
+       memcpy(command, payload, payload_len); /* Safe */
+       command[payload_len] = '\0';
+       command = ast_strip(command);
+
+       ast_debug(4, "%s: Received: %s\n",
+               ast_channel_name(instance->channel), command);
+
+       return handle_command(instance, command);
+}
+
 static int process_binary_message(struct websocket_pvt *instance,
        char *payload, uint64_t payload_len)
 {
@@ -838,34 +1060,15 @@ static int read_from_ws_and_queue(struct websocket_pvt *instance)
 static void *read_thread_handler(void *obj)
 {
        RAII_VAR(struct websocket_pvt *, instance, obj, ao2_cleanup);
-       RAII_VAR(char *, command, NULL, ast_free);
        int res = 0;
 
        ast_debug(3, "%s: Read thread started\n", ast_channel_name(instance->channel));
 
-       /*
-        * We need to tell the remote app what channel this media is for.
-        * This is especially important for outbound connections otherwise
-        * the app won't know who the media is for.
-        */
-       res = ast_asprintf(&command, "%s connection_id:%s channel:%s channel_id:%s format:%s optimal_frame_size:%d ptime:%d", MEDIA_START,
-               instance->connection_id, ast_channel_name(instance->channel),
-               ast_channel_uniqueid(instance->channel),
-               ast_format_get_name(instance->native_format),
-               instance->optimal_frame_size, instance->native_codec->default_ms);
-       if (res <= 0 || !command) {
-               ast_queue_control(instance->channel, AST_CONTROL_HANGUP);
-               ast_log(LOG_ERROR, "%s: Failed to create MEDIA_START\n", ast_channel_name(instance->channel));
-               return NULL;
-       }
-       res = ast_websocket_write_string(instance->websocket, command);
-       if (res != 0) {
-               ast_log(LOG_ERROR, "%s: Failed to send MEDIA_START\n", ast_channel_name(instance->channel));
+       res = send_event(instance, MEDIA_START);
+       if (res != 0 ) {
                ast_queue_control(instance->channel, AST_CONTROL_HANGUP);
                return NULL;
        }
-       ast_debug(3, "%s: Sent %s\n", ast_channel_name(instance->channel),
-               command);
 
        if (!instance->no_auto_answer) {
                ast_debug(3, "%s: ANSWER by auto_answer\n", ast_channel_name(instance->channel));
@@ -1277,6 +1480,7 @@ enum {
        OPT_WS_NO_AUTO_ANSWER =  (1 << 1),
        OPT_WS_URI_PARAM =  (1 << 2),
        OPT_WS_PASSTHROUGH =  (1 << 3),
+       OPT_WS_MSG_FORMAT =  (1 << 4),
 };
 
 enum {
@@ -1284,6 +1488,7 @@ enum {
        OPT_ARG_WS_NO_AUTO_ANSWER,
        OPT_ARG_WS_URI_PARAM,
        OPT_ARG_WS_PASSTHROUGH,
+       OPT_ARG_WS_MSG_FORMAT,
        OPT_ARG_ARRAY_SIZE
 };
 
@@ -1292,6 +1497,7 @@ AST_APP_OPTIONS(websocket_options, BEGIN_OPTIONS
        AST_APP_OPTION('n', OPT_WS_NO_AUTO_ANSWER),
        AST_APP_OPTION_ARG('v', OPT_WS_URI_PARAM, OPT_ARG_WS_URI_PARAM),
        AST_APP_OPTION('p', OPT_WS_PASSTHROUGH),
+       AST_APP_OPTION_ARG('f', OPT_WS_MSG_FORMAT, OPT_ARG_WS_MSG_FORMAT),
        END_OPTIONS );
 
 static struct ast_channel *webchan_request(const char *type,
@@ -1310,6 +1516,9 @@ static struct ast_channel *webchan_request(const char *type,
        struct ast_flags opts = { 0, };
        char *opt_args[OPT_ARG_ARRAY_SIZE];
        const char *requestor_name = requestor ? ast_channel_name(requestor) : "no channel";
+       RAII_VAR(struct webchan_conf_global *, global_cfg, NULL, ao2_cleanup);
+
+       global_cfg = ast_sorcery_retrieve_by_id(sorcery, "global", "global");
 
        ast_debug(3, "%s: WebSocket channel requested\n",
                requestor_name);
@@ -1405,6 +1614,19 @@ static struct ast_channel *webchan_request(const char *type,
                ast_debug(3, "%s: Using final URI '%s'\n", requestor_name, instance->uri_params);
        }
 
+       if (ast_test_flag(&opts, OPT_WS_MSG_FORMAT)) {
+               instance->control_msg_format = control_msg_format_from_str(opt_args[OPT_ARG_WS_MSG_FORMAT]);
+
+               if (instance->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_INVALID) {
+                       ast_log(LOG_WARNING, "%s: 'f/control message format' dialstring parameter value missing or invalid. "
+                               "Defaulting to 'plain-text'\n",
+                               ast_channel_name(requestor));
+                       instance->control_msg_format = WEBCHAN_CONTROL_MSG_FORMAT_PLAIN;
+               }
+       } else if (global_cfg) {
+               instance->control_msg_format = global_cfg->control_msg_format;
+       }
+
        chan = ast_channel_alloc(1, AST_STATE_DOWN, "", "", "", "", "", assignedids,
                requestor, 0, "WebSocket/%s/%p", args.connection_id, instance);
        if (!chan) {
@@ -1502,28 +1724,13 @@ static int webchan_hangup(struct ast_channel *ast)
 
 static int webchan_send_dtmf_text(struct ast_channel *ast, char digit, unsigned int duration)
 {
-               struct websocket_pvt *instance = ast_channel_tech_pvt(ast);
-               char *command;
-               int res = 0;
+       struct websocket_pvt *instance = ast_channel_tech_pvt(ast);
 
-               if (!instance) {
-                       return -1;
-               }
+       if (!instance) {
+               return -1;
+       }
 
-               res = ast_asprintf(&command, "%s digit:%c channel_id:%s", DTMF_END, digit, ast_channel_uniqueid(instance->channel));
-               if (res <= 0 || !command) {
-                       ast_log(LOG_ERROR, "%s: Failed to create DTMF_END\n", ast_channel_name(instance->channel));
-                       return 0;
-               }
-               res = ast_websocket_write_string(instance->websocket, command);
-               if (res != 0) {
-                       ast_log(LOG_ERROR, "%s: Failed to send DTMF_END\n", ast_channel_name(instance->channel));
-                       ast_free(command);
-                       return 0;
-               }
-               ast_debug(3, "%s: Sent %s\n", ast_channel_name(instance->channel), command);
-               ast_free(command);
-               return 0;
+       return send_event(instance, DTMF_END, digit);
 }
 
 /*!
@@ -1687,6 +1894,85 @@ static struct ast_http_uri http_uri = {
        .no_decode_uri = 1,
 };
 
+AO2_STRING_FIELD_HASH_FN(instance_proxy, connection_id)
+AO2_STRING_FIELD_CMP_FN(instance_proxy, connection_id)
+AO2_STRING_FIELD_SORT_FN(instance_proxy, connection_id)
+
+static int global_control_message_format_from_str(const struct aco_option *opt,
+       struct ast_variable *var, void *obj)
+{
+       struct webchan_conf_global *cfg = obj;
+
+       cfg->control_msg_format = control_msg_format_from_str(var->value);
+
+       if (cfg->control_msg_format == WEBCHAN_CONTROL_MSG_FORMAT_INVALID) {
+               ast_log(LOG_ERROR, "chan_websocket.conf: Invalid value '%s' for "
+                       "control_mesage_format. Must be 'plain-text' or 'json'\n",
+                       var->value);
+               return -1;
+       }
+
+       return 0;
+}
+
+static int global_control_message_format_to_str(const void *obj, const intptr_t *args, char **buf)
+{
+       const struct webchan_conf_global *cfg = obj;
+
+       *buf = ast_strdup(control_msg_format_to_str(cfg->control_msg_format));
+
+       return 0;
+}
+
+static void *global_alloc(const char *name)
+{
+       struct webchan_conf_global *cfg = ast_sorcery_generic_alloc(
+               sizeof(*cfg), NULL);
+
+       if (!cfg) {
+               return NULL;
+       }
+
+       return cfg;
+}
+
+static int global_apply(const struct ast_sorcery *sorcery, void *obj)
+{
+       struct webchan_conf_global *cfg = obj;
+
+       ast_debug(1, "control_msg_format: %s\n",
+               control_msg_format_to_str(cfg->control_msg_format));
+
+       return 0;
+}
+
+static int load_config(void)
+{
+       ast_debug(2, "Initializing Websocket Client Configuration\n");
+       sorcery = ast_sorcery_open();
+       if (!sorcery) {
+               ast_log(LOG_ERROR, "Failed to open sorcery\n");
+               return -1;
+       }
+
+       ast_sorcery_apply_default(sorcery, "global", "config",
+               "chan_websocket.conf,criteria=type=global,single_object=yes,explicit_name=global");
+
+       if (ast_sorcery_object_register(sorcery, "global", global_alloc, NULL, global_apply)) {
+               ast_log(LOG_ERROR, "Failed to register chan_websocket global object with sorcery\n");
+               ast_sorcery_unref(sorcery);
+               sorcery = NULL;
+               return -1;
+       }
+
+       ast_sorcery_object_field_register_nodoc(sorcery, "global", "type", "", OPT_NOOP_T, 0, 0);
+       ast_sorcery_register_cust(global, control_message_format, "plain-text");
+
+       ast_sorcery_load(sorcery);
+
+       return 0;
+}
+
 /*! \brief Function called when our module is unloaded */
 static int unload_module(void)
 {
@@ -1701,12 +1987,19 @@ static int unload_module(void)
        ao2_cleanup(instances);
        instances = NULL;
 
+       ast_sorcery_unref(sorcery);
+       sorcery = NULL;
+
        return 0;
 }
 
-AO2_STRING_FIELD_HASH_FN(instance_proxy, connection_id)
-AO2_STRING_FIELD_CMP_FN(instance_proxy, connection_id)
-AO2_STRING_FIELD_SORT_FN(instance_proxy, connection_id)
+static int reload_module(void)
+{
+       ast_debug(2, "Reloading chan_websocket configuration\n");
+       ast_sorcery_reload(sorcery);
+
+       return 0;
+}
 
 /*! \brief Function called when our module is loaded */
 static int load_module(void)
@@ -1714,6 +2007,11 @@ static int load_module(void)
        int res = 0;
        struct ast_websocket_protocol *protocol;
 
+       res = load_config();
+       if (res != 0) {
+               return AST_MODULE_LOAD_DECLINE;
+       }
+
        if (!(websocket_tech.capabilities = ast_format_cap_alloc(AST_FORMAT_CAP_FLAG_DEFAULT))) {
                return AST_MODULE_LOAD_DECLINE;
        }
@@ -1758,6 +2056,7 @@ AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Websocket Media Chann
        .support_level = AST_MODULE_SUPPORT_CORE,
        .load = load_module,
        .unload = unload_module,
+       .reload = reload_module,
        .load_pri = AST_MODPRI_CHANNEL_DRIVER,
        .requires = "res_http_websocket,res_websocket_client",
 );
diff --git a/channels/chan_websocket_doc.xml b/channels/chan_websocket_doc.xml
new file mode 100644 (file)
index 0000000..e264783
--- /dev/null
@@ -0,0 +1,119 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE docs SYSTEM "appdocsxml.dtd">
+<docs xmlns:xi="http://www.w3.org/2001/XInclude">
+       <configInfo name="chan_websocket" language="en_US">
+               <synopsis>Configuration for chan_websocket</synopsis>
+               <description><para>
+                       <emphasis>WebSocket Channel Driver</emphasis>
+                       </para>
+               </description>
+               <configFile name="chan_websocket.conf">
+                       <configObject name="global">
+                               <since>
+                                       <version>20.18.0</version>
+                                       <version>22.8.0</version>
+                                       <version>23.2.0</version>
+                               </since>
+                               <synopsis>Global settings for chan_websocket</synopsis>
+                               <configOption name="control_message_format" default="plain-text">
+                                       <since>
+                                               <version>20.18.0</version>
+                                               <version>22.8.0</version>
+                                               <version>23.2.0</version>
+                                       </since>
+                                       <synopsis>Determines the format used for sending and receiving
+                                       control mesages on the websocket.
+                                       </synopsis>
+                                       <description>
+                                               <enumlist>
+                                                       <enum name="plain-text">
+                                                       <para>The legacy plain text single-line message format.</para>
+                                                       </enum>
+                                                       <enum name="json">
+                                                       <para>The new JSON format.</para>
+                                                       </enum>
+                                               </enumlist>
+                                       </description>
+                               </configOption>
+                       </configObject>
+               </configFile>
+       </configInfo>
+
+       <info name="Dial_Resource" language="en_US" tech="WebSocket">
+               <para>WebSocket Dial Strings:</para>
+               <para><literal>Dial(WebSocket/connectionid[/websocket_options])</literal></para>
+               <para>WebSocket Parameters:</para>
+               <enumlist>
+                       <enum name="connectionid">
+                               <para>For outgoing WebSockets, this is the ID of the connection
+                               in websocket_client.conf to use for the call.  To accept incoming
+                               WebSocket connections use the literal <literal>INCOMING</literal></para>
+                       </enum>
+                       <enum name="websocket_options">
+                               <para>Options to control how the WebSocket channel behaves.</para>
+                               <enumlist>
+                                       <enum name="c(codec) - Specify the codec to use in the channel">
+                                               <para></para>
+                                               <para> If not specified, the first codec from the caller's channel will be used.
+                                               </para>
+                                       </enum>
+                                       <enum name="n - Don't auto answer">
+                                               <para>Normally, the WebSocket channel will be answered when
+                                               connection is established with the remote app.  If this
+                                               option is specified however, the channel will not be
+                                               answered until the <literal>ANSWER</literal> command is
+                                               received from the remote app or the remote app calls the
+                                               /channels/answer ARI endpoint.
+                                               </para>
+                                       </enum>
+                                       <enum name="f(format) - Control message format for this call">
+                                               <para>
+                                               format:
+                                               </para>
+                                               <xi:include xpointer="xpointer(/docs/configInfo[@name='chan_websocket']/configFile[@name='chan_websocket.conf']/configObject[@name='global']/configOption[@name='control_message_format']/description/enumlist)"/>
+                                       </enum>
+                                       <enum name="p - Passthrough mode">
+                                               <para>In passthrough mode, the channel driver won't attempt
+                                               to re-frame or re-time media coming in over the websocket from
+                                               the remote app.  This can be used for any codec but MUST be used
+                                               for codecs that use packet headers or whose data stream can't be
+                                               broken up on arbitrary byte boundaries. In this case, the remote
+                                               app is fully responsible for correctly framing and timing media
+                                               sent to Asterisk and the MEDIA text commands that could be sent
+                                               over the websocket are disabled.  Currently, passthrough mode is
+                                               automatically set for the opus, speex and g729 codecs.
+                                               </para>
+                                       </enum>
+                                       <enum name="v(uri_parameters) - Add parameters to the outbound URI">
+                                               <para>This option allows you to add additional parameters to the
+                                               outbound URI.  The format is:
+                                               <literal>v(param1=value1,param2=value2...)</literal>
+                                               </para>
+                                               <para>You must ensure that no parameter name or value contains
+                                               characters not valid in a URL.  The easiest way to do this is to
+                                               use the URIENCODE() dialplan function to encode them.  Be aware
+                                               though that each name and value must be encoded separately.  You
+                                               can't simply encode the whole string.</para>
+                                       </enum>
+                               </enumlist>
+                       </enum>
+               </enumlist>
+               <para>Examples:
+               </para>
+               <example title="Make an outbound WebSocket connection using connection 'connection1' and the 'sln16' codec.">
+               same => n,Dial(WebSocket/connection1/c(sln16))
+               </example>
+               <example title="Make an outbound WebSocket connection using connection 'connection1' and the 'opus' codec. Passthrough mode will automatically be set.">
+               same => n,Dial(WebSocket/connection1/c(opus))
+               </example>
+               <example title="Listen for an incoming WebSocket connection and don't auto-answer it.">
+               same => n,Dial(WebSocket/INCOMING/n)
+               </example>
+               <example title="Add URI parameters.">
+               same => n,Dial(WebSocket/connection1/v(${URIENCODE(vari able)}=${URIENCODE(${CHANNEL})},variable2=$(URIENCODE(${EXTEN})}))
+               </example>
+               <example title="Use the JSON control message format for this call.">
+               same => n,Dial(WebSocket/connection1/f(json))
+               </example>
+       </info>
+</docs>
diff --git a/configs/samples/chan_websocket.conf.sample b/configs/samples/chan_websocket.conf.sample
new file mode 100644 (file)
index 0000000..19a6b7a
--- /dev/null
@@ -0,0 +1,10 @@
+; Configuration for chan_websocket
+;
+;[general]
+;control_message_format = plain-text ; The format for the control messages sent
+                                     ; and received on the websocket.
+                                     ; plain-text: The legacy single-line message
+                                     ; format.
+                                     ; json: All messages are properly formatted
+                                     ; JSON.
+                                     ; Default: plain-text
index 8218896f0ec91db77aaf5ac76fb459e9be918100..d989abb9ab1a9c3677af605c01fa79ad0c7acec3 100644 (file)
@@ -2213,9 +2213,10 @@ static int external_media_websocket(struct ast_ari_channels_external_media_args
        struct ast_channel *chan;
        struct varshead *vars;
 
-       if (ast_asprintf(&endpoint, "WebSocket/%s/c(%s)",
+       if (ast_asprintf(&endpoint, "WebSocket/%s%s%s",
                        args->external_host,
-                       args->format) == -1) {
+                       S_COR(args->transport_data, "/", ""),
+                       S_OR(args->transport_data, "")) == -1) {
                return 1;
        }
 
index b79f5f0255bb202e722fd2ba6b2c12a486df5e21..e090dc204e45837047ceab3f73fa3c9ec0e7290d 100644 (file)
@@ -861,6 +861,8 @@ struct ast_ari_channels_external_media_args {
        const char *direction;
        /*! An arbitrary data field */
        const char *data;
+       /*! Transport-specific data. For websocket this is appended to the dialstring. */
+       const char *transport_data;
 };
 /*!
  * \brief Body parsing function for /channels/externalMedia.
index b37e8307539f138111b5b3bcbbe03f34c42346e3..5474b60f4285947970f764bda6534160afa48330 100644 (file)
@@ -2975,6 +2975,10 @@ int ast_ari_channels_external_media_parse_body(
        if (field) {
                args->data = ast_json_string_get(field);
        }
+       field = ast_json_object_get(body, "transport_data");
+       if (field) {
+               args->transport_data = ast_json_string_get(field);
+       }
        return 0;
 }
 
@@ -3027,6 +3031,9 @@ static void ast_ari_channels_external_media_cb(
                if (strcmp(i->name, "data") == 0) {
                        args.data = (i->value);
                } else
+               if (strcmp(i->name, "transport_data") == 0) {
+                       args.transport_data = (i->value);
+               } else
                {}
        }
        args.variables = body;
index a50b8720de56cb84f99c8af17e8ac154a8dc9adb..f7ea634d070709eeacade138d8d586c56e72252d 100644 (file)
                                                        "required": false,
                                                        "allowMultiple": false,
                                                        "dataType": "string"
+                                               },
+                                               {
+                                                       "name": "transport_data",
+                                                       "description": "Transport-specific data. For websocket this is appended to the dialstring.",
+                                                       "paramType": "query",
+                                                       "required": false,
+                                                       "allowMultiple": false,
+                                                       "dataType": "string"
                                                }
                                        ],
                                        "errorResponses": [