getbotons 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { OWNER_REACTION_MAX, reactionEmoji } from '../../shared/reactions.ts';
|
|
2
|
+
import { currentName } from '../../shared/legacy-names.mjs';
|
|
3
|
+
import type { Message, ReactionActor, ReactionImportResult, ReactionTransfer, ReactionTransferEntry } from '../../shared/types.ts';
|
|
4
|
+
import type { Principal } from './auth.ts';
|
|
5
|
+
import { RunLimiter } from './limits.ts';
|
|
6
|
+
import { log } from './log.ts';
|
|
7
|
+
import { HttpError, type Store } from './store.ts';
|
|
8
|
+
import { nowIso, type Row } from './util.ts';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Emoji reactions on chat messages (the message_reactions table; schema.ts). The owner reacts to the Boton's
|
|
12
|
+
* messages from a paired device, and the Boton to the owner's own messages in its chat, during a turn that may change
|
|
13
|
+
* what its later turns know (not a sandboxed one). A reaction is feedback only: it is never read by approvals or
|
|
14
|
+
* cards, can't be put on an approval or card event, and pushes nothing. An owner's reaction reaches the Boton in the
|
|
15
|
+
* context of its next chat turn; one of the owner's follow-up reactions (Settings.reactionFollowUps) also starts a
|
|
16
|
+
* short follow-up turn, at most once per message and emoji, never when one is taken off.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A Boton's reactions added or taken off in an hour, at most; the owner's aren't limited. */
|
|
20
|
+
const BOTON_CHANGES_PER_HOUR = 60;
|
|
21
|
+
/** The owner's reactions not yet told, given to a turn at most. */
|
|
22
|
+
const FEEDBACK_MAX = 20;
|
|
23
|
+
const EXCERPT_CHARS = 160;
|
|
24
|
+
|
|
25
|
+
/** An owner's reaction the Boton hasn't been given yet, with the start of the message it is on. */
|
|
26
|
+
export interface Feedback {
|
|
27
|
+
messageId: string;
|
|
28
|
+
emoji: string;
|
|
29
|
+
at: string;
|
|
30
|
+
excerpt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const excerptOf = (text: string) => {
|
|
34
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
35
|
+
return flat.length > EXCERPT_CHARS ? `${flat.slice(0, EXCERPT_CHARS - 1)}…` : flat;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A message only the owner wrote (not another Boton's, nor one the server posted for them), in the owner's turn. */
|
|
39
|
+
export const ownersOwn = (m: Message) => m.role === 'user' && !m.meta?.fromBotId && !m.meta?.kind;
|
|
40
|
+
|
|
41
|
+
export class Reactions {
|
|
42
|
+
#store: Store;
|
|
43
|
+
#wake: (botId: string) => void;
|
|
44
|
+
#limiter = new RunLimiter(BOTON_CHANGES_PER_HOUR, Number.MAX_SAFE_INTEGER);
|
|
45
|
+
|
|
46
|
+
constructor(deps: { store: Store; wake: (botId: string) => void }) {
|
|
47
|
+
this.#store = deps.store;
|
|
48
|
+
this.#wake = deps.wake;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#all(sql: string, ...params: (string | number | null)[]): Row[] {
|
|
52
|
+
return this.#store.db.prepare(sql).all(...params) as Row[];
|
|
53
|
+
}
|
|
54
|
+
#run(sql: string, ...params: (string | number | null)[]) {
|
|
55
|
+
return this.#store.db.prepare(sql).run(...params);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Who reacts for this principal: the owner's devices, or the Boton whose chat it is (the route checked which). */
|
|
59
|
+
#actor(principal: Principal | null): ReactionActor {
|
|
60
|
+
return principal?.kind === 'boton' ? 'boton' : 'owner';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#message(botId: string, messageId: string): Message {
|
|
64
|
+
this.#store.getBot(botId);
|
|
65
|
+
const m = this.#store.findMessage(messageId);
|
|
66
|
+
if (!m || m.botId !== botId) throw new HttpError(404, `No message ${messageId} in this chat`);
|
|
67
|
+
return m;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#emoji(v: unknown): string {
|
|
71
|
+
const e = reactionEmoji(v);
|
|
72
|
+
if (!e) throw new HttpError(400, 'emoji must be one emoji, such as 👍');
|
|
73
|
+
return e;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#limit(principal: Principal | null) {
|
|
77
|
+
if (principal?.kind !== 'boton') return;
|
|
78
|
+
if (this.#limiter.check(principal.botId)) throw new HttpError(429, `At most ${BOTON_CHANGES_PER_HOUR} reactions an hour: react only where a reaction says it all`);
|
|
79
|
+
this.#limiter.record(principal.botId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Checks who may put a reaction on `m`; throws with why not. */
|
|
83
|
+
#mayReact(m: Message, actor: ReactionActor): void {
|
|
84
|
+
const owner = this.#store.getSettings().ownerName;
|
|
85
|
+
if (actor === 'owner' && m.role !== 'bot') {
|
|
86
|
+
throw new HttpError(400, `Only the Boton's messages take reactions${m.role === 'event' ? ': an approval or a card is decided with its own buttons, never with a reaction' : ''}`);
|
|
87
|
+
}
|
|
88
|
+
if (actor === 'boton' && !ownersOwn(m)) throw new HttpError(400, `You can react only to ${owner}'s own messages in your chat, not to your own, another Boton's or the server's`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Adds a reaction (again is no change); answers with the message as it is now. */
|
|
92
|
+
react(botId: string, messageId: string, emojiInput: unknown, principal: Principal | null): Message {
|
|
93
|
+
const actor = this.#actor(principal);
|
|
94
|
+
const emoji = this.#emoji(emojiInput);
|
|
95
|
+
let wake = false;
|
|
96
|
+
const out = this.#store.atomically(() => {
|
|
97
|
+
const m = this.#message(botId, messageId);
|
|
98
|
+
this.#mayReact(m, actor);
|
|
99
|
+
const mine = (m.reactions ?? []).filter((r) => r.by === actor);
|
|
100
|
+
if (mine.some((r) => r.emoji === emoji)) return m;
|
|
101
|
+
if (actor === 'boton' && mine.length) {
|
|
102
|
+
throw new HttpError(409, `You already reacted ${mine[0].emoji} to this message, and one reaction is the most: take it off with \`botons unreact ${messageId}\` first to change it`);
|
|
103
|
+
}
|
|
104
|
+
if (actor === 'owner' && mine.length >= OWNER_REACTION_MAX) throw new HttpError(409, `At most ${OWNER_REACTION_MAX} reactions on one message: take one off first`);
|
|
105
|
+
this.#limit(principal);
|
|
106
|
+
const at = nowIso();
|
|
107
|
+
this.#run('insert into message_reactions (message_id, bot_id, actor, emoji, created_at) values (?, ?, ?, ?, ?)', messageId, botId, actor, emoji, at);
|
|
108
|
+
if (actor === 'owner') wake = this.#followUp(m, emoji, at);
|
|
109
|
+
const updated = this.#store.findMessage(messageId)!;
|
|
110
|
+
this.#store.publish({ type: 'message.updated', message: updated });
|
|
111
|
+
return updated;
|
|
112
|
+
});
|
|
113
|
+
log.info('reaction added', { botId, messageId, by: actor, followUp: wake || undefined });
|
|
114
|
+
if (wake) this.#wake(botId);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A follow-up turn for one of the owner's follow-up reactions, the first time it is put on this message: a pending
|
|
120
|
+
* message the turn answers (engine.ts gives it as the reaction's request), which carries the reaction, so it is
|
|
121
|
+
* marked told. True if one was added.
|
|
122
|
+
*/
|
|
123
|
+
#followUp(m: Message, emoji: string, at: string): boolean {
|
|
124
|
+
if (!this.#store.getSettings().reactionFollowUps.includes(emoji)) return false;
|
|
125
|
+
if (!Number(this.#run('insert or ignore into reaction_followups (message_id, emoji, at) values (?, ?, ?)', m.id, emoji, at).changes)) return false;
|
|
126
|
+
this.#run("update message_reactions set told_at = ? where message_id = ? and actor = 'owner' and emoji = ?", at, m.id, emoji);
|
|
127
|
+
this.#store.addMessage(m.botId, 'user', `Reacted ${emoji} to a message`, { kind: 'reaction.followup', reactionTo: m.id, emoji }, true);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Takes a reaction off: the given emoji, or (no emoji) the actor's every one. A follow-up the reaction asked for
|
|
133
|
+
* that hasn't started is withdrawn; one that has runs on. Answers with the message as it is now.
|
|
134
|
+
*/
|
|
135
|
+
unreact(botId: string, messageId: string, emojiInput: unknown, principal: Principal | null): Message {
|
|
136
|
+
const actor = this.#actor(principal);
|
|
137
|
+
const emoji = emojiInput === undefined || emojiInput === null || emojiInput === '' ? null : this.#emoji(emojiInput);
|
|
138
|
+
return this.#store.atomically(() => {
|
|
139
|
+
const m = this.#message(botId, messageId);
|
|
140
|
+
const gone = (m.reactions ?? []).filter((r) => r.by === actor && (emoji === null || r.emoji === emoji));
|
|
141
|
+
if (!gone.length) return m;
|
|
142
|
+
this.#limit(principal);
|
|
143
|
+
for (const r of gone) this.#run('delete from message_reactions where message_id = ? and actor = ? and emoji = ?', messageId, actor, r.emoji);
|
|
144
|
+
if (actor === 'owner') for (const r of gone) this.#withdraw(m, r.emoji);
|
|
145
|
+
const updated = this.#store.findMessage(messageId)!;
|
|
146
|
+
this.#store.publish({ type: 'message.updated', message: updated });
|
|
147
|
+
log.info('reaction removed', { botId, messageId, by: actor });
|
|
148
|
+
return updated;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The follow-up for this reaction, if it is still waiting (pending 1: not in flight), answered without a turn. */
|
|
153
|
+
#withdraw(m: Message, emoji: string): void {
|
|
154
|
+
const rows = this.#all(
|
|
155
|
+
`select id from messages where bot_id = ? and role = 'user' and pending = 1 and json_extract(meta, '$.kind') = 'reaction.followup'
|
|
156
|
+
and json_extract(meta, '$.reactionTo') = ? and json_extract(meta, '$.emoji') = ?`,
|
|
157
|
+
m.botId,
|
|
158
|
+
m.id,
|
|
159
|
+
emoji,
|
|
160
|
+
);
|
|
161
|
+
for (const r of rows) {
|
|
162
|
+
this.#run("update messages set pending = 0, meta = json_set(meta, '$.withdrawn', json('true')) where id = ?", r.id);
|
|
163
|
+
this.#store.publish({ type: 'message.updated', message: this.#store.findMessage(String(r.id))! });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The owner's reactions this Boton hasn't been given yet, oldest first (the newest FEEDBACK_MAX). */
|
|
168
|
+
feedback(botId: string): Feedback[] {
|
|
169
|
+
return this.#all(
|
|
170
|
+
`select r.message_id, r.emoji, r.created_at, m.text from message_reactions r join messages m on m.id = r.message_id
|
|
171
|
+
where r.bot_id = ? and r.actor = 'owner' and r.told_at is null order by r.created_at desc, r.rowid desc limit ?`,
|
|
172
|
+
botId,
|
|
173
|
+
FEEDBACK_MAX,
|
|
174
|
+
)
|
|
175
|
+
.reverse()
|
|
176
|
+
.map((r) => ({ messageId: String(r.message_id), emoji: String(r.emoji), at: String(r.created_at), excerpt: excerptOf(String(r.text)) }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** After a turn that was given them ended with its reply: not given again (one taken off since is gone already). */
|
|
180
|
+
markTold(given: Feedback[]): void {
|
|
181
|
+
const at = nowIso();
|
|
182
|
+
for (const f of given) this.#run("update message_reactions set told_at = ? where message_id = ? and actor = 'owner' and emoji = ? and told_at is null", at, f.messageId, f.emoji);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Which of `ids` the Boton put a reaction on since `since` (its turn's start). */
|
|
186
|
+
botonReactedTo(botId: string, ids: string[], since: string): Set<string> {
|
|
187
|
+
if (!ids.length) return new Set();
|
|
188
|
+
const rows = this.#all(
|
|
189
|
+
`select distinct message_id from message_reactions where bot_id = ? and actor = 'boton' and created_at >= ? and message_id in (${ids.map(() => '?').join(', ')})`,
|
|
190
|
+
botId,
|
|
191
|
+
since,
|
|
192
|
+
...ids,
|
|
193
|
+
);
|
|
194
|
+
return new Set(rows.map((r) => String(r.message_id)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ------------------------------------------------------------------ export and import
|
|
198
|
+
|
|
199
|
+
export(): ReactionTransfer {
|
|
200
|
+
const reactions = this.#all('select message_id, bot_id, actor, emoji, created_at from message_reactions order by created_at, rowid').map((r) => ({
|
|
201
|
+
messageId: String(r.message_id),
|
|
202
|
+
botId: String(r.bot_id),
|
|
203
|
+
by: r.actor as ReactionActor,
|
|
204
|
+
emoji: String(r.emoji),
|
|
205
|
+
at: String(r.created_at),
|
|
206
|
+
}));
|
|
207
|
+
return { format: 'botons-reactions', version: 1, exportedAt: nowIso(), reactions };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** All of it, or nothing if any entry is wrong: see ReactionTransfer. */
|
|
211
|
+
import(input: unknown): ReactionImportResult {
|
|
212
|
+
const entries = transferEntries(input);
|
|
213
|
+
return this.#store.atomically(() => {
|
|
214
|
+
const byMessage = new Map<string, ReactionTransferEntry[]>();
|
|
215
|
+
for (const e of entries) byMessage.set(e.messageId, [...(byMessage.get(e.messageId) ?? []), e]);
|
|
216
|
+
const skipped: string[] = [];
|
|
217
|
+
const told = nowIso();
|
|
218
|
+
let imported = 0;
|
|
219
|
+
const changed: string[] = [];
|
|
220
|
+
for (const [messageId, list] of byMessage) {
|
|
221
|
+
const m = this.#store.findMessage(messageId);
|
|
222
|
+
if (!m || list.some((e) => e.botId !== m.botId)) {
|
|
223
|
+
skipped.push(messageId);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
const keep = list.filter((e) => !seen.has(`${e.by} ${e.emoji}`) && seen.add(`${e.by} ${e.emoji}`));
|
|
228
|
+
for (const by of ['owner', 'boton'] as const) if (keep.some((e) => e.by === by)) this.#mayReact(m, by);
|
|
229
|
+
if (keep.filter((e) => e.by === 'boton').length > 1) throw new HttpError(400, `Message ${messageId}: the Boton has one reaction at most`);
|
|
230
|
+
if (keep.filter((e) => e.by === 'owner').length > OWNER_REACTION_MAX) throw new HttpError(400, `Message ${messageId}: at most ${OWNER_REACTION_MAX} reactions of the owner's`);
|
|
231
|
+
// An owner's reaction the file doesn't keep is taken off: its waiting follow-up is withdrawn, as by unreact.
|
|
232
|
+
const kept = new Set(keep.filter((e) => e.by === 'owner').map((e) => e.emoji));
|
|
233
|
+
for (const r of m.reactions ?? []) if (r.by === 'owner' && !kept.has(r.emoji)) this.#withdraw(m, r.emoji);
|
|
234
|
+
this.#run('delete from message_reactions where message_id = ?', messageId);
|
|
235
|
+
// Not new feedback: the Boton isn't told of them as if they were just added, and no follow-up starts.
|
|
236
|
+
for (const e of keep) {
|
|
237
|
+
this.#run('insert into message_reactions (message_id, bot_id, actor, emoji, created_at, told_at) values (?, ?, ?, ?, ?, ?)', messageId, m.botId, e.by, e.emoji, e.at, told);
|
|
238
|
+
}
|
|
239
|
+
imported += keep.length;
|
|
240
|
+
changed.push(messageId);
|
|
241
|
+
}
|
|
242
|
+
for (const id of changed) this.#store.publish({ type: 'message.updated', message: this.#store.findMessage(id)! });
|
|
243
|
+
log.info('reactions imported', { imported, messages: changed.length, skipped: skipped.length });
|
|
244
|
+
return { imported, skipped };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function transferEntries(v: unknown): ReactionTransferEntry[] {
|
|
250
|
+
const t = v as Partial<ReactionTransfer> | null;
|
|
251
|
+
// A file exported under the deprecated format and actor names reads the same (shared/legacy-names.mjs).
|
|
252
|
+
if (typeof t !== 'object' || t === null || Array.isArray(t) || currentName(String(t.format)) !== 'botons-reactions') throw new HttpError(400, 'Not a reactions file (format must be "botons-reactions")');
|
|
253
|
+
if (t.version !== 1) throw new HttpError(400, `This server reads reactions files of version 1, not ${JSON.stringify(t.version)}`);
|
|
254
|
+
if (!Array.isArray(t.reactions)) throw new HttpError(400, 'reactions must be a list');
|
|
255
|
+
return t.reactions.map((x, i) => {
|
|
256
|
+
const e = x && typeof x === 'object' && typeof (x as ReactionTransferEntry).by === 'string' ? { ...(x as ReactionTransferEntry), by: currentName((x as ReactionTransferEntry).by) as ReactionTransferEntry['by'] } : (x as Partial<ReactionTransferEntry> | null);
|
|
257
|
+
const where = `reactions[${i}]`;
|
|
258
|
+
if (typeof e !== 'object' || e === null) throw new HttpError(400, `${where} must be an object`);
|
|
259
|
+
if (typeof e.messageId !== 'string' || !e.messageId || typeof e.botId !== 'string' || !e.botId) throw new HttpError(400, `${where} needs a messageId and a botId`);
|
|
260
|
+
if (e.by !== 'owner' && e.by !== 'boton') throw new HttpError(400, `${where}.by must be "owner" or "boton"`);
|
|
261
|
+
const emoji = reactionEmoji(e.emoji);
|
|
262
|
+
if (!emoji) throw new HttpError(400, `${where}.emoji must be one emoji`);
|
|
263
|
+
if (typeof e.at !== 'string' || Number.isNaN(Date.parse(e.at))) throw new HttpError(400, `${where}.at must be a time`);
|
|
264
|
+
return { messageId: e.messageId, botId: e.botId, by: e.by, emoji, at: new Date(e.at).toISOString() };
|
|
265
|
+
});
|
|
266
|
+
}
|