From c630730a1df33a9a2755a2067e54a985fecb6531 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 --- docs/plugin-api-additions/caps.md | 35 ++++++++++++++ plugins/python/_zoitechat_caps.py | 64 +++++++++++++++++++++++++ src/common/inbound.c | 78 +++++++++++++++++++++++++++++-- src/common/plugin.c | 39 ++++++++++++++++ src/common/plugin.h | 1 + src/common/zoitechat-plugin.h | 5 ++ 6 files changed, 218 insertions(+), 4 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 bb2a74e8..a27fd5c9 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" @@ -1962,10 +1964,58 @@ inbound_cap_ack (server *serv, char *nick, char *extensions, inbound_toggle_caps (serv, extensions, TRUE); } +/* Split requests only at token boundaries, including plugin-added names. */ +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_plugin_cap_new (server *serv, const char *extensions, time_t timestamp) +{ + char buffer[500] = "CAP REQ :"; + char **tokens = g_strsplit (extensions, " ", 0); + GHashTable *seen = g_hash_table_new (g_str_hash, g_str_equal); + int i; + gboolean requested = FALSE; + for (i = 0; tokens[i]; i++) + { + char *value = strchr (tokens[i], '='); + if (value) + *value = '\0'; + if (plugin_requests_capability (serv, tokens[i]) && + !g_hash_table_contains (seen, tokens[i])) + { + g_hash_table_add (seen, tokens[i]); + inbound_cap_request_append (serv, buffer, sizeof (buffer), tokens[i], timestamp); + requested = TRUE; + } + } + if (requested) + { + 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_hash_table_destroy (seen); + g_strfreev (tokens); +} + void inbound_cap_new (server *serv, char *nick, char *extensions, const message_tags_data *tags_data) { + gboolean sts_upgrade_triggered = FALSE; if (extensions) { char **tokens = g_strsplit (extensions, " ", 0); @@ -1977,7 +2027,7 @@ inbound_cap_new (server *serv, char *nick, char *extensions, if (!g_strcmp0 (parts[0], "sts") && parts[1] && parts[1][0]) { - sts_handle_capability (serv, parts[1]); + sts_upgrade_triggered |= sts_handle_capability (serv, parts[1]); } g_strfreev (parts); @@ -1986,6 +2036,9 @@ inbound_cap_new (server *serv, char *nick, char *extensions, g_strfreev (tokens); } + if (extensions && !sts_upgrade_triggered) + inbound_plugin_cap_new (serv, extensions, tags_data->timestamp); + EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPACK, serv->server_session, nick, extensions, NULL, NULL, 0, tags_data->timestamp); @@ -2116,6 +2169,7 @@ 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 (""); int i; if (g_str_has_prefix (extensions_str, "* ")) @@ -2181,7 +2235,7 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str, } want_cap = TRUE; serv->waiting_on_sasl = TRUE; - g_strlcat (buffer, "sasl ", sizeof(buffer)); + g_string_append (requests, "sasl "); continue; } @@ -2189,28 +2243,44 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str, { if (!g_strcmp0 (extension, supported_caps[x])) { - g_strlcat (buffer, extension, sizeof(buffer)); - g_strlcat (buffer, " ", sizeof(buffer)); + g_string_append (requests, extension); + g_string_append_c (requests, ' '); want_cap = TRUE; + break; } } + if (x == G_N_ELEMENTS (supported_caps) && plugin_requests_capability (serv, extension)) + { + g_string_append (requests, extension); + g_string_append_c (requests, ' '); + want_cap = TRUE; + } + } g_strfreev (extensions); if (sts_upgrade_triggered) { + g_string_free (requests, TRUE); return; } if (want_cap) { + char **tokens = g_strsplit (requests->str, " ", 0); + /* Defer every batch until STS has decided whether to reconnect. */ + 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); /* 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)); } + 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 ef87e361..efe28a07 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; int connection_filter; char *channel_filter; gboolean context_filtered; @@ -147,6 +148,7 @@ enum HOOK_TIMER = 1 << 5, /* timeouts */ HOOK_FD = 1 << 6, /* sockets & fds */ HOOK_PRINT_AFTER = 1 << 8, /* opt-in post-display observers */ + HOOK_CAPABILITY = 1 << 9, /* script-owned CAP requests */ HOOK_DELETED = 1 << 7 /* marked for deletion */ }; @@ -355,6 +357,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; pl->zoitechat_hook_server_filtered = zoitechat_hook_server_filtered; pl->zoitechat_hook_print_filtered = zoitechat_hook_print_filtered; pl->zoitechat_hook_print_after = zoitechat_hook_print_after; @@ -1114,6 +1117,39 @@ zoitechat_hook_print_filtered (zoitechat_plugin *ph, const char *name, int pri, HOOK_PRINT_ATTRS, callback, 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; /* authentication and transport policy belong to core */ + 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) @@ -1289,6 +1325,9 @@ zoitechat_get_info (zoitechat_plugin *ph, const char *id) if (strcmp (id, "api_context_filters") == 0) return "1"; + 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 ae071349..d3836b4b 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); void plugin_emit_print_after (session *sess, char *word[], time_t server_time); int plugin_emit_dummy_print (session *sess, char *name); int plugin_emit_keypress (session *sess, unsigned int state, unsigned int keyval, gunichar key); diff --git a/src/common/zoitechat-plugin.h b/src/common/zoitechat-plugin.h index 92a64078..f3e96fe9 100644 --- a/src/common/zoitechat-plugin.h +++ b/src/common/zoitechat-plugin.h @@ -222,6 +222,10 @@ zoitechat_hook *zoitechat_hook_print_filtered (zoitechat_plugin *ph, int (*callback) (char *word[], zoitechat_event_attrs *attrs, void *userdata), void *userdata); +/* 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, @@ -441,6 +445,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_server_filtered ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_server_filtered) #define zoitechat_hook_print_filtered ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print_filtered) #define zoitechat_hook_print_after ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print_after)