Skip to content

Commit 7a38171

Browse files
committed
feat: better error messages
1 parent 928bc68 commit 7a38171

4 files changed

Lines changed: 390 additions & 42 deletions

File tree

scripts/slack.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function createManifest() {
4848
command: '/tip',
4949
description: 'Tip teammates and manage Tipbot',
5050
should_escape: true,
51-
usage_hint: '@account, connect, disconnect, help, status',
51+
usage_hint: '@account, connect, disconnect, help, leaderboard, status',
5252
url: `${baseUrl}/api/chat/slack`,
5353
},
5454
],

src/chat.ts

Lines changed: 218 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createCloudflareState } from '#/vendor/chatStateCloudflareDO.ts'
1010
import { createSlackAdapter } from '@chat-adapter/slack'
1111
import * as chat from 'chat'
1212
import { env } from 'cloudflare:workers'
13+
import { sql } from 'kysely'
1314
import { z } from 'zod'
1415

1516
let bot: chat.Chat | null = null
@@ -308,6 +309,7 @@ const handlers = {
308309
['/tip connect', 'Connect to Tipbot'],
309310
['/tip disconnect', 'Disconnect from Tipbot'],
310311
['/tip help', 'Show help message'],
312+
['/tip leaderboard', 'Show top tippers and recipients'],
311313
['/tip status', 'Check connection status'],
312314
]
313315
const body = new URLSearchParams()
@@ -342,6 +344,120 @@ const handlers = {
342344
)
343345
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postEphemeral failed.')
344346
},
347+
async leaderboard(event, ctx) {
348+
if (ctx.text) {
349+
await postInvalidUsage(event, ctx)
350+
return
351+
}
352+
if (ctx.provider.type !== 'slack') throw new Error('Provider not implemented yet.')
353+
354+
const workspace = await ctx.db
355+
.selectFrom('workspace')
356+
.selectAll()
357+
.where('provider', '=', ctx.provider.type)
358+
.where('provider_id', '=', ctx.provider.id)
359+
.executeTakeFirst()
360+
if (!workspace) {
361+
await event.channel.postEphemeral(
362+
event.user,
363+
'Tipbot not configured for this workspace. Reinstall Tipbot and try again.',
364+
{ fallbackToDM: false },
365+
)
366+
return
367+
}
368+
369+
const received = await getLeaderboardRows(ctx, {
370+
memberIdColumn: 'tip.recipient_member_id',
371+
workspaceId: workspace.id,
372+
})
373+
const sent = await getLeaderboardRows(ctx, {
374+
memberIdColumn: 'tip.sender_member_id',
375+
workspaceId: workspace.id,
376+
})
377+
if (received.length === 0 && sent.length === 0) {
378+
await event.channel.postEphemeral(event.user, 'No confirmed tips yet.', {
379+
fallbackToDM: false,
380+
})
381+
return
382+
}
383+
384+
const installation = await getSlack().getInstallation(ctx.provider.id)
385+
if (!installation) return
386+
387+
const body = new URLSearchParams()
388+
body.set('channel', event.channel.id.replace(/^slack:/, ''))
389+
body.set(
390+
'text',
391+
[
392+
'Tips received',
393+
[
394+
'Rank Account Tips',
395+
...received.map((row, index) =>
396+
[String(index + 1), `<@${row.providerUserId}>`, String(row.tipCount)].join(' '),
397+
),
398+
].join('\n'),
399+
'',
400+
'Tips sent',
401+
[
402+
'Rank Account Tips',
403+
...sent.map((row, index) =>
404+
[String(index + 1), `<@${row.providerUserId}>`, String(row.tipCount)].join(' '),
405+
),
406+
].join('\n'),
407+
].join('\n'),
408+
)
409+
body.set(
410+
'blocks',
411+
JSON.stringify([
412+
{
413+
text: { text: 'Tips received', type: 'mrkdwn' },
414+
type: 'section',
415+
},
416+
{
417+
rows: [
418+
[slackTableCell('Rank'), slackTableCell('Account'), slackTableCell('Tips')],
419+
...received.map((row, index) => [
420+
slackTableCell(String(index + 1)),
421+
slackTableCell(`<@${row.providerUserId}>`),
422+
slackTableCell(String(row.tipCount)),
423+
]),
424+
],
425+
type: 'table',
426+
},
427+
{
428+
text: { text: 'Tips sent', type: 'mrkdwn' },
429+
type: 'section',
430+
},
431+
{
432+
rows: [
433+
[slackTableCell('Rank'), slackTableCell('Account'), slackTableCell('Tips')],
434+
...sent.map((row, index) => [
435+
slackTableCell(String(index + 1)),
436+
slackTableCell(`<@${row.providerUserId}>`),
437+
slackTableCell(String(row.tipCount)),
438+
]),
439+
],
440+
type: 'table',
441+
},
442+
]),
443+
)
444+
body.set('user', event.user.userId)
445+
const response = await getSlack().withBotToken(installation.botToken, () =>
446+
fetch(`${env.SLACK_API_URL}/chat.postEphemeral`, {
447+
body,
448+
headers: { authorization: `Bearer ${installation.botToken}` },
449+
method: 'POST',
450+
}),
451+
)
452+
const json = z.parse(
453+
z.object({
454+
error: z.string().optional(),
455+
ok: z.boolean().optional(),
456+
}),
457+
await response.json(),
458+
)
459+
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postEphemeral failed.')
460+
},
345461
async status(event, ctx) {
346462
const workspace = await ctx.db
347463
.selectFrom('workspace')
@@ -380,29 +496,7 @@ const handlers = {
380496
const parsed = Tip.parseTipText(ctx.text)
381497
if (!parsed) {
382498
if (ctx.provider.type !== 'slack') throw new Error('Provider not implemented yet.')
383-
384-
const installation = await getSlack().getInstallation(ctx.provider.id)
385-
if (!installation) throw new Error('Tibot app not installed for this workspace.')
386-
387-
const body = new URLSearchParams()
388-
body.set('channel', event.channel.id.replace(/^slack:/, ''))
389-
body.set('text', 'Invalid `/tip` usage. Try `/tip @account` or `/tip help` for more info.')
390-
body.set('user', event.user.userId)
391-
const response = await getSlack().withBotToken(installation.botToken, () =>
392-
fetch(`${env.SLACK_API_URL}/chat.postEphemeral`, {
393-
body,
394-
headers: { authorization: `Bearer ${installation.botToken}` },
395-
method: 'POST',
396-
}),
397-
)
398-
const json = z.parse(
399-
z.object({
400-
error: z.string().optional(),
401-
ok: z.boolean().optional(),
402-
}),
403-
await response.json(),
404-
)
405-
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postEphemeral failed.')
499+
await postInvalidUsage(event, ctx)
406500
return
407501
}
408502

@@ -464,8 +558,47 @@ const handlers = {
464558
if (result.code === 'recipient_unconnected')
465559
return `Payment not sent. ${event.channel.mentionUser(result.recipientProviderUserId ?? parsed.recipientProviderUserId)} needs to connect Tipbot before receiving payments.`
466560
if (result.code === 'pending') return 'Payment still sending.'
561+
if (result.code === 'insufficient_funds')
562+
return 'Payment not sent. Your wallet has insufficient funds. Add funds to your Tempo Wallet and try again.'
467563
return 'Payment failed.'
468564
})()
565+
if (result.code === 'insufficient_funds') {
566+
if (ctx.provider.type !== 'slack') throw new Error('Provider not implemented yet.')
567+
568+
const installation = await getSlack().getInstallation(ctx.provider.id)
569+
if (!installation) throw new Error('Tibot app not installed for this workspace.')
570+
571+
const linkedText = message.replace('Add funds', '<https://wallet.tempo.xyz|Add funds>')
572+
const body = new URLSearchParams()
573+
body.set(
574+
'blocks',
575+
JSON.stringify([
576+
{
577+
text: { text: linkedText, type: 'mrkdwn' },
578+
type: 'section',
579+
},
580+
]),
581+
)
582+
body.set('channel', event.channel.id.replace(/^slack:/, ''))
583+
body.set('text', message)
584+
body.set('user', event.user.userId)
585+
const response = await getSlack().withBotToken(installation.botToken, () =>
586+
fetch(`${env.SLACK_API_URL}/chat.postEphemeral`, {
587+
body,
588+
headers: { authorization: `Bearer ${installation.botToken}` },
589+
method: 'POST',
590+
}),
591+
)
592+
const json = z.parse(
593+
z.object({
594+
error: z.string().optional(),
595+
ok: z.boolean().optional(),
596+
}),
597+
await response.json(),
598+
)
599+
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postEphemeral failed.')
600+
return
601+
}
469602
if (
470603
'chainId' in result &&
471604
result.chainId &&
@@ -488,7 +621,7 @@ const handlers = {
488621
(event: chat.SlashCommandEvent, ctx: HandlerContext) => Promise<void>
489622
>
490623

491-
const commandNames = ['config', 'connect', 'disconnect', 'help', 'status'] as const
624+
const commandNames = ['config', 'connect', 'disconnect', 'help', 'leaderboard', 'status'] as const
492625
const commandPattern = new RegExp(`^(${commandNames.join('|')})(?:\\s+([\\s\\S]*))?$`)
493626
const actionNames = ['config_edit', 'connect_cancel'] as const
494627
const modalSubmitNames = ['config_edit'] as const
@@ -499,6 +632,11 @@ type HandlerContext = {
499632
text: string
500633
}
501634

635+
type LeaderboardRow = {
636+
providerUserId: string
637+
tipCount: number
638+
}
639+
502640
function getProvider(event: chat.SlashCommandEvent): {
503641
id: string
504642
type: DB_gen.Selectable.workspace['provider']
@@ -518,6 +656,59 @@ function getProvider(event: chat.SlashCommandEvent): {
518656

519657
//////////////////////////////////////////////////////////////////////////////////////
520658

659+
async function getLeaderboardRows(
660+
ctx: HandlerContext,
661+
options: {
662+
memberIdColumn: 'tip.recipient_member_id' | 'tip.sender_member_id'
663+
workspaceId: string
664+
},
665+
) {
666+
const rows = await ctx.db
667+
.selectFrom('tip')
668+
.innerJoin('member', 'member.id', options.memberIdColumn)
669+
.select(['member.provider_user_id', sql<number>`count("tip"."id")`.as('tip_count')])
670+
.where('tip.workspace_id', '=', options.workspaceId)
671+
.where('tip.confirmed_at', 'is not', null)
672+
.groupBy(['member.id', 'member.provider_user_id'])
673+
.orderBy('tip_count', 'desc')
674+
.orderBy('member.provider_user_id', 'asc')
675+
.limit(10)
676+
.execute()
677+
678+
return rows.map(
679+
(row) =>
680+
({
681+
providerUserId: row.provider_user_id,
682+
tipCount: Number(row.tip_count),
683+
}) satisfies LeaderboardRow,
684+
)
685+
}
686+
687+
async function postInvalidUsage(event: chat.SlashCommandEvent, ctx: HandlerContext) {
688+
const installation = await getSlack().getInstallation(ctx.provider.id)
689+
if (!installation) throw new Error('Tibot app not installed for this workspace.')
690+
691+
const body = new URLSearchParams()
692+
body.set('channel', event.channel.id.replace(/^slack:/, ''))
693+
body.set('text', 'Invalid `/tip` usage. Try `/tip @account` or `/tip help` for more info.')
694+
body.set('user', event.user.userId)
695+
const response = await getSlack().withBotToken(installation.botToken, () =>
696+
fetch(`${env.SLACK_API_URL}/chat.postEphemeral`, {
697+
body,
698+
headers: { authorization: `Bearer ${installation.botToken}` },
699+
method: 'POST',
700+
}),
701+
)
702+
const json = z.parse(
703+
z.object({
704+
error: z.string().optional(),
705+
ok: z.boolean().optional(),
706+
}),
707+
await response.json(),
708+
)
709+
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postEphemeral failed.')
710+
}
711+
521712
async function postConnectLink(event: chat.SlashCommandEvent, ctx: HandlerContext) {
522713
const workspace = await ctx.db
523714
.selectFrom('workspace')
@@ -677,13 +868,10 @@ function createReceiptBlocks(
677868
) {
678869
return [
679870
{
680-
accessory: {
681-
action_id: `receipt_${transactionHash.slice(0, 64)}`,
682-
text: { emoji: true, text: 'Receipt', type: 'plain_text' },
683-
type: 'button',
684-
url: Tempo.formatTxLink(chainId, transactionHash),
871+
text: {
872+
text: `${text} <${Tempo.formatTxLink(chainId, transactionHash)}|Receipt>`,
873+
type: 'mrkdwn',
685874
},
686-
text: { text, type: 'mrkdwn' },
687875
type: 'section',
688876
},
689877
...(context

0 commit comments

Comments
 (0)