@open-everywhere/provider-slack 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 +201 -0
- package/NOTICE +21 -0
- package/README.md +47 -0
- package/agent/access/slack-attachment-fetch.ts +168 -0
- package/agent/app-manifest.ts +149 -0
- package/agent/channels/slack-approval-card.ts +275 -0
- package/agent/channels/slack-attachments.ts +195 -0
- package/agent/channels/slack-commands.ts +227 -0
- package/agent/channels/slack-context.ts +185 -0
- package/agent/channels/slack-events.ts +5406 -0
- package/agent/channels/slack-front.ts +2349 -0
- package/agent/channels/slack-identity.ts +520 -0
- package/agent/channels/slack-input-response.ts +162 -0
- package/agent/channels/slack-mrkdwn.ts +61 -0
- package/agent/channels/slack-origin-codec.ts +62 -0
- package/agent/channels/slack-post-arm.ts +314 -0
- package/agent/channels/slack-read.ts +248 -0
- package/agent/channels/slack-route-hold.ts +107 -0
- package/agent/channels/slack-shortcut.ts +336 -0
- package/agent/channels/slack-signature.ts +53 -0
- package/agent/channels/slack-spend-gate.ts +62 -0
- package/agent/channels/slack-tenant-fence.ts +99 -0
- package/agent/channels/slack-wake-backscroll.ts +604 -0
- package/agent/channels/slack-web-api.ts +178 -0
- package/agent/channels/slack-webhook-verifier.ts +116 -0
- package/agent/channels/slack.ts +1266 -0
- package/agent/contributions.ts +32 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +50 -0
- package/agent/provider-events.ts +91 -0
- package/agent/register.ts +144 -0
- package/agent/surface-parity.ts +99 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
- package/dist/agent/access/slack-attachment-fetch.js +151 -0
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
- package/dist/agent/app-manifest.d.ts +15 -0
- package/dist/agent/app-manifest.d.ts.map +1 -0
- package/dist/agent/app-manifest.js +141 -0
- package/dist/agent/app-manifest.js.map +1 -0
- package/dist/agent/channels/slack-approval-card.d.ts +71 -0
- package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-approval-card.js +232 -0
- package/dist/agent/channels/slack-approval-card.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +66 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
- package/dist/agent/channels/slack-attachments.js +174 -0
- package/dist/agent/channels/slack-attachments.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +27 -0
- package/dist/agent/channels/slack-commands.d.ts.map +1 -0
- package/dist/agent/channels/slack-commands.js +208 -0
- package/dist/agent/channels/slack-commands.js.map +1 -0
- package/dist/agent/channels/slack-context.d.ts +26 -0
- package/dist/agent/channels/slack-context.d.ts.map +1 -0
- package/dist/agent/channels/slack-context.js +178 -0
- package/dist/agent/channels/slack-context.js.map +1 -0
- package/dist/agent/channels/slack-events.d.ts +1654 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -0
- package/dist/agent/channels/slack-events.js +4240 -0
- package/dist/agent/channels/slack-events.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +387 -0
- package/dist/agent/channels/slack-front.d.ts.map +1 -0
- package/dist/agent/channels/slack-front.js +1958 -0
- package/dist/agent/channels/slack-front.js.map +1 -0
- package/dist/agent/channels/slack-identity.d.ts +245 -0
- package/dist/agent/channels/slack-identity.d.ts.map +1 -0
- package/dist/agent/channels/slack-identity.js +405 -0
- package/dist/agent/channels/slack-identity.js.map +1 -0
- package/dist/agent/channels/slack-input-response.d.ts +26 -0
- package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
- package/dist/agent/channels/slack-input-response.js +127 -0
- package/dist/agent/channels/slack-input-response.js.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.js +53 -0
- package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
- package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/slack-origin-codec.js +55 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -0
- package/dist/agent/channels/slack-post-arm.d.ts +54 -0
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/slack-post-arm.js +268 -0
- package/dist/agent/channels/slack-post-arm.js.map +1 -0
- package/dist/agent/channels/slack-read.d.ts +84 -0
- package/dist/agent/channels/slack-read.d.ts.map +1 -0
- package/dist/agent/channels/slack-read.js +205 -0
- package/dist/agent/channels/slack-read.js.map +1 -0
- package/dist/agent/channels/slack-route-hold.d.ts +34 -0
- package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
- package/dist/agent/channels/slack-route-hold.js +97 -0
- package/dist/agent/channels/slack-route-hold.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +162 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
- package/dist/agent/channels/slack-shortcut.js +262 -0
- package/dist/agent/channels/slack-shortcut.js.map +1 -0
- package/dist/agent/channels/slack-signature.d.ts +19 -0
- package/dist/agent/channels/slack-signature.d.ts.map +1 -0
- package/dist/agent/channels/slack-signature.js +50 -0
- package/dist/agent/channels/slack-signature.js.map +1 -0
- package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
- package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
- package/dist/agent/channels/slack-spend-gate.js +48 -0
- package/dist/agent/channels/slack-spend-gate.js.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.js +88 -0
- package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.js +473 -0
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
- package/dist/agent/channels/slack-web-api.d.ts +46 -0
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
- package/dist/agent/channels/slack-web-api.js +147 -0
- package/dist/agent/channels/slack-web-api.js.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.js +112 -0
- package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/slack.d.ts +37 -0
- package/dist/agent/channels/slack.d.ts.map +1 -0
- package/dist/agent/channels/slack.js +1169 -0
- package/dist/agent/channels/slack.js.map +1 -0
- package/dist/agent/contributions.d.ts +17 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +32 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +3 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +38 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +84 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +118 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +95 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +209 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// THE SLACK ARM OF THE HITL-ANSWER ADMISSION (OPE-574).
|
|
2
|
+
//
|
|
3
|
+
// eve's Slack channel accepts an answer to any parked `ask_question` / session-limit prompt from
|
|
4
|
+
// whoever pressed the widget, with no check at all: `defaultOnInputResponse(ctx){return{auth:
|
|
5
|
+
// ctx.defaultAuth}}`. Authoring `onInputResponse` replaces that default with OE code. The decision
|
|
6
|
+
// itself lives in core (approvals/input-response-floor.ts) so the Teams arm reaches the same
|
|
7
|
+
// verdict; this file is the Slack-shaped edge — resolve the partition, hand the facts over, render
|
|
8
|
+
// the refusal.
|
|
9
|
+
//
|
|
10
|
+
// 🔴 FAIL-OPEN ON A THROW, deliberately. eve logs a thrown hook and treats it as REJECTION
|
|
11
|
+
// (`slack/input-response.js` catches and returns `null`), so a transient store or roster blip
|
|
12
|
+
// inside our code would silently make every question unanswerable — the exact outcome the
|
|
13
|
+
// clicker floor's own catch exists to prevent. Anything unexpected therefore returns eve's own
|
|
14
|
+
// default result and says so in the log; only a real verdict refuses.
|
|
15
|
+
//
|
|
16
|
+
// The refusal is EPHEMERAL, not a thread post. The card is still live for someone who may answer,
|
|
17
|
+
// and a public "you may not answer" note in the room adds nothing the answerer does not already
|
|
18
|
+
// need to know. This mirrors the Discord approval refusal (an ephemeral), not the Slack approval
|
|
19
|
+
// refusal (a thread note, which exists there because the CARD is public and its state matters to
|
|
20
|
+
// the room).
|
|
21
|
+
//
|
|
22
|
+
// LEG B HAS TEETH HERE (OPE-589) — the core header's "Slack cannot [answer B]" is now stale. This
|
|
23
|
+
// module reuses `slackClickerIsGuest` (channels/slack-events.ts) — `slackAuthorIsMember`, the SAME
|
|
24
|
+
// `users.info` call OPE-466 already makes to mark a non-member's chat message, inverted to a guest
|
|
25
|
+
// verdict. The clicker floor's own leg D (approvals/flow.ts's `handleApprovalPayload`) reuses the
|
|
26
|
+
// SAME function, so the two planes cannot drift on what "positively a guest" means. An unresolvable
|
|
27
|
+
// probe returns `undefined` and the leg stays INERT, exactly as it did before.
|
|
28
|
+
//
|
|
29
|
+
// 🔴 SPEND GATE (OPE-588), FIRST — ahead of the room/guest admission below. This click RESUMES a
|
|
30
|
+
// parked turn: a decision to spend more, taken through a button, on a scope nobody asked whether it
|
|
31
|
+
// could afford. Every ordinary ingress door consults the ONE shared spend gate before it dispatches
|
|
32
|
+
// a turn (spend/ingress-gate.ts); eve's own interactivity route calls `onInputResponse` and then
|
|
33
|
+
// `.respond()` directly, with no call site of that gate anywhere on the seam — verified against the
|
|
34
|
+
// installed eve/dist. `ingressSpendGate` fails open on a broken check and fails closed (visible
|
|
35
|
+
// refusal, door `"answer"`) on a definite over-cap, the same posture the DM/channel doors already
|
|
36
|
+
// hold. A decline returns eve's own reject-the-answer `null`, so the request stays PENDING — the
|
|
37
|
+
// same outcome a room/guest refusal already leaves.
|
|
38
|
+
|
|
39
|
+
import type {
|
|
40
|
+
SlackInputResponseContext,
|
|
41
|
+
SlackInputResponseResult,
|
|
42
|
+
SlackInputResponseSubmission,
|
|
43
|
+
} from "eve/channels/slack";
|
|
44
|
+
import {
|
|
45
|
+
admitInputResponse,
|
|
46
|
+
ingressSpendGate,
|
|
47
|
+
resolvePartition,
|
|
48
|
+
SLACK_SPEND_BADGES,
|
|
49
|
+
type BindableContext,
|
|
50
|
+
type IngressSpendGateInput,
|
|
51
|
+
type InputResponseAdmission,
|
|
52
|
+
type InputResponseScope,
|
|
53
|
+
type SpendGateDecision,
|
|
54
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
55
|
+
import { slackClickerIsGuest } from "./slack-events.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The RAW `team_id` off the interaction's own auth attributes — never `resolvePartition`'s
|
|
59
|
+
* `workspace` field (OPE-589 review fix). `resolvePartition` degrades an unnamed workspace to the
|
|
60
|
+
* `UNNAMED_TENANT` placeholder ("dev"), which is harmless where `workspace` only ever fed a
|
|
61
|
+
* surfaceKey lookup, but the guest-verdict probe below compares it against Slack's REAL `team_id`
|
|
62
|
+
* (`partnerFactsFromInfo`'s `ownWorkspace` check) — a placeholder there does not mean "no team", it
|
|
63
|
+
* means "a team literally named dev", and would falsely fail that comparison and refuse a genuine
|
|
64
|
+
* member. Reading the wire attribute directly keeps "no team known" as `undefined`. The probe then
|
|
65
|
+
* answers that absence from the operator's register alone (OPE-1508): a declared workspace or Grid
|
|
66
|
+
* still clears leg B, and an undeclared one is a GUEST, because absence infers non-member. Only an
|
|
67
|
+
* unreadable `users.info` leaves leg B inert. This is
|
|
68
|
+
* the same shape `flow.ts`'s `handleApprovalPayload` already reads off the raw Slack
|
|
69
|
+
* payload for the SAME probe on the approval-click side. LOCAL COPY of the attribute read (the
|
|
70
|
+
* `slack-identity.ts` header states the precedent): a three-line structural read over the public
|
|
71
|
+
* auth shape, not worth a barrel export.
|
|
72
|
+
*/
|
|
73
|
+
function slackAuthTeamId(auth: SlackInputResponseContext["defaultAuth"]): string | undefined {
|
|
74
|
+
const v = auth.attributes?.["team_id"];
|
|
75
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
76
|
+
if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SlackInputResponseDeps {
|
|
81
|
+
/** The core admission. Injected in tests; the live default is the shared decision. */
|
|
82
|
+
readonly admit?: (scope: InputResponseScope) => Promise<InputResponseAdmission>;
|
|
83
|
+
/** Render the refusal to the answerer alone. Live default is `chat.postEphemeral` in the thread. */
|
|
84
|
+
readonly notify?: (userId: string, text: string) => Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Positive per-answerer guest verdict for leg B (OPE-589). Live default is `slackClickerIsGuest`
|
|
87
|
+
* (channels/slack-events.ts). `undefined` is ALWAYS inert.
|
|
88
|
+
*/
|
|
89
|
+
readonly guestVerdict?: (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
|
|
90
|
+
/** The shared spend-admission decision (OPE-588). Injected in tests; the live default is
|
|
91
|
+
* `ingressSpendGate` (spend/ingress-gate.ts) — the SAME function every ordinary ingress door
|
|
92
|
+
* consults. */
|
|
93
|
+
readonly spendGate?: (input: IngressSpendGateInput) => Promise<SpendGateDecision>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Admit or refuse one eve-owned HITL answer submitted through Slack interactivity.
|
|
98
|
+
*
|
|
99
|
+
* Returns eve's own default result (`{ auth: ctx.defaultAuth }`) on an allow, so an admitted answer
|
|
100
|
+
* is byte-identical to today's behaviour. Returns `null` on a refusal, which leaves the request
|
|
101
|
+
* PENDING — eve has no cancel on this seam (see the core module's residual note).
|
|
102
|
+
*/
|
|
103
|
+
export async function admitSlackInputResponse(
|
|
104
|
+
ctx: SlackInputResponseContext,
|
|
105
|
+
submission: SlackInputResponseSubmission,
|
|
106
|
+
deps: SlackInputResponseDeps = {},
|
|
107
|
+
): Promise<SlackInputResponseResult> {
|
|
108
|
+
const allow: SlackInputResponseResult = { auth: ctx.defaultAuth };
|
|
109
|
+
try {
|
|
110
|
+
const bindable = {
|
|
111
|
+
channel: { kind: "slack" },
|
|
112
|
+
session: { auth: { current: ctx.defaultAuth } },
|
|
113
|
+
} as BindableContext;
|
|
114
|
+
const p = resolvePartition(bindable);
|
|
115
|
+
const teamId = slackAuthTeamId(ctx.defaultAuth);
|
|
116
|
+
|
|
117
|
+
// SPEND GATE (OPE-588) — FIRST, ahead of the room/guest admission below (see the header note).
|
|
118
|
+
// A decline skips the guest probe entirely (over budget outranks identity) and returns eve's
|
|
119
|
+
// reject-the-answer `null`, leaving the request PENDING exactly like a room/guest refusal.
|
|
120
|
+
const spendGate = deps.spendGate ?? ingressSpendGate;
|
|
121
|
+
const spendVerdict = await spendGate({
|
|
122
|
+
bindable,
|
|
123
|
+
door: "answer",
|
|
124
|
+
principalId: submission.user.id,
|
|
125
|
+
workspaceRef: teamId ?? null,
|
|
126
|
+
badges: SLACK_SPEND_BADGES,
|
|
127
|
+
post: async (text) => {
|
|
128
|
+
await ctx.thread.postEphemeral(submission.user.id, text);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (spendVerdict === "declined") return null;
|
|
132
|
+
|
|
133
|
+
const guestVerdict = deps.guestVerdict ?? slackClickerIsGuest;
|
|
134
|
+
const answererIsGuest = await guestVerdict(submission.user.id, teamId).catch(() => undefined);
|
|
135
|
+
const verdict = await (deps.admit ?? admitInputResponse)({
|
|
136
|
+
org: p.org,
|
|
137
|
+
platform: p.platform,
|
|
138
|
+
workspace: p.workspace,
|
|
139
|
+
surface: p.surface,
|
|
140
|
+
surfaceKey: p.surfaceKey,
|
|
141
|
+
principalId: submission.user.id,
|
|
142
|
+
responses: submission.inputResponses.length,
|
|
143
|
+
// Leg B (OPE-589): a POSITIVE per-answerer guest verdict from `slackAuthorIsMember`, or
|
|
144
|
+
// omitted when the probe could not answer — `undefined` never refuses.
|
|
145
|
+
...(answererIsGuest === undefined ? {} : { answererIsGuest }),
|
|
146
|
+
});
|
|
147
|
+
if (verdict.ok) return allow;
|
|
148
|
+
const notify =
|
|
149
|
+
deps.notify ??
|
|
150
|
+
(async (userId: string, text: string): Promise<void> => {
|
|
151
|
+
await ctx.thread.postEphemeral(userId, text);
|
|
152
|
+
});
|
|
153
|
+
await notify(submission.user.id, verdict.notice).catch((err: unknown) => {
|
|
154
|
+
// The refusal still stands — the human just does not see why. One log line, never a throw.
|
|
155
|
+
console.error(`[open-everywhere:slack] HITL refusal notice failed to post: ${String(err)}`);
|
|
156
|
+
});
|
|
157
|
+
return null;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(`[open-everywhere:slack] HITL answer admission failed softly — accepting the answer: ${String(err)}`);
|
|
160
|
+
return allow;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// slack-mrkdwn — markdown (what the model writes) → Slack's mrkdwn dialect (OPE-870).
|
|
2
|
+
//
|
|
3
|
+
// Slack renders mrkdwn, not GitHub markdown: `*bold*` (not `**bold**`), `_italic_`, `~strike~`,
|
|
4
|
+
// `<url|text>` links, no heading syntax. Every Slack post shipped the model's raw markdown
|
|
5
|
+
// verbatim until this file existed, so `**bold**` and `[text](url)` rendered as literal
|
|
6
|
+
// characters — observed live 2026-08-20, #oe-soak-ops, the channel-join intro's
|
|
7
|
+
// `**Track soak health**`.
|
|
8
|
+
//
|
|
9
|
+
// PURE — no I/O. Applied at the Slack provider's delivery seam (slack-post-arm.ts): the reply
|
|
10
|
+
// tool, edit_reply, and the join intro all post through dispatchSlackReply / liveSlackPost /
|
|
11
|
+
// editSlackMessage, so converting there covers all three without touching core or any other
|
|
12
|
+
// provider (Linear/GitHub take real markdown; Notion has its own block model).
|
|
13
|
+
//
|
|
14
|
+
// Code fences and inline code are protected FIRST and restored LAST — their contents must never
|
|
15
|
+
// change, even when they look like markdown. Text that is already valid Slack mrkdwn (a single
|
|
16
|
+
// `*bold*`, an existing `_italic_`, a `>` blockquote) is left untouched: Slack has no `**`/`__`
|
|
17
|
+
// bold marker, so those are unambiguous GFM syntax safe to convert, but a single `*`/`_` is
|
|
18
|
+
// ALREADY Slack's own syntax, and converting it again would corrupt it. That is also why this
|
|
19
|
+
// function is idempotent — running it twice on its own output is a no-op.
|
|
20
|
+
|
|
21
|
+
const CODE_PLACEHOLDER = "";
|
|
22
|
+
|
|
23
|
+
/** Convert model-authored markdown to Slack mrkdwn. */
|
|
24
|
+
export function markdownToSlackMrkdwn(text: string): string {
|
|
25
|
+
if (!text) return text;
|
|
26
|
+
|
|
27
|
+
// 1. Protect fenced code blocks, then inline code spans — restored verbatim at the end. Fences
|
|
28
|
+
// first: an inline-code regex would otherwise match the single backticks INSIDE a fence.
|
|
29
|
+
const blocks: string[] = [];
|
|
30
|
+
const stash = (s: string): string => {
|
|
31
|
+
const token = `${CODE_PLACEHOLDER}${blocks.length}${CODE_PLACEHOLDER}`;
|
|
32
|
+
blocks.push(s);
|
|
33
|
+
return token;
|
|
34
|
+
};
|
|
35
|
+
let out = text.replace(/```[\s\S]*?```/g, (m) => stash(m));
|
|
36
|
+
out = out.replace(/`[^`\n]+`/g, (m) => stash(m));
|
|
37
|
+
|
|
38
|
+
// 2. Headers → a bold line (Slack has no heading syntax).
|
|
39
|
+
out = out.replace(/^ {0,3}#{1,6}[ \t]+(.+)$/gm, (_m, body: string) => `*${body.trim()}*`);
|
|
40
|
+
|
|
41
|
+
// 3. Links: [text](url) → <url|text>.
|
|
42
|
+
out = out.replace(/\[([^\]\n]+)\]\((\S+?)\)/g, (_m, label: string, url: string) => `<${url}|${label}>`);
|
|
43
|
+
|
|
44
|
+
// 4. Bold: **text** / __text__ → *text* — unambiguous, Slack has no ** or __ marker so this
|
|
45
|
+
// never fires on text that is already Slack mrkdwn.
|
|
46
|
+
out = out.replace(/\*\*([^*\n]+)\*\*/g, (_m, body: string) => `*${body}*`);
|
|
47
|
+
out = out.replace(/__([^_\n]+)__/g, (_m, body: string) => `*${body}*`);
|
|
48
|
+
|
|
49
|
+
// 5. Strikethrough: ~~text~~ → ~text~.
|
|
50
|
+
out = out.replace(/~~([^~\n]+)~~/g, (_m, body: string) => `~${body}~`);
|
|
51
|
+
|
|
52
|
+
// 6. Bullet lists: "- item" / "* item" → "• item". The dash/star must be followed by
|
|
53
|
+
// whitespace to match — a line opening on **bold** or *bold* never qualifies (no space
|
|
54
|
+
// right after the marker), so this never collides with rule 4's untouched single-star text.
|
|
55
|
+
out = out.replace(/^(\s*)[-*][ \t]+/gm, (_m, indent: string) => `${indent}• `);
|
|
56
|
+
|
|
57
|
+
// 7. Restore code spans/fences untouched — no rule above ever saw their contents.
|
|
58
|
+
out = out.replace(new RegExp(`${CODE_PLACEHOLDER}(\\d+)${CODE_PLACEHOLDER}`, "g"), (_m, i: string) => blocks[Number(i)] ?? "");
|
|
59
|
+
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The Slack UnifiedWakeOrigin codec — MOVED VERBATIM out of core's
|
|
2
|
+
// channels/wake-origin-codecs.ts (OPE-114 stage 4). STORED-DATA COMPATIBILITY IS THE
|
|
3
|
+
// CONTRACT: a Slack origin is JSON-serialised into the durable operational store and
|
|
4
|
+
// fail-closed re-validated on read, so every behaviour here is frozen by core's
|
|
5
|
+
// scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's boot
|
|
6
|
+
// wiring — scripts/wake-origin-fixtures-boot.ts). Validator tolerances (typeof-only
|
|
7
|
+
// optional checks, extra unknown fields) are LOAD-BEARING for rows already on disk — never
|
|
8
|
+
// tighten them here.
|
|
9
|
+
//
|
|
10
|
+
// The `UnifiedWakeOrigin` TYPE union (including its slack arm) stays in core — it is the
|
|
11
|
+
// wire schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
|
|
12
|
+
// registerSlackProvider() (../register.ts).
|
|
13
|
+
|
|
14
|
+
import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
|
|
15
|
+
|
|
16
|
+
type SlackOrigin = Extract<UnifiedWakeOrigin, { platform: "slack" }>;
|
|
17
|
+
|
|
18
|
+
/** COLON-FREE by construction (a task windowId is `[^:]+` — core channels/window-key.ts): any colon
|
|
19
|
+
* in a raw surface id is folded to `_`, deterministically, so spawn + ingress agree on identity. */
|
|
20
|
+
const noColon = (s: string): string => s.replace(/:/g, "_");
|
|
21
|
+
|
|
22
|
+
export const slackWakeOriginCodec: WakeOriginCodec<SlackOrigin> = {
|
|
23
|
+
validate: (o) =>
|
|
24
|
+
typeof o.channelId === "string" &&
|
|
25
|
+
o.channelId.length > 0 &&
|
|
26
|
+
(o.threadTs === undefined || typeof o.threadTs === "string") &&
|
|
27
|
+
(o.replyAnchorTs === undefined || typeof o.replyAnchorTs === "string"),
|
|
28
|
+
threadRef: (origin) => ({ channelKey: noColon(`slack/${origin.channelId}`), threadKey: noColon(origin.threadTs ?? origin.channelId) }),
|
|
29
|
+
// threadTs present ⇔ the trigger was itself in a thread (the exact same presence threadRef keys
|
|
30
|
+
// on). Absent = a top-level trigger, which stays top-level for IDENTITY even though its reply
|
|
31
|
+
// opens a thread on it — that placement rides `replyAnchorTs`, which nothing here reads (OPE-759).
|
|
32
|
+
isThreadScoped: (origin) => typeof origin.threadTs === "string" && origin.threadTs.length > 0,
|
|
33
|
+
containerId: (origin) => origin.channelId,
|
|
34
|
+
// THE PLACEMENT CARRY (OPE-1824) — Slack is the one surface that needs it, and the line above says
|
|
35
|
+
// why: `replyAnchorTs` is placement ONLY and stays outside `threadRef`, so two top-level wakes in
|
|
36
|
+
// one channel are ONE conversation holding two different anchors, and a wake minted for a message
|
|
37
|
+
// that answers nothing (a routine fire, a webhook post-back) holds none. The reply fence admits a
|
|
38
|
+
// sibling wake on the conversation test alone; without this carry it would hand the post arm an
|
|
39
|
+
// anchorless origin and the answer would land FLAT on the channel.
|
|
40
|
+
// NARROWING-ONLY, and both guards are load-bearing. A `threadTs` target is already placed — its
|
|
41
|
+
// answer goes in that thread and must never re-anchor on a trigger message (the rule
|
|
42
|
+
// slackThreadTarget states) — and a target that already holds an anchor keeps it, because the model
|
|
43
|
+
// named THAT wake. So this fills exactly one gap: no placement at all, where the source has one.
|
|
44
|
+
// EMPTINESS IS ABSENCE ON ALL THREE READS, matching `slackThreadTarget` and `isThreadScoped`: a
|
|
45
|
+
// stored `""` is not a thread, not an anchor, and not an anchor worth carrying. No mint site writes
|
|
46
|
+
// one today, and reading it three ways in one file is how that stops being true quietly.
|
|
47
|
+
carryPlacement: (target, source) =>
|
|
48
|
+
!(target.threadTs && target.threadTs.length > 0) && !(target.replyAnchorTs && target.replyAnchorTs.length > 0) && typeof source.replyAnchorTs === "string" && source.replyAnchorTs.length > 0
|
|
49
|
+
? { ...target, replyAnchorTs: source.replyAnchorTs }
|
|
50
|
+
: target,
|
|
51
|
+
// THE PLACEMENT AXIS ITSELF (OPE-1826) — the declaration `carryPlacement` above always implied and
|
|
52
|
+
// never stated. `replyAnchorTs` is the whole of it: `threadTs` is IDENTITY (it is what `threadRef`
|
|
53
|
+
// keys on and what `isThreadScoped` answers from), so a threaded origin's placement is already
|
|
54
|
+
// settled by the conversation test and naming it here would make two origins of one thread compare
|
|
55
|
+
// unequal on an axis that cannot differ. EMPTINESS IS ABSENCE, the third of the three reads this
|
|
56
|
+
// file takes that way.
|
|
57
|
+
placementRef: (origin) => (typeof origin.replyAnchorTs === "string" && origin.replyAnchorTs.length > 0 ? origin.replyAnchorTs : undefined),
|
|
58
|
+
// A `dmOrigin` arm lived here (OPE-306) to mint a DM delegation anchor's reply origin from the
|
|
59
|
+
// binding. OPE-368 deleted the anchor and the interface member with it — a Slack 1:1 or group DM
|
|
60
|
+
// now diverts into its own first-class window (OPE-351 stages 2 + 4), so its origin is minted and
|
|
61
|
+
// STORED at dispatch time by `scheduleDmWake` like every other wake.
|
|
62
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// slack-post-arm — Slack's POST arm for the post-arms registry (OPE-114 stage 1c). Absorbs, VERBATIM,
|
|
2
|
+
// Slack's arms out of the three reply/delivery spines: reply-post.ts (resolveReplyTarget's slack
|
|
3
|
+
// case + dispatchReply's chat.postMessage branch), unified-reply.ts (liveSlackPost), and
|
|
4
|
+
// unified-redrive.ts (the slack describe/container cases). Slack's ONE bot-token write client is
|
|
5
|
+
// slackForm (slack-events.ts) — every Slack post here funnels through it.
|
|
6
|
+
|
|
7
|
+
import { slackForm, botDisplayName } from "./slack-events.js";
|
|
8
|
+
import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
|
|
9
|
+
import { markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
|
|
10
|
+
import type {
|
|
11
|
+
PostArm,
|
|
12
|
+
TargetResolveBag,
|
|
13
|
+
ReplyTarget,
|
|
14
|
+
ReplierLabel,
|
|
15
|
+
EditLocus,
|
|
16
|
+
UnifiedWakeOrigin,
|
|
17
|
+
UnifiedReplyDeps,
|
|
18
|
+
DirectMessageRequest,
|
|
19
|
+
DirectMessageDelivery,
|
|
20
|
+
DirectMessageAction,
|
|
21
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
22
|
+
|
|
23
|
+
type SlackTarget = Extract<ReplyTarget, { platform: "slack" }>;
|
|
24
|
+
type SlackOrigin = Extract<UnifiedWakeOrigin, { platform: "slack" }>;
|
|
25
|
+
|
|
26
|
+
// --- OPE-508: the reply TIER signal, native on Slack ------------------------------------------
|
|
27
|
+
//
|
|
28
|
+
// Slack is the one surface OE's send path can vary the display NAME on, per message: chat.postMessage
|
|
29
|
+
// takes an optional `username` override on a bot-token post — needs the `chat:write.customize` scope
|
|
30
|
+
// (see docs/deploy/surfaces/slack.md). Every other wired surface (Discord's bot-token REST message
|
|
31
|
+
// create, Teams' Bot-Framework activity, Notion's/Linear's comment APIs) has no equivalent per-message
|
|
32
|
+
// override in what OE's send path already uses, so they get the shared bracket-BODY fallback instead
|
|
33
|
+
// (reply-post.ts / unified-reply.ts, gated on `displayNameCapable` being false/absent for them).
|
|
34
|
+
//
|
|
35
|
+
// 🔴 SCOPE-OPTIONAL, GRACEFUL FALLBACK — BY CONSTRUCTION, NOT BY AN ASSUMPTION ABOUT SLACK'S OWN
|
|
36
|
+
// BEHAVIOUR. Whether an app WITHOUT `chat:write.customize` gets `username` silently ignored or the
|
|
37
|
+
// WHOLE post refused (`missing_scope`) is a Slack-side behaviour this codebase has not measured
|
|
38
|
+
// against a real unscoped app — asserting one over the other would be an unmeasured vendor claim
|
|
39
|
+
// baked into an availability-critical path. {@link postSlackMessage} makes the answer not matter: a
|
|
40
|
+
// username-bearing post that comes back non-`ok` is retried EXACTLY ONCE with the tier-signal params
|
|
41
|
+
// stripped, so a missing scope costs only the bracket, never the reply. A genuine failure (a
|
|
42
|
+
// non-scope error, or a failure on the retry too) still throws — the fallback only ever widens what
|
|
43
|
+
// counts as "the reply landed", never swallows a real one.
|
|
44
|
+
//
|
|
45
|
+
// PURE core: {@link slackReplierParams} decides WHAT to send; {@link postSlackMessage} is the thin
|
|
46
|
+
// impure shell (resolve the bot's own name, dispatch, retry-on-reject). The `post` seam is injected
|
|
47
|
+
// so the retry behaviour is unit-testable against a SCRIPTED bridge, without a live Slack token.
|
|
48
|
+
|
|
49
|
+
/** Pure: the extra `chat.postMessage` params for a worker-turn reply. `username` is set to
|
|
50
|
+
* "<agentName> [<taskLabel>]" only when BOTH a resolvable agent name and a replier label are
|
|
51
|
+
* present; `{}` (no override — the bot's own default name renders, bare) for a supervisor/ambient
|
|
52
|
+
* reply OR when the agent name can't be resolved — fail-open to today's behaviour, never blocks
|
|
53
|
+
* the post over a cosmetic name. */
|
|
54
|
+
export function slackReplierParams(agentName: string | undefined, replier: ReplierLabel | undefined): Record<string, string> {
|
|
55
|
+
if (!replier || !agentName || agentName.trim().length === 0) return {};
|
|
56
|
+
return { username: `${agentName} [${replier.taskLabel}]` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The minimal shape {@link postSlackMessage} needs from a `chat.postMessage` response — the same
|
|
60
|
+
* fields `slackForm` already returns, narrowed so a test double doesn't need the real client. */
|
|
61
|
+
export interface SlackPostResult {
|
|
62
|
+
readonly ok?: boolean;
|
|
63
|
+
readonly ts?: unknown;
|
|
64
|
+
readonly error?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The injectable `chat.postMessage` dispatch {@link postSlackMessage} calls — defaults to the live
|
|
68
|
+
* `slackForm` client; a test supplies a scripted one. */
|
|
69
|
+
export type SlackPostFn = (body: URLSearchParams) => Promise<SlackPostResult | undefined>;
|
|
70
|
+
|
|
71
|
+
const livePost: SlackPostFn = (body) => slackForm("chat.postMessage", body.toString());
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* POST `chat.postMessage` with the OPE-508 replier-tier fallback: `base` (channel/thread/text, no
|
|
75
|
+
* tier params) is the request that must ALWAYS have a chance to land. When `replier` resolves to
|
|
76
|
+
* non-empty params (a worker turn with a resolvable agent name), the FIRST attempt adds them; on
|
|
77
|
+
* any non-`ok` response to THAT attempt, retries ONCE with `base` alone. A response that was never
|
|
78
|
+
* tier-bearing (no replier, or an unresolvable agent name) gets no retry — a real failure there is
|
|
79
|
+
* real, not scope-shaped. `post` defaults to the live Slack client; `resolveDisplayName` defaults
|
|
80
|
+
* to the live `botDisplayName()`; a test injects scripted versions of both (the live
|
|
81
|
+
* `botDisplayName` reaches its own module-level memoized Slack call, which a `post`-only test
|
|
82
|
+
* double can't reach — see slack-replier-label-test.ts).
|
|
83
|
+
*/
|
|
84
|
+
export async function postSlackMessage(
|
|
85
|
+
base: URLSearchParams,
|
|
86
|
+
replier: ReplierLabel | undefined,
|
|
87
|
+
errPrefix: string,
|
|
88
|
+
post: SlackPostFn = livePost,
|
|
89
|
+
resolveDisplayName: () => Promise<string | undefined> = botDisplayName,
|
|
90
|
+
): Promise<string> {
|
|
91
|
+
let usedReplierParams = false;
|
|
92
|
+
let attempt = base;
|
|
93
|
+
if (replier) {
|
|
94
|
+
const name = await resolveDisplayName().catch(() => undefined);
|
|
95
|
+
const params = slackReplierParams(name, replier);
|
|
96
|
+
if (Object.keys(params).length > 0) {
|
|
97
|
+
attempt = new URLSearchParams(base);
|
|
98
|
+
for (const [k, v] of Object.entries(params)) attempt.set(k, v);
|
|
99
|
+
usedReplierParams = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const res = await post(attempt);
|
|
103
|
+
const ts = typeof res?.ts === "string" ? res.ts : undefined;
|
|
104
|
+
if (res?.ok === true && ts) return ts;
|
|
105
|
+
if (usedReplierParams) {
|
|
106
|
+
// A rejected tier-signal param must never cost the reply itself — retry bare, exactly once.
|
|
107
|
+
const retry = await post(base);
|
|
108
|
+
const retryTs = typeof retry?.ts === "string" ? retry.ts : undefined;
|
|
109
|
+
if (retry?.ok === true && retryTs) return retryTs;
|
|
110
|
+
throw classifiedPostError(retry, `${errPrefix} — ${(retry?.error as string) ?? (res?.error as string) ?? "no ts returned (check SLACK_BOT_TOKEN)"}`);
|
|
111
|
+
}
|
|
112
|
+
throw classifiedPostError(res, `${errPrefix} — ${(res?.error as string) ?? "no ts returned (check SLACK_BOT_TOKEN)"}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The failure error for a `chat.postMessage` that did not return a usable ts, CLASSIFIED (OPE-821).
|
|
117
|
+
*
|
|
118
|
+
* `ok:false` IS DEFINITIVE, AND ONLY THAT. Slack answers HTTP 200 with `ok:false` when it read the
|
|
119
|
+
* request and refused it (`channel_not_found`, `not_in_channel`, `invalid_auth`) — nothing reached
|
|
120
|
+
* the room, so the post spine may release its dedupe claim and let an identical retry through. There
|
|
121
|
+
* is no HTTP status to carry that fact, so the arm states it (channels/platform-failure.ts).
|
|
122
|
+
*
|
|
123
|
+
* 🔴 `ok:true` WITH NO `ts` IS NOT DEFINITIVE, and the distinction is the whole point. Slack saying
|
|
124
|
+
* "ok" means the message DID post; a missing `ts` then means this host cannot name it, not that the
|
|
125
|
+
* room is empty. Marking that case would tell the spine to let a retry through and put the message
|
|
126
|
+
* in the room twice. A transport throw from `post()` never reaches here at all and stays AMBIGUOUS,
|
|
127
|
+
* which is the honest answer: a request that got no reply may still have applied.
|
|
128
|
+
*/
|
|
129
|
+
function classifiedPostError(res: SlackPostResult | undefined, message: string): Error {
|
|
130
|
+
const err = new Error(message);
|
|
131
|
+
return res?.ok === false ? markNotApplied(err) : err;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 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> {
|
|
136
|
+
const body = new URLSearchParams({ channel: target.channelId, text: markdownToSlackMrkdwn(text) });
|
|
137
|
+
if (target.threadTs) body.set("thread_ts", target.threadTs);
|
|
138
|
+
return postSlackMessage(body, replier, "reply couldn't post to Slack");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `thread_ts` a unified reply posts under, or undefined for a top-level post. PURE, and the ONE
|
|
143
|
+
* statement of Slack's reply placement:
|
|
144
|
+
* • `threadTs` — the trigger was already in a thread, so the answer goes in that thread.
|
|
145
|
+
* • `replyAnchorTs` — the trigger was a top-level channel message, so the answer opens a thread on
|
|
146
|
+
* it (OPE-759; slack-events.ts mints the anchor for an addressed mention AND an ambient wake).
|
|
147
|
+
* • neither — a post with no triggering message (a routine fire, a webhook post-back). It lands
|
|
148
|
+
* top-level, which is the right placement for a message that answers nothing.
|
|
149
|
+
* Exported so the placement is pinned by a test rather than by reading one `??` inside an
|
|
150
|
+
* un-injectable network call (scripts/slack-replier-label-test.ts).
|
|
151
|
+
*/
|
|
152
|
+
export function slackThreadTarget(origin: Pick<SlackOrigin, "threadTs" | "replyAnchorTs">): string | undefined {
|
|
153
|
+
const anchor = origin.threadTs ?? origin.replyAnchorTs;
|
|
154
|
+
return anchor && anchor.length > 0 ? anchor : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 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> {
|
|
159
|
+
const body = new URLSearchParams({ channel: origin.channelId, text: markdownToSlackMrkdwn(text) });
|
|
160
|
+
const threadTarget = slackThreadTarget(origin);
|
|
161
|
+
if (threadTarget) body.set("thread_ts", threadTarget);
|
|
162
|
+
return postSlackMessage(body, replier, "unified reply couldn't post to Slack");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* UNIFIED, WITH BUTTONS (OPE-1720) — `liveSlackPost` plus one `actions` block, so an interactive card
|
|
167
|
+
* can reach a unified origin (a DM seat, a single-window channel) instead of only a DM.
|
|
168
|
+
*
|
|
169
|
+
* IDENTICAL GEOMETRY TO THE PLAIN POST: same channel, same `thread_ts` resolution, same replier
|
|
170
|
+
* fallback through {@link postSlackMessage} — the ONLY difference is the `blocks` field, so a card
|
|
171
|
+
* lands exactly where the same reply would have. An `actions` list that renders to NOTHING (every
|
|
172
|
+
* entry over Slack's caps) posts the plain message rather than a `blocks` array with an empty row.
|
|
173
|
+
*/
|
|
174
|
+
async function liveSlackPostWithActions(origin: SlackOrigin, text: string, actions: readonly DirectMessageAction[], replier?: ReplierLabel): Promise<string> {
|
|
175
|
+
return postSlackMessage(slackUnifiedActionForm(origin, text, actions), replier, "unified reply couldn't post to Slack");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* PURE: the exact `chat.postMessage` form a unified card post sends. Split out from the dispatch so
|
|
180
|
+
* the BLOCK PAYLOAD itself is pinned by a contract test with no Slack token and no transport — the
|
|
181
|
+
* shape is the contract, and a shape Slack rejects costs the room the whole message, not just the
|
|
182
|
+
* buttons.
|
|
183
|
+
*/
|
|
184
|
+
export function slackUnifiedActionForm(origin: Pick<SlackOrigin, "channelId" | "threadTs" | "replyAnchorTs">, text: string, actions: readonly DirectMessageAction[]): URLSearchParams {
|
|
185
|
+
const rendered = markdownToSlackMrkdwn(text);
|
|
186
|
+
const body = new URLSearchParams({ channel: origin.channelId, text: rendered });
|
|
187
|
+
const threadTarget = slackThreadTarget(origin);
|
|
188
|
+
if (threadTarget) body.set("thread_ts", threadTarget);
|
|
189
|
+
const actionBlock = slackDirectMessageActionBlock(actions);
|
|
190
|
+
if (actionBlock !== undefined) {
|
|
191
|
+
// The text block rides ALONGSIDE the buttons, for the reason `deliverSlackDirectMessage` states:
|
|
192
|
+
// once `blocks` is present `text` is only the notification fallback, so without the section the
|
|
193
|
+
// room would see a row of buttons over an empty message.
|
|
194
|
+
body.set("blocks", JSON.stringify([{ text: { text: rendered, type: "mrkdwn" }, type: "section" }, actionBlock]));
|
|
195
|
+
}
|
|
196
|
+
return body;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* THE COMPONENT PLANE (OPE-1060 S3) — the `actions` a {@link DirectMessageRequest} carries, rendered
|
|
201
|
+
* as ONE Slack `actions` block of buttons. Shared since OPE-1720 with the unified post above, so a
|
|
202
|
+
* bridged approval card and a DM card cannot drift in how a button is built or capped.
|
|
203
|
+
*
|
|
204
|
+
* 🔴 THE TEXT STILL STANDS ON ITS OWN. `post-arms.ts` states the contract: an arm with no component
|
|
205
|
+
* plane delivers the text alone and never fakes the buttons, so every caller's copy already says what
|
|
206
|
+
* the answers ARE in words. These buttons are a shortcut over that copy, never a replacement for it —
|
|
207
|
+
* which is also why the `text` field below stays the full message rather than a placeholder.
|
|
208
|
+
*
|
|
209
|
+
* 🔴 THE ACTION ID IS THE WHOLE DECISION AND THE LABEL IS HOST COPY. Neither is model-authored: the
|
|
210
|
+
* caller composes both (`access/credential-grant-card.ts`), and this arm only transports them. Slack
|
|
211
|
+
* caps an `action_id` at 255 characters and a button label at 75; an id or a label past its cap makes
|
|
212
|
+
* Slack reject the WHOLE `chat.postMessage`, which would cost the person the message as well as the
|
|
213
|
+
* buttons. So an over-long action drops out and the text still goes.
|
|
214
|
+
*
|
|
215
|
+
* `intent` maps to Slack's own button styles: `reject` renders `danger`, `affirm` renders default
|
|
216
|
+
* (never `primary` — four buttons of which three are primary reads as no emphasis at all, and the
|
|
217
|
+
* grant card's three affirms are genuinely equal choices).
|
|
218
|
+
*/
|
|
219
|
+
function slackDirectMessageActionBlock(actions: readonly DirectMessageAction[]): Record<string, unknown> | undefined {
|
|
220
|
+
const elements = actions
|
|
221
|
+
.filter((a) => a.id.length > 0 && a.id.length <= 255 && a.label.length > 0 && a.label.length <= 75)
|
|
222
|
+
.map((a) => ({
|
|
223
|
+
action_id: a.id,
|
|
224
|
+
text: { emoji: true, text: a.label, type: "plain_text" },
|
|
225
|
+
type: "button",
|
|
226
|
+
...(a.intent === "reject" ? { style: "danger" } : {}),
|
|
227
|
+
}));
|
|
228
|
+
return elements.length > 0 ? { elements, type: "actions" } : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* DM: open (or reuse) the 1:1 IM with a Slack member and post there (OPE-44's DM-delivery arm).
|
|
233
|
+
* `conversations.open` is idempotent — repeat calls return the SAME `im` channel id — so there is no
|
|
234
|
+
* "already open" branch to write. Needs the `im:write` bot scope on top of `chat:write`; without it
|
|
235
|
+
* Slack answers `missing_scope`, which this surfaces as a loud token-free throw so the governance
|
|
236
|
+
* arm refuses (and never falls back to rendering pane substance in the room).
|
|
237
|
+
*
|
|
238
|
+
* A request carrying `actions` also gets a row of buttons (OPE-1060 S3). The clicks come back on the
|
|
239
|
+
* app's interactivity URL, which the front route classifies by the action-id prefix.
|
|
240
|
+
*/
|
|
241
|
+
async function deliverSlackDirectMessage(input: DirectMessageRequest): Promise<DirectMessageDelivery> {
|
|
242
|
+
const opened = await slackForm("conversations.open", new URLSearchParams({ users: input.principalId }).toString());
|
|
243
|
+
const channel = opened?.channel as { id?: unknown } | undefined;
|
|
244
|
+
const dmChannelId = typeof channel?.id === "string" ? channel.id : undefined;
|
|
245
|
+
if (opened?.ok !== true || !dmChannelId) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`couldn't open a Slack DM with the requester — ${(opened?.error as string) ?? "no channel returned (check SLACK_BOT_TOKEN and the im:write bot scope)"}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const rendered = markdownToSlackMrkdwn(input.text);
|
|
251
|
+
const actionBlock = input.actions === undefined ? undefined : slackDirectMessageActionBlock(input.actions);
|
|
252
|
+
const form = new URLSearchParams({ channel: dmChannelId, text: rendered });
|
|
253
|
+
if (actionBlock !== undefined) {
|
|
254
|
+
// The text block rides ALONGSIDE the buttons. `text` alone is only the notification fallback once
|
|
255
|
+
// `blocks` is present, so without this section the person's Slack client would show a row of
|
|
256
|
+
// buttons over an empty message.
|
|
257
|
+
form.set("blocks", JSON.stringify([{ text: { text: rendered, type: "mrkdwn" }, type: "section" }, actionBlock]));
|
|
258
|
+
}
|
|
259
|
+
const posted = await slackForm("chat.postMessage", form.toString());
|
|
260
|
+
const ts = typeof posted?.ts === "string" ? posted.ts : undefined;
|
|
261
|
+
if (posted?.ok !== true || !ts) {
|
|
262
|
+
throw new Error(`couldn't post the Slack DM — ${(posted?.error as string) ?? "no ts returned (check SLACK_BOT_TOKEN)"}`);
|
|
263
|
+
}
|
|
264
|
+
return { surfaceId: dmChannelId, messageId: ts };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* EDIT (OPE-793): rewrite one of our own posted messages via `chat.update` (channel + ts + text).
|
|
269
|
+
* Slack renders an "(edited)" marker on the message, so the room sees that it changed.
|
|
270
|
+
*
|
|
271
|
+
* Ownership is NOT decided here — channels/reply-edit.ts already refused anything outside the
|
|
272
|
+
* own-post ledger before this runs. Slack's own rule (an app may only update a message its own token
|
|
273
|
+
* posted) is the backstop under that, and its refusal arrives as a token-free throw the model reads.
|
|
274
|
+
* `chat.update` needs the same `chat:write` scope the post path already has.
|
|
275
|
+
*/
|
|
276
|
+
async function editSlackMessage(locus: EditLocus, messageId: string, text: string): Promise<void> {
|
|
277
|
+
const body = new URLSearchParams({ channel: locus.channelId, ts: messageId, text: markdownToSlackMrkdwn(text) });
|
|
278
|
+
const res = await slackForm("chat.update", body.toString());
|
|
279
|
+
if (res?.ok !== true) {
|
|
280
|
+
// DEFINITIVE (OPE-802): `chat.update` answers HTTP 200 with `ok:false`, so Slack read the request
|
|
281
|
+
// and decided against it — the message did not change. There is no status to carry that, so the
|
|
282
|
+
// arm says it. A transport throw from `slackForm` carries neither and stays AMBIGUOUS, which is
|
|
283
|
+
// the honest answer: a request that never got a reply may still have applied.
|
|
284
|
+
throw markNotApplied(
|
|
285
|
+
new Error(`edit_reply couldn't update the Slack message — ${(res?.error as string) ?? "chat.update returned no ok (check SLACK_BOT_TOKEN)"}`),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const slackPostArm: PostArm = {
|
|
291
|
+
platform: "slack",
|
|
292
|
+
resolveTarget: (bag: TargetResolveBag): ReplyTarget | null => {
|
|
293
|
+
const channelId = bag.str("channelId");
|
|
294
|
+
// Slack threads its replies; bag.threadTs is the anchor (undefined ⇒ top-level).
|
|
295
|
+
return channelId ? { platform: "slack", channelId, threadTs: bag.threadTs } : null;
|
|
296
|
+
},
|
|
297
|
+
displayNameCapable: true,
|
|
298
|
+
dispatchReply: (target, text, replier) => dispatchSlackReply(target as SlackTarget, text, replier),
|
|
299
|
+
dispatchUnified: (origin, text, deps: UnifiedReplyDeps, replier) =>
|
|
300
|
+
deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPost(origin as SlackOrigin, text, replier),
|
|
301
|
+
// OPE-1720: Slack declares the unified component plane because the tap route exists on it —
|
|
302
|
+
// `slack-front.ts` classifies the `oe_input:` click and `slack-approval-card.ts` settles it. The
|
|
303
|
+
// injected `postSlack` override is honoured VERBATIM and drops the buttons: a test that scripted a
|
|
304
|
+
// text-only post must not start receiving a card it never asked for.
|
|
305
|
+
dispatchUnifiedActions: (origin, text, actions, deps: UnifiedReplyDeps, replier) =>
|
|
306
|
+
deps.postSlack ? deps.postSlack(origin as SlackOrigin, text) : liveSlackPostWithActions(origin as SlackOrigin, text, actions, replier),
|
|
307
|
+
describeRedriveTarget: (origin) => {
|
|
308
|
+
const o = origin as SlackOrigin;
|
|
309
|
+
return `slack ${o.threadTs ? `thread in ${o.channelId}` : `channel ${o.channelId}`}`;
|
|
310
|
+
},
|
|
311
|
+
redriveContainerId: (origin) => (origin as SlackOrigin).channelId,
|
|
312
|
+
deliverDirectMessage: deliverSlackDirectMessage,
|
|
313
|
+
editMessage: editSlackMessage,
|
|
314
|
+
};
|