Serenity Operating System
1/*
2 * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org>
3 * All rights reserved.
4 *
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions are met:
7 *
8 * 1. Redistributions of source code must retain the above copyright notice, this
9 * list of conditions and the following disclaimer.
10 *
11 * 2. Redistributions in binary form must reproduce the above copyright notice,
12 * this list of conditions and the following disclaimer in the documentation
13 * and/or other materials provided with the distribution.
14 *
15 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25 */
26
27#include "IRCClient.h"
28#include "IRCAppWindow.h"
29#include "IRCChannel.h"
30#include "IRCLogBuffer.h"
31#include "IRCQuery.h"
32#include "IRCWindow.h"
33#include "IRCWindowListModel.h"
34#include <AK/QuickSort.h>
35#include <AK/StringBuilder.h>
36#include <LibCore/DateTime.h>
37#include <LibCore/Notifier.h>
38#include <arpa/inet.h>
39#include <netinet/in.h>
40#include <stdio.h>
41#include <sys/socket.h>
42#include <time.h>
43#include <unistd.h>
44
45#define IRC_DEBUG
46
47enum IRCNumeric {
48 RPL_WHOISUSER = 311,
49 RPL_WHOISSERVER = 312,
50 RPL_WHOISOPERATOR = 313,
51 RPL_WHOISIDLE = 317,
52 RPL_ENDOFWHOIS = 318,
53 RPL_WHOISCHANNELS = 319,
54 RPL_TOPIC = 332,
55 RPL_TOPICWHOTIME = 333,
56 RPL_NAMREPLY = 353,
57 RPL_ENDOFNAMES = 366,
58};
59
60IRCClient::IRCClient()
61 : m_nickname("seren1ty")
62 , m_client_window_list_model(IRCWindowListModel::create(*this))
63 , m_log(IRCLogBuffer::create())
64 , m_config(Core::ConfigFile::get_for_app("IRCClient"))
65{
66 m_socket = Core::TCPSocket::construct(this);
67 m_nickname = m_config->read_entry("User", "Nickname", "seren1ty");
68 m_hostname = m_config->read_entry("Connection", "Server", "");
69 m_port = m_config->read_num_entry("Connection", "Port", 6667);
70}
71
72IRCClient::~IRCClient()
73{
74}
75
76void IRCClient::set_server(const String& hostname, int port)
77{
78 m_hostname = hostname;
79 m_port = port;
80 m_config->write_entry("Connection", "Server", hostname);
81 m_config->write_num_entry("Connection", "Port", port);
82 m_config->sync();
83}
84
85void IRCClient::on_socket_connected()
86{
87 m_notifier = Core::Notifier::construct(m_socket->fd(), Core::Notifier::Read);
88 m_notifier->on_ready_to_read = [this] { receive_from_server(); };
89
90 send_user();
91 send_nick();
92
93 auto channel_str = m_config->read_entry("Connection", "AutoJoinChannels", "#test");
94 dbgprintf("IRCClient: Channels to autojoin: %s\n", channel_str.characters());
95 auto channels = channel_str.split(',');
96 for (auto& channel : channels) {
97 join_channel(channel);
98 dbgprintf("IRCClient: Auto joining channel: %s\n", channel.characters());
99 }
100}
101
102bool IRCClient::connect()
103{
104 if (m_socket->is_connected())
105 ASSERT_NOT_REACHED();
106
107 m_socket->on_connected = [this] { on_socket_connected(); };
108 bool success = m_socket->connect(m_hostname, m_port);
109 if (!success)
110 return false;
111 return true;
112}
113
114void IRCClient::receive_from_server()
115{
116 while (m_socket->can_read_line()) {
117 auto line = m_socket->read_line(PAGE_SIZE);
118 if (line.is_null()) {
119 if (!m_socket->is_connected()) {
120 printf("IRCClient: Connection closed!\n");
121 exit(1);
122 }
123 ASSERT_NOT_REACHED();
124 }
125 process_line(move(line));
126 }
127}
128
129void IRCClient::process_line(ByteBuffer&& line)
130{
131 Message msg;
132 Vector<char, 32> prefix;
133 Vector<char, 32> command;
134 Vector<char, 256> current_parameter;
135 enum {
136 Start,
137 InPrefix,
138 InCommand,
139 InStartOfParameter,
140 InParameter,
141 InTrailingParameter,
142 } state
143 = Start;
144
145 for (size_t i = 0; i < line.size(); ++i) {
146 char ch = line[i];
147 if (ch == '\r')
148 continue;
149 if (ch == '\n')
150 break;
151 switch (state) {
152 case Start:
153 if (ch == ':') {
154 state = InPrefix;
155 continue;
156 }
157 state = InCommand;
158 [[fallthrough]];
159 case InCommand:
160 if (ch == ' ') {
161 state = InStartOfParameter;
162 continue;
163 }
164 command.append(ch);
165 continue;
166 case InPrefix:
167 if (ch == ' ') {
168 state = InCommand;
169 continue;
170 }
171 prefix.append(ch);
172 continue;
173 case InStartOfParameter:
174 if (ch == ':') {
175 state = InTrailingParameter;
176 continue;
177 }
178 state = InParameter;
179 [[fallthrough]];
180 case InParameter:
181 if (ch == ' ') {
182 if (!current_parameter.is_empty())
183 msg.arguments.append(String(current_parameter.data(), current_parameter.size()));
184 current_parameter.clear_with_capacity();
185 state = InStartOfParameter;
186 continue;
187 }
188 current_parameter.append(ch);
189 continue;
190 case InTrailingParameter:
191 current_parameter.append(ch);
192 continue;
193 }
194 }
195 if (!current_parameter.is_empty())
196 msg.arguments.append(String::copy(current_parameter));
197 msg.prefix = String::copy(prefix);
198 msg.command = String::copy(command);
199 handle(msg);
200}
201
202void IRCClient::send(const String& text)
203{
204 if (!m_socket->send(ByteBuffer::wrap(text.characters(), text.length()))) {
205 perror("send");
206 exit(1);
207 }
208}
209
210void IRCClient::send_user()
211{
212 send(String::format("USER %s 0 * :%s\r\n", m_nickname.characters(), m_nickname.characters()));
213}
214
215void IRCClient::send_nick()
216{
217 send(String::format("NICK %s\r\n", m_nickname.characters()));
218}
219
220void IRCClient::send_pong(const String& server)
221{
222 send(String::format("PONG %s\r\n", server.characters()));
223 sleep(1);
224}
225
226void IRCClient::join_channel(const String& channel_name)
227{
228 send(String::format("JOIN %s\r\n", channel_name.characters()));
229}
230
231void IRCClient::part_channel(const String& channel_name)
232{
233 send(String::format("PART %s\r\n", channel_name.characters()));
234}
235
236void IRCClient::send_whois(const String& nick)
237{
238 send(String::format("WHOIS %s\r\n", nick.characters()));
239}
240
241void IRCClient::handle(const Message& msg)
242{
243#ifdef IRC_DEBUG
244 printf("IRCClient::execute: prefix='%s', command='%s', arguments=%zu\n",
245 msg.prefix.characters(),
246 msg.command.characters(),
247 msg.arguments.size());
248
249 int i = 0;
250 for (auto& arg : msg.arguments) {
251 printf(" [%d]: %s\n", i, arg.characters());
252 ++i;
253 }
254#endif
255
256 bool is_numeric;
257 int numeric = msg.command.to_uint(is_numeric);
258
259 if (is_numeric) {
260 switch (numeric) {
261 case RPL_WHOISCHANNELS:
262 return handle_rpl_whoischannels(msg);
263 case RPL_ENDOFWHOIS:
264 return handle_rpl_endofwhois(msg);
265 case RPL_WHOISOPERATOR:
266 return handle_rpl_whoisoperator(msg);
267 case RPL_WHOISSERVER:
268 return handle_rpl_whoisserver(msg);
269 case RPL_WHOISUSER:
270 return handle_rpl_whoisuser(msg);
271 case RPL_WHOISIDLE:
272 return handle_rpl_whoisidle(msg);
273 case RPL_TOPICWHOTIME:
274 return handle_rpl_topicwhotime(msg);
275 case RPL_TOPIC:
276 return handle_rpl_topic(msg);
277 case RPL_NAMREPLY:
278 return handle_rpl_namreply(msg);
279 case RPL_ENDOFNAMES:
280 return handle_rpl_endofnames(msg);
281 }
282 }
283
284 if (msg.command == "PING")
285 return handle_ping(msg);
286
287 if (msg.command == "JOIN")
288 return handle_join(msg);
289
290 if (msg.command == "PART")
291 return handle_part(msg);
292
293 if (msg.command == "TOPIC")
294 return handle_topic(msg);
295
296 if (msg.command == "PRIVMSG")
297 return handle_privmsg_or_notice(msg, PrivmsgOrNotice::Privmsg);
298
299 if (msg.command == "NOTICE")
300 return handle_privmsg_or_notice(msg, PrivmsgOrNotice::Notice);
301
302 if (msg.command == "NICK")
303 return handle_nick(msg);
304
305 if (msg.arguments.size() >= 2)
306 add_server_message(String::format("[%s] %s", msg.command.characters(), msg.arguments[1].characters()));
307}
308
309void IRCClient::add_server_message(const String& text, Color color)
310{
311 m_log->add_message(0, "", text, color);
312 m_server_subwindow->did_add_message();
313}
314
315void IRCClient::send_privmsg(const String& target, const String& text)
316{
317 send(String::format("PRIVMSG %s :%s\r\n", target.characters(), text.characters()));
318}
319
320void IRCClient::send_notice(const String& target, const String& text)
321{
322 send(String::format("NOTICE %s :%s\r\n", target.characters(), text.characters()));
323}
324
325void IRCClient::handle_user_input_in_channel(const String& channel_name, const String& input)
326{
327 if (input.is_empty())
328 return;
329 if (input[0] == '/')
330 return handle_user_command(input);
331 ensure_channel(channel_name).say(input);
332}
333
334void IRCClient::handle_user_input_in_query(const String& query_name, const String& input)
335{
336 if (input.is_empty())
337 return;
338 if (input[0] == '/')
339 return handle_user_command(input);
340 ensure_query(query_name).say(input);
341}
342
343void IRCClient::handle_user_input_in_server(const String& input)
344{
345 if (input.is_empty())
346 return;
347 if (input[0] == '/')
348 return handle_user_command(input);
349}
350
351bool IRCClient::is_nick_prefix(char ch) const
352{
353 switch (ch) {
354 case '@':
355 case '+':
356 case '~':
357 case '&':
358 case '%':
359 return true;
360 }
361 return false;
362}
363
364static bool has_ctcp_payload(const StringView& string)
365{
366 return string.length() >= 2 && string[0] == 0x01 && string[string.length() - 1] == 0x01;
367}
368
369void IRCClient::handle_privmsg_or_notice(const Message& msg, PrivmsgOrNotice type)
370{
371 if (msg.arguments.size() < 2)
372 return;
373 if (msg.prefix.is_empty())
374 return;
375 auto parts = msg.prefix.split('!');
376 auto sender_nick = parts[0];
377 auto target = msg.arguments[0];
378
379 bool is_ctcp = has_ctcp_payload(msg.arguments[1]);
380
381#ifdef IRC_DEBUG
382 printf("handle_privmsg_or_notice: type='%s'%s, sender_nick='%s', target='%s'\n",
383 type == PrivmsgOrNotice::Privmsg ? "privmsg" : "notice",
384 is_ctcp ? " (ctcp)" : "",
385 sender_nick.characters(),
386 target.characters());
387#endif
388
389 if (sender_nick.is_empty())
390 return;
391
392 char sender_prefix = 0;
393 if (is_nick_prefix(sender_nick[0])) {
394 sender_prefix = sender_nick[0];
395 sender_nick = sender_nick.substring(1, sender_nick.length() - 1);
396 }
397
398 String message_text = msg.arguments[1];
399 auto message_color = Color::Black;
400
401 if (is_ctcp) {
402 auto ctcp_payload = msg.arguments[1].substring_view(1, msg.arguments[1].length() - 2);
403 if (type == PrivmsgOrNotice::Privmsg)
404 handle_ctcp_request(sender_nick, ctcp_payload);
405 else
406 handle_ctcp_response(sender_nick, ctcp_payload);
407 StringBuilder builder;
408 builder.append("(CTCP) ");
409 builder.append(ctcp_payload);
410 message_text = builder.to_string();
411 message_color = Color::Blue;
412 }
413
414 {
415 auto it = m_channels.find(target);
416 if (it != m_channels.end()) {
417 (*it).value->add_message(sender_prefix, sender_nick, message_text, message_color);
418 return;
419 }
420 }
421
422 // For NOTICE or CTCP messages, only put them in query if one already exists.
423 // Otherwise, put them in the server window. This seems to match other clients.
424 IRCQuery* query = nullptr;
425 if (is_ctcp || type == PrivmsgOrNotice::Notice) {
426 query = query_with_name(sender_nick);
427 } else {
428 query = &ensure_query(sender_nick);
429 }
430 if (query)
431 query->add_message(sender_prefix, sender_nick, message_text, message_color);
432 else {
433 add_server_message(String::format("<%s> %s", sender_nick.characters(), message_text.characters()), message_color);
434 }
435}
436
437IRCQuery* IRCClient::query_with_name(const String& name)
438{
439 return const_cast<IRCQuery*>(m_queries.get(name).value_or(nullptr));
440}
441
442IRCQuery& IRCClient::ensure_query(const String& name)
443{
444 auto it = m_queries.find(name);
445 if (it != m_queries.end())
446 return *(*it).value;
447 auto query = IRCQuery::create(*this, name);
448 auto& query_reference = *query;
449 m_queries.set(name, query);
450 return query_reference;
451}
452
453IRCChannel& IRCClient::ensure_channel(const String& name)
454{
455 auto it = m_channels.find(name);
456 if (it != m_channels.end())
457 return *(*it).value;
458 auto channel = IRCChannel::create(*this, name);
459 auto& channel_reference = *channel;
460 m_channels.set(name, channel);
461 return channel_reference;
462}
463
464void IRCClient::handle_ping(const Message& msg)
465{
466 if (msg.arguments.size() < 1)
467 return;
468 m_log->add_message(0, "", "Ping? Pong!");
469 send_pong(msg.arguments[0]);
470}
471
472void IRCClient::handle_join(const Message& msg)
473{
474 if (msg.arguments.size() != 1)
475 return;
476 auto prefix_parts = msg.prefix.split('!');
477 if (prefix_parts.size() < 1)
478 return;
479 auto nick = prefix_parts[0];
480 auto& channel_name = msg.arguments[0];
481 ensure_channel(channel_name).handle_join(nick, msg.prefix);
482}
483
484void IRCClient::handle_part(const Message& msg)
485{
486 if (msg.arguments.size() < 1)
487 return;
488 auto prefix_parts = msg.prefix.split('!');
489 if (prefix_parts.size() < 1)
490 return;
491 auto nick = prefix_parts[0];
492 auto& channel_name = msg.arguments[0];
493 ensure_channel(channel_name).handle_part(nick, msg.prefix);
494}
495
496void IRCClient::handle_nick(const Message& msg)
497{
498 auto prefix_parts = msg.prefix.split('!');
499 if (prefix_parts.size() < 1)
500 return;
501 auto old_nick = prefix_parts[0];
502 if (msg.arguments.size() != 1)
503 return;
504 auto& new_nick = msg.arguments[0];
505 if (old_nick == m_nickname)
506 m_nickname = new_nick;
507 add_server_message(String::format("~ %s changed nickname to %s", old_nick.characters(), new_nick.characters()));
508 if (on_nickname_changed)
509 on_nickname_changed(new_nick);
510 for (auto& it : m_channels) {
511 it.value->notify_nick_changed(old_nick, new_nick);
512 }
513}
514
515void IRCClient::handle_topic(const Message& msg)
516{
517 if (msg.arguments.size() != 2)
518 return;
519 auto prefix_parts = msg.prefix.split('!');
520 if (prefix_parts.size() < 1)
521 return;
522 auto nick = prefix_parts[0];
523 auto& channel_name = msg.arguments[0];
524 ensure_channel(channel_name).handle_topic(nick, msg.arguments[1]);
525}
526
527void IRCClient::handle_rpl_topic(const Message& msg)
528{
529 if (msg.arguments.size() < 3)
530 return;
531 auto& channel_name = msg.arguments[1];
532 auto& topic = msg.arguments[2];
533 ensure_channel(channel_name).handle_topic({}, topic);
534 // FIXME: Handle RPL_TOPICWHOTIME so we can know who set it and when.
535}
536
537void IRCClient::handle_rpl_namreply(const Message& msg)
538{
539 if (msg.arguments.size() < 4)
540 return;
541 auto& channel_name = msg.arguments[2];
542 auto& channel = ensure_channel(channel_name);
543 auto members = msg.arguments[3].split(' ');
544
545 quick_sort(members.begin(), members.end(), [](auto& a, auto& b) {
546 return strcasecmp(a.characters(), b.characters()) < 0;
547 });
548
549 for (auto& member : members) {
550 if (member.is_empty())
551 continue;
552 char prefix = 0;
553 if (is_nick_prefix(member[0]))
554 prefix = member[0];
555 channel.add_member(member, prefix);
556 }
557}
558
559void IRCClient::handle_rpl_endofnames(const Message&)
560{
561}
562
563void IRCClient::handle_rpl_endofwhois(const Message&)
564{
565 add_server_message("// End of WHOIS");
566}
567
568void IRCClient::handle_rpl_whoisoperator(const Message& msg)
569{
570 if (msg.arguments.size() < 2)
571 return;
572 auto& nick = msg.arguments[1];
573 add_server_message(String::format("* %s is an IRC operator", nick.characters()));
574}
575
576void IRCClient::handle_rpl_whoisserver(const Message& msg)
577{
578 if (msg.arguments.size() < 3)
579 return;
580 auto& nick = msg.arguments[1];
581 auto& server = msg.arguments[2];
582 add_server_message(String::format("* %s is using server %s", nick.characters(), server.characters()));
583}
584
585void IRCClient::handle_rpl_whoisuser(const Message& msg)
586{
587 if (msg.arguments.size() < 6)
588 return;
589 auto& nick = msg.arguments[1];
590 auto& username = msg.arguments[2];
591 auto& host = msg.arguments[3];
592 auto& asterisk = msg.arguments[4];
593 auto& realname = msg.arguments[5];
594 (void)asterisk;
595 add_server_message(String::format("* %s is %s@%s, real name: %s",
596 nick.characters(),
597 username.characters(),
598 host.characters(),
599 realname.characters()));
600}
601
602void IRCClient::handle_rpl_whoisidle(const Message& msg)
603{
604 if (msg.arguments.size() < 3)
605 return;
606 auto& nick = msg.arguments[1];
607 auto& secs = msg.arguments[2];
608 add_server_message(String::format("* %s is %s seconds idle", nick.characters(), secs.characters()));
609}
610
611void IRCClient::handle_rpl_whoischannels(const Message& msg)
612{
613 if (msg.arguments.size() < 3)
614 return;
615 auto& nick = msg.arguments[1];
616 auto& channel_list = msg.arguments[2];
617 add_server_message(String::format("* %s is in channels %s", nick.characters(), channel_list.characters()));
618}
619
620void IRCClient::handle_rpl_topicwhotime(const Message& msg)
621{
622 if (msg.arguments.size() < 4)
623 return;
624 auto& channel_name = msg.arguments[1];
625 auto& nick = msg.arguments[2];
626 auto setat = msg.arguments[3];
627 bool ok;
628 time_t setat_time = setat.to_uint(ok);
629 if (ok)
630 setat = Core::DateTime::from_timestamp(setat_time).to_string();
631 ensure_channel(channel_name).add_message(String::format("*** (set by %s at %s)", nick.characters(), setat.characters()), Color::Blue);
632}
633
634void IRCClient::register_subwindow(IRCWindow& subwindow)
635{
636 if (subwindow.type() == IRCWindow::Server) {
637 m_server_subwindow = &subwindow;
638 subwindow.set_log_buffer(*m_log);
639 }
640 m_windows.append(&subwindow);
641 m_client_window_list_model->update();
642}
643
644void IRCClient::unregister_subwindow(IRCWindow& subwindow)
645{
646 if (subwindow.type() == IRCWindow::Server) {
647 m_server_subwindow = &subwindow;
648 }
649 for (size_t i = 0; i < m_windows.size(); ++i) {
650 if (m_windows.at(i) == &subwindow) {
651 m_windows.remove(i);
652 break;
653 }
654 }
655 m_client_window_list_model->update();
656}
657
658void IRCClient::handle_user_command(const String& input)
659{
660 auto parts = input.split_view(' ');
661 if (parts.is_empty())
662 return;
663 auto command = String(parts[0]).to_uppercase();
664 if (command == "/NICK") {
665 if (parts.size() >= 2)
666 change_nick(parts[1]);
667 return;
668 }
669 if (command == "/JOIN") {
670 if (parts.size() >= 2)
671 join_channel(parts[1]);
672 return;
673 }
674 if (command == "/PART") {
675 if (parts.size() >= 2)
676 part_channel(parts[1]);
677 return;
678 }
679 if (command == "/QUERY") {
680 if (parts.size() >= 2) {
681 auto& query = ensure_query(parts[1]);
682 IRCAppWindow::the().set_active_window(query.window());
683 }
684 return;
685 }
686 if (command == "/MSG") {
687 if (parts.size() < 3)
688 return;
689 auto nick = parts[1];
690 auto& query = ensure_query(nick);
691 IRCAppWindow::the().set_active_window(query.window());
692 query.say(input.view().substring_view_starting_after_substring(nick));
693 return;
694 }
695 if (command == "/WHOIS") {
696 if (parts.size() >= 2)
697 send_whois(parts[1]);
698 return;
699 }
700}
701
702void IRCClient::change_nick(const String& nick)
703{
704 send(String::format("NICK %s\r\n", nick.characters()));
705}
706
707void IRCClient::handle_whois_action(const String& nick)
708{
709 send_whois(nick);
710}
711
712void IRCClient::handle_open_query_action(const String& nick)
713{
714 ensure_query(nick);
715}
716
717void IRCClient::handle_change_nick_action(const String& nick)
718{
719 change_nick(nick);
720}
721
722void IRCClient::handle_close_query_action(const String& nick)
723{
724 m_queries.remove(nick);
725 m_client_window_list_model->update();
726}
727
728void IRCClient::handle_join_action(const String& channel)
729{
730 join_channel(channel);
731}
732
733void IRCClient::handle_part_action(const String& channel)
734{
735 part_channel(channel);
736}
737
738void IRCClient::did_part_from_channel(Badge<IRCChannel>, IRCChannel& channel)
739{
740 if (on_part_from_channel)
741 on_part_from_channel(channel);
742}
743
744void IRCClient::send_ctcp_response(const StringView& peer, const StringView& payload)
745{
746 StringBuilder builder;
747 builder.append(0x01);
748 builder.append(payload);
749 builder.append(0x01);
750 auto message = builder.to_string();
751 send_notice(peer, message);
752}
753
754void IRCClient::handle_ctcp_request(const StringView& peer, const StringView& payload)
755{
756 dbg() << "handle_ctcp_request: " << payload;
757
758 if (payload == "VERSION") {
759 send_ctcp_response(peer, "VERSION IRC Client [x86] / Serenity OS");
760 return;
761 }
762
763 if (payload.starts_with("PING")) {
764 send_ctcp_response(peer, payload);
765 return;
766 }
767}
768
769void IRCClient::handle_ctcp_response(const StringView& peer, const StringView& payload)
770{
771 dbg() << "handle_ctcp_response(" << peer << "): " << payload;
772}