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: make commands work
dunkirk.sh
3 months ago
9c84fca1
29902a8c
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+324
-147
3 changed files
expand all
collapse all
unified
split
slack-manifest.yaml
src
commands.ts
index.ts
+27
-2
slack-manifest.yaml
···
6
6
bot_user:
7
7
display_name: IRC Bridge
8
8
always_online: true
9
9
+
slash_commands:
10
10
+
- command: /irc-bridge-channel
11
11
+
url: https://casual-renewing-reptile.ngrok-free.app/slack
12
12
+
description: Bridge this Slack channel to an IRC channel
13
13
+
usage_hint: "#irc-channel"
14
14
+
should_escape: true
15
15
+
- command: /irc-unbridge-channel
16
16
+
url: https://casual-renewing-reptile.ngrok-free.app/slack
17
17
+
description: Remove bridge from this Slack channel
18
18
+
should_escape: true
19
19
+
- command: /irc-bridge-user
20
20
+
url: https://casual-renewing-reptile.ngrok-free.app/slack
21
21
+
description: Link your Slack account to an IRC nickname
22
22
+
usage_hint: "irc-nick"
23
23
+
should_escape: true
24
24
+
- command: /irc-unbridge-user
25
25
+
url: https://casual-renewing-reptile.ngrok-free.app/slack
26
26
+
description: Remove your IRC nickname link
27
27
+
should_escape: true
28
28
+
- command: /irc-bridge-list
29
29
+
url: https://casual-renewing-reptile.ngrok-free.app/slack
30
30
+
description: List all channel and user bridges
31
31
+
should_escape: true
9
32
oauth_config:
10
33
scopes:
11
34
bot:
12
35
- channels:history
13
36
- channels:read
14
14
-
- channels:write
15
37
- channels:manage
38
38
+
- channels:join
16
39
- chat:write
40
40
+
- chat:write.public
17
41
- chat:write.customize
42
42
+
- commands
18
43
- groups:read
19
44
- groups:write
20
45
- mpim:write
21
21
-
- im:write
46
46
+
- im:read
22
47
- users:read
23
48
settings:
24
49
event_subscriptions:
+146
src/commands.ts
···
1
1
+
import { channelMappings, userMappings } from "./db";
2
2
+
import { slackApp, ircClient } from "./index";
3
3
+
4
4
+
export function registerCommands() {
5
5
+
// Link Slack channel to IRC channel
6
6
+
slackApp.command("/irc-bridge-channel", async ({ payload, context }) => {
7
7
+
const args = payload.text.trim().split(/\s+/);
8
8
+
const ircChannel = args[0];
9
9
+
10
10
+
if (!ircChannel || !ircChannel.startsWith("#")) {
11
11
+
return {
12
12
+
text: "Usage: `/irc-bridge-channel #irc-channel`\nExample: `/irc-bridge-channel #lounge`",
13
13
+
};
14
14
+
}
15
15
+
16
16
+
const slackChannelId = payload.channel_id;
17
17
+
18
18
+
try {
19
19
+
// Create the mapping
20
20
+
channelMappings.create(slackChannelId, ircChannel);
21
21
+
22
22
+
// Join the IRC channel
23
23
+
ircClient.join(ircChannel);
24
24
+
25
25
+
// Join the Slack channel if not already in it
26
26
+
await context.client.conversations.join({
27
27
+
channel: slackChannelId,
28
28
+
});
29
29
+
30
30
+
return {
31
31
+
text: `✅ Successfully bridged this channel to ${ircChannel}`,
32
32
+
};
33
33
+
} catch (error) {
34
34
+
console.error("Error creating channel mapping:", error);
35
35
+
return {
36
36
+
text: `❌ Failed to bridge channel: ${error}`,
37
37
+
};
38
38
+
}
39
39
+
});
40
40
+
41
41
+
// Unlink Slack channel from IRC
42
42
+
slackApp.command("/irc-unbridge-channel", async ({ payload }) => {
43
43
+
const slackChannelId = payload.channel_id;
44
44
+
45
45
+
try {
46
46
+
const mapping = channelMappings.getBySlackChannel(slackChannelId);
47
47
+
if (!mapping) {
48
48
+
return {
49
49
+
text: "❌ This channel is not bridged to IRC",
50
50
+
};
51
51
+
}
52
52
+
53
53
+
channelMappings.delete(slackChannelId);
54
54
+
55
55
+
return {
56
56
+
text: `✅ Removed bridge to ${mapping.irc_channel}`,
57
57
+
};
58
58
+
} catch (error) {
59
59
+
console.error("Error removing channel mapping:", error);
60
60
+
return {
61
61
+
text: `❌ Failed to remove bridge: ${error}`,
62
62
+
};
63
63
+
}
64
64
+
});
65
65
+
66
66
+
// Link Slack user to IRC nick
67
67
+
slackApp.command("/irc-bridge-user", async ({ payload }) => {
68
68
+
const args = payload.text.trim().split(/\s+/);
69
69
+
const ircNick = args[0];
70
70
+
71
71
+
if (!ircNick) {
72
72
+
return {
73
73
+
text: "Usage: `/irc-bridge-user <irc-nick>`\nExample: `/irc-bridge-user myircnick`",
74
74
+
};
75
75
+
}
76
76
+
77
77
+
const slackUserId = payload.user_id;
78
78
+
79
79
+
try {
80
80
+
userMappings.create(slackUserId, ircNick);
81
81
+
console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`);
82
82
+
83
83
+
return {
84
84
+
text: `✅ Successfully linked your account to IRC nick: ${ircNick}`,
85
85
+
};
86
86
+
} catch (error) {
87
87
+
console.error("Error creating user mapping:", error);
88
88
+
return {
89
89
+
text: `❌ Failed to link user: ${error}`,
90
90
+
};
91
91
+
}
92
92
+
});
93
93
+
94
94
+
// Unlink Slack user from IRC
95
95
+
slackApp.command("/irc-unbridge-user", async ({ payload }) => {
96
96
+
const slackUserId = payload.user_id;
97
97
+
98
98
+
try {
99
99
+
const mapping = userMappings.getBySlackUser(slackUserId);
100
100
+
if (!mapping) {
101
101
+
return {
102
102
+
text: "❌ You don't have an IRC nick mapping",
103
103
+
};
104
104
+
}
105
105
+
106
106
+
userMappings.delete(slackUserId);
107
107
+
108
108
+
return {
109
109
+
text: `✅ Removed link to IRC nick: ${mapping.irc_nick}`,
110
110
+
};
111
111
+
} catch (error) {
112
112
+
console.error("Error removing user mapping:", error);
113
113
+
return {
114
114
+
text: `❌ Failed to remove link: ${error}`,
115
115
+
};
116
116
+
}
117
117
+
});
118
118
+
119
119
+
// List channel mappings
120
120
+
slackApp.command("/irc-bridge-list", async ({ payload }) => {
121
121
+
const channelMaps = channelMappings.getAll();
122
122
+
const userMaps = userMappings.getAll();
123
123
+
124
124
+
let text = "*Channel Bridges:*\n";
125
125
+
if (channelMaps.length === 0) {
126
126
+
text += "None\n";
127
127
+
} else {
128
128
+
for (const map of channelMaps) {
129
129
+
text += `• <#${map.slack_channel_id}> ↔️ ${map.irc_channel}\n`;
130
130
+
}
131
131
+
}
132
132
+
133
133
+
text += "\n*User Mappings:*\n";
134
134
+
if (userMaps.length === 0) {
135
135
+
text += "None\n";
136
136
+
} else {
137
137
+
for (const map of userMaps) {
138
138
+
text += `• <@${map.slack_user_id}> ↔️ ${map.irc_nick}\n`;
139
139
+
}
140
140
+
}
141
141
+
142
142
+
return {
143
143
+
text,
144
144
+
};
145
145
+
});
146
146
+
}
+151
-145
src/index.ts
···
1
1
import * as irc from "irc";
2
2
import { SlackApp } from "slack-edge";
3
3
import { version } from "../package.json";
4
4
+
import { registerCommands } from "./commands";
4
5
import { channelMappings, userMappings } from "./db";
5
5
-
import { parseSlackMarkdown, parseIRCFormatting } from "./parser";
6
6
+
import { parseIRCFormatting, parseSlackMarkdown } from "./parser";
6
7
import type { CachetUser } from "./types";
7
8
8
9
const missingEnvVars = [];
9
10
if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
10
11
if (!process.env.SLACK_SIGNING_SECRET)
11
11
-
missingEnvVars.push("SLACK_SIGNING_SECRET");
12
12
+
missingEnvVars.push("SLACK_SIGNING_SECRET");
12
13
if (!process.env.ADMINS) missingEnvVars.push("ADMINS");
13
14
if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK");
14
15
15
16
if (missingEnvVars.length > 0) {
16
16
-
throw new Error(
17
17
-
`Missing required environment variables: ${missingEnvVars.join(", ")}`,
18
18
-
);
17
17
+
throw new Error(
18
18
+
`Missing required environment variables: ${missingEnvVars.join(", ")}`,
19
19
+
);
19
20
}
20
21
21
22
const slackApp = new SlackApp({
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,
23
23
+
env: {
24
24
+
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
25
25
+
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string,
26
26
+
SLACK_LOGGING_LEVEL: "INFO",
27
27
+
},
28
28
+
startLazyListenerAfterAck: true,
28
29
});
29
30
const slackClient = slackApp.client;
30
31
31
32
// Get bot user ID
32
33
let botUserId: string | undefined;
33
34
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
-
});
35
35
+
.test({
36
36
+
token: process.env.SLACK_BOT_TOKEN,
37
37
+
})
38
38
+
.then((result) => {
39
39
+
botUserId = result.user_id;
40
40
+
console.log(`Bot user ID: ${botUserId}`);
41
41
+
});
41
42
42
43
// IRC client setup
43
44
const ircClient = new irc.Client(
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
-
},
45
45
+
"irc.hackclub.com",
46
46
+
process.env.IRC_NICK || "slackbridge",
47
47
+
{
48
48
+
port: 6667,
49
49
+
autoRejoin: true,
50
50
+
autoConnect: true,
51
51
+
channels: [],
52
52
+
secure: false,
53
53
+
userName: process.env.IRC_NICK,
54
54
+
realName: "Slack IRC Bridge",
55
55
+
},
55
56
);
56
57
57
58
// Clean up IRC connection on hot reload or exit
58
59
process.on("beforeExit", () => {
59
59
-
ircClient.disconnect("Reloading", () => {
60
60
-
console.log("IRC client disconnected");
61
61
-
});
60
60
+
ircClient.disconnect("Reloading", () => {
61
61
+
console.log("IRC client disconnected");
62
62
+
});
62
63
});
63
64
65
65
+
// Register slash commands
66
66
+
registerCommands();
67
67
+
64
68
// Join all mapped IRC channels on connect
65
69
ircClient.addListener("registered", async () => {
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
-
}
70
70
+
console.log("Connected to IRC server");
71
71
+
const mappings = channelMappings.getAll();
72
72
+
for (const mapping of mappings) {
73
73
+
ircClient.join(mapping.irc_channel);
74
74
+
}
71
75
});
72
76
73
77
ircClient.addListener("join", (channel: string, nick: string) => {
74
74
-
if (nick === process.env.IRC_NICK) {
75
75
-
console.log(`Joined IRC channel: ${channel}`);
76
76
-
}
78
78
+
if (nick === process.env.IRC_NICK) {
79
79
+
console.log(`Joined IRC channel: ${channel}`);
80
80
+
}
77
81
});
78
82
79
83
ircClient.addListener(
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;
84
84
+
"message",
85
85
+
async (nick: string, to: string, text: string) => {
86
86
+
// Ignore messages from our own bot (with or without numbers suffix)
87
87
+
const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`);
88
88
+
if (botNickPattern.test(nick)) return;
89
89
+
if (nick === "****") return;
86
90
87
87
-
// Find Slack channel mapping for this IRC channel
88
88
-
const mapping = channelMappings.getByIrcChannel(to);
89
89
-
if (!mapping) return;
91
91
+
// Find Slack channel mapping for this IRC channel
92
92
+
const mapping = channelMappings.getByIrcChannel(to);
93
93
+
if (!mapping) return;
90
94
91
91
-
// Check if this IRC nick is mapped to a Slack user
92
92
-
const userMapping = userMappings.getByIrcNick(nick);
95
95
+
// Check if this IRC nick is mapped to a Slack user
96
96
+
const userMapping = userMappings.getByIrcNick(nick);
93
97
94
94
-
const displayName = `${nick} <irc>`;
95
95
-
let iconUrl: string | undefined;
98
98
+
const displayName = `${nick} <irc>`;
99
99
+
let iconUrl: string | undefined;
96
100
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
-
}
101
101
+
if (userMapping) {
102
102
+
try {
103
103
+
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
104
104
+
} catch (error) {
105
105
+
console.error("Error fetching user info:", error);
106
106
+
}
107
107
+
}
104
108
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
-
},
109
109
+
try {
110
110
+
await slackClient.chat.postMessage({
111
111
+
token: process.env.SLACK_BOT_TOKEN,
112
112
+
channel: mapping.slack_channel_id,
113
113
+
text: parseIRCFormatting(text),
114
114
+
username: displayName,
115
115
+
icon_url: iconUrl,
116
116
+
unfurl_links: false,
117
117
+
unfurl_media: false,
118
118
+
});
119
119
+
console.log(`IRC → Slack: <${nick}> ${text}`);
120
120
+
} catch (error) {
121
121
+
console.error("Error posting to Slack:", error);
122
122
+
}
123
123
+
},
120
124
);
121
125
122
126
ircClient.addListener("error", (error: string) => {
123
123
-
console.error("IRC error:", error);
127
127
+
console.error("IRC error:", error);
124
128
});
125
129
126
130
// Slack event handlers
127
131
slackApp.event("message", async ({ payload }) => {
128
128
-
if (payload.subtype) return;
129
129
-
if (payload.bot_id) return;
130
130
-
if (payload.user === botUserId) return;
132
132
+
if (payload.subtype) return;
133
133
+
if (payload.bot_id) return;
134
134
+
if (payload.user === botUserId) return;
131
135
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
-
}
136
136
+
// Find IRC channel mapping for this Slack channel
137
137
+
const mapping = channelMappings.getBySlackChannel(payload.channel);
138
138
+
if (!mapping) {
139
139
+
console.log(
140
140
+
`No IRC channel mapping found for Slack channel ${payload.channel}`,
141
141
+
);
142
142
+
slackClient.conversations.leave({
143
143
+
channel: payload.channel,
144
144
+
});
145
145
+
return;
146
146
+
}
143
147
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
-
});
148
148
+
try {
149
149
+
const userInfo = await slackClient.users.info({
150
150
+
token: process.env.SLACK_BOT_TOKEN,
151
151
+
user: payload.user,
152
152
+
});
149
153
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";
154
154
+
// Check for user mapping, otherwise use Slack name
155
155
+
const userMapping = userMappings.getBySlackUser(payload.user);
156
156
+
const username =
157
157
+
userMapping?.irc_nick ||
158
158
+
userInfo.user?.real_name ||
159
159
+
userInfo.user?.name ||
160
160
+
"Unknown";
157
161
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
+
// Parse Slack mentions and replace with display names
163
163
+
let messageText = payload.text;
164
164
+
const mentionRegex = /<@(U[A-Z0-9]+)>/g;
165
165
+
const mentions = Array.from(messageText.matchAll(mentionRegex));
162
166
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
-
}
167
167
+
for (const match of mentions) {
168
168
+
const userId = match[1];
169
169
+
try {
170
170
+
const response = await fetch(
171
171
+
`https://cachet.dunkirk.sh/users/${userId}`,
172
172
+
);
173
173
+
if (response.ok) {
174
174
+
const data = (await response.json()) as CachetUser;
175
175
+
messageText = messageText.replace(match[0], `@${data.displayName}`);
176
176
+
}
177
177
+
} catch (error) {
178
178
+
console.error(`Error fetching user ${userId} from cachet:`, error);
179
179
+
}
180
180
+
}
177
181
178
178
-
// Parse Slack markdown formatting
179
179
-
messageText = parseSlackMarkdown(messageText);
182
182
+
// Parse Slack markdown formatting
183
183
+
messageText = parseSlackMarkdown(messageText);
180
184
181
181
-
const message = `<${username}> ${messageText}`;
185
185
+
const message = `<${username}> ${messageText}`;
182
186
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
-
}
187
187
+
ircClient.say(mapping.irc_channel, message);
188
188
+
console.log(`Slack → IRC: ${message}`);
189
189
+
} catch (error) {
190
190
+
console.error("Error handling Slack message:", error);
191
191
+
}
188
192
});
189
193
190
194
export default {
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;
195
195
+
port: process.env.PORT || 3000,
196
196
+
async fetch(request: Request) {
197
197
+
const url = new URL(request.url);
198
198
+
const path = url.pathname;
195
199
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
-
},
200
200
+
switch (path) {
201
201
+
case "/":
202
202
+
return new Response(`Hello World from irc-slack-bridge@${version}`);
203
203
+
case "/health":
204
204
+
return new Response("OK");
205
205
+
case "/slack":
206
206
+
return slackApp.run(request);
207
207
+
default:
208
208
+
return new Response("404 Not Found", { status: 404 });
209
209
+
}
210
210
+
},
207
211
};
208
212
209
213
console.log(
210
210
-
`🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
214
214
+
`🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
211
215
);
212
216
console.log(
213
213
-
`Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
217
217
+
`Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
214
218
);
215
219
console.log(`Channel mappings: ${channelMappings.getAll().length}`);
216
220
console.log(`User mappings: ${userMappings.getAll().length}`);
221
221
+
222
222
+
export { slackApp, slackClient, ircClient };