@devosurf/tesser-connectors 0.1.0-alpha.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/slack/index.ts ADDED
@@ -0,0 +1,587 @@
1
+ // Slack connector — P0 exemplar: 200-with-{ok:false} error mapping inside actions,
2
+ // slackSigning verification, url_verification challenge, MANUAL webhook registration
3
+ // (Slack's Request URL is app-level config with no per-install API, ADR-0013).
4
+
5
+ import { z } from "zod";
6
+ import { TerminalError, RetryableError } from "@devosurf/tesser-sdk";
7
+ import { action, apiKey, defineConnector, oauth2, trigger, verify, type ActionCtx } from "@devosurf/tesser-sdk/connector";
8
+ import { slackProvider } from "../providers/slack.js";
9
+
10
+ // Slack answers 200 with { ok: false, error } — surface it with retry semantics.
11
+ function assertOk<T extends { ok?: boolean; error?: string }>(res: T, what: string): T {
12
+ if (res.ok === false) {
13
+ const code = res.error ?? "unknown_error";
14
+ if (code === "ratelimited" || code === "rate_limited") {
15
+ throw new RetryableError(`${what}: slack rate-limited`);
16
+ }
17
+ throw new TerminalError(`${what}: slack responded ok=false (${code})`);
18
+ }
19
+ return res;
20
+ }
21
+
22
+ // Cursor pagination: loop response_metadata.next_cursor, accumulating via `pick`, with a
23
+ // page cap so a runaway cursor can never spin forever. Slack reads are GETs.
24
+ async function cursorPaginate<Row>(
25
+ ctx: ActionCtx,
26
+ path: string,
27
+ query: Record<string, string | number>,
28
+ pick: (page: SlackOk) => Row[] | undefined,
29
+ what: string,
30
+ pageCap = 10,
31
+ ): Promise<Row[]> {
32
+ const out: Row[] = [];
33
+ let cursor = "";
34
+ for (let page = 0; page < pageCap; page++) {
35
+ const res = assertOk(
36
+ (await ctx.http.get(path, {
37
+ query: { ...query, ...(cursor ? { cursor } : {}) },
38
+ })) as SlackOk,
39
+ what,
40
+ );
41
+ out.push(...(pick(res) ?? []));
42
+ cursor = res.response_metadata?.next_cursor ?? "";
43
+ if (!cursor) break;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ type SlackOk = {
49
+ ok: boolean;
50
+ error?: string;
51
+ response_metadata?: { next_cursor?: string };
52
+ [k: string]: unknown;
53
+ };
54
+
55
+ // ---- Stable output shapes (never the raw provider blob) ----
56
+
57
+ const messageShape = z.object({
58
+ ts: z.string(),
59
+ user: z.string(),
60
+ text: z.string(),
61
+ threadTs: z.string().optional(),
62
+ subtype: z.string().optional(),
63
+ });
64
+
65
+ type RawMessage = {
66
+ ts?: string;
67
+ user?: string;
68
+ text?: string;
69
+ thread_ts?: string;
70
+ subtype?: string;
71
+ };
72
+
73
+ function mapMessage(m: RawMessage): z.infer<typeof messageShape> {
74
+ return {
75
+ ts: m.ts ?? "",
76
+ user: m.user ?? "",
77
+ text: m.text ?? "",
78
+ ...(m.thread_ts !== undefined ? { threadTs: m.thread_ts } : {}),
79
+ ...(m.subtype !== undefined ? { subtype: m.subtype } : {}),
80
+ };
81
+ }
82
+
83
+ const channelShape = z.object({
84
+ id: z.string(),
85
+ name: z.string(),
86
+ isPrivate: z.boolean(),
87
+ topic: z.string(),
88
+ purpose: z.string(),
89
+ numMembers: z.number().optional(),
90
+ });
91
+
92
+ type RawChannel = {
93
+ id?: string;
94
+ name?: string;
95
+ is_private?: boolean;
96
+ topic?: { value?: string };
97
+ purpose?: { value?: string };
98
+ num_members?: number;
99
+ };
100
+
101
+ function mapChannel(c: RawChannel): z.infer<typeof channelShape> {
102
+ return {
103
+ id: c.id ?? "",
104
+ name: c.name ?? "",
105
+ isPrivate: c.is_private ?? false,
106
+ topic: c.topic?.value ?? "",
107
+ purpose: c.purpose?.value ?? "",
108
+ ...(c.num_members !== undefined ? { numMembers: c.num_members } : {}),
109
+ };
110
+ }
111
+
112
+ const userShape = z.object({
113
+ id: z.string(),
114
+ name: z.string(),
115
+ realName: z.string(),
116
+ email: z.string().optional(),
117
+ tz: z.string(),
118
+ isBot: z.boolean(),
119
+ isAdmin: z.boolean(),
120
+ });
121
+
122
+ type RawUser = {
123
+ id?: string;
124
+ name?: string;
125
+ real_name?: string;
126
+ tz?: string;
127
+ is_bot?: boolean;
128
+ is_admin?: boolean;
129
+ profile?: { real_name?: string; email?: string };
130
+ };
131
+
132
+ function mapUser(u: RawUser): z.infer<typeof userShape> {
133
+ const email = u.profile?.email;
134
+ return {
135
+ id: u.id ?? "",
136
+ name: u.name ?? "",
137
+ realName: u.real_name ?? u.profile?.real_name ?? "",
138
+ ...(email !== undefined ? { email } : {}),
139
+ tz: u.tz ?? "",
140
+ isBot: u.is_bot ?? false,
141
+ isAdmin: u.is_admin ?? false,
142
+ };
143
+ }
144
+
145
+ export default defineConnector({
146
+ id: "slack",
147
+ describe: "Slack messaging",
148
+ provider: slackProvider,
149
+ auth: {
150
+ // message.* events require the matching *:history scope; app_mention requires
151
+ // app_mentions:read; reactions require reactions:read. Scopes are declared once.
152
+ oauth: oauth2({
153
+ provider: "slack",
154
+ scopes: [
155
+ "chat:write",
156
+ "chat:write.public",
157
+ "channels:read",
158
+ "channels:history",
159
+ "groups:history",
160
+ "im:history",
161
+ "mpim:history",
162
+ "app_mentions:read",
163
+ "reactions:read",
164
+ "users:read",
165
+ "users:read.email",
166
+ ],
167
+ }),
168
+ token: apiKey({ prefix: "Bearer ", describe: "Bot token (xoxb-…) with chat:write" }),
169
+ },
170
+ samples: {
171
+ "chat.postMessage": { ts: "1718000000.000100", channel: "C0TESSER01" },
172
+ "chat.update": { channel: "C0TESSER01", ts: "1718000000.000100", text: "edited" },
173
+ "chat.delete": { channel: "C0TESSER01", ts: "1718000000.000100" },
174
+ "chat.postEphemeral": { messageTs: "1718000000.000300" },
175
+ "conversations.list": [{ id: "C0TESSER01", name: "tesser-test", isPrivate: false }],
176
+ "conversations.history": [{ ts: "1718000000.000200", user: "U123", text: "hello" }],
177
+ "conversations.replies": [
178
+ { ts: "1718000000.000200", user: "U123", text: "parent", threadTs: "1718000000.000200" },
179
+ { ts: "1718000000.000400", user: "U456", text: "reply", threadTs: "1718000000.000200" },
180
+ ],
181
+ "conversations.info": {
182
+ id: "C0TESSER01",
183
+ name: "tesser-test",
184
+ isPrivate: false,
185
+ topic: "shipping tesser",
186
+ purpose: "the test channel",
187
+ numMembers: 3,
188
+ },
189
+ "reactions.add": { added: true },
190
+ "users.info": {
191
+ id: "U123",
192
+ name: "ada",
193
+ realName: "Ada Lovelace",
194
+ email: "ada@example.com",
195
+ tz: "America/New_York",
196
+ isBot: false,
197
+ isAdmin: true,
198
+ },
199
+ "users.lookupByEmail": {
200
+ id: "U123",
201
+ name: "ada",
202
+ realName: "Ada Lovelace",
203
+ email: "ada@example.com",
204
+ tz: "America/New_York",
205
+ isBot: false,
206
+ isAdmin: true,
207
+ },
208
+ "trigger:messagePosted": {
209
+ channel: "C0TESSER01",
210
+ user: "U123",
211
+ text: "hello",
212
+ ts: "1718000000.000200",
213
+ },
214
+ "trigger:appMention": {
215
+ channel: "C0TESSER01",
216
+ user: "U123",
217
+ text: "<@U0BOT> ship it",
218
+ ts: "1718000000.000500",
219
+ },
220
+ "trigger:reactionAdded": {
221
+ reaction: "thumbsup",
222
+ user: "U123",
223
+ channel: "C0TESSER01",
224
+ messageTs: "1718000000.000200",
225
+ },
226
+ },
227
+ actions: {
228
+ chat: {
229
+ postMessage: action({
230
+ describe: "Post a message to a channel the app is in",
231
+ input: z.object({
232
+ channel: z.string().min(1),
233
+ text: z.string().min(1),
234
+ threadTs: z.string().optional(),
235
+ }),
236
+ output: z.object({ ts: z.string(), channel: z.string() }),
237
+ run: async (ctx, i) => {
238
+ const res = assertOk(
239
+ (await ctx.http.post("/chat.postMessage", {
240
+ channel: i.channel,
241
+ text: i.text,
242
+ ...(i.threadTs !== undefined ? { thread_ts: i.threadTs } : {}),
243
+ })) as { ok: boolean; error?: string; ts?: string; channel?: string },
244
+ "chat.postMessage",
245
+ );
246
+ return { ts: res.ts ?? "", channel: res.channel ?? i.channel };
247
+ },
248
+ }),
249
+ // Edits only messages the same token authored. ts is a string id, never a number.
250
+ update: action({
251
+ describe: "Edit a message the app posted",
252
+ input: z.object({
253
+ channel: z.string().min(1),
254
+ ts: z.string().min(1),
255
+ text: z.string().min(1),
256
+ }),
257
+ output: z.object({ channel: z.string(), ts: z.string(), text: z.string() }),
258
+ run: async (ctx, i) => {
259
+ const res = assertOk(
260
+ (await ctx.http.post("/chat.update", {
261
+ channel: i.channel,
262
+ ts: i.ts,
263
+ text: i.text,
264
+ })) as { ok: boolean; error?: string; channel?: string; ts?: string; text?: string },
265
+ "chat.update",
266
+ );
267
+ return { channel: res.channel ?? i.channel, ts: res.ts ?? i.ts, text: res.text ?? i.text };
268
+ },
269
+ }),
270
+ delete: action({
271
+ describe: "Delete a message the app posted",
272
+ input: z.object({ channel: z.string().min(1), ts: z.string().min(1) }),
273
+ output: z.object({ channel: z.string(), ts: z.string() }),
274
+ run: async (ctx, i) => {
275
+ const res = assertOk(
276
+ (await ctx.http.post("/chat.delete", { channel: i.channel, ts: i.ts })) as {
277
+ ok: boolean;
278
+ error?: string;
279
+ channel?: string;
280
+ ts?: string;
281
+ },
282
+ "chat.delete",
283
+ );
284
+ return { channel: res.channel ?? i.channel, ts: res.ts ?? i.ts };
285
+ },
286
+ }),
287
+ // Ephemeral: only `user` sees it; returns message_ts, not channel.
288
+ postEphemeral: action({
289
+ describe: "Post a message only one user can see",
290
+ input: z.object({
291
+ channel: z.string().min(1),
292
+ user: z.string().min(1),
293
+ text: z.string().min(1),
294
+ threadTs: z.string().optional(),
295
+ }),
296
+ output: z.object({ messageTs: z.string() }),
297
+ run: async (ctx, i) => {
298
+ const res = assertOk(
299
+ (await ctx.http.post("/chat.postEphemeral", {
300
+ channel: i.channel,
301
+ user: i.user,
302
+ text: i.text,
303
+ ...(i.threadTs !== undefined ? { thread_ts: i.threadTs } : {}),
304
+ })) as { ok: boolean; error?: string; message_ts?: string },
305
+ "chat.postEphemeral",
306
+ );
307
+ return { messageTs: res.message_ts ?? "" };
308
+ },
309
+ }),
310
+ },
311
+ conversations: {
312
+ list: action({
313
+ describe: "List channels visible to the app",
314
+ input: z.object({ limit: z.number().int().min(1).max(1000).default(100) }),
315
+ output: z.array(z.object({ id: z.string(), name: z.string(), isPrivate: z.boolean() })),
316
+ safety: "read",
317
+ run: async (ctx, i) => {
318
+ // Filtering is post-page, so a page may return fewer than `limit` while the cursor
319
+ // is still non-empty — keep paging on the cursor, not the count.
320
+ const rows = await cursorPaginate<{ id?: string; name?: string; is_private?: boolean }>(
321
+ ctx,
322
+ "/conversations.list",
323
+ { limit: i.limit, types: "public_channel,private_channel" },
324
+ (page) => page["channels"] as Array<{ id?: string; name?: string; is_private?: boolean }> | undefined,
325
+ "conversations.list",
326
+ );
327
+ return rows.map((c) => ({ id: c.id ?? "", name: c.name ?? "", isPrivate: c.is_private ?? false }));
328
+ },
329
+ }),
330
+ history: action({
331
+ describe: "Read recent messages in a channel (cursor-paginated, newest first)",
332
+ input: z.object({
333
+ channel: z.string().min(1),
334
+ limit: z.number().int().min(1).max(999).default(100),
335
+ oldest: z.string().optional(),
336
+ latest: z.string().optional(),
337
+ }),
338
+ output: z.array(messageShape),
339
+ safety: "read",
340
+ run: async (ctx, i) => {
341
+ const rows = await cursorPaginate<RawMessage>(
342
+ ctx,
343
+ "/conversations.history",
344
+ {
345
+ channel: i.channel,
346
+ limit: i.limit,
347
+ ...(i.oldest !== undefined ? { oldest: i.oldest } : {}),
348
+ ...(i.latest !== undefined ? { latest: i.latest } : {}),
349
+ },
350
+ (page) => page["messages"] as RawMessage[] | undefined,
351
+ "conversations.history",
352
+ );
353
+ return rows.map(mapMessage);
354
+ },
355
+ }),
356
+ replies: action({
357
+ describe: "Read all messages in a thread (cursor-paginated)",
358
+ input: z.object({
359
+ channel: z.string().min(1),
360
+ ts: z.string().min(1),
361
+ limit: z.number().int().min(1).max(999).default(100),
362
+ }),
363
+ output: z.array(messageShape),
364
+ safety: "read",
365
+ run: async (ctx, i) => {
366
+ const rows = await cursorPaginate<RawMessage>(
367
+ ctx,
368
+ "/conversations.replies",
369
+ { channel: i.channel, ts: i.ts, limit: i.limit },
370
+ (page) => page["messages"] as RawMessage[] | undefined,
371
+ "conversations.replies",
372
+ );
373
+ return rows.map(mapMessage);
374
+ },
375
+ }),
376
+ info: action({
377
+ describe: "Fetch one channel's metadata",
378
+ input: z.object({ channel: z.string().min(1) }),
379
+ output: channelShape,
380
+ safety: "read",
381
+ run: async (ctx, i) => {
382
+ const res = assertOk(
383
+ (await ctx.http.get("/conversations.info", {
384
+ query: { channel: i.channel, include_num_members: "true" },
385
+ })) as { ok: boolean; error?: string; channel?: RawChannel },
386
+ "conversations.info",
387
+ );
388
+ return mapChannel(res.channel ?? {});
389
+ },
390
+ }),
391
+ },
392
+ reactions: {
393
+ add: action({
394
+ describe: "Add an emoji reaction to a message",
395
+ input: z.object({
396
+ channel: z.string().min(1),
397
+ name: z.string().min(1),
398
+ timestamp: z.string().min(1),
399
+ }),
400
+ output: z.object({ added: z.boolean() }),
401
+ run: async (ctx, i) => {
402
+ assertOk(
403
+ (await ctx.http.post("/reactions.add", {
404
+ channel: i.channel,
405
+ name: i.name,
406
+ timestamp: i.timestamp,
407
+ })) as { ok: boolean; error?: string },
408
+ "reactions.add",
409
+ );
410
+ return { added: true };
411
+ },
412
+ }),
413
+ },
414
+ users: {
415
+ info: action({
416
+ describe: "Fetch one user's profile (email needs users:read.email)",
417
+ input: z.object({ user: z.string().min(1) }),
418
+ output: userShape,
419
+ safety: "read",
420
+ run: async (ctx, i) => {
421
+ const res = assertOk(
422
+ (await ctx.http.get("/users.info", { query: { user: i.user } })) as {
423
+ ok: boolean;
424
+ error?: string;
425
+ user?: RawUser;
426
+ },
427
+ "users.info",
428
+ );
429
+ return mapUser(res.user ?? {});
430
+ },
431
+ }),
432
+ // Returns users_not_found (ok:false) when no match -> terminal via assertOk.
433
+ lookupByEmail: action({
434
+ describe: "Find a user by email (needs users:read.email)",
435
+ input: z.object({ email: z.string().min(3) }),
436
+ output: userShape,
437
+ safety: "read",
438
+ run: async (ctx, i) => {
439
+ const res = assertOk(
440
+ (await ctx.http.get("/users.lookupByEmail", { query: { email: i.email } })) as {
441
+ ok: boolean;
442
+ error?: string;
443
+ user?: RawUser;
444
+ },
445
+ "users.lookupByEmail",
446
+ );
447
+ return mapUser(res.user ?? {});
448
+ },
449
+ }),
450
+ },
451
+ },
452
+ webhook: {
453
+ verify: verify.slackSigning(),
454
+ challenge: (req) => {
455
+ const body = req.json as { type?: string; challenge?: string } | undefined;
456
+ return body?.type === "url_verification" && typeof body.challenge === "string"
457
+ ? body.challenge
458
+ : null;
459
+ },
460
+ identify: (req) => {
461
+ const body = req.json as
462
+ | {
463
+ type?: string;
464
+ event_id?: string;
465
+ team_id?: string;
466
+ event?: { type?: string };
467
+ }
468
+ | undefined;
469
+ if (!body || body.type !== "event_callback" || !body.event?.type) return null;
470
+ return {
471
+ event: body.event.type,
472
+ ...(body.event_id !== undefined ? { deliveryId: body.event_id } : {}),
473
+ ...(body.team_id !== undefined ? { connectionHint: body.team_id } : {}),
474
+ payload: body,
475
+ };
476
+ },
477
+ },
478
+ triggers: {
479
+ messagePosted: trigger.webhook({
480
+ describe: "Fires for each message posted in a channel the app is in",
481
+ input: z.object({ channel: z.string().optional() }),
482
+ output: z.object({
483
+ channel: z.string(),
484
+ user: z.string(),
485
+ text: z.string(),
486
+ ts: z.string(),
487
+ }),
488
+ event: "message",
489
+ map: (payload, params) => {
490
+ const body = payload as {
491
+ event?: { channel?: string; user?: string; text?: string; ts?: string; bot_id?: string; subtype?: string };
492
+ };
493
+ const e = body.event;
494
+ if (!e?.channel || !e.ts || e.bot_id !== undefined || e.subtype !== undefined) return null;
495
+ if (params.channel !== undefined && e.channel !== params.channel) return null;
496
+ return { channel: e.channel, user: e.user ?? "", text: e.text ?? "", ts: e.ts };
497
+ },
498
+ register: {
499
+ mode: "manual",
500
+ instructions: (reg) =>
501
+ [
502
+ "Slack delivers events to one app-level Request URL — registration is in your Slack app settings:",
503
+ "1. Open https://api.slack.com/apps → your app → Event Subscriptions.",
504
+ "2. Toggle Enable Events on.",
505
+ `3. Paste this Request URL: ${reg.url}`,
506
+ " (Slack sends a url_verification challenge; this endpoint answers it automatically.)",
507
+ "4. Under Subscribe to bot events, add message.channels (and message.groups / message.im / message.mpim for private channels, DMs, and group DMs).",
508
+ " Each message.* event needs its matching history scope: channels:history / groups:history / im:history / mpim:history.",
509
+ " For the app-mention trigger, also add app_mention under bot events (scope app_mentions:read).",
510
+ "5. Save, then reinstall the app when Slack prompts you (adding any bot event scope requires a reinstall).",
511
+ `6. Signing secret: paste your app's Signing Secret (Settings → Basic Information) into the connect page — Tesser verifies every delivery with it.`,
512
+ ].join("\n"),
513
+ },
514
+ }),
515
+ appMention: trigger.webhook({
516
+ describe: "Fires when the app is @-mentioned in a channel",
517
+ input: z.object({ channel: z.string().optional() }),
518
+ output: z.object({
519
+ channel: z.string(),
520
+ user: z.string(),
521
+ text: z.string(),
522
+ ts: z.string(),
523
+ }),
524
+ event: "app_mention",
525
+ // app_mention never echoes the bot's own posts, so no bot_id filter is needed.
526
+ map: (payload, params) => {
527
+ const body = payload as {
528
+ event?: { channel?: string; user?: string; text?: string; ts?: string };
529
+ };
530
+ const e = body.event;
531
+ if (!e?.channel || !e.ts) return null;
532
+ if (params.channel !== undefined && e.channel !== params.channel) return null;
533
+ return { channel: e.channel, user: e.user ?? "", text: e.text ?? "", ts: e.ts };
534
+ },
535
+ register: {
536
+ mode: "manual",
537
+ instructions: (reg) =>
538
+ [
539
+ "Slack delivers events to one app-level Request URL — registration is in your Slack app settings:",
540
+ "1. Open https://api.slack.com/apps → your app → Event Subscriptions.",
541
+ "2. Toggle Enable Events on.",
542
+ `3. Paste this Request URL: ${reg.url}`,
543
+ " (Slack sends a url_verification challenge; this endpoint answers it automatically.)",
544
+ "4. Under Subscribe to bot events, add app_mention (scope app_mentions:read).",
545
+ "5. Save, then reinstall the app when Slack prompts you (adding any bot event scope requires a reinstall).",
546
+ `6. Signing secret: paste your app's Signing Secret (Settings → Basic Information) into the connect page — Tesser verifies every delivery with it.`,
547
+ ].join("\n"),
548
+ },
549
+ }),
550
+ reactionAdded: trigger.webhook({
551
+ describe: "Fires when someone adds an emoji reaction to a message",
552
+ input: z.object({ channel: z.string().optional() }),
553
+ output: z.object({
554
+ reaction: z.string(),
555
+ user: z.string(),
556
+ channel: z.string(),
557
+ messageTs: z.string(),
558
+ }),
559
+ event: "reaction_added",
560
+ // item may be a file/file_comment (no channel/ts) — skip those.
561
+ map: (payload, params) => {
562
+ const body = payload as {
563
+ event?: { reaction?: string; user?: string; item?: { channel?: string; ts?: string } };
564
+ };
565
+ const e = body.event;
566
+ const item = e?.item;
567
+ if (!e?.reaction || !item?.channel || !item.ts) return null;
568
+ if (params.channel !== undefined && item.channel !== params.channel) return null;
569
+ return { reaction: e.reaction, user: e.user ?? "", channel: item.channel, messageTs: item.ts };
570
+ },
571
+ register: {
572
+ mode: "manual",
573
+ instructions: (reg) =>
574
+ [
575
+ "Slack delivers events to one app-level Request URL — registration is in your Slack app settings:",
576
+ "1. Open https://api.slack.com/apps → your app → Event Subscriptions.",
577
+ "2. Toggle Enable Events on.",
578
+ `3. Paste this Request URL: ${reg.url}`,
579
+ " (Slack sends a url_verification challenge; this endpoint answers it automatically.)",
580
+ "4. Under Subscribe to bot events, add reaction_added (scope reactions:read).",
581
+ "5. Save, then reinstall the app when Slack prompts you (adding any bot event scope requires a reinstall).",
582
+ `6. Signing secret: paste your app's Signing Secret (Settings → Basic Information) into the connect page — Tesser verifies every delivery with it.`,
583
+ ].join("\n"),
584
+ },
585
+ }),
586
+ },
587
+ });