draft/multiline: inbound BATCH parsing + concat assembly, deliver assembled lines; advertise limits
This commit is contained in:
parent
5a34ec57da
commit
d4c4c1a7d0
6 changed files with 148 additions and 1 deletions
|
|
@ -127,9 +127,40 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
Box::new(Redact),
|
Box::new(Redact),
|
||||||
Box::new(MarkRead),
|
Box::new(MarkRead),
|
||||||
Box::new(Metadata),
|
Box::new(Metadata),
|
||||||
|
Box::new(Batch),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BATCH — the client side of draft/multiline. `BATCH +<ref> draft/multiline
|
||||||
|
/// <target>` opens a batch; the `@batch=<ref>`-tagged PRIVMSG/NOTICE lines are
|
||||||
|
/// buffered (see `Ircd::dispatch`); `BATCH -<ref>` assembles them (honouring
|
||||||
|
/// `draft/multiline-concat`) and delivers each logical line normally.
|
||||||
|
struct Batch;
|
||||||
|
impl Command for Batch {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"BATCH"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let tag = ¶ms[0];
|
||||||
|
if let Some(bref) = tag.strip_prefix('+') {
|
||||||
|
if params.get(1).map(|t| t.as_str()) == Some("draft/multiline") {
|
||||||
|
let target = params.get(2).cloned().unwrap_or_default();
|
||||||
|
s.multiline_open(uid, bref, &target);
|
||||||
|
}
|
||||||
|
} else if let Some(bref) = tag.strip_prefix('-') {
|
||||||
|
if let Some((target, notice, lines)) = s.multiline_close(uid, bref) {
|
||||||
|
for line in lines {
|
||||||
|
deliver(s, uid, &[target.clone(), line], notice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// METADATA — draft/metadata-2. `METADATA <target> <GET|LIST|SET|CLEAR> [args]`.
|
/// METADATA — draft/metadata-2. `METADATA <target> <GET|LIST|SET|CLEAR> [args]`.
|
||||||
/// `<target>` is `*` (self), a nick, or a `#channel`. Anyone may GET/LIST; only the
|
/// `<target>` is `*` (self), a nick, or a `#channel`. Anyone may GET/LIST; only the
|
||||||
/// user themselves or a channel op/oper may SET/CLEAR. Values are public (`*`
|
/// user themselves or a channel op/oper may SET/CLEAR. Values are public (`*`
|
||||||
|
|
|
||||||
16
src/ircd.rs
16
src/ircd.rs
|
|
@ -191,6 +191,22 @@ impl Ircd {
|
||||||
if registered && !matches!(cmd, "PING" | "PONG" | "QUIT") && self.server.user_shunned(uid) {
|
if registered && !matches!(cmd, "PING" | "PONG" | "QUIT") && self.server.user_shunned(uid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// draft/multiline: a PRIVMSG/NOTICE tagged for an open batch is buffered,
|
||||||
|
// not delivered on its own — it's assembled and sent when the BATCH closes.
|
||||||
|
if let Some(bref) = &msg.batch {
|
||||||
|
if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 {
|
||||||
|
let consumed = self.server.multiline_accumulate(
|
||||||
|
uid,
|
||||||
|
bref,
|
||||||
|
cmd == "NOTICE",
|
||||||
|
&msg.params[1],
|
||||||
|
msg.concat,
|
||||||
|
);
|
||||||
|
if consumed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// module pre-command gate
|
// module pre-command gate
|
||||||
for m in &mut self.modules {
|
for m in &mut self.modules {
|
||||||
if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny {
|
if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny {
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,8 @@ impl Server {
|
||||||
params: msg.params[2..].to_vec(),
|
params: msg.params[2..].to_vec(),
|
||||||
ctags: String::new(),
|
ctags: String::new(),
|
||||||
label: None,
|
label: None,
|
||||||
|
batch: None,
|
||||||
|
concat: false,
|
||||||
};
|
};
|
||||||
self.on_link(from, &sub);
|
self.on_link(from, &sub);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ pub struct Message {
|
||||||
/// The IRCv3 `label` tag value, if the client tagged this command (for
|
/// The IRCv3 `label` tag value, if the client tagged this command (for
|
||||||
/// labeled-response); `None` otherwise.
|
/// labeled-response); `None` otherwise.
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
|
/// The `batch` tag value (which client batch this line belongs to), for
|
||||||
|
/// inbound draft/multiline.
|
||||||
|
pub batch: Option<String>,
|
||||||
|
/// Whether the line carried the `draft/multiline-concat` tag (join to the
|
||||||
|
/// previous multiline part with no newline).
|
||||||
|
pub concat: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
impl Message {
|
||||||
|
|
@ -47,6 +53,8 @@ pub fn parse(line: &str) -> Option<Message> {
|
||||||
// IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest.
|
// IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest.
|
||||||
let mut ctags = String::new();
|
let mut ctags = String::new();
|
||||||
let mut label = None;
|
let mut label = None;
|
||||||
|
let mut batch = None;
|
||||||
|
let mut concat = false;
|
||||||
if let Some(after_at) = rest.strip_prefix('@') {
|
if let Some(after_at) = rest.strip_prefix('@') {
|
||||||
let (tags, r) = after_at.split_once(' ')?;
|
let (tags, r) = after_at.split_once(' ')?;
|
||||||
ctags = tags
|
ctags = tags
|
||||||
|
|
@ -58,6 +66,11 @@ pub fn parse(line: &str) -> Option<Message> {
|
||||||
.split(';')
|
.split(';')
|
||||||
.find_map(|t| t.strip_prefix("label="))
|
.find_map(|t| t.strip_prefix("label="))
|
||||||
.map(|v| v.to_string());
|
.map(|v| v.to_string());
|
||||||
|
batch = tags
|
||||||
|
.split(';')
|
||||||
|
.find_map(|t| t.strip_prefix("batch="))
|
||||||
|
.map(|v| v.to_string());
|
||||||
|
concat = tags.split(';').any(|t| t == "draft/multiline-concat");
|
||||||
rest = r.trim_start();
|
rest = r.trim_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +113,8 @@ pub fn parse(line: &str) -> Option<Message> {
|
||||||
params,
|
params,
|
||||||
ctags,
|
ctags,
|
||||||
label,
|
label,
|
||||||
|
batch,
|
||||||
|
concat,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,20 @@ pub struct HistMsg {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Limits advertised in the `draft/multiline` cap and enforced while buffering.
|
||||||
|
pub const MLINE_MAX_BYTES: usize = 4096;
|
||||||
|
pub const MLINE_MAX_LINES: usize = 24;
|
||||||
|
|
||||||
|
/// An in-progress inbound draft/multiline batch — one long client message being
|
||||||
|
/// assembled from several `@batch=`-tagged PRIVMSG/NOTICE lines.
|
||||||
|
pub struct MlineBatch {
|
||||||
|
pub bref: String,
|
||||||
|
pub target: String,
|
||||||
|
pub notice: bool,
|
||||||
|
pub parts: Vec<(String, bool)>, // (text, concat-with-previous-part)
|
||||||
|
pub bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// A recently-departed identity, kept for WHOWAS.
|
/// A recently-departed identity, kept for WHOWAS.
|
||||||
pub struct WhowasEntry {
|
pub struct WhowasEntry {
|
||||||
pub nick: String,
|
pub nick: String,
|
||||||
|
|
@ -152,6 +166,7 @@ pub struct Server {
|
||||||
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
||||||
pub read_markers: HashMap<String, HashMap<String, u64>>, // identity -> target -> read ts (MARKREAD)
|
pub read_markers: HashMap<String, HashMap<String, u64>>, // identity -> target -> read ts (MARKREAD)
|
||||||
pub metadata: HashMap<String, HashMap<String, String>>, // target key -> key -> value (draft/metadata-2)
|
pub metadata: HashMap<String, HashMap<String, String>>, // target key -> key -> value (draft/metadata-2)
|
||||||
|
pub mline: HashMap<Uid, MlineBatch>, // in-progress inbound multiline batches
|
||||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||||
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +213,7 @@ impl Server {
|
||||||
history: HashMap::new(),
|
history: HashMap::new(),
|
||||||
read_markers: HashMap::new(),
|
read_markers: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
|
mline: HashMap::new(),
|
||||||
event_tx,
|
event_tx,
|
||||||
conn_counter,
|
conn_counter,
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +268,64 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open an inbound draft/multiline batch for `uid` (a client assembling one
|
||||||
|
/// long message from several tagged PRIVMSG/NOTICE lines).
|
||||||
|
pub fn multiline_open(&mut self, uid: Uid, bref: &str, target: &str) {
|
||||||
|
self.mline.insert(
|
||||||
|
uid,
|
||||||
|
MlineBatch {
|
||||||
|
bref: bref.to_string(),
|
||||||
|
target: target.to_string(),
|
||||||
|
notice: false,
|
||||||
|
parts: Vec::new(),
|
||||||
|
bytes: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffer one PRIVMSG/NOTICE line into `uid`'s open multiline batch when `bref`
|
||||||
|
/// matches (bounded by the advertised byte/line limits). Returns true if it was
|
||||||
|
/// part of the batch — i.e. it should not be delivered on its own.
|
||||||
|
pub fn multiline_accumulate(
|
||||||
|
&mut self,
|
||||||
|
uid: Uid,
|
||||||
|
bref: &str,
|
||||||
|
notice: bool,
|
||||||
|
text: &str,
|
||||||
|
concat: bool,
|
||||||
|
) -> bool {
|
||||||
|
match self.mline.get_mut(&uid) {
|
||||||
|
Some(mb) if mb.bref == bref => {
|
||||||
|
if mb.parts.len() < MLINE_MAX_LINES && mb.bytes + text.len() <= MLINE_MAX_BYTES {
|
||||||
|
mb.notice = notice;
|
||||||
|
mb.bytes += text.len();
|
||||||
|
mb.parts.push((text.to_string(), concat));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close `uid`'s multiline batch `bref` and return `(target, is_notice, lines)`
|
||||||
|
/// with `concat` parts joined into single logical lines. `None` if no match.
|
||||||
|
pub fn multiline_close(&mut self, uid: Uid, bref: &str) -> Option<(String, bool, Vec<String>)> {
|
||||||
|
match self.mline.get(&uid) {
|
||||||
|
Some(mb) if mb.bref == bref => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
let mb = self.mline.remove(&uid)?;
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
for (text, concat) in mb.parts {
|
||||||
|
if concat && !lines.is_empty() {
|
||||||
|
lines.last_mut().unwrap().push_str(&text);
|
||||||
|
} else {
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((mb.target, mb.notice, lines))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a METADATA target (a nick or `#channel`) to its metadata storage
|
/// Resolve a METADATA target (a nick or `#channel`) to its metadata storage
|
||||||
/// key, or `None` if it doesn't exist. User keys are `u<uid>` (stable across
|
/// key, or `None` if it doesn't exist. User keys are `u<uid>` (stable across
|
||||||
/// nick changes); channel keys are the lowercased name.
|
/// nick changes); channel keys are the lowercased name.
|
||||||
|
|
@ -453,6 +527,7 @@ impl Server {
|
||||||
self.uuid_local.remove(&user.uuid);
|
self.uuid_local.remove(&user.uuid);
|
||||||
self.read_markers.remove(&format!("~{uid}")); // session read-markers (kept if account-keyed)
|
self.read_markers.remove(&format!("~{uid}")); // session read-markers (kept if account-keyed)
|
||||||
self.metadata.remove(&format!("u{uid}")); // per-user metadata
|
self.metadata.remove(&format!("u{uid}")); // per-user metadata
|
||||||
|
self.mline.remove(&uid); // any half-open multiline batch
|
||||||
if user.registered {
|
if user.registered {
|
||||||
self.push_whowas(
|
self.push_whowas(
|
||||||
&user.nick,
|
&user.nick,
|
||||||
|
|
|
||||||
10
src/users.rs
10
src/users.rs
|
|
@ -8,7 +8,7 @@ use std::net::{SocketAddr, TcpStream};
|
||||||
use crate::extensible::Extensible;
|
use crate::extensible::Extensible;
|
||||||
use crate::module::Hook;
|
use crate::module::Hook;
|
||||||
use crate::numeric::*;
|
use crate::numeric::*;
|
||||||
use crate::server::{Server, VERSION};
|
use crate::server::{Server, MLINE_MAX_BYTES, MLINE_MAX_LINES, VERSION};
|
||||||
use crate::socketengine::OutSink;
|
use crate::socketengine::OutSink;
|
||||||
use crate::Uid;
|
use crate::Uid;
|
||||||
|
|
||||||
|
|
@ -101,6 +101,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
||||||
"draft/message-redaction",
|
"draft/message-redaction",
|
||||||
"draft/pre-away",
|
"draft/pre-away",
|
||||||
"draft/metadata-2",
|
"draft/metadata-2",
|
||||||
|
"draft/multiline",
|
||||||
"cap-notify",
|
"cap-notify",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -130,6 +131,7 @@ pub struct Caps {
|
||||||
pub message_redaction: bool, // draft/message-redaction — understands REDACT
|
pub message_redaction: bool, // draft/message-redaction — understands REDACT
|
||||||
pub pre_away: bool, // draft/pre-away — may set AWAY before registration
|
pub pre_away: bool, // draft/pre-away — may set AWAY before registration
|
||||||
pub metadata: bool, // draft/metadata-2 — wants metadata + change notices
|
pub metadata: bool, // draft/metadata-2 — wants metadata + change notices
|
||||||
|
pub multiline: bool, // draft/multiline — may send multiline message batches
|
||||||
pub cap_notify: bool,
|
pub cap_notify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +152,10 @@ impl Caps {
|
||||||
} else {
|
} else {
|
||||||
"sasl=PLAIN".to_string()
|
"sasl=PLAIN".to_string()
|
||||||
}
|
}
|
||||||
|
} else if *c == "draft/multiline" && cap302 {
|
||||||
|
format!(
|
||||||
|
"draft/multiline=max-bytes={MLINE_MAX_BYTES},max-lines={MLINE_MAX_LINES}"
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(*c).to_string()
|
(*c).to_string()
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +187,7 @@ impl Caps {
|
||||||
"draft/message-redaction" => self.message_redaction,
|
"draft/message-redaction" => self.message_redaction,
|
||||||
"draft/pre-away" => self.pre_away,
|
"draft/pre-away" => self.pre_away,
|
||||||
"draft/metadata-2" => self.metadata,
|
"draft/metadata-2" => self.metadata,
|
||||||
|
"draft/multiline" => self.multiline,
|
||||||
"cap-notify" => self.cap_notify,
|
"cap-notify" => self.cap_notify,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +217,7 @@ impl Caps {
|
||||||
"draft/message-redaction" => &mut self.message_redaction,
|
"draft/message-redaction" => &mut self.message_redaction,
|
||||||
"draft/pre-away" => &mut self.pre_away,
|
"draft/pre-away" => &mut self.pre_away,
|
||||||
"draft/metadata-2" => &mut self.metadata,
|
"draft/metadata-2" => &mut self.metadata,
|
||||||
|
"draft/multiline" => &mut self.multiline,
|
||||||
"cap-notify" => &mut self.cap_notify,
|
"cap-notify" => &mut self.cap_notify,
|
||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue