@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 +33 -0
- package/dist/cli/bot/watch.js +1 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/messenger/stored.js +12 -7
- package/dist/cli/messenger/watch-command.js +6 -0
- package/dist/domain/models.d.ts +23 -2
- package/dist/errors/reasons.js +1 -0
- package/dist/meetings/import.js +1 -0
- package/dist/meetings/store.d.ts +6 -0
- package/dist/ports/messenger.d.ts +10 -3
- package/dist/resolve.d.ts +6 -0
- package/dist/resolve.js +13 -4
- package/dist/search/garbled.d.ts +2 -1
- package/dist/search/garbled.js +6 -1
- package/dist/store/messages.d.ts +16 -3
- package/dist/store/messages.js +7 -1
- package/dist/store/sqlite/accounts.js +2 -1
- package/dist/store/sqlite/chat-rekey.js +9 -1
- package/dist/store/sqlite/chats.js +2 -1
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/meetings.js +56 -1
- package/dist/store/sqlite/messages.d.ts +11 -3
- package/dist/store/sqlite/messages.js +74 -3
- package/dist/store/sqlite/migrations.generated.js +7 -0
- package/dist/store/sqlite/open-store.js +16 -5
- package/dist/store/sqlite/schema.d.ts +155 -0
- package/dist/store/sqlite/schema.js +22 -0
- package/package.json +1 -1
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
|
package/dist/cli/bot/watch.js
CHANGED
|
@@ -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") {
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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()
|
|
216
|
-
if (people.all().some((person) => person.alias
|
|
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": {
|
package/dist/domain/models.d.ts
CHANGED
|
@@ -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[];
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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"),
|
package/dist/meetings/import.js
CHANGED
|
@@ -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++;
|
package/dist/meetings/store.d.ts
CHANGED
|
@@ -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
|
|
348
|
-
* after them, so a message in the same batch is held before
|
|
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()
|
|
12
|
-
const matches = chats.filter((chat) => chat.title
|
|
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(/^@/, "")
|
|
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(
|
|
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])
|
package/dist/search/garbled.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/search/garbled.js
CHANGED
|
@@ -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))
|
package/dist/store/messages.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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. */
|
package/dist/store/messages.js
CHANGED
|
@@ -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
|
-
|
|
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: (
|
|
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 = (
|
|
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
|
-
|
|
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.
|
|
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",
|