The OpenD Programming Language

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 {
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 				logger.info(i"Reconnecting discord websocket");
734 
735 				this.close(4999, "Reconnect requested");
736 				reconnectAndResume();
737 			break;
738 			case OpCode.InvalidSession:
739 				logger.info(i"Starting new discord session");
740 
741 				close();
742 				connect(); // try starting a brand new session
743 			break;
744 			default:
745 				// ignored
746 		}
747 	}
748 
749 	protected void reconnectAndResume() {
750 		websocketConnectInLoop(Uri(this.resume_gateway_url));
751 
752 		var resumeData = var.emptyObject;
753 		resumeData.token = this.token;
754 		resumeData.session_id = this.session_id;
755 		resumeData.seq = lastSequenceNumberReceived;
756 
757 		sendWebsocketCommand(OpCode.Resume, resumeData);
758 
759 		// the close event will cancel the heartbeat and thus we need to restart it
760 		if(requestedHeartbeat)
761 			setHeartbeatInterval(requestedHeartbeat);
762 	}
763 
764 	/++
765 	+/
766 	protected void eventReceived(string eventName, var data) {
767 		// FIXME: any time i get an event i could prolly spin it off into an independent async task
768 		switch(eventName) {
769 			case "INTERACTION_CREATE":
770 				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}
771 
772 				SlashCommandHandler.CommandArgs commandArgs;
773 
774 				commandArgs.interactionType = cast(InteractionType) data.type.get!int;
775 				commandArgs.interactionToken = data.token.get!string;
776 				commandArgs.interactionId = data.id.get!string;
777 				commandArgs.guildId = data.guild_id.get!string;
778 				commandArgs.channelId = data.channel_id.get!string;
779 				commandArgs.member = member;
780 				commandArgs.channel = data.channel;
781 
782 				commandArgs.interactionData = data.data;
783 				// data.data : type/name/id. can use this to determine what function to call. prolly will include other info too
784 				// "data":{"type":1,"name":"hello","id":"1233221536522174535"}
785 
786 				// application_id and app_permissions and some others there too but that doesn't seem important
787 
788 				/+
789 					replies:
790 					https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type
791 				+/
792 
793 				scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs);
794 
795 				Exception throwExternally;
796 
797 				try {
798 					if(slashCommandHandler_ is null)
799 						throwExternally = ArsdException!"No slash commands registered"();
800 					else {
801 						auto cmdName = commandArgs.interactionData.name.get!string;
802 						if(auto pHandler = cmdName in slashCommandHandler_.handlers) {
803 							(*pHandler)(commandArgs, replyHelper, api);
804 						} else {
805 							throwExternally = ArsdException!"Unregistered slash command"(cmdName);
806 						}
807 					}
808 				} catch(ArsdExceptionBase e) {
809 					const(char)[] msg = e.message;
810 					if(msg.length == 0)
811 						msg = "I am error.";
812 
813 					e.getAdditionalPrintableInformation((string name, in char[] value) {
814 						msg ~= ("\n");
815 						msg ~= (name);
816 						msg ~= (": ");
817 						msg ~= (value);
818 					});
819 
820 					replyHelper.replyWithError(msg);
821 				} catch(Exception e) {
822 					replyHelper.replyWithError(e.message);
823 				}
824 
825 				if(throwExternally !is null)
826 					throw throwExternally;
827 			break;
828 			case "READY":
829 				this.session_id = data.session_id.get!string;
830 				this.resume_gateway_url = data.resume_gateway_url.get!string;
831 				this.applicationId_ = data.application.id.get!string;
832 
833 				if(slashCommandHandler_ !is null && applicationId.length)
834 					slashCommandHandler_.register(api, applicationId);
835 			break;
836 
837 			default:
838 		}
839 	}
840 
841 	private string session_id;
842 	private string resume_gateway_url;
843 	private string applicationId_;
844 
845 	/++
846 		Returns your application id. Only available after the connection is established.
847 	+/
848 	public string applicationId() {
849 		return applicationId_;
850 	}
851 
852 	private arsd.core.Timer heartbeatTimer;
853 	private int requestedHeartbeat;
854 	private bool requestedHeartbeatSet;
855 	//private int heartbeatsSent;
856 	//private int heartbeatAcksReceived;
857 	private MonoTime mostRecentHeartbeatAckRecivedAt;
858 
859 	protected void sendHeartbeat() {
860 		logger.info(i"heartbeat");
861 		sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived));
862 	}
863 
864 	private final void sendHeartbeatThunk() {
865 		this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat
866 		if(requestedHeartbeatSet == false) {
867 			heartbeatTimer.changeTime(requestedHeartbeat, true);
868 			requestedHeartbeatSet = true;
869 		} else {
870 			if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) {
871 				// throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info?
872 				websocket.close(1006, "heartbeat unanswered");
873 				reconnectAndResume();
874 			}
875 		}
876 	}
877 
878 	/++
879 	+/
880 	protected void setHeartbeatInterval(int msecs) {
881 		requestedHeartbeat = msecs;
882 		requestedHeartbeatSet = false;
883 
884 		if(heartbeatTimer is null) {
885 			heartbeatTimer = new arsd.core.Timer;
886 			heartbeatTimer.setPulseCallback(&sendHeartbeatThunk);
887 		}
888 
889 		// the first one is supposed to have random jitter
890 		// so we'll do that one-off (but with a non-zero time
891 		// since my timers don't like being run twice in one loop
892 		// iteration) then that first one will set the repeating time
893 		import arsd.random;
894 		auto firstBeat = arsd.random.uniform(10, msecs);
895 		heartbeatTimer.changeTime(firstBeat, false);
896 	}
897 
898 	/++
899 
900 	+/
901 	void sendWebsocketCommand(OpCode op, var d) {
902 		assert(websocket !is null, "call connect before sending commands");
903 
904 		var cmd = var.emptyObject;
905 		cmd.d = d;
906 		cmd.op = cast(int) op;
907 		websocket.send(cmd.toJson());
908 	}
909 
910 	/++
911 
912 	+/
913 	void connect() {
914 		assert(websocket is null, "do not call connect twice");
915 
916 		if(cachedGatewayUrl is null) {
917 			auto obj = api.rest.gateway.bot.GET().result;
918 			cachedGatewayUrl = obj.url.get!string;
919 		}
920 
921 		websocketConnectInLoop(Uri(cachedGatewayUrl));
922 
923 		var d = var.emptyObject;
924 		d.token = token;
925 			// FIXME?
926 		d.properties = [
927 			"os": "linux",
928 			"browser": "arsd.discord",
929 			"device": "arsd.discord",
930 		];
931 
932 		sendWebsocketCommand(OpCode.Identify, d);
933 	}
934 
935 	/+
936 		SocketOSException needs full reconnect
937 	+/
938 
939 	void websocketConnectInLoop(Uri uri) {
940 		// FIXME: if the connect fails we should set a timer and try
941 		// again, but if it fails then, quit. at least if it is not a websocket reply
942 		// cuz it could be discord went down or something.
943 
944 		import core.time;
945 		auto d = 1.seconds;
946 		int count = 0;
947 
948 		try_again:
949 
950 		this.websocket_ = new WebSocket(uri);
951 		websocket.onmessage = &handleWebsocketMessage;
952 		websocket.onclose = &handleWebsocketClose;
953 
954 		try {
955 			this.websocket_.connect();
956 		} catch(Exception e) {
957 			// it disconnects after 30 days rn w/
958 			// std.socket.SocketOSException@std/socket.d(2897): Unable to connect socket: Transport endpoint is already connected
959 			// and idk the root cause, it has invalid session at first
960 
961 			.destroy(this.websocket_);
962 
963 			logSwallowedException(e);
964 
965 			import core.thread;
966 			Thread.sleep(d);
967 			d *= 2;
968 			count++;
969 			if(count == 10)
970 				throw e;
971 
972 			goto try_again;
973 		}
974 	}
975 
976 
977 }
978 
979 class DiscordRpcConnection {
980 
981 	// this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config);
982 	// websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`);
983 	// writeln(websocket.waitForNextMessage.textData);
984 
985 	// these would tell me user names and ids when people join/leave but it needs authentication alas
986 
987 	/+
988 	websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
989     "guild_id": "SSSSSSSSSSSSSSSS",
990     "channel_id": "CCCCCCCCCCCCCCCCC"
991   },
992   "evt": "VOICE_STATE_CREATE",
993   "cmd": "SUBSCRIBE"
994 }`);
995 	writeln(websocket.waitForNextMessage.textData);
996 
997 	websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
998     "guild_id": "SSSSSSSSSSSSSSSS",
999     "channel_id": "CCCCCCCCCCCCCCCCC"
1000   },
1001   "evt": "VOICE_STATE_DELETE",
1002   "cmd": "SUBSCRIBE"
1003 }`);
1004 
1005 		websocket.onmessage = delegate(in char[] msg) {
1006 			writeln(msg);
1007 
1008 			import arsd.jsvar;
1009 			var m = var.fromJson(msg.idup);
1010 			if(m.cmd == "DISPATCH") {
1011 				if(m.evt == "SPEAKING_START") {
1012 					//setSpeaking(m.data.user_id.get!ulong, true);
1013 				} else if(m.evt == "SPEAKING_STOP") {
1014 					//setSpeaking(m.data.user_id.get!ulong, false);
1015 				}
1016 			}
1017 		};
1018 
1019 
1020 	+/
1021 
1022 
1023 }