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