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