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 				writeln(e.toString());
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 			import std.conv; // FIXME
501 			replyHelper.reply(to!string(ret), ephemeral);
502 		}
503 
504 		void registerAll(T)(T t) {
505 			assert(t !is null);
506 			foreach(memberName; __traits(derivedMembers, T))
507 				static if(memberName != "__ctor") { // FIXME
508 					HandlerInfo hi = makeHandler!(__traits(getMember, T, memberName))(t);
509 					registerFromRuntimeInfo(hi);
510 				}
511 		}
512 
513 		void registerFromRuntimeInfo(HandlerInfo info) {
514 			handlers[info.name] = info.handler;
515 			if(jsonArrayForDiscord is var.init)
516 				jsonArrayForDiscord = var.emptyArray;
517 			jsonArrayForDiscord ~= info.jsonObjectForDiscord;
518 		}
519 
520 		alias InternalHandler = void delegate(CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api);
521 		struct HandlerInfo {
522 			string name;
523 			InternalHandler handler;
524 			var jsonObjectForDiscord;
525 		}
526 		InternalHandler[string] handlers;
527 		var jsonArrayForDiscord;
528 	}
529 }
530 
531 /++
532 	A SendingUser is a special DiscordUser type that just represents the person who sent the message.
533 
534 	It exists so you can use it in a function parameter list that is auto-mapped to a message handler.
535 +/
536 class SendingUser : DiscordUser {
537 	private this(DiscordRestApi api, string id, var initialCache) {
538 		super(api, id);
539 	}
540 }
541 
542 class SendingChannel : DiscordChannel {
543 	private this(DiscordRestApi api, string id, var initialCache) {
544 		super(api, id);
545 	}
546 }
547 
548 // SendingChannel
549 // SendingMessage
550 
551 /++
552 	Use as a UDA
553 
554 	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.
555 
556 	FIXME: watch the file for changes for auto-reload and update on the discord side
557 
558 	FIXME: NOT IMPLEMENTED
559 +/
560 struct ChoicesFromFile {
561 	string filename;
562 }
563 
564 /++
565 	Most the magic is inherited from [arsd.http2.HttpApiClient].
566 +/
567 class DiscordRestApi : HttpApiClient!() {
568 	/++
569 		Creates an API client.
570 
571 		Params:
572 			token = the bot authorization token you got from Discord
573 			yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work.
574 			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.
575 	+/
576 	this(string botToken, string yourBotUrl, string yourBotVersion) {
577 		this.authType = "Bot";
578 		super("https://discord.com/api/v10/", botToken);
579 	}
580 }
581 
582 /++
583 
584 +/
585 class DiscordGatewayConnection {
586 	private WebSocket websocket_;
587 	private long lastSequenceNumberReceived;
588 	private string token;
589 	private DiscordRestApi api_;
590 
591 	/++
592 		An instance to the REST api object associated with your connection.
593 	+/
594 	public final DiscordRestApi api() {
595 		return this.api_;
596 	}
597 
598 	/++
599 
600 	+/
601 	protected final WebSocket websocket() {
602 		return websocket_;
603 	}
604 
605 	// https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes
606 	enum OpCode {
607 		Dispatch = 0, // recv
608 		Heartbeat = 1, // s/r
609 		Identify = 2, // s
610 		PresenceUpdate = 3, // s
611 		VoiceStateUpdate = 4, // s
612 		Resume = 6, // s
613 		Reconnect = 7, // r
614 		RequestGuildMembers = 8, // s
615 		InvalidSession = 9, // r - you should reconnect and identify/resume
616 		Hello = 10, // r
617 		HeartbeatAck = 11, // r
618 	}
619 
620 	enum DisconnectCodes {
621 		UnknownError = 4000, // t
622 		UnknownOpcode = 4001, // t (user error)
623 		DecodeError = 4002, // t (user error)
624 		NotAuthenticated = 4003, // t (user error)
625 		AuthenticationFailed = 4004, // f (user error)
626 		AlreadyAuthenticated = 4005, // t (user error)
627 		InvalidSeq = 4007, // t
628 		RateLimited = 4008, // t
629 		SessionTimedOut = 4009, // t
630 		InvalidShard = 4010, // f (user error)
631 		ShardingRequired = 4011, // f
632 		InvalidApiVersion = 4012, // f
633 		InvalidIntents = 4013, // f
634 		DisallowedIntents = 4014, // f
635 	}
636 
637 	private string cachedGatewayUrl;
638 
639 	/++
640 		Prepare a gateway connection. After you construct it, you still need to call [connect].
641 
642 		Params:
643 			token = the bot authorization token you got from Discord
644 			yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work.
645 			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.
646 	+/
647 	public this(string token, string yourBotUrl, string yourBotVersion) {
648 		this.token = token;
649 		this.api_ = new DiscordRestApi(token, yourBotUrl, yourBotVersion);
650 	}
651 
652 	/++
653 		Allows you to set up a subclass of [SlashCommandHandler] for handling discord slash commands.
654 	+/
655 	final void slashCommandHandler(SlashCommandHandler t) {
656 		if(slashCommandHandler_ !is null && t !is null)
657 			throw ArsdException!"SlashCommandHandler is already set"();
658 		slashCommandHandler_ = t;
659 		if(t && applicationId.length)
660 			t.register(api, applicationId);
661 	}
662 	private SlashCommandHandler slashCommandHandler_;
663 
664 	/++
665 
666 	+/
667 	protected void handleWebsocketClose(WebSocket.CloseEvent closeEvent) {
668 		import std.stdio; writeln(closeEvent);
669 		if(heartbeatTimer)
670 			heartbeatTimer.cancel();
671 
672 		if(closeEvent.code == 1006 || closeEvent.code == 1001) {
673 			reconnectAndResume();
674 		} else {
675 			// otherwise, unless we were asked by the api user to close, let's try reconnecting
676 			// since discord just does discord things.
677 			websocket_ = null;
678 			connect();
679 		}
680 	}
681 
682 	/++
683 	+/
684 	void close() {
685 		close(1000, null);
686 	}
687 
688 	/// ditto
689 	void close(int reason, string reasonText) {
690 		if(heartbeatTimer)
691 			heartbeatTimer.cancel();
692 
693 		websocket_.onclose = null;
694 		websocket_.ontextmessage = null;
695 		websocket_.onbinarymessage = null;
696 		websocket.close(reason, reasonText);
697 		websocket_ = null;
698 	}
699 
700 	/++
701 	+/
702 	protected void handleWebsocketMessage(in char[] msg) {
703 		var m = var.fromJson(msg.idup);
704 
705 		OpCode op = cast(OpCode) m.op.get!int;
706 		var data = m.d;
707 
708 		switch(op) {
709 			case OpCode.Dispatch:
710 				// these are null if op != 0
711 				string eventName = m.t.get!string;
712 				long seqNumber = m.s.get!long;
713 
714 				if(seqNumber > lastSequenceNumberReceived)
715 					lastSequenceNumberReceived = seqNumber;
716 
717 				eventReceived(eventName, data);
718 			break;
719 			case OpCode.Hello:
720 				// the hello heartbeat_interval is in milliseconds
721 				if(slashCommandHandler_ !is null && applicationId.length)
722 					slashCommandHandler_.register(api, applicationId);
723 
724 				setHeartbeatInterval(data.heartbeat_interval.get!int);
725 			break;
726 			case OpCode.Heartbeat:
727 				sendHeartbeat();
728 			break;
729 			case OpCode.HeartbeatAck:
730 				mostRecentHeartbeatAckRecivedAt = MonoTime.currTime;
731 			break;
732 			case OpCode.Reconnect:
733 				writeln("reconnecting");
734 				this.close(4999, "Reconnect requested");
735 				reconnectAndResume();
736 			break;
737 			case OpCode.InvalidSession:
738 				writeln("starting new session");
739 
740 				close();
741 				connect(); // try starting a brand new session
742 			break;
743 			default:
744 				// ignored
745 		}
746 	}
747 
748 	protected void reconnectAndResume() {
749 		this.websocket_ = new WebSocket(Uri(this.resume_gateway_url));
750 
751 		websocket.onmessage = &handleWebsocketMessage;
752 		websocket.onclose = &handleWebsocketClose;
753 
754 		websocketConnectInLoop();
755 
756 		var resumeData = var.emptyObject;
757 		resumeData.token = this.token;
758 		resumeData.session_id = this.session_id;
759 		resumeData.seq = lastSequenceNumberReceived;
760 
761 		sendWebsocketCommand(OpCode.Resume, resumeData);
762 
763 		// the close event will cancel the heartbeat and thus we need to restart it
764 		if(requestedHeartbeat)
765 			setHeartbeatInterval(requestedHeartbeat);
766 	}
767 
768 	/++
769 	+/
770 	protected void eventReceived(string eventName, var data) {
771 		// FIXME: any time i get an event i could prolly spin it off into an independent async task
772 		switch(eventName) {
773 			case "INTERACTION_CREATE":
774 				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}
775 
776 				SlashCommandHandler.CommandArgs commandArgs;
777 
778 				commandArgs.interactionType = cast(InteractionType) data.type.get!int;
779 				commandArgs.interactionToken = data.token.get!string;
780 				commandArgs.interactionId = data.id.get!string;
781 				commandArgs.guildId = data.guild_id.get!string;
782 				commandArgs.channelId = data.channel_id.get!string;
783 				commandArgs.member = member;
784 				commandArgs.channel = data.channel;
785 
786 				commandArgs.interactionData = data.data;
787 				// data.data : type/name/id. can use this to determine what function to call. prolly will include other info too
788 				// "data":{"type":1,"name":"hello","id":"1233221536522174535"}
789 
790 				// application_id and app_permissions and some others there too but that doesn't seem important
791 
792 				/+
793 					replies:
794 					https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type
795 				+/
796 
797 				scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs);
798 
799 				Exception throwExternally;
800 
801 				try {
802 					if(slashCommandHandler_ is null)
803 						throwExternally = ArsdException!"No slash commands registered"();
804 					else {
805 						auto cmdName = commandArgs.interactionData.name.get!string;
806 						if(auto pHandler = cmdName in slashCommandHandler_.handlers) {
807 							(*pHandler)(commandArgs, replyHelper, api);
808 						} else {
809 							throwExternally = ArsdException!"Unregistered slash command"(cmdName);
810 						}
811 					}
812 				} catch(ArsdExceptionBase e) {
813 					const(char)[] msg = e.message;
814 					if(msg.length == 0)
815 						msg = "I am error.";
816 
817 					e.getAdditionalPrintableInformation((string name, in char[] value) {
818 						msg ~= ("\n");
819 						msg ~= (name);
820 						msg ~= (": ");
821 						msg ~= (value);
822 					});
823 
824 					replyHelper.replyWithError(msg);
825 				} catch(Exception e) {
826 					replyHelper.replyWithError(e.message);
827 				}
828 
829 				if(throwExternally !is null)
830 					throw throwExternally;
831 			break;
832 			case "READY":
833 				this.session_id = data.session_id.get!string;
834 				this.resume_gateway_url = data.resume_gateway_url.get!string;
835 				this.applicationId_ = data.application.id.get!string;
836 
837 				if(slashCommandHandler_ !is null && applicationId.length)
838 					slashCommandHandler_.register(api, applicationId);
839 			break;
840 
841 			default:
842 		}
843 	}
844 
845 	private string session_id;
846 	private string resume_gateway_url;
847 	private string applicationId_;
848 
849 	/++
850 		Returns your application id. Only available after the connection is established.
851 	+/
852 	public string applicationId() {
853 		return applicationId_;
854 	}
855 
856 	private arsd.core.Timer heartbeatTimer;
857 	private int requestedHeartbeat;
858 	private bool requestedHeartbeatSet;
859 	//private int heartbeatsSent;
860 	//private int heartbeatAcksReceived;
861 	private MonoTime mostRecentHeartbeatAckRecivedAt;
862 
863 	protected void sendHeartbeat() {
864 	arsd.core.writeln("sendHeartbeat");
865 		sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived));
866 	}
867 
868 	private final void sendHeartbeatThunk() {
869 		this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat
870 		if(requestedHeartbeatSet == false) {
871 			heartbeatTimer.changeTime(requestedHeartbeat, true);
872 			requestedHeartbeatSet = true;
873 		} else {
874 			if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) {
875 				// throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info?
876 				websocket.close(1006, "heartbeat unanswered");
877 				reconnectAndResume();
878 			}
879 		}
880 	}
881 
882 	/++
883 	+/
884 	protected void setHeartbeatInterval(int msecs) {
885 		requestedHeartbeat = msecs;
886 		requestedHeartbeatSet = false;
887 
888 		if(heartbeatTimer is null) {
889 			heartbeatTimer = new arsd.core.Timer;
890 			heartbeatTimer.setPulseCallback(&sendHeartbeatThunk);
891 		}
892 
893 		// the first one is supposed to have random jitter
894 		// so we'll do that one-off (but with a non-zero time
895 		// since my timers don't like being run twice in one loop
896 		// iteration) then that first one will set the repeating time
897 		import std.random;
898 		auto firstBeat = std.random.uniform(10, msecs);
899 		heartbeatTimer.changeTime(firstBeat, false);
900 	}
901 
902 	/++
903 
904 	+/
905 	void sendWebsocketCommand(OpCode op, var d) {
906 		assert(websocket !is null, "call connect before sending commands");
907 
908 		var cmd = var.emptyObject;
909 		cmd.d = d;
910 		cmd.op = cast(int) op;
911 		websocket.send(cmd.toJson());
912 	}
913 
914 	/++
915 
916 	+/
917 	void connect() {
918 		assert(websocket is null, "do not call connect twice");
919 
920 		if(cachedGatewayUrl is null) {
921 			auto obj = api.rest.gateway.bot.GET().result;
922 			cachedGatewayUrl = obj.url.get!string;
923 		}
924 
925 		this.websocket_ = new WebSocket(Uri(cachedGatewayUrl));
926 
927 		websocket.onmessage = &handleWebsocketMessage;
928 		websocket.onclose = &handleWebsocketClose;
929 
930 		websocketConnectInLoop();
931 
932 		var d = var.emptyObject;
933 		d.token = token;
934 			// FIXME?
935 		d.properties = [
936 			"os": "linux",
937 			"browser": "arsd.discord",
938 			"device": "arsd.discord",
939 		];
940 
941 		sendWebsocketCommand(OpCode.Identify, d);
942 	}
943 
944 	void websocketConnectInLoop() {
945 		// FIXME: if the connect fails we should set a timer and try
946 		// again, but if it fails then, quit. at least if it is not a websocket reply
947 		// cuz it could be discord went down or something.
948 
949 		import core.time;
950 		auto d = 1.seconds;
951 		int count = 0;
952 
953 		try_again:
954 
955 		try {
956 			this.websocket_.connect();
957 		} catch(Exception e) {
958 			import core.thread;
959 			Thread.sleep(d);
960 			d *= 2;
961 			count++;
962 			if(count == 10)
963 				throw e;
964 
965 			goto try_again;
966 		}
967 	}
968 
969 
970 }
971 
972 class DiscordRpcConnection {
973 
974 	// this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config);
975 	// websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`);
976 	// writeln(websocket.waitForNextMessage.textData);
977 
978 	// these would tell me user names and ids when people join/leave but it needs authentication alas
979 
980 	/+
981 	websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
982     "guild_id": "SSSSSSSSSSSSSSSS",
983     "channel_id": "CCCCCCCCCCCCCCCCC"
984   },
985   "evt": "VOICE_STATE_CREATE",
986   "cmd": "SUBSCRIBE"
987 }`);
988 	writeln(websocket.waitForNextMessage.textData);
989 
990 	websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
991     "guild_id": "SSSSSSSSSSSSSSSS",
992     "channel_id": "CCCCCCCCCCCCCCCCC"
993   },
994   "evt": "VOICE_STATE_DELETE",
995   "cmd": "SUBSCRIBE"
996 }`);
997 
998 		websocket.onmessage = delegate(in char[] msg) {
999 			writeln(msg);
1000 
1001 			import arsd.jsvar;
1002 			var m = var.fromJson(msg.idup);
1003 			if(m.cmd == "DISPATCH") {
1004 				if(m.evt == "SPEAKING_START") {
1005 					//setSpeaking(m.data.user_id.get!ulong, true);
1006 				} else if(m.evt == "SPEAKING_STOP") {
1007 					//setSpeaking(m.data.user_id.get!ulong, false);
1008 				}
1009 			}
1010 		};
1011 
1012 
1013 	+/
1014 
1015 
1016 }