1+ /**
2+ * slackRecognitionWebhook
3+ *
4+ * Handles Slack slash command: /recognize @recipient category message
5+ *
6+ * Usage from Slack:
7+ * /recognize @jane .doe teamwork Great job on the Q1 launch!
8+ *
9+ * Supported categories: teamwork, innovation, leadership, going_above,
10+ * customer_focus, problem_solving, mentorship, culture_champion
11+ *
12+ * Setup in Slack App:
13+ * - Slash Commands → /recognize → Request URL: <this function URL>/
14+ * - Interactivity not required
15+ */
16+
17+ import { createClientFromRequest } from 'npm:@base44/sdk@0.8.21' ;
18+
19+ const VALID_CATEGORIES = [
20+ 'teamwork' , 'innovation' , 'leadership' , 'going_above' ,
21+ 'customer_focus' , 'problem_solving' , 'mentorship' , 'culture_champion'
22+ ] ;
23+
24+ const CATEGORY_LABELS = {
25+ teamwork : '🤝 Teamwork' ,
26+ innovation : '💡 Innovation' ,
27+ leadership : '🌟 Leadership' ,
28+ going_above : '🚀 Going Above & Beyond' ,
29+ customer_focus : '🎯 Customer Focus' ,
30+ problem_solving : '🧩 Problem Solving' ,
31+ mentorship : '🎓 Mentorship' ,
32+ culture_champion : '🏆 Culture Champion' ,
33+ } ;
34+
35+ // Verify Slack request signature to prevent spoofing
36+ async function verifySlackSignature ( req , body ) {
37+ const signingSecret = Deno . env . get ( 'SLACK_SIGNING_SECRET' ) ;
38+ if ( ! signingSecret ) return true ; // Skip if not configured (dev mode)
39+
40+ const timestamp = req . headers . get ( 'x-slack-request-timestamp' ) ;
41+ const signature = req . headers . get ( 'x-slack-signature' ) ;
42+
43+ if ( ! timestamp || ! signature ) return false ;
44+
45+ // Reject requests older than 5 minutes
46+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
47+ if ( Math . abs ( now - parseInt ( timestamp ) ) > 300 ) return false ;
48+
49+ const baseString = `v0:${ timestamp } :${ body } ` ;
50+ const key = await crypto . subtle . importKey (
51+ 'raw' ,
52+ new TextEncoder ( ) . encode ( signingSecret ) ,
53+ { name : 'HMAC' , hash : 'SHA-256' } ,
54+ false ,
55+ [ 'sign' ]
56+ ) ;
57+ const sig = await crypto . subtle . sign ( 'HMAC' , key , new TextEncoder ( ) . encode ( baseString ) ) ;
58+ const hexSig = 'v0=' + Array . from ( new Uint8Array ( sig ) ) . map ( b => b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
59+ return hexSig === signature ;
60+ }
61+
62+ // Look up a Slack user's email via Slack API
63+ async function getSlackUserEmail ( slackUserId , accessToken ) {
64+ const res = await fetch ( `https://slack.com/api/users.info?user=${ slackUserId } ` , {
65+ headers : { Authorization : `Bearer ${ accessToken } ` } ,
66+ } ) ;
67+ const data = await res . json ( ) ;
68+ if ( ! data . ok || ! data . user ?. profile ?. email ) return null ;
69+ return { email : data . user . profile . email , name : data . user . real_name || data . user . name } ;
70+ }
71+
72+ // Post a confirmation message back to Slack
73+ async function postSlackMessage ( channel , blocks , accessToken ) {
74+ await fetch ( 'https://slack.com/api/chat.postMessage' , {
75+ method : 'POST' ,
76+ headers : {
77+ Authorization : `Bearer ${ accessToken } ` ,
78+ 'Content-Type' : 'application/json' ,
79+ } ,
80+ body : JSON . stringify ( {
81+ channel,
82+ username : 'INTeract' ,
83+ icon_emoji : ':star:' ,
84+ blocks,
85+ } ) ,
86+ } ) ;
87+ }
88+
89+ Deno . serve ( async ( req ) => {
90+ if ( req . method !== 'POST' ) {
91+ return Response . json ( { error : 'Method not allowed' } , { status : 405 } ) ;
92+ }
93+
94+ // Read raw body for signature verification
95+ const rawBody = await req . text ( ) ;
96+
97+ // Verify Slack signature
98+ const isValid = await verifySlackSignature ( req , rawBody ) ;
99+ if ( ! isValid ) {
100+ return Response . json ( { error : 'Invalid signature' } , { status : 401 } ) ;
101+ }
102+
103+ // Parse form-encoded Slack payload
104+ const params = new URLSearchParams ( rawBody ) ;
105+ const slackUserId = params . get ( 'user_id' ) ;
106+ const channelId = params . get ( 'channel_id' ) ;
107+ const text = ( params . get ( 'text' ) || '' ) . trim ( ) ;
108+ const responseUrl = params . get ( 'response_url' ) ;
109+
110+ if ( ! text ) {
111+ return Response . json ( {
112+ response_type : 'ephemeral' ,
113+ text : '❌ Usage: `/recognize @recipient category message`\n\nCategories: `teamwork`, `innovation`, `leadership`, `going_above`, `customer_focus`, `problem_solving`, `mentorship`, `culture_champion`' ,
114+ } ) ;
115+ }
116+
117+ // Parse: @recipient category message...
118+ // text example: "@jane.doe teamwork Great job on the launch!"
119+ const parts = text . split ( / \s + / ) ;
120+ if ( parts . length < 3 ) {
121+ return Response . json ( {
122+ response_type : 'ephemeral' ,
123+ text : '❌ Usage: `/recognize @recipient category message`\nExample: `/recognize @jane.doe teamwork Great job on the Q1 launch!`' ,
124+ } ) ;
125+ }
126+
127+ const recipientHandle = parts [ 0 ] . replace ( / ^ @ / , '' ) . toLowerCase ( ) ;
128+ const category = parts [ 1 ] . toLowerCase ( ) ;
129+ const message = parts . slice ( 2 ) . join ( ' ' ) ;
130+
131+ if ( ! VALID_CATEGORIES . includes ( category ) ) {
132+ return Response . json ( {
133+ response_type : 'ephemeral' ,
134+ text : `❌ Invalid category \`${ category } \`.\n\nValid categories: ${ VALID_CATEGORIES . map ( c => `\`${ c } \`` ) . join ( ', ' ) } ` ,
135+ } ) ;
136+ }
137+
138+ if ( message . length < 10 ) {
139+ return Response . json ( {
140+ response_type : 'ephemeral' ,
141+ text : '❌ Recognition message must be at least 10 characters.' ,
142+ } ) ;
143+ }
144+
145+ try {
146+ const base44 = createClientFromRequest ( req ) ;
147+
148+ // Get the Slack bot access token
149+ const { accessToken } = await base44 . asServiceRole . connectors . getConnection ( 'slackbot' ) ;
150+
151+ // Resolve sender's Slack email
152+ const sender = await getSlackUserEmail ( slackUserId , accessToken ) ;
153+ if ( ! sender ) {
154+ return Response . json ( {
155+ response_type : 'ephemeral' ,
156+ text : '❌ Could not look up your Slack profile. Make sure your Slack email matches your INTeract account.' ,
157+ } ) ;
158+ }
159+
160+ // Find recipient by email or username match in UserProfile
161+ let recipientProfile = null ;
162+ const profiles = await base44 . asServiceRole . entities . UserProfile . filter ( {
163+ user_email : { $regex : recipientHandle , $options : 'i' }
164+ } ) ;
165+
166+ if ( profiles . length > 0 ) {
167+ recipientProfile = profiles [ 0 ] ;
168+ } else {
169+ // Try partial name match
170+ const allProfiles = await base44 . asServiceRole . entities . UserProfile . list ( '-created_date' , 200 ) ;
171+ recipientProfile = allProfiles . find ( p =>
172+ ( p . user_email || '' ) . toLowerCase ( ) . includes ( recipientHandle ) ||
173+ ( p . role || '' ) . toLowerCase ( ) . includes ( recipientHandle )
174+ ) || null ;
175+ }
176+
177+ if ( ! recipientProfile ) {
178+ return Response . json ( {
179+ response_type : 'ephemeral' ,
180+ text : `❌ Could not find a user matching \`${ recipientHandle } \` in INTeract. Make sure they have a profile set up.` ,
181+ } ) ;
182+ }
183+
184+ // Prevent self-recognition
185+ if ( recipientProfile . user_email === sender . email ) {
186+ return Response . json ( {
187+ response_type : 'ephemeral' ,
188+ text : '❌ You cannot recognize yourself.' ,
189+ } ) ;
190+ }
191+
192+ // Create the Recognition record
193+ await base44 . asServiceRole . entities . Recognition . create ( {
194+ sender_email : sender . email ,
195+ sender_name : sender . name ,
196+ recipient_email : recipientProfile . user_email ,
197+ recipient_name : recipientProfile . role || recipientProfile . user_email ,
198+ message,
199+ category,
200+ points_awarded : 25 ,
201+ visibility : 'public' ,
202+ status : 'approved' , // Slack recognitions auto-approved
203+ source : 'slack' ,
204+ } ) ;
205+
206+ // Post public confirmation to channel
207+ const confirmBlocks = [
208+ {
209+ type : 'section' ,
210+ text : {
211+ type : 'mrkdwn' ,
212+ text : `🌟 *Recognition Alert!*\n\n*${ sender . name } * just recognized *${ recipientProfile . user_email } *` ,
213+ } ,
214+ } ,
215+ {
216+ type : 'section' ,
217+ fields : [
218+ { type : 'mrkdwn' , text : `*Category:*\n${ CATEGORY_LABELS [ category ] } ` } ,
219+ { type : 'mrkdwn' , text : `*Points Awarded:*\n⭐ 25 pts` } ,
220+ ] ,
221+ } ,
222+ {
223+ type : 'section' ,
224+ text : { type : 'mrkdwn' , text : `"${ message } "` } ,
225+ } ,
226+ {
227+ type : 'context' ,
228+ elements : [
229+ { type : 'mrkdwn' , text : 'Synced to INTeract · View the full recognition feed in the platform.' } ,
230+ ] ,
231+ } ,
232+ ] ;
233+
234+ await postSlackMessage ( channelId , confirmBlocks , accessToken ) ;
235+
236+ return Response . json ( {
237+ response_type : 'ephemeral' ,
238+ text : `✅ Recognition sent to *${ recipientProfile . user_email } * and posted to this channel!` ,
239+ } ) ;
240+
241+ } catch ( err ) {
242+ return Response . json ( {
243+ response_type : 'ephemeral' ,
244+ text : `❌ Something went wrong: ${ err . message } ` ,
245+ } ) ;
246+ }
247+ } ) ;
0 commit comments