···100100 - Image URLs are automatically displayed as inline attachments
101101 - IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users
102102 - IRC formatting codes are converted to Slack markdown
103103+ - IRC `/me` actions are displayed in a context block with the user's avatar
103104- **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels
104105 - Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format
105106 - Slack markdown is converted to IRC formatting codes
+81
src/index.ts
···247247 console.error("IRC error:", error);
248248});
249249250250+// Handle IRC /me actions
251251+ircClient.addListener("action", async (nick: string, to: string, text: string) => {
252252+ // Ignore messages from our own bot
253253+ const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`);
254254+ if (botNickPattern.test(nick)) return;
255255+ if (nick === "****") return;
256256+257257+ // Find Slack channel mapping for this IRC channel
258258+ const mapping = channelMappings.getByIrcChannel(to);
259259+ if (!mapping) return;
260260+261261+ // Check if this IRC nick is mapped to a Slack user
262262+ const userMapping = userMappings.getByIrcNick(nick);
263263+264264+ let iconUrl: string;
265265+ if (userMapping) {
266266+ iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
267267+ } else {
268268+ iconUrl = getAvatarForNick(nick);
269269+ }
270270+271271+ // Parse IRC formatting and mentions
272272+ let messageText = parseIRCFormatting(text);
273273+274274+ // Find all @mentions and nick: mentions in the IRC message
275275+ const atMentionPattern = /@(\w+)/g;
276276+ const nickMentionPattern = /(\w+):/g;
277277+278278+ const atMentions = Array.from(messageText.matchAll(atMentionPattern));
279279+ const nickMentions = Array.from(messageText.matchAll(nickMentionPattern));
280280+281281+ for (const match of atMentions) {
282282+ const mentionedNick = match[1] as string;
283283+ const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
284284+ if (mentionedUserMapping) {
285285+ messageText = messageText.replace(
286286+ match[0],
287287+ `<@${mentionedUserMapping.slack_user_id}>`,
288288+ );
289289+ }
290290+ }
291291+292292+ for (const match of nickMentions) {
293293+ const mentionedNick = match[1] as string;
294294+ const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
295295+ if (mentionedUserMapping) {
296296+ messageText = messageText.replace(
297297+ match[0],
298298+ `<@${mentionedUserMapping.slack_user_id}>:`,
299299+ );
300300+ }
301301+ }
302302+303303+ // Format as action message with context block
304304+ const actionText = `${nick} ${messageText}`;
305305+306306+ await slackClient.chat.postMessage({
307307+ token: process.env.SLACK_BOT_TOKEN,
308308+ channel: mapping.slack_channel_id,
309309+ text: actionText,
310310+ blocks: [
311311+ {
312312+ type: "context",
313313+ elements: [
314314+ {
315315+ type: "image",
316316+ image_url: iconUrl,
317317+ alt_text: nick,
318318+ },
319319+ {
320320+ type: "mrkdwn",
321321+ text: actionText,
322322+ },
323323+ ],
324324+ },
325325+ ],
326326+ });
327327+328328+ console.log(`IRC → Slack (action): ${actionText}`);
329329+});
330330+250331// Slack event handlers
251332slackApp.event("message", async ({ payload, context }) => {
252333 // Ignore bot messages and threaded messages