@wirecat/cli-core 0.380.0 → 0.381.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,10 +7,42 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.381.0 — 22.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Meeting search finds words speech recognition cut off.** A stored word of four letters or more that begins a
15
+ query word, and that the Russian dictionary does not know, widens it: «може» for «может», «напри» for «например».
16
+ Real shorter words («после» for «последующих») still do not. This is ztx's rule, behind the dictionary.
17
+
18
+ - **People's reactions in the store (store version 26).** For a messenger that sends one person's reaction change at a
19
+ time (WhatsApp), a `watch` event `{ event: "person_reaction", chatId, chatTitle, messageId, reaction }`
20
+ (`PersonReactionEvent`) and a pushed batch's `reactions` (`HistoryReaction`) carry `PersonReaction`
21
+ (`{ person, own, reaction, reactedAt }`, `reaction: null` a take-back). `store.messages.saveReaction` and
22
+ `saveMessages({ reactions })` keep each person's newest in the new `message_reactions` table, so an older history
23
+ push never undoes a newer live change, and write the message's `reactions` totals from them: readers, output and
24
+ MCP see the same shape. tg and max keep sending totals; a message with people's reactions keeps the derived ones.
25
+ See [adapters](docs/dev/ADAPTERS.md).
26
+
27
+ ### Fixed
28
+
29
+ - **A chat or person is found by a name copied with its emoji.** A copied emoji often carries an invisible mark
30
+ (U+FE0F, U+FE0E, or the U+200D joiner) that a typed one lacks, so "Семья❤️" did not find the chat "Семья❤". Names
31
+ now compare without those marks and with accented letters in one form, still ignoring case, wherever a chat or a
32
+ person is named (`pickChat`, `pickPerson`, a stored alias).
33
+ - **A store the process may not write is a permission error, not a crash.** Opening the store for writing where the
34
+ file or its folder is read-only (`EROFS`, `EACCES`, `EPERM`) was reported as `internal_error` "this is a bug".
35
+ It is now `permission_error` (exit 5) with reason `store.not-writable`, and the message names no path.
36
+
10
37
  ## 0.380.0 — 21.10.2026
11
38
 
12
39
  ### Added
13
40
 
41
+ - **A file's copy and the provider's copy of one meeting are one record.** `MeetingSave.fromFile` marks a save read
42
+ from a file; it joins the provider's record of the same occurrence (same series key, start within 15 minutes)
43
+ and only fills a missing transcript there. A provider record saved after a file takes over that record and its
44
+ key, and a provider transcript supersedes transcripts with source `file`. Two provider records never merge.
45
+ See [meetings](docs/dev/MEETINGS.md).
14
46
  - **Meetings import text transcripts.** `parseTranscriptText` reads the text form zoom-searcher wrote: the header
15
47
  (`Встреча:` title, `Дата:` start as written, `Zoom ID:`, `Говорят:` speakers) and `[hh:mm:ss] Speaker: text`
16
48
  blocks, a block running over several lines. `ImportFile.format: "text"` makes `importFiles` use it; `vtt` stays
@@ -18,6 +50,7 @@ shape of this file.
18
50
  - **An import says the zone it read file times in, and a host can explain its file names.** `RunReport.timezone` is
19
51
  set when every imported file's meeting carries one zone; `MeetingCommandDeps.importHelp` adds the host's note
20
52
  (file name patterns, how their time is read) under `meetings import --help`.
53
+ ||||||| Stash base
21
54
  - **Meeting search finds words spread over neighbouring lines.** `"циан риелтор"~3` in a meeting query finds a
22
55
  transcript line with one of the words when the others are at most three lines away in the same transcript; `~0`
23
56
  is one line, any order; at most 20. The words widen to garbled spellings as single words do. It needs
@@ -131,7 +131,7 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
131
131
  else if (event.event === "delete") {
132
132
  context.streams.data(`deleted in ${title(event.chatId)}: message ${event.messageId}\n`);
133
133
  }
