@open-everywhere/provider-slack 0.6.0 → 0.8.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/agent/access/slack-attachment-fetch.ts +2 -1
- package/agent/app-manifest.ts +15 -1
- package/agent/channels/slack-agent-session.ts +249 -0
- package/agent/channels/slack-attachments.ts +20 -11
- package/agent/channels/slack-bot-ask.ts +101 -0
- package/agent/channels/slack-commands.ts +178 -17
- package/agent/channels/slack-context.ts +0 -5
- package/agent/channels/slack-events.ts +110 -33
- package/agent/channels/slack-follow-ups.ts +148 -0
- package/agent/channels/slack-friendly-names.ts +55 -0
- package/agent/channels/slack-front.ts +262 -27
- package/agent/channels/slack-home.ts +9 -3
- package/agent/channels/slack-identity.ts +9 -1
- package/agent/channels/slack-memory-canvas.ts +197 -54
- package/agent/channels/slack-origin-codec.ts +9 -0
- package/agent/channels/slack-post-arm.ts +68 -11
- package/agent/channels/slack-read.ts +27 -2
- package/agent/channels/slack-reply-blocks.ts +180 -0
- package/agent/channels/slack-reply-feedback.ts +75 -0
- package/agent/channels/slack-shortcut.ts +1 -1
- package/agent/channels/slack-voice-clips.ts +82 -0
- package/agent/channels/slack-wake-backscroll.ts +0 -5
- package/agent/channels/slack-web-api.ts +4 -2
- package/agent/channels/slack.ts +81 -19
- package/agent/docs-generated.ts +1282 -2
- package/agent/ops.ts +14 -21
- package/agent/register.ts +25 -0
- package/agent/setup/credentials.ts +17 -0
- package/agent/setup/page.ts +6 -3
- package/agent/surface-parity.ts +2 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +15 -1
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-agent-session.d.ts +81 -0
- package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
- package/dist/agent/channels/slack-agent-session.js +198 -0
- package/dist/agent/channels/slack-agent-session.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +11 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
- package/dist/agent/channels/slack-attachments.js +21 -14
- package/dist/agent/channels/slack-attachments.js.map +1 -1
- package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
- package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
- package/dist/agent/channels/slack-bot-ask.js +91 -0
- package/dist/agent/channels/slack-bot-ask.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +113 -10
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +151 -18
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-context.d.ts +1 -2
- package/dist/agent/channels/slack-context.d.ts.map +1 -1
- package/dist/agent/channels/slack-context.js +0 -4
- package/dist/agent/channels/slack-context.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +47 -97
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +90 -22
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
- package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
- package/dist/agent/channels/slack-follow-ups.js +129 -0
- package/dist/agent/channels/slack-follow-ups.js.map +1 -0
- package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
- package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
- package/dist/agent/channels/slack-friendly-names.js +45 -0
- package/dist/agent/channels/slack-friendly-names.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +38 -46
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +230 -23
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-home.d.ts.map +1 -1
- package/dist/agent/channels/slack-home.js +10 -3
- package/dist/agent/channels/slack-home.js.map +1 -1
- package/dist/agent/channels/slack-identity.d.ts +7 -21
- package/dist/agent/channels/slack-identity.d.ts.map +1 -1
- package/dist/agent/channels/slack-identity.js +3 -1
- package/dist/agent/channels/slack-identity.js.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
- package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.js +166 -55
- package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/slack-origin-codec.js +10 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -1
- package/dist/agent/channels/slack-post-arm.d.ts +21 -1
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
- package/dist/agent/channels/slack-post-arm.js +57 -10
- package/dist/agent/channels/slack-post-arm.js.map +1 -1
- package/dist/agent/channels/slack-read.d.ts +11 -1
- package/dist/agent/channels/slack-read.d.ts.map +1 -1
- package/dist/agent/channels/slack-read.js +27 -3
- package/dist/agent/channels/slack-read.js.map +1 -1
- package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
- package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-blocks.js +172 -0
- package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.js +63 -0
- package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +10 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
- package/dist/agent/channels/slack-shortcut.js +1 -1
- package/dist/agent/channels/slack-shortcut.js.map +1 -1
- package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
- package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
- package/dist/agent/channels/slack-voice-clips.js +80 -0
- package/dist/agent/channels/slack-voice-clips.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
- package/dist/agent/channels/slack-wake-backscroll.js +0 -4
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
- package/dist/agent/channels/slack-web-api.js +4 -2
- package/dist/agent/channels/slack-web-api.js.map +1 -1
- package/dist/agent/channels/slack.d.ts +22 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +70 -16
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/docs-generated.d.ts.map +1 -1
- package/dist/agent/docs-generated.js +1282 -2
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +15 -21
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +22 -1
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/setup/credentials.d.ts +12 -0
- package/dist/agent/setup/credentials.d.ts.map +1 -1
- package/dist/agent/setup/credentials.js +17 -0
- package/dist/agent/setup/credentials.js.map +1 -1
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +6 -3
- package/dist/agent/setup/page.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +2 -0
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +40 -3
|
@@ -24,14 +24,23 @@
|
|
|
24
24
|
// reports, until the next memory write or trust event re-runs it (docs/capabilities/memory.md lists
|
|
25
25
|
// every limit).
|
|
26
26
|
//
|
|
27
|
-
// 🔴
|
|
28
|
-
//
|
|
29
|
-
// `
|
|
30
|
-
//
|
|
27
|
+
// 🔴 ONLY THE MEMORY CANVAS IS EVER TOUCHED (OPE-2587). A channel can show more than one canvas (the
|
|
28
|
+
// channel canvas and its canvas tabs). People write their own, and the agent creates canvases for
|
|
29
|
+
// people through `write_canvas`, with the app's own bot user as the creator. So the creator does not
|
|
30
|
+
// mark the memory canvas. The app records the id `conversations.canvases.create` answers in the
|
|
31
|
+
// `memory_canvas` row and rewrites only that canvas (core `memoryCanvasAmong` names the rule, and how
|
|
32
|
+
// a canvas made before the row existed is adopted). Every other canvas stays as its owner left it,
|
|
33
|
+
// unless a person types the memory canvas footer line into it by hand (core `memoryCanvasAmong` rule
|
|
34
|
+
// 3). A channel whose canvas slot another canvas holds gets no memory canvas, and the refusal is logged
|
|
35
|
+
// and audited so an operator can see why a channel shows no memory.
|
|
31
36
|
//
|
|
32
37
|
// 🔴 WHAT A CANVAS DOES NOT DO. It is a READ view. Members correct or forget a line by asking the
|
|
33
38
|
// agent in the channel, which goes through the tools and the approval card like any other change.
|
|
34
39
|
// An edit made directly in the canvas is overwritten on the next memory write — the canvas says so.
|
|
40
|
+
// The agent's own `write_canvas` refuses this canvas for the same reason (core
|
|
41
|
+
// channels/slack-canvas-list.ts `refuseMemoryCanvasWrite`, OPE-2585): both sides find the memory
|
|
42
|
+
// canvas through the one core rule, `memoryCanvasAmong`. It reads the `properties.canvas` field AND
|
|
43
|
+
// every canvas tab (`properties.tabs[]`, OPE-2587), because Slack can hold a channel canvas only as a tab.
|
|
35
44
|
|
|
36
45
|
import {
|
|
37
46
|
agentDisplayName,
|
|
@@ -39,10 +48,21 @@ import {
|
|
|
39
48
|
domainForChannel,
|
|
40
49
|
domainsNamingSilo,
|
|
41
50
|
getConfig,
|
|
51
|
+
getOperationalStore,
|
|
52
|
+
MEMORY_CANVAS_MARKER,
|
|
53
|
+
memoryCanvasAmong,
|
|
54
|
+
memoryCanvasSurfaceKey,
|
|
42
55
|
readDomainMemory,
|
|
56
|
+
storedMemoryCanvas,
|
|
57
|
+
memoryLineForReader,
|
|
43
58
|
renderMemoryMarkdown,
|
|
59
|
+
scrubAuditText,
|
|
60
|
+
type AuditDetail,
|
|
44
61
|
type Domain,
|
|
62
|
+
type MemoryCanvasMatch,
|
|
45
63
|
type MemoryWriteEvent,
|
|
64
|
+
type OperationalStore,
|
|
65
|
+
type SlackCanvasRead,
|
|
46
66
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
47
67
|
import { confirmChannelInternalFresh, slackAuthorIsMember, slackForm } from "./slack-events.js";
|
|
48
68
|
import { encodeSlackForm } from "./slack-web-api.js";
|
|
@@ -85,6 +105,68 @@ export interface MemoryCanvasDeps {
|
|
|
85
105
|
* `users.info` probe (slack-events.ts `slackAuthorIsMember`). */
|
|
86
106
|
readonly authorIsMember: (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
|
|
87
107
|
readonly now?: () => Date;
|
|
108
|
+
/** The `memory_canvas` row (OPE-2587). The live dep is the operational store. */
|
|
109
|
+
readonly store?: Pick<OperationalStore, "getMemoryCanvas" | "setMemoryCanvas">;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The Slack refusal behind a `failed` or `clear-failed` outcome, for the audit row (OPE-2600). `error`
|
|
113
|
+
* is Slack's error code (`missing_scope`, `channel_not_found`, …), or `no_response` when the call gave
|
|
114
|
+
* no answer at all. `needed` is the scope Slack names on a `missing_scope`. Operator data only: no
|
|
115
|
+
* person-facing text carries it. */
|
|
116
|
+
interface CanvasSlackError {
|
|
117
|
+
readonly method: string;
|
|
118
|
+
readonly error: string;
|
|
119
|
+
readonly needed?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The longest Slack-supplied string the audit row keeps. Slack's codes and scope lists are far shorter. */
|
|
123
|
+
const SLACK_ERROR_TEXT_MAX = 120;
|
|
124
|
+
|
|
125
|
+
/** Slack's own text, kept only when it is a short code or scope list: never a free-text blob. */
|
|
126
|
+
function slackCode(v: unknown): string | undefined {
|
|
127
|
+
return typeof v === "string" && v.length > 0 && v.length <= SLACK_ERROR_TEXT_MAX && /^[A-Za-z0-9_.:,-]+$/.test(v) ? v : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wrap the deps of ONE canvas decision so the decision's last Slack refusal is kept (OPE-2600). Every
|
|
132
|
+
* lookup on the canvas path is fail-soft, and core's {@link memoryCanvasAmong} reads only `ok`, so
|
|
133
|
+
* without this a scope gap and a product defect both reach the audit row as a bare `failed`. The
|
|
134
|
+
* LAST refusal of the decision is kept. On most paths that is the call that decided the outcome. An
|
|
135
|
+
* earlier refusal can also have only shaped the path: a refused `auth.test` leaves the bot unknown, and
|
|
136
|
+
* a refused `conversations.members` sends the decision to the take-down. A throw is never a Slack
|
|
137
|
+
* refusal, so the callers drop the refusal on their throw path.
|
|
138
|
+
*/
|
|
139
|
+
function recordSlackRefusals(deps: MemoryCanvasDeps): { deps: MemoryCanvasDeps; refusal: () => CanvasSlackError | undefined } {
|
|
140
|
+
let last: CanvasSlackError | undefined;
|
|
141
|
+
return {
|
|
142
|
+
deps: {
|
|
143
|
+
...deps,
|
|
144
|
+
call: async (method, body) => {
|
|
145
|
+
const res = await deps.call(method, body);
|
|
146
|
+
if (res === undefined) last = { method, error: "no_response" };
|
|
147
|
+
else if (res.ok !== true) {
|
|
148
|
+
const needed = slackCode(res.needed);
|
|
149
|
+
last = { method, error: slackCode(res.error) ?? "unknown", ...(needed ? { needed } : {}) };
|
|
150
|
+
}
|
|
151
|
+
return res;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
refusal: () => last,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The Slack refusal behind an outcome, when it has one: present only on `failed` and `clear-failed`,
|
|
159
|
+
* and only when a Slack call was refused. A failure with no Slack refusal (a store read, a throw)
|
|
160
|
+
* answers undefined. */
|
|
161
|
+
function refusalFor(outcome: CanvasOutcome, refusal: CanvasSlackError | undefined): CanvasSlackError | undefined {
|
|
162
|
+
return outcome === "failed" || outcome === "clear-failed" ? refusal : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The audit detail fields for a refusal: `slackError` (Slack's code, through the audit scrub, as every
|
|
166
|
+
* error-named key must be), `slackMethod`, and `slackNeeded` when Slack names a scope. */
|
|
167
|
+
function slackErrorDetail(e: CanvasSlackError | undefined): AuditDetail {
|
|
168
|
+
if (!e) return {};
|
|
169
|
+
return { slackError: scrubAuditText(e.error), slackMethod: e.method, ...(e.needed ? { slackNeeded: scrubAuditText(e.needed) } : {}) };
|
|
88
170
|
}
|
|
89
171
|
|
|
90
172
|
/** The live deps: every call through `slackForm` (the one Web API seam, fail-soft), the room verdict
|
|
@@ -111,9 +193,10 @@ function memoryCanvasMarkdown(domain: Domain, groups: Parameters<typeof renderMe
|
|
|
111
193
|
const name = agentDisplayName();
|
|
112
194
|
const body = renderMemoryMarkdown(`What ${name} knows — ${domain.title ?? domain.name}`, groups, nowIso);
|
|
113
195
|
const footer =
|
|
114
|
-
`\n---\n${name} keeps this canvas up to date
|
|
115
|
-
|
|
116
|
-
|
|
196
|
+
`\n---\n${name} keeps this canvas up to date. To correct or forget a line, ask ${name} in this channel. ` +
|
|
197
|
+
`${MEMORY_CANVAS_MARKER}\n`;
|
|
198
|
+
// OPE-2486 — the memory provenance carriers read as plain words on a canvas a whole channel reads.
|
|
199
|
+
const text = defuseCanvasText(memoryLineForReader(body));
|
|
117
200
|
const cut = text.length > CANVAS_MARKDOWN_MAX ? text.slice(0, CANVAS_MARKDOWN_MAX) + "\n\n_This canvas is full. Ask in this channel for the rest._\n" : text;
|
|
118
201
|
return cut + footer;
|
|
119
202
|
}
|
|
@@ -181,8 +264,10 @@ function withdrawnCanvasMarkdown(): string {
|
|
|
181
264
|
const name = agentDisplayName();
|
|
182
265
|
return (
|
|
183
266
|
`# ${name}'s memory canvas was withdrawn\n\n` +
|
|
184
|
-
`This channel
|
|
185
|
-
`The memory itself is unchanged. Members can still ask ${name} about it
|
|
267
|
+
`This channel includes guests or people from outside your organisation, so ${name} no longer shows its memory here. ` +
|
|
268
|
+
`The memory itself is unchanged. Members can still ask ${name} about it in an internal channel. ` +
|
|
269
|
+
// The marker keeps the withdrawn canvas the memory canvas, so a later refresh rewrites it in place.
|
|
270
|
+
`${MEMORY_CANVAS_MARKER}\n`
|
|
186
271
|
);
|
|
187
272
|
}
|
|
188
273
|
|
|
@@ -190,78 +275,121 @@ function withdrawnCanvasMarkdown(): string {
|
|
|
190
275
|
* longer belongs to a domain that shows one (`memoryCanvas: false`, no `memory`, or no domain). */
|
|
191
276
|
type WithdrawReason = "external" | "guest" | "disabled";
|
|
192
277
|
|
|
278
|
+
/** The channel one canvas decision is about, and the org and team its `memory_canvas` row is keyed by. */
|
|
279
|
+
export interface CanvasRoom {
|
|
280
|
+
readonly org: string;
|
|
281
|
+
readonly teamId: string;
|
|
282
|
+
readonly channelId: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Slack's answer when it answered `ok`, else undefined: the read shape core's rule takes. */
|
|
286
|
+
function readOk(deps: MemoryCanvasDeps): SlackCanvasRead {
|
|
287
|
+
return async (method, params) => {
|
|
288
|
+
const res = await deps.call(method, params);
|
|
289
|
+
return res?.ok === true ? res : undefined;
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The memory canvas among the channel's canvases, by core's one rule (OPE-2587). */
|
|
294
|
+
async function findMemoryCanvas(room: CanvasRoom, info: Record<string, unknown>, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<MemoryCanvasMatch> {
|
|
295
|
+
const stored = await storedMemoryCanvas(deps.store ?? getOperationalStore(), room.org, memoryCanvasSurfaceKey(room.teamId, room.channelId));
|
|
296
|
+
return memoryCanvasAmong(readOk(deps), info, stored, botUserId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Record the room's memory canvas id. A failed write is not an outcome: the row then names an older id
|
|
300
|
+
* or none, the canvas carries {@link MEMORY_CANVAS_MARKER}, and core's rule scans for the marker
|
|
301
|
+
* whenever the row's id is not on screen. So the next decision finds this canvas again. */
|
|
302
|
+
async function recordMemoryCanvas(room: CanvasRoom, canvasId: string, deps: MemoryCanvasDeps): Promise<void> {
|
|
303
|
+
const at = (deps.now?.() ?? new Date()).toISOString();
|
|
304
|
+
await (deps.store ?? getOperationalStore()).setMemoryCanvas(room.org, memoryCanvasSurfaceKey(room.teamId, room.channelId), canvasId, at).catch(() => undefined);
|
|
305
|
+
}
|
|
306
|
+
|
|
193
307
|
/**
|
|
194
|
-
* Take the
|
|
195
|
-
* `conversations.info`,
|
|
196
|
-
* a skip — the caller cannot rule out that the
|
|
197
|
-
* say so. `skipped-*` is kept for the
|
|
198
|
-
*
|
|
308
|
+
* Take the memory canvas down in one channel, if it has one. FAIL-CLOSED ON EVERY LOOKUP: a failed
|
|
309
|
+
* `conversations.info`, store read, `files.info` or section lookup, or an unknown bot identity, answers
|
|
310
|
+
* `clear-failed`, never a skip — the caller cannot rule out that the memory canvas is still on screen,
|
|
311
|
+
* and the audit row must say so. `skipped-*` is kept for the answer that PROVES no memory canvas is on
|
|
312
|
+
* screen ({@link memoryCanvasAmong} answers `none`). Only the memory canvas is cleared: a person's
|
|
313
|
+
* canvas, and a canvas the agent made for a person through `write_canvas`, are not touched (OPE-2587),
|
|
314
|
+
* unless a person typed the memory canvas footer line into one by hand ({@link memoryCanvasAmong} rule 3).
|
|
199
315
|
*/
|
|
200
|
-
async function withdrawIfOurs(
|
|
201
|
-
const info = await deps.call("conversations.info", { channel: channelId });
|
|
316
|
+
async function withdrawIfOurs(room: CanvasRoom, reason: WithdrawReason, botUserId: string | undefined, deps: MemoryCanvasDeps): Promise<CanvasOutcome> {
|
|
317
|
+
const info = await deps.call("conversations.info", { channel: room.channelId });
|
|
202
318
|
if (info?.ok !== true) return "clear-failed";
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
if (file?.ok !== true) return "clear-failed";
|
|
207
|
-
const creator = (file.file as { user?: unknown } | undefined)?.user;
|
|
208
|
-
if (botUserId === undefined) return "clear-failed";
|
|
209
|
-
if (creator !== botUserId) return `skipped-${reason}`;
|
|
319
|
+
const match = await findMemoryCanvas(room, info, botUserId, deps);
|
|
320
|
+
if (match.kind === "unsure") return "clear-failed";
|
|
321
|
+
if (match.kind === "none") return `skipped-${reason}`;
|
|
210
322
|
const cleared = await deps.call("canvases.edit", {
|
|
211
|
-
canvas_id:
|
|
323
|
+
canvas_id: match.canvasId,
|
|
212
324
|
changes: [{ operation: "replace", document_content: { type: "markdown", markdown: withdrawnCanvasMarkdown() } }],
|
|
213
325
|
});
|
|
214
|
-
|
|
326
|
+
if (cleared?.ok !== true) return "clear-failed";
|
|
327
|
+
if (match.adopt) await recordMemoryCanvas(room, match.canvasId, deps);
|
|
328
|
+
return `cleared-${reason}`;
|
|
215
329
|
}
|
|
216
330
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
331
|
+
/** Slack's refusals of `conversations.canvases.create` that mean the channel's canvas slot holds a
|
|
332
|
+
* canvas that is not the memory canvas: a person's channel canvas, or the one tab a free plan allows,
|
|
333
|
+
* which a person's canvas or a canvas the agent made through `write_canvas` can hold. */
|
|
334
|
+
const CANVAS_SLOT_TAKEN = new Set(["channel_canvas_already_exists", "free_team_canvas_tab_already_exists"]);
|
|
221
335
|
|
|
222
336
|
/**
|
|
223
337
|
* Write one channel's canvas — or, with `markdown: null`, only RECHECK it. Never throws.
|
|
224
338
|
*
|
|
225
|
-
* 🔴 THE GATE RUNS BEFORE EVERY DECISION, AND A FAILED GATE TAKES THE
|
|
339
|
+
* 🔴 THE GATE RUNS BEFORE EVERY DECISION, AND A FAILED GATE TAKES THE MEMORY CANVAS DOWN (OPE-2451,
|
|
226
340
|
* the brain's security review). A canvas the app wrote while the channel passed outlives the room's
|
|
227
341
|
* trust otherwise: once the channel is shared, or a guest joins, a refresh that merely SKIPS leaves
|
|
228
342
|
* every line of the domain's memory on screen for the new audience. So on either refusal the app
|
|
229
|
-
* replaces
|
|
230
|
-
*
|
|
343
|
+
* replaces the memory canvas with {@link withdrawnCanvasMarkdown} ({@link withdrawIfOurs}).
|
|
344
|
+
*
|
|
345
|
+
* 🔴 ONLY THE MEMORY CANVAS IS WRITTEN (OPE-2587). Slack shows a channel's canvases as tabs, and the
|
|
346
|
+
* canvas a person approves through `write_canvas` has the app's bot user as its creator too. So the app
|
|
347
|
+
* rewrites only the canvas core's {@link memoryCanvasAmong} finds: the id its `memory_canvas` row
|
|
348
|
+
* holds, or, for a canvas made before the row existed, the rule that function names. A channel with no
|
|
349
|
+
* memory canvas gets one, and the row records its id. A person's canvas and a `write_canvas` canvas are
|
|
350
|
+
* not touched, in either direction, unless a person typed the memory canvas footer line into one by
|
|
351
|
+
* hand ({@link memoryCanvasAmong} rule 3). A lookup that leaves the answer open writes nothing (`failed`),
|
|
352
|
+
* because a new canvas could then duplicate the memory canvas.
|
|
231
353
|
*
|
|
232
354
|
* `null` markdown is the recheck the trust EVENTS run: it takes the canvas down on a failed gate and
|
|
233
355
|
* writes nothing on a passing one (`unchanged`). `joinedUserId` is the person a join event named: they
|
|
234
356
|
* are probed DIRECTLY, because Slack's member list may not carry them yet when the event arrives.
|
|
235
357
|
*/
|
|
236
358
|
export async function publishChannelCanvas(
|
|
237
|
-
|
|
359
|
+
room: CanvasRoom,
|
|
238
360
|
markdown: string | null,
|
|
239
361
|
botUserId: string | undefined,
|
|
240
362
|
deps: MemoryCanvasDeps,
|
|
241
|
-
teamId?: string,
|
|
242
363
|
joinedUserId?: string,
|
|
243
364
|
): Promise<CanvasOutcome> {
|
|
365
|
+
const { channelId, teamId } = room;
|
|
244
366
|
const blocked: "external" | "guest" | undefined = !(await deps.channelInternal(channelId))
|
|
245
367
|
? "external"
|
|
246
368
|
: (joinedUserId !== undefined && joinedUserId !== botUserId && (await deps.authorIsMember(joinedUserId, teamId)) !== true) ||
|
|
247
369
|
!(await everyMemberIsFull(channelId, teamId, botUserId, deps))
|
|
248
370
|
? "guest"
|
|
249
371
|
: undefined;
|
|
250
|
-
if (blocked) return withdrawIfOurs(
|
|
372
|
+
if (blocked) return withdrawIfOurs(room, blocked, botUserId, deps);
|
|
251
373
|
if (markdown === null) return "unchanged";
|
|
252
374
|
const info = await deps.call("conversations.info", { channel: channelId });
|
|
253
375
|
if (info?.ok !== true) return "failed";
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
376
|
+
const match = await findMemoryCanvas(room, info, botUserId, deps);
|
|
377
|
+
if (match.kind === "unsure") return "failed";
|
|
378
|
+
if (match.kind === "none") {
|
|
256
379
|
const created = await deps.call("conversations.canvases.create", { channel_id: channelId, document_content: { type: "markdown", markdown } });
|
|
257
|
-
|
|
380
|
+
if (created?.ok !== true) return typeof created?.error === "string" && CANVAS_SLOT_TAKEN.has(created.error) ? "skipped-foreign" : "failed";
|
|
381
|
+
if (typeof created.canvas_id === "string" && created.canvas_id.length > 0) await recordMemoryCanvas(room, created.canvas_id, deps);
|
|
382
|
+
return "created";
|
|
258
383
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
384
|
+
const edited = await deps.call("canvases.edit", { canvas_id: match.canvasId, changes: [{ operation: "replace", document_content: { type: "markdown", markdown } }] });
|
|
385
|
+
if (edited?.ok !== true) return "failed";
|
|
386
|
+
if (match.adopt) await recordMemoryCanvas(room, match.canvasId, deps);
|
|
387
|
+
return "updated";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The deploy-log tail for a Slack refusal: the method and code, and the scope Slack names. */
|
|
391
|
+
function slackErrorLog(e: CanvasSlackError | undefined): string {
|
|
392
|
+
return e ? ` (${e.method}: ${e.error}${e.needed ? `, needs ${e.needed}` : ""})` : "";
|
|
265
393
|
}
|
|
266
394
|
|
|
267
395
|
/** What woke a canvas decision: a memory write, or one of the two trust events. */
|
|
@@ -288,16 +416,24 @@ export async function recheckChannelCanvas(
|
|
|
288
416
|
deps: MemoryCanvasDeps,
|
|
289
417
|
): Promise<CanvasOutcome> {
|
|
290
418
|
const surfaceKey = `slack/${input.teamId}/${input.channelId}`;
|
|
419
|
+
const room: CanvasRoom = { org: input.org, teamId: input.teamId, channelId: input.channelId };
|
|
291
420
|
const domain = domainForChannel(surfaceKey, getConfig().loadDomains());
|
|
292
421
|
const shows = domain !== undefined && domain.memory !== undefined && domain.memoryCanvas !== false;
|
|
293
|
-
|
|
422
|
+
// The recorder starts before `auth.test`: an unknown bot identity is a refusal the row should name.
|
|
423
|
+
const recorded = recordSlackRefusals(deps);
|
|
424
|
+
const auth = await recorded.deps.call("auth.test", {});
|
|
294
425
|
const botUserId = auth?.ok === true && typeof auth.user_id === "string" ? auth.user_id : undefined;
|
|
426
|
+
let threw = false;
|
|
295
427
|
const outcome = await (shows
|
|
296
|
-
? publishChannelCanvas(
|
|
297
|
-
: withdrawIfOurs(
|
|
298
|
-
).catch((): CanvasOutcome =>
|
|
428
|
+
? publishChannelCanvas(room, null, botUserId, recorded.deps, input.joinedUserId)
|
|
429
|
+
: withdrawIfOurs(room, "disabled", botUserId, recorded.deps)
|
|
430
|
+
).catch((): CanvasOutcome => {
|
|
431
|
+
threw = true;
|
|
432
|
+
return "clear-failed";
|
|
433
|
+
});
|
|
434
|
+
const refusal = threw ? undefined : refusalFor(outcome, recorded.refusal());
|
|
299
435
|
if (outcome !== "unchanged" && outcome !== "skipped-disabled") {
|
|
300
|
-
console.log(`[open-everywhere:slack-canvas] ${input.trigger} recheck, channel ${input.channelId}: ${outcome}`);
|
|
436
|
+
console.log(`[open-everywhere:slack-canvas] ${input.trigger} recheck, channel ${input.channelId}: ${outcome}${slackErrorLog(refusal)}`);
|
|
301
437
|
}
|
|
302
438
|
await appendAudit({
|
|
303
439
|
org: input.org,
|
|
@@ -305,7 +441,7 @@ export async function recheckChannelCanvas(
|
|
|
305
441
|
workspace: input.teamId,
|
|
306
442
|
surface: input.channelId,
|
|
307
443
|
action: MEMORY_CANVAS_ACTION,
|
|
308
|
-
detail: { domain: domain?.name ?? null, trigger: input.trigger, outcome },
|
|
444
|
+
detail: { domain: domain?.name ?? null, trigger: input.trigger, outcome, ...slackErrorDetail(refusal) },
|
|
309
445
|
}).catch(() => undefined);
|
|
310
446
|
return outcome;
|
|
311
447
|
}
|
|
@@ -335,11 +471,18 @@ export async function refreshMemoryCanvases(event: MemoryWriteEvent, deps: Memor
|
|
|
335
471
|
const markdown = shows ? memoryCanvasMarkdown(domain, await readDomainMemory(event.org, domain), nowIso) : "";
|
|
336
472
|
for (const room of rooms) {
|
|
337
473
|
// A throw on the clear-only path cannot rule out that the old canvas is still up, so it says so.
|
|
338
|
-
const
|
|
339
|
-
|
|
474
|
+
const canvasRoom: CanvasRoom = { org: event.org, teamId, channelId: room.channelId };
|
|
475
|
+
const recorded = recordSlackRefusals(deps);
|
|
476
|
+
let threw = false;
|
|
477
|
+
const outcome = await (shows ? publishChannelCanvas(canvasRoom, markdown, botUserId, recorded.deps) : withdrawIfOurs(canvasRoom, "disabled", botUserId, recorded.deps)).catch(
|
|
478
|
+
(): CanvasOutcome => {
|
|
479
|
+
threw = true;
|
|
480
|
+
return shows ? "failed" : "clear-failed";
|
|
481
|
+
},
|
|
340
482
|
);
|
|
483
|
+
const refusal = threw ? undefined : refusalFor(outcome, recorded.refusal());
|
|
341
484
|
if (outcome !== "created" && outcome !== "updated") {
|
|
342
|
-
console.log(`[open-everywhere:slack-canvas] domain "${domain.name}" channel ${room.channelId}: ${outcome}`);
|
|
485
|
+
console.log(`[open-everywhere:slack-canvas] domain "${domain.name}" channel ${room.channelId}: ${outcome}${slackErrorLog(refusal)}`);
|
|
343
486
|
}
|
|
344
487
|
const [platform, workspace, surface] = room.surfaceKey.split("/");
|
|
345
488
|
await appendAudit({
|
|
@@ -348,7 +491,7 @@ export async function refreshMemoryCanvases(event: MemoryWriteEvent, deps: Memor
|
|
|
348
491
|
workspace: workspace!,
|
|
349
492
|
surface: surface!,
|
|
350
493
|
action: MEMORY_CANVAS_ACTION,
|
|
351
|
-
detail: { domain: domain.name, trigger: "memory-write", silo: event.silo, outcome, chars: markdown.length },
|
|
494
|
+
detail: { domain: domain.name, trigger: "memory-write", silo: event.silo, outcome, chars: markdown.length, ...slackErrorDetail(refusal) },
|
|
352
495
|
}).catch(() => undefined);
|
|
353
496
|
}
|
|
354
497
|
}
|
|
@@ -63,6 +63,15 @@ export const slackWakeOriginCodec: WakeOriginCodec<SlackOrigin> = {
|
|
|
63
63
|
const ts = typeof origin.threadTs === "string" && origin.threadTs.length > 0 ? origin.threadTs : typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : undefined;
|
|
64
64
|
return ts === undefined ? undefined : { channelId: origin.channelId, messageId: ts };
|
|
65
65
|
},
|
|
66
|
+
// THE THREAD A REPLY OPENS (OPE-2532). A post that is already in a thread answers undefined, because its
|
|
67
|
+
// own `threadTs` is the key. A post placed under an anchor lands in the anchor's thread (the post arm
|
|
68
|
+
// reads `threadTs ?? replyAnchorTs`), and Slack threads do not nest, so a reply carries the anchor. A
|
|
69
|
+
// top-level post opens a thread under its own ts.
|
|
70
|
+
replyThreadUnder: (origin, messageId) => {
|
|
71
|
+
if (typeof origin.threadTs === "string" && origin.threadTs.length > 0) return undefined;
|
|
72
|
+
const anchor = typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : messageId;
|
|
73
|
+
return { platform: "slack", channelId: origin.channelId, threadTs: anchor };
|
|
74
|
+
},
|
|
66
75
|
// A `dmOrigin` arm lived here (OPE-306) to mint a DM delegation anchor's reply origin from the
|
|
67
76
|
// binding. OPE-368 deleted the anchor and the interface member with it — a Slack 1:1 or group DM
|
|
68
77
|
// now diverts into its own first-class window (OPE-351 stages 2 + 4), so its origin is minted and
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { slackForm, botDisplayName } from "./slack-events.js";
|
|
8
8
|
import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
|
|
9
|
+
import { isSlackBlocksRefusal, slackReplyBlocks } from "./slack-reply-blocks.js";
|
|
9
10
|
import { markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
|
|
10
11
|
import type {
|
|
11
12
|
PostArm,
|
|
13
|
+
PostIntent,
|
|
12
14
|
TargetResolveBag,
|
|
13
15
|
ReplyTarget,
|
|
14
16
|
ReplierLabel,
|
|
@@ -131,11 +133,39 @@ function classifiedPostError(res: SlackPostResult | undefined, message: string):
|
|
|
131
133
|
return res?.ok === false ? markNotApplied(err) : err;
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Post an ANSWER (OPE-2520): `base` carries the channel, the thread and the mrkdwn `text`; when the
|
|
138
|
+
* intent asks for source links or the native thumbs, the first attempt adds the answer's `blocks`
|
|
139
|
+
* (slack-reply-blocks.ts). A post Slack refuses FOR ITS BLOCKS is retried once without them, so a
|
|
140
|
+
* workspace that rejects a block still gets the answer. Only that refusal retries: Slack answered
|
|
141
|
+
* `ok:false`, so nothing posted and the plain retry cannot double the message.
|
|
142
|
+
*/
|
|
143
|
+
export async function postSlackAnswer(
|
|
144
|
+
base: URLSearchParams,
|
|
145
|
+
intent: PostIntent | undefined,
|
|
146
|
+
replier: ReplierLabel | undefined,
|
|
147
|
+
errPrefix: string,
|
|
148
|
+
post: SlackPostFn = livePost,
|
|
149
|
+
resolveDisplayName: () => Promise<string | undefined> = botDisplayName,
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
const blocks = slackReplyBlocks(base.get("text") ?? "", intent);
|
|
152
|
+
if (blocks === undefined) return postSlackMessage(base, replier, errPrefix, post, resolveDisplayName);
|
|
153
|
+
const withBlocks = new URLSearchParams(base);
|
|
154
|
+
withBlocks.set("blocks", JSON.stringify(blocks));
|
|
155
|
+
try {
|
|
156
|
+
return await postSlackMessage(withBlocks, replier, errPrefix, post, resolveDisplayName);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (!isSlackBlocksRefusal(err)) throw err;
|
|
159
|
+
console.warn("[open-everywhere:slack-post-arm] Slack refused the answer's blocks; posting the text alone");
|
|
160
|
+
return postSlackMessage(base, replier, errPrefix, post, resolveDisplayName);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
134
164
|
/** MID-TURN: post via chat.postMessage (returns `ts`), thread-anchored when the trigger was threaded. */
|
|
135
|
-
async function dispatchSlackReply(target: SlackTarget, text: string, replier?: ReplierLabel): Promise<string> {
|
|
165
|
+
async function dispatchSlackReply(target: SlackTarget, text: string, replier?: ReplierLabel, intent?: PostIntent): Promise<string> {
|
|
136
166
|
const body = new URLSearchParams({ channel: target.channelId, text: markdownToSlackMrkdwn(text) });
|
|
137
167
|
if (target.threadTs) body.set("thread_ts", target.threadTs);
|
|
138
|
-
return
|
|
168
|
+
return postSlackAnswer(body, intent, replier, "reply couldn't post to Slack");
|
|
139
169
|
}
|
|
140
170
|
|
|
141
171
|
/**
|
|
@@ -155,11 +185,11 @@ export function slackThreadTarget(origin: Pick<SlackOrigin, "threadTs" | "replyA
|
|
|
155
185
|
}
|
|
156
186
|
|
|
157
187
|
/** UNIFIED: post into the wake origin's channel/thread (planSupervisorReply's exact semantics). */
|
|
158
|
-
async function liveSlackPost(origin: SlackOrigin, text: string, replier?: ReplierLabel): Promise<string> {
|
|
188
|
+
async function liveSlackPost(origin: SlackOrigin, text: string, replier?: ReplierLabel, intent?: PostIntent): Promise<string> {
|
|
159
189
|
const body = new URLSearchParams({ channel: origin.channelId, text: markdownToSlackMrkdwn(text) });
|
|
160
190
|
const threadTarget = slackThreadTarget(origin);
|
|
161
191
|
if (threadTarget) body.set("thread_ts", threadTarget);
|
|
162
|
-
return
|
|
192
|
+
return postSlackAnswer(body, intent, replier, "unified reply couldn't post to Slack");
|
|
163
193
|
}
|
|
164
194
|
|
|
165
195
|
/**
|
|
@@ -238,9 +268,29 @@ async function deliverSlackDirectMessage(input: DirectMessageRequest): Promise<D
|
|
|
238
268
|
* posted) is the backstop under that, and its refusal arrives as a token-free throw the model reads.
|
|
239
269
|
* `chat.update` needs the same `chat:write` scope the post path already has.
|
|
240
270
|
*/
|
|
241
|
-
async function editSlackMessage(
|
|
242
|
-
|
|
243
|
-
|
|
271
|
+
export async function editSlackMessage(
|
|
272
|
+
locus: EditLocus,
|
|
273
|
+
messageId: string,
|
|
274
|
+
text: string,
|
|
275
|
+
intent?: PostIntent,
|
|
276
|
+
/** The `chat.update` write — the live `slackForm` client; a test supplies a scripted one. */
|
|
277
|
+
form: (method: string, body: string) => Promise<Record<string, unknown> | undefined> = slackForm,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
const rendered = markdownToSlackMrkdwn(text);
|
|
280
|
+
const body = new URLSearchParams({ channel: locus.channelId, ts: messageId, text: rendered });
|
|
281
|
+
// OPE-2520: an edit sends the answer's blocks too. A message posted with blocks shows its blocks,
|
|
282
|
+
// not its `text`, so an edit that sent `text` alone would leave the old answer on screen.
|
|
283
|
+
const blocks = slackReplyBlocks(rendered, intent);
|
|
284
|
+
if (blocks !== undefined) body.set("blocks", JSON.stringify(blocks));
|
|
285
|
+
let res = await form("chat.update", body.toString());
|
|
286
|
+
if (res?.ok !== true && blocks !== undefined && isSlackBlocksRefusal(String(res?.error))) {
|
|
287
|
+
// Slack refused the BLOCKS, so the message did not change. Retry once with the text alone and an
|
|
288
|
+
// EMPTY block list: `chat.update` keeps a message's existing blocks unless new ones are sent
|
|
289
|
+
// (surface-message-ops.ts), so a retry that only dropped the field would leave the old answer on
|
|
290
|
+
// screen while Slack answered `ok`.
|
|
291
|
+
body.set("blocks", "[]");
|
|
292
|
+
res = await form("chat.update", body.toString());
|
|
293
|
+
}
|
|
244
294
|
if (res?.ok !== true) {
|
|
245
295
|
// DEFINITIVE (OPE-802): `chat.update` answers HTTP 200 with `ok:false`, so Slack read the request
|
|
246
296
|
// and decided against it — the message did not change. There is no status to carry that, so the
|
|
@@ -260,14 +310,21 @@ export const slackPostArm: PostArm = {
|
|
|
260
310
|
return channelId ? { platform: "slack", channelId, threadTs: bag.threadTs } : null;
|
|
261
311
|
},
|
|
262
312
|
displayNameCapable: true,
|
|
263
|
-
dispatchReply: (target, text, replier) => dispatchSlackReply(target as SlackTarget, text, replier),
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
dispatchReply: (target, text, replier, intent) => dispatchSlackReply(target as SlackTarget, text, replier, intent),
|
|
314
|
+
// OPE-2558: a Slack target and a Slack origin name the same two facts, a channel and a thread.
|
|
315
|
+
originOfTarget: (target) => {
|
|
316
|
+
const t = target as SlackTarget;
|
|
317
|
+
return { platform: "slack", channelId: t.channelId, ...(t.threadTs !== undefined && t.threadTs.length > 0 ? { threadTs: t.threadTs } : {}) };
|
|
318
|
+
},
|
|
319
|
+
dispatchUnified: (origin, text, deps: UnifiedReplyDeps, replier, intent) =>
|
|
320
|
+
deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPost(origin as SlackOrigin, text, replier, intent),
|
|
321
|
+
// OPE-2520: a `D…` channel is a 1:1 DM between the app and one person. A group DM is `G…`/`C…`.
|
|
322
|
+
isDirectConversation: (locus) => locus.channelId.startsWith("D"),
|
|
266
323
|
describeRedriveTarget: (origin) => {
|
|
267
324
|
const o = origin as SlackOrigin;
|
|
268
325
|
return `slack ${o.threadTs ? `thread in ${o.channelId}` : `channel ${o.channelId}`}`;
|
|
269
326
|
},
|
|
270
327
|
redriveContainerId: (origin) => (origin as SlackOrigin).channelId,
|
|
271
328
|
deliverDirectMessage: deliverSlackDirectMessage,
|
|
272
|
-
editMessage: editSlackMessage,
|
|
329
|
+
editMessage: (locus, messageId, text, intent) => editSlackMessage(locus, messageId, text, intent),
|
|
273
330
|
};
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
markNotApplied,
|
|
26
26
|
type BindableContext,
|
|
27
27
|
type ReadScopeAuditDeps,
|
|
28
|
+
type ReadFile,
|
|
28
29
|
type RenderedLine,
|
|
29
30
|
type WakeReadAddress,
|
|
30
31
|
domainRoster,
|
|
@@ -137,6 +138,27 @@ export interface RenderedMessage {
|
|
|
137
138
|
/** A machine author (`bot_id` present). Excluded from the probe: a bot is below a member but is
|
|
138
139
|
* not a person outside the org — the same exclusion access/taint-signal.ts makes for `relay`. */
|
|
139
140
|
readonly bot?: boolean;
|
|
141
|
+
/** The message's files: id, name and media type only (core channel-read.ts `ReadFile`). */
|
|
142
|
+
readonly files?: readonly ReadFile[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A Slack message's `files` array, reduced to what a reader needs: the file id (`F…`, the id
|
|
147
|
+
* `describe_image` resolves as an attachment), the name (else the title) and the `mimetype`.
|
|
148
|
+
* Every other field is dropped, `url_private` and the permalinks above all: a reader that held a
|
|
149
|
+
* URL could fetch outside the governed attachment seam. A deleted file (`mode: "tombstone"`) and an
|
|
150
|
+
* entry with no id are skipped. PURE.
|
|
151
|
+
*/
|
|
152
|
+
export function slackMessageFiles(raw: unknown): ReadFile[] {
|
|
153
|
+
if (!Array.isArray(raw)) return [];
|
|
154
|
+
const out: ReadFile[] = [];
|
|
155
|
+
for (const f of raw as Array<Record<string, unknown>>) {
|
|
156
|
+
if (typeof f !== "object" || f === null) continue;
|
|
157
|
+
if (typeof f.id !== "string" || f.id.length === 0 || f.mode === "tombstone") continue;
|
|
158
|
+
const name = typeof f.name === "string" && f.name.length > 0 ? f.name : typeof f.title === "string" ? f.title : "";
|
|
159
|
+
out.push({ id: f.id, name, ...(typeof f.mimetype === "string" && f.mimetype.length > 0 ? { mimetype: f.mimetype } : {}) });
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
140
162
|
}
|
|
141
163
|
|
|
142
164
|
/**
|
|
@@ -155,9 +177,11 @@ export function renderMessages(raw: unknown, opts: { oldestFirst?: boolean } = {
|
|
|
155
177
|
if (!Array.isArray(raw)) return [];
|
|
156
178
|
const out: RenderedMessage[] = [];
|
|
157
179
|
for (const m of raw as Array<Record<string, unknown>>) {
|
|
158
|
-
|
|
180
|
+
const files = slackMessageFiles(m.files);
|
|
181
|
+
// Skip joins, edits and system lines. A `file_share` line is a person's upload, so it stays.
|
|
182
|
+
if (typeof m.subtype === "string" && m.subtype.length > 0 && !(m.subtype === "file_share" && files.length > 0)) continue;
|
|
159
183
|
const text = typeof m.text === "string" ? m.text : "";
|
|
160
|
-
if (!text) continue;
|
|
184
|
+
if (!text && files.length === 0) continue;
|
|
161
185
|
const user = typeof m.user === "string" && m.user.length > 0 ? m.user : undefined;
|
|
162
186
|
const botId = typeof m.bot_id === "string" && m.bot_id.length > 0 ? m.bot_id : undefined;
|
|
163
187
|
out.push({
|
|
@@ -165,6 +189,7 @@ export function renderMessages(raw: unknown, opts: { oldestFirst?: boolean } = {
|
|
|
165
189
|
ts: (m.ts as string) ?? "",
|
|
166
190
|
text,
|
|
167
191
|
...(botId !== undefined ? { bot: true } : user !== undefined ? { authorId: user } : {}),
|
|
192
|
+
...(files.length > 0 ? { files } : {}),
|
|
168
193
|
});
|
|
169
194
|
}
|
|
170
195
|
// conversations.history returns newest-first; replies returns oldest-first.
|