1 /++
2 	Module for interacting with the Discord chat service. I use it to run a discord bot providing some slash commands.
3 
4 
5 	$(LIST
6 		* Real time gateway
7 			See [DiscordGatewayConnection]
8 
9 			You can use [SlashCommandHandler] subclasses registered with a gateway connection to easily add slash commands to your app.
10 		* REST api
11 			See [DiscordRestApi]
12 		* Local RPC server
13 			See [DiscordRpcConnection] (not implemented)
14 		* Voice connections
15 			not implemented
16 		* Login with Discord
17 			OAuth2 is easy enough without the lib, see bingo.d line 340ish-380ish.
18 	)
19 
20 	History:
21 		Started April 20, 2024.
22 +/
23 module arsd.discord;
24 
25 // FIXME: it thought it was still alive but showed as not online and idk why. maybe setPulseCallback stopped triggering?
26 
27 // FIXME: Secure Connect Failed sometimes on trying to reconnect, should prolly just try again after a short period, or ditch the whole thing if reconnectAndResume and try fresh
28 
29 // FIXME: User-Agent: DiscordBot ($url, $versionNumber)
30 
31 import arsd.http2;
32 import arsd.jsvar;
33 
34 import arsd.core;
35 
36 import core.time;
37 
38 static assert(use_arsd_core);
39 
40 /++
41 	Base class to represent some object on Discord, e.g. users, channels, etc., through its subclasses.
42 
43 
44 	Among its implementations are:
45 
46 	$(LIST
47 		* [DiscordChannel]
48 		* [DiscordUser]
49 		* [DiscordRole]
50 	)
51 +/
52 abstract class DiscordEntity {
53 	private DiscordRestApi api;
54 	private string id_;
55 
56 	protected this(DiscordRestApi api, string id) {
57 		this.api = api;
58 		this.id_ = id;
59 	}
60 
61 	override string toString() {
62 		return restType ~ "/" ~ id;
63 	}
64 
65 	/++
66 
67 	+/
68 	abstract string restType();
69 
70 	/++
71 
72 	+/
73 	final string id() {
74 		return id_;
75 	}
76 
77 	/++
78 		Gives easy access to its rest api through [arsd.http2.HttpApiClient]'s dynamic dispatch functions.
79 
80 	+/
81 	DiscordRestApi.RestBuilder rest() {
82 		return api.rest[restType()][id()];
83 	}
84 }
85 
86 /++
87 	Represents something mentionable on Discord with `@name` - roles and users.
88 +/
89 abstract class DiscordMentionable : DiscordEntity {
90 	this(DiscordRestApi api, string id) {
91 		super(api, id);
92 	}
93 }
94 
95 /++
96 	https://discord.com/developers/docs/resources/channel
97 +/
98 class DiscordChannel : DiscordEntity {
99 	this(DiscordRestApi api, string id) {
100 		super(api, id);
101 	}
102 
103 	override string restType() {
104 		return "channels";
105 	}
106 
107 	void sendMessage(string message) {
108 		if(message.length == 0)
109 			message = "empty message specified";
110 		var msg = var.emptyObject;
111 		msg.content = message;
112 		rest.messages.POST(msg).result;
113 	}
114 }
115 
116 /++
117 
118 +/
119 class DiscordRole : DiscordMentionable {
120 	this(DiscordRestApi api, DiscordGuild guild, string id) {
121 		this.guild_ = guild;
122 		super(api, id);
123 	}
124 
125 	private DiscordGuild guild_;
126 
127 	/++
128 
129 	+/
130 	DiscordGuild guild() {
131 		return guild_;
132 	}
133 
134 	override string restType() {
135 		return "roles";
136 	}
137 }
138 
139 /++
140 	https://discord.com/developers/docs/resources/user
141 +/
142 class DiscordUser : DiscordMentionable {
143 	this(DiscordRestApi api, string id) {
144 		super(api, id);
145 	}
146 
147 	private var cachedData;
148 
149 	// DiscordGuild selectedGuild;
150 
151 	override string restType() {
152 		return "users";
153 	}
154 
155 	void addRole(DiscordRole role) {
156 		// PUT /guilds/{guild.id}/members/{user.id}/roles/{role.id}
157 
158 		auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id];
159 		//writeln(thing.toUri);
160 
161 		auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].PUT().result;
162 	}
163 
164 	void removeRole(DiscordRole role) {
165 		// DELETE /guilds/{guild.id}/members/{user.id}/roles/{role.id}
166 
167 		auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id];
168 		//writeln(thing.toUri);
169 
170 		auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].DELETE().result;
171 	}
172 
173 	private DiscordChannel dmChannel_;
174 
175 	DiscordChannel dmChannel() {
176 		if(dmChannel_ is null) {
177 			var obj = var.emptyObject;
178 			obj.recipient_id = this.id;
179 			var result = this.api.rest.users["@me"].channels.POST(obj).result;
180 
181 			dmChannel_ = new DiscordChannel(api, result.id.get!string);//, result);
182 		}
183 		return dmChannel_;
184 	}
185 
186 	void sendMessage(string what) {
187 		dmChannel.sendMessage(what);
188 	}
189 }
190 
191 /++
192 
193 +/
194 class DiscordGuild : DiscordEntity {
195 	this(DiscordRestApi api, string id) {
196 		super(api, id);
197 	}
198 
199 	override string restType() {
200 		return "guilds";
201 	}
202 
203 }
204 
205 
206 enum InteractionType {
207 	PING = 1,
208 	APPLICATION_COMMAND = 2, // the main one
209 	MESSAGE_COMPONENT = 3,
210 	APPLICATION_COMMAND_AUTOCOMPLETE = 4,
211 	MODAL_SUBMIT = 5,
212 }
213 
214 
215 /++
216 	You can create your own slash command handlers by subclassing this and writing methods like
217 
218 	It will register for you when you connect and call your function when things come in.
219 
220 	See_Also:
221 		https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands
222 +/
223 class SlashCommandHandler {
224 	enum ApplicationCommandOptionType {
225 		INVALID = 0, // my addition
226 		SUB_COMMAND = 1,
227 		SUB_COMMAND_GROUP = 2,
228 		STRING = 3,
229 		INTEGER = 4, // double's int part
230 		BOOLEAN = 5,
231 		USER = 6,
232 		CHANNEL = 7,
233 		ROLE = 8,
234 		MENTIONABLE = 9,
235 		NUMBER = 10, // double
236 		ATTACHMENT = 11,
237 	}
238 
239 	/++
240 		This takes the child type into the parent so we can reflect over your added methods.
241 		to initialize the reflection info to send to Discord. If you subclass your subclass,
242 		make sure the grandchild constructor does `super(); registerAll(this);` to add its method
243 		to the list too, but if you only have one level of child, the compiler will auto-generate
244 		a constructor for you that calls this.
245 	+/
246 	protected this(this This)() {
247 		registerAll(cast(This) this);
248 	}
249 
250 	/++
251 
252 	+/
253 	static class InteractionReplyHelper {
254 		private DiscordRestApi api;
255 		private CommandArgs commandArgs;
256 
257 		private this(DiscordRestApi api, CommandArgs commandArgs) {
258 			this.api = api;
259 			this.commandArgs = commandArgs;
260 
261 		}
262 
263 		/++
264 
265 		+/
266 		void reply(string message, bool ephemeral = false) scope {
267 			replyLowLevel(message, ephemeral);
268 		}
269 
270 		/++
271 
272 		+/
273 		void replyWithError(scope const(char)[] message) scope {
274 			if(message.length == 0)
275 				message = "I am error.";
276 			replyLowLevel(message.idup, true);
277 		}
278 
279 		enum MessageFlags : uint {
280 			SUPPRESS_EMBEDS        = (1 << 2), // skip the embedded content
281 			EPHEMERAL              = (1 << 6), // only visible to you
282 			LOADING                = (1 << 7), // the bot is "thinking"
283 			SUPPRESS_NOTIFICATIONS = (1 << 12) // skip push/desktop notifications
284 		}
285 
286 		void replyLowLevel(string message, bool ephemeral) scope {
287 			if(message.length == 0)
288 				message = "empty message";
289 			var reply = var.emptyObject;
290 			reply.type = 4; // chat response in message. 5 can be answered quick and edited later if loading, 6 if quick answer, no loading message
291 			var replyData = var.emptyObject;
292 			replyData.content = message;
293 			replyData.flags = ephemeral ? (1 << 6) : 0;
294 			reply.data = replyData;
295 			try {
296 				var result = api.rest.
297 					interactions[commandArgs.interactionId][commandArgs.interactionToken].callback
298 					.POST(reply).result;
299 				// writeln(result.toString);
300 			} catch(Exception e) {
301 				// import std.stdio; writeln(commandArgs);
302 				logSwallowedException(e);
303 			}
304 		}
305 	}
306 
307 
308 	private bool alreadyRegistered;
309 	private void register(DiscordRestApi api, string appId) {
310 		if(alreadyRegistered)
311 			return;
312 		auto result = api.rest.applications[appId].commands.PUT(jsonArrayForDiscord).result;
313 		alreadyRegistered = true;
314 	}
315 
316 	private static struct CommandArgs {
317 		InteractionType interactionType;
318 		string interactionToken;
319 		string interactionId;
320 		string guildId;
321 		string channelId;
322 
323 		var interactionData;
324 
325 		var member;
326 		var channel;
327 	}
328 
329 	private {
330 
331 		static void validateDiscordSlashCommandName(string name) {
332 			foreach(ch; name) {
333 				if(ch != '_' && !(ch >= 'a' && ch <= 'z'))
334 					throw new InvalidArgumentsException("name", "discord names must be all lower-case with only letters and underscores", LimitedVariant(name));
335 			}
336 		}
337 
338 		static HandlerInfo makeHandler(alias handler, T)(T slashThis) {
339 			HandlerInfo info;
340 
341 			// must be all lower case!
342 			info.name = __traits(identifier, handler);
343 
344 			validateDiscordSlashCommandName(info.name);
345 
346 			var cmd = var.emptyObject();
347 			cmd.name = info.name;
348 			version(D_OpenD)
349 				cmd.description = __traits(docComment, handler);
350 			else
351 				cmd.description = "";
352 
353 			if(cmd.description == "")
354 				cmd.description = "Can't be blank for CHAT_INPUT";
355 
356 			cmd.type = 1; // CHAT_INPUT
357 
358 			var optionsArray = var.emptyArray;
359 
360 			static if(is(typeof(handler) Params == __parameters)) {}
361 
362 			string[] names;
363 
364 			// extract parameters
365 			foreach(idx, param; Params) {
366 				var option = var.emptyObject;
367 				auto name = __traits(identifier, Params[idx .. idx + 1]);
368 				validateDiscordSlashCommandName(name);
369 				names ~= name;
370 				option.name = name;
371 				option.description = "desc";
372 				option.type = cast(int) applicationComandOptionTypeFromDType!(param);
373 				// can also add "choices" which limit it to just specific members
374 				if(option.type) {
375 					optionsArray ~= option;
376 				}
377 			}
378 
379 			cmd.options = optionsArray;
380 
381 			info.jsonObjectForDiscord = cmd;
382 			info.handler = (CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api) {
383 				// extract args
384 				// call the function
385 				// send the appropriate reply
386 				static if(is(typeof(handler) Return == return)) {
387 					static if(is(Return == void)) {
388 						__traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof);
389 						sendHandlerReply("OK", replyHelper, true);
390 					} else {
391 						sendHandlerReply(__traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof), replyHelper, false);
392 					}
393 				} else static assert(0);
394 			};
395 
396 			return info;
397 		}
398 
399 		static auto fargsFromJson(Params...)(DiscordRestApi api, string[] names, var obj, CommandArgs args/*, Params defaults*/) {
400 			static struct Holder {
401 				// FIXME: default params no work
402 				Params params;// = defaults;
403 			}
404 
405 			Holder holder;
406 			foreach(idx, ref param; holder.params) {
407 				setParamFromJson(param, names[idx], api, obj, args);
408 			}
409 
410 			return holder;
411 
412 /+
413 
414 ync def something(interaction:discord.Interaction):
415     await interaction.response.send_message("NOTHING",ephemeral=True)
416     # optional (if you want to edit the response later,delete it, or send a followup)
417     await interaction.edit_original_response(content="Something")
418     await interaction.followup.send("This is a message too.",ephemeral=True)
419     await interaction.delete_original_response()
420     # if you have deleted the original response you can't edit it or send a followup after it
421 +/
422 
423 		}
424 
425 
426 // {"t":"INTERACTION_CREATE","s":7,"op":0,"d":{"version":1,"type":2,"token":"aW50ZXJhY3Rpb246MTIzMzIyNzE0OTU0NTE3NzE2OTp1Sjg5RE0wMzJiWER2UDRURk5XSWRaUTJtMExBeklWNEtpVEZocTQ4a0VZQ3NWUm9ta3g2SG1JbTBzUm1yWmlUNzQ3eWxpc0FnM0RzUzZHaWtENnRXUDBsdUhERElKSWlaYlFWMlNsZlZXTlFkU3VVQUVWU01PNU9TNFQ5cmFQSw",
427 
428 // "member":{"user":{"username":"wrathful_vengeance_god_unleashed","public_flags":0,"id":"395786107780071424","global_name":"adr","discriminator":"0","clan":null,"avatar_decoration_data":null,"avatar":"e3c2aacef7920d3a661a19aaab969337"},"unusual_dm_activity_until":null,"roles":[],"premium_since":null,"permissions":"1125899906842623","pending":false,"nick":"adr","mute":false,"joined_at":"2022-08-24T12:37:21.252000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null},
429 
430 // "locale":"en-US","id":"1233227149545177169","guild_locale":"en-US","guild_id":"1011977515109187704",
431 // "guild":{"locale":"en-US","id":"1011977515109187704","features":[]},
432 // "entitlements":[],"entitlement_sku_ids":[],
433 // "data":{"type":1,"name":"hello","id":"1233221536522174535"},"channel_id":"1011977515109187707",
434 // "channel":{"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permissions":"1125899906842623","parent_id":"1011977515109187705","nsfw":false,"name":"general","last_message_id":"1233227103844171806","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0},
435 // "application_id":"1223724819821105283","app_permissions":"1122573558992465"}}
436 
437 
438 		template applicationComandOptionTypeFromDType(T) {
439 			static if(is(T == SendingUser) || is(T == SendingChannel))
440 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INVALID; // telling it to skip sending this to discord, it purely internal
441 			else static if(is(T == DiscordRole))
442 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.ROLE;
443 			else static if(is(T == string))
444 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.STRING;
445 			else static if(is(T == bool))
446 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.BOOLEAN;
447 			else static if(is(T : const long))
448 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INTEGER;
449 			else static if(is(T : const double))
450 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.NUMBER;
451 			else
452 				static assert(0, T.stringof);
453 		}
454 
455 		static var getOptionForName(var obj, string name) {
456 			foreach(option; obj.options)
457 				if(option.name == name)
458 					return option;
459 			return var.init;
460 		}
461 
462 		static void setParamFromJson(T)(ref T param, string name, DiscordRestApi api, var obj, CommandArgs args) {
463 			static if(is(T == SendingUser)) {
464 				param = new SendingUser(api, args.member.user.id.get!string, obj.member.user);
465 			} else static if(is(T == SendingChannel)) {
466 				param = new SendingChannel(api, args.channel.id.get!string, obj.channel);
467 			} else static if(is(T == string)) {
468 				var option = getOptionForName(obj, name);
469 				if(option.type == cast(int) ApplicationCommandOptionType.STRING)
470 					param = option.value.get!(typeof(param));
471 			} else static if(is(T == bool)) {
472 				var option = getOptionForName(obj, name);
473 				if(option.type == cast(int) ApplicationCommandOptionType.BOOLEAN)
474 					param = option.value.get!(typeof(param));
475 			} else static if(is(T : const long)) {
476 				var option = getOptionForName(obj, name);
477 				if(option.type == cast(int) ApplicationCommandOptionType.INTEGER)
478 					param = option.value.get!(typeof(param));
479 			} else static if(is(T : const double)) {
480 				var option = getOptionForName(obj, name);
481 				if(option.type == cast(int) ApplicationCommandOptionType.NUMBER)
482 					param = option.value.get!(typeof(param));
483 			} else static if(is(T == DiscordRole)) {
484 
485 //"data":{"type":1,"resolved":{"roles":{"1223727548295544865":{"unicode_emoji":null,"tags":{"bot_id":"1223724819821105283"},"position":1,"permissions":"3088","name":"OpenD","mentionable":false,"managed":true,"id":"1223727548295544865","icon":null,"hoist":false,"flags":0,"description":null,"color":0}}},"options":[{"value":"1223727548295544865","type":8,"name":"role"}],"name":"add_role","id":"1234130839315677226"},"channel_id":"1011977515109187707","channel":{"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permissions":"1125899906842623","parent_id":"1011977515109187705","nsfw":false,"name":"general","last_message_id":"1234249771745804399","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0},"application_id":"1223724819821105283","app_permissions":"1122573558992465"}}
486 
487 // resolved gives you some precache info
488 
489 				var option = getOptionForName(obj, name);
490 				if(option.type == cast(int) ApplicationCommandOptionType.ROLE)
491 					param = new DiscordRole(api, new DiscordGuild(api, args.guildId), option.value.get!string);
492 				else
493 					param = null;
494 			} else {
495 				static assert(0, "Bad type " ~ T.stringof);
496 			}
497 		}
498 
499 		static void sendHandlerReply(T)(T ret, scope InteractionReplyHelper replyHelper, bool ephemeral) {
500 			replyHelper.reply(toStringInternal(ret), ephemeral);
501 		}
502 
503 		void registerAll(T)(T t) {
504 			assert(t !is null);
505 			foreach(memberName; __traits(derivedMembers, T))
506 				static if(memberName != "__ctor") { // FIXME
507 					HandlerInfo hi = makeHandler!(__traits(getMember, T, memberName))(t);
508 					registerFromRuntimeInfo(hi);
509 				}
510 		}
511 
512 		void registerFromRuntimeInfo(HandlerInfo info) {
513 			handlers[info.name] = info.handler;
514 			if(jsonArrayForDiscord is var.init)
515 				jsonArrayForDiscord = var.emptyArray;
516 			jsonArrayForDiscord ~= info.jsonObjectForDiscord;
517 		}
518 
519 		alias InternalHandler = void delegate(CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api);
520 		struct HandlerInfo {
521 			string name;
522 			InternalHandler handler;
523 			var jsonObjectForDiscord;
524 		}
525 		InternalHandler[string] handlers;
526 		var jsonArrayForDiscord;
527 	}
528 }
529 
530 /++
531 	A SendingUser is a special DiscordUser type that just represents the person who sent the message.
532 
533 	It exists so you can use it in a function parameter list that is auto-mapped to a message handler.
534 +/
535 class SendingUser : DiscordUser {
536 	private this(DiscordRestApi api, string id, var initialCache) {
537 		super(api, id);
538 	}
539 }
540 
541 class SendingChannel : DiscordChannel {
542 	private this(DiscordRestApi api, string id, var initialCache) {
543 		super(api, id);
544 	}
545 }
546 
547 // SendingChannel
548 // SendingMessage
549 
550 /++
551 	Use as a UDA
552 
553 	A file of choices for the given option. The exact interpretation depends on the type but the general rule is one option per line, id or name.
554 
555 	FIXME: watch the file for changes for auto-reload and update on the discord side
556 
557 	FIXME: NOT IMPLEMENTED
558 +/
559 struct ChoicesFromFile {
560 	string filename;
561 }
562 
563 /++
564 	Most the magic is inherited from [arsd.http2.HttpApiClient].
565 +/
566 class DiscordRestApi : HttpApiClient!() {
567 	/++
568 		Creates an API client.
569 
570 		Params:
571 			token = the bot authorization token you got from Discord
572 			yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work.
573 			yourBotVersion = version number (or whatever) for your bot, used as part of the user-agent. Should not be null according to the docs but it doesn't seem to matter in practice.
574 	+/
575 	this(string botToken, string yourBotUrl, string yourBotVersion) {
576 		this.authType = "Bot";
577 		super("https://discord.com/api/v10/", botToken);
578 	}
579 }
580 
581 /++
582 
583 +/
584 class DiscordGatewayConnection {
585 	private WebSocket websocket_;
586 	private long lastSequenceNumberReceived;
587 	private string token;
588 	private DiscordRestApi api_;
589 
590 	/++
591 		An instance to the REST api object associated with your connection.
592 	+/
593 	public final DiscordRestApi api() {
594 		return this.api_;
595 	}
596 
597 	/++
598 
599 	+/
600 	protected final WebSocket websocket() {
601 		return websocket_;
602 	}
603 
604 	// https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes
605 	enum OpCode {
606 		Dispatch = 0, // recv
607 		Heartbeat = 1, // s/r
608 		Identify = 2, // s
609 		PresenceUpdate = 3, // s
610 		VoiceStateUpdate = 4, // s
611 		Resume = 6, // s
612 		Reconnect = 7, // r
613 		RequestGuildMembers = 8, // s
614 		InvalidSession = 9, // r - you should reconnect and identify/resume
615 		Hello = 10, // r
616 		HeartbeatAck = 11, // r
617 	}
618 
619 	enum DisconnectCodes {
620 		UnknownError = 4000, // t
621 		UnknownOpcode = 4001, // t (user error)
622 		DecodeError = 4002, // t (user error)
623 		NotAuthenticated = 4003, // t (user error)
624 		AuthenticationFailed = 4004, // f (user error)
625 		AlreadyAuthenticated = 4005, // t (user error)
626 		InvalidSeq = 4007, // t
627 		RateLimited = 4008, // t
628 		SessionTimedOut = 4009, // t
629 		InvalidShard = 4010, // f (user error)
630 		ShardingRequired = 4011, // f
631 		InvalidApiVersion = 4012, // f
632 		InvalidIntents = 4013, // f
633 		DisallowedIntents = 4014, // f
634 	}
635 
636 	private string cachedGatewayUrl;
637 
638 	/++
639 		Prepare a gateway connection. After you construct it, you still need to call [connect].
640 
641 		Params:
642 			token = the bot authorization token you got from Discord
643 			yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work.
644 			yourBotVersion = version number (or whatever) for your bot, used as part of the user-agent. Should not be null according to the docs but it doesn't seem to matter in practice.
645 	+/
646 	public this(string token, string yourBotUrl, string yourBotVersion) {
647 		this.token = token;
648 		this.api_ = new DiscordRestApi(token, yourBotUrl, yourBotVersion);
649 	}
650 
651 	/++
652 		Allows you to set up a subclass of [SlashCommandHandler] for handling discord slash commands.
653 	+/
654 	final void slashCommandHandler(SlashCommandHandler t) {
655 		if(slashCommandHandler_ !is null && t !is null)
656 			throw ArsdException!"SlashCommandHandler is already set"();
657 		slashCommandHandler_ = t;
658 		if(t && applicationId.length)
659 			t.register(api, applicationId);
660 	}
661 	private SlashCommandHandler slashCommandHandler_;
662 
663 	/++
664 
665 	+/
666 	protected void handleWebsocketClose(WebSocket.CloseEvent closeEvent) {
667 		logger.info(i"$(closeEvent.toString())");
668 
669 		if(heartbeatTimer)
670 			heartbeatTimer.cancel();
671 
672 		if(closeEvent.code == 1006 || closeEvent.code == 1001) {
673 			reconnectAndResume();
674 		} else if(closeEvent.code == 4004) {
675 			// auth failed
676 			throw new Exception("Authentication failed");
677 		} else {
678 			// otherwise, unless we were asked by the api user to close, let's try reconnecting
679 			// since discord just does discord things.
680 			websocket_ = null;
681 			connect();
682 		}
683 	}
684 
685 	/++
686 	+/
687 	void close() {
688 		close(1000, null);
689 	}
690 
691 	/// ditto
692 	void close(int reason, string reasonText) {
693 		if(heartbeatTimer)
694 			heartbeatTimer.cancel();
695 
696 		websocket_.onclose = null;
697 		websocket_.ontextmessage = null;
698 		websocket_.onbinarymessage = null;
699 		websocket.close(reason, reasonText);
700 		websocket_ = null;
701 	}
702 
703 	/++
704 	+/
705 	protected void handleWebsocketMessage(in char[] msg) {
706 		var m = var.fromJson(msg.idup);
707 
708 		OpCode op = cast(OpCode) m.op.get!int;
709 		var data = m.d;
710 
711 		switch(op) {
712 			case OpCode.Dispatch:
713 				// these are null if op != 0
714 				string eventName = m.t.get!string;
715 				long seqNumber = m.s.get!long;
716 
717 				if(seqNumber > lastSequenceNumberReceived)
718 					lastSequenceNumberReceived = seqNumber;
719 
720 				eventReceived(eventName, data);
721 			break;
722 			case OpCode.Hello:
723 				// the hello heartbeat_interval is in milliseconds
724 				if(slashCommandHandler_ !is null && applicationId.length)
725 					slashCommandHandler_.register(api, applicationId);
726 
727 				setHeartbeatInterval(data.heartbeat_interval.get!int);
728 			break;
729 			case OpCode.Heartbeat:
730 				sendHeartbeat();
731 			break;
732 			case OpCode.HeartbeatAck:
733 				mostRecentHeartbeatAckRecivedAt = MonoTime.currTime;
734 			break;
735 			case OpCode.Reconnect:
736 				logger.info(i"Reconnecting discord websocket");
737 
738 				this.close(4999, "Reconnect requested");
739 				reconnectAndResume();
740 			break;
741 			case OpCode.InvalidSession:
742 				logger.info(i"Starting new discord session");
743 
744 				close();
745 				connect(); // try starting a brand new session
746 			break;
747 			default:
748 				// ignored
749 		}
750 	}
751 
752 	protected void reconnectAndResume() {
753 		websocketConnectInLoop(Uri(this.resume_gateway_url));
754 
755 		var resumeData = var.emptyObject;
756 		resumeData.token = this.token;
757 		resumeData.session_id = this.session_id;
758 		resumeData.seq = lastSequenceNumberReceived;
759 
760 		sendWebsocketCommand(OpCode.Resume, resumeData);
761 
762 		// the close event will cancel the heartbeat and thus we need to restart it
763 		if(requestedHeartbeat)
764 			setHeartbeatInterval(requestedHeartbeat);
765 	}
766 
767 	/++
768 	+/
769 	protected void eventReceived(string eventName, var data) {
770 		// FIXME: any time i get an event i could prolly spin it off into an independent async task
771 		switch(eventName) {
772 			case "INTERACTION_CREATE":
773 				var member = data.member; // {"user":{"username":"wrathful_vengeance_god_unleashed","public_flags":0,"id":"395786107780071424","global_name":"adr","discriminator":"0","clan":null,"avatar_decoration_data":null,"avatar":"e3c2aacef7920d3a661a19aaab969337"},"unusual_dm_activity_until":null,"roles":[],"premium_since":null,"permissions":"1125899906842623","pending":false,"nick":"adr","mute":false,"joined_at":"2022-08-24T12:37:21.252000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null}
774 
775 				SlashCommandHandler.CommandArgs commandArgs;
776 
777 				commandArgs.interactionType = cast(InteractionType) data.type.get!int;
778 				commandArgs.interactionToken = data.token.get!string;
779 				commandArgs.interactionId = data.id.get!string;
780 				commandArgs.guildId = data.guild_id.get!string;
781 				commandArgs.channelId = data.channel_id.get!string;
782 				commandArgs.member = member;
783 				commandArgs.channel = data.channel;
784 
785 				commandArgs.interactionData = data.data;
786 				// data.data : type/name/id. can use this to determine what function to call. prolly will include other info too
787 				// "data":{"type":1,"name":"hello","id":"1233221536522174535"}
788 
789 				// application_id and app_permissions and some others there too but that doesn't seem important
790 
791 				/+
792 					replies:
793 					https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type
794 				+/
795 
796 				scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs);
797 
798 				Exception throwExternally;
799 
800 				try {
801 					if(slashCommandHandler_ is null)
802 						throwExternally = ArsdException!"No slash commands registered"();
803 					else {
804 						auto cmdName = commandArgs.interactionData.name.get!string;
805 						if(auto pHandler = cmdName in slashCommandHandler_.handlers) {
806 							(*pHandler)(commandArgs, replyHelper, api);
807 						} else {
808 							throwExternally = ArsdException!"Unregistered slash command"(cmdName);
809 						}
810 					}
811 				} catch(ArsdExceptionBase e) {
812 					const(char)[] msg = e.message;
813 					if(msg.length == 0)
814 						msg = "I am error.";
815 
816 					e.getAdditionalPrintableInformation((string name, in char[] value) {
817 						msg ~= ("\n");
818 						msg ~= (name);
819 						msg ~= (": ");
820 						msg ~= (value);
821 					});
822 
823 					replyHelper.replyWithError(msg);
824 				} catch(Exception e) {
825 					replyHelper.replyWithError(e.message);
826 				}
827 
828 				if(throwExternally !is null)
829 					throw throwExternally;
830 			break;
831 			case "READY":
832 				this.session_id = data.session_id.get!string;
833 				this.resume_gateway_url = data.resume_gateway_url.get!string;
834 				this.applicationId_ = data.application.id.get!string;
835 
836 				if(slashCommandHandler_ !is null && applicationId.length)
837 					slashCommandHandler_.register(api, applicationId);
838 			break;
839 
840 			default:
841 		}
842 	}
843 
844 	private string session_id;
845 	private string resume_gateway_url;
846 	private string applicationId_;
847 
848 	/++
849 		Returns your application id. Only available after the connection is established.
850 	+/
851 	public string applicationId() {
852 		return applicationId_;
853 	}
854 
855 	private arsd.core.Timer heartbeatTimer;
856 	private int requestedHeartbeat;
857 	private bool requestedHeartbeatSet;
858 	//private int heartbeatsSent;
859 	//private int heartbeatAcksReceived;
860 	private MonoTime mostRecentHeartbeatAckRecivedAt;
861 
862 	protected void sendHeartbeat() {
863 		logger.info(i"heartbeat");
864 		sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived));
865 	}
866 
867 	private final void sendHeartbeatThunk() {
868 		this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat
869 		if(requestedHeartbeatSet == false) {
870 			heartbeatTimer.changeTime(requestedHeartbeat, true);
871 			requestedHeartbeatSet = true;
872 		} else {
873 			if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) {
874 				// throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info?
875 				websocket.close(1006, "heartbeat unanswered");
876 				reconnectAndResume();
877 			}
878 		}
879 	}
880 
881 	/++
882 	+/
883 	protected void setHeartbeatInterval(int msecs) {
884 		requestedHeartbeat = msecs;
885 		requestedHeartbeatSet = false;
886 
887 		if(heartbeatTimer is null) {
888 			heartbeatTimer = new arsd.core.Timer;
889 			heartbeatTimer.setPulseCallback(&sendHeartbeatThunk);
890 		}
891 
892 		// the first one is supposed to have random jitter
893 		// so we'll do that one-off (but with a non-zero time
894 		// since my timers don't like being run twice in one loop
895 		// iteration) then that first one will set the repeating time
896 		import arsd.random;
897 		auto firstBeat = arsd.random.uniform(10, msecs);
898 		heartbeatTimer.changeTime(firstBeat, false);
899 	}
900 
901 	/++
902 
903 	+/
904 	void sendWebsocketCommand(OpCode op, var d) {
905 		assert(websocket !is null, "call connect before sending commands");
906 
907 		var cmd = var.emptyObject;
908 		cmd.d = d;
909 		cmd.op = cast(int) op;
910 		websocket.send(cmd.toJson());
911 	}
912 
913 	/++
914 
915 	+/
916 	void connect() {
917 		assert(websocket is null, "do not call connect twice");
918 
919 		if(cachedGatewayUrl is null) {
920 			auto obj = api.rest.gateway.bot.GET().result;
921 			cachedGatewayUrl = obj.url.get!string;
922 		}
923 
924 		websocketConnectInLoop(Uri(cachedGatewayUrl));
925 
926 		var d = var.emptyObject;
927 		d.token = token;
928 			// FIXME?
929 		d.properties = [
930 			"os": "linux",
931 			"browser": "arsd.discord",
932 			"device": "arsd.discord",
933 		];
934 
935 		sendWebsocketCommand(OpCode.Identify, d);
936 	}
937 
938 	/+
939 		SocketOSException needs full reconnect
940 	+/
941 
942 	void websocketConnectInLoop(Uri uri) {
943 		// FIXME: if the connect fails we should set a timer and try
944 		// again, but if it fails then, quit. at least if it is not a websocket reply
945 		// cuz it could be discord went down or something.
946 
947 		import core.time;
948 		auto d = 1.seconds;
949 		int count = 0;
950 
951 		try_again:
952 
953 		this.websocket_ = new WebSocket(uri);
954 		websocket.onmessage = &handleWebsocketMessage;
955 		websocket.onclose = &handleWebsocketClose;
956 
957 		try {
958 			this.websocket_.connect();
959 		} catch(Exception e) {
960 			// it disconnects after 30 days rn w/
961 			// std.socket.SocketOSException@std/socket.d(2897): Unable to connect socket: Transport endpoint is already connected
962 			// and idk the root cause, it has invalid session at first
963 
964 			.destroy(this.websocket_);
965 
966 			logSwallowedException(e);
967 
968 			import core.thread;
969 			Thread.sleep(d);
970 			d *= 2;
971 			count++;
972 			if(count == 10)
973 				throw e;
974 
975 			goto try_again;
976 		}
977 	}
978 
979 
980 }
981 
982 class DiscordRpcConnection {
983 
984 	// this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config);
985 	// websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`);
986 	// writeln(websocket.waitForNextMessage.textData);
987 
988 	// these would tell me user names and ids when people join/leave but it needs authentication alas
989 
990 	/+
991 	websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
992     "guild_id": "SSSSSSSSSSSSSSSS",
993     "channel_id": "CCCCCCCCCCCCCCCCC"
994   },
995   "evt": "VOICE_STATE_CREATE",
996   "cmd": "SUBSCRIBE"
997 }`);
998 	writeln(websocket.waitForNextMessage.textData);
999 
1000 	websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
1001     "guild_id": "SSSSSSSSSSSSSSSS",
1002     "channel_id": "CCCCCCCCCCCCCCCCC"
1003   },
1004   "evt": "VOICE_STATE_DELETE",
1005   "cmd": "SUBSCRIBE"
1006 }`);
1007 
1008 		websocket.onmessage = delegate(in char[] msg) {
1009 			writeln(msg);
1010 
1011 			import arsd.jsvar;
1012 			var m = var.fromJson(msg.idup);
1013 			if(m.cmd == "DISPATCH") {
1014 				if(m.evt == "SPEAKING_START") {
1015 					//setSpeaking(m.data.user_id.get!ulong, true);
1016 				} else if(m.evt == "SPEAKING_STOP") {
1017 					//setSpeaking(m.data.user_id.get!ulong, false);
1018 				}
1019 			}
1020 		};
1021 
1022 
1023 	+/
1024 
1025 
1026 }