tangled
alpha
login
or
join now
dunkirk.sh
/
irc-slack-bridge
1
fork
atom
this repo has no description
1
fork
atom
overview
issues
pulls
pipelines
feat: format both sides
dunkirk.sh
3 months ago
29902a8c
b0be7a6e
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+255
-138
4 changed files
expand all
collapse all
unified
split
package.json
src
index.ts
parser.ts
types.ts
+1
-1
package.json
···
5
5
"type": "module",
6
6
"private": true,
7
7
"scripts": {
8
8
-
"dev": "bun --hot src/index.ts",
8
8
+
"dev": "bun src/index.ts",
9
9
"start": "bun src/index.ts",
10
10
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
11
11
},
+156
-137
src/index.ts
···
1
1
import * as irc from "irc";
2
2
-
import { SlackAPIClient, SlackApp } from "slack-edge";
2
2
+
import { SlackApp } from "slack-edge";
3
3
import { version } from "../package.json";
4
4
import { channelMappings, userMappings } from "./db";
5
5
+
import { parseSlackMarkdown, parseIRCFormatting } from "./parser";
6
6
+
import type { CachetUser } from "./types";
5
7
6
8
const missingEnvVars = [];
7
9
if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
8
10
if (!process.env.SLACK_SIGNING_SECRET)
9
9
-
missingEnvVars.push("SLACK_SIGNING_SECRET");
11
11
+
missingEnvVars.push("SLACK_SIGNING_SECRET");
10
12
if (!process.env.ADMINS) missingEnvVars.push("ADMINS");
11
13
if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK");
12
14
13
15
if (missingEnvVars.length > 0) {
14
14
-
throw new Error(
15
15
-
`Missing required environment variables: ${missingEnvVars.join(", ")}`,
16
16
-
);
16
16
+
throw new Error(
17
17
+
`Missing required environment variables: ${missingEnvVars.join(", ")}`,
18
18
+
);
17
19
}
18
20
19
21
const slackApp = new SlackApp({
20
20
-
env: {
21
21
-
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
22
22
-
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string,
23
23
-
SLACK_LOGGING_LEVEL: "INFO",
24
24
-
},
25
25
-
startLazyListenerAfterAck: true,
22
22
+
env: {
23
23
+
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
24
24
+
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string,
25
25
+
SLACK_LOGGING_LEVEL: "INFO",
26
26
+
},
27
27
+
startLazyListenerAfterAck: true,
26
28
});
27
29
const slackClient = slackApp.client;
28
30
29
31
// Get bot user ID
30
32
let botUserId: string | undefined;
31
31
-
slackClient.auth.test({
32
32
-
token: process.env.SLACK_BOT_TOKEN,
33
33
-
}).then((result) => {
34
34
-
botUserId = result.user_id;
35
35
-
console.log(`Bot user ID: ${botUserId}`);
36
36
-
});
33
33
+
slackClient.auth
34
34
+
.test({
35
35
+
token: process.env.SLACK_BOT_TOKEN,
36
36
+
})
37
37
+
.then((result) => {
38
38
+
botUserId = result.user_id;
39
39
+
console.log(`Bot user ID: ${botUserId}`);
40
40
+
});
37
41
38
42
// IRC client setup
39
43
const ircClient = new irc.Client(
40
40
-
"irc.hackclub.com",
41
41
-
process.env.IRC_NICK || "slackbridge",
42
42
-
{
43
43
-
port: 6667,
44
44
-
autoRejoin: true,
45
45
-
autoConnect: true,
46
46
-
channels: [],
47
47
-
secure: false,
48
48
-
userName: process.env.IRC_NICK,
49
49
-
realName: "Slack IRC Bridge",
50
50
-
},
44
44
+
"irc.hackclub.com",
45
45
+
process.env.IRC_NICK || "slackbridge",
46
46
+
{
47
47
+
port: 6667,
48
48
+
autoRejoin: true,
49
49
+
autoConnect: true,
50
50
+
channels: [],
51
51
+
secure: false,
52
52
+
userName: process.env.IRC_NICK,
53
53
+
realName: "Slack IRC Bridge",
54
54
+
},
51
55
);
52
56
57
57
+
// Clean up IRC connection on hot reload or exit
58
58
+
process.on("beforeExit", () => {
59
59
+
ircClient.disconnect("Reloading", () => {
60
60
+
console.log("IRC client disconnected");
61
61
+
});
62
62
+
});
63
63
+
53
64
// Join all mapped IRC channels on connect
54
65
ircClient.addListener("registered", async () => {
55
55
-
console.log("Connected to IRC server");
56
56
-
const mappings = channelMappings.getAll();
57
57
-
for (const mapping of mappings) {
58
58
-
ircClient.join(mapping.irc_channel);
59
59
-
}
66
66
+
console.log("Connected to IRC server");
67
67
+
const mappings = channelMappings.getAll();
68
68
+
for (const mapping of mappings) {
69
69
+
ircClient.join(mapping.irc_channel);
70
70
+
}
60
71
});
61
72
62
73
ircClient.addListener("join", (channel: string, nick: string) => {
63
63
-
if (nick === process.env.IRC_NICK) {
64
64
-
console.log(`Joined IRC channel: ${channel}`);
65
65
-
}
74
74
+
if (nick === process.env.IRC_NICK) {
75
75
+
console.log(`Joined IRC channel: ${channel}`);
76
76
+
}
66
77
});
67
78
68
79
ircClient.addListener(
69
69
-
"message",
70
70
-
async (nick: string, to: string, text: string) => {
71
71
-
if (nick === process.env.IRC_NICK) return;
72
72
-
if (nick === "****") return;
80
80
+
"message",
81
81
+
async (nick: string, to: string, text: string) => {
82
82
+
// Ignore messages from our own bot (with or without numbers suffix)
83
83
+
const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`);
84
84
+
if (botNickPattern.test(nick)) return;
85
85
+
if (nick === "****") return;
73
86
74
74
-
// Find Slack channel mapping for this IRC channel
75
75
-
const mapping = channelMappings.getByIrcChannel(to);
76
76
-
if (!mapping) return;
87
87
+
// Find Slack channel mapping for this IRC channel
88
88
+
const mapping = channelMappings.getByIrcChannel(to);
89
89
+
if (!mapping) return;
77
90
78
78
-
// Check if this IRC nick is mapped to a Slack user
79
79
-
const userMapping = userMappings.getByIrcNick(nick);
91
91
+
// Check if this IRC nick is mapped to a Slack user
92
92
+
const userMapping = userMappings.getByIrcNick(nick);
80
93
81
81
-
const displayName = `${nick} <irc>`;
82
82
-
let iconUrl: string | undefined;
94
94
+
const displayName = `${nick} <irc>`;
95
95
+
let iconUrl: string | undefined;
83
96
84
84
-
if (userMapping) {
85
85
-
try {
86
86
-
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
87
87
-
} catch (error) {
88
88
-
console.error("Error fetching user info:", error);
89
89
-
}
90
90
-
}
97
97
+
if (userMapping) {
98
98
+
try {
99
99
+
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
100
100
+
} catch (error) {
101
101
+
console.error("Error fetching user info:", error);
102
102
+
}
103
103
+
}
91
104
92
92
-
try {
93
93
-
await slackClient.chat.postMessage({
94
94
-
token: process.env.SLACK_BOT_TOKEN,
95
95
-
channel: mapping.slack_channel_id,
96
96
-
text: text,
97
97
-
username: displayName,
98
98
-
icon_url: iconUrl,
99
99
-
unfurl_links: false,
100
100
-
unfurl_media: false,
101
101
-
});
102
102
-
} catch (error) {
103
103
-
console.error("Error posting to Slack:", error);
104
104
-
}
105
105
-
},
105
105
+
try {
106
106
+
await slackClient.chat.postMessage({
107
107
+
token: process.env.SLACK_BOT_TOKEN,
108
108
+
channel: mapping.slack_channel_id,
109
109
+
text: parseIRCFormatting(text),
110
110
+
username: displayName,
111
111
+
icon_url: iconUrl,
112
112
+
unfurl_links: false,
113
113
+
unfurl_media: false,
114
114
+
});
115
115
+
console.log(`IRC → Slack: <${nick}> ${text}`);
116
116
+
} catch (error) {
117
117
+
console.error("Error posting to Slack:", error);
118
118
+
}
119
119
+
},
106
120
);
107
121
108
122
ircClient.addListener("error", (error: string) => {
109
109
-
console.error("IRC error:", error);
123
123
+
console.error("IRC error:", error);
110
124
});
111
125
112
126
// Slack event handlers
113
127
slackApp.event("message", async ({ payload }) => {
114
114
-
if (payload.subtype) return;
115
115
-
if (payload.bot_id) return;
116
116
-
if (payload.user === botUserId) return;
128
128
+
if (payload.subtype) return;
129
129
+
if (payload.bot_id) return;
130
130
+
if (payload.user === botUserId) return;
117
131
118
118
-
// Find IRC channel mapping for this Slack channel
119
119
-
const mapping = channelMappings.getBySlackChannel(payload.channel);
120
120
-
if (!mapping) {
121
121
-
console.log(
122
122
-
`No IRC channel mapping found for Slack channel ${payload.channel}`,
123
123
-
);
124
124
-
slackClient.conversations.leave({
125
125
-
channel: payload.channel,
126
126
-
});
127
127
-
return;
128
128
-
}
132
132
+
// Find IRC channel mapping for this Slack channel
133
133
+
const mapping = channelMappings.getBySlackChannel(payload.channel);
134
134
+
if (!mapping) {
135
135
+
console.log(
136
136
+
`No IRC channel mapping found for Slack channel ${payload.channel}`,
137
137
+
);
138
138
+
slackClient.conversations.leave({
139
139
+
channel: payload.channel,
140
140
+
});
141
141
+
return;
142
142
+
}
129
143
130
130
-
try {
131
131
-
const userInfo = await slackClient.users.info({
132
132
-
token: process.env.SLACK_BOT_TOKEN,
133
133
-
user: payload.user,
134
134
-
});
144
144
+
try {
145
145
+
const userInfo = await slackClient.users.info({
146
146
+
token: process.env.SLACK_BOT_TOKEN,
147
147
+
user: payload.user,
148
148
+
});
135
149
136
136
-
// Check for user mapping, otherwise use Slack name
137
137
-
const userMapping = userMappings.getBySlackUser(payload.user);
138
138
-
const username =
139
139
-
userMapping?.irc_nick ||
140
140
-
userInfo.user?.real_name ||
141
141
-
userInfo.user?.name ||
142
142
-
"Unknown";
143
143
-
144
144
-
// Parse Slack mentions and replace with display names
145
145
-
let messageText = payload.text;
146
146
-
const mentionRegex = /<@(U[A-Z0-9]+)>/g;
147
147
-
const mentions = Array.from(messageText.matchAll(mentionRegex));
148
148
-
149
149
-
for (const match of mentions) {
150
150
-
const userId = match[1];
151
151
-
try {
152
152
-
const response = await fetch(`https://cachet.dunkirk.sh/users/${userId}`);
153
153
-
if (response.ok) {
154
154
-
const data = await response.json();
155
155
-
messageText = messageText.replace(match[0], `@${data.displayName}`);
156
156
-
}
157
157
-
} catch (error) {
158
158
-
console.error(`Error fetching user ${userId} from cachet:`, error);
159
159
-
}
160
160
-
}
161
161
-
162
162
-
const message = `<${username}> ${messageText}`;
150
150
+
// Check for user mapping, otherwise use Slack name
151
151
+
const userMapping = userMappings.getBySlackUser(payload.user);
152
152
+
const username =
153
153
+
userMapping?.irc_nick ||
154
154
+
userInfo.user?.real_name ||
155
155
+
userInfo.user?.name ||
156
156
+
"Unknown";
163
157
164
164
-
console.log(`Sending to IRC ${mapping.irc_channel}: ${message}`);
165
165
-
ircClient.say(mapping.irc_channel, message);
166
166
-
} catch (error) {
167
167
-
console.error("Error handling Slack message:", error);
168
168
-
}
158
158
+
// Parse Slack mentions and replace with display names
159
159
+
let messageText = payload.text;
160
160
+
const mentionRegex = /<@(U[A-Z0-9]+)>/g;
161
161
+
const mentions = Array.from(messageText.matchAll(mentionRegex));
162
162
+
163
163
+
for (const match of mentions) {
164
164
+
const userId = match[1];
165
165
+
try {
166
166
+
const response = await fetch(
167
167
+
`https://cachet.dunkirk.sh/users/${userId}`,
168
168
+
);
169
169
+
if (response.ok) {
170
170
+
const data = await response.json() as CachetUser;
171
171
+
messageText = messageText.replace(match[0], `@${data.displayName}`);
172
172
+
}
173
173
+
} catch (error) {
174
174
+
console.error(`Error fetching user ${userId} from cachet:`, error);
175
175
+
}
176
176
+
}
177
177
+
178
178
+
// Parse Slack markdown formatting
179
179
+
messageText = parseSlackMarkdown(messageText);
180
180
+
181
181
+
const message = `<${username}> ${messageText}`;
182
182
+
183
183
+
ircClient.say(mapping.irc_channel, message);
184
184
+
console.log(`Slack → IRC: ${message}`);
185
185
+
} catch (error) {
186
186
+
console.error("Error handling Slack message:", error);
187
187
+
}
169
188
});
170
189
171
190
export default {
172
172
-
port: process.env.PORT || 3000,
173
173
-
async fetch(request: Request) {
174
174
-
const url = new URL(request.url);
175
175
-
const path = url.pathname;
191
191
+
port: process.env.PORT || 3000,
192
192
+
async fetch(request: Request) {
193
193
+
const url = new URL(request.url);
194
194
+
const path = url.pathname;
176
195
177
177
-
switch (path) {
178
178
-
case "/":
179
179
-
return new Response(`Hello World from irc-slack-bridge@${version}`);
180
180
-
case "/health":
181
181
-
return new Response("OK");
182
182
-
case "/slack":
183
183
-
return slackApp.run(request);
184
184
-
default:
185
185
-
return new Response("404 Not Found", { status: 404 });
186
186
-
}
187
187
-
},
196
196
+
switch (path) {
197
197
+
case "/":
198
198
+
return new Response(`Hello World from irc-slack-bridge@${version}`);
199
199
+
case "/health":
200
200
+
return new Response("OK");
201
201
+
case "/slack":
202
202
+
return slackApp.run(request);
203
203
+
default:
204
204
+
return new Response("404 Not Found", { status: 404 });
205
205
+
}
206
206
+
},
188
207
};
189
208
190
209
console.log(
191
191
-
`🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
210
210
+
`🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
192
211
);
193
212
console.log(
194
194
-
`Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
213
213
+
`Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
195
214
);
196
215
console.log(`Channel mappings: ${channelMappings.getAll().length}`);
197
216
console.log(`User mappings: ${userMappings.getAll().length}`);
+89
src/parser.ts
···
1
1
+
/**
2
2
+
* Parse Slack mrkdwn formatting and convert to IRC-friendly plain text
3
3
+
*/
4
4
+
export function parseSlackMarkdown(text: string): string {
5
5
+
let parsed = text;
6
6
+
7
7
+
// Replace channel mentions <#C123ABC|channel-name> or <#C123ABC>
8
8
+
parsed = parsed.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
9
9
+
parsed = parsed.replace(/<#[A-Z0-9]+>/g, "#channel");
10
10
+
11
11
+
// Replace links <http://example.com|text> or <http://example.com>
12
12
+
parsed = parsed.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2 ($1)");
13
13
+
parsed = parsed.replace(/<(https?:\/\/[^>]+)>/g, "$1");
14
14
+
15
15
+
// Replace mailto links <mailto:email|text>
16
16
+
parsed = parsed.replace(/<mailto:([^|>]+)\|([^>]+)>/g, "$2 <$1>");
17
17
+
parsed = parsed.replace(/<mailto:([^>]+)>/g, "$1");
18
18
+
19
19
+
// Replace special mentions
20
20
+
parsed = parsed.replace(/<!here>/g, "@here");
21
21
+
parsed = parsed.replace(/<!channel>/g, "@channel");
22
22
+
parsed = parsed.replace(/<!everyone>/g, "@everyone");
23
23
+
24
24
+
// Replace user group mentions <!subteam^GROUP_ID|handle>
25
25
+
parsed = parsed.replace(/<!subteam\^[A-Z0-9]+\|([^>]+)>/g, "@$1");
26
26
+
parsed = parsed.replace(/<!subteam\^[A-Z0-9]+>/g, "@group");
27
27
+
28
28
+
// Date formatting - just use fallback text
29
29
+
parsed = parsed.replace(/<!date\^[0-9]+\^[^|]+\|([^>]+)>/g, "$1");
30
30
+
31
31
+
// Replace Slack bold *text* with IRC bold \x02text\x02
32
32
+
parsed = parsed.replace(/\*((?:[^\*]|\\\*)+)\*/g, "\x02$1\x02");
33
33
+
34
34
+
// Replace Slack italic _text_ with IRC italic \x1Dtext\x1D
35
35
+
parsed = parsed.replace(/_((?:[^_]|\\_)+)_/g, "\x1D$1\x1D");
36
36
+
37
37
+
// Replace Slack strikethrough ~text~ with plain text (IRC doesn't support strikethrough well)
38
38
+
parsed = parsed.replace(/~((?:[^~]|\\~)+)~/g, "$1");
39
39
+
40
40
+
// Replace code blocks ```code``` with plain text
41
41
+
parsed = parsed.replace(/```([^`]+)```/g, "$1");
42
42
+
43
43
+
// Replace inline code `code` with plain text
44
44
+
parsed = parsed.replace(/`([^`]+)`/g, "$1");
45
45
+
46
46
+
// Handle block quotes - prefix with >
47
47
+
parsed = parsed.replace(/^>/gm, ">");
48
48
+
49
49
+
// Unescape HTML entities
50
50
+
parsed = parsed.replace(/&/g, "&");
51
51
+
parsed = parsed.replace(/</g, "<");
52
52
+
parsed = parsed.replace(/>/g, ">");
53
53
+
54
54
+
return parsed;
55
55
+
}
56
56
+
57
57
+
/**
58
58
+
* Parse IRC formatting codes and convert to Slack mrkdwn
59
59
+
*/
60
60
+
export function parseIRCFormatting(text: string): string {
61
61
+
let parsed = text;
62
62
+
63
63
+
// IRC color codes - strip them (Slack doesn't support colors in the same way)
64
64
+
// \x03 followed by optional color codes
65
65
+
parsed = parsed.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "");
66
66
+
67
67
+
// IRC bold \x02text\x02 -> Slack bold *text*
68
68
+
parsed = parsed.replace(/\x02([^\x02]*)\x02/g, "*$1*");
69
69
+
70
70
+
// IRC italic \x1D text\x1D -> Slack italic _text_
71
71
+
parsed = parsed.replace(/\x1D([^\x1D]*)\x1D/g, "_$1_");
72
72
+
73
73
+
// IRC underline \x1F text\x1F -> Slack doesn't have underline, use italic instead
74
74
+
parsed = parsed.replace(/\x1F([^\x1F]*)\x1F/g, "_$1_");
75
75
+
76
76
+
// IRC reverse/inverse \x16 - strip it (Slack doesn't support)
77
77
+
parsed = parsed.replace(/\x16/g, "");
78
78
+
79
79
+
// IRC reset \x0F - strip it
80
80
+
parsed = parsed.replace(/\x0F/g, "");
81
81
+
82
82
+
// Escape special Slack characters that would be interpreted as formatting
83
83
+
parsed = parsed.replace(/&/g, "&");
84
84
+
parsed = parsed.replace(/</g, "<");
85
85
+
parsed = parsed.replace(/>/g, ">");
86
86
+
87
87
+
return parsed;
88
88
+
}
89
89
+
+9
src/types.ts
···
1
1
+
export interface CachetUser {
2
2
+
type: "user";
3
3
+
id: string;
4
4
+
userId: string;
5
5
+
displayName: string;
6
6
+
pronouns: string;
7
7
+
imageUrl: string;
8
8
+
expiration: string;
9
9
+
}