Skip to content

Instantly share code, notes, and snippets.

@vjt
Last active April 25, 2026 10:20
Show Gist options
  • Select an option

  • Save vjt/a41738b986e433d29e74ca8a9204c91b to your computer and use it in GitHub Desktop.

Select an option

Save vjt/a41738b986e433d29e74ca8a9204c91b to your computer and use it in GitHub Desktop.
irssi qignore -- per-mask + per-bridge-subuser ignore w/ persistence + smart cloak detection (commissioned by nextime, written by vjt-claude)
use strict;
use warnings;
use Irssi;
use vars qw($VERSION %IRSSI);
$VERSION = '0.4';
%IRSSI = (
authors => 'vjt-claude (commissioned by nextime)',
contact => 'nextime on Azzurra',
name => 'qignore',
description => 'Per-mask + per-bridge-subuser ignore with persistence, '
. 'session/forever lists, smart cloak detection, and tab-complete.',
license => 'MIT',
);
# ----------------------------------------------------------------------------
# Storage layout (in-memory):
# %FOREVER : id => entry
# %SESSION : id => entry
# entry kinds:
# { kind => 'mask', mask => '*!*@host', nick => 'foo', added => ts }
# { kind => 'bridge', bridge => 'Trillian', sub => 'mrwlff', added => ts }
#
# Forever -> ~/.irssi/qignore.conf (TSV).
# Session -> in-memory only.
# Bridge entries match by message body prefix `<sub>` (matterbridge/Trillian
# convention) when the IRC source nick equals the bridge nick.
# ----------------------------------------------------------------------------
my %FOREVER;
my %SESSION;
my $NEXT_FOREVER_ID = 1;
my $NEXT_SESSION_ID = 1;
# Pending WHOIS lookups for /qignore add|tmp
my %PENDING;
my $CONF = Irssi::get_irssi_dir() . '/qignore.conf';
# ----------------------------------------------------------------------------
# Persistence: TSV
# MASK<TAB>id<TAB>mask<TAB>nick<TAB>epoch
# BRIDGE<TAB>id<TAB>bridge<TAB>sub<TAB>epoch
# ----------------------------------------------------------------------------
sub load_conf {
return unless -f $CONF;
open(my $fh, '<', $CONF) or do {
qprint("qignore: cannot read $CONF: $!", MSGLEVEL_CLIENTERROR);
return;
};
while (my $line = <$fh>) {
chomp $line;
next unless length $line;
next if $line =~ /^#/;
my @f = split /\t/, $line;
next unless @f >= 4;
my $kind = shift @f;
if ($kind eq 'MASK') {
my ($id, $mask, $nick, $added) = @f;
$FOREVER{$id} = { kind => 'mask', mask => $mask, nick => $nick // '', added => $added // time };
$NEXT_FOREVER_ID = $id + 1 if $id >= $NEXT_FOREVER_ID;
}
elsif ($kind eq 'BRIDGE') {
my ($id, $bridge, $sub, $added) = @f;
$FOREVER{$id} = { kind => 'bridge', bridge => $bridge, sub => $sub, added => $added // time };
$NEXT_FOREVER_ID = $id + 1 if $id >= $NEXT_FOREVER_ID;
}
}
close $fh;
}
sub save_conf {
open(my $fh, '>', "$CONF.tmp") or do {
qprint("qignore: cannot write $CONF.tmp: $!", MSGLEVEL_CLIENTERROR);
return;
};
print $fh "# qignore forever list -- auto-generated, edit with /qignore commands\n";
for my $id (sort { $a <=> $b } keys %FOREVER) {
my $e = $FOREVER{$id};
if ($e->{kind} eq 'mask') {
print $fh join("\t", 'MASK', $id, $e->{mask}, $e->{nick}, $e->{added}), "\n";
} elsif ($e->{kind} eq 'bridge') {
print $fh join("\t", 'BRIDGE', $id, $e->{bridge}, $e->{sub}, $e->{added}), "\n";
}
}
close $fh;
rename "$CONF.tmp", $CONF
or qprint("qignore: rename failed: $!", MSGLEVEL_CLIENTERROR);
}
# ----------------------------------------------------------------------------
# Mask computation. See README/help for rules.
# ----------------------------------------------------------------------------
sub compute_mask {
my ($nick, $user, $host, $full) = @_;
return undef unless defined $host && length $host;
if ($host =~ /(Azzurra[-=][0-9A-Fa-f]+)/) {
return "*!*@*$1*";
}
if ($host =~ /^(\d{1,3}\.){3}\d{1,3}$/) {
return $full ? "*!*@$host" : "*!*@" . _ipv4_glob($host);
}
if ($host =~ /^\d+\..+/) {
if ($full) {
return "*!*@$host";
}
my @parts = split /\./, $host;
shift @parts while @parts && $parts[0] =~ /^\d+$/;
if (@parts >= 2) {
return "*!*@*." . join('.', @parts);
}
return "*!*@$host";
}
return "*!*@$host";
}
sub _ipv4_glob {
my ($ip) = @_;
my @o = split /\./, $ip;
return "$o[0].$o[1].*.*";
}
# ----------------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------------
sub list_get { $_[0] eq 'forever' ? \%FOREVER : \%SESSION; }
# qprint: print to the active window when it's a channel/query, else status.
# Multi-line messages (e.g. /qignore help) are split per-line so each line
# carries its own MSGLEVEL_CLIENTCRAP, which keeps formatting clean.
sub qprint {
my ($msg) = @_;
my $win = Irssi::active_win();
for my $line (split /\n/, $msg) {
if ($win) {
$win->print($line, MSGLEVEL_CLIENTCRAP);
} else {
Irssi::print($line, MSGLEVEL_CLIENTCRAP);
}
}
}
sub next_id {
my ($which) = @_;
return $which eq 'forever' ? $NEXT_FOREVER_ID++ : $NEXT_SESSION_ID++;
}
sub mask_in_use {
my ($mask) = @_;
for my $e (values %FOREVER, values %SESSION) {
next unless $e->{kind} eq 'mask';
return 1 if $e->{mask} eq $mask;
}
return 0;
}
sub bridge_in_use {
my ($bridge, $sub) = @_;
for my $e (values %FOREVER, values %SESSION) {
next unless $e->{kind} eq 'bridge';
return 1 if lc $e->{bridge} eq lc $bridge && lc $e->{sub} eq lc $sub;
}
return 0;
}
sub mask_match {
my ($mask, $nick, $user, $host) = @_;
my $re = quotemeta $mask;
$re =~ s/\\\*/.*/g;
$re =~ s/\\\?/./g;
$re = '^' . $re . '$';
return "$nick!$user\@$host" =~ /$re/i;
}
sub bridge_match {
my ($entry, $src_nick, $msg) = @_;
return 0 unless lc $entry->{bridge} eq lc $src_nick;
# Common bridge prefixes: <sub>, [sub], (sub), all followed by space.
my $sub = quotemeta $entry->{sub};
return $msg =~ /^[<\[(]\s*$sub\s*[>\])]/i;
}
# Citation match: drop a message authored by *anyone* if it cites a muted
# entity. Two forms recognized:
# (1) IRC direct address -- line begins with "<name>:" (e.g. "sarca: ma")
# (2) Matterbridge re-block -- "(re @<bot>: <name> ...)" or any "<name>"
# angle-quote inline anywhere in the body.
# `$name` = entry->{nick} for mask entries, entry->{sub} for bridge entries.
sub citation_match {
my ($entry, $msg) = @_;
my $name;
if ($entry->{kind} eq 'mask') { $name = $entry->{nick}; }
elsif ($entry->{kind} eq 'bridge') { $name = $entry->{sub}; }
return 0 unless defined $name && length $name;
my $q = quotemeta $name;
return 1 if $msg =~ /^\s*$q\s*:/i;
return 1 if $msg =~ /<\s*$q\s*>/i;
return 0;
}
# ----------------------------------------------------------------------------
# /qignore command dispatcher
# ----------------------------------------------------------------------------
sub cmd_qignore {
my ($args, $server, $witem) = @_;
$args =~ s/^\s+//;
my @tok = split /\s+/, $args;
my $sub = lc(shift(@tok) // 'list');
if ($sub eq 'add' || $sub eq 'tmp') {
my $list = $sub eq 'add' ? 'forever' : 'session';
my ($nick, @rest) = @tok;
unless (defined $nick && length $nick) {
qprint("qignore: usage: /qignore $sub <nick> [--full|-f]");
return;
}
my $full = grep { $_ eq '--full' || $_ eq '-f' } @rest;
unless ($server) {
qprint("qignore: no server context, run from a server window");
return;
}
$PENDING{lc $nick} = { list => $list, full => $full, server_tag => $server->{tag} };
$server->redirect_event('whois',
1, $nick, 0, undef,
{
'event 311' => 'redir qignore_whois',
'event 318' => 'redir qignore_whois_end',
'event 401' => 'redir qignore_whois_nosuch',
'' => 'event empty',
});
$server->send_raw("WHOIS $nick");
}
elsif ($sub eq 'bridge' || $sub eq 'tmpbridge') {
my $list = $sub eq 'bridge' ? 'forever' : 'session';
my ($bridge_nick, $sub_user) = @tok;
unless (defined $bridge_nick && defined $sub_user && length $sub_user) {
qprint("qignore: usage: /qignore $sub <bridge-nick> <sub-user>");
qprint(" e.g. /qignore bridge Trillian mrwlff");
return;
}
if (bridge_in_use($bridge_nick, $sub_user)) {
qprint("qignore: bridge entry $bridge_nick/$sub_user already exists");
return;
}
my $id = next_id($list);
my $bucket = list_get($list);
$bucket->{$id} = {
kind => 'bridge', bridge => $bridge_nick, sub => $sub_user, added => time,
};
save_conf() if $list eq 'forever';
qprint("qignore: $list [$id] bridge $bridge_nick / <$sub_user>");
}
elsif ($sub eq 'del' || $sub eq 'rm' || $sub eq 'remove') {
my $target = shift(@tok);
unless (defined $target && length $target) {
qprint("qignore: usage: /qignore del <id|nick|sub>");
return;
}
my $found = 0;
for my $list (\%FOREVER, \%SESSION) {
for my $id (keys %$list) {
my $e = $list->{$id};
my $hit = 0;
if ($id eq $target) { $hit = 1; }
elsif ($e->{kind} eq 'mask' && $e->{nick} && lc $e->{nick} eq lc $target) { $hit = 1; }
elsif ($e->{kind} eq 'bridge' && lc $e->{sub} eq lc $target) { $hit = 1; }
if ($hit) {
my $desc = $e->{kind} eq 'mask'
? "$e->{mask} ($e->{nick})"
: "bridge $e->{bridge}/<$e->{sub}>";
qprint("qignore: removed [$id] $desc");
delete $list->{$id};
$found++;
}
}
}
save_conf() if $found;
qprint("qignore: nothing matched '$target'") unless $found;
}
elsif ($sub eq 'list' || $sub eq 'ls') {
for my $name ('forever', 'session') {
my $bucket = list_get($name);
Irssi::print("qignore -- $name (" . scalar(keys %$bucket) . "):");
for my $id (sort { $a <=> $b } keys %$bucket) {
my $e = $bucket->{$id};
if ($e->{kind} eq 'mask') {
qprint(sprintf(" [%d] %-40s (%s)", $id, $e->{mask}, $e->{nick}));
} else {
qprint(sprintf(" [%d] bridge %s / <%s>", $id, $e->{bridge}, $e->{sub}));
}
}
}
}
elsif ($sub eq 'mask') {
my ($id, $new) = @tok;
unless (defined $id && defined $new && length $new) {
qprint("qignore: usage: /qignore mask <id> <new-mask>");
return;
}
for my $list (\%FOREVER, \%SESSION) {
if (exists $list->{$id} && $list->{$id}{kind} eq 'mask') {
my $old = $list->{$id}{mask};
$list->{$id}{mask} = $new;
qprint("qignore: [$id] $old -> $new");
save_conf() if $list == \%FOREVER;
return;
}
}
qprint("qignore: id '$id' not found or not a mask entry");
}
elsif ($sub eq 'help' || $sub eq '?') {
qprint(<<'HELP');
qignore commands:
/qignore add <nick> [--full|-f] mask, forever (saved to disk)
/qignore tmp <nick> [--full|-f] mask, session-only
/qignore bridge <bridge-nick> <sub-user> sub-user via bridge, forever
/qignore tmpbridge <bridge> <sub> sub-user via bridge, session
/qignore del <id|nick|sub> remove
/qignore list show both lists
/qignore mask <id> <new-mask> edit mask of entry
/qignore help this text
flags:
--full / -f use exact host instead of smart-masked one (mask entries)
bridge entries match when the source nick equals <bridge-nick> AND the
message body begins with <sub-user>, [sub-user] or (sub-user). Standard
matterbridge/Trillian convention.
citations: any message authored by anyone is also dropped if it cites a
muted entity. Matches "name:" line-prefix and angle-quote "<name>"
inline (catches matterbridge "(re @bot: <name> ...)" reply blocks).
HELP
}
else {
qprint("qignore: unknown subcommand '$sub' -- try /qignore help");
}
}
# ----------------------------------------------------------------------------
# WHOIS redirect handlers
# ----------------------------------------------------------------------------
sub sig_whois {
my ($server, $data) = @_;
my (undef, $nick, $user, $host) = split / /, $data;
return unless defined $nick;
my $key = lc $nick;
my $p = delete $PENDING{$key};
return unless $p;
my $mask = compute_mask($nick, $user, $host, $p->{full});
unless (defined $mask) {
qprint("qignore: could not compute mask for $nick ($user\@$host)");
return;
}
if (mask_in_use($mask)) {
qprint("qignore: mask $mask already exists, skipping");
return;
}
my $id = next_id($p->{list});
my $bucket = list_get($p->{list});
$bucket->{$id} = { kind => 'mask', mask => $mask, nick => $nick, added => time };
save_conf() if $p->{list} eq 'forever';
qprint("qignore: $p->{list} [$id] $mask ($nick)");
}
sub sig_whois_end { }
sub sig_whois_nosuch {
my ($server, $data) = @_;
my (undef, $nick) = split / /, $data;
return unless defined $nick;
delete $PENDING{lc $nick};
qprint("qignore: WHOIS $nick -> no such nick");
}
# ----------------------------------------------------------------------------
# Drop incoming messages that match
# ----------------------------------------------------------------------------
sub _drop_if_match {
my ($server, $msg, $nick, $address, $is_public) = @_;
return 0 unless defined $address && $address =~ /^([^@]+)\@(.+)$/;
my ($user, $host) = ($1, $2);
for my $e (values %FOREVER, values %SESSION) {
if ($e->{kind} eq 'mask' && mask_match($e->{mask}, $nick, $user, $host)) {
Irssi::signal_stop();
return 1;
}
if ($e->{kind} eq 'bridge' && bridge_match($e, $nick, $msg)) {
Irssi::signal_stop();
return 1;
}
if (citation_match($e, $msg)) {
Irssi::signal_stop();
return 1;
}
}
return 0;
}
sub sig_message_public {
my ($server, $msg, $nick, $address, $target) = @_;
_drop_if_match($server, $msg, $nick, $address, 1);
}
sub sig_message_private {
my ($server, $msg, $nick, $address) = @_;
_drop_if_match($server, $msg, $nick, $address, 0);
}
# ----------------------------------------------------------------------------
# Tab-completion
# ----------------------------------------------------------------------------
sub sig_complete {
my ($complist, $window, $word, $linestart, $want_space) = @_;
return unless $linestart =~ /^\/qignore\s*(.*)$/i;
my $rest = $1;
if ($rest !~ /\s/) {
my @subs = qw(add tmp bridge tmpbridge del list mask help);
push @$complist, grep { /^\Q$word\E/i } @subs;
$$want_space = 1;
Irssi::signal_stop();
return;
}
if ($rest =~ /^(add|tmp|del|rm|remove|bridge|tmpbridge)\s/i) {
my $witem = Irssi::active_win()->{active};
if (ref $witem && $witem->{type} eq 'CHANNEL') {
my @nicks = map { $_->{nick} } $witem->nicks();
push @$complist, grep { /^\Q$word\E/i } @nicks;
$$want_space = 1;
Irssi::signal_stop();
return;
}
}
}
# ----------------------------------------------------------------------------
# Wire up
# ----------------------------------------------------------------------------
Irssi::command_bind('qignore', \&cmd_qignore);
Irssi::signal_add_first('message public', \&sig_message_public);
Irssi::signal_add_first('message private', \&sig_message_private);
Irssi::signal_add('redir qignore_whois', \&sig_whois);
Irssi::signal_add('redir qignore_whois_end', \&sig_whois_end);
Irssi::signal_add('redir qignore_whois_nosuch', \&sig_whois_nosuch);
Irssi::signal_add('complete word', \&sig_complete);
Irssi::signal_register({ 'redir qignore_whois' => [qw(iobject string)] });
Irssi::signal_register({ 'redir qignore_whois_end' => [qw(iobject string)] });
Irssi::signal_register({ 'redir qignore_whois_nosuch' => [qw(iobject string)] });
load_conf();
Irssi::print("qignore $VERSION loaded -- /qignore help", MSGLEVEL_CLIENTCRAP);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment