Compare commits

...
Author SHA1 Message Date
deepend 37c93bb0f0 add structured IRC event hooks
(cherry picked from commit 44e677a973)
2026-11-08 06:05:59 -06:00
deepend 2355ffc5d0 reserve additive API slots and load optional Python modules
(cherry picked from commit 1cdee8ea6b)
2026-11-08 06:00:01 -06:00
12 changed files with 189 additions and 4 deletions

No files matched your search

@@ -0,0 +1,24 @@
# Optional plugin API additions (discussion #144)
Existing hooks, callbacks, return values, context lookup, strings and menu behaviour
remain unchanged. The C plugin function table gains four fixed append-only slots;
unused slots are NULL. Older binaries use the unchanged prefix. A plugin must check
the documented get_info() feature key before accessing a new slot on an older host.
New Python modules are installed by Meson and the existing Windows copy/installer
pipeline. Only modules present in the installation are exported; imports of
zoitechat, hexchat and xchat retain their compatibility aliases.
All APIs run on the client main thread unless explicitly documented otherwise.
New reserved flags must be zero. These changes do not make old API calls thread-safe.
Nested hook dispatch defers deleted-hook reclamation until its outermost return,
so new observers can safely emit events and unhook themselves or other hooks.
The optional asyncio module requires Python 3.7 or newer; older supported
interpreters skip that module and retain the existing scripting API.
Python reload drops the script/API modules while retaining the embedding module
so callback and lib.ph pointer types keep the same CFFI identity.
Server-hook insertion matches both raw and attrs hooks so documented priorities
are honoured for mixed registrations, including new filtered attrs callbacks.
+26
View File
@@ -0,0 +1,26 @@
# Semantic IRC hooks
`on_join`, `on_part`, `on_quit`, `on_nick`, `on_kick`, `on_message`, and
`on_notice` accept `(callback, userdata=None, priority=PRI_NORM)`. The general
form is `hook_event(command, callback, userdata=None, priority=PRI_NORM)`.
Callbacks receive `(event, userdata)`, where the immutable IRCEvent has
command, prefix, nick, user, host, params, context, connection_id and time.
`params` preserves the trailing parameter, including spaces; JOIN parameters
also retain extended-join account/realname data when supplied by the server.
These wrappers use existing server hooks, before core command processing;
return the ordinary EAT_* constants. EAT_ZOITECHAT suppresses the entire IRC
command (including client state updates), not just its text. Use EAT_NONE for
observation. Other plugins' priority and eating rules remain unchanged.
The context is the core-selected context, which can be the server tab when a
query/channel does not yet exist. QUIT is one event per incoming command,
not one callback per affected channel. `event.time` is the existing server-time
attribute (zero when unavailable); other message tags are not exposed here.
```python
def joined(event, userdata):
event.context.prnt('{} joined {}'.format(event.nick, event.params[0]))
return zoitechat.EAT_NONE
zoitechat.on_join(joined)
```
+21
View File
@@ -383,3 +383,24 @@ def find_context(server=None, channel=None):
return None return None
return Context(ctx) return Context(ctx)
# Optional additive APIs live in separate modules so builds can select features.
def __load_additive_features():
import importlib
import os
for suffix in ('fd', 'events', 'async', 'names', 'contexts',
'post', 'filters', 'caps'):
if suffix == 'async' and sys.version_info < (3, 7):
continue
name = '_zoitechat_' + suffix
if not os.path.isfile(os.path.join(os.path.dirname(__file__), name + '.py')):
continue
module = importlib.import_module(name)
for symbol in module.__all__:
globals()[symbol] = getattr(module, symbol)
__all__.append(symbol)
__load_additive_features()
+77
View File
@@ -0,0 +1,77 @@
"""Structured, opt-in hooks before the IRC command's core processing."""
from collections import namedtuple
import _zoitechat as api
__all__ = ['IRCEvent', 'hook_event', 'on_join', 'on_part', 'on_quit',
'on_nick', 'on_kick', 'on_message', 'on_notice']
IRCEvent = namedtuple('IRCEvent', 'command prefix nick user host params context connection_id time')
_SUPPORTED = frozenset(('JOIN', 'PART', 'QUIT', 'NICK', 'KICK', 'PRIVMSG', 'NOTICE'))
def _parse(line):
prefix = ''
if line.startswith(':'):
prefix, separator, line = line[1:].partition(' ')
if not separator:
return None
command, separator, rest = line.partition(' ')
params = []
while rest:
rest = rest.lstrip(' ')
if rest.startswith(':'):
params.append(rest[1:])
break
param, separator, rest = rest.partition(' ')
if param:
params.append(param)
nick, marker, userhost = prefix.partition('!')
user, marker, host = userhost.partition('@')
return command.upper(), prefix, nick, user, host, tuple(params)
def hook_event(command, callback, userdata=None, priority=api.PRI_NORM):
"""callback(event, userdata) returns existing EAT_* constants."""
command = command.upper()
if command not in _SUPPORTED:
raise ValueError('unsupported semantic event: ' + command)
if not callable(callback):
raise TypeError('callback must be callable')
def dispatch(word, word_eol, data, attrs):
if not word_eol:
return api.EAT_NONE
parsed = _parse(word_eol[0])
if parsed is None or parsed[0] != command:
return api.EAT_NONE
event = IRCEvent(*(parsed + (api.get_context(), api.get_prefs('id'), attrs.time)))
return callback(event, data)
return api.hook_server_attrs(command, dispatch, userdata, priority)
def on_join(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('JOIN', callback, userdata, priority)
def on_part(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('PART', callback, userdata, priority)
def on_quit(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('QUIT', callback, userdata, priority)
def on_nick(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('NICK', callback, userdata, priority)
def on_kick(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('KICK', callback, userdata, priority)
def on_message(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('PRIVMSG', callback, userdata, priority)
def on_notice(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('NOTICE', callback, userdata, priority)
+5 -1
View File
@@ -27,7 +27,11 @@ python3_source = custom_target('python-bindings',
command: [find_program('generate_plugin.py'), '@INPUT@', '@OUTPUT@'] command: [find_program('generate_plugin.py'), '@INPUT@', '@OUTPUT@']
) )
install_data(['_zoitechat.py', 'zoitechat.py', 'xchat.py', 'hexchat.py'], # Include any selected additive API modules alongside the compatibility modules.
python_api_files = run_command(python3, '-c',
'import glob,sys; print("\\n".join(sorted(glob.glob(sys.argv[1] + "/_zoitechat*.py"))))',
meson.current_source_dir(), check: true).stdout().strip().split('\n')
install_data(python_api_files + ['zoitechat.py', 'xchat.py', 'hexchat.py'],
install_dir: join_paths(get_option('libdir'), 'zoitechat/python') install_dir: join_paths(get_option('libdir'), 'zoitechat/python')
) )
+6 -1
View File
@@ -590,7 +590,12 @@ def _on_plugin_deinit():
sys.stderr = sys.__stderr__ sys.stderr = sys.__stderr__
pydoc.help = pydoc.Helper() pydoc.help = pydoc.Helper()
for mod in ('_zoitechat', 'zoitechat', 'xchat', '_zoitechat_embedded'): # CFFI callbacks keep the embedding module's ffi/lib identity for the
# process lifetime; deleting it creates incompatible types on reload.
for mod in ('_zoitechat', 'zoitechat', 'hexchat', 'xchat',
'_zoitechat_fd', '_zoitechat_events', '_zoitechat_async',
'_zoitechat_names', '_zoitechat_contexts', '_zoitechat_post',
'_zoitechat_filters', '_zoitechat_caps'):
try: try:
del sys.modules[mod] del sys.modules[mod]
+1
View File
@@ -52,6 +52,7 @@
<None Include="xchat.py" /> <None Include="xchat.py" />
<None Include="hexchat.py" /> <None Include="hexchat.py" />
<None Include="_zoitechat.py" /> <None Include="_zoitechat.py" />
<None Include="_zoitechat_*.py" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ClCompile Include="$(IntDir)python.c" /> <ClCompile Include="$(IntDir)python.c" />
+1
View File
@@ -52,6 +52,7 @@
<None Include="xchat.py" /> <None Include="xchat.py" />
<None Include="hexchat.py" /> <None Include="hexchat.py" />
<None Include="_zoitechat.py" /> <None Include="_zoitechat.py" />
<None Include="_zoitechat_*.py" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ClCompile Include="$(IntDir)python.c" /> <ClCompile Include="$(IntDir)python.c" />
+8 -2
View File
@@ -176,6 +176,7 @@ enum
GSList *plugin_list = NULL; /* export for plugingui.c */ GSList *plugin_list = NULL; /* export for plugingui.c */
static GSList *hook_list = NULL; static GSList *hook_list = NULL;
static unsigned int plugin_dispatch_depth;
extern const struct prefs vars[]; /* cfgfiles.c */ extern const struct prefs vars[]; /* cfgfiles.c */
@@ -243,7 +244,7 @@ plugin_list_add (zoitechat_context *ctx, char *filename, const char *name,
{ {
zoitechat_plugin *pl; zoitechat_plugin *pl;
pl = g_new (zoitechat_plugin, 1); pl = g_new0 (zoitechat_plugin, 1);
pl->handle = handle; pl->handle = handle;
pl->filename = filename; pl->filename = filename;
pl->context = ctx; pl->context = ctx;
@@ -610,6 +611,7 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
zoitechat_hook *hook; zoitechat_hook *hook;
int ret, eat = 0; int ret, eat = 0;
plugin_dispatch_depth++;
list = hook_list; list = hook_list;
while (1) while (1)
{ {
@@ -655,6 +657,10 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
} }
xit: xit:
/* Nested callbacks can unhook entries still used by an outer dispatch.
* Keep both hook objects and list links alive until the outermost return. */
if (--plugin_dispatch_depth != 0)
return eat;
/* really remove deleted hooks now */ /* really remove deleted hooks now */
list = hook_list; list = hook_list;
while (list) while (list)
@@ -806,7 +812,7 @@ plugin_insert_hook (zoitechat_hook *new_hook)
break; break;
case HOOK_SERVER: case HOOK_SERVER:
case HOOK_SERVER_ATTRS: case HOOK_SERVER_ATTRS:
new_hook_type = HOOK_SERVER | HOOK_PRINT_ATTRS; new_hook_type = HOOK_SERVER | HOOK_SERVER_ATTRS;
break; break;
default: default:
new_hook_type = new_hook->type; new_hook_type = new_hook->type;
+9
View File
@@ -150,6 +150,15 @@ struct _zoitechat_plugin
void (*zoitechat_event_attrs_free) (zoitechat_plugin *ph, void (*zoitechat_event_attrs_free) (zoitechat_plugin *ph,
zoitechat_event_attrs *attrs); zoitechat_event_attrs *attrs);
/* Additive API slots: append only; old binary offsets are unchanged. */
zoitechat_hook *(*zoitechat_hook_print_after) (zoitechat_plugin *, const char *, int,
int, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_server_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_print_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_register_capability) (zoitechat_plugin *, const char *, int, int, void *);
/* PRIVATE FIELDS! */ /* PRIVATE FIELDS! */
void *handle; /* from dlopen */ void *handle; /* from dlopen */
char *filename; /* loaded from */ char *filename; /* loaded from */
+9
View File
@@ -193,6 +193,15 @@ struct _zoitechat_plugin
zoitechat_event_attrs *(*zoitechat_event_attrs_create) (zoitechat_plugin *ph); zoitechat_event_attrs *(*zoitechat_event_attrs_create) (zoitechat_plugin *ph);
void (*zoitechat_event_attrs_free) (zoitechat_plugin *ph, void (*zoitechat_event_attrs_free) (zoitechat_plugin *ph,
zoitechat_event_attrs *attrs); zoitechat_event_attrs *attrs);
/* Additive API slots: append only; old binary offsets are unchanged. */
zoitechat_hook *(*zoitechat_hook_print_after) (zoitechat_plugin *, const char *, int,
int, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_server_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_print_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_register_capability) (zoitechat_plugin *, const char *, int, int, void *);
}; };
#endif #endif
+2
View File
@@ -90,6 +90,7 @@
<MSWindowsTheme Include="$(DepsRoot)\share\themes\MS-Windows\**\*" /> <MSWindowsTheme Include="$(DepsRoot)\share\themes\MS-Windows\**\*" />
<HicolorIcons Include="$(DepsRoot)\share\icons\hicolor\index.theme" /> <HicolorIcons Include="$(DepsRoot)\share\icons\hicolor\index.theme" />
<HicolorIcons Include="$(DepsRoot)\share\icons\hicolor\**\*.*" /> <HicolorIcons Include="$(DepsRoot)\share\icons\hicolor\**\*.*" />
<PythonAdditiveApis Include="..\..\plugins\python\_zoitechat_*.py" />
</ItemGroup> </ItemGroup>
<Copy SourceFiles="@(None)" DestinationFolder="$(ZoiteChatRel)" /> <Copy SourceFiles="@(None)" DestinationFolder="$(ZoiteChatRel)" />
@@ -115,6 +116,7 @@
<Copy SourceFiles="..\..\plugins\python\hexchat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" /> <Copy SourceFiles="..\..\plugins\python\hexchat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" />
<Copy SourceFiles="..\..\plugins\python\zoitechat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" /> <Copy SourceFiles="..\..\plugins\python\zoitechat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" />
<Copy SourceFiles="..\..\plugins\python\_zoitechat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" /> <Copy SourceFiles="..\..\plugins\python\_zoitechat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" />
<Copy SourceFiles="@(PythonAdditiveApis)" DestinationFolder="$(ZoiteChatRel)\python" Condition="('$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true') and '@(PythonAdditiveApis)' != ''" />
<WriteLinesToFile File="$(ZoiteChatRel)portable-mode" Lines="2" Overwrite="true" /> <WriteLinesToFile File="$(ZoiteChatRel)portable-mode" Lines="2" Overwrite="true" />