roger-roger 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 +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Messages the user sends the bot in Slack, and getting each one to the right agent.
|
|
2
|
+
//
|
|
3
|
+
// The daemon is always connected, so a message is taken in the moment it is sent, whether or not any
|
|
4
|
+
// agent happens to be waiting. Where it goes is decided by router.mjs; this file does what that
|
|
5
|
+
// decision says: drops a note on a progress message, answers a question, hands a message to a
|
|
6
|
+
// session's queue, or — when several agents are running and nothing says which — asks the user which
|
|
7
|
+
// one it is for, with a button per session in that session's colour.
|
|
8
|
+
//
|
|
9
|
+
// Reactions say what has happened to a message: 👀 an agent has it, ✅ an agent has answered it.
|
|
10
|
+
// Reading a message is not the same as acting on one — an agent parked on `listen` picks a message
|
|
11
|
+
// up instantly, so marking that as done told the user nothing.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { rogerRogerHome, isUserId, loadConfig, pickedBlocks, pickerBlocks, progressBlocks, sentToBlocks, stripTags, NOBODY } from "./lib.mjs";
|
|
16
|
+
import { addReaction, downloadFile, history, openDm, postToChannel, replies, updateMessage } from "./slack.mjs";
|
|
17
|
+
import { routeMessage } from "./router.mjs";
|
|
18
|
+
import * as progressStore from "./progress.mjs";
|
|
19
|
+
import * as sessions from "./sessions.mjs";
|
|
20
|
+
import * as decisions from "./decisions.mjs";
|
|
21
|
+
|
|
22
|
+
const KEEP = 200;
|
|
23
|
+
|
|
24
|
+
function file() {
|
|
25
|
+
return path.join(rogerRogerHome(), "inbox.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readState() {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(file(), "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return { channel: "", lastTs: "", messages: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeState(state) {
|
|
37
|
+
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
|
38
|
+
const tmp = `${file()}.${process.pid}.tmp`;
|
|
39
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...state, messages: state.messages.slice(-KEEP) }, null, 2) + "\n", "utf8");
|
|
40
|
+
fs.renameSync(tmp, file());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A message carrying a file arrives as a subtype, not as a plain message. Dropping those lost
|
|
44
|
+
// screenshots entirely — the most useful thing the user can send — so they are let through, and so
|
|
45
|
+
// is a file posted with no caption at all.
|
|
46
|
+
const CARRIES_FILES = new Set(["file_share", "thread_broadcast"]);
|
|
47
|
+
|
|
48
|
+
/** A Slack message the user wrote (not the bot's own posts, edits, joins, …). */
|
|
49
|
+
export function isUserMessage(m) {
|
|
50
|
+
if (!m || !m.user || m.bot_id) return false;
|
|
51
|
+
if (m.subtype && !CARRIES_FILES.has(m.subtype)) return false;
|
|
52
|
+
const text = typeof m.text === "string" ? m.text.trim() : "";
|
|
53
|
+
return Boolean(text || (Array.isArray(m.files) && m.files.length));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tsIso = (ts) => new Date(Math.floor(Number(ts) * 1000)).toISOString();
|
|
57
|
+
|
|
58
|
+
const filesDir = () => path.join(rogerRogerHome(), "files");
|
|
59
|
+
|
|
60
|
+
// Attachments are a convenience, not a record. Decisions are kept a week; so are these.
|
|
61
|
+
const KEEP_FILES_MS = 7 * 24 * 3600 * 1000;
|
|
62
|
+
|
|
63
|
+
function pruneFiles(now = Date.now()) {
|
|
64
|
+
let names;
|
|
65
|
+
try {
|
|
66
|
+
names = fs.readdirSync(filesDir());
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const file = path.join(filesDir(), name);
|
|
72
|
+
try {
|
|
73
|
+
if (now - fs.statSync(file).mtimeMs > KEEP_FILES_MS) fs.rmSync(file, { force: true });
|
|
74
|
+
} catch {
|
|
75
|
+
// A file that vanished or can't be read is not worth failing an incoming message over.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bring down whatever the user attached, so the agent is handed a path it can open rather than a
|
|
82
|
+
* Slack URL it cannot read. One that won't come down is still reported, with the reason.
|
|
83
|
+
*/
|
|
84
|
+
async function takeFiles(message, log) {
|
|
85
|
+
const shared = Array.isArray(message.files) ? message.files : [];
|
|
86
|
+
if (shared.length) pruneFiles();
|
|
87
|
+
const saved = [];
|
|
88
|
+
for (const f of shared) {
|
|
89
|
+
const about = { name: f.name ?? f.title ?? f.id, mimetype: f.mimetype ?? "", size: f.size ?? null, permalink: f.permalink ?? null };
|
|
90
|
+
try {
|
|
91
|
+
saved.push({ ...about, path: await downloadFile(f, filesDir()) });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
log(`could not save ${about.name}: ${e.message}`);
|
|
94
|
+
saved.push({ ...about, path: null, error: e.message });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return saved;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ATTENDING = new Set(["listening", "working", "waiting"]);
|
|
101
|
+
const RECENTLY_MS = 2 * 60 * 1000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which reaction says the truth. :eyes: means someone is there — parked on `listen`, or running
|
|
105
|
+
* commands right now. :zzz: means the message is kept but the agent is idle, so it arrives at that
|
|
106
|
+
* agent's next step rather than in a moment. Guessing between those two was the thing the user
|
|
107
|
+
* could not do from Slack.
|
|
108
|
+
*/
|
|
109
|
+
export function reactionFor(route) {
|
|
110
|
+
if (route.kind !== "session") return "eyes";
|
|
111
|
+
const session = sessions.get(route.id);
|
|
112
|
+
if (!session) return "zzz";
|
|
113
|
+
if (session.state === "listening") return "eyes";
|
|
114
|
+
const recent = Date.now() - Date.parse(session.lastSeen ?? 0) < RECENTLY_MS;
|
|
115
|
+
return recent && ATTENDING.has(session.state) ? "eyes" : "zzz";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The world as the router sees it. */
|
|
119
|
+
function snapshot() {
|
|
120
|
+
return {
|
|
121
|
+
progress: progressStore.active(),
|
|
122
|
+
pending: decisions.list().filter((d) => d.status === "pending"),
|
|
123
|
+
sessions: sessions.live(),
|
|
124
|
+
threads: sessions.threads(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Take one message in: route it, act on it, react 👀. Already-seen messages are ignored. */
|
|
129
|
+
export async function ingest(message, { log = () => {}, live = false, waiting = () => false } = {}) {
|
|
130
|
+
if (!isUserMessage(message)) return null;
|
|
131
|
+
// The message is claimed before anything is awaited. Downloading its files takes a while, and
|
|
132
|
+
// the same message can arrive twice in that time (the live event, and a sync after a
|
|
133
|
+
// reconnect): a claim written only afterwards let both through. Every write below re-reads the
|
|
134
|
+
// file first, so an entry another ingest wrote while this one was downloading is kept, not
|
|
135
|
+
// overwritten with the state as it was before the download.
|
|
136
|
+
const state = readState();
|
|
137
|
+
if (state.messages.some((m) => m.ts === message.ts)) return null;
|
|
138
|
+
const channel = message.channel || state.channel;
|
|
139
|
+
const text = String(message.text ?? "");
|
|
140
|
+
const claim = {
|
|
141
|
+
ts: message.ts,
|
|
142
|
+
threadTs: message.thread_ts ?? null,
|
|
143
|
+
channel,
|
|
144
|
+
text,
|
|
145
|
+
user: message.user,
|
|
146
|
+
// Not routed yet: nothing hands out or re-routes a message in this state.
|
|
147
|
+
route: { kind: "pending" },
|
|
148
|
+
receivedAt: new Date().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
writeState({ ...state, channel: state.channel || channel, messages: [...state.messages, claim] });
|
|
151
|
+
|
|
152
|
+
let entry;
|
|
153
|
+
try {
|
|
154
|
+
const files = await takeFiles(message, log);
|
|
155
|
+
const route = routeMessage(message, snapshot());
|
|
156
|
+
entry = { ...claim, ...(files.length ? { files } : {}), route };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
// Let go of the claim, so the next sync can take the message in rather than it being lost.
|
|
159
|
+
const now = readState();
|
|
160
|
+
writeState({ ...now, messages: now.messages.filter((m) => m.ts !== message.ts) });
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
const now = readState();
|
|
164
|
+
const claimed = now.messages.some((m) => m.ts === entry.ts);
|
|
165
|
+
writeState({ ...now, messages: claimed ? now.messages.map((m) => (m.ts === entry.ts ? entry : m)) : [...now.messages, entry] });
|
|
166
|
+
const route = entry.route;
|
|
167
|
+
const files = entry.files ?? [];
|
|
168
|
+
log(`message ${message.ts} → ${route.kind}${route.key ? ` ${route.key}` : route.id ? ` ${route.id}` : ""}`);
|
|
169
|
+
|
|
170
|
+
await addReaction(channel, message.ts, reactionFor(route)).catch((e) => log(`reaction failed: ${e.message}`));
|
|
171
|
+
|
|
172
|
+
if (route.kind === "progress") {
|
|
173
|
+
await addNote(route.key, message, channel, files);
|
|
174
|
+
if (route.session) sessions.bindThread(message.thread_ts ?? message.ts, route.session);
|
|
175
|
+
} else if (route.kind === "decision") {
|
|
176
|
+
const settled = await decisions.settle(route.id, {
|
|
177
|
+
status: "answered",
|
|
178
|
+
answer: { index: null, choice: null, text: text.trim(), by: message.user, via: "message", ...(files.length ? { files } : {}) },
|
|
179
|
+
}, log, { waiting: waiting(route.id) });
|
|
180
|
+
if (settled) await addReaction(channel, message.ts, "white_check_mark").catch(() => {});
|
|
181
|
+
} else if (route.kind === "session") {
|
|
182
|
+
deliver(route.id, { text: route.text ?? text, by: message.user, at: tsIso(message.ts), ts: message.ts, threadTs: message.thread_ts ?? null, channel, via: route.via, ...(files.length ? { files } : {}) });
|
|
183
|
+
sessions.bindThread(message.thread_ts ?? message.ts, route.id);
|
|
184
|
+
if (route.via === "listening") await sayWhereItWent(entry, route.id, log);
|
|
185
|
+
} else if (route.kind === "ask") {
|
|
186
|
+
await askWhichSession(entry, log);
|
|
187
|
+
} else if (route.kind === "inbox" && live) {
|
|
188
|
+
await postToChannel(channel, {
|
|
189
|
+
text: "📥 No agent is running right now, so nobody can read this yet. It's saved, and the next agent you start will get it when it checks its messages.",
|
|
190
|
+
thread_ts: message.ts,
|
|
191
|
+
}).catch((e) => log(`inbox reply failed: ${e.message}`));
|
|
192
|
+
}
|
|
193
|
+
return entry;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A note on a progress message, shown in it and delivered at the agent's next check. */
|
|
197
|
+
async function addNote(key, message, channel, files = []) {
|
|
198
|
+
const p = progressStore.current(key);
|
|
199
|
+
if (!p) return;
|
|
200
|
+
const note = { text: String(message.text ?? ""), by: message.user, at: tsIso(message.ts), slackTs: message.ts, channel, ...(files.length ? { files } : {}) };
|
|
201
|
+
const next = { ...p, notes: [...(p.notes ?? []), note] };
|
|
202
|
+
progressStore.save(key, next);
|
|
203
|
+
await updateMessage(p.channel, p.ts, {
|
|
204
|
+
text: stripTags(p.headline),
|
|
205
|
+
blocks: progressBlocks({ ...next, key }),
|
|
206
|
+
color: p.colour,
|
|
207
|
+
}).catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function deliver(sessionId, message) {
|
|
211
|
+
sessions.enqueue(sessionId, message);
|
|
212
|
+
sessions.touch(sessionId, {});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Several agents are running: ask which one, in the thread of the message itself. */
|
|
216
|
+
/** Own the guess: say which agent got it, and put the others one tap away. */
|
|
217
|
+
async function sayWhereItWent(entry, sessionId, log) {
|
|
218
|
+
const chosen = sessions.get(sessionId);
|
|
219
|
+
if (!chosen) return;
|
|
220
|
+
const decorated = sessions.decorate(chosen);
|
|
221
|
+
const others = sessions.live().filter((s) => s.id !== sessionId);
|
|
222
|
+
try {
|
|
223
|
+
const note = await postToChannel(entry.channel, {
|
|
224
|
+
text: `Sent to ${chosen.nickname}`,
|
|
225
|
+
blocks: sentToBlocks({ text: entry.text, session: decorated, others, messageTs: entry.ts }),
|
|
226
|
+
thread_ts: entry.ts,
|
|
227
|
+
color: decorated.colour,
|
|
228
|
+
});
|
|
229
|
+
const state = readState();
|
|
230
|
+
writeState({ ...state, messages: state.messages.map((m) => (m.ts === entry.ts ? { ...m, pickerTs: note.ts } : m)) });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
log(`could not say where the message went: ${e.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function askWhichSession(entry, log) {
|
|
237
|
+
const live = sessions.live();
|
|
238
|
+
try {
|
|
239
|
+
const picker = await postToChannel(entry.channel, {
|
|
240
|
+
text: "Which agent is this for?",
|
|
241
|
+
blocks: pickerBlocks({ text: entry.text, sessions: live, messageTs: entry.ts }),
|
|
242
|
+
thread_ts: entry.ts,
|
|
243
|
+
});
|
|
244
|
+
const state = readState();
|
|
245
|
+
writeState({
|
|
246
|
+
...state,
|
|
247
|
+
messages: state.messages.map((m) => (m.ts === entry.ts ? { ...m, pickerTs: picker.ts, candidates: live.map((s) => s.id) } : m)),
|
|
248
|
+
});
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// Without buttons the message still isn't lost: it falls back to the inbox.
|
|
251
|
+
log(`could not ask which session: ${e.message}`);
|
|
252
|
+
const state = readState();
|
|
253
|
+
writeState({ ...state, messages: state.messages.map((m) => (m.ts === entry.ts ? { ...m, route: { kind: "inbox" } } : m)) });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The user picked a session from the buttons. The message goes to it, the thread is remembered so
|
|
259
|
+
* the next reply needs no picker, and the buttons are replaced by where it went.
|
|
260
|
+
*/
|
|
261
|
+
export async function resolvePick(messageTs, sessionId, log = () => {}) {
|
|
262
|
+
const state = readState();
|
|
263
|
+
const entry = state.messages.find((m) => m.ts === messageTs);
|
|
264
|
+
// Either the user is answering the picker, or they are correcting a message that was sent to the
|
|
265
|
+
// one agent that happened to be listening.
|
|
266
|
+
if (!entry || (entry.route.kind !== "ask" && entry.route.via !== "listening")) return null;
|
|
267
|
+
|
|
268
|
+
const chosen = sessionId === NOBODY ? null : sessions.get(sessionId);
|
|
269
|
+
const route = chosen ? { kind: "session", id: chosen.id, via: "picked" } : { kind: "inbox" };
|
|
270
|
+
writeState({ ...state, messages: state.messages.map((m) => (m.ts === messageTs ? { ...m, route } : m)) });
|
|
271
|
+
|
|
272
|
+
if (chosen) {
|
|
273
|
+
deliver(chosen.id, { text: entry.text, by: entry.user, at: tsIso(entry.ts), ts: entry.ts, channel: entry.channel, via: "picked" });
|
|
274
|
+
sessions.bindThread(entry.ts, chosen.id);
|
|
275
|
+
log(`message ${messageTs} → ${chosen.nickname}`);
|
|
276
|
+
} else {
|
|
277
|
+
log(`message ${messageTs} → inbox (nobody)`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (entry.pickerTs) {
|
|
281
|
+
await updateMessage(entry.channel, entry.pickerTs, {
|
|
282
|
+
text: chosen ? `Sent to ${chosen.nickname}` : "Kept for the next agent",
|
|
283
|
+
blocks: pickedBlocks({ text: entry.text, session: chosen ? sessions.decorate(chosen) : null }),
|
|
284
|
+
color: chosen ? sessions.decorate(chosen).colour : undefined,
|
|
285
|
+
}).catch((e) => log(`could not update the picker: ${e.message}`));
|
|
286
|
+
}
|
|
287
|
+
return route;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The DM channel with the configured user, remembered once found. Null when Slack isn't a DM. */
|
|
291
|
+
async function dmChannel(state) {
|
|
292
|
+
if (state.channel) return state.channel;
|
|
293
|
+
const target = loadConfig()?.slack?.target ?? "";
|
|
294
|
+
if (!isUserId(target)) return null;
|
|
295
|
+
return openDm(target);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Catch up on messages Slack has that haven't been taken in yet. With an always-on daemon this is
|
|
300
|
+
* only needed after downtime — a reboot, or the daemon being restarted — but it costs little and
|
|
301
|
+
* means nothing sent while nothing was listening is lost. The first sync only sets a starting point,
|
|
302
|
+
* so old conversation history is never replayed.
|
|
303
|
+
*/
|
|
304
|
+
export async function sync({ log = () => {} } = {}) {
|
|
305
|
+
const state = readState();
|
|
306
|
+
const channel = await dmChannel(state).catch(() => null);
|
|
307
|
+
if (!channel) return [];
|
|
308
|
+
const nowTs = (Date.now() / 1000).toFixed(6);
|
|
309
|
+
if (!state.lastTs) {
|
|
310
|
+
// Re-read: finding the DM channel was a network call, and a live message may have landed since.
|
|
311
|
+
writeState({ ...readState(), channel, lastTs: nowTs });
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const found = [...(await history(channel, state.lastTs))];
|
|
316
|
+
const threads = [
|
|
317
|
+
...progressStore.active().map((p) => p.ts),
|
|
318
|
+
...decisions.list().filter((d) => d.status === "pending" && d.channel === channel).map((d) => d.ts),
|
|
319
|
+
...Object.keys(sessions.threads()),
|
|
320
|
+
];
|
|
321
|
+
for (const ts of new Set(threads)) {
|
|
322
|
+
found.push(...(await replies(channel, ts, state.lastTs).catch(() => [])));
|
|
323
|
+
}
|
|
324
|
+
const taken = [];
|
|
325
|
+
for (const m of found.sort((a, b) => Number(a.ts) - Number(b.ts))) {
|
|
326
|
+
if (Number(m.ts) <= Number(state.lastTs)) continue;
|
|
327
|
+
const entry = await ingest({ ...m, channel }, { log });
|
|
328
|
+
if (entry) taken.push(entry);
|
|
329
|
+
}
|
|
330
|
+
const latest = readState();
|
|
331
|
+
const maxTs = found.reduce((max, m) => (Number(m.ts) > Number(max) ? m.ts : max), state.lastTs);
|
|
332
|
+
writeState({ ...latest, channel, lastTs: maxTs });
|
|
333
|
+
return taken;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Mark messages as read, so nothing an agent has already been given is handed out twice. */
|
|
337
|
+
function markRead(timestamps) {
|
|
338
|
+
const seen = new Set(timestamps.filter(Boolean));
|
|
339
|
+
if (!seen.size) return;
|
|
340
|
+
const state = readState();
|
|
341
|
+
const now = new Date().toISOString();
|
|
342
|
+
writeState({ ...state, messages: state.messages.map((m) => (seen.has(m.ts) && !m.readAt ? { ...m, readAt: now } : m)) });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Everything queued for one session, handed over and ✅ in Slack. */
|
|
346
|
+
export async function takeForSession(id) {
|
|
347
|
+
const queued = sessions.drain(id);
|
|
348
|
+
if (!queued.length) return [];
|
|
349
|
+
markRead(queued.map((m) => m.ts));
|
|
350
|
+
// `replyTo` is the thread to answer in: the one the user wrote in, or the message itself.
|
|
351
|
+
return queued.map((m) => ({
|
|
352
|
+
text: m.text,
|
|
353
|
+
by: m.by,
|
|
354
|
+
at: m.at,
|
|
355
|
+
...(m.via ? { via: m.via } : {}),
|
|
356
|
+
...(m.threadTs || m.ts ? { replyTo: m.threadTs || m.ts } : {}),
|
|
357
|
+
...(m.files?.length ? { files: m.files } : {}),
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Unread inbox messages, marked read and ✅: the first agent to ask gets them. Includes messages that
|
|
363
|
+
* were waiting for a session that has since gone, since nobody is coming back for those.
|
|
364
|
+
*/
|
|
365
|
+
export async function takeInbox() {
|
|
366
|
+
const state = readState();
|
|
367
|
+
// A message still sitting in a session's queue when that session goes is nobody's until someone
|
|
368
|
+
// asks for it; one already handed over has been read and never comes back.
|
|
369
|
+
const orphaned = (m) => m.route.kind === "session" && !sessions.isLive(sessions.get(m.route.id));
|
|
370
|
+
const unread = state.messages.filter((m) => (m.route.kind === "inbox" || orphaned(m)) && !m.readAt);
|
|
371
|
+
if (!unread.length) return [];
|
|
372
|
+
const now = new Date().toISOString();
|
|
373
|
+
writeState({ ...state, messages: state.messages.map((m) => (unread.includes(m) ? { ...m, readAt: now } : m)) });
|
|
374
|
+
// No tick here either: an agent taking a message is not the same as answering it.
|
|
375
|
+
return unread.map((m) => ({ text: m.text, by: m.user, at: tsIso(m.ts) }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** ✅ on Slack messages that just became read as progress notes. */
|
|
379
|
+
export async function markNotesRead(notes) {
|
|
380
|
+
await Promise.all(notes.filter((n) => n.slackTs && n.channel).map((n) => addReaction(n.channel, n.slackTs, "white_check_mark").catch(() => {})));
|
|
381
|
+
}
|