Closes#5052
Multi-line input is currently sent as one PRIVMSG per line, so a paste
arrives as N unrelated messages. On networks that support
[draft/multiline](https://ircv3.net/specs/extensions/multiline) it can
be sent as a single BATCH and stays one message everywhere.
## Summary
Sending goes through `sayMultiline()` in `plugins/inputs/msg.ts` when
the network advertises limits we can respect (`multilineLimits()`); if
the batch is over max-bytes/max-lines the send falls back to individual
messages, and the local echo follows whichever path was actually taken
so the sender sees what everyone else got. `Client.input()` stops
splitting on newlines when the target network has the cap. Inbound
batches arrive from irc-framework already reassembled and are flagged
`multiline` on the `Msg`, which drives line-by-line rendering (`parse()`
joins lines with `<br>`), one entry per message in the text logs, and a
first-non-empty-line notification preview. `MULTILINE_*` standard
replies get readable text.
No pin bump: `master` already points at the irc-framework commit that
carries multiline (kiwiirc/irc-framework#414).
## Test Plan
- New unit tests for the send path (batch, fallback on max-bytes, no
limits advertised, blank lines), the text log format, `parse()`, and the
standard replies.
- Ran the server against a local IRCd advertising `draft/multiline` and
confirmed on the wire: a 3-line message goes out as one `BATCH …
draft/multiline` with three frames; with the cap advertised bare (no
limits) or with `max-bytes` too small it degrades to three plain
PRIVMSGs and the local echo degrades with it; a CRLF paste sends clean
frames and echoes without stray `\r`; an inbound batch from another user
renders as one message.
- Not verified: rendering was checked through unit tests rather than in
a browser.
https://github.com/thelounge/thelounge/pull/5044 added the type + value
to handles, but now we need to persist it in the database so e.g.
mentions work after a server restart.
`msgid` is optional
Fixes#5103
Today, `getMessages()` filters by `(network, channel)` but sorts by
`time DESC, id DESC` and no index covers both (and a new one supersedes
`network_channel`). This has always been an issue but
https://github.com/thelounge/thelounge/pull/5055 made this code
synchronous which exposed it.
| | Before | After |
|--------|--------|--------|
| history load, 5 channels (4.4M rows total, busiest 3M) | 6.9s, event
loop blocked | 80ms |
| single channel with 3M messages | 4.6s | 18ms |
| query plan | index scan + temp B-tree sort | index only |
When sending a message that is too long, thelounge will split it up into
multiple PRIVMSGs. These will be saved to the sqlite database with
identical timestamps. Thelounge retrieves messages from the DB using
`ORDER BY time DESC`, which under certain circumstances causes these
messages to be loaded and displayed in the wrong order. This PR
addresses that by also ordering by id (only affects messages with
identical timestamps).
Before:
<img width="1050" height="107" alt="image"
src="https://github.com/user-attachments/assets/54fac085-a989-48fa-9538-1faecb09aa44"
/>
After:
<img width="1058" height="114" alt="image"
src="https://github.com/user-attachments/assets/0bb99db9-5a5d-481d-b799-87162d0535e8"
/>
Apparently sqlite started validating this in certain versions.
Crashes on BSDs with:
SQLite version 3.50.4 2025-07-30 19:33:53
sqlite> SELECT msg, type, time, network, channel FROM messages WHERE type = "message";
Parse error: no such column: "message" - should this be a string literal in single-quotes?
time, network, channel FROM messages WHERE type = "message";
error here ---^
This is laying the foundation to build a cleaning task that's
sort of database agnostic.
All calls are done by acting on a "DeletionRequest" so interpretation
of the config will go through a single point
This enables db migrations to be undone, or "down migrated".
The down migration shouldn't be done automatically
as it could lead to severe data loss if that were done.
Hence, we still hard fail if we encounter a version lower than what
we have in the DB.
A CLI will be added in a later commit that allows users to explicitly
do that.
We want primary keys to never get re-used to so that we
can implement jump to messages / context fetching etc
in the future.
This isn't hooked up yet at all to the rest of the code, only
the schema is changed
This sets up the testing infrastructure to test migrations we are
doing.
It's done on a in memory database directly, we are only interested
in the statements themselves and it's easier than to try and
inject a prepared db into the store.
We do add some dummy data though to make sure we actually execute
the things as we expect.
Prior to this, the search is still racy but one tends to notice
this only when the DB is large or network is involved.
The user can initiate a search, get bored, navigate to another chan
issue a different search.
Now however, the results of the first search come back in and
hilarity ensues as we are now confused with the state.
To avoid this, keep track of the last search done and any result
that comes in that isn't equal to the active query is garbage and
can be dropped.
The only reason we accepted a client was that so we have access
to the next message id when we need it.
So let's accept an id provider function instead.
The interface should not contain things that aren't the API of the
storage interface.
Further, rename ISqliteMessageStorage to SearchableMessageStorage,
as that's also an implementation detail.
We'll never have a second sqlite backend, so the name seems
strange.
TL is stupid and doesn't wait for message{Provider,Storage} to
settle before it starts using the store.
While this should be fixed globally, we can hack around the problem
by pushing everything onto the call stack and hope that we'll eventually
finish the setup before we blow the stack.
Message stores are more complicated that a sync "fire and forget"
API allows for.
For starters, non trivial stores (say sqlite) can fail during init
and we want to be able to catch that.
Second, we really need to be able to run migrations and such, which
may block (and fail) the activation of the store.
On the plus side, this pushes error handling to the caller rather
than the stores, which is a good thing as that allows us to eventually
push this to the client in the UI, rather than just logging it in the
server on stdout