@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.1
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/CLAUDE.md +1119 -0
- package/dist/api-errors.d.ts +5 -0
- package/dist/api-errors.js +33 -0
- package/dist/app-storage.d.ts +7 -0
- package/dist/app-storage.js +22 -0
- package/dist/assembly.d.ts +49 -0
- package/dist/assembly.js +235 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +118 -0
- package/dist/client-companies.d.ts +16 -0
- package/dist/client-companies.js +23 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.js +14 -3
- package/dist/labels.d.ts +9 -0
- package/dist/labels.js +17 -0
- package/dist/mint-bearer.d.ts +18 -0
- package/dist/mint-bearer.js +152 -0
- package/dist/nango.d.ts +174 -0
- package/dist/nango.js +430 -0
- package/dist/network-capture.d.ts +36 -0
- package/dist/network-capture.js +473 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +20 -0
- package/dist/resource-resolution.d.ts +89 -0
- package/dist/resource-resolution.js +142 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.js +15 -5
- package/dist/session-exchange.d.ts +20 -0
- package/dist/session-exchange.js +101 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +20 -0
- package/package.json +9 -3
- package/skills/assembly-ai/SKILL.md +133 -0
- package/skills/assembly-app-bridge-header/SKILL.md +88 -0
- package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
- package/skills/assembly-avatar-sub/SKILL.md +103 -0
- package/skills/assembly-client-users/SKILL.md +45 -0
- package/skills/assembly-clients-companies/SKILL.md +143 -0
- package/skills/assembly-companies/SKILL.md +45 -0
- package/skills/assembly-custom-fields/SKILL.md +86 -0
- package/skills/assembly-db-transactions/SKILL.md +79 -0
- package/skills/assembly-dev-server-logs/SKILL.md +26 -0
- package/skills/assembly-forms/SKILL.md +103 -0
- package/skills/assembly-internal-users/SKILL.md +69 -0
- package/skills/assembly-mutations/SKILL.md +58 -0
- package/skills/assembly-native-resources/SKILL.md +117 -0
- package/skills/assembly-route-auth/SKILL.md +223 -0
- package/skills/assembly-route-errors/SKILL.md +81 -0
- package/skills/assembly-semantic-colors/SKILL.md +76 -0
- package/skills/assembly-session-token/SKILL.md +153 -0
- package/skills/assembly-storage/SKILL.md +157 -0
- package/skills/assembly-user-company-selector/SKILL.md +98 -0
- package/skills/assembly-workspace-labels/SKILL.md +50 -0
- package/skills/clearing-client-notifications/SKILL.md +100 -0
package/dist/nango.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { getAssembly } from './assembly';
|
|
2
|
+
import { findResource, normalizeResourcePage, TtlCache, } from './resource-resolution';
|
|
3
|
+
// Thrown when the broker returns a structured `{ error, provider }` fail-closed
|
|
4
|
+
// response. Catch it server-side and branch on `code` to render the right empty
|
|
5
|
+
// state (e.g. "reconnect required" for needs_reauth, "not configured" for
|
|
6
|
+
// connection_required). `status` is the broker's HTTP status for the response.
|
|
7
|
+
export class ConnectorError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
provider;
|
|
10
|
+
status;
|
|
11
|
+
/**
|
|
12
|
+
* For `action_failed`: what the action script threw — usually the
|
|
13
|
+
* provider-level failure, e.g. `{ type: 'slack_error', message, response }`
|
|
14
|
+
* where `response.error` is Slack's error code.
|
|
15
|
+
*/
|
|
16
|
+
detail;
|
|
17
|
+
constructor(args) {
|
|
18
|
+
super(`Connector ${args.provider} error: ${args.code}`);
|
|
19
|
+
this.name = 'ConnectorError';
|
|
20
|
+
this.code = args.code;
|
|
21
|
+
this.provider = args.provider;
|
|
22
|
+
this.status = args.status;
|
|
23
|
+
this.detail = args.detail;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Build the broker proxy path: /v1/connectors/:provider/proxy/<endpoint>?<params>.
|
|
27
|
+
// The provider path is the URL suffix after /proxy/; query params are appended.
|
|
28
|
+
function buildProxyPath(provider, endpoint, params) {
|
|
29
|
+
const cleanEndpoint = endpoint.replace(/^\//, '');
|
|
30
|
+
let path = `/v1/connectors/${provider}/proxy/${cleanEndpoint}`;
|
|
31
|
+
if (params) {
|
|
32
|
+
const search = new URLSearchParams();
|
|
33
|
+
for (const [key, value] of Object.entries(params)) {
|
|
34
|
+
if (value !== undefined)
|
|
35
|
+
search.set(key, String(value));
|
|
36
|
+
}
|
|
37
|
+
const qs = search.toString();
|
|
38
|
+
if (qs)
|
|
39
|
+
path += `?${qs}`;
|
|
40
|
+
}
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
// Reads a structured `{ error, provider }` fail-closed response off a non-2xx
|
|
44
|
+
// broker response and throws ConnectorError. On a 2xx this is a no-op.
|
|
45
|
+
async function throwIfBrokerError(res, provider) {
|
|
46
|
+
if (res.ok)
|
|
47
|
+
return;
|
|
48
|
+
let code = `http_${res.status}`;
|
|
49
|
+
let detail;
|
|
50
|
+
try {
|
|
51
|
+
const cloned = res.clone();
|
|
52
|
+
const body = (await cloned.json());
|
|
53
|
+
// A relayed Nango error nests the code (`{ error: { code } }`); the
|
|
54
|
+
// broker's own fail-closed responses use a flat string.
|
|
55
|
+
if (typeof body?.error === 'string')
|
|
56
|
+
code = body.error;
|
|
57
|
+
else if (typeof body?.error?.code === 'string')
|
|
58
|
+
code = body.error.code;
|
|
59
|
+
detail = body?.detail;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Non-JSON error body (rare) — fall back to the http_<status> code.
|
|
63
|
+
}
|
|
64
|
+
throw new ConnectorError({ code, provider, status: res.status, detail });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Returns a thin, server-side-only client for a connected provider. Every method
|
|
68
|
+
* POSTs to the Assembly connector broker (`/v1/connectors/:provider/...`) via
|
|
69
|
+
* getAssembly().fetch — there is no Nango client and no provider credentials in
|
|
70
|
+
* the app. The broker resolves the workspace's connection server-side and
|
|
71
|
+
* relays the provider response transparently.
|
|
72
|
+
*
|
|
73
|
+
* MUST be called from server-side code only (API routes, server components,
|
|
74
|
+
* server actions) within an authenticated request scope. `getAssembly()` reads
|
|
75
|
+
* the viewer bearer from the request cookie (same as every other data call);
|
|
76
|
+
* there is no `x-assembly-token` header and no token argument. Background/cron
|
|
77
|
+
* calls with no viewer are out of scope for v1 — see connector Phase-2.
|
|
78
|
+
*/
|
|
79
|
+
export async function getIntegration(provider) {
|
|
80
|
+
const assembly = await getAssembly();
|
|
81
|
+
// Relays a provider HTTP call through the broker's universal proxy. The broker
|
|
82
|
+
// endpoint is always POST-ed (so a body carries through); the real provider
|
|
83
|
+
// verb travels in X-Connector-Method (default GET). The provider response is
|
|
84
|
+
// relayed transparently, so we return the raw Response for the caller to read
|
|
85
|
+
// (.json(), .text(), status, headers).
|
|
86
|
+
const proxy = async (opts) => {
|
|
87
|
+
const method = (opts.method ?? 'GET').toUpperCase();
|
|
88
|
+
const path = buildProxyPath(provider, opts.endpoint, opts.params);
|
|
89
|
+
// Named proxyHeaders (not `headers`) so callers' opts.headers stay distinct
|
|
90
|
+
// from this local map.
|
|
91
|
+
const proxyHeaders = {
|
|
92
|
+
...(opts.headers ?? {}),
|
|
93
|
+
'X-Connector-Method': method,
|
|
94
|
+
};
|
|
95
|
+
const init = { method: 'POST', headers: proxyHeaders };
|
|
96
|
+
if (opts.data !== undefined) {
|
|
97
|
+
if (typeof opts.data === 'string') {
|
|
98
|
+
init.body = opts.data;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
init.body = JSON.stringify(opts.data);
|
|
102
|
+
// fetch defaults string bodies to text/plain; the broker forwards the
|
|
103
|
+
// content-type to the provider, so declare JSON unless the caller set
|
|
104
|
+
// their own content type.
|
|
105
|
+
const hasContentType = Object.keys(proxyHeaders).some((key) => key.toLowerCase() === 'content-type');
|
|
106
|
+
if (!hasContentType)
|
|
107
|
+
proxyHeaders['Content-Type'] = 'application/json';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const res = await assembly.fetch(path, init);
|
|
111
|
+
await throwIfBrokerError(res, provider);
|
|
112
|
+
return res;
|
|
113
|
+
};
|
|
114
|
+
// Verb sugar: same as proxy() but pins the provider HTTP method.
|
|
115
|
+
const withMethod = (method) => (opts) => proxy({ ...opts, method });
|
|
116
|
+
return {
|
|
117
|
+
proxy,
|
|
118
|
+
get: withMethod('GET'),
|
|
119
|
+
post: withMethod('POST'),
|
|
120
|
+
put: withMethod('PUT'),
|
|
121
|
+
patch: withMethod('PATCH'),
|
|
122
|
+
delete: withMethod('DELETE'),
|
|
123
|
+
// Synchronously triggers a pre-built Nango Action and returns its normalized
|
|
124
|
+
// result inline as JSON. The whole `input` object is the action input.
|
|
125
|
+
// The available actions are listed in the assembly-integrations skill —
|
|
126
|
+
// never guess a name not on that list (an unknown name fails at runtime).
|
|
127
|
+
// A script-level failure throws ConnectorError with code 'action_failed'
|
|
128
|
+
// and the provider error in `detail`.
|
|
129
|
+
action: async (name, input) => {
|
|
130
|
+
const path = `/v1/connectors/${provider}/action/${encodeURIComponent(name)}`;
|
|
131
|
+
const res = await assembly.fetch(path, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify(input ?? {}),
|
|
135
|
+
});
|
|
136
|
+
await throwIfBrokerError(res, provider);
|
|
137
|
+
return (await res.json());
|
|
138
|
+
},
|
|
139
|
+
// Reads the workspace's stored connection state from the broker — no
|
|
140
|
+
// provider round-trip, no side effects. Gate errors (provider_not_declared,
|
|
141
|
+
// app_key_required) still throw ConnectorError; a missing or reauth-needing
|
|
142
|
+
// connection is a normal `{ connected: false, status }` result. Prefer the
|
|
143
|
+
// isConnected() helper for a plain boolean.
|
|
144
|
+
status: async () => {
|
|
145
|
+
const res = await assembly.fetch(`/v1/connectors/${provider}/status`, {
|
|
146
|
+
method: 'GET',
|
|
147
|
+
});
|
|
148
|
+
await throwIfBrokerError(res, provider);
|
|
149
|
+
return (await res.json());
|
|
150
|
+
},
|
|
151
|
+
// Reserved seam for the future two-way sync surface (read synced records via
|
|
152
|
+
// the broker). Not implemented in v1 — declare it so call sites and the
|
|
153
|
+
// backend sync endpoint can land additively without changing this signature.
|
|
154
|
+
records: (_model, _query) => {
|
|
155
|
+
throw new Error('getIntegration().records is not supported in v1');
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// The ONLY ConnectorError codes that mean a provider is genuinely not usable
|
|
160
|
+
// by this app. Every other outcome — connector_unavailable, a transport blip —
|
|
161
|
+
// leaves the stored connection intact; a connectivity check must never treat
|
|
162
|
+
// those as "not connected".
|
|
163
|
+
const NOT_CONNECTED_CODES = new Set([
|
|
164
|
+
'connection_required',
|
|
165
|
+
'needs_reauth',
|
|
166
|
+
'needs_reauth_scope',
|
|
167
|
+
'provider_not_declared',
|
|
168
|
+
'app_key_required',
|
|
169
|
+
]);
|
|
170
|
+
/**
|
|
171
|
+
* Report whether a provider is connected for the current workspace via the
|
|
172
|
+
* broker's read-only status endpoint — no provider call is made, so an
|
|
173
|
+
* unrelated call failure can never masquerade as "not connected".
|
|
174
|
+
*
|
|
175
|
+
* - status says connected → true
|
|
176
|
+
* - status says connection_required /
|
|
177
|
+
* needs_reauth / needs_reauth_scope → false (user must (re)connect)
|
|
178
|
+
* - provider_not_declared / app_key_required → false (a wiring bug — the
|
|
179
|
+
* provider is unusable until the manifest / call site is fixed)
|
|
180
|
+
* - the status call itself fails
|
|
181
|
+
* (connector_unavailable, transport) → true (unknown ≠ disconnected; a
|
|
182
|
+
* healthy connection must never render a Connect prompt over a blip)
|
|
183
|
+
*
|
|
184
|
+
* Do NOT hand-roll connectivity checks by making a provider call and branching
|
|
185
|
+
* on whether it succeeded — that reports a healthy connection as "not
|
|
186
|
+
* connected" the moment any unrelated call fails (the classic bug: probing a
|
|
187
|
+
* non-existent endpoint 404s, and the app shows "not connected" while real
|
|
188
|
+
* reads/writes succeed).
|
|
189
|
+
*/
|
|
190
|
+
export async function isConnected(provider) {
|
|
191
|
+
try {
|
|
192
|
+
const integration = await getIntegration(provider);
|
|
193
|
+
const { connected } = await integration.status();
|
|
194
|
+
return connected;
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err instanceof ConnectorError && NOT_CONNECTED_CODES.has(err.code)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Short-lived memo of resolver pages so a user typing in a picker doesn't
|
|
204
|
+
// re-hit the (often rate-limited) provider for every keystroke revisit. Safe
|
|
205
|
+
// to share across requests: a deployment serves one workspace and connections
|
|
206
|
+
// are workspace-scoped. Errors are never cached.
|
|
207
|
+
const resolveResourceCache = new TtlCache(200);
|
|
208
|
+
const RESOLVE_RESOURCE_CACHE_TTL_MS = 30_000;
|
|
209
|
+
/**
|
|
210
|
+
* Fetch one page of provider resources as normalized `{ id, label }` options —
|
|
211
|
+
* the server half of a resource picker (channel/folder/spreadsheet/base
|
|
212
|
+
* selection). Runs the resolver action through the broker and maps the result
|
|
213
|
+
* via `fields`, so apps never re-derive pagination, id/label mapping, or
|
|
214
|
+
* connector error handling per picker.
|
|
215
|
+
*
|
|
216
|
+
* Returns a serializable ResourcePage rather than throwing ConnectorError:
|
|
217
|
+
* call it from a server action and hand the result (or the action itself) to
|
|
218
|
+
* `<ResourcePicker>`, which renders the matching option list, "Load more",
|
|
219
|
+
* and reconnect/error states. Non-connector errors still propagate.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* // app/actions/channels.ts
|
|
223
|
+
* 'use server';
|
|
224
|
+
* import { getIntegration, resolveResource } from '@assembly-js/app-foundation/server';
|
|
225
|
+
*
|
|
226
|
+
* export async function searchChannels(args: { query: string; cursor?: string }) {
|
|
227
|
+
* // list-channels has no query input, so pages are label-filtered locally.
|
|
228
|
+
* return resolveResource('slack', 'list-channels', {
|
|
229
|
+
* ...args,
|
|
230
|
+
* fields: { id: 'id', label: 'name', items: 'conversations', nextCursor: 'next_cursor' },
|
|
231
|
+
* });
|
|
232
|
+
* }
|
|
233
|
+
*/
|
|
234
|
+
export async function resolveResource(provider, resolverAction, opts) {
|
|
235
|
+
const query = opts.query?.trim() || undefined;
|
|
236
|
+
const actionInput = { ...(opts.input ?? {}) };
|
|
237
|
+
if (query && opts.queryInput)
|
|
238
|
+
actionInput[opts.queryInput] = query;
|
|
239
|
+
if (opts.cursor)
|
|
240
|
+
actionInput[opts.cursorInput ?? 'cursor'] = opts.cursor;
|
|
241
|
+
const cacheKey = JSON.stringify([
|
|
242
|
+
provider,
|
|
243
|
+
resolverAction,
|
|
244
|
+
actionInput,
|
|
245
|
+
opts.fields,
|
|
246
|
+
query,
|
|
247
|
+
]);
|
|
248
|
+
const cached = resolveResourceCache.get(cacheKey);
|
|
249
|
+
if (cached)
|
|
250
|
+
return cached;
|
|
251
|
+
let raw;
|
|
252
|
+
try {
|
|
253
|
+
const integration = await getIntegration(provider);
|
|
254
|
+
raw = await integration.action(resolverAction, actionInput);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
if (err instanceof ConnectorError) {
|
|
258
|
+
return { ok: false, error: { code: err.code, provider: err.provider } };
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
const page = normalizeResourcePage(raw, opts.fields, {
|
|
263
|
+
// When the action searched server-side, don't second-guess its matching
|
|
264
|
+
// with a literal substring filter.
|
|
265
|
+
query: opts.queryInput ? undefined : query,
|
|
266
|
+
requestCursor: opts.cursor,
|
|
267
|
+
});
|
|
268
|
+
const result = { ok: true, ...page };
|
|
269
|
+
resolveResourceCache.set(cacheKey, result, RESOLVE_RESOURCE_CACHE_TTL_MS);
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
// Process-lifetime memo of resolved channel IDs so repeated sends don't
|
|
273
|
+
// re-walk the (rate-limited) channel list. Names are stored normalized.
|
|
274
|
+
// Misses are not cached — the channel may be created later.
|
|
275
|
+
const slackChannelIdCache = new Map();
|
|
276
|
+
const SLACK_LIST_PAGE_LIMIT = '999'; // Slack caps below 1000; fewer, bigger pages
|
|
277
|
+
const SLACK_LIST_MAX_PAGES = 100; // defensive bound; trust but cap the cursor
|
|
278
|
+
const SLACK_RATE_LIMIT_MAX_WAITS = 3;
|
|
279
|
+
function sleep(ms) {
|
|
280
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Resolve a Slack channel name to its ID, walking the cursor-paginated
|
|
284
|
+
* conversations.list until found (Slack treats `limit` as a per-page cap and
|
|
285
|
+
* may return fewer; a single un-paginated call misses channels in large
|
|
286
|
+
* workspaces). Input is normalized (leading '#' stripped, lowercased — Slack
|
|
287
|
+
* channel names are always lowercase). Resolved IDs are memoized for the
|
|
288
|
+
* process lifetime; Slack rate limits (conversations.list is a low-tier
|
|
289
|
+
* method) are absorbed by waiting and resuming from the same cursor.
|
|
290
|
+
*
|
|
291
|
+
* Use the returned ID for every follow-up call — conversations.join /
|
|
292
|
+
* conversations.history / chat.postMessage should all be given IDs; most
|
|
293
|
+
* methods reject a `#name` with `invalid_arguments`.
|
|
294
|
+
*
|
|
295
|
+
* Membership: public channels are listed whether or not the bot joined, but
|
|
296
|
+
* posting still needs membership — on a `not_in_channel` error from
|
|
297
|
+
* chat.postMessage, call conversations.join with the ID and retry once.
|
|
298
|
+
* Private channels are listed only when the bot is already a member (and only
|
|
299
|
+
* when the connection has the groups:read scope — without it this helper
|
|
300
|
+
* automatically falls back to public channels only). A private channel cannot
|
|
301
|
+
* be self-joined: surface "invite the bot to #channel in Slack" to the user.
|
|
302
|
+
*/
|
|
303
|
+
export async function slackFindChannelId(slack, name) {
|
|
304
|
+
const target = name.replace(/^#/, '').trim().toLowerCase();
|
|
305
|
+
if (!target)
|
|
306
|
+
return null;
|
|
307
|
+
const cached = slackChannelIdCache.get(target);
|
|
308
|
+
if (cached)
|
|
309
|
+
return cached;
|
|
310
|
+
const fields = {
|
|
311
|
+
id: 'id',
|
|
312
|
+
label: 'name',
|
|
313
|
+
items: 'channels',
|
|
314
|
+
nextCursor: 'response_metadata.next_cursor',
|
|
315
|
+
};
|
|
316
|
+
let types = 'public_channel,private_channel';
|
|
317
|
+
let rateLimitWaits = 0;
|
|
318
|
+
// findResource drives the cursor walk; this fetcher owns the Slack quirks —
|
|
319
|
+
// rate-limit waits resume from the SAME cursor (throwing would discard the
|
|
320
|
+
// walk and a retry would restart from page 1), and a missing_scope for
|
|
321
|
+
// private_channel (no groups:read) fails the WHOLE first call, so retrying
|
|
322
|
+
// public-only from the same cursor restarts the walk correctly.
|
|
323
|
+
const fetchPage = async (cursor) => {
|
|
324
|
+
for (;;) {
|
|
325
|
+
let res;
|
|
326
|
+
try {
|
|
327
|
+
res = await slack.get({
|
|
328
|
+
endpoint: '/conversations.list',
|
|
329
|
+
params: {
|
|
330
|
+
types,
|
|
331
|
+
limit: SLACK_LIST_PAGE_LIMIT,
|
|
332
|
+
exclude_archived: 'true',
|
|
333
|
+
...(cursor ? { cursor } : {}),
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
// A provider 429 relays through the broker as a ConnectorError with
|
|
339
|
+
// the provider's status.
|
|
340
|
+
if (err instanceof ConnectorError &&
|
|
341
|
+
err.status === 429 &&
|
|
342
|
+
rateLimitWaits < SLACK_RATE_LIMIT_MAX_WAITS) {
|
|
343
|
+
rateLimitWaits++;
|
|
344
|
+
await sleep(Math.min(10_000 * rateLimitWaits, 30_000));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
const data = (await res.json());
|
|
350
|
+
if (!data.ok) {
|
|
351
|
+
if (data.error === 'missing_scope' && types !== 'public_channel') {
|
|
352
|
+
types = 'public_channel';
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (data.error === 'ratelimited' &&
|
|
356
|
+
rateLimitWaits < SLACK_RATE_LIMIT_MAX_WAITS) {
|
|
357
|
+
rateLimitWaits++;
|
|
358
|
+
await sleep(Math.min(10_000 * rateLimitWaits, 30_000));
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
throw new Error(data.error ?? 'Slack conversations.list failed');
|
|
362
|
+
}
|
|
363
|
+
return normalizeResourcePage(data, fields, { requestCursor: cursor });
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
// Slack channel names are always lowercase, so an exact label match is the
|
|
367
|
+
// same comparison the raw walk made against channel.name.
|
|
368
|
+
const hit = await findResource(fetchPage, (option) => option.label === target, {
|
|
369
|
+
maxPages: SLACK_LIST_MAX_PAGES,
|
|
370
|
+
});
|
|
371
|
+
if (hit)
|
|
372
|
+
slackChannelIdCache.set(target, hit.id);
|
|
373
|
+
return hit?.id ?? null;
|
|
374
|
+
}
|
|
375
|
+
// Slack channel/group IDs (C…/G…) pass through slackPostMessage untouched;
|
|
376
|
+
// anything else is treated as a name and resolved first.
|
|
377
|
+
const SLACK_CHANNEL_ID_RE = /^[CG][A-Z0-9]{6,}$/;
|
|
378
|
+
/**
|
|
379
|
+
* Post a message to a Slack channel by name or ID. Use this instead of calling
|
|
380
|
+
* the send-message action or chat.postMessage yourself: it resolves `#name` →
|
|
381
|
+
* ID via slackFindChannelId (Slack rejects names with `invalid_arguments`),
|
|
382
|
+
* sends via the pre-built `send-message` action, and on `not_in_channel` it
|
|
383
|
+
* joins the channel and retries once (posting can require membership; joining
|
|
384
|
+
* needs the `channels:join` scope).
|
|
385
|
+
*
|
|
386
|
+
* Slack-level failures come back as `{ ok: false, error }` rather than throwing,
|
|
387
|
+
* so render the right state per code:
|
|
388
|
+
* - `channel_not_found` — no such channel; check the name.
|
|
389
|
+
* - `not_in_channel` — the bot couldn't self-join (private channel, or
|
|
390
|
+
* `channels:join` not declared in the manifest scopes): tell the user to
|
|
391
|
+
* invite the bot to the channel in Slack.
|
|
392
|
+
* Broker-level failures (provider not connected, throttled) still throw
|
|
393
|
+
* ConnectorError like every other call.
|
|
394
|
+
*/
|
|
395
|
+
export async function slackPostMessage(slack, args) {
|
|
396
|
+
const channelId = SLACK_CHANNEL_ID_RE.test(args.channel)
|
|
397
|
+
? args.channel
|
|
398
|
+
: await slackFindChannelId(slack, args.channel);
|
|
399
|
+
if (!channelId)
|
|
400
|
+
return { ok: false, error: 'channel_not_found' };
|
|
401
|
+
// The send-message action throws on Slack-level failure (relayed as
|
|
402
|
+
// ConnectorError 'action_failed' with the thrown payload in detail); map
|
|
403
|
+
// that back to `{ ok: false, error }` so callers branch on one shape.
|
|
404
|
+
const post = async () => {
|
|
405
|
+
try {
|
|
406
|
+
const result = await slack.action('send-message', { channel_id: channelId, text: args.text });
|
|
407
|
+
return { ok: result.ok, ts: result.ts };
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
if (err instanceof ConnectorError && err.code === 'action_failed') {
|
|
411
|
+
const detail = err.detail;
|
|
412
|
+
const slackError = detail?.response?.error ?? detail?.message;
|
|
413
|
+
if (typeof slackError === 'string')
|
|
414
|
+
return { ok: false, error: slackError };
|
|
415
|
+
}
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
let result = await post();
|
|
420
|
+
if (!result.ok && result.error === 'not_in_channel') {
|
|
421
|
+
const joinRes = await slack.post({
|
|
422
|
+
endpoint: '/conversations.join',
|
|
423
|
+
data: { channel: channelId },
|
|
424
|
+
});
|
|
425
|
+
const join = (await joinRes.json());
|
|
426
|
+
if (join.ok)
|
|
427
|
+
result = await post();
|
|
428
|
+
}
|
|
429
|
+
return { ok: result.ok, ts: result.ts, channelId, error: result.error };
|
|
430
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const NETWORK_CAPTURE_MESSAGE_TYPE: "assembly.debug.network";
|
|
2
|
+
export interface NetworkCaptureHeaderFingerprint {
|
|
3
|
+
present: true;
|
|
4
|
+
/** Auth scheme word (e.g. "Bearer", "Basic") when the value looks like "<scheme> <token>". */
|
|
5
|
+
scheme?: string;
|
|
6
|
+
/** Bounded prefix of the value (after the scheme, if any) — never the full secret. */
|
|
7
|
+
prefix: string;
|
|
8
|
+
/** Length of the original, un-redacted value. */
|
|
9
|
+
len: number;
|
|
10
|
+
}
|
|
11
|
+
type RedactedHeaderValue = string | NetworkCaptureHeaderFingerprint;
|
|
12
|
+
export interface NetworkCaptureRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
source: 'fetch' | 'xhr';
|
|
15
|
+
method: string;
|
|
16
|
+
url: string;
|
|
17
|
+
status: number | null;
|
|
18
|
+
ok: boolean | null;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
requestHeaders: Record<string, RedactedHeaderValue>;
|
|
22
|
+
responseHeaders: Record<string, RedactedHeaderValue>;
|
|
23
|
+
requestBodySnippet?: string;
|
|
24
|
+
responseBodySnippet?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function redactUrl(rawUrl: string): string;
|
|
28
|
+
export declare function collectHeaders(headers: HeadersInit | Headers | null | undefined): Record<string, RedactedHeaderValue>;
|
|
29
|
+
export declare function collectXhrResponseHeaders(raw: string): Record<string, RedactedHeaderValue>;
|
|
30
|
+
export declare function snippetFromRequestBody(body: unknown): string | undefined;
|
|
31
|
+
export declare function getCapturedRecords(): readonly NetworkCaptureRecord[];
|
|
32
|
+
export declare function recordNetworkCapture(record: NetworkCaptureRecord): void;
|
|
33
|
+
export declare function installFetchCapture(): void;
|
|
34
|
+
export declare function installXhrCapture(): void;
|
|
35
|
+
export declare function installNetworkCapture(): void;
|
|
36
|
+
export {};
|