134
- else if (event.event === "reaction") {
134
+ else if (event.event === "reaction" || event.event === "person_reaction") {
135
135
  context.streams.data(`reactions in ${title(event.chatId)} on message ${event.messageId}\n`);
136
136
  }
137
137
  else if (event.event === "vote") {
@@ -11,7 +11,7 @@ export { callMeetingSeriesInventoryTool, meetingSeriesInventoryTool } from "../m
11
11
  export { registerLinkConversationsPrompt } from "../mcp/prompts.js";
12
12
  export { agentBody, agentJson, agentResult, UNTRUSTED, UNTRUSTED_INSTRUCTION } from "../mcp/text.js";
13
13
  export { type AppIdentity, envName, readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
14
- export type { AccountEditing, AccountHealth, AccountRecords, AccountSettings, AccountStanding, AccountTools, After, BotChats, ChannelComments, ChatDeletion, ChatFolders, ChatReading, ContactBook, Download, ForumControl, ForumState, GroupAdmin, GroupModeration, HistoryBatch, HistoryChatEvent, HistoryStar, HistoryVote, InviteLinks, JoinRequests, LiveUpdates, MessageButtons, MessageEditing, MessageMedia, MessagePermalinks, MessagePins, MessagePolls, MessageReactions, MessageSearch, MessageStars, MessengerAdapter, MessengerCore, NewPoll, OfficialStats, PassiveUpdates, PersonProfiles, ProfileChange, ProfilePhotos, PushedHistory, ReadState, RemoteFile, ScheduledMessages, SenderIdentities, SenderSearch, SendOptions, Sent, ServerQuery, ServerReads, ThreadAddressing, TopicEditing, TopicHistory, TopicReadState, Transcript, } from "../ports/messenger.js";
14
+ export type { AccountEditing, AccountHealth, AccountRecords, AccountSettings, AccountStanding, AccountTools, After, BotChats, ChannelComments, ChatDeletion, ChatFolders, ChatReading, ContactBook, Download, ForumControl, ForumState, GroupAdmin, GroupModeration, HistoryBatch, HistoryChatEvent, HistoryReaction, HistoryStar, HistoryVote, InviteLinks, JoinRequests, LiveUpdates, MessageButtons, MessageEditing, MessageMedia, MessagePermalinks, MessagePins, MessagePolls, MessageReactions, MessageSearch, MessageStars, MessengerAdapter, MessengerCore, NewPoll, OfficialStats, PassiveUpdates, PersonProfiles, ProfileChange, ProfilePhotos, PushedHistory, ReadState, RemoteFile, ScheduledMessages, SenderIdentities, SenderSearch, SendOptions, Sent, ServerQuery, ServerReads, ThreadAddressing, TopicEditing, TopicHistory, TopicReadState, Transcript, } from "../ports/messenger.js";
15
15
  export type { Fetching, MediaOption, MessengerTraits } from "../ports/traits.js";
16
16
  export { senderFacts } from "../replies/dry-run.js";
17
17
  export { type ReplyRender, replyRenderer } from "../replies/rendering.js";
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { capability, throughWrapper } from "../../ports/messenger.js";
3
- import { isId, pickPerson } from "../../resolve.js";
3
+ import { foldName, isId, pickPerson } from "../../resolve.js";
4
4
  import { codeOf } from "../../sends/guarded.js";
5
5
  import { chatIdIn } from "../../services/message-resolution.js";
6
6
  const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
@@ -212,8 +212,8 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
212
212
  const { id, held } = namesFromStore ? await storedChat(opened, scoped, namesFromStore, reference) : {};
213
213
  if (!isId(reference)) {
214
214
  const people = await opened.people.people(scoped.provider, { account: scoped.account });
215
- const wanted = reference.trim().toLowerCase();
216
- if (people.all().some((person) => person.alias?.toLowerCase().includes(wanted))) {
215
+ const wanted = foldName(reference.trim());
216
+ if (people.all().some((person) => person.alias && foldName(person.alias).includes(wanted))) {
217
217
  const person = pickPerson(reference, people);
218
218
  const chats = (await opened.chats.chatsWith(scoped, person.id)).filter((chat) => chat.kind === "dialog");
219
219
  if (chats.length !== 1 || !chats[0])
@@ -517,7 +517,7 @@ const fill = async (store, account, batch) => {
517
517
  await store.people.savePeople(account, batch.people);
518
518
  const byChat = new Map();
519
519
  const of = (chatId) => {
520
- const held = byChat.get(chatId) ?? { messages: [], votes: [], stars: [] };
520
+ const held = byChat.get(chatId) ?? { messages: [], votes: [], reactions: [], stars: [] };
521
521
  byChat.set(chatId, held);
522
522
  return held;
523
523
  };
@@ -525,10 +525,12 @@ const fill = async (store, account, batch) => {
525
525
  of(message.chatId).messages.push(message);
526
526
  for (const { chatId, messageId, vote } of batch.votes ?? [])
527
527
  of(chatId).votes.push({ messageId, vote });
528
+ for (const { chatId, messageId, reaction } of batch.reactions ?? [])
529
+ of(chatId).reactions.push({ messageId, reaction });
528
530
  for (const { chatId, messageId, starred } of batch.stars ?? [])
529
531
  of(chatId).stars.push({ messageId, starred });
530
- for (const [chatId, { messages, votes, stars }] of byChat)
531
- await store.messages.saveMessages(account, chatId, messages, { via: "history", votes, stars });
532
+ for (const [chatId, { messages, votes, reactions, stars }] of byChat)
533
+ await store.messages.saveMessages(account, chatId, messages, { via: "history", votes, reactions, stars });
532
534
  const changes = new Map();
533
535
  for (const { chatId, change } of batch.events ?? [])
534
536
  changes.set(chatId, [...(changes.get(chatId) ?? []), change]);
@@ -537,7 +539,7 @@ const fill = async (store, account, batch) => {
537
539
  };
538
540
  /**
539
541
  * Each change as the store keeps it: a message or an edit upserted, a deletion a tombstone, reactions replaced, a
540
- * voter's vote replacing their older one, a star set or taken off, a chat event kept once.
542
+ * voter's vote or a person's reaction replacing their older one, a star set or taken off, a chat event kept once.
541
543
  */
542
544
  const keep = async (store, account, event, seenAt, among) => {
543
545
  switch (event.event) {
@@ -553,6 +555,9 @@ const keep = async (store, account, event, seenAt, among) => {
553
555
  case "vote":
554
556
  await store.messages.saveVote(account, event.chatId, event.messageId, event.vote);
555
557
  return;
558
+ case "person_reaction":
559
+ await store.messages.saveReaction(account, event.chatId, event.messageId, event.reaction);
560
+ return;
556
561
  case "star":
557
562
  await store.messages.saveStar(account, event.chatId, event.messageId, event.starred);
558
563
  return;
@@ -110,6 +110,12 @@ const describe = (event, render) => {
110
110
  const choice = answers.length > 0 ? answers.join(", ") : "took it back";
111
111
  return `vote in ${event.chatTitle ?? event.chatId} on poll ${event.messageId}: ${person.name ?? person.id} — ${choice}\n`;
112
112
  }
113
+ case "person_reaction": {
114
+ const { person, reaction } = event.reaction;
115
+ const who = person.name ?? person.id;
116
+ const what = reaction === null ? "took their reaction back" : `reacted ${reaction}`;
117
+ return `${who} ${what} in ${event.chatTitle ?? event.chatId} on message ${event.messageId}\n`;
118
+ }
113
119
  case "star":
114
120
  return `${event.starred ? "starred" : "unstarred"} in ${event.chatTitle ?? event.chatId}: message ${event.messageId}\n`;
115
121
  case "chat_event": {
@@ -263,6 +263,19 @@ export interface Reactions {
263
263
  mine: string | null;
264
264
  total: number;
265
265
  }
266
+ /**
267
+ * One person's reaction to a message, for a messenger that sends a reaction change at a time (WhatsApp) instead of
268
+ * totals. `reaction` is `null` when they took it back. The store keeps each person's newest by `reactedAt` and derives
269
+ * the message's `Reactions` from them.
270
+ */
271
+ export interface PersonReaction {
272
+ person: Member;
273
+ /** The owner's own reaction, which becomes `Reactions.mine`. */
274
+ own: boolean;
275
+ reaction: string | null;
276
+ /** ISO 8601 — when the person reacted, as the messenger says, not when it arrived. */
277
+ reactedAt: string;
278
+ }
266
279
  /** The message a reply answers or a forward carries. */
267
280
  export interface QuotedMessage {
268
281
  id: Id;
@@ -384,6 +397,14 @@ export interface PollVoteEvent {
384
397
  messageId: Id;
385
398
  vote: PollVote;
386
399
  }
400
+ /** One person reacted to `messageId`, changed their reaction, or took it back; saved, an older one never wins. */
401
+ export interface PersonReactionEvent {
402
+ event: "person_reaction";
403
+ chatId: Id;
404
+ chatTitle: string | null;
405
+ messageId: Id;
406
+ reaction: PersonReaction;
407
+ }
387
408
  /** The owner starred a message or took the star off, here or on another device; saved, the same twice is one. */
388
409
  export interface MessageStarEvent {
389
410
  event: "star";
@@ -399,11 +420,11 @@ export interface ChatEventNotice {
399
420
  chatTitle: string | null;
400
421
  change: ChatEvent;
401
422
  }
402
- /** What a listening connection reports: a new message, a change to one, a vote, a star, or a change to a chat. */
423
+ /** What a listening connection reports: a new message, a change to one, a vote, a reaction, a star, or a change to a chat. */
403
424
  export type MessageEvent = {
404
425
  event: "message";
405
426
  message: MessageHit;
406
- } | MessageChange | PollVoteEvent | MessageStarEvent | ChatEventNotice;
427
+ } | MessageChange | PollVoteEvent | PersonReactionEvent | MessageStarEvent | ChatEventNotice;
407
428
  /** One chat's share of `inbox`: other people's messages since the last check, oldest first. */
408
429
  export interface InboxChat extends Pick<Chat, "id" | "title" | "kind" | "unreadCount"> {
409
430
  messages: Message[];
@@ -27,6 +27,7 @@ const OTHERS = [
27
27
  validation("file.locked", "another process holds the file's lock"),
28
28
  ["profile.wrong-account", "authentication_error", "the signed-in source account does not match this profile"],
29
29
  validation("store.refused", "the local store refused the document text"),
30
+ ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
30
31
  validation("catalog.scope-limit", "the selected catalog exceeds its entry limit"),
31
32
  validation("evidence.row-too-large", "one evidence row is over its size limit"),
32
33
  validation("index.not-ready", "the search index is still being built"),
@@ -15,6 +15,7 @@ export const importFiles = async (files, store, now = Date.now()) => {
15
15
  participants: file.participants,
16
16
  transcripts: [await transcriptInput(lines, participants, file.source ?? "file", file.content, format)],
17
17
  now,
18
+ fromFile: true,
18
19
  });
19
20
  await linkEvent(store, details, now);
20
21
  report.meetings++;
@@ -36,6 +36,12 @@ export interface MeetingSave {
36
36
  summaries?: Omit<NewRecord<Summary>, "meetingId">[];
37
37
  attachments?: Omit<NewRecord<Attachment>, "attachableType" | "attachableId">[];
38
38
  now: number;
39
+ /**
40
+ * A copy of the occurrence read from a file, not from the provider. It joins the provider's record of the same
41
+ * occurrence — same series, start within 15 minutes — and only fills a missing transcript there; a provider
42
+ * record saved later takes over a record made from files. Transcripts from files have source `file`.
43
+ */
44
+ fromFile?: boolean;
39
45
  }
40
46
  export interface MeetingDetails {
41
47
  meeting: Meeting;
@@ -3,7 +3,7 @@ import type { CounterField, CounterObservations } from "../domain/counters.js";
3
3
  import type { HtmlFormatting, MarkdownFormatting, TextSpan } from "../domain/formatting.js";
4
4
  import type { Markup } from "../domain/markdown.js";
5
5
  import type { MessagePermalink } from "../domain/message-link.js";
6
- import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
6
+ import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PersonReaction, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
7
7
  /** One attachment's bytes, fetched over the adapter's connection when `bytes` is read — so read it before the command closes. */
8
8
  export interface RemoteFile {
9
9
  kind: string;
@@ -331,6 +331,12 @@ export interface HistoryVote {
331
331
  messageId: Id;
332
332
  vote: PollVote;
333
333
  }
334
+ /** One person's reaction to message `messageId` of `chatId`, as `PersonReactionEvent` has it. */
335
+ export interface HistoryReaction {
336
+ chatId: Id;
337
+ messageId: Id;
338
+ reaction: PersonReaction;
339
+ }
334
340
  /** One change to who is in chat `chatId`, or to its title, as `ChatEventNotice` has it. */
335
341
  export interface HistoryChatEvent {
336
342
  chatId: Id;
@@ -344,8 +350,8 @@ export interface HistoryStar {
344
350
  }
345
351
  /**
346
352
  * What a messenger pushed in one go: any of chats, the people in them, past messages of any chats, votes in
347
- * their polls, the owner's stars, and changes to who is in them. A vote or a star is saved with its chat's messages,
348
- * after them, so a message in the same batch is held before its votes and its star.
353
+ * their polls, each person's reactions, the owner's stars, and changes to who is in them. A vote, a reaction or a star
354
+ * is saved with its chat's messages, after them, so a message in the same batch is held before them.
349
355
  * `rekeys` are chats the messenger now names by another id; they apply first, so the batch's messages under the
350
356
  * new id join the chat already stored under the old one.
351
357
  */
@@ -358,6 +364,7 @@ export interface HistoryBatch {
358
364
  people?: Member[];
359
365
  messages?: Message[];
360
366
  votes?: HistoryVote[];
367
+ reactions?: HistoryReaction[];
361
368
  stars?: HistoryStar[];
362
369
  events?: HistoryChatEvent[];
363
370
  }
package/dist/resolve.d.ts CHANGED
@@ -7,6 +7,12 @@ export interface PeopleLookup {
7
7
  }
8
8
  /** Shaped like a Telegram or MAX id. Another messenger's ids may be any string, so a miss here is not "not an id". */
9
9
  export declare const isId: (reference: string) => boolean;
10
+ /**
11
+ * A name as matching compares it. A copied emoji often carries a presentation mark (U+FE0F, U+FE0E) or a joiner
12
+ * (U+200D) the typed one lacks, and a letter may come composed or as letter plus accent; none of that is a different
13
+ * name.
14
+ */
15
+ export declare const foldName: (name: string) => string;
10
16
  /**
11
17
  * A name matched as a fragment — and **an ambiguous one is an error, not a guess**: sending to the
12
18
  * wrong conversation is not undoable, so the caller is shown the candidates and asked to be
package/dist/resolve.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { CliError, singleLine } from "@wirecat/cli-base";
2
2
  /** Shaped like a Telegram or MAX id. Another messenger's ids may be any string, so a miss here is not "not an id". */
3
3
  export const isId = (reference) => /^-?\d+$/.test(reference.trim());
4
+ /**
5
+ * A name as matching compares it. A copied emoji often carries a presentation mark (U+FE0F, U+FE0E) or a joiner
6
+ * (U+200D) the typed one lacks, and a letter may come composed or as letter plus accent; none of that is a different
7
+ * name.
8
+ */
9
+ export const foldName = (name) => name
10
+ .normalize("NFC")
11
+ .replace(/[\uFE0E\uFE0F\u200D]/g, "")
12
+ .toLowerCase();
4
13
  /**
5
14
  * A name matched as a fragment — and **an ambiguous one is an error, not a guess**: sending to the
6
15
  * wrong conversation is not undoable, so the caller is shown the candidates and asked to be
@@ -8,8 +17,8 @@ export const isId = (reference) => /^-?\d+$/.test(reference.trim());
8
17
  * "Мама" must not quietly take a send meant for "Мама Иванова".
9
18
  */
10
19
  export const pickChat = (reference, chats) => {
11
- const wanted = reference.trim().toLowerCase();
12
- const matches = chats.filter((chat) => chat.title?.toLowerCase().includes(wanted));
20
+ const wanted = foldName(reference.trim());
21
+ const matches = chats.filter((chat) => chat.title !== null && foldName(chat.title).includes(wanted));
13
22
  if (matches.length === 1 && matches[0])
14
23
  return matches[0];
15
24
  if (matches.length === 0)
@@ -27,9 +36,9 @@ export const pickPerson = (reference, people) => {
27
36
  return known;
28
37
  if (isId(trimmed))
29
38
  throw new CliError("not_found", `no person ${trimmed} in what this account has seen`);
30
- const wanted = trimmed.replace(/^@/, "").toLowerCase();
39
+ const wanted = foldName(trimmed.replace(/^@/, ""));
31
40
  const everyone = people.all().toSorted((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
32
- const fields = (person) => [person.alias, person.name, person.username].filter(isPresent).map((one) => one.toLowerCase());
41
+ const fields = (person) => [person.alias, person.name, person.username].filter(isPresent).map(foldName);
33
42
  const exact = everyone.filter((person) => fields(person).includes(wanted));
34
43
  const matches = exact.length > 0 ? exact : everyone.filter((person) => fields(person).some((one) => one.includes(wanted)));
35
44
  if (matches.length === 1 && matches[0])
@@ -13,7 +13,8 @@ export declare const plainMatch: (probe: GarbledProbe, word: string, stems: stri
13
13
  * stemmer cut differently: a stem one side extends counts only when the other side's word spells the extension
14
14
  * out — «циана» (stem `циа`) reaches «цианом» (`циан`), «устав» (`уста`) does not reach «установить» (`установ`).
15
15
  * `1` is a spelling recognition may have garbled, compared by stem: «рилторов» is three edits from «риелтор»
16
- * as a word but one as a stem.
16
+ * as a word but one as a stem; or a word recognition cut off, of four letters or more, that begins the query word
17
+ * («може» for «может»), as ztx matched.
17
18
  */
18
19
  export declare const garbledDistance: (probe: GarbledProbe, word: string, stems: string[]) => number | null;
19
20
  /**
@@ -34,7 +34,8 @@ export const plainMatch = (probe, word, stems) => word.startsWith(probe.literal)
34
34
  * stemmer cut differently: a stem one side extends counts only when the other side's word spells the extension
35
35
  * out — «циана» (stem `циа`) reaches «цианом» (`циан`), «устав» (`уста`) does not reach «установить» (`установ`).
36
36
  * `1` is a spelling recognition may have garbled, compared by stem: «рилторов» is three edits from «риелтор»
37
- * as a word but one as a stem.
37
+ * as a word but one as a stem; or a word recognition cut off, of four letters or more, that begins the query word
38
+ * («може» for «может»), as ztx matched.
38
39
  */
39
40
  export const garbledDistance = (probe, word, stems) => {
40
41
  if (probe.literal.length < SHORTEST_GARBLED || plainMatch(probe, word, stems))
@@ -59,6 +60,8 @@ export const garbledDistance = (probe, word, stems) => {
59
60
  if (distance <= 1)
60
61
  best = distance;
61
62
  }
63
+ if (best === null && stored.length >= SHORTEST_GARBLED && literal.startsWith(stored))
64
+ return 1;
62
65
  return best;
63
66
  };
64
67
  /** `editDistance` from `stem` to the nearest beginning of `word`, or `max + 1` once every beginning is further. */
@@ -88,6 +91,8 @@ const prefixDistance = (stem, word, max) => {
88
91
  */
89
92
  const worthStemming = (probe, word) => {
90
93
  const stored = plain(word);
94
+ if (stored.length >= SHORTEST_GARBLED && plain(probe.literal).startsWith(stored))
95
+ return true;
91
96
  return probe.stems.some((raw) => {
92
97
  const stem = plain(raw);
93
98
  if (stem.length >= 3 && stored.startsWith(stem))
@@ -1,5 +1,5 @@
1
1
  import type { CounterObservations } from "../domain/counters.js";
2
- import type { Id, Message, Page, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
2
+ import type { Id, Message, Page, PersonReaction, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
3
3
  import { type DocumentLinkMessage } from "./sqlite/document-links.js";
4
4
  import type { StoreContext } from "./sqlite/open.js";
5
5
  import type { AccountKey, DeletionScope, IngestedVia, MessageFilter, SenderChatStats, StoredHit } from "./types.js";
@@ -10,7 +10,8 @@ export interface AccountMessages {
10
10
  * `seenAt` is when the messenger was asked: a message it still returned is not deleted, so a
11
11
  * tombstone older than that is lifted. A tombstone set after it stays — the deletion is newer.
12
12
  *
13
- * `votes` and `stars` are saved after the messages in the same transaction, as `saveVote` and `saveStar` save each.
13
+ * `votes`, `reactions` and `stars` are saved after the messages in the same transaction, as `saveVote`,
14
+ * `saveReaction` and `saveStar` save each.
14
15
  *
15
16
  * `typedChatId` is the id the owner wrote to when the messenger answered `chatId`: a chat row made for `chatId`
16
17
  * takes the typed row's title, kind and privacy, and is never more visible than it.
@@ -22,6 +23,10 @@ export interface AccountMessages {
22
23
  messageId: Id;
23
24
  vote: PollVote;
24
25
  }[];
26
+ reactions?: {
27
+ messageId: Id;
28
+ reaction: PersonReaction;
29
+ }[];
25
30
  stars?: {
26
31
  messageId: Id;
27
32
  starred: boolean;
@@ -89,10 +94,18 @@ export interface AccountMessages {
89
94
  chatId?: Id;
90
95
  among?: DeletionScope;
91
96
  }): Promise<number>;
92
- /** A message's reactions as they are now; answers whether the message is held at all. */
97
+ /**
98
+ * A message's reactions as they are now; answers whether the message is held at all. Ignored where the store holds
99
+ * people's own reactions to it (`saveReaction`): the totals come from those.
100
+ */
93
101
  saveReactions(key: AccountKey, chatId: Id, messageId: Id, reactions: Reactions, options?: {
94
102
  observation?: CounterObservations["reactions"];
95
103
  }): Promise<boolean>;
104
+ /**
105
+ * One person's reaction to a message, unless the store holds a newer one of theirs; a `null` reaction is a take-back.
106
+ * The message's `reactions` become the totals of every person's newest, whenever the message is held.
107
+ */
108
+ saveReaction(key: AccountKey, chatId: Id, messageId: Id, reaction: PersonReaction): Promise<void>;
96
109
  /** The owner's star on a message, or its removal; answers whether the message is held at all. */
97
110
  saveStar(key: AccountKey, chatId: Id, messageId: Id, starred: boolean): Promise<boolean>;
98
111
  /** One voter's vote in the poll a message carries, unless the store holds a newer one of theirs; no answers is a take-back. */
@@ -11,7 +11,7 @@ export const accountMessagesOver = (context) => {
11
11
  const { database } = context;
12
12
  const { accountPk, findAccountPk, findChatPk, chatKeyOf, chatPkFor, answeredChatPk, upsertMessage } = keysOver(context);
13
13
  return {
14
- saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], stars = [], typedChatId }) => write(database, () => {
14
+ saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], reactions = [], stars = [], typedChatId }) => write(database, () => {
15
15
  const accountKey = accountPk(key);
16
16
  const chatKey = typedChatId === undefined ? chatPkFor(accountKey, chatId) : answeredChatPk(accountKey, chatId, typedChatId);
17
17
  for (const message of messages) {
@@ -20,6 +20,8 @@ export const accountMessagesOver = (context) => {
20
20
  }
21
21
  for (const { messageId, vote } of votes)
22
22
  messageWrites.saveVote(context, accountKey, key.provider, chatKey, messageId, vote);
23
+ for (const { messageId, reaction } of reactions)
24
+ messageWrites.saveReaction(context, accountKey, key.provider, chatKey, messageId, reaction);
23
25
  for (const { messageId, starred } of stars)
24
26
  messageWrites.saveStar(context, chatKey, messageId, starred);
25
27
  }),
@@ -83,6 +85,10 @@ export const accountMessagesOver = (context) => {
83
85
  });
84
86
  return saved;
85
87
  },
88
+ saveReaction: async (key, chatId, messageId, reaction) => write(database, () => {
89
+ const accountKey = accountPk(key);
90
+ messageWrites.saveReaction(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, reaction);
91
+ }),
86
92
  saveVote: async (key, chatId, messageId, vote) => write(database, () => {
87
93
  const accountKey = accountPk(key);
88
94
  messageWrites.saveVote(context, accountKey, key.provider, chatPkFor(accountKey, chatId), messageId, vote);
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, asc, eq, inArray, sql } from "./drizzle/core.js";
3
3
  import { pointerPairs } from "./entity-types.js";
4
- import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
4
+ import { accountIdentities, accounts, aliases, botUpdates, chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, projects, syncCursors, syncRanges, syncs, } from "./schema.js";
5
5
  /** An account's row, made on first sight; a name only replaces what was known when there is one. */
6
6
  export const accountPk = ({ orm, now }, { provider, account, scope }, name = null) => Number(orm
7
7
  .insert(accounts)
@@ -67,6 +67,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
67
67
  orm.delete(syncs).where(eq(syncs.accountId, accountKey)).run();
68
68
  orm.delete(messageTranscripts).where(inArray(messageTranscripts.chatId, chatsOf)).run();
69
69
  orm.delete(pollVotes).where(inArray(pollVotes.chatId, chatsOf)).run();
70
+ orm.delete(messageReactions).where(inArray(messageReactions.chatId, chatsOf)).run();
70
71
  orm
71
72
  .delete(chatEventPeople)
72
73
  .where(inArray(chatEventPeople.chatEventId, orm.select({ id: chatEvents.id }).from(chatEvents).where(inArray(chatEvents.chatId, chatsOf))))
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator } from "../../domain/locator.js";
3
3
  import { purgeChats, revisedBy, stricterPrivacy } from "./chats.js";
4
4
  import { pointerPairs } from "./entity-types.js";
5
- import { tombstone } from "./messages.js";
5
+ import { tombstone, writeDerivedReactions } from "./messages.js";
6
6
  import { targetKey } from "./notes.js";
7
7
  import { write } from "./transaction.js";
8
8
  const chatRow = (context, accountKey, chatId) => context.database.prepare("SELECT * FROM chats WHERE account_id = ? AND external_id = ?").get(accountKey, chatId);
@@ -106,6 +106,9 @@ const absorb = (context, kept, gone) => {
106
106
  database
107
107
  .prepare("DELETE FROM poll_votes WHERE chat_id = ? AND EXISTS (SELECT 1 FROM poll_votes g WHERE g.chat_id = ? AND g.message_external_id = poll_votes.message_external_id AND g.identity_id = poll_votes.identity_id AND g.voted_at > poll_votes.voted_at)")
108
108
  .run(kept, gone);
109
+ database
110
+ .prepare("DELETE FROM message_reactions WHERE chat_id = ? AND EXISTS (SELECT 1 FROM message_reactions g WHERE g.chat_id = ? AND g.message_external_id = message_reactions.message_external_id AND g.identity_id = message_reactions.identity_id AND g.reacted_at > message_reactions.reacted_at)")
111
+ .run(kept, gone);
109
112
  // The absorbed copy of an event both chats hold is purged, so the people only it named move first.
110
113
  database
111
114
  .prepare("INSERT OR IGNORE INTO chat_event_people (chat_event_id, identity_id) SELECT k.id, p.identity_id FROM chat_events g JOIN chat_events k ON k.chat_id = ? AND k.source_id = g.source_id JOIN chat_event_people p ON p.chat_event_id = g.id WHERE g.chat_id = ?")
@@ -113,6 +116,7 @@ const absorb = (context, kept, gone) => {
113
116
  foldOpenStays(context, kept, gone);
114
117
  for (const table of [
115
118
  "poll_votes",
119
+ "message_reactions",
116
120
  "chat_revisions",
117
121
  "chat_events",
118
122
  "message_transcripts",
@@ -131,6 +135,10 @@ const absorb = (context, kept, gone) => {
131
135
  database
132
136
  .prepare("DELETE FROM message_transcripts WHERE chat_id = ? AND message_id IN (SELECT id FROM messages WHERE chat_id = ? AND deleted_at IS NOT NULL)")
133
137
  .run(kept, kept);
138
+ for (const { id } of database
139
+ .prepare("SELECT DISTINCT message_external_id AS id FROM message_reactions WHERE chat_id = ?")
140
+ .all(kept))
141
+ writeDerivedReactions(context, kept, id);
134
142
  database.prepare("UPDATE chats SET parent_chat_id = ? WHERE parent_chat_id = ?").run(kept, gone);
135
143
  pointerMover(context, "chat")(gone, kept);
136
144
  purgeChats(context, [gone]);
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, or, sql } from "./drizzle/core.js";
3
3
  import { identityPk } from "./identities.js";
4
4
  import { bound } from "./query-filter.js";
5
- import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
5
+ import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageReactions, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
6
6
  import { ensureTag } from "./tags.js";
7
7
  import { parsed, present, toIso, toMs } from "./values.js";
8
8
  /** The chat's own id, when the store key is a chat of this account. */
@@ -262,6 +262,7 @@ export const purgeChats = ({ orm }, pks) => {
262
262
  .run();
263
263
  orm.delete(messageTranscripts).where(inArray(messageTranscripts.chatId, pks)).run();
264
264
  orm.delete(pollVotes).where(inArray(pollVotes.chatId, pks)).run();
265
+ orm.delete(messageReactions).where(inArray(messageReactions.chatId, pks)).run();
265
266
  orm
266
267
  .delete(chatEventPeople)
267
268
  .where(inArray(chatEventPeople.chatEventId, orm.select({ id: chatEvents.id }).from(chatEvents).where(inArray(chatEvents.chatId, pks))))
@@ -31,6 +31,7 @@ export const MANIFEST = [
31
31
  { name: "20261018063952_version-24-message-stars-index", version: 24, minCompatible: 1 },
32
32
  { name: "20261019115025_version-25-chat-revisions", version: 25, minCompatible: 1 },
33
33
  { name: "20261019115027_version-25-chat-revisions-triggers", version: 25, minCompatible: 1 },
34
+ { name: "20261021214318_version-26-message-reactions", version: 26, minCompatible: 1 },
34
35
  ];
35
36
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
36
37
  const migrations = [];
@@ -166,14 +166,65 @@ export const saveAttachment = (database, type, parentId, a, now) => upsert(datab
166
166
  content_sha256: a.contentSha256,
167
167
  extracted_at: a.extractedAt,
168
168
  }, now);
169
+ const FILE_SOURCE = "file";
170
+ const SAME_OCCURRENCE_MS = 15 * 60_000;
171
+ /** The account's record of this occurrence under another key: same series, the nearest start within 15 minutes. */
172
+ const sameOccurrence = (database, meeting, seriesKey) => {
173
+ if (!seriesKey || meeting.startedAt === null)
174
+ return undefined;
175
+ const row = database
176
+ .prepare(`SELECT m.id,m.external_id,m.meeting_series_id,
177
+ EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id=m.id AND t.source=?) AND
178
+ NOT EXISTS (SELECT 1 FROM meeting_transcripts t WHERE t.meeting_id=m.id AND t.source!=?) AS from_file
179
+ FROM meetings m JOIN meeting_series s ON s.id=m.meeting_series_id
180
+ WHERE m.account_id=? AND s.account_id=? AND s.external_id=? AND m.deleted_at IS NULL
181
+ AND m.started_at BETWEEN ? AND ? ORDER BY abs(m.started_at-?),m.id LIMIT 1`)
182
+ .get(FILE_SOURCE, FILE_SOURCE, meeting.accountId, meeting.accountId, seriesKey, meeting.startedAt - SAME_OCCURRENCE_MS, meeting.startedAt + SAME_OCCURRENCE_MS, meeting.startedAt);
183
+ return row
184
+ ? {
185
+ id: row.id,
186
+ externalId: row.external_id,
187
+ seriesId: row.meeting_series_id,
188
+ fromFile: Number(row.from_file) === 1,
189
+ }
190
+ : undefined;
191
+ };
192
+ /** A file's copy of an occurrence the provider already gave: its transcript only, and only where there is none. */
193
+ const fillGap = (context, input, meetingId) => {
194
+ const { database } = context;
195
+ const held = database
196
+ .prepare("SELECT 1 FROM meeting_transcripts WHERE meeting_id = ? AND superseded_at IS NULL AND deleted_at IS NULL")
197
+ .get(meetingId);
198
+ if (held || !input.transcripts?.length)
199
+ return meetingId;
200
+ const participantIds = (input.participants ?? []).map(({ identity, ...p }) => upsert(database, "meeting_participants", {
201
+ meeting_id: meetingId,
202
+ identity_id: meetingIdentityPk({ ...context, now: () => input.now }, input.meeting.accountId, identity),
203
+ }, { display_name: p.displayName, email: p.email, role: p.role, external_id: p.externalId }, input.now));
204
+ saveTranscripts(database, meetingId, input.transcripts, (position) => (position === null ? null : (participantIds[position] ?? null)), input.now);
205
+ return meetingId;
206
+ };
169
207
  const save = (context, input) => {
170
208
  const { database } = context;
171
209
  const { meeting: { series: seriesInput, ...meeting }, now, } = input;
172
210
  if (!Number.isSafeInteger(meeting.accountId) || meeting.accountId <= 0 || !meeting.externalId)
173
211
  throw new Error("Invalid meeting key");
174
- const existing = database
212
+ let existing = database
175
213
  .prepare("SELECT id, meeting_series_id FROM meetings WHERE account_id = ? AND external_id = ?")
176
214
  .get(meeting.accountId, meeting.externalId);
215
+ const same = existing ? undefined : sameOccurrence(database, meeting, seriesInput?.externalId);
216
+ if (same?.fromFile === false && input.fromFile)
217
+ return fillGap(context, input, Number(same.id));
218
+ if (same && (same.fromFile || input.fromFile)) {
219
+ // A provider record takes over a record made from files; two files of one occurrence share the first one's key.
220
+ if (input.fromFile)
221
+ meeting.externalId = String(same.externalId);
222
+ else
223
+ database
224
+ .prepare("UPDATE meetings SET external_id = ?, updated_at = ? WHERE id = ?")
225
+ .run(meeting.externalId, now, Number(same.id));
226
+ existing = { id: same.id, meeting_series_id: same.seriesId };
227
+ }
177
228
  let seriesId = existing ? int(existing.meeting_series_id) : null;
178
229
  if (seriesInput) {
179
230
  if (!seriesInput.externalId)
@@ -228,6 +279,10 @@ const save = (context, input) => {
228
279
  return id;
229
280
  };
230
281
  saveTranscripts(database, meetingId, input.transcripts ?? [], participantId, now);
282
+ if ((input.transcripts ?? []).some((transcript) => transcript.source !== FILE_SOURCE))
283
+ database
284
+ .prepare("UPDATE meeting_transcripts SET superseded_at = ?, updated_at = ? WHERE meeting_id = ? AND source = ? AND superseded_at IS NULL")
285
+ .run(now, now, meetingId, FILE_SOURCE);
231
286
  for (const { senderParticipantPosition, ...line } of input.chat ?? []) {
232
287
  const values = {
233
288
  sent_at: line.sentAt,
@@ -1,4 +1,4 @@
1
- import type { Id, Message, PollVote, Provider, Reactions } from "../../domain/models.js";
1
+ import type { Id, Message, PersonReaction, PollVote, Provider, Reactions } from "../../domain/models.js";
2
2
  import type { AccountKey, DeletionScope } from "../types.js";
3
3
  import type { StoreContext } from "./open.js";
4
4
  export declare const upsertMessage: (context: StoreContext, key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) => void;
@@ -7,11 +7,19 @@ export declare const upsertMessage: (context: StoreContext, key: AccountKey, acc
7
7
  * back, and leaves no text — not in the row, the search copy, the edit history, a transcript or a vector.
8
8
  */
9
9
  export declare const tombstone: (context: StoreContext, pk: number) => number;
10
- /** Whether the message is held at all. */
11
- export declare const saveReactions: ({ orm }: StoreContext, chatKey: number, messageId: Id, reactions: Reactions) => boolean;
10
+ /** Whether the message is held at all. Where people's own reactions are held, the totals come from them instead. */
11
+ export declare const saveReactions: (context: StoreContext, chatKey: number, messageId: Id, reactions: Reactions) => boolean;
12
12
  /** Answers whether the message is held; a deleted one keeps no star. */
13
13
  export declare const saveStar: ({ orm }: StoreContext, chatKey: number, messageId: Id, starred: boolean) => boolean;
14
14
  export declare const markDeleted: (context: StoreContext, accountKey: number, messageIds: Id[], chatId: Id | undefined, among?: DeletionScope) => number;
15
15
  /** A vote older than the one held for that voter is dropped, and so is any vote in a deleted poll. */
16
16
  export declare const saveVote: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, answers, votedAt }: PollVote) => void;
17
17
  export declare const pollVotes: ({ database }: StoreContext, chatKey: number, messageId: Id) => PollVote[];
18
+ /**
19
+ * The totals of the people's reactions the store holds for a message, or `undefined` when it holds none: then the
20
+ * messenger's own totals stand. A taken-back reaction still counts as held, so all of them taken back is zero.
21
+ */
22
+ export declare const derivedReactions: ({ database }: StoreContext, chatKey: number, messageId: Id) => Reactions | undefined;
23
+ /** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
24
+ export declare const saveReaction: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, own, reaction, reactedAt }: PersonReaction) => void;
25
+ export declare const writeDerivedReactions: (context: StoreContext, chatKey: number, messageId: Id) => void;
@@ -174,6 +174,13 @@ export const upsertMessage = (context, key, accountKey, chatKey, message, via, s
174
174
  if (revived)
175
175
  orm.update(messages).set({ deletedAt: null, text: message.text }).where(eq(messages.id, pk)).run();
176
176
  }
177
+ const derived = derivedReactions(context, chatKey, message.id);
178
+ if (derived)
179
+ orm
180
+ .update(messages)
181
+ .set({ reactions: JSON.stringify(derived) })
182
+ .where(eq(messages.id, pk))
183
+ .run();
177
184
  if (message.counterObservations)
178
185
  applyCounterObservations(context, pk, message.counterObservations);
179
186
  if (message.starred !== undefined)
@@ -237,6 +244,9 @@ export const tombstone = (context, pk) => {
237
244
  context.database
238
245
  .prepare("DELETE FROM poll_votes WHERE chat_id=? AND message_external_id=?")
239
246
  .run(row.chatId, row.externalId);
247
+ context.database
248
+ .prepare("DELETE FROM message_reactions WHERE chat_id=? AND message_external_id=?")
249
+ .run(row.chatId, row.externalId);
240
250
  purgeVectorsOf(context, pk);
241
251
  orm
242
252
  .delete(messageTranscripts)
@@ -244,10 +254,10 @@ export const tombstone = (context, pk) => {
244
254
  .run();
245
255
  return 1;
246
256
  };
247
- /** Whether the message is held at all. */
248
- export const saveReactions = ({ orm }, chatKey, messageId, reactions) => orm
257
+ /** Whether the message is held at all. Where people's own reactions are held, the totals come from them instead. */
258
+ export const saveReactions = (context, chatKey, messageId, reactions) => context.orm
249
259
  .update(messages)
250
- .set({ reactions: JSON.stringify(reactions) })
260
+ .set({ reactions: JSON.stringify(derivedReactions(context, chatKey, messageId) ?? reactions) })
251
261
  .where(and(eq(messages.chatId, chatKey), eq(messages.externalId, messageId), isNull(messages.deletedAt)))
252
262
  .returning({ pk: messages.id })
253
263
  .all().length > 0;
@@ -326,3 +336,64 @@ export const pollVotes = ({ database }, chatKey, messageId) => database
326
336
  answers: JSON.parse(answers),
327
337
  votedAt: new Date(votedAt).toISOString(),
328
338
  }));
339
+ const reactionStatements = new WeakMap();
340
+ const reactionStatementsOf = (database) => {
341
+ const held = reactionStatements.get(database);
342
+ if (held)
343
+ return held;
344
+ const statements = {
345
+ gone: database.prepare("SELECT 1 FROM messages WHERE chat_id=? AND external_id=? AND deleted_at IS NOT NULL"),
346
+ upsert: database.prepare(`INSERT INTO message_reactions
347
+ (chat_id, message_external_id, identity_id, own, reaction, reacted_at, created_at, updated_at)
348
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
349
+ ON CONFLICT (chat_id, message_external_id, identity_id) DO UPDATE SET
350
+ own = excluded.own, reaction = excluded.reaction, reacted_at = excluded.reacted_at,
351
+ updated_at = excluded.updated_at
352
+ WHERE excluded.reacted_at >= message_reactions.reacted_at`),
353
+ held: database.prepare("SELECT reaction, own, reacted_at AS reactedAt FROM message_reactions WHERE chat_id=? AND message_external_id=?"),
354
+ total: database.prepare("UPDATE messages SET reactions=? WHERE chat_id=? AND external_id=? AND deleted_at IS NULL"),
355
+ };
356
+ reactionStatements.set(database, statements);
357
+ return statements;
358
+ };
359
+ /**
360
+ * The totals of the people's reactions the store holds for a message, or `undefined` when it holds none: then the
361
+ * messenger's own totals stand. A taken-back reaction still counts as held, so all of them taken back is zero.
362
+ */
363
+ export const derivedReactions = ({ database }, chatKey, messageId) => {
364
+ const rows = reactionStatementsOf(database).held.all(chatKey, messageId);
365
+ if (rows.length === 0)
366
+ return undefined;
367
+ const counts = new Map();
368
+ let mine = null;
369
+ for (const { reaction, own, reactedAt } of rows) {
370
+ if (reaction === null)
371
+ continue;
372
+ const held = counts.get(reaction);
373
+ counts.set(reaction, { count: (held?.count ?? 0) + 1, first: Math.min(held?.first ?? reactedAt, reactedAt) });
374
+ if (own === 1 && (mine === null || reactedAt > mine.at))
375
+ mine = { reaction, at: reactedAt };
376
+ }
377
+ const sorted = [...counts].sort(([, a], [, b]) => b.count - a.count || a.first - b.first);
378
+ return {
379
+ counts: sorted.map(([reaction, { count }]) => ({ reaction, count })),
380
+ mine: mine?.reaction ?? null,
381
+ total: sorted.reduce((sum, [, { count }]) => sum + count, 0),
382
+ };
383
+ };
384
+ /** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
385
+ export const saveReaction = (context, accountKey, provider, chatKey, messageId, { person, own, reaction, reactedAt }) => {
386
+ const { database, now } = context;
387
+ const statements = reactionStatementsOf(database);
388
+ if (statements.gone.get(chatKey, messageId))
389
+ return;
390
+ const reactor = identityPk(context, accountKey, provider, person.id, person.name, person.username ? { username: person.username } : {});
391
+ const at = toMs(reactedAt) ?? now();
392
+ statements.upsert.run(chatKey, messageId, reactor, own ? 1 : 0, reaction, at, now(), now());
393
+ writeDerivedReactions(context, chatKey, messageId);
394
+ };
395
+ export const writeDerivedReactions = (context, chatKey, messageId) => {
396
+ const derived = derivedReactions(context, chatKey, messageId);
397
+ if (derived)
398
+ reactionStatementsOf(context.database).total.run(JSON.stringify(derived), chatKey, messageId);
399
+ };
@@ -554,5 +554,12 @@ export const GENERATED = [
554
554
  "CREATE TRIGGER chat_revisions_au AFTER UPDATE OF external_id, title, membership_state, scope, searchable, parent_chat_id ON chats BEGIN\n INSERT INTO chat_revisions (chat_id, account_id, field, old_value, new_value, source, created_at)\n SELECT new.id, new.account_id, f.field, f.old_value, f.new_value, s.source,\n coalesce(s.at, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))\n FROM (\n SELECT 'external_id' AS field, old.external_id AS old_value, new.external_id AS new_value\n UNION ALL SELECT 'title', old.title, new.title\n UNION ALL SELECT 'membership_state', old.membership_state, new.membership_state\n UNION ALL SELECT 'scope', old.scope, new.scope\n UNION ALL SELECT 'searchable', old.searchable, new.searchable\n UNION ALL SELECT 'parent_chat_id', old.parent_chat_id, new.parent_chat_id\n ) f\n LEFT JOIN chat_revision_source s ON s.id = 1\n WHERE f.old_value IS NOT f.new_value;\nEND;",
555
555
  "CREATE TRIGGER chat_revisions_ad AFTER DELETE ON chats BEGIN\n DELETE FROM chat_revisions WHERE chat_id = old.id;\nEND;"
556
556
  ]
557
+ },
558
+ {
559
+ "name": "20261021214318_version-26-message-reactions",
560
+ "statements": [
561
+ "CREATE TABLE `message_reactions` (\n\t`id` integer PRIMARY KEY,\n\t`chat_id` integer NOT NULL,\n\t`message_external_id` text NOT NULL,\n\t`identity_id` integer NOT NULL,\n\t`own` integer NOT NULL,\n\t`reaction` text,\n\t`reacted_at` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_message_reactions_chat_id_chats_id_fk` FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`),\n\tCONSTRAINT `fk_message_reactions_identity_id_identities_id_fk` FOREIGN KEY (`identity_id`) REFERENCES `identities`(`id`),\n\tCONSTRAINT `message_reactions_chat_id_message_external_id_identity_id_unique` UNIQUE(`chat_id`,`message_external_id`,`identity_id`)\n);",
562
+ "CREATE INDEX `message_reactions_by_identity_id` ON `message_reactions` (`identity_id`);"
563
+ ]
557
564
  }
558
565
  ];
@@ -13,6 +13,20 @@ import { assertReadCompatible } from "./read-compatibility.js";
13
13
  import { stemsState } from "./stems.js";
14
14
  import { fillStems, followDefaultStemmers } from "./stems-fill.js";
15
15
  export const BACKFILL_ON_OPEN = 5_000;
16
+ const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
17
+ const createWritable = (file) => {
18
+ try {
19
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
20
+ // SQLite gives its sidecar files the database's permissions.
21
+ writeFileSync(file, "", { flag: "a", mode: 0o600 });
22
+ }
23
+ catch (error) {
24
+ if (!NOT_WRITABLE.has(error.code ?? ""))
25
+ throw error;
26
+ // The errno's own sentence names the path, which holds the owner's home folder.
27
+ throw new CliError("permission_error", "the message store cannot be written from here; run where it is writable, or set MESSAGING_STORE", { reason: "store.not-writable" });
28
+ }
29
+ };
16
30
  export const openStoreContext = async ({ path, env, now = Date.now, command, readOnly = false, } = {}) => {
17
31
  if (typeof readOnly !== "boolean")
18
32
  throw new CliError("validation_error", "readOnly must be a boolean");
@@ -22,11 +36,8 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, rea
22
36
  if (!statSync(file, { throwIfNoEntry: false })?.isFile())
23
37
  throw new CliError("not_found", "The store does not exist as a file; read-only opening does not create it");
24
38
  }
25
- else {
26
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
27
- // SQLite gives its sidecar files the database's permissions.
28
- writeFileSync(file, "", { flag: "a", mode: 0o600 });
29
- }
39
+ else
40
+ createWritable(file);
30
41
  const opened = await openSqlite(file, { readOnly });
31
42
  const database = opened.database;
32
43
  try {
@@ -4478,6 +4478,161 @@ export declare const chatEventPeople: import("drizzle-orm/sqlite-core").SQLiteTa
4478
4478
  };
4479
4479
  dialect: 'sqlite';
4480
4480
  }>;
4481
+ /**
4482
+ * Each person's newest reaction to a message, for a messenger that sends one person's change at a time; the store
4483
+ * derives `messages.reactions` from them. Keyed by chat and external id: a reaction can arrive before its message.
4484
+ */
4485
+ export declare const messageReactions: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4486
+ name: "message_reactions";
4487
+ schema: undefined;
4488
+ columns: {
4489
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4490
+ name: string;
4491
+ tableName: "message_reactions";
4492
+ dataType: "number int53";
4493
+ data: number;
4494
+ driverParam: number;
4495
+ notNull: true;
4496
+ hasDefault: true;
4497
+ isPrimaryKey: true;
4498
+ isAutoincrement: false;
4499
+ hasRuntimeDefault: false;
4500
+ enumValues: undefined;
4501
+ baseColumn: never;
4502
+ identity: undefined;
4503
+ generated: undefined;
4504
+ }, {}>;
4505
+ chatId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4506
+ name: string;
4507
+ tableName: "message_reactions";
4508
+ dataType: "number int53";
4509
+ data: number;
4510
+ driverParam: number;
4511
+ notNull: true;
4512
+ hasDefault: false;
4513
+ isPrimaryKey: false;
4514
+ isAutoincrement: false;
4515
+ hasRuntimeDefault: false;
4516
+ enumValues: undefined;
4517
+ baseColumn: never;
4518
+ identity: undefined;
4519
+ generated: undefined;
4520
+ }, {}>;
4521
+ messageExternalId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4522
+ name: string;
4523
+ tableName: "message_reactions";
4524
+ dataType: "string";
4525
+ data: string;
4526
+ driverParam: string;
4527
+ notNull: true;
4528
+ hasDefault: false;
4529
+ isPrimaryKey: false;
4530
+ isAutoincrement: false;
4531
+ hasRuntimeDefault: false;
4532
+ enumValues: [string, ...string[]];
4533
+ baseColumn: never;
4534
+ identity: undefined;
4535
+ generated: undefined;
4536
+ }, {}>;
4537
+ identityId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4538
+ name: string;
4539
+ tableName: "message_reactions";
4540
+ dataType: "number int53";
4541
+ data: number;
4542
+ driverParam: number;
4543
+ notNull: true;
4544
+ hasDefault: false;
4545
+ isPrimaryKey: false;
4546
+ isAutoincrement: false;
4547
+ hasRuntimeDefault: false;
4548
+ enumValues: undefined;
4549
+ baseColumn: never;
4550
+ identity: undefined;
4551
+ generated: undefined;
4552
+ }, {}>;
4553
+ own: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4554
+ name: string;
4555
+ tableName: "message_reactions";
4556
+ dataType: "number int53";
4557
+ data: number;
4558
+ driverParam: number;
4559
+ notNull: true;
4560
+ hasDefault: false;
4561
+ isPrimaryKey: false;
4562
+ isAutoincrement: false;
4563
+ hasRuntimeDefault: false;
4564
+ enumValues: undefined;
4565
+ baseColumn: never;
4566
+ identity: undefined;
4567
+ generated: undefined;
4568
+ }, {}>;
4569
+ reaction: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4570
+ name: string;
4571
+ tableName: "message_reactions";
4572
+ dataType: "string";
4573
+ data: string;
4574
+ driverParam: string;
4575
+ notNull: false;
4576
+ hasDefault: false;
4577
+ isPrimaryKey: false;
4578
+ isAutoincrement: false;
4579
+ hasRuntimeDefault: false;
4580
+ enumValues: [string, ...string[]];
4581
+ baseColumn: never;
4582
+ identity: undefined;
4583
+ generated: undefined;
4584
+ }, {}>;
4585
+ reactedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4586
+ name: string;
4587
+ tableName: "message_reactions";
4588
+ dataType: "number int53";
4589
+ data: number;
4590
+ driverParam: number;
4591
+ notNull: true;
4592
+ hasDefault: false;
4593
+ isPrimaryKey: false;
4594
+ isAutoincrement: false;
4595
+ hasRuntimeDefault: false;
4596
+ enumValues: undefined;
4597
+ baseColumn: never;
4598
+ identity: undefined;
4599
+ generated: undefined;
4600
+ }, {}>;
4601
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4602
+ name: string;
4603
+ tableName: "message_reactions";
4604
+ dataType: "number int53";
4605
+ data: number;
4606
+ driverParam: number;
4607
+ notNull: true;
4608
+ hasDefault: false;
4609
+ isPrimaryKey: false;
4610
+ isAutoincrement: false;
4611
+ hasRuntimeDefault: false;
4612
+ enumValues: undefined;
4613
+ baseColumn: never;
4614
+ identity: undefined;
4615
+ generated: undefined;
4616
+ }, {}>;
4617
+ updatedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
4618
+ name: string;
4619
+ tableName: "message_reactions";
4620
+ dataType: "number int53";
4621
+ data: number;
4622
+ driverParam: number;
4623
+ notNull: true;
4624
+ hasDefault: false;
4625
+ isPrimaryKey: false;
4626
+ isAutoincrement: false;
4627
+ hasRuntimeDefault: false;
4628
+ enumValues: undefined;
4629
+ baseColumn: never;
4630
+ identity: undefined;
4631
+ generated: undefined;
4632
+ }, {}>;
4633
+ };
4634
+ dialect: 'sqlite';
4635
+ }>;
4481
4636
  /** One read of a chat's member list. Retention reads presence at a checkpoint from these, not from stays. */
4482
4637
  export declare const memberObservations: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
4483
4638
  name: "member_observations";
@@ -511,6 +511,28 @@ export const chatEventPeople = sqliteTable("chat_event_people", {
511
511
  primaryKey({ columns: [table.chatEventId, table.identityId] }),
512
512
  index("chat_event_people_by_identity_id").on(table.identityId),
513
513
  ]);
514
+ /**
515
+ * Each person's newest reaction to a message, for a messenger that sends one person's change at a time; the store
516
+ * derives `messages.reactions` from them. Keyed by chat and external id: a reaction can arrive before its message.
517
+ */
518
+ export const messageReactions = sqliteTable("message_reactions", {
519
+ id: integer("id").primaryKey(),
520
+ chatId: integer("chat_id")
521
+ .notNull()
522
+ .references(() => chats.id),
523
+ messageExternalId: text("message_external_id").notNull(),
524
+ identityId: integer("identity_id")
525
+ .notNull()
526
+ .references(() => identities.id),
527
+ own: integer("own").notNull(),
528
+ reaction: text("reaction"),
529
+ reactedAt: integer("reacted_at").notNull(),
530
+ createdAt: integer("created_at").notNull(),
531
+ updatedAt: integer("updated_at").notNull(),
532
+ }, (table) => [
533
+ unique().on(table.chatId, table.messageExternalId, table.identityId),
534
+ index("message_reactions_by_identity_id").on(table.identityId),
535
+ ]);
514
536
  /** One read of a chat's member list. Retention reads presence at a checkpoint from these, not from stays. */
515
537
  export const memberObservations = sqliteTable("member_observations", {
516
538
  id: integer("id").primaryKey(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.380.0",
3
+ "version": "0.381.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",