1
0
mirror of https://github.com/unrealircd/unrealircd.git synced 2026-06-28 00:46:37 +02:00
Files
unrealircd/src/modules/multiline.c
T
Bram Matthys b0dba4bede Add draft/multiline support with a default max-lines of 15 for known-users
and 7 for unknown-users (with max-bytes 5250 and 1500 respectively). This
allows pasting a short snippet of code, config file, text from a site, etc.

With multiline you have the guarantee that:
1) You will see the entire text with no delay between lines
2) You won't see another persons chat half-way through such a paste
3) For multiline supporting clients it is now clear that all the text
   belongs to each other, which can make selecting/copying it easier.
This basically means short snippets/pastes like that can be completely on
IRC again. No need for a pastebin for it. Though, you may still need such
a service if you are pasting more lines.

Regarding the implementation in UnrealIRCd:
* Clients without multiline get individual fallback lines (concat lines
  merged, blank lines skipped, as per spec). And we know that clients like
  weechat - which does support multiline - also shows all lines and not
  only a few plus snippet style "[.."]. That is another reason for only
  allowing 15 lines by default and not something much more. Otherwise all
  those clients would get a big wall of text, which just sucks.
* Spamfilter (also) runs on the full text of all lines together, so
  splitting a phrase across lines does not evade spamfilter.
* Fakelag: a client can send the BATCH start+PRIVMSG (or NOTICE)+BATCH end
  at full speed. We impose no fake lag there. Also, the multiline default
  max-lines and max-bytes are lower than the example class::recvq of 8000,
  so should be perfectly safe. If the entire BATCH is accepted then we
  will impose fake-lag afterwards, with a cap of 15 seconds maximum.
  If the BATCH is rejected, we impose half the fakelag plus 2sec.
* If the time between BATCH start and BATCH end is more than 15 seconds
  then the BATCH is rejected (set::multiline::batch-timeout).
* The BATCH is atomic (either you see it all, or you see none of it):
  * When the client sends it to server, it is buffered first.
  * Only after the batch close the server indicates if it is accepted
    or rejected. This has various reasons, two of them are: 1) The client
    is going to send everything in one go anyway and not wait for a
    response between each PRIVMSG, and 2) we can't do many checks in the
    buffering stage and skip those after, that would cause a TOCTOU
    problem (eg. a banned user still being able to speak).
  * If any line gets rejected due to spamfilter or other case
    (eg +c, +b ~text with block, etc etc), the entire batch is rejected
  * Locally we deliver all or nothing (as said)
  * S2S we buffer the batch as well, so if a server splits after having
    received 10 lines out of 15, then clients will not see anything.
* We send max-lines and max-bytes, this is the hard upper limit.
* A multiline can still be limited more tight if:
  * +f with 't' or 'm' restricts to fewer lines,
    eg +f [5t]:15, which means max 5 lines per 15 seconds,
    means the max accepted multiline is 5 for that channel.
  * +F works the same, except that default +F normal does not
    have a 't' at the moment and 'm' is very high (50) so
    practically not limited by default.
  * There will be a future +f flood subtype for some more control

TODO: we will send CAP NEW on unknown-users <-> known-users to
      indicate the new max-lines value if you transition security groups

TODO: chat history does not yet include multiline batches.
2026-03-30 13:16:48 +02:00

1954 lines
58 KiB
C

