Skip to content

Commit d903b5e

Browse files
committed
chore: up
1 parent f40a93f commit d903b5e

6 files changed

Lines changed: 183 additions & 92 deletions

File tree

.github/TODO.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
# TODO
2+
3+
- Add `@Tipbot` message support for sending payments, using Slack assistant/thread UI to show a “Sending payment” status while the transaction is in progress.

src/api.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Handler } from 'accounts/server'
2-
import { Card, CardText } from 'chat'
32
import { Hono } from 'hono'
43
import { Address, Base64, Hex } from 'ox'
54
import { decodeFunctionData, http } from 'viem'
@@ -10,6 +9,7 @@ import * as Chat from '#/chat.ts'
109
import * as AccountLink from '#/lib/accountLink.ts'
1110
import * as hono from '#/lib/hono.ts'
1211
import * as Nanoid from '#/lib/nanoid.ts'
12+
import * as Slack from '#/lib/slack.ts'
1313
import * as Tempo from '#/lib/tempo.ts'
1414
import * as Tip from '#/lib/tip.ts'
1515
import * as DB from '#db/client.ts'
@@ -236,22 +236,9 @@ export const api = new Hono<{
236236
? link.provider_channel_id!
237237
: `slack:${link.provider_channel_id!}`,
238238
)
239-
.postEphemeral(
240-
link.member_provider_user_id,
241-
{
242-
card: Card({
243-
children: [
244-
CardText(
245-
'Send and receive payments in Slack.\nTry `/tip @account for coffee`.',
246-
),
247-
],
248-
title: 'Connected to Tipbot',
249-
}),
250-
fallbackText:
251-
'Connected to Tipbot\nSend and receive payments in Slack.\nTry `/tip @account for coffee`.',
252-
},
253-
{ fallbackToDM: false },
254-
),
239+
.postEphemeral(link.member_provider_user_id, 'Connected', {
240+
fallbackToDM: false,
241+
}),
255242
)
256243
})().catch((error) => {
257244
console.error('Failed to notify Slack member after wallet connection:', error)
@@ -272,9 +259,49 @@ export const api = new Hono<{
272259
)
273260
.post('/api/chat/slack', async (c) => {
274261
const request = c.req.raw
262+
const body = await request.text()
263+
const params = request.headers
264+
.get('content-type')
265+
?.includes('application/x-www-form-urlencoded')
266+
? new URLSearchParams(body)
267+
: null
268+
if (params?.has('command') && !params.has('payload')) {
269+
if (
270+
!(await Slack.verifySlackSignature({
271+
body,
272+
signature: request.headers.get('x-slack-signature'),
273+
signingSecret: c.env.SLACK_SIGNING_SECRET,
274+
timestamp: request.headers.get('x-slack-request-timestamp'),
275+
}))
276+
)
277+
return new Response('Invalid signature', { status: 401 })
278+
279+
const tasks: Promise<unknown>[] = []
280+
const task = (async () => {
281+
await Chat.getChat().webhooks.slack(
282+
new Request(request.url, {
283+
body,
284+
headers: request.headers,
285+
method: request.method,
286+
}),
287+
{
288+
waitUntil(promise) {
289+
tasks.push(promise)
290+
try {
291+
c.executionCtx.waitUntil(promise)
292+
} catch {}
293+
},
294+
},
295+
)
296+
await Promise.all(tasks)
297+
})()
298+
c.executionCtx.waitUntil(task)
299+
return new Response('', { status: 200 })
300+
}
301+
275302
return await Chat.getChat().webhooks.slack(
276303
new Request(request.url, {
277-
body: await request.text(),
304+
body,
278305
headers: request.headers,
279306
method: request.method,
280307
}),

src/api.workers.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,7 @@ describe('/api/account/link/:token', () => {
205205

206206
expect(response.status).toBe(200)
207207
expect(initialize).toHaveBeenCalled()
208-
await expectSlackMessage(channelId, 'Connected to Tipbot')
209-
await expectSlackMessage(channelId, 'Send and receive payments in Slack.')
208+
await expectSlackMessage(channelId, 'Connected')
210209
})
211210

212211
test('rejects token reuse', async () => {

src/chat.ts

Lines changed: 86 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -311,22 +311,7 @@ const handlers = {
311311
.where('revoked_at', 'is', null)
312312
.executeTakeFirst()
313313
if (accessKey) {
314-
await event.channel.postEphemeral(
315-
event.user,
316-
{
317-
card: chat.Card({
318-
children: [
319-
chat.CardText(
320-
'Send and receive payments in this workspace.\nUse `/tip disconnect` to disconnect.',
321-
),
322-
],
323-
title: 'Already connected to Tipbot',
324-
}),
325-
fallbackText:
326-
'Already connected to Tipbot\nSend and receive payments in this workspace.\nUse `/tip disconnect` to disconnect.',
327-
},
328-
{ fallbackToDM: false },
329-
)
314+
await event.channel.postEphemeral(event.user, 'Already connected', { fallbackToDM: false })
330315
return
331316
}
332317
}
@@ -335,13 +320,10 @@ const handlers = {
335320
const token = Nanoid.generate()
336321
const accessKey = AccessKey.generate()
337322
const linkAction = member.account_id ? 'Reconnect' : 'Connect'
338-
const linkButtonLabel = member.account_id ? 'Refresh connection' : 'Connect Tipbot'
339-
const linkDescription = member.account_id
340-
? 'Tipbot connection needs a quick refresh before sending payments.'
341-
: 'Connect once to send and receive payments in Slack.'
342-
const linkTitle = member.account_id ? 'Refresh Tipbot connection' : 'Connect to Tipbot'
323+
const linkButtonLabel = member.account_id ? 'Refresh connection' : 'Connect to Tipbot'
324+
const linkDescription = 'Link expires in 10 minutes.'
343325
const linkUrl = `https://${env.HOST}/connect/${token}`
344-
const linkText = `${linkTitle}\n${linkDescription}\nThis private link expires in 10 minutes.\n${linkAction} to Tipbot: ${linkUrl}`
326+
const linkText = `${linkAction} to Tipbot: ${linkUrl}\n${linkDescription}`
345327
const linkExpiresAt = new Date(now.getTime() + 10 * 60 * 1000).toISOString() // 10 minutes
346328
const accessKeyExpiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days
347329
await ctx.db
@@ -373,14 +355,13 @@ const handlers = {
373355
{
374356
card: chat.Card({
375357
children: [
376-
chat.CardText(`${linkDescription}\nThis private link expires in 10 minutes.`),
377-
chat.CardLink({ label: 'Private link', url: linkUrl }),
358+
chat.CardLink({ label: `${linkAction} to Tipbot`, url: linkUrl }),
378359
chat.Actions([
379360
chat.LinkButton({ label: linkButtonLabel, style: 'primary', url: linkUrl }),
380361
chat.Button({ id: 'connect_cancel', label: 'Cancel' }),
381362
]),
363+
chat.CardText(linkDescription, { style: 'muted' }),
382364
],
383-
title: linkTitle,
384365
}),
385366
fallbackText: linkText,
386367
},
@@ -426,31 +407,52 @@ const handlers = {
426407
fallbackToDM: false,
427408
})
428409
},
429-
async help(event, _ctx) {
430-
await event.channel.postEphemeral(
431-
event.user,
432-
{
433-
card: chat.Card({
434-
children: [
435-
chat.Table({
436-
headers: ['Command', 'Description'],
437-
rows: [
438-
['`/tip @account for coffee`', 'Send a payment in chat'],
439-
['`/tip config`', 'View workspace settings'],
440-
['`/tip connect`', 'Connect Tipbot'],
441-
['`/tip disconnect`', 'Disconnect Tipbot'],
442-
['`/tip help`', 'Show commands'],
443-
['`/tip status`', 'Check Tipbot connection'],
444-
],
445-
}),
410+
async help(event, ctx) {
411+
if (ctx.provider.type !== 'slack') throw new Error('Provider is not implemented yet.')
412+
413+
const installation = await getSlack().getInstallation(ctx.provider.id)
414+
if (!installation) return
415+
416+
const rows = [
417+
['/tip @account for coffee', 'Send a payment in chat'],
418+
['/tip config', 'View workspace settings'],
419+
['/tip connect', 'Connect Tipbot'],
420+
['/tip disconnect', 'Disconnect Tipbot'],
421+
['/tip help', 'Show commands'],
422+
['/tip status', 'Check Tipbot connection'],
423+
]
424+
const body = new URLSearchParams()
425+
body.set('channel', event.channel.id.replace(/^slack:/, ''))
426+
body.set('text', `Tipbot commands\n${rows.map((row) => `${row[0]} ${row[1]}`).join('\n')}`)
427+
body.set(
428+
'blocks',
429+
JSON.stringify([
430+
{ text: { emoji: true, text: 'Tipbot commands', type: 'plain_text' }, type: 'header' },
431+
{
432+
rows: [
433+
[slackTableCell('Command'), slackTableCell('Description')],
434+
...rows.map((row) => [slackTableCell(row[0], { code: true }), slackTableCell(row[1])]),
446435
],
447-
title: 'Tipbot commands',
448-
}),
449-
fallbackText:
450-
'Tipbot commands\n`/tip @account for coffee` Send a payment in chat\n`/tip config` View workspace settings\n`/tip connect` Connect Tipbot\n`/tip disconnect` Disconnect Tipbot\n`/tip help` Show commands\n`/tip status` Check Tipbot connection',
451-
},
452-
{ fallbackToDM: false },
436+
type: 'table',
437+
},
438+
]),
439+
)
440+
body.set('user', event.user.userId)
441+
const response = await getSlack().withBotToken(installation.botToken, () =>
442+
fetch(`${env.SLACK_API_URL}/chat.postEphemeral`, {
443+
body,
444+
headers: { authorization: `Bearer ${installation.botToken}` },
445+
method: 'POST',
446+
}),
453447
)
448+
const json = z.parse(
449+
z.object({
450+
error: z.string().optional(),
451+
ok: z.boolean().optional(),
452+
}),
453+
await response.json(),
454+
)
455+
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postEphemeral failed.')
454456
},
455457
async status(event, ctx) {
456458
const workspace = await ctx.db
@@ -488,7 +490,22 @@ const handlers = {
488490

489491
await event.channel.postEphemeral(
490492
event.user,
491-
`Account ID: ${member.account_id}\nAddress: ${member.account_address}\nProvider user ID: ${member.provider_user_id}`,
493+
{
494+
card: chat.Card({
495+
children: [
496+
chat.Table({
497+
headers: ['Field', 'Value'],
498+
rows: [
499+
['Account ID', member.account_id],
500+
['Address', member.account_address],
501+
['Provider user ID', member.provider_user_id],
502+
],
503+
}),
504+
],
505+
title: 'Status',
506+
}),
507+
fallbackText: `Status\nAccount ID ${member.account_id}\nAddress ${member.account_address}\nProvider user ID ${member.provider_user_id}`,
508+
},
492509
{ fallbackToDM: false },
493510
)
494511
},
@@ -663,14 +680,14 @@ function configCard(
663680
return {
664681
card: chat.Card({
665682
children: [
666-
chat.Fields([
667-
chat.Field({ label: 'Network', value: networkLabel }),
668-
chat.Field({
669-
label: 'Default token',
670-
value: `<${Tempo.formatTokenLink(workspace.chain_id, tokenAddress)}|${token.symbol}>`,
671-
}),
672-
chat.Field({ label: 'Default amount', value: formatAmount(workspace.default_amount) }),
673-
]),
683+
chat.Table({
684+
headers: ['Setting', 'Value'],
685+
rows: [
686+
['Network', networkLabel],
687+
['Default token', token.symbol],
688+
['Default amount', formatAmount(workspace.default_amount)],
689+
],
690+
}),
674691
...(options?.canEdit
675692
? [
676693
chat.Actions([
@@ -689,3 +706,15 @@ function configCard(
689706
fallbackText: `${options?.title ?? 'Workspace settings'}\nNetwork ${networkLabel}\nDefault token ${token.symbol} ${Tempo.formatTokenLink(workspace.chain_id, tokenAddress)}\nDefault amount ${formatAmount(workspace.default_amount)}`,
690707
}
691708
}
709+
710+
function slackTableCell(text: string, style?: { code?: boolean }) {
711+
return {
712+
elements: [
713+
{
714+
elements: [style ? { style, text, type: 'text' } : { text, type: 'text' }],
715+
type: 'rich_text_section',
716+
},
717+
],
718+
type: 'rich_text',
719+
}
720+
}

src/chat.workers.test.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,12 @@ describe('/tip config', () => {
176176

177177
expect(response.status).toBe(200)
178178
await expectSlackMessage('Workspace settings')
179-
await expectSlackMessage('Network: Testnet')
180-
await expectSlackMessage('Default token:')
179+
await expectSlackMessage('Network')
180+
await expectSlackMessage('Testnet')
181+
await expectSlackMessage('Default token')
181182
await expectSlackMessage('pathUSD')
182-
await expectSlackMessage('Default amount: 0.001')
183+
await expectSlackMessage('Default amount')
184+
await expectSlackMessage('0.001')
183185
})
184186

185187
test('handles missing workspace', async () => {
@@ -357,8 +359,8 @@ describe('/tip connect', () => {
357359
.executeTakeFirstOrThrow()
358360

359361
expect(response.status).toBe(200)
360-
await expectSlackMessage('Connect to Tipbot')
361-
await expectSlackMessage('This private link expires in 10 minutes.')
362+
await expectSlackMessage('/connect/')
363+
await expectSlackMessage('Link expires in 10 minutes.')
362364
expect(link).toEqual(expect.schemaMatching(Schema.account_link_token))
363365
expect(member).toEqual(expect.schemaMatching(Schema.member))
364366
expect(link.access_key_address).toEqual(expect.stringMatching(/^0x[0-9a-fA-F]{40}$/))
@@ -434,8 +436,7 @@ describe('/tip connect', () => {
434436
.executeTakeFirstOrThrow()
435437

436438
expect(response.status).toBe(200)
437-
await expectSlackMessage('Connect to Tipbot')
438-
await expectSlackMessage('Connect once to send and receive payments in Slack.')
439+
await expectSlackMessage('Link expires in 10 minutes.')
439440
expect(members).toHaveLength(1)
440441
expect(link.member_id).toBe(member.id)
441442
})
@@ -457,8 +458,7 @@ describe('/tip connect', () => {
457458
const response = await postSlashCommand('connect')
458459

459460
expect(response.status).toBe(200)
460-
await expectSlackMessage('Already connected to Tipbot')
461-
await expectSlackMessage('Send and receive payments in this workspace.')
461+
await expectSlackMessage('Already connected')
462462
await expectSlackMessageNotContaining(account.address)
463463
})
464464

@@ -487,9 +487,7 @@ describe('/tip connect', () => {
487487
.executeTakeFirstOrThrow()
488488

489489
expect(response.status).toBe(200)
490-
await expectSlackMessage('Refresh Tipbot connection')
491-
await expectSlackMessage('Tipbot connection needs a quick refresh before sending payments.')
492-
await expectSlackMessage('This private link expires in 10 minutes.')
490+
await expectSlackMessage('Link expires in 10 minutes.')
493491
await expectSlackMessageNotContaining('Use `/tip disconnect` to disconnect.')
494492
expect(link.member_id).toEqual(expect.any(String))
495493
})
@@ -585,9 +583,12 @@ describe('/tip status', () => {
585583
const response = await postSlashCommand('status')
586584

587585
expect(response.status).toBe(200)
588-
await expectSlackMessage(`Account ID: ${account.id}`)
589-
await expectSlackMessage(`Address: ${account.address}`)
590-
await expectSlackMessage(`Provider user ID: ${Constants.slack.adminUserId}`)
586+
await expectSlackMessage('Account ID')
587+
await expectSlackMessage(account.id)
588+
await expectSlackMessage('Address')
589+
await expectSlackMessage(account.address)
590+
await expectSlackMessage('Provider user ID')
591+
await expectSlackMessage(Constants.slack.adminUserId)
591592
})
592593

593594
test('handles no connected account', async () => {

0 commit comments

Comments
 (0)