Bump copyright years
[cacert-votebot.git] / src / main / java / org / cacert / votebot / shared / IRCClient.java
1 /*
2 * Copyright (c) 2015 Felix Doerre
3 * Copyright (c) 2015 Benny Baumann
4 * Copyright (c) 2016 Jan Dittberner
5 *
6 * This file is part of CAcert votebot.
7 *
8 * CAcert votebot is free software: you can redistribute it and/or modify it
9 * under the terms of the GNU General Public License as published by the Free
10 * Software Foundation, either version 3 of the License, or (at your option)
11 * any later version.
12 *
13 * CAcert votebot is distributed in the hope that it will be useful, but
14 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
15 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
16 * more details.
17 *
18 * You should have received a copy of the GNU General Public License along with
19 * CAcert votebot. If not, see <http://www.gnu.org/licenses/>.
20 */
21 package org.cacert.votebot.shared;
22
23 import org.apache.commons.cli.CommandLine;
24 import org.apache.commons.cli.CommandLineParser;
25 import org.apache.commons.cli.DefaultParser;
26 import org.apache.commons.cli.HelpFormatter;
27 import org.apache.commons.cli.Option;
28 import org.apache.commons.cli.Options;
29 import org.apache.commons.cli.ParseException;
30 import org.cacert.votebot.shared.exceptions.IRCClientException;
31 import org.cacert.votebot.shared.exceptions.InvalidChannelName;
32 import org.cacert.votebot.shared.exceptions.InvalidNickName;
33 import org.cacert.votebot.shared.exceptions.NoBotAssigned;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
36 import org.springframework.stereotype.Component;
37
38 import javax.annotation.PreDestroy;
39 import javax.net.ssl.SSLSocketFactory;
40 import java.io.BufferedReader;
41 import java.io.IOException;
42 import java.io.InputStreamReader;
43 import java.io.PrintWriter;
44 import java.net.Socket;
45 import java.nio.charset.StandardCharsets;
46 import java.nio.file.Files;
47 import java.nio.file.Path;
48 import java.nio.file.Paths;
49 import java.nio.file.StandardOpenOption;
50 import java.nio.file.attribute.FileAttribute;
51 import java.nio.file.attribute.PosixFilePermission;
52 import java.nio.file.attribute.PosixFilePermissions;
53 import java.util.ArrayList;
54 import java.util.HashMap;
55 import java.util.HashSet;
56 import java.util.List;
57 import java.util.Map;
58 import java.util.Set;
59 import java.util.concurrent.Semaphore;
60
61 /**
62 * This class encapsulates the communication with the IRC server.
63 *
64 * @author Felix Doerre
65 * @author Jan Dittberner
66 */
67 @SuppressWarnings({"unused", "WeakerAccess"})
68 @Component
69 public final class IRCClient {
70 /**
71 * Logger.
72 */
73 private static final Logger LOGGER = LoggerFactory.getLogger(IRCClient.class);
74 /**
75 * Regular expression to validate IRC nick names.
76 */
77 private static final String NICK_RE = "[a-zA-Z0-9_-]+";
78 /**
79 * Regular expression to validate IRC channel names.
80 */
81 private static final String CHANNEL_RE = "[a-zA-Z0-9_-]+";
82
83 private final Semaphore loggedin = new Semaphore(0);
84 private PrintWriter out;
85 private final Set<String> joinedChannels = new HashSet<>();
86 private IRCBot targetBot;
87
88 /**
89 * Initialize the IRC client based on command line arguments.
90 *
91 * @param args command line arguments
92 * @return the instance itself
93 * @throws IOException in case of network IO problems
94 * @throws InterruptedException in case of thread interruption
95 * @throws ParseException in case of problems parsing the command line arguments
96 * @throws IRCClientException in case of syntactic errors related to the IRC protocol
97 */
98 public IRCClient initializeFromArgs(final String... args)
99 throws IOException, InterruptedException, ParseException, IRCClientException {
100 final Options opts = new Options();
101 opts.addOption(
102 Option.builder("u").longOpt("no-ssl")
103 .desc("disable SSL").build());
104 opts.addOption(
105 Option.builder("h").longOpt("host").hasArg(true).required()
106 .desc("hostname of the IRC server").build());
107 opts.addOption(
108 Option.builder("p").longOpt("port").hasArg(true)
109 .desc("tcp port of the IRC server").type(Integer.class).build());
110 opts.addOption(
111 Option.builder("n").longOpt("nick").hasArg(true).argName("nick").required()
112 .desc("IRC nick name").build());
113
114 final CommandLineParser commandLineParser = new DefaultParser();
115 try {
116 final CommandLine commandLine = commandLineParser.parse(opts, args);
117 initialize(
118 commandLine.getOptionValue("nick"),
119 commandLine.getOptionValue("host"),
120 Integer.parseInt(commandLine.getOptionValue("port", "7000")),
121 !commandLine.hasOption("no-ssl"));
122 } catch (final ParseException pe) {
123 final HelpFormatter formatter = new HelpFormatter();
124 formatter.printHelp("votebot", opts);
125 throw pe;
126 }
127 return this;
128 }
129
130 private void initialize(final String nick, final String server, final int port, final boolean ssl)
131 throws IOException,
132 InterruptedException, IRCClientException {
133 if (!nick.matches(NICK_RE)) {
134 throw new IRCClientException(String.format("malformed nickname %s", nick));
135 }
136
137 final Socket socket;
138 if (ssl) {
139 socket = SSLSocketFactory.getDefault().createSocket(server, port); //default-ssl = 7000
140 } else {
141 socket = new Socket(server, port); // default-plain = 6667
142 }
143
144 out = new PrintWriter(socket.getOutputStream(), true);
145 final BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
146
147 new ServerReader(in);
148
149 out.println("USER " + nick + " 0 * :unknown");
150 out.println("NICK " + nick);
151
152 loggedin.acquire();
153 }
154
155 /**
156 * Check whether preconditions for a channel command are met.
157 *
158 * @param channel channel name
159 * @throws NoBotAssigned when no bot is associated with this client
160 * @throws InvalidChannelName when the channel name is invalid
161 */
162 private void checkChannelPreconditions(final String channel) throws NoBotAssigned, InvalidChannelName {
163 if (targetBot == null) {
164 throw new NoBotAssigned();
165 }
166
167 if (!channel.matches(CHANNEL_RE)) {
168 throw new InvalidChannelName(channel);
169 }
170 }
171
172 /**
173 * Check whether preconditions for a private message command are met.
174 *
175 * @param nick nick name
176 * @throws NoBotAssigned when no bot is associated with this client
177 * @throws InvalidNickName when the nick name is invalid
178 */
179 private void checkPrivateMessagePreconditions(final String nick) throws NoBotAssigned, InvalidNickName {
180 if (targetBot == null) {
181 throw new NoBotAssigned();
182 }
183
184 if (!nick.matches(NICK_RE)) {
185 throw new InvalidNickName(nick);
186 }
187 }
188
189 /**
190 * Let the associated bot join the given channel.
191 *
192 * @param channel channel name
193 * @throws IRCClientException for IRC client issue
194 */
195 public void join(final String channel) throws IRCClientException {
196 checkChannelPreconditions(channel);
197
198 if (joinedChannels.add(channel)) {
199 out.println("JOIN #" + channel);
200 }
201 }
202
203 /**
204 * Let the associated bot leave the given channel.
205 *
206 * @param channel channel name
207 * @throws IRCClientException for IRC client issues
208 */
209 public void leave(final String channel) throws IRCClientException {
210 checkChannelPreconditions(channel);
211
212 if (joinedChannels.remove(channel)) {
213 out.println("PART #" + channel);
214 }
215 }
216
217 @PreDestroy
218 public void leaveAll() {
219 List<String> channels = new ArrayList<>(joinedChannels);
220 for (String channel : channels) {
221 try {
222 leave(channel);
223 } catch (IRCClientException e) {
224 LOGGER.error(e.getMessage(), e);
225 }
226 }
227 }
228
229 /**
230 * Send a message to the given channel.
231 *
232 * @param msg message
233 * @param channel channel name
234 * @throws IRCClientException for IRC client issues
235 */
236 public void send(final String msg, final String channel) throws IRCClientException {
237 checkChannelPreconditions(channel);
238
239 out.println(String.format("PRIVMSG #%s :%s", channel, msg));
240 }
241
242 /**
243 * Send a private message to the given nick name.
244 *
245 * @param msg message
246 * @param to nickname
247 * @throws IRCClientException for IRC client issues
248 */
249 public void sendPrivate(final String msg, final String to) throws IRCClientException {
250 checkPrivateMessagePreconditions(to);
251
252 out.println(String.format("PRIVMSG %s :%s", to, msg));
253 }
254
255 /**
256 * Assign a bot to this client.
257 *
258 * @param bot IRC bot instance
259 */
260 public void assignBot(final IRCBot bot) {
261 targetBot = bot;
262 }
263
264 /**
265 * Reader thread for handling the IRC connection.
266 */
267 private class ServerReader implements Runnable {
268 private final BufferedReader bufferedReader;
269 private final Map<String, PrintWriter> logs = new HashMap<>();
270
271 ServerReader(final BufferedReader bufferedReader) {
272 this.bufferedReader = bufferedReader;
273
274 new Thread(this).start();
275 }
276
277 @Override
278 public void run() {
279 String line;
280
281 try {
282 while ((line = bufferedReader.readLine()) != null) {
283 final String fullLine = line;
284
285 if (line.startsWith("PING ")) {
286 handleIrcPing(line);
287 continue;
288 }
289
290 String referent = "";
291
292 if (line.startsWith(":")) {
293 final String[] parts = line.split(" ", 2);
294 referent = parts[0];
295 line = parts[1];
296 }
297
298 final String[] command = line.split(" ", 3);
299
300 if (command[0].equals("001")) {
301 loggedin.release();
302 }
303
304 switch (command[0]) {
305 case "PRIVMSG":
306 final String msg = command[2].substring(1);
307 final String chnl = command[1];
308
309 if (chnl.startsWith("#")) {
310 handleMsg(referent, chnl, msg);
311 } else {
312 handlePrivMsg(referent, msg);
313 }
314
315 log(chnl, fullLine);
316 break;
317 case "JOIN": {
318 final String channel = command[1].substring(1);
319 targetBot.join(cleanReferent(referent), channel.substring(1));
320 log(channel, fullLine);
321 break;
322 }
323 case "PART":
324 final String channel = command[1];
325 targetBot.part(cleanReferent(referent), channel);
326 log(channel, fullLine);
327 break;
328 default:
329 LOGGER.info("unknown line: {}", line);
330 break;
331 }
332 }
333 } catch (final IOException | IRCClientException e) {
334 LOGGER.error(e.getMessage(), e);
335 }
336 }
337
338 private void handleIrcPing(final String line) {
339 LOGGER.debug("PONG");
340 out.println("PONG " + line.substring("PING ".length()));
341 }
342
343 private String cleanReferent(final String referent) {
344 final String[] parts = referent.split("!");
345
346 if (!parts[0].startsWith(":")) {
347 LOGGER.error("invalid public message");
348 return "unknown";
349 }
350
351 return parts[0];
352 }
353
354 private void log(final String channel, final String logline) {
355 PrintWriter log = logs.get(channel);
356
357 if (log == null) {
358 final Path dirPath = Paths.get("irc");
359 if (!Files.exists(dirPath)) {
360 final Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rwxr-x---");
361 final FileAttribute fileAttributes = PosixFilePermissions.asFileAttribute(permissions);
362 try {
363 Files.createDirectory(dirPath, fileAttributes);
364 } catch (final IOException e) {
365 LOGGER.error("error creating directory 'irc': {}", e.getMessage());
366 return;
367 }
368 }
369 final Path filePath = dirPath.resolve(String.format("log_%s", channel));
370 try {
371 log = new PrintWriter(Files
372 .newBufferedWriter(filePath, StandardCharsets.UTF_8, StandardOpenOption.APPEND,
373 StandardOpenOption.CREATE, StandardOpenOption.WRITE));
374 } catch (final IOException e) {
375 LOGGER.error("error opening log file '{}' for writing: {}", filePath, e.getMessage());
376 return;
377 }
378 logs.put(channel, log);
379 }
380
381 log.println(logline);
382 log.flush();
383 }
384
385 @SuppressWarnings("unused")
386 @PreDestroy
387 private void closeLogs() {
388 for (final PrintWriter pwr : logs.values()) {
389 pwr.flush();
390 pwr.close();
391 }
392 logs.clear();
393 }
394
395 private void handlePrivMsg(final String referent, final String msg) throws IRCClientException {
396 if (targetBot == null) {
397 throw new NoBotAssigned();
398 }
399
400 final String[] parts = referent.split("!");
401
402 if (!parts[0].startsWith(":")) {
403 LOGGER.warn("invalid private message: {}", msg);
404 return;
405 }
406
407 targetBot.privateMessage(parts[0].substring(1), msg);
408 }
409
410 private void handleMsg(final String referent, final String chnl, final String msg) throws IRCClientException {
411 if (targetBot == null) {
412 throw new NoBotAssigned();
413 }
414
415 final String[] parts = referent.split("!");
416
417 if (!parts[0].startsWith(":")) {
418 LOGGER.warn("invalid public message");
419 return;
420 }
421
422 if (!chnl.startsWith("#")) {
423 LOGGER.warn("invalid public message (chnl)");
424 return;
425 }
426
427 targetBot.publicMessage(parts[0].substring(1), chnl.substring(1), msg);
428 }
429
430 }
431 }