/*
* IRC - Internet Relay Chat, src/modules/multiline.c
* (C) 2026 Syzop & The UnrealIRCd Team
*
* See file AUTHORS in IRC package for additional names of
* the programmers.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 1, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
*/
#include "unrealircd.h"
ModuleHeader MOD_HEADER
= {
"multiline",
"1.0",
"IRCv3 draft/multiline",
"UnrealIRCd Team",
"unrealircd-6",
};
// TODO: history does not work currently with multiline, we should communicate
// the batch stuff via a different hook, and be atomic to include it in history
// This will be done later...
/* ===================== CONFIGURATION ===================== */
struct {
int batch_timeout;
} cfg;
/* ===================== DATA STRUCTURES ===================== */
/** A single line within a multiline batch */
typedef struct MultilineLine MultilineLine;
struct MultilineLine {
MultilineLine *next;
char *text; /**< Message text for this line (may be empty string for blank lines) */
int concat; /**< 1 if draft/multiline-concat tag was present */
};
/** State for a locally-initiated multiline batch (one per local client) */
typedef struct MultilineBatch MultilineBatch;
struct MultilineBatch {
char batch_id[BATCHLEN+1]; /**< Client-chosen batch reference tag */
char *target; /**< Target channel or nick */
SendType sendtype; /**< SEND_TYPE_PRIVMSG or SEND_TYPE_NOTICE */
int sendtype_set; /**< Has sendtype been determined (from first line)? */
char member_modes[2]; /**< Member mode filter from STATUSMSG prefix (e.g. "o"), or empty string */
MessageTag *client_mtags; /**< Tags from the opening BATCH command */
int line_count;
int received_bytes; /**< Total user-sent content bytes (for max-bytes policy) */
time_t start_time;
int failed; /**< Batch marked as failed — consume remaining lines, send error at BATCH close */
char *fail_message; /**< FAIL response to send at BATCH close (if failed) */
char label[256]; /**< Saved label for echo-message + labeled-response interaction */
/* Buffered lines */
MultilineLine *lines;
MultilineLine *lines_tail;
MultilineLine *fallback_lines; /**< Cached fallback lines (built once, reused for all non-multiline clients) */
};
/** State for an S2S multiline batch being received from a remote server */
typedef struct S2SMultilineBatch S2SMultilineBatch;
struct S2SMultilineBatch {
S2SMultilineBatch *prev, *next;
Client *sender; /**< Remote user who initiated */
Client *direction; /**< Server link it came from */
char batch_id[BATCHLEN+1];
char *target;
int is_channel; /**< 1 if target is a channel, 0 if user */
SendType sendtype;
int sendtype_set;
char member_modes[2]; /**< Member mode filter from STATUSMSG prefix */
MessageTag *first_mtags; /**< Message tags from the opening BATCH (for relay) */
int line_count;
int received_bytes;
time_t start_time;
MultilineLine *lines;
MultilineLine *lines_tail;
};
/* ===================== FORWARD DECLARATIONS ===================== */
/* Module callbacks */
int multiline_config_test(ConfigFile *cf, ConfigEntry *ce, int type, int *errs);
int multiline_config_run(ConfigFile *cf, ConfigEntry *ce, int type);
/* Command overrides */
CMD_OVERRIDE_FUNC(multiline_override_batch);
CMD_OVERRIDE_FUNC(multiline_override_msg);
/* Message tag handler */
int multiline_concat_mtag_is_ok(Client *client, const char *name, const char *value);
/* Capability parameter callback */
const char *multiline_capability_parameter(Client *client);
/* ModData free callback */
void multiline_mdata_free(ModData *m);
/* Hooks */
int multiline_close_connection(Client *client);
int multiline_remote_quit(Client *client, MessageTag *mtags, const char *comment);
int multiline_server_quit(Client *client, MessageTag *mtags);
/* Timer */
EVENT(multiline_timeout_check);
/* Core functions */
static void multiline_deliver(Client *client, MultilineBatch *batch);
static void multiline_deliver_channel(Client *client, MultilineBatch *batch, Channel *channel);
static void multiline_deliver_user(Client *client, MultilineBatch *batch, Client *target);
static void multiline_send_batch_to_client(Client *to, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *targetstr, const char *cmd);
static void multiline_send_fallback_to_client(Client *to, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *targetstr, const char *cmd);
static void multiline_echo_to_sender(Client *client, MultilineBatch *batch, MessageTag *mtags, const char *targetstr, const char *cmd);
static void multiline_send_s2s_to_direction(Client *direction, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *cmd, const char *targetstr, const char *ref);
static void multiline_send_s2s_channel(Client *from, MultilineBatch *batch, Channel *channel, MessageTag *base_mtags, const char *cmd, Client *skip_direction, const char *targetstr);
static void multiline_send_s2s_user(Client *from, MultilineBatch *batch, Client *target, MessageTag *base_mtags, const char *cmd);
static void multiline_deliver_to_local_members(Channel *channel, Client *from, MultilineBatch *batch, MessageTag *mtags, const char *targetstr, const char *cmd, const char *filter_modes, Client *skip, int sendflags);
static void multiline_run_chanmsg_hooks(Client *sender, Channel *channel, int sendflags, const char *member_modes, const char *targetstr, MessageTag *mtags, MultilineLine *lines, SendType sendtype);
static void multiline_run_usermsg_hooks(Client *sender, Client *target, MessageTag *mtags, MultilineLine *lines, SendType sendtype);
static void multiline_fail_batch(Client *client, MultilineBatch *batch, const char *fail_msg);
static void multiline_abort_batch(Client *client, MultilineBatch *batch);
static void multiline_append_line(MultilineLine **lines, MultilineLine **lines_tail, int *line_count, const char *text, int is_concat);
static void multiline_free_lines(MultilineLine *lines);
static void multiline_free_batch(MultilineBatch *batch);
static char *multiline_concat_text(MultilineBatch *batch);
/* S2S receiving */
static void multiline_handle_s2s_batch_open(Client *client, MessageTag *recv_mtags, int parc, const char *parv[], int is_channel);
static void multiline_handle_s2s_batch_close(Client *client, const char *batch_id);
static void multiline_handle_s2s_msg(Client *client, MessageTag *recv_mtags, int parc, const char *parv[], SendType sendtype);
static S2SMultilineBatch *multiline_find_s2s_batch(Client *sender, const char *batch_id);
static void multiline_free_s2s_batch(S2SMultilineBatch *s2s);
static void multiline_deliver_s2s_batch(S2SMultilineBatch *s2s);
static void multiline_free_s2s_batches_all(ModData *m);
/* ===================== VARIABLES ===================== */
static long CAP_MULTILINE = 0L;
static long CAP_BATCH = 0L;
#define HasMultiline(c) (HasCapabilityFast(c, CAP_MULTILINE) && HasCapabilityFast(c, CAP_BATCH))
static ModDataInfo *multiline_md = NULL;
static S2SMultilineBatch *s2s_batches = NULL;
/* ===================== MODULE LIFECYCLE ===================== */
MOD_TEST()
{
MARK_AS_OFFICIAL_MODULE(modinfo);
HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, multiline_config_test);
return MOD_SUCCESS;
}
MOD_INIT()
{
ClientCapabilityInfo cap;
ClientCapability *c;
MessageTagHandlerInfo mtag;
ModDataInfo mreq;
MARK_AS_OFFICIAL_MODULE(modinfo);
LoadPersistentPointer(modinfo, s2s_batches, multiline_free_s2s_batches_all);
/* Register draft/multiline capability */
memset(&cap, 0, sizeof(cap));
cap.name = "draft/multiline";
cap.parameter = multiline_capability_parameter;
c = ClientCapabilityAdd(modinfo->handle, &cap, &CAP_MULTILINE);
/* Register draft/multiline-concat message tag */
memset(&mtag, 0, sizeof(mtag));
mtag.name = "draft/multiline-concat";
mtag.is_ok = multiline_concat_mtag_is_ok;
mtag.clicap_handler = c;
MessageTagHandlerAdd(modinfo->handle, &mtag);
/* Register ModData for per-client multiline state */
memset(&mreq, 0, sizeof(mreq));
mreq.name = "multiline";
mreq.type = MODDATATYPE_LOCAL_CLIENT;
mreq.free = multiline_mdata_free;
mreq.serialize = NULL;
mreq.unserialize = NULL;
mreq.sync = 0;
multiline_md = ModDataAdd(modinfo->handle, mreq);
/* Command overrides */
CommandOverrideAdd(modinfo->handle, "BATCH", 0, multiline_override_batch);
CommandOverrideAdd(modinfo->handle, "PRIVMSG", 0, multiline_override_msg);
CommandOverrideAdd(modinfo->handle, "NOTICE", 0, multiline_override_msg);
/* Hooks */
HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, multiline_config_run);
HookAdd(modinfo->handle, HOOKTYPE_CLOSE_CONNECTION, 0, multiline_close_connection);
HookAdd(modinfo->handle, HOOKTYPE_REMOTE_QUIT, 0, multiline_remote_quit);
HookAdd(modinfo->handle, HOOKTYPE_SERVER_QUIT, 0, multiline_server_quit);
/* Timer for batch timeout */
EventAdd(modinfo->handle, "multiline_timeout", multiline_timeout_check, NULL, 5000, 0);
/* Set default batch timeout (multiline limits are in FloodSettings via conf.c) */
cfg.batch_timeout = 15;
return MOD_SUCCESS;
}
MOD_LOAD()
{
CAP_BATCH = ClientCapabilityBit("batch");
return MOD_SUCCESS;
}
MOD_UNLOAD()
{
SavePersistentPointer(modinfo, s2s_batches);
return MOD_SUCCESS;
}
/* ===================== CAPABILITY ===================== */
const char *multiline_capability_parameter(Client *client)
{
static char buf[128];
FloodSettings *f = get_floodsettings_for_user(client, FLD_MULTILINE);
snprintf(buf, sizeof(buf), "max-bytes=%d,max-lines=%d",
(int)f->period[FLD_MULTILINE],
(int)f->limit[FLD_MULTILINE]);
return buf;
}
/* ===================== MESSAGE TAG ===================== */
int multiline_concat_mtag_is_ok(Client *client, const char *name, const char *value)
{
/* Tag must have no value per spec */
if (!BadPtr(value))
return 0;
if (IsServer(client))
return 1;
/* Allow from local users who have an active multiline batch */
if (MyUser(client))
{
MultilineBatch *batch = moddata_local_client(client, multiline_md).ptr;
if (batch)
return 1;
}
return 0;
}
/* ===================== CONFIGURATION ===================== */
int multiline_config_test(ConfigFile *cf, ConfigEntry *ce, int type, int *errs)
{
int errors = 0;
ConfigEntry *cep;
if (type != CONFIG_SET)
return 0;
if (!ce || strcmp(ce->name, "multiline"))
return 0;
for (cep = ce->items; cep; cep = cep->next)
{
if (!cep->value)
{
config_error("%s:%i: blank set::multiline::%s without value",
cep->file->filename, cep->line_number, cep->name);
errors++;
continue;
}
if (!strcmp(cep->name, "batch-timeout"))
{
int v = atoi(cep->value);
if (v < 5 || v > 120)
{
config_error("%s:%i: set::multiline::batch-timeout must be between 5 and 120 seconds",
cep->file->filename, cep->line_number);
errors++;
}
} else
{
config_error("%s:%i: unknown directive set::multiline::%s",
cep->file->filename, cep->line_number, cep->name);
errors++;
}
}
*errs = errors;
return errors ? -1 : 1;
}
int multiline_config_run(ConfigFile *cf, ConfigEntry *ce, int type)
{
ConfigEntry *cep;
if (type != CONFIG_SET)
return 0;
if (!ce || strcmp(ce->name, "multiline"))
return 0;
for (cep = ce->items; cep; cep = cep->next)
{
if (!strcmp(cep->name, "batch-timeout"))
cfg.batch_timeout = atoi(cep->value);
}
return 1;
}
/* ===================== MODDATA ===================== */
void multiline_mdata_free(ModData *m)
{
MultilineBatch *batch = m->ptr;
if (batch)
{
multiline_free_batch(batch);
m->ptr = NULL;
}
}
/* ===================== HELPER FUNCTIONS ===================== */
/** Find channel for a target that may have a STATUSMSG prefix (e.g. "@#channel") */
static inline Channel *multiline_find_channel(const char *target)
{
const char *p = strchr(target, '#');
return p ? find_channel(p) : NULL;
}
/** Duplicate a message tag list, excluding a specific tag name.
* @param mtags Source tag list
* @param exclude Tag name to exclude
* @returns New tag list (caller must free with free_message_tags())
*/
static MessageTag *duplicate_mtags_excluding(MessageTag *mtags, const char *exclude)
{
MessageTag *out = NULL, *m, *dup;
for (m = mtags; m; m = m->next)
{
if (!strcmp(m->name, exclude))
continue;
dup = duplicate_mtag(m);
AddListItem(dup, out);
}
return out;
}
/** Duplicate a message tag list, excluding tags with MTAG_HANDLER_FLAGS_FIRST_ONLY.
* Used to build the tag set for subsequent fallback lines (lines 2..N),
* where tags like msgid and +draft/reply should not be repeated.
*/
static MessageTag *duplicate_mtags_for_subsequent_lines(MessageTag *mtags)
{
MessageTag *out = NULL, *m, *dup;
for (m = mtags; m; m = m->next)
{
MessageTagHandler *handler = MessageTagHandlerFind(m->name);
if (handler && (handler->flags & MTAG_HANDLER_FLAGS_FIRST_ONLY))
continue;
dup = duplicate_mtag(m);
AddListItem(dup, out);
}
return out;
}
/** Run HOOKTYPE_CHANMSG per-line for a multiline batch.
* First line carries mtags (incl msgid), subsequent lines don't.
*/
static void multiline_run_chanmsg_hooks(Client *sender, Channel *channel,
int sendflags, const char *member_modes, const char *targetstr,
MessageTag *mtags, MultilineLine *lines, SendType sendtype)
{
MultilineLine *line;
echo_message_inhibit = 1;
for (line = lines; line; line = line->next)
{
RunHook(HOOKTYPE_CHANMSG, sender, channel, sendflags,
member_modes, targetstr,
(line == lines) ? mtags : NULL,
line->text, sendtype);
}
echo_message_inhibit = 0;
}
/** Run HOOKTYPE_USERMSG per-line for a multiline batch.
* First line carries mtags (incl msgid), subsequent lines don't.
*/
static void multiline_run_usermsg_hooks(Client *sender, Client *target,
MessageTag *mtags, MultilineLine *lines, SendType sendtype)
{
MultilineLine *line;
echo_message_inhibit = 1;
for (line = lines; line; line = line->next)
{
RunHook(HOOKTYPE_USERMSG, sender, target,
(line == lines) ? mtags : NULL,
line->text, sendtype);
}
echo_message_inhibit = 0;
}
/** Free a linked list of multiline lines */
static void multiline_free_lines(MultilineLine *lines)
{
MultilineLine *l, *l_next;
for (l = lines; l; l = l_next)
{
l_next = l->next;
safe_free(l->text);
safe_free(l);
}
}
/** Free a local multiline batch (but not the ModData slot itself) */
static void multiline_free_batch(MultilineBatch *batch)
{
if (!batch)
return;
safe_free(batch->target);
safe_free(batch->fail_message);
free_message_tags(batch->client_mtags);
multiline_free_lines(batch->lines);
multiline_free_lines(batch->fallback_lines);
safe_free(batch);
}
/** Append a line to a multiline line list */
static void multiline_append_line(MultilineLine **lines, MultilineLine **lines_tail,
int *line_count, const char *text, int is_concat)
{
MultilineLine *line = safe_alloc(sizeof(MultilineLine));
safe_strdup(line->text, text ? text : "");
line->concat = is_concat;
line->next = NULL;
if (*lines_tail)
(*lines_tail)->next = line;
else
*lines = line;
*lines_tail = line;
(*line_count)++;
}
/** Calculate the multiline batch fake lag in milliseconds.
* Uses the same per-line formula as parse.c with a floor of
* 1 extra line and a hard cap of 15 seconds.
* FIXME: reconsider exact algo
*/
static long calculate_multiline_fakelag(Client *client, MultilineBatch *batch)
{
FloodSettings *settings;
int lag_penalty, lag_penalty_bytes;
long lag_msec;
MultilineLine *l;
if (!batch->lines)
return 0;
settings = get_floodsettings_for_user(client, FLD_LAG_PENALTY);
lag_penalty = settings->period[FLD_LAG_PENALTY];
lag_penalty_bytes = settings->limit[FLD_LAG_PENALTY];
if (lag_penalty_bytes < 1)
lag_penalty_bytes = 1;
lag_msec = lag_penalty; /* floor: 1 extra line */
for (l = batch->lines; l; l = l->next)
lag_msec += (1 + ((int)strlen(l->text) / lag_penalty_bytes)) * lag_penalty;
if (lag_msec > 15000)
lag_msec = 15000;
return lag_msec;
}
/** Mark a local multiline batch as failed.
* The batch stays open so remaining lines are silently consumed.
* The error is sent when the client sends BATCH -ref.
* @param fail_msg The FAIL response line (after ":<server> "), e.g.
* "FAIL BATCH MULTILINE_MAX_LINES 5 :Too many lines in batch"
*/
static void multiline_fail_batch(Client *client, MultilineBatch *batch, const char *fail_msg)
{
if (!batch)
return;
if (batch->failed)
return; /* Already marked, keep first error */
batch->failed = 1;
safe_strdup(batch->fail_message, fail_msg);
}
/** Abort a local multiline batch: apply half fake lag penalty and free state.
* Called at BATCH close for failed batches, or for fatal errors where
* the batch state must be freed immediately.
*/
static void multiline_abort_batch(Client *client, MultilineBatch *batch)
{
if (!batch)
return;
/* Apply half the batch fake lag penalty for aborted batches */
add_fake_lag(client, 2000 + calculate_multiline_fakelag(client, batch) / 2);
multiline_free_batch(batch);
moddata_local_client(client, multiline_md).ptr = NULL;
}
/** Calculate the number of bytes a line adds to a multiline batch.
* Accounts for the \n separator between non-concat lines.
* If you wonder why this is a separate and surprisingly small function,
* it is because already screwing up twice, where the local-case
* diverted from the remote-case, so hence this helper :D.
*/
static inline int multiline_calc_add_bytes(int text_len, int line_count, int is_concat)
{
return text_len + (((line_count > 0) && !is_concat) ? 1 : 0);
}
/** Concatenate all batch lines into a single string (for spamfilter).
* Concat lines are joined without separator, normal lines with \n.
* @returns A dynamically allocated string. Caller must free with safe_free().
*/
static char *multiline_concat_text(MultilineBatch *batch)
{
MultilineLine *l;
int bufsize = 1; /* 1 for \0 */
char *buf;
int pos = 0;
/* Calculate buffer size from actual line data, since text
* may have been transformed at delivery time (+S, +G, etc.).
*/
for (l = batch->lines; l; l = l->next)
{
if (l != batch->lines && !l->concat)
bufsize++; /* \n separator */
if (l->text)
bufsize += strlen(l->text);
}
buf = safe_alloc(bufsize);
/* Now build the big string*/
for (l = batch->lines; l; l = l->next)
{
if (l != batch->lines && !l->concat)
{
if (pos + 1 >= bufsize)
break;
buf[pos++] = '\n';
}
if (l->text)
{
int len = strlen(l->text);
if (pos + len >= bufsize)
break;
strlcpy(buf + pos, l->text, bufsize - pos);
pos += len;
}
}
buf[pos] = '\0';
return buf;
}
/** Build fallback text: merge concat lines into their predecessors.
* Returns a list of "logical lines" for fallback delivery.
* Caller must free the returned list with multiline_free_lines().
*/
static MultilineLine *multiline_build_fallback_lines(MultilineBatch *batch)
{
MultilineLine *out = NULL, *out_tail = NULL;
MultilineLine *l;
for (l = batch->lines; l; l = l->next)
{
if (l->concat && out_tail)
{
/* Append to current logical line without separator */
const char *append = l->text ? l->text : "";
int newlen = strlen(out_tail->text) + strlen(append) + 1;
char *newtext = safe_alloc(newlen);
strlcpy(newtext, out_tail->text, newlen);
strlcat(newtext, append, newlen);
safe_free(out_tail->text);
out_tail->text = newtext;
} else {
/* Start a new logical line */
MultilineLine *newline = safe_alloc(sizeof(MultilineLine));
safe_strdup(newline->text, l->text ? l->text : "");
newline->concat = 0;
newline->next = NULL;
if (out_tail)
out_tail->next = newline;
else
out = newline;
out_tail = newline;
}
}
return out;
}
/** Get cached fallback lines, building them on first call */
static MultilineLine *multiline_get_fallback_lines(MultilineBatch *batch)
{
if (!batch->fallback_lines)
batch->fallback_lines = multiline_build_fallback_lines(batch);
return batch->fallback_lines;
}
/** Check if a batch has at least one non-blank line */
static int multiline_batch_has_content(MultilineBatch *batch)
{
MultilineLine *l;
for (l = batch->lines; l; l = l->next)
if (l->text && l->text[0])
return 1;
return 0;
}
/* ===================== BATCH COMMAND OVERRIDE ===================== */
CMD_OVERRIDE_FUNC(multiline_override_batch)
{
const char *ref;
MultilineBatch *batch;
/* Handle S2S multiline batches.
* Both channel and user cases use the same wire format:
* BATCH <target> +ref draft/multiline [extra]
* BATCH <target> -ref
* where <target> is a channel name or user UID.
* We distinguish channel vs user by checking if parv[1] contains '#'.
*/
if (IsServer(client) || !MyConnect(client))
{
if (parc >= 3 && (parv[2][0] == '+' || parv[2][0] == '-'))
{
int is_channel = (strchr(parv[1], '#') != NULL);
if (parv[2][0] == '+' && parc >= 4 && !strcmp(parv[3], "draft/multiline"))
{
multiline_handle_s2s_batch_open(client, recv_mtags, parc, parv, is_channel);
return;
}
if (parv[2][0] == '-')
{
S2SMultilineBatch *s2s = multiline_find_s2s_batch(client, parv[2] + 1);
if (s2s)
{
multiline_handle_s2s_batch_close(client, parv[2] + 1);
return;
}
}
}
/* Not a multiline batch - fall through to batch.c */
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
/* Not a local user - fall through */
if (!MyUser(client))
{
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
if (parc < 2 || BadPtr(parv[1]))
{
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
ref = parv[1];
/* Opening batch: BATCH +ref draft/multiline <target> */
if (ref[0] == '+')
{
const char *batch_type;
const char *target;
ref++; /* skip '+' */
if (!valid_batch_reference_tag(ref))
{
sendto_one(client, NULL, ":%s FAIL BATCH INVALID_REFTAG %s :Invalid batch reference tag", me.name, ref);
return;
}
if (parc < 4 || BadPtr(parv[2]) || BadPtr(parv[3]))
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Insufficient parameters", me.name);
return;
}
batch_type = parv[2];
target = parv[3];
/* Only handle draft/multiline batch type */
if (strcmp(batch_type, "draft/multiline"))
{
/* Not a multiline batch - fall through */
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
/* Check capabilities */
if (!HasMultiline(client))
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :You must negotiate the draft/multiline and batch capabilities", me.name);
return;
}
/* Check for existing open batch */
if (moddata_local_client(client, multiline_md).ptr)
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :You already have an open multiline batch", me.name);
return;
}
/* Validate batch reference length */
if (strlen(ref) > BATCHLEN || strlen(ref) < 1)
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Invalid batch reference tag", me.name);
return;
}
/* Allocate and initialize batch state */
batch = safe_alloc(sizeof(MultilineBatch));
strlcpy(batch->batch_id, ref, sizeof(batch->batch_id));
/* Parse STATUSMSG prefix from target, modeled on message.c cmd_message() */
{
const char *p2 = strchr(target, '#');
if (p2 && (p2 - target > 0))
{
char prefix_tmp[32];
char prefix;
Channel *channel;
strlncpy(prefix_tmp, target, sizeof(prefix_tmp), p2 - target);
prefix = lowest_ranking_prefix(prefix_tmp);
if (!prefix)
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Invalid prefix", me.name);
safe_free(batch);
return;
}
channel = find_channel(p2);
if (!channel)
{
sendnumeric(client, ERR_NOSUCHNICK, p2);
safe_free(batch);
return;
}
/* STATUSMSG access is checked at delivery time
* (in multiline_deliver_channel) to avoid TOCTOU.
*/
/* Store normalized prefixed target (e.g., "@#channel") */
{
char pfixchan[CHANNELLEN + 4];
snprintf(pfixchan, sizeof(pfixchan), "%c%s", prefix, channel->name);
safe_strdup(batch->target, pfixchan);
}
/* Store member mode */
batch->member_modes[0] = prefix_to_mode(prefix);
batch->member_modes[1] = '\0';
} else {
safe_strdup(batch->target, target);
batch->member_modes[0] = '\0';
}
}
batch->sendtype_set = 0;
batch->line_count = 0;
batch->received_bytes = 0;
batch->start_time = TStime();
batch->lines = NULL;
batch->lines_tail = NULL;
/* Save client tags from the BATCH command (spec: other client tags go here) */
batch->client_mtags = duplicate_mtags_excluding(recv_mtags, "batch");
/* For echo-message + labeled-response interaction: save the label
* and clear the LR context so lr_post_command won't send a
* premature ACK. The label will be placed on the echo batch's
* BATCH open line at delivery time instead.
* Spec: "Servers MUST only include the label tag on the opening
* BATCH command when replying to a client using echo-message."
*/
batch->label[0] = '\0';
if (HasCapability(client, "echo-message") &&
HasCapability(client, "labeled-response"))
{
MessageTag *mt;
for (mt = recv_mtags; mt; mt = mt->next)
{
if (!strcmp(mt->name, "label") && mt->value)
{
strlcpy(batch->label, mt->value, sizeof(batch->label));
labeled_response_set_context(NULL);
break;
}
}
}
moddata_local_client(client, multiline_md).ptr = batch;
return; /* Consumed, don't fall through */
}
/* Closing batch: BATCH -ref */
if (ref[0] == '-')
{
ref++; /* skip '-' */
batch = moddata_local_client(client, multiline_md).ptr;
if (!batch || strcmp(batch->batch_id, ref))
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :No matching open batch", me.name);
return;
}
/* If the batch was marked as failed during line processing,
* send the deferred error now and clean up.
*/
if (batch->failed)
{
sendto_one(client, NULL, ":%s %s", me.name, batch->fail_message);
multiline_abort_batch(client, batch);
return;
}
/* Validate: batch must have at least one line */
if (batch->line_count == 0)
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Empty batch", me.name);
multiline_abort_batch(client, batch);
return;
}
/* Validate: batch must not be entirely blank lines */
if (!multiline_batch_has_content(batch))
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Batch consists entirely of blank lines", me.name);
multiline_abort_batch(client, batch);
return;
}
/* Deliver the batch */
multiline_deliver(client, batch);
/* Apply batch fake lag */
add_fake_lag(client, calculate_multiline_fakelag(client, batch));
/* Clean up */
multiline_free_batch(batch);
moddata_local_client(client, multiline_md).ptr = NULL;
return;
}
/* Not +/- prefixed, fall through */
CALL_NEXT_COMMAND_OVERRIDE();
}
/* ===================== PRIVMSG/NOTICE OVERRIDE ===================== */
CMD_OVERRIDE_FUNC(multiline_override_msg)
{
MultilineBatch *batch;
MessageTag *m;
FloodSettings *f;
const char *batch_ref = NULL;
Channel *channel;
const char *target;
const char *text;
int is_concat = 0;
int text_len;
int add_bytes;
SendType sendtype;
/* Only intercept from local users */
if (!MyUser(client))
{
/* Check for S2S multiline batch line */
if (IsServer(client) || !MyConnect(client))
{
m = find_mtag(recv_mtags, "batch");
if (m && m->value)
{
S2SMultilineBatch *s2s = multiline_find_s2s_batch(client, m->value);
if (s2s)
{
/* Determine sendtype from command name */
sendtype = !strcmp(ovr->command->cmd, "NOTICE") ? SEND_TYPE_NOTICE : SEND_TYPE_PRIVMSG;
multiline_handle_s2s_msg(client, recv_mtags, parc, parv, sendtype);
return;
}
}
}
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
/* Check for @batch= tag */
m = find_mtag(recv_mtags, "batch");
if (!m || !m->value)
{
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
batch_ref = m->value;
/* Look up active batch */
batch = moddata_local_client(client, multiline_md).ptr;
if (!batch || strcmp(batch->batch_id, batch_ref))
{
/* No matching batch - process as normal message */
CALL_NEXT_COMMAND_OVERRIDE();
return;
}
/* If the batch was already marked as failed, silently consume
* remaining lines. The error will be sent at BATCH close.
*/
if (batch->failed)
{
/* Undo fakelag for discarded lines, but only up to
* max-lines total to prevent flood evasion.
*/
FloodSettings *f = get_floodsettings_for_user(client, FLD_MULTILINE);
int max_lines = f->limit[FLD_MULTILINE];
batch->failed++;
if (batch->line_count + batch->failed <= max_lines)
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
/* We are inside an active multiline batch - buffer this line */
if (parc < 2 || BadPtr(parv[1]))
{
sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Insufficient parameters", me.name);
multiline_abort_batch(client, batch);
return;
}
target = parv[1];
text = (parc >= 3 && parv[2]) ? parv[2] : "";
/* Determine SendType from command name */
sendtype = !strcmp(ovr->command->cmd, "NOTICE") ? SEND_TYPE_NOTICE : SEND_TYPE_PRIVMSG;
/* First line sets sendtype, subsequent must match (no mixing PRIVMSG/NOTICE) */
if (!batch->sendtype_set)
{
batch->sendtype = sendtype;
batch->sendtype_set = 1;
} else if (batch->sendtype != sendtype) {
char buf[512];
snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_INVALID :Cannot mix PRIVMSG and NOTICE in a multiline batch");
multiline_fail_batch(client, batch, buf);
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
/* Validate target matches batch target */
if (strcasecmp(target, batch->target))
{
char buf[512];
snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_INVALID_TARGET %s %s :Target mismatch",
batch->target, target);
multiline_fail_batch(client, batch, buf);
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
/* Check for draft/multiline-concat tag */
if (find_mtag(recv_mtags, "draft/multiline-concat"))
is_concat = 1;
/* Validate: blank line with concat is invalid */
if (is_concat && (!text || !text[0]))
{
multiline_fail_batch(client, batch, "FAIL BATCH MULTILINE_INVALID :Blank line with concat tag");
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
/* Reject CTCP in multiline batches (including ACTION).
* A batch mixing CTCP and non-CTCP lines has no coherent
* semantics, and even a pure-CTCP batch would behave
* differently for multiline-capable vs fallback recipients.
* Fallback delivery sends each line as an individual PRIVMSG,
* turning one batch into N separate CTCP requests — a flood
* amplification vector triggering auto-replies from each client.
*/
if (text && *text == '\001')
{
multiline_fail_batch(client, batch, "FAIL BATCH MULTILINE_INVALID :CTCP not permitted in multiline batches");
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
/* Check max-lines and max-bytes (per-group limits) */
if ((f = get_floodsettings_for_user(client, FLD_MULTILINE)))
{
int max_lines = f->limit[FLD_MULTILINE];
int max_bytes = (int)f->period[FLD_MULTILINE];
/* If target is a channel, also respect +f/+F 'm' and 't' limits */
channel = multiline_find_channel(batch->target);
if (channel)
max_lines = MIN(max_lines, get_floodprot_channel_max_lines(channel));
if (batch->line_count >= max_lines)
{
char buf[512];
snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_MAX_LINES %d :Too many lines in batch", max_lines);
multiline_fail_batch(client, batch, buf);
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
/* Check max-bytes (text length + 1 for newline separator, except for concat and first line) */
text_len = text ? strlen(text) : 0;
add_bytes = multiline_calc_add_bytes(text_len, batch->line_count, is_concat);
if (batch->received_bytes + add_bytes > max_bytes)
{
char buf[512];
snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_MAX_BYTES %d :Too many bytes in batch", max_bytes);
multiline_fail_batch(client, batch, buf);
subtract_fake_lag(client, clictx->fake_lag_added_msec);
return;
}
batch->received_bytes += add_bytes;
}
/* Permission checks (can_send_to_channel / can_send_to_user) and text
* transformations (+S, +G, etc.) are intentionally NOT done here during
* buffering. They are deferred to delivery time (multiline_deliver_channel
* / multiline_deliver_user) to avoid TOCTOU issues: channel modes, bans,
* or user modes could change between buffering and delivery.
* Raw (untransformed) text is buffered here.
*/
/* Buffer the line */
multiline_append_line(&batch->lines, &batch->lines_tail, &batch->line_count, text, is_concat);
/* Undo the fake lag that parse_addlag() added for this buffered line,
* the batch formula at BATCH close will apply the correct lag instead.
*/
subtract_fake_lag(client, clictx->fake_lag_added_msec);
/* Line buffered - do NOT call CALL_NEXT_COMMAND_OVERRIDE() */
}
/* ===================== BATCH DELIVERY ===================== */
/** Deliver a completed multiline batch */
static void multiline_deliver(Client *client, MultilineBatch *batch)
{
Channel *channel;
Client *target;
const char *text, *errmsg;
char *concat_text;
/* Force labeled-response (like message.c does) */
labeled_response_force = 1;
/* Resolve target (channel, or NULL for user targets / vanished channels) */
channel = multiline_find_channel(batch->target);
if (channel)
{
multiline_deliver_channel(client, batch, channel);
} else if (strchr(batch->target, '#')) {
/* Target had a '#' but channel not found (parted/destroyed during batch) */
sendnumeric(client, ERR_NOSUCHNICK, batch->target);
return;
} else {
target = hash_find_nickatserver(batch->target, NULL);
if (!target)
{
if (SERVICES_NAME)
{
char *server = strchr(batch->target, '@');
if (server && strncasecmp(server + 1, SERVICES_NAME, strlen(SERVICES_NAME)) == 0)
{
sendnumeric(client, ERR_SERVICESDOWN, batch->target);
return;
}
}
sendnumeric(client, ERR_NOSUCHNICK, batch->target);
return;
}
multiline_deliver_user(client, batch, target);
}
}
/** Deliver multiline batch to a channel */
static void multiline_deliver_channel(Client *client, MultilineBatch *batch, Channel *channel)
{
char *concat_text;
const char *cmd = sendtype_to_cmd(batch->sendtype);
MessageTag *mtags = NULL;
int sendflags = SEND_ALL;
MultilineLine *line;
char expanded_modes[64];
const char *filter_modes = NULL;
/* Run permission checks and text transformations at delivery time
* (not during buffering) to avoid TOCTOU: channel modes, bans,
* or membership could change between buffering and batch close.
*/
if (MyUser(client) && !IsULine(client))
{
for (line = batch->lines; line; line = line->next)
{
TextAnalysis ta;
ClientContext delivery_clictx;
const char *check_text;
const char *check_errmsg = NULL;
if (!line->text || !*line->text)
continue; /* blank lines are valid paragraph breaks */
memset(&ta, 0, sizeof(ta));
memset(&delivery_clictx, 0, sizeof(delivery_clictx));
delivery_clictx.textanalysis = &ta;
RunHook(HOOKTYPE_ANALYZE_TEXT, client, line->text, &ta);
check_text = line->text;
if (!can_send_to_channel(client, channel, &check_text, &check_errmsg, batch->sendtype, &delivery_clictx))
{
if (IsDead(client))
return;
return; /* Batch rejected */
}
/* Apply transformed text (e.g. +S stripped colors, +G censored) */
if (check_text != line->text)
{
safe_free(line->text);
safe_strdup(line->text, check_text);
}
}
/* Re-check STATUSMSG access (user may have lost op/voice during batch) */
if (batch->member_modes[0])
{
if (!op_can_override("channel:override:message:prefix", client, channel, NULL))
{
Membership *lp = find_membership_link(client->user->channel, channel);
if (!lp || !check_channel_access_membership(lp, "vhoaq"))
{
sendnumeric(client, ERR_CHANOPRIVSNEEDED, channel->name);
return;
}
}
}
}
/* Expand member_modes to include equal-or-higher ranks for STATUSMSG filtering */
if (batch->member_modes[0])
{
channel_member_modes_generate_equal_or_greater(batch->member_modes, expanded_modes, sizeof(expanded_modes));
filter_modes = expanded_modes;
}
/* Spamfilter on concatenated text */
if (MyUser(client))
{
int spamtype = (batch->sendtype == SEND_TYPE_NOTICE ? SPAMF_CHANNOTICE : SPAMF_CHANMSG);
concat_text = multiline_concat_text(batch);
if (match_spamfilter(client, concat_text, spamtype, cmd, channel->name, 0, NULL, NULL))
{
safe_free(concat_text);
return;
}
safe_free(concat_text);
}
/* Generate outgoing message tags */
new_message(client, batch->client_mtags, &mtags);
/* Compute sendflags */
if (batch->lines && batch->lines->text && !strchr(CHANCMDPFX, batch->lines->text[0]))
sendflags |= SKIP_DEAF;
/* Hook: PRE_CHANMSG (per-line, matching single-message behavior) */
for (line = batch->lines; line; line = line->next)
RunHook(HOOKTYPE_PRE_CHANMSG, client, channel, &mtags, line->text, batch->sendtype);
/* Deliver to local channel members */
multiline_deliver_to_local_members(channel, client, batch, mtags,
batch->target, cmd, filter_modes, client, sendflags);
/* Echo-message for sender */
multiline_echo_to_sender(client, batch, mtags, batch->target, cmd);
/* S2S relay */
multiline_send_s2s_channel(client, batch, channel, mtags, cmd, client->direction, batch->target);
/* FIXME: history module needs a dedicated hook (or new approach)
* to store and replay multiline batches properly, as per-line
* hooks lose the multiline structure.
*/
multiline_run_chanmsg_hooks(client, channel, sendflags,
batch->member_modes[0] ? batch->member_modes : NULL,
batch->target, mtags, batch->lines, batch->sendtype);
free_message_tags(mtags);
}
/** Deliver multiline batch to a user */
static void multiline_deliver_user(Client *client, MultilineBatch *batch, Client *target)
{
char *concat_text;
const char *cmd = sendtype_to_cmd(batch->sendtype);
MessageTag *mtags = NULL;
/* Run permission checks and text transformations at delivery time
* (not during buffering) to avoid TOCTOU: user modes or target
* identity could change between buffering and batch close.
*/
if (MyUser(client) && !IsULine(client))
{
MultilineLine *line;
for (line = batch->lines; line; line = line->next)
{
TextAnalysis ta;
ClientContext delivery_clictx;
const char *check_text;
const char *check_errmsg = NULL;
if (!line->text || !*line->text)
continue;
memset(&ta, 0, sizeof(ta));
memset(&delivery_clictx, 0, sizeof(delivery_clictx));
delivery_clictx.textanalysis = &ta;
RunHook(HOOKTYPE_ANALYZE_TEXT, client, line->text, &ta);
check_text = line->text;
if (!can_send_to_user(client, target, &check_text, &check_errmsg, batch->sendtype, &delivery_clictx, 0))
{
if (IsDead(client))
return;
return; /* Batch rejected */
}
if (check_text != line->text)
{
safe_free(line->text);
safe_strdup(line->text, check_text);
}
}
}
/* Spamfilter on concatenated text (not per-line, to catch
* patterns spanning multiple lines and match channel behavior).
*/
if (MyUser(client) && (batch->sendtype != SEND_TYPE_TAGMSG))
{
int spamtype = (batch->sendtype == SEND_TYPE_NOTICE ? SPAMF_USERNOTICE : SPAMF_USERMSG);
concat_text = multiline_concat_text(batch);
if (match_spamfilter(client, concat_text, spamtype, cmd, target->name, 0, NULL, NULL))
{
safe_free(concat_text);
return;
}
safe_free(concat_text);
}
/* Inform sender of away status */
if (batch->sendtype == SEND_TYPE_PRIVMSG && MyConnect(client) && target->user && target->user->away)
sendnumeric(client, RPL_AWAY, target->name, target->user->away);
/* Generate outgoing message tags */
new_message(client, batch->client_mtags, &mtags);
labeled_response_inhibit = 1;
if (MyUser(target))
{
/* Target is local */
if (HasMultiline(target))
{
multiline_send_batch_to_client(target, client, batch, mtags, target->name, cmd);
} else {
multiline_send_fallback_to_client(target, client, batch, mtags, target->name, cmd);
}
} else {
/* Target is remote - relay as S2S batch */
multiline_send_s2s_user(client, batch, target, mtags, cmd);
}
labeled_response_inhibit = 0;
/* Echo-message for sender */
multiline_echo_to_sender(client, batch, mtags, target->name, cmd);
multiline_run_usermsg_hooks(client, target, mtags, batch->lines, batch->sendtype);
free_message_tags(mtags);
}
/** Echo-message: send the multiline batch back to the sender, with label if applicable */
static void multiline_echo_to_sender(Client *client, MultilineBatch *batch,
MessageTag *mtags, const char *targetstr, const char *cmd)
{
if (!MyUser(client) || !HasCapability(client, "echo-message"))
return;
MessageTag label_mtag;
MessageTag *echo_mtags = mtags;
/* labeled-response: include label on the echo's BATCH open */
if (batch->label[0])
{
memset(&label_mtag, 0, sizeof(label_mtag));
label_mtag.name = "label";
label_mtag.value = batch->label;
label_mtag.next = mtags;
echo_mtags = &label_mtag;
}
if (HasMultiline(client))
{
multiline_send_batch_to_client(client, client, batch, echo_mtags, targetstr, cmd);
} else {
multiline_send_fallback_to_client(client, client, batch, echo_mtags, targetstr, cmd);
}
}
/* ===================== SENDING HELPERS ===================== */
/** Send a multiline batch to a local client that supports draft/multiline */
static void multiline_send_batch_to_client(Client *to, Client *from, MultilineBatch *batch,
MessageTag *base_mtags, const char *targetstr, const char *cmd)
{
char server_batch_id[BATCHLEN+1];
MultilineLine *l;
MessageTag *m;
generate_batch_id(server_batch_id);
/* Send BATCH open with base_mtags (msgid, time, etc.) */
sendto_prefix_one(to, from, base_mtags,
":%s BATCH +%s draft/multiline %s",
from->name, server_batch_id, targetstr);
/* Send each line */
for (l = batch->lines; l; l = l->next)
{
/* Build mtags with @batch= and optionally ;draft/multiline-concat */
MessageTag batch_tag;
MessageTag concat_tag;
MessageTag *line_mtags = NULL;
memset(&batch_tag, 0, sizeof(batch_tag));
batch_tag.name = "batch";
batch_tag.value = server_batch_id;
if (l->concat)
{
memset(&concat_tag, 0, sizeof(concat_tag));
concat_tag.name = "draft/multiline-concat";
concat_tag.value = NULL;
concat_tag.next = NULL;
batch_tag.next = &concat_tag;
}
line_mtags = &batch_tag;
sendto_prefix_one(to, from, line_mtags,
":%s %s %s :%s",
from->name, cmd, targetstr, l->text ? l->text : "");
}
/* Send BATCH close */
sendto_prefix_one(to, from, NULL, ":%s BATCH -%s", from->name, server_batch_id);
}
/** Send multiline as individual fallback lines to a local client */
static void multiline_send_fallback_to_client(Client *to, Client *from, MultilineBatch *batch,
MessageTag *base_mtags, const char *targetstr, const char *cmd)
{
MultilineLine *fallback_lines, *l;
MessageTag *rest_mtags;
int first = 1;
fallback_lines = multiline_get_fallback_lines(batch);
/* Build rest_mtags: base_mtags minus msgid.
* Spec: msgid MUST only be on the first line, but other tags
* (time, account, etc.) MAY be on subsequent lines.
*/
rest_mtags = duplicate_mtags_for_subsequent_lines(base_mtags);
for (l = fallback_lines; l; l = l->next)
{
/* Spec: "Servers MUST NOT send blank lines to clients
* that have not negotiated the multiline capability."
*/
if (!l->text || !l->text[0])
continue;
if (first)
{
/* First non-blank line gets full mtags (msgid, time, account, etc.) */
sendto_prefix_one(to, from, base_mtags,
":%s %s %s :%s",
from->name, cmd, targetstr, l->text);
first = 0;
} else {
/* Subsequent lines get all tags except msgid */
sendto_prefix_one(to, from, rest_mtags,
":%s %s %s :%s",
from->name, cmd, targetstr, l->text);
}
}
free_message_tags(rest_mtags);
}
/** Deliver multiline batch to local channel members.
* Iterates local_members, applies STATUSMSG filtering, skips sender and deaf
* as requested, and dispatches via batch or fallback depending on capability.
* @param skip Client to skip (sender for local delivery, NULL for S2S)
* @param sendflags Send flags (SKIP_DEAF etc.), 0 if not applicable
*/
static void multiline_deliver_to_local_members(Channel *channel, Client *from,
MultilineBatch *batch, MessageTag *mtags, const char *targetstr,
const char *cmd, const char *filter_modes, Client *skip, int sendflags)
{
LocalMember *lm;
for (lm = channel->local_members; lm; lm = lm->next)
{
Client *acptr = lm->ptr->client;
if (acptr == skip)
continue;
if (IsDeaf(acptr) && (sendflags & SKIP_DEAF))
continue;
if (filter_modes && !check_channel_access_member(lm->ptr, filter_modes))
continue;
if (HasMultiline(acptr))
multiline_send_batch_to_client(acptr, from, batch, mtags, targetstr, cmd);
else
multiline_send_fallback_to_client(acptr, from, batch, mtags, targetstr, cmd);
}
}
/** Send a multiline batch (BATCH open + lines + BATCH close) to a single server direction */
static void multiline_send_s2s_to_direction(Client *direction, Client *from,
MultilineBatch *batch, MessageTag *base_mtags, const char *cmd,
const char *targetstr, const char *ref)
{
MultilineLine *l;
int first;
sendto_one(direction, NULL,
":%s BATCH %s +%s draft/multiline",
from->id, targetstr, ref);
first = 1;
for (l = batch->lines; l; l = l->next)
{
MessageTag batch_tag;
MessageTag concat_tag;
memset(&batch_tag, 0, sizeof(batch_tag));
batch_tag.name = "batch";
batch_tag.value = (char *)ref;
if (l->concat)
{
memset(&concat_tag, 0, sizeof(concat_tag));
concat_tag.name = "draft/multiline-concat";
concat_tag.value = NULL;
concat_tag.next = NULL;
batch_tag.next = &concat_tag;
}
if (first)
{
/* First line carries base_mtags plus batch tag */
MessageTag *combined = NULL;
MessageTag *dup;
MessageTag *mtag_iter;
for (mtag_iter = base_mtags; mtag_iter; mtag_iter = mtag_iter->next)
{
dup = duplicate_mtag(mtag_iter);
AddListItem(dup, combined);
}
dup = duplicate_mtag(&batch_tag);
if (l->concat)
{
MessageTag *dup_concat = safe_alloc(sizeof(MessageTag));
safe_strdup(dup_concat->name, "draft/multiline-concat");
AddListItem(dup_concat, combined);
}
AddListItem(dup, combined);
sendto_prefix_one(direction, from, combined,
":%s %s %s :%s",
from->id, cmd, targetstr, l->text ? l->text : "");
free_message_tags(combined);
first = 0;
} else {
sendto_prefix_one(direction, from, &batch_tag,
":%s %s %s :%s",
from->id, cmd, targetstr, l->text ? l->text : "");
}
}
sendto_one(direction, NULL,
":%s BATCH %s -%s",
from->id, targetstr, ref);
}
/** Send multiline batch to remote servers (channel target) */
static void multiline_send_s2s_channel(Client *from, MultilineBatch *batch, Channel *channel,
MessageTag *base_mtags, const char *cmd, Client *skip_direction, const char *targetstr)
{
char ref[BATCHLEN+1];
Member *lp;
Client *acptr;
char expanded_modes[64];
const char *filter_modes = NULL;
generate_batch_id(ref);
/* Expand member_modes for STATUSMSG filtering */
if (batch->member_modes[0])
{
channel_member_modes_generate_equal_or_greater(batch->member_modes, expanded_modes, sizeof(expanded_modes));
filter_modes = expanded_modes;
}
/* Track which server directions we've already sent to */
++current_serial;
for (lp = channel->members; lp; lp = lp->next)
{
acptr = lp->client;
if (MyUser(acptr))
continue; /* Already handled locally */
if (acptr->direction == skip_direction)
continue;
/* STATUSMSG filtering: only relay to directions with matching members */
if (filter_modes && !check_channel_access_member(lp, filter_modes))
continue;
if (acptr->direction->local->serial == current_serial)
continue; /* Already sent to this direction */
acptr->direction->local->serial = current_serial;
multiline_send_s2s_to_direction(acptr->direction, from, batch, base_mtags, cmd, targetstr, ref);
}
/* Also handle broadcast-channel-messages for +H channels etc. */
if ((iConf.broadcast_channel_messages == BROADCAST_CHANNEL_MESSAGES_ALWAYS) ||
((iConf.broadcast_channel_messages == BROADCAST_CHANNEL_MESSAGES_AUTO) && has_channel_mode(channel, 'H')))
{
list_for_each_entry(acptr, &server_list, special_node)
{
if (acptr->direction == skip_direction)
continue;
if (acptr->direction->local->serial == current_serial)
continue;
acptr->direction->local->serial = current_serial;
multiline_send_s2s_to_direction(acptr->direction, from, batch, base_mtags, cmd, targetstr, ref);
}
}
}
/** Send multiline batch to remote server (user-to-user target) */
static void multiline_send_s2s_user(Client *from, MultilineBatch *batch, Client *target,
MessageTag *base_mtags, const char *cmd)
{
char ref[BATCHLEN+1];
generate_batch_id(ref);
multiline_send_s2s_to_direction(target->direction, from, batch, base_mtags, cmd, target->id, ref);
}
/* ===================== S2S RECEIVING ===================== */
/** Find an active S2S batch by sender and batch_id */
static S2SMultilineBatch *multiline_find_s2s_batch(Client *sender, const char *batch_id)
{
S2SMultilineBatch *s2s;
for (s2s = s2s_batches; s2s; s2s = s2s->next)
{
if (s2s->sender == sender && !strcmp(s2s->batch_id, batch_id))
return s2s;
}
return NULL;
}
/** Free an S2S batch and remove from the global list */
static void multiline_free_s2s_batch(S2SMultilineBatch *s2s)
{
if (!s2s)
return;
DelListItem(s2s, s2s_batches);
safe_free(s2s->target);
free_message_tags(s2s->first_mtags);
multiline_free_lines(s2s->lines);
safe_free(s2s);
}
/** Free all S2S batches (called when module is permanently removed) */
static void multiline_free_s2s_batches_all(ModData *m)
{
S2SMultilineBatch *s2s, *s2s_next;
if (!m->ptr)
return;
s2s_batches = m->ptr;
for (s2s = s2s_batches; s2s; s2s = s2s_next)
{
s2s_next = s2s->next;
multiline_free_s2s_batch(s2s);
}
s2s_batches = NULL;
m->ptr = NULL;
}
/** Handle an incoming S2S multiline batch open */
static void multiline_handle_s2s_batch_open(Client *client, MessageTag *recv_mtags,
int parc, const char *parv[], int is_channel)
{
S2SMultilineBatch *s2s;
const char *ref;
const char *target;
/* BATCH <target> +ref draft/multiline */
target = parv[1];
ref = parv[2] + 1; /* skip '+' */
/* Check for duplicate */
if (multiline_find_s2s_batch(client, ref))
return; /* Duplicate, ignore */
s2s = safe_alloc(sizeof(S2SMultilineBatch));
strlcpy(s2s->batch_id, ref, sizeof(s2s->batch_id));
safe_strdup(s2s->target, target);
s2s->is_channel = is_channel;
/* Parse STATUSMSG prefix from target (e.g., "@#channel") */
s2s->member_modes[0] = '\0';
if (is_channel)
{
const char *p2 = strchr(target, '#');
if (p2 && (p2 - target > 0))
{
char prefix_tmp[32];
char prefix;
strlncpy(prefix_tmp, target, sizeof(prefix_tmp), p2 - target);
prefix = lowest_ranking_prefix(prefix_tmp);
if (prefix)
{
s2s->member_modes[0] = prefix_to_mode(prefix);
s2s->member_modes[1] = '\0';
}
}
}
s2s->sender = client;
s2s->direction = client->direction;
s2s->sendtype_set = 0;
s2s->line_count = 0;
s2s->received_bytes = 0;
s2s->start_time = TStime();
s2s->lines = NULL;
s2s->lines_tail = NULL;
s2s->first_mtags = NULL;
/* Save message tags from the BATCH open (for relay) */
s2s->first_mtags = duplicate_mtags_excluding(recv_mtags, "batch");
AddListItem(s2s, s2s_batches);
}
/** Handle a line within an S2S multiline batch */
static void multiline_handle_s2s_msg(Client *client, MessageTag *recv_mtags,
int parc, const char *parv[], SendType sendtype)
{
MessageTag *m;
S2SMultilineBatch *s2s;
const char *text;
int is_concat = 0;
m = find_mtag(recv_mtags, "batch");
if (!m || !m->value)
return;
s2s = multiline_find_s2s_batch(client, m->value);
if (!s2s)
return;
/* Set or validate sendtype */
if (!s2s->sendtype_set)
{
s2s->sendtype = sendtype;
s2s->sendtype_set = 1;
} else if (s2s->sendtype != sendtype) {
/* Mismatch - discard batch */
multiline_free_s2s_batch(s2s);
return;
}
text = (parc >= 3 && parv[2]) ? parv[2] : "";
/* Check for concat tag */
if (find_mtag(recv_mtags, "draft/multiline-concat"))
is_concat = 1;
/* Check limits (use hardcoded max, the originating server already enforces per-user limits) */
{
int add_bytes = multiline_calc_add_bytes(strlen(text), s2s->line_count, is_concat);
if ((s2s->line_count >= MULTILINE_MAX_CONFIGURABLE_LINES) ||
(s2s->received_bytes + add_bytes > MULTILINE_MAX_CONFIGURABLE_BYTES))
{
/* Over limit - discard batch */
multiline_free_s2s_batch(s2s);
return;
}
s2s->received_bytes += add_bytes;
}
/* Save first line's mtags for relay (msgid, time, etc.) */
if (s2s->line_count == 0 && !s2s->first_mtags)
{
MessageTag *mtag_iter;
for (mtag_iter = recv_mtags; mtag_iter; mtag_iter = mtag_iter->next)
{
if (!strcmp(mtag_iter->name, "batch"))
continue;
if (!strcmp(mtag_iter->name, "draft/multiline-concat"))
continue;
MessageTag *dup = duplicate_mtag(mtag_iter);
AddListItem(dup, s2s->first_mtags);
}
}
/* Buffer the line */
multiline_append_line(&s2s->lines, &s2s->lines_tail, &s2s->line_count, text, is_concat);
}
/** Handle an S2S multiline batch close */
static void multiline_handle_s2s_batch_close(Client *client, const char *batch_id)
{
S2SMultilineBatch *s2s = multiline_find_s2s_batch(client, batch_id);
if (!s2s)
return;
if (s2s->line_count == 0)
{
multiline_free_s2s_batch(s2s);
return;
}
multiline_deliver_s2s_batch(s2s);
multiline_free_s2s_batch(s2s);
}
/** Deliver a completed S2S multiline batch to local members and relay further */
static void multiline_deliver_s2s_batch(S2SMultilineBatch *s2s)
{
const char *cmd = sendtype_to_cmd(s2s->sendtype);
MessageTag *mtags = NULL;
MultilineBatch *batch = safe_alloc(sizeof(MultilineBatch));
/* Build a temporary MultilineBatch struct for the sending helpers */
strlcpy(batch->batch_id, s2s->batch_id, sizeof(batch->batch_id));
batch->target = s2s->target;
batch->sendtype = s2s->sendtype;
strlcpy(batch->member_modes, s2s->member_modes, sizeof(batch->member_modes));
batch->lines = s2s->lines;
batch->lines_tail = s2s->lines_tail;
batch->line_count = s2s->line_count;
batch->received_bytes = s2s->received_bytes;
if (s2s->is_channel)
{
Channel *channel = multiline_find_channel(s2s->target);
char expanded_modes[64];
const char *filter_modes = NULL;
if (channel)
{
/* Expand member_modes for STATUSMSG filtering */
if (s2s->member_modes[0])
{
channel_member_modes_generate_equal_or_greater(s2s->member_modes, expanded_modes, sizeof(expanded_modes));
filter_modes = expanded_modes;
}
/* Generate outgoing mtags from the first line's tags */
new_message(s2s->sender, s2s->first_mtags, &mtags);
/* Deliver to local members */
multiline_deliver_to_local_members(channel, s2s->sender, batch, mtags,
s2s->target, cmd, filter_modes, NULL, 0);
/* Relay to further server directions (excluding where it came from) */
multiline_send_s2s_channel(s2s->sender, batch, channel, mtags, cmd, s2s->direction, s2s->target);
multiline_run_chanmsg_hooks(s2s->sender, channel, SEND_ALL,
s2s->member_modes[0] ? s2s->member_modes : NULL,
s2s->target, mtags, s2s->lines, s2s->sendtype);
}
} else {
/* User-to-user */
Client *target = find_client(s2s->target, NULL);
if (target)
{
new_message(s2s->sender, s2s->first_mtags, &mtags);
if (MyUser(target))
{
if (HasMultiline(target))
multiline_send_batch_to_client(target, s2s->sender, batch, mtags, target->name, cmd);
else
multiline_send_fallback_to_client(target, s2s->sender, batch, mtags, target->name, cmd);
} else {
/* Relay further */
multiline_send_s2s_user(s2s->sender, batch, target, mtags, cmd);
}
multiline_run_usermsg_hooks(s2s->sender, target, mtags, s2s->lines, s2s->sendtype);
}
}
/* Clean up - NULL out borrowed pointers before freeing */
batch->target = NULL;
batch->lines = NULL;
batch->lines_tail = NULL;
multiline_free_batch(batch);
free_message_tags(mtags);
}
/* ===================== TIMEOUT AND CLEANUP ===================== */
/** Periodic timer: check for timed-out multiline batches */
EVENT(multiline_timeout_check)
{
Client *client;
S2SMultilineBatch *s2s, *s2s_next;
/* Check local client batches */
list_for_each_entry(client, &lclient_list, lclient_node)
{
MultilineBatch *batch;
if (!IsUser(client))
continue;
batch = moddata_local_client(client, multiline_md).ptr;
if (batch && (TStime() - batch->start_time > cfg.batch_timeout))
{
sendto_one(client, NULL, ":%s FAIL BATCH TIMEOUT %s :Batch timed out", me.name, batch->batch_id);
multiline_abort_batch(client, batch);
}
}
/* Check S2S batches */
for (s2s = s2s_batches; s2s; s2s = s2s_next)
{
s2s_next = s2s->next;
if (TStime() - s2s->start_time > cfg.batch_timeout)
{
multiline_free_s2s_batch(s2s);
}
}
}
/** Hook: local client disconnecting */
int multiline_close_connection(Client *client)
{
/* ModData free callback handles cleanup, but we might need
* additional cleanup here in the future.
*/
return 0;
}
/** Hook: remote user quitting */
int multiline_remote_quit(Client *client, MessageTag *mtags, const char *comment)
{
S2SMultilineBatch *s2s, *s2s_next;
for (s2s = s2s_batches; s2s; s2s = s2s_next)
{
s2s_next = s2s->next;
if (s2s->sender == client)
multiline_free_s2s_batch(s2s);
}
return 0;
}
/** Hook: server disconnecting */
int multiline_server_quit(Client *client, MessageTag *mtags)
{
S2SMultilineBatch *s2s, *s2s_next;
for (s2s = s2s_batches; s2s; s2s = s2s_next)
{
s2s_next = s2s->next;
if (s2s->direction == client)
multiline_free_s2s_batch(s2s);
}
return 0;
}