crewhaus 0.1.7 → 0.2.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/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +246 -0
- package/dist/feedback.js +612 -0
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11727 -740
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -64
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 61 — `crewhaus channel provision|verify`: one-command channel app
|
|
3
|
+
* setup + scope doctor, factored out of the entry file `index.ts` (which
|
|
4
|
+
* runs a top-level argv switch and so cannot be imported by a test without
|
|
5
|
+
* executing the CLI). Side-effect-free on import and directly unit-testable,
|
|
6
|
+
* mirroring `audit-verify.ts` / `security-digest.ts` / `scope-audit-drift.ts`.
|
|
7
|
+
*
|
|
8
|
+
* The problem this closes: channel-bot harnesses assume platform-side
|
|
9
|
+
* configuration the user had to hand-build — a Slack app with the right
|
|
10
|
+
* scopes/event subscriptions, a Telegram webhook registered with the secret
|
|
11
|
+
* the adapter verifies (`channel-adapter-telegram` documents the
|
|
12
|
+
* `setWebhook(secret_token=...)` expectation but nothing performed it), a
|
|
13
|
+
* Discord application pointed at the daemon's interactions endpoint.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is DERIVED from the adapters + the compiled daemon, not
|
|
16
|
+
* guessed (per-platform derivations are documented on the builders below).
|
|
17
|
+
* The one shared fact: the generated gateway routes webhooks by path —
|
|
18
|
+
* `/^\/([^/]+)\/events$/` in target-channel-bot's `renderGateway` — so the
|
|
19
|
+
* platform-side URL is always `<base-url>/<adapterId>/events`.
|
|
20
|
+
*
|
|
21
|
+
* Design decisions:
|
|
22
|
+
* - Slack is EMIT-AND-INSTRUCT only (no `--apply`). Slack's manifest API
|
|
23
|
+
* (`apps.manifest.create`) authenticates with an app *configuration*
|
|
24
|
+
* token; the adapter/spec carry only the bot token (`xoxb`, Bearer for
|
|
25
|
+
* chat.postMessage/reactions.add), the signing secret (HMAC verify), and
|
|
26
|
+
* the reserved Socket-Mode `appToken` (`xapp`) — none of which can call
|
|
27
|
+
* the manifest API, and inventing a new env var for a token family the
|
|
28
|
+
* runtime never uses was ruled out.
|
|
29
|
+
* - Discord's adapter is interactions-endpoint-based (Ed25519-verified
|
|
30
|
+
* webhooks; types 1/2/3/5), NOT gateway-websocket-based — so provision
|
|
31
|
+
* PATCHes `applications/@me` `interactions_endpoint_url` and prints the
|
|
32
|
+
* invite URL with permission bits derived from the adapter's REST usage.
|
|
33
|
+
* Application *commands* are not registered: the spec declares no
|
|
34
|
+
* command list and the adapter accepts ANY command name (it renders
|
|
35
|
+
* `/<name> <options>` into the agent turn), so command names cannot be
|
|
36
|
+
* derived without guesswork — provision prints the registration
|
|
37
|
+
* instruction instead.
|
|
38
|
+
* - All network calls go through an injectable `fetchImpl` so tests are
|
|
39
|
+
* credential-free, and every network-performing path has a dry-run
|
|
40
|
+
* counterpart (`display` plans / `describeVerifyProbes`) that renders
|
|
41
|
+
* env refs as `$NAME` and literals as `[redacted]` — never the value.
|
|
42
|
+
*/
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Shared plumbing
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** Platforms `channel provision|verify` supports. whatsapp (Meta app config
|
|
47
|
+
* lives in the Meta developer console) and imessage (host-local chat.db —
|
|
48
|
+
* there is no platform side to provision) are out of item-61 scope; when a
|
|
49
|
+
* spec configures them the CLI prints a note instead of failing. */
|
|
50
|
+
export const CHANNEL_PLATFORMS = ["slack", "telegram", "discord"];
|
|
51
|
+
/** Placeholder for an inline-literal secret in printable output — mirrors
|
|
52
|
+
* `packages/ir/src/redact.ts`'s `REDACTED_VALUE` convention. */
|
|
53
|
+
export const REDACTED_LITERAL = "[redacted]";
|
|
54
|
+
/** Thrown on a malformed/unconfigured `--platform` value. The CLI entry file
|
|
55
|
+
* catches it and routes the message through `die()`. */
|
|
56
|
+
export class InvalidPlatformFlagError extends Error {
|
|
57
|
+
name = "InvalidPlatformFlagError";
|
|
58
|
+
}
|
|
59
|
+
/** Thrown when a secret env-ref (`$VAR_NAME` in the spec) is unset at the
|
|
60
|
+
* moment a REAL platform call needs its value. Dry-run paths never throw
|
|
61
|
+
* this — they print the `$VAR_NAME` placeholder instead. */
|
|
62
|
+
export class ChannelEnvRefError extends Error {
|
|
63
|
+
label;
|
|
64
|
+
envName;
|
|
65
|
+
name = "ChannelEnvRefError";
|
|
66
|
+
constructor(label, envName) {
|
|
67
|
+
super(`${label} references $${envName} but it is unset — export ${envName} (or inline a literal in the spec)`);
|
|
68
|
+
this.label = label;
|
|
69
|
+
this.envName = envName;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Thrown on a failed platform API call (non-2xx or a logical `ok: false`).
|
|
73
|
+
* Messages are built from REDACTED endpoints — never the token-bearing URL. */
|
|
74
|
+
export class ChannelApiError extends Error {
|
|
75
|
+
name = "ChannelApiError";
|
|
76
|
+
}
|
|
77
|
+
/** Thrown on a malformed `--base-url`. */
|
|
78
|
+
export class InvalidBaseUrlError extends Error {
|
|
79
|
+
name = "InvalidBaseUrlError";
|
|
80
|
+
constructor(value, detail) {
|
|
81
|
+
super(`invalid --base-url "${value}" — ${detail} (expected the daemon's publicly reachable origin, e.g. https://bot.example.com)`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the `--platform` flag against the spec's configured channels.
|
|
86
|
+
* `all` (or an absent flag) selects every configured platform this command
|
|
87
|
+
* supports; an explicit platform must actually be configured in the spec —
|
|
88
|
+
* provisioning a channel the daemon will never serve is a footgun, not a
|
|
89
|
+
* convenience. `unsupported` lists configured channels outside item-61
|
|
90
|
+
* scope (whatsapp/imessage) so the CLI can print a note.
|
|
91
|
+
*/
|
|
92
|
+
export function resolvePlatformsFlag(flagValue, channels) {
|
|
93
|
+
const configured = CHANNEL_PLATFORMS.filter((p) => channels[p] !== undefined);
|
|
94
|
+
const unsupported = ["whatsapp", "imessage"].filter((p) => channels[p] !== undefined);
|
|
95
|
+
const value = flagValue ?? "all";
|
|
96
|
+
if (value === "all") {
|
|
97
|
+
if (configured.length === 0) {
|
|
98
|
+
throw new InvalidPlatformFlagError(`the spec's channels block configures none of: ${CHANNEL_PLATFORMS.join(", ")}${unsupported.length > 0
|
|
99
|
+
? ` (${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} configured but not supported by this command)`
|
|
100
|
+
: ""}`);
|
|
101
|
+
}
|
|
102
|
+
return { platforms: configured, unsupported };
|
|
103
|
+
}
|
|
104
|
+
if (!CHANNEL_PLATFORMS.includes(value)) {
|
|
105
|
+
throw new InvalidPlatformFlagError(`invalid --platform "${value}" — expected one of: ${CHANNEL_PLATFORMS.join(", ")}, all`);
|
|
106
|
+
}
|
|
107
|
+
const platform = value;
|
|
108
|
+
if (channels[platform] === undefined) {
|
|
109
|
+
throw new InvalidPlatformFlagError(`--platform ${platform} but the spec configures no channels.${platform} block`);
|
|
110
|
+
}
|
|
111
|
+
return { platforms: [platform], unsupported };
|
|
112
|
+
}
|
|
113
|
+
/** Resolve a lowered secret ref to its value. Env-refs read `env`; an unset
|
|
114
|
+
* (or empty) env var throws {@link ChannelEnvRefError} — matching the
|
|
115
|
+
* compiled daemon's own startup check, which exits non-zero on the same
|
|
116
|
+
* condition rather than serving webhooks with an empty secret. */
|
|
117
|
+
export function resolveSecretRef(label, ref, env) {
|
|
118
|
+
if (ref.kind === "literal")
|
|
119
|
+
return ref.value;
|
|
120
|
+
const value = env[ref.name];
|
|
121
|
+
if (value === undefined || value === "")
|
|
122
|
+
throw new ChannelEnvRefError(label, ref.name);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
/** Printable form of a secret ref: `$NAME` for env-refs (the indirection is
|
|
126
|
+
* not a secret), {@link REDACTED_LITERAL} for inline literals. */
|
|
127
|
+
export function describeSecretRef(ref) {
|
|
128
|
+
return ref.kind === "env" ? `$${ref.name}` : REDACTED_LITERAL;
|
|
129
|
+
}
|
|
130
|
+
/** The daemon route the platform must deliver webhooks to. Single source:
|
|
131
|
+
* target-channel-bot's gateway matches `/^\/([^/]+)\/events$/` and looks the
|
|
132
|
+
* captured segment up in its adapter map keyed by adapter id. */
|
|
133
|
+
export function channelEventsPath(platform) {
|
|
134
|
+
return `/${platform}/events`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Join `--base-url` with a daemon route. Validates the base URL parses and
|
|
138
|
+
* is http(s); trailing slashes are normalized so `https://x/` and `https://x`
|
|
139
|
+
* produce the same webhook URL. (Slack, Telegram, and Discord all require a
|
|
140
|
+
* publicly reachable https URL in production — http is tolerated here for
|
|
141
|
+
* local tunnels/tests; the platform rejects it server-side if unsupported.)
|
|
142
|
+
*/
|
|
143
|
+
export function joinBaseUrl(baseUrl, path) {
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = new URL(baseUrl);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new InvalidBaseUrlError(baseUrl, "not a parseable URL");
|
|
150
|
+
}
|
|
151
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
152
|
+
throw new InvalidBaseUrlError(baseUrl, `unsupported protocol "${parsed.protocol}"`);
|
|
153
|
+
}
|
|
154
|
+
const origin = parsed.origin + parsed.pathname.replace(/\/+$/, "");
|
|
155
|
+
return `${origin}${path}`;
|
|
156
|
+
}
|
|
157
|
+
function collectMissing(refs, env) {
|
|
158
|
+
const missing = [];
|
|
159
|
+
for (const { label, ref } of refs) {
|
|
160
|
+
if (ref.kind === "env" && (env[ref.name] === undefined || env[ref.name] === "")) {
|
|
161
|
+
missing.push({ label, envName: ref.name });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return missing;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The secret fields a platform's daemon REQUIRES at boot, per
|
|
168
|
+
* target-channel-bot's `requiredEnvNames`: slack botToken + signingSecret
|
|
169
|
+
* (appToken is reserved for a future Socket-Mode path — parsed but unused,
|
|
170
|
+
* so not required), telegram botToken + secretToken, discord applicationId +
|
|
171
|
+
* botToken + publicKeyHex. Shared by the doctor channel check and `verify`.
|
|
172
|
+
*/
|
|
173
|
+
export function platformSecretRefs(platform, channels) {
|
|
174
|
+
if (platform === "slack") {
|
|
175
|
+
const slack = channels.slack;
|
|
176
|
+
if (slack === undefined)
|
|
177
|
+
return [];
|
|
178
|
+
return [
|
|
179
|
+
{ label: "channels.slack.botToken", ref: slack.botToken },
|
|
180
|
+
{ label: "channels.slack.signingSecret", ref: slack.signingSecret },
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
if (platform === "telegram") {
|
|
184
|
+
const telegram = channels.telegram;
|
|
185
|
+
if (telegram === undefined)
|
|
186
|
+
return [];
|
|
187
|
+
return [
|
|
188
|
+
{ label: "channels.telegram.botToken", ref: telegram.botToken },
|
|
189
|
+
{ label: "channels.telegram.secretToken", ref: telegram.secretToken },
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
const discord = channels.discord;
|
|
193
|
+
if (discord === undefined)
|
|
194
|
+
return [];
|
|
195
|
+
return [
|
|
196
|
+
{ label: "channels.discord.applicationId", ref: discord.applicationId },
|
|
197
|
+
{ label: "channels.discord.botToken", ref: discord.botToken },
|
|
198
|
+
{ label: "channels.discord.publicKeyHex", ref: discord.publicKeyHex },
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Slack — app manifest generation (emit-and-instruct; see header for the
|
|
203
|
+
// no-`--apply` decision)
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
export const SLACK_MANIFEST_FILENAME = "slack-app-manifest.yaml";
|
|
206
|
+
const DEFAULT_SLACK_API_BASE_URL = "https://slack.com/api";
|
|
207
|
+
/**
|
|
208
|
+
* Bot scopes derived from `@crewhaus/channel-adapter-slack`'s ACTUAL API
|
|
209
|
+
* usage and parsed event types — not from a generic bot template:
|
|
210
|
+
* - `sendReply` POSTs `chat.postMessage` → chat:write
|
|
211
|
+
* - `react` POSTs `reactions.add` (the generated session-router always
|
|
212
|
+
* attempts 👀/✅/⚠️ status acks) → reactions:write
|
|
213
|
+
* - `parseInbound` handles `app_mention` → app_mentions:read
|
|
214
|
+
* - `parseInbound` handles `message` in any
|
|
215
|
+
* conversation type, so each `message.*` event
|
|
216
|
+
* subscription needs its history scope → channels/groups/im/mpim:history
|
|
217
|
+
* - `parseInbound` normalises `reaction_added` into 👍/👎 `user_feedback`
|
|
218
|
+
* (the v0.1.8 ratings feature), but the generated gateway only
|
|
219
|
+
* dispatches it when the spec sets `feedback.channelReactions` — so
|
|
220
|
+
* reactions:read is requested exactly then → reactions:read
|
|
221
|
+
*/
|
|
222
|
+
export function deriveSlackScopes(opts) {
|
|
223
|
+
const scopes = [
|
|
224
|
+
{ scope: "app_mentions:read", reason: "receive app_mention events (adapter parseInbound)" },
|
|
225
|
+
{ scope: "channels:history", reason: "receive message.channels events (public channels)" },
|
|
226
|
+
{ scope: "groups:history", reason: "receive message.groups events (private channels)" },
|
|
227
|
+
{ scope: "im:history", reason: "receive message.im events (DMs)" },
|
|
228
|
+
{ scope: "mpim:history", reason: "receive message.mpim events (group DMs)" },
|
|
229
|
+
{ scope: "chat:write", reason: "sendReply → chat.postMessage" },
|
|
230
|
+
{ scope: "reactions:write", reason: "react → reactions.add (👀/✅/⚠️ status acks)" },
|
|
231
|
+
];
|
|
232
|
+
if (opts.channelReactions) {
|
|
233
|
+
scopes.push({
|
|
234
|
+
scope: "reactions:read",
|
|
235
|
+
reason: "receive reaction_added events (feedback.channelReactions 👍/👎 ratings)",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return scopes;
|
|
239
|
+
}
|
|
240
|
+
/** Event subscriptions matching {@link deriveSlackScopes} — the event types
|
|
241
|
+
* the adapter's `parseInbound` consumes (everything else parses to skip). */
|
|
242
|
+
export function deriveSlackBotEvents(opts) {
|
|
243
|
+
const events = [
|
|
244
|
+
"app_mention",
|
|
245
|
+
"message.channels",
|
|
246
|
+
"message.groups",
|
|
247
|
+
"message.im",
|
|
248
|
+
"message.mpim",
|
|
249
|
+
];
|
|
250
|
+
if (opts.channelReactions)
|
|
251
|
+
events.push("reaction_added");
|
|
252
|
+
return events;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Build the complete Slack app manifest for a channel spec. The request URL
|
|
256
|
+
* is the compiled daemon's slack route under `--base-url`; `socket_mode` is
|
|
257
|
+
* off because the daemon is a webhook server (the spec's `appToken` is
|
|
258
|
+
* documented as reserved for a future Socket-Mode path).
|
|
259
|
+
*/
|
|
260
|
+
export function buildSlackManifest(spec, baseUrl) {
|
|
261
|
+
const reactions = { channelReactions: spec.channelReactions };
|
|
262
|
+
return {
|
|
263
|
+
display_information: {
|
|
264
|
+
// Slack limits: name ≤ 35 chars, description ≤ 140.
|
|
265
|
+
name: spec.name.slice(0, 35),
|
|
266
|
+
description: `CrewHaus channel bot "${spec.name}" (generated by crewhaus channel provision)`.slice(0, 140),
|
|
267
|
+
},
|
|
268
|
+
features: {
|
|
269
|
+
bot_user: { display_name: spec.name.slice(0, 35), always_online: true },
|
|
270
|
+
},
|
|
271
|
+
oauth_config: {
|
|
272
|
+
scopes: { bot: deriveSlackScopes(reactions).map((s) => s.scope) },
|
|
273
|
+
},
|
|
274
|
+
settings: {
|
|
275
|
+
event_subscriptions: {
|
|
276
|
+
request_url: joinBaseUrl(baseUrl, channelEventsPath("slack")),
|
|
277
|
+
bot_events: deriveSlackBotEvents(reactions),
|
|
278
|
+
},
|
|
279
|
+
org_deploy_enabled: false,
|
|
280
|
+
socket_mode_enabled: false,
|
|
281
|
+
token_rotation_enabled: false,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** Minimal YAML renderer for the manifest shape (nested maps, string arrays,
|
|
286
|
+
* scalars). String scalars are always double-quoted via JSON.stringify —
|
|
287
|
+
* valid YAML, and immune to `:`/`#`/leading-symbol edge cases in URLs. */
|
|
288
|
+
function renderYamlValue(value, indent) {
|
|
289
|
+
const pad = " ".repeat(indent);
|
|
290
|
+
if (Array.isArray(value)) {
|
|
291
|
+
return value
|
|
292
|
+
.map((item) => `${pad}- ${typeof item === "string" ? JSON.stringify(item) : String(item)}`)
|
|
293
|
+
.join("\n");
|
|
294
|
+
}
|
|
295
|
+
if (typeof value === "object" && value !== null) {
|
|
296
|
+
return Object.entries(value)
|
|
297
|
+
.map(([key, v]) => {
|
|
298
|
+
if (typeof v === "object" && v !== null) {
|
|
299
|
+
return `${pad}${key}:\n${renderYamlValue(v, indent + 1)}`;
|
|
300
|
+
}
|
|
301
|
+
return `${pad}${key}: ${typeof v === "string" ? JSON.stringify(v) : String(v)}`;
|
|
302
|
+
})
|
|
303
|
+
.join("\n");
|
|
304
|
+
}
|
|
305
|
+
return `${pad}${typeof value === "string" ? JSON.stringify(value) : String(value)}`;
|
|
306
|
+
}
|
|
307
|
+
export function renderSlackManifestYaml(manifest) {
|
|
308
|
+
return `${renderYamlValue(manifest, 0)}\n`;
|
|
309
|
+
}
|
|
310
|
+
/** Post-manifest instructions. Slack app creation from a manifest is a
|
|
311
|
+
* console flow (no `--apply` — see the module header for why), so the CLI
|
|
312
|
+
* prints exactly what to do with the file it just wrote. */
|
|
313
|
+
export function slackNextSteps(cfg, manifestRef) {
|
|
314
|
+
return [
|
|
315
|
+
`1. open https://api.slack.com/apps → "Create New App" → "From an app manifest",`,
|
|
316
|
+
` pick the workspace, and paste ${manifestRef}`,
|
|
317
|
+
`2. "Install to Workspace", then copy the credentials into the spec's env refs:`,
|
|
318
|
+
` Bot User OAuth Token (xoxb-…) → ${describeSecretRef(cfg.botToken)}`,
|
|
319
|
+
` Signing Secret → ${describeSecretRef(cfg.signingSecret)}`,
|
|
320
|
+
"3. start the daemon (bun daemon.ts) BEFORE saving the request URL — Slack",
|
|
321
|
+
" sends a url_verification challenge the gateway must answer",
|
|
322
|
+
"4. re-run `crewhaus channel verify --platform slack` to confirm granted scopes",
|
|
323
|
+
"(no --apply: Slack's manifest API needs an app *configuration* token — the",
|
|
324
|
+
" adapter only uses the xoxb bot token, signing secret, and reserved xapp token)",
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Telegram — setWebhook (the call the adapter documents but nothing performed)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
const DEFAULT_TELEGRAM_API_BASE_URL = "https://api.telegram.org";
|
|
331
|
+
/** Update kinds the adapter's `parseInbound` actually consumes — its three
|
|
332
|
+
* populated slots are `message`, `edited_message`, and `callback_query`;
|
|
333
|
+
* everything else returns skip, so the webhook shouldn't receive it. */
|
|
334
|
+
export const TELEGRAM_ALLOWED_UPDATES = ["message", "edited_message", "callback_query"];
|
|
335
|
+
/**
|
|
336
|
+
* Build the `setWebhook` call: url = the daemon's `/telegram/events` route
|
|
337
|
+
* under `--base-url`, secret_token = the spec's `secretToken` — the SAME
|
|
338
|
+
* value the adapter compares (timing-safely) against the
|
|
339
|
+
* `X-Telegram-Bot-Api-Secret-Token` header on every inbound POST —
|
|
340
|
+
* allowed_updates = the adapter's three consumed update kinds. The api base
|
|
341
|
+
* honours `TELEGRAM_API_BASE_URL` exactly like the adapter does.
|
|
342
|
+
*/
|
|
343
|
+
export function buildTelegramProvision(cfg, baseUrl, env) {
|
|
344
|
+
const apiBase = env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_TELEGRAM_API_BASE_URL;
|
|
345
|
+
const webhookUrl = joinBaseUrl(baseUrl, channelEventsPath("telegram"));
|
|
346
|
+
const display = {
|
|
347
|
+
endpoint: `${apiBase}/bot${describeSecretRef(cfg.botToken)}/setWebhook`,
|
|
348
|
+
payload: {
|
|
349
|
+
url: webhookUrl,
|
|
350
|
+
secret_token: describeSecretRef(cfg.secretToken),
|
|
351
|
+
allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
const missingEnv = collectMissing([
|
|
355
|
+
{ label: "channels.telegram.botToken", ref: cfg.botToken },
|
|
356
|
+
{ label: "channels.telegram.secretToken", ref: cfg.secretToken },
|
|
357
|
+
], env);
|
|
358
|
+
if (missingEnv.length > 0)
|
|
359
|
+
return { display, missingEnv };
|
|
360
|
+
return {
|
|
361
|
+
display,
|
|
362
|
+
missingEnv,
|
|
363
|
+
request: {
|
|
364
|
+
endpoint: `${apiBase}/bot${resolveSecretRef("channels.telegram.botToken", cfg.botToken, env)}/setWebhook`,
|
|
365
|
+
payload: {
|
|
366
|
+
url: webhookUrl,
|
|
367
|
+
secret_token: resolveSecretRef("channels.telegram.secretToken", cfg.secretToken, env),
|
|
368
|
+
allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** Perform the setWebhook POST. Errors carry the REDACTED endpoint (the real
|
|
374
|
+
* one embeds the bot token in its path). Returns Telegram's description. */
|
|
375
|
+
export async function performTelegramSetWebhook(provision, fetchImpl = fetch) {
|
|
376
|
+
const request = provision.request;
|
|
377
|
+
if (request === undefined) {
|
|
378
|
+
throw new ChannelApiError(`setWebhook not performed — unset env: ${provision.missingEnv.map((m) => `$${m.envName}`).join(", ")}`);
|
|
379
|
+
}
|
|
380
|
+
let res;
|
|
381
|
+
try {
|
|
382
|
+
res = await fetchImpl(request.endpoint, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: { "Content-Type": "application/json" },
|
|
385
|
+
body: JSON.stringify(request.payload),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
throw new ChannelApiError(`POST ${provision.display.endpoint} unreachable: ${err.message}`);
|
|
390
|
+
}
|
|
391
|
+
if (!res.ok) {
|
|
392
|
+
throw new ChannelApiError(`POST ${provision.display.endpoint} failed: ${res.status} ${res.statusText}`);
|
|
393
|
+
}
|
|
394
|
+
const body = (await res.json());
|
|
395
|
+
if (body.ok !== true) {
|
|
396
|
+
throw new ChannelApiError(`setWebhook error: ${body.description ?? "unknown"} (endpoint ${provision.display.endpoint})`);
|
|
397
|
+
}
|
|
398
|
+
return body.description ?? "webhook set";
|
|
399
|
+
}
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Discord — interactions endpoint registration + invite URL
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
const DEFAULT_DISCORD_API_BASE_URL = "https://discord.com/api/v10";
|
|
404
|
+
/**
|
|
405
|
+
* Invite-URL permission bits derived from the adapter's REST usage — the
|
|
406
|
+
* adapter never opens a gateway websocket, so no presence/guild intents:
|
|
407
|
+
* - `sendReply` POSTs `channels/<id>/messages` → View Channel + Send Messages
|
|
408
|
+
* - threads are first-class (`threadTs` from `channel.parent_id`), and the
|
|
409
|
+
* same POST into a thread channel → Send Messages in Threads
|
|
410
|
+
* - `setTyping` POSTs `channels/<id>/typing` → covered by Send Messages
|
|
411
|
+
*/
|
|
412
|
+
export const DISCORD_PERMISSIONS = [
|
|
413
|
+
{ name: "View Channel", bit: 1024 }, // 1 << 10
|
|
414
|
+
{ name: "Send Messages", bit: 2048 }, // 1 << 11
|
|
415
|
+
{ name: "Send Messages in Threads", bit: 274_877_906_944 }, // 1 << 38
|
|
416
|
+
];
|
|
417
|
+
export const DISCORD_PERMISSION_BITS = DISCORD_PERMISSIONS.reduce((sum, p) => sum + p.bit, 0);
|
|
418
|
+
function discordInviteUrl(applicationId) {
|
|
419
|
+
// bot → the REST sends; applications.commands → slash commands invokable.
|
|
420
|
+
// No percent-encoding: real ids are numeric snowflakes, and the dry-run
|
|
421
|
+
// placeholder ($DISCORD_APPLICATION_ID) must stay readable.
|
|
422
|
+
return `https://discord.com/oauth2/authorize?client_id=${applicationId}&scope=bot+applications.commands&permissions=${DISCORD_PERMISSION_BITS}`;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Build the interactions-endpoint registration: PATCH `applications/@me`
|
|
426
|
+
* (Bot-token auth — the same token the adapter uses for its REST sends)
|
|
427
|
+
* setting `interactions_endpoint_url` to the daemon's `/discord/events`
|
|
428
|
+
* route. Discord VALIDATES the URL during the PATCH (it sends a PING plus a
|
|
429
|
+
* bad-signature probe), so the daemon must be live at `--base-url` first.
|
|
430
|
+
*/
|
|
431
|
+
export function buildDiscordProvision(cfg, baseUrl, env) {
|
|
432
|
+
const apiBase = env["DISCORD_API_BASE_URL"] ?? DEFAULT_DISCORD_API_BASE_URL;
|
|
433
|
+
const endpoint = `${apiBase}/applications/@me`;
|
|
434
|
+
const payload = { interactions_endpoint_url: joinBaseUrl(baseUrl, channelEventsPath("discord")) };
|
|
435
|
+
const display = {
|
|
436
|
+
method: "PATCH",
|
|
437
|
+
endpoint,
|
|
438
|
+
payload,
|
|
439
|
+
authorization: `Bot ${describeSecretRef(cfg.botToken)}`,
|
|
440
|
+
inviteUrl: discordInviteUrl(describeSecretRef(cfg.applicationId)),
|
|
441
|
+
};
|
|
442
|
+
const missingEnv = collectMissing([
|
|
443
|
+
{ label: "channels.discord.applicationId", ref: cfg.applicationId },
|
|
444
|
+
{ label: "channels.discord.botToken", ref: cfg.botToken },
|
|
445
|
+
], env);
|
|
446
|
+
if (missingEnv.length > 0)
|
|
447
|
+
return { display, missingEnv };
|
|
448
|
+
return {
|
|
449
|
+
display,
|
|
450
|
+
missingEnv,
|
|
451
|
+
request: {
|
|
452
|
+
endpoint,
|
|
453
|
+
payload,
|
|
454
|
+
authorization: `Bot ${resolveSecretRef("channels.discord.botToken", cfg.botToken, env)}`,
|
|
455
|
+
},
|
|
456
|
+
inviteUrl: discordInviteUrl(resolveSecretRef("channels.discord.applicationId", cfg.applicationId, env)),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Perform the registration, read-before-write (adversarial-review F5): GET
|
|
461
|
+
* `applications/@me` first, and if an `interactions_endpoint_url` is already
|
|
462
|
+
* set to something OTHER than the daemon route, refuse to overwrite it
|
|
463
|
+
* unless `force` is set — silently clobbering it would detach whatever
|
|
464
|
+
* service the application currently points at (the same stance as `state
|
|
465
|
+
* restore`, which refuses to overwrite without --force). A 4xx on the PATCH
|
|
466
|
+
* usually means Discord's live endpoint validation failed — surfaced with
|
|
467
|
+
* that hint.
|
|
468
|
+
*/
|
|
469
|
+
export async function performDiscordProvision(provision, fetchImpl = fetch, opts = {}) {
|
|
470
|
+
const request = provision.request;
|
|
471
|
+
if (request === undefined) {
|
|
472
|
+
throw new ChannelApiError(`interactions endpoint not registered — unset env: ${provision.missingEnv.map((m) => `$${m.envName}`).join(", ")}`);
|
|
473
|
+
}
|
|
474
|
+
let getRes;
|
|
475
|
+
try {
|
|
476
|
+
getRes = await fetchImpl(request.endpoint, {
|
|
477
|
+
method: "GET",
|
|
478
|
+
headers: { Authorization: request.authorization },
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
throw new ChannelApiError(`GET ${request.endpoint} unreachable: ${err.message}`);
|
|
483
|
+
}
|
|
484
|
+
if (!getRes.ok) {
|
|
485
|
+
throw new ChannelApiError(`GET ${request.endpoint} failed: ${getRes.status} ${getRes.statusText} — cannot read the existing interactions endpoint, so no overwrite was attempted`);
|
|
486
|
+
}
|
|
487
|
+
const app = (await getRes.json().catch(() => ({})));
|
|
488
|
+
const previousEndpoint = typeof app.interactions_endpoint_url === "string" && app.interactions_endpoint_url !== ""
|
|
489
|
+
? app.interactions_endpoint_url
|
|
490
|
+
: undefined;
|
|
491
|
+
const target = request.payload.interactions_endpoint_url;
|
|
492
|
+
const wouldReplace = previousEndpoint !== undefined && previousEndpoint !== target;
|
|
493
|
+
if (wouldReplace && opts.force !== true) {
|
|
494
|
+
throw new ChannelApiError(`interactions_endpoint_url is already set to "${previousEndpoint}" — refusing to overwrite it with "${target}" (that would detach whatever currently serves it); re-run with --force to replace`);
|
|
495
|
+
}
|
|
496
|
+
let res;
|
|
497
|
+
try {
|
|
498
|
+
res = await fetchImpl(request.endpoint, {
|
|
499
|
+
method: "PATCH",
|
|
500
|
+
headers: {
|
|
501
|
+
"Content-Type": "application/json",
|
|
502
|
+
Authorization: request.authorization,
|
|
503
|
+
},
|
|
504
|
+
body: JSON.stringify(request.payload),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
throw new ChannelApiError(`PATCH ${request.endpoint} unreachable: ${err.message}`);
|
|
509
|
+
}
|
|
510
|
+
if (!res.ok) {
|
|
511
|
+
let detail = `${res.status} ${res.statusText}`;
|
|
512
|
+
try {
|
|
513
|
+
const body = (await res.json());
|
|
514
|
+
if (typeof body.message === "string")
|
|
515
|
+
detail = `${detail} — ${body.message}`;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// non-JSON error body — status line is all we have
|
|
519
|
+
}
|
|
520
|
+
throw new ChannelApiError(`PATCH ${request.endpoint} failed: ${detail} (Discord validates the interactions endpoint live — the daemon must be running and reachable at the --base-url when you provision)`);
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
...(previousEndpoint !== undefined ? { previousEndpoint } : {}),
|
|
524
|
+
replacedPrevious: wouldReplace,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/** Post-registration instructions: the invite URL plus the command-
|
|
528
|
+
* registration note (see the module header for why commands aren't
|
|
529
|
+
* auto-registered: the spec declares none and the adapter accepts any). */
|
|
530
|
+
export function discordNextSteps(inviteUrl) {
|
|
531
|
+
return [
|
|
532
|
+
`1. invite the bot with the derived permission bits (${DISCORD_PERMISSIONS.map((p) => p.name).join(", ")}):`,
|
|
533
|
+
` ${inviteUrl}`,
|
|
534
|
+
"2. register the slash command(s) your users should invoke — the adapter routes",
|
|
535
|
+
' ANY command to the agent (rendered as "/<name> <options>"), and the spec',
|
|
536
|
+
" declares no command list, so names are yours to choose:",
|
|
537
|
+
` PUT ${DEFAULT_DISCORD_API_BASE_URL}/applications/<application-id>/commands`,
|
|
538
|
+
"3. re-run `crewhaus channel verify --platform discord` to confirm the endpoint",
|
|
539
|
+
];
|
|
540
|
+
}
|
|
541
|
+
function envRefCheck(label, fieldLabel, ref, env) {
|
|
542
|
+
if (ref.kind === "literal") {
|
|
543
|
+
return {
|
|
544
|
+
label,
|
|
545
|
+
pass: true,
|
|
546
|
+
warn: true,
|
|
547
|
+
reason: `${fieldLabel} is an inline literal — prefer a $ENV ref so the value stays out of the spec`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const set = env[ref.name] !== undefined && env[ref.name] !== "";
|
|
551
|
+
return set
|
|
552
|
+
? { label, pass: true }
|
|
553
|
+
: {
|
|
554
|
+
label,
|
|
555
|
+
pass: false,
|
|
556
|
+
reason: `${fieldLabel} references $${ref.name} but it is unset — the daemon exits at boot without it`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Slack probes: `auth.test` with the bot token (identity + reachability),
|
|
561
|
+
* then the token's GRANTED scopes — Slack returns them on every Web API
|
|
562
|
+
* response in the `x-oauth-scopes` header — diffed against the needed set
|
|
563
|
+
* from {@link deriveSlackScopes}. The signing secret is only checked for
|
|
564
|
+
* presence: Slack never exposes it back over the API.
|
|
565
|
+
*/
|
|
566
|
+
export async function verifySlackChannel(cfg, spec, opts) {
|
|
567
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
568
|
+
const apiBase = opts.env["SLACK_API_BASE_URL"] ?? DEFAULT_SLACK_API_BASE_URL;
|
|
569
|
+
const checks = [];
|
|
570
|
+
checks.push(envRefCheck("Slack signing secret", "channels.slack.signingSecret", cfg.signingSecret, opts.env));
|
|
571
|
+
let botToken;
|
|
572
|
+
try {
|
|
573
|
+
botToken = resolveSecretRef("channels.slack.botToken", cfg.botToken, opts.env);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
checks.push({ label: "Slack bot token", pass: false, reason: err.message });
|
|
577
|
+
return checks;
|
|
578
|
+
}
|
|
579
|
+
let res;
|
|
580
|
+
try {
|
|
581
|
+
res = await fetchImpl(`${apiBase}/auth.test`, {
|
|
582
|
+
method: "POST",
|
|
583
|
+
headers: { Authorization: `Bearer ${botToken}` },
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
checks.push({
|
|
588
|
+
label: "Slack auth.test",
|
|
589
|
+
pass: false,
|
|
590
|
+
reason: `Slack API unreachable: ${err.message}`,
|
|
591
|
+
});
|
|
592
|
+
return checks;
|
|
593
|
+
}
|
|
594
|
+
const body = (await res.json().catch(() => ({})));
|
|
595
|
+
if (body.ok !== true) {
|
|
596
|
+
checks.push({
|
|
597
|
+
label: "Slack auth.test",
|
|
598
|
+
pass: false,
|
|
599
|
+
reason: `token rejected: ${body.error ?? `${res.status} ${res.statusText}`}`,
|
|
600
|
+
});
|
|
601
|
+
return checks;
|
|
602
|
+
}
|
|
603
|
+
checks.push({
|
|
604
|
+
label: `Slack auth.test (team ${body.team ?? "?"}, bot user ${body.user ?? "?"})`,
|
|
605
|
+
pass: true,
|
|
606
|
+
});
|
|
607
|
+
const needed = deriveSlackScopes(spec).map((s) => s.scope);
|
|
608
|
+
const grantedHeader = res.headers.get("x-oauth-scopes");
|
|
609
|
+
if (grantedHeader === null) {
|
|
610
|
+
checks.push({
|
|
611
|
+
label: "Slack bot scopes",
|
|
612
|
+
pass: true,
|
|
613
|
+
warn: true,
|
|
614
|
+
reason: "Slack did not return x-oauth-scopes — granted scopes could not be compared",
|
|
615
|
+
});
|
|
616
|
+
return checks;
|
|
617
|
+
}
|
|
618
|
+
const granted = new Set(grantedHeader
|
|
619
|
+
.split(",")
|
|
620
|
+
.map((s) => s.trim())
|
|
621
|
+
.filter((s) => s !== ""));
|
|
622
|
+
const missing = needed.filter((s) => !granted.has(s));
|
|
623
|
+
checks.push(missing.length === 0
|
|
624
|
+
? { label: `Slack bot scopes (${needed.length} needed, all granted)`, pass: true }
|
|
625
|
+
: {
|
|
626
|
+
label: "Slack bot scopes",
|
|
627
|
+
pass: false,
|
|
628
|
+
reason: `missing: ${missing.join(", ")} — update the app from the manifest (crewhaus channel provision --platform slack) and re-install`,
|
|
629
|
+
});
|
|
630
|
+
return checks;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Telegram probes: `getWebhookInfo`, compared against the daemon route.
|
|
634
|
+
* Telegram never returns the configured secret_token (write-only by design),
|
|
635
|
+
* so the secret is checked for presence on the daemon side — the adapter
|
|
636
|
+
* rejects every inbound POST if the two ever diverge, which surfaces as
|
|
637
|
+
* pending updates + a 401 last_error here.
|
|
638
|
+
*/
|
|
639
|
+
export async function verifyTelegramChannel(cfg, opts) {
|
|
640
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
641
|
+
const apiBase = opts.env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_TELEGRAM_API_BASE_URL;
|
|
642
|
+
const checks = [];
|
|
643
|
+
checks.push(envRefCheck("Telegram secret token (daemon side; Telegram never returns it)", "channels.telegram.secretToken", cfg.secretToken, opts.env));
|
|
644
|
+
let botToken;
|
|
645
|
+
try {
|
|
646
|
+
botToken = resolveSecretRef("channels.telegram.botToken", cfg.botToken, opts.env);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
checks.push({ label: "Telegram bot token", pass: false, reason: err.message });
|
|
650
|
+
return checks;
|
|
651
|
+
}
|
|
652
|
+
const redactedEndpoint = `${apiBase}/bot${describeSecretRef(cfg.botToken)}/getWebhookInfo`;
|
|
653
|
+
let res;
|
|
654
|
+
try {
|
|
655
|
+
res = await fetchImpl(`${apiBase}/bot${botToken}/getWebhookInfo`, { method: "GET" });
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
checks.push({
|
|
659
|
+
label: "Telegram getWebhookInfo",
|
|
660
|
+
pass: false,
|
|
661
|
+
reason: `${redactedEndpoint} unreachable: ${err.message}`,
|
|
662
|
+
});
|
|
663
|
+
return checks;
|
|
664
|
+
}
|
|
665
|
+
const body = (await res.json().catch(() => ({})));
|
|
666
|
+
if (body.ok !== true || body.result === undefined) {
|
|
667
|
+
checks.push({
|
|
668
|
+
label: "Telegram getWebhookInfo",
|
|
669
|
+
pass: false,
|
|
670
|
+
reason: `token rejected: ${body.description ?? `${res.status} ${res.statusText}`}`,
|
|
671
|
+
});
|
|
672
|
+
return checks;
|
|
673
|
+
}
|
|
674
|
+
checks.push({ label: "Telegram getWebhookInfo", pass: true });
|
|
675
|
+
const info = body.result;
|
|
676
|
+
const url = info.url ?? "";
|
|
677
|
+
if (url === "") {
|
|
678
|
+
checks.push({
|
|
679
|
+
label: "Telegram webhook url",
|
|
680
|
+
pass: false,
|
|
681
|
+
reason: "no webhook registered — run `crewhaus channel provision --platform telegram`",
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
else if (opts.baseUrl !== undefined) {
|
|
685
|
+
const expected = joinBaseUrl(opts.baseUrl, channelEventsPath("telegram"));
|
|
686
|
+
checks.push(url === expected
|
|
687
|
+
? { label: `Telegram webhook url (${url})`, pass: true }
|
|
688
|
+
: {
|
|
689
|
+
label: "Telegram webhook url",
|
|
690
|
+
pass: false,
|
|
691
|
+
reason: `registered "${url}" but this daemon expects "${expected}" — re-run provision`,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
checks.push({
|
|
696
|
+
label: "Telegram webhook url",
|
|
697
|
+
pass: true,
|
|
698
|
+
warn: true,
|
|
699
|
+
reason: `registered as ${url} — pass --base-url to assert it matches this daemon`,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
const allowedUpdates = info.allowed_updates;
|
|
703
|
+
if (allowedUpdates !== undefined) {
|
|
704
|
+
const missing = TELEGRAM_ALLOWED_UPDATES.filter((u) => !allowedUpdates.includes(u));
|
|
705
|
+
if (missing.length > 0) {
|
|
706
|
+
checks.push({
|
|
707
|
+
label: "Telegram allowed_updates",
|
|
708
|
+
pass: true,
|
|
709
|
+
warn: true,
|
|
710
|
+
reason: `webhook does not deliver: ${missing.join(", ")} — the adapter consumes them; re-run provision to restore`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
checks.push({ label: "Telegram allowed_updates", pass: true });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const pending = info.pending_update_count ?? 0;
|
|
718
|
+
if (pending > 0) {
|
|
719
|
+
checks.push({
|
|
720
|
+
label: "Telegram pending updates",
|
|
721
|
+
pass: true,
|
|
722
|
+
warn: true,
|
|
723
|
+
reason: `${pending} update(s) queued — the daemon may be unreachable at the webhook url`,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
if (info.last_error_message !== undefined && info.last_error_message !== "") {
|
|
727
|
+
const at = info.last_error_date !== undefined
|
|
728
|
+
? ` at ${new Date(info.last_error_date * 1000).toISOString()}`
|
|
729
|
+
: "";
|
|
730
|
+
checks.push({
|
|
731
|
+
label: "Telegram last delivery error",
|
|
732
|
+
pass: true,
|
|
733
|
+
warn: true,
|
|
734
|
+
reason: `${info.last_error_message}${at}`,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
return checks;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Discord probes: `GET applications/@me` with the bot token, asserting the
|
|
741
|
+
* three facts the adapter depends on — the token belongs to the declared
|
|
742
|
+
* `applicationId`, the application's `verify_key` equals the spec's
|
|
743
|
+
* `publicKeyHex` (a mismatch means every inbound webhook fails Ed25519
|
|
744
|
+
* verification with a 401), and `interactions_endpoint_url` points at the
|
|
745
|
+
* daemon's `/discord/events` route.
|
|
746
|
+
*/
|
|
747
|
+
export async function verifyDiscordChannel(cfg, opts) {
|
|
748
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
749
|
+
const apiBase = opts.env["DISCORD_API_BASE_URL"] ?? DEFAULT_DISCORD_API_BASE_URL;
|
|
750
|
+
const checks = [];
|
|
751
|
+
let botToken;
|
|
752
|
+
try {
|
|
753
|
+
botToken = resolveSecretRef("channels.discord.botToken", cfg.botToken, opts.env);
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
checks.push({ label: "Discord bot token", pass: false, reason: err.message });
|
|
757
|
+
return checks;
|
|
758
|
+
}
|
|
759
|
+
let res;
|
|
760
|
+
try {
|
|
761
|
+
res = await fetchImpl(`${apiBase}/applications/@me`, {
|
|
762
|
+
method: "GET",
|
|
763
|
+
headers: { Authorization: `Bot ${botToken}` },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
checks.push({
|
|
768
|
+
label: "Discord application fetch",
|
|
769
|
+
pass: false,
|
|
770
|
+
reason: `${apiBase}/applications/@me unreachable: ${err.message}`,
|
|
771
|
+
});
|
|
772
|
+
return checks;
|
|
773
|
+
}
|
|
774
|
+
if (!res.ok) {
|
|
775
|
+
checks.push({
|
|
776
|
+
label: "Discord application fetch",
|
|
777
|
+
pass: false,
|
|
778
|
+
reason: `token rejected: ${res.status} ${res.statusText}`,
|
|
779
|
+
});
|
|
780
|
+
return checks;
|
|
781
|
+
}
|
|
782
|
+
const app = (await res.json().catch(() => ({})));
|
|
783
|
+
checks.push({ label: `Discord application fetch (${app.name ?? "?"})`, pass: true });
|
|
784
|
+
try {
|
|
785
|
+
const declaredId = resolveSecretRef("channels.discord.applicationId", cfg.applicationId, opts.env);
|
|
786
|
+
checks.push(app.id === declaredId
|
|
787
|
+
? { label: `Discord application id (${declaredId})`, pass: true }
|
|
788
|
+
: {
|
|
789
|
+
label: "Discord application id",
|
|
790
|
+
pass: false,
|
|
791
|
+
reason: `bot token belongs to application ${app.id ?? "?"} but the spec declares ${declaredId}`,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
catch (err) {
|
|
795
|
+
checks.push({ label: "Discord application id", pass: false, reason: err.message });
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const declaredKey = resolveSecretRef("channels.discord.publicKeyHex", cfg.publicKeyHex, opts.env);
|
|
799
|
+
checks.push(app.verify_key === declaredKey
|
|
800
|
+
? { label: "Discord public key matches verify_key", pass: true }
|
|
801
|
+
: {
|
|
802
|
+
label: "Discord public key",
|
|
803
|
+
pass: false,
|
|
804
|
+
reason: "spec publicKeyHex differs from the application's verify_key — every inbound webhook will fail Ed25519 verification (401)",
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
checks.push({ label: "Discord public key", pass: false, reason: err.message });
|
|
809
|
+
}
|
|
810
|
+
const endpointUrl = app.interactions_endpoint_url ?? "";
|
|
811
|
+
if (endpointUrl === "") {
|
|
812
|
+
checks.push({
|
|
813
|
+
label: "Discord interactions endpoint",
|
|
814
|
+
pass: false,
|
|
815
|
+
reason: "not set — the adapter is webhook-based; run `crewhaus channel provision --platform discord`",
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
else if (opts.baseUrl !== undefined) {
|
|
819
|
+
const expected = joinBaseUrl(opts.baseUrl, channelEventsPath("discord"));
|
|
820
|
+
checks.push(endpointUrl === expected
|
|
821
|
+
? { label: `Discord interactions endpoint (${endpointUrl})`, pass: true }
|
|
822
|
+
: {
|
|
823
|
+
label: "Discord interactions endpoint",
|
|
824
|
+
pass: false,
|
|
825
|
+
reason: `set to "${endpointUrl}" but this daemon expects "${expected}" — re-run provision`,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
checks.push({
|
|
830
|
+
label: "Discord interactions endpoint",
|
|
831
|
+
pass: true,
|
|
832
|
+
warn: true,
|
|
833
|
+
reason: `set to ${endpointUrl} — pass --base-url to assert it matches this daemon`,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
return checks;
|
|
837
|
+
}
|
|
838
|
+
/** Redacted one-liners describing what `verify` would call — the dry-run
|
|
839
|
+
* counterpart for the probes (network never happens under --dry-run). */
|
|
840
|
+
export function describeVerifyProbes(platform, channels, env) {
|
|
841
|
+
if (platform === "slack" && channels.slack !== undefined) {
|
|
842
|
+
const apiBase = env["SLACK_API_BASE_URL"] ?? DEFAULT_SLACK_API_BASE_URL;
|
|
843
|
+
return [
|
|
844
|
+
`would POST ${apiBase}/auth.test (Authorization: Bearer ${describeSecretRef(channels.slack.botToken)})`,
|
|
845
|
+
"would compare the x-oauth-scopes response header against the derived scope set",
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
if (platform === "telegram" && channels.telegram !== undefined) {
|
|
849
|
+
const apiBase = env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_TELEGRAM_API_BASE_URL;
|
|
850
|
+
return [
|
|
851
|
+
`would GET ${apiBase}/bot${describeSecretRef(channels.telegram.botToken)}/getWebhookInfo`,
|
|
852
|
+
];
|
|
853
|
+
}
|
|
854
|
+
if (platform === "discord" && channels.discord !== undefined) {
|
|
855
|
+
const apiBase = env["DISCORD_API_BASE_URL"] ?? DEFAULT_DISCORD_API_BASE_URL;
|
|
856
|
+
return [
|
|
857
|
+
`would GET ${apiBase}/applications/@me (Authorization: Bot ${describeSecretRef(channels.discord.botToken)})`,
|
|
858
|
+
];
|
|
859
|
+
}
|
|
860
|
+
return [];
|
|
861
|
+
}
|
|
862
|
+
/** Render checks as the doctor's ✓/~/✗ lines + the exit code — non-zero on
|
|
863
|
+
* any hard failure (missing scopes, mismatched webhook, bad credentials);
|
|
864
|
+
* warns are informational. Mirrors audit-verify's `summarizeVerifyResult`. */
|
|
865
|
+
export function summarizeChannelChecks(checks) {
|
|
866
|
+
const lines = checks.map((c) => {
|
|
867
|
+
if (c.warn === true && c.pass)
|
|
868
|
+
return `~ ${c.label}: ${c.reason ?? "informational"}`;
|
|
869
|
+
if (c.pass)
|
|
870
|
+
return `✓ ${c.label}`;
|
|
871
|
+
return `✗ ${c.label}: ${c.reason ?? "failed"}`;
|
|
872
|
+
});
|
|
873
|
+
const failed = checks.filter((c) => !c.pass).length;
|
|
874
|
+
return {
|
|
875
|
+
lines: [
|
|
876
|
+
...lines,
|
|
877
|
+
`${checks.length} check(s), ${failed} failed${failed > 0 ? "" : " — channel wiring looks healthy"}`,
|
|
878
|
+
],
|
|
879
|
+
exitCode: failed > 0 ? 1 : 0,
|
|
880
|
+
};
|
|
881
|
+
}
|