@@ -10,6 +10,7 @@ import { createCloudflareState } from '#/vendor/chatStateCloudflareDO.ts'
1010import { createSlackAdapter } from '@chat-adapter/slack'
1111import * as chat from 'chat'
1212import { env } from 'cloudflare:workers'
13+ import { sql } from 'kysely'
1314import { z } from 'zod'
1415
1516let 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 ( / ^ s l a c k : / , '' ) )
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 ( / ^ s l a c k : / , '' ) )
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 ( / ^ s l a c k : / , '' ) )
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
492625const commandPattern = new RegExp ( `^(${ commandNames . join ( '|' ) } )(?:\\s+([\\s\\S]*))?$` )
493626const actionNames = [ 'config_edit' , 'connect_cancel' ] as const
494627const 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+
502640function 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 ( / ^ s l a c k : / , '' ) )
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+
521712async 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