Skip to content

Commit 124f21e

Browse files
File changes
1 parent b55b0b4 commit 124f21e

File tree

2 files changed

+478
-0
lines changed

2 files changed

+478
-0
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)