From 111b1fa0ad9e5878a880433aec48f17aabdded16 Mon Sep 17 00:00:00 2001 From: deepend-tildeclub Date: Fri, 2 Oct 2026 14:15:22 -0600 Subject: [PATCH] negotiate explicitly registered IRC capabilities (cherry picked from commit c630730a1df33a9a2755a2067e54a985fecb6531) --- docs/plugin-api-additions/caps.md | 35 ++++++++++ plugins/python/_zoitechat_caps.py | 64 ++++++++++++++++++ src/common/inbound.c | 106 ++++++++++++++++++++++++------ src/common/plugin.c | 43 +++++++++++- src/common/plugin.h | 1 + src/common/zoitechat-plugin.h | 5 ++ 6 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 docs/plugin-api-additions/caps.md create mode 100644 plugins/python/_zoitechat_caps.py diff --git a/docs/plugin-api-additions/caps.md b/docs/plugin-api-additions/caps.md new file mode 100644 index 00000000..204c5d25 --- /dev/null +++ b/docs/plugin-api-additions/caps.md @@ -0,0 +1,35 @@ +# Script-supported capabilities + +`register_capability(name, userdata=None, connection_id=None)` returns a normal +hook handle. Register before connecting/reconnecting. Core requests the name only +if advertised in CAP LS or CAP NEW. Existing built-in requests remain enabled; +SASL and STS registration is rejected so scripts cannot change authentication or +transport policy through this API. None/-1 means any connection (zero is a valid +connection ID). Names are lowercase ASCII alphanumerics plus - . / _, 1-200 bytes. +Duplicate registrations do not duplicate requests for an advertised token. + +`unhook(handle)` or script/plugin unload removes future request ownership. It +intentionally does not disable a capability already acknowledged on a live +connection, because another plugin/core may depend on it. Registering after CAP +LS does not retrospectively negotiate an already-advertised capability: reconnect +or wait for CAP NEW. Core retains ownership of CAP END; this API does not extend +SASL waiting or delay registration awaiting plugin-only CAP ACKs. + +`hook_capability(name, callback, userdata=None, priority=PRI_NORM)` independently +observes negotiation through existing raw CAP hooks. Callback receives +`(CapabilityEvent, userdata)` with name, subcommand, value, enabled, context and +connection_id. LS/NEW mean advertised (`enabled=None`), ACK/LIST mean enabled +(unless a negative ACK), NAK means rejected and DEL means removed. Callback +returns are ignored so this observer cannot eat core negotiation. Existing raw +hooks may still eat CAP before this hook according to their normal priorities. + +C: `zoitechat_register_capability(ph, name, flags, connection_id, userdata)`; +flags=0, ID=-1 for any. Check get_info("api_plugin_caps") for "1" before using +the appended Windows slot. Use existing server CAP hooks for notifications. +Requests are split at complete token boundaries within the existing wire limit. + +```python +registration = zoitechat.register_capability('draft/example') +zoitechat.hook_capability('draft/example', lambda event, data: + event.context.prnt('{}: {}'.format(event.subcommand, event.enabled))) +``` diff --git a/plugins/python/_zoitechat_caps.py b/plugins/python/_zoitechat_caps.py new file mode 100644 index 00000000..71fbfbba --- /dev/null +++ b/plugins/python/_zoitechat_caps.py @@ -0,0 +1,64 @@ +"""Script-owned capability requests and structured negotiation observations.""" +from collections import namedtuple +import operator +import _zoitechat as api +from _zoitechat_embedded import ffi, lib + +__all__ = ['CapabilityEvent', 'register_capability', 'hook_capability'] +CapabilityEvent = namedtuple('CapabilityEvent', + 'name subcommand value enabled context connection_id') + + +def register_capability(name, userdata=None, connection_id=None): + """Request an advertised capability on next negotiation; unhook releases ownership.""" + connection_id = -1 if connection_id is None else operator.index(connection_id) + if connection_id < -1 or connection_id > 2147483647: + raise ValueError('connection_id must fit the C API; -1 means any') + plugin = api.__get_current_plugin() + hook = plugin.add_hook(None, userdata) + handle = lib.zoitechat_register_capability(lib.ph, name.encode(), 0, + connection_id, hook.handle) + if handle == ffi.NULL: + hook.is_unload = True + plugin.remove_hook(id(hook)) + raise ValueError('invalid capability name, reserved capability or connection ID') + hook.zoitechat_hook = handle + return id(hook) + + +def hook_capability(name, callback, userdata=None, priority=api.PRI_NORM): + """Observe LS/NEW/ACK/NAK/DEL/LIST; this hook does not itself request the capability.""" + if not callable(callback): + raise TypeError('callback must be callable') + + def dispatch(word, word_eol, data): + if not word_eol: + return api.EAT_NONE + line = word_eol[0] + if line.startswith(':'): + unused, separator, line = line.partition(' ') + parts = line.split(' ', 3) + if len(parts) != 4 or parts[0].upper() != 'CAP': + return api.EAT_NONE + subcommand, tokens = parts[2].upper(), parts[3] + if subcommand not in ('LS', 'NEW', 'ACK', 'NAK', 'DEL', 'LIST'): + return api.EAT_NONE + if tokens.startswith('* '): + tokens = tokens[2:] + tokens = tokens.lstrip(':') + for token in tokens.split(): + negative = token.startswith('-') + cap, equals, value = token.lstrip('-').partition('=') + if cap != name: + continue + enabled = None + if subcommand in ('ACK', 'LIST'): + enabled = not negative + elif subcommand in ('DEL', 'NAK'): + enabled = False + event = CapabilityEvent(cap, subcommand, value if equals else None, + enabled, api.get_context(), api.get_prefs('id')) + callback(event, data) + return api.EAT_NONE # observing CAP must not suppress core negotiation + + return api.hook_server('CAP', dispatch, userdata, priority) diff --git a/src/common/inbound.c b/src/common/inbound.c index 3207d07c..f0504691 100644 --- a/src/common/inbound.c +++ b/src/common/inbound.c @@ -41,6 +41,8 @@ #include "network.h" #include "notify.h" #include "outbound.h" +#include +#include "plugin.h" #include "inbound.h" #include "server.h" #include "servlist.h" @@ -1980,6 +1982,22 @@ inbound_cap_ack (server *serv, char *nick, char *extensions, inbound_toggle_caps (serv, extensions, TRUE); } +/* Keep CAP REQ tokens intact and below the existing 500-byte buffer. */ +static void +inbound_cap_request_append (server *serv, char *buffer, gsize size, + const char *name, time_t timestamp) +{ + if (strlen (buffer) + strlen (name) + 1 >= size) + { + EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session, + buffer + 9, NULL, NULL, NULL, 0, timestamp); + tcp_sendf (serv, "%s\r\n", g_strchomp (buffer)); + g_strlcpy (buffer, "CAP REQ :", size); + } + g_strlcat (buffer, name, size); + g_strlcat (buffer, " ", size); +} + static void inbound_request_new_caps (server *serv, const char *extensions_str, const message_tags_data *tags_data); @@ -2095,6 +2113,32 @@ inbound_request_new_caps (server *serv, const char *extensions_str, } } } + /* Also request plugin-owned capabilities, without repeating core requests. */ + for (i = 0; extensions[i]; i++) + { + gsize previous; + gboolean builtin = FALSE; + for (x = 0; x < G_N_ELEMENTS (supported_caps); x++) + { + if (!strcmp (extensions[i], supported_caps[x])) + { + builtin = TRUE; + break; + } + } + if (builtin || !plugin_requests_capability (serv, extensions[i])) + continue; + for (previous = 0; previous < i; previous++) + { + if (!strcmp (extensions[previous], extensions[i])) + break; + } + if (previous != i) + continue; + EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session, + extensions[i], NULL, NULL, NULL, 0, tags_data->timestamp); + tcp_sendf (serv, "CAP REQ :%s\r\n", extensions[i]); + } g_strfreev (extensions); } @@ -2161,6 +2205,8 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str, gboolean want_cap = FALSE; /* format the CAP REQ string based on previous capabilities being requested or not */ gboolean sts_upgrade_triggered = FALSE; char **extensions; + GString *requests = g_string_new (NULL); + GHashTable *seen = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL); int i; if (g_str_has_prefix (extensions_str, "* ")) @@ -2226,36 +2272,56 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str, } want_cap = TRUE; serv->waiting_on_sasl = TRUE; - g_strlcat (buffer, "sasl ", sizeof(buffer)); + if (g_hash_table_add (seen, g_strdup ("sasl"))) + g_string_append (requests, "sasl "); continue; } - for (x = 0; x < G_N_ELEMENTS(supported_caps); ++x) - { - if (!g_strcmp0 (extension, supported_caps[x])) - { - g_strlcat (buffer, extension, sizeof(buffer)); - g_strlcat (buffer, " ", sizeof(buffer)); - want_cap = TRUE; - } - } + for (x = 0; x < G_N_ELEMENTS (supported_caps); ++x) + { + if (!g_strcmp0 (extension, supported_caps[x])) + break; + } + if (x < G_N_ELEMENTS (supported_caps) || + plugin_requests_capability (serv, extension)) + { + if (g_hash_table_add (seen, g_strdup (extension))) + { + g_string_append (requests, extension); + g_string_append_c (requests, ' '); + want_cap = TRUE; + } + } } g_strfreev (extensions); if (sts_upgrade_triggered) - { - return; - } + { + g_hash_table_destroy (seen); + g_string_free (requests, TRUE); + return; + } if (want_cap) - { - /* buffer + 9 = emit buffer without "CAP REQ :" */ - EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session, - buffer + 9, NULL, NULL, NULL, 0, - tags_data->timestamp); - tcp_sendf (serv, "%s\r\n", g_strchomp (buffer)); - } + { + char **tokens = g_strsplit (requests->str, " ", 0); + for (i = 0; tokens[i]; i++) + { + if (tokens[i][0]) + inbound_cap_request_append (serv, buffer, sizeof (buffer), + tokens[i], tags_data->timestamp); + } + g_strfreev (tokens); + if (buffer[9]) + { + EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session, + buffer + 9, NULL, NULL, NULL, 0, tags_data->timestamp); + tcp_sendf (serv, "%s\r\n", g_strchomp (buffer)); + } + } + g_hash_table_destroy (seen); + g_string_free (requests, TRUE); if (!serv->waiting_on_sasl && !serv->waiting_on_cap) { /* if we use SASL, CAP END is dealt via raw numerics */ diff --git a/src/common/plugin.c b/src/common/plugin.c index 07c08a58..d586e12d 100644 --- a/src/common/plugin.c +++ b/src/common/plugin.c @@ -99,6 +99,7 @@ struct _zoitechat_hook int tag; /* for timers & FDs only */ int type; /* HOOK_* */ int pri; /* fd */ /* priority / fd for HOOK_FD only */ + int cap_connection_id; }; struct _zoitechat_list @@ -143,7 +144,8 @@ enum HOOK_PRINT_ATTRS = 1 << 4, /* same as above, with attributes */ HOOK_TIMER = 1 << 5, /* timeouts */ HOOK_FD = 1 << 6, /* sockets & fds */ - HOOK_DELETED = 1 << 7 /* marked for deletion */ + HOOK_DELETED = 1 << 7, /* marked for deletion */ + HOOK_CAPABILITY = 1 << 9 /* script-owned CAP requests */ }; enum @@ -351,6 +353,7 @@ plugin_add (session *sess, char *filename, void *handle, void *init_func, pl->zoitechat_emit_print_attrs = zoitechat_emit_print_attrs; pl->zoitechat_event_attrs_create = zoitechat_event_attrs_create; pl->zoitechat_event_attrs_free = zoitechat_event_attrs_free; + pl->zoitechat_register_capability = zoitechat_register_capability; /* run zoitechat_plugin_init, if it returns 0, close the plugin */ if (((zoitechat_init_func *)init_func) (pl, &pl->name, &pl->desc, &pl->version, arg) == 0) @@ -1028,6 +1031,41 @@ zoitechat_unhook (zoitechat_plugin *ph, zoitechat_hook *hook) return hook->userdata; } +zoitechat_hook * +zoitechat_register_capability (zoitechat_plugin *ph, const char *name, int flags, + int connection_id, void *userdata) +{ + const unsigned char *p; + zoitechat_hook *hook; + if (flags != 0 || connection_id < -1 || !name || !name[0] || strlen (name) > 200 || + strcmp (name, "sasl") == 0 || strcmp (name, "sts") == 0) + return NULL; + for (p = (const unsigned char *)name; *p; p++) + { + if (!g_ascii_islower (*p) && !g_ascii_isdigit (*p) && + *p != '-' && *p != '.' && *p != '/' && *p != '_') + return NULL; + } + hook = plugin_add_hook (ph, HOOK_CAPABILITY, 0, name, NULL, NULL, 0, userdata); + hook->cap_connection_id = connection_id; + return hook; +} + +gboolean +plugin_requests_capability (server *serv, const char *name) +{ + GSList *entry; + for (entry = hook_list; entry; entry = entry->next) + { + zoitechat_hook *hook = entry->data; + if (hook && hook->type == HOOK_CAPABILITY && + (hook->cap_connection_id == -1 || hook->cap_connection_id == serv->id) && + strcmp (hook->name, name) == 0) + return TRUE; + } + return FALSE; +} + zoitechat_hook * zoitechat_hook_command (zoitechat_plugin *ph, const char *name, int pri, zoitechat_cmd_cb *callb, const char *help_text, void *userdata) @@ -1187,6 +1225,9 @@ zoitechat_get_info (zoitechat_plugin *ph, const char *id) return text_find_format_string (e); } + if (strcmp (id, "api_plugin_caps") == 0) + return "1"; + hash = str_hash (id); /* do the session independant ones first */ switch (hash) diff --git a/src/common/plugin.h b/src/common/plugin.h index d4ef3334..d2fc65c3 100644 --- a/src/common/plugin.h +++ b/src/common/plugin.h @@ -183,6 +183,7 @@ int plugin_emit_command (session *sess, char *name, char *word[], char *word_eol int plugin_emit_server (session *sess, char *name, char *word[], char *word_eol[], time_t server_time); int plugin_emit_print (session *sess, char *word[], time_t server_time); +gboolean plugin_requests_capability (server *serv, const char *name); int plugin_emit_dummy_print (session *sess, char *name); int plugin_emit_keypress (session *sess, unsigned int state, unsigned int keyval, gunichar key); GList* plugin_command_list(GList *tmp_list); diff --git a/src/common/zoitechat-plugin.h b/src/common/zoitechat-plugin.h index e7863238..a9b1eb3e 100644 --- a/src/common/zoitechat-plugin.h +++ b/src/common/zoitechat-plugin.h @@ -206,6 +206,10 @@ struct _zoitechat_plugin #endif +/* api_plugin_caps=1: register before CAP LS; flags=0, connection_id=-1 for any. */ +zoitechat_hook *zoitechat_register_capability (zoitechat_plugin *ph, + const char *name, int flags, int connection_id, void *userdata); + zoitechat_hook * zoitechat_hook_command (zoitechat_plugin *ph, const char *name, @@ -425,6 +429,7 @@ zoitechat_pluginpref_list (zoitechat_plugin *ph, #define zoitechat_hook_server ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_server) #define zoitechat_hook_server_attrs ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_server_attrs) #define zoitechat_hook_print ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print) +#define zoitechat_register_capability ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_register_capability) #define zoitechat_hook_print_attrs ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print_attrs) #define zoitechat_hook_timer ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_timer) #define zoitechat_hook_fd ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_fd)