-
Notifications
You must be signed in to change notification settings - Fork 0
/
globals.pike
1601 lines (1510 loc) · 74.5 KB
/
globals.pike
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
protected void create(string n)
{
foreach (indices(this),string f) if (f!="create" && f[0]!='_') add_constant(f,this[f]);
foreach (Program.annotations(this_program); string anno;)
if (stringp(anno) && sscanf(anno, "G->G->%s", string gl) && gl)
if (!G->G[gl]) G->G[gl] = ([]);
if (!all_constants()["create_hook"]) add_constant("create_hook", _HookID("")); //Don't recreate this one
catch {G->G->instance_config = Standards.JSON.decode_utf8(Stdio.read_file("instance-config.json"));};
//HACK: Support hot reload across the implementation of G->G->args. Can delete once bot restarted everywhere.
if (!G->G->args) G->G->args = Arg.parse(G->G->argv);
if (G->G->args["no-http"]) G->G->instance_config->http_address = G->G->instance_config->local_address = "";
}
//A sendable message could be a string (echo that string), a mapping with a "message"
//key (echo that string, possibly with other attributes), or an array of the above
//(echo them all, in order). Mappings and arrays can nest arbitrarily; for example:
//- "single message"
//- ({"sequential", "messages"})
//- (["message": "text to send", "attr": "value"])
//- (["message": ({"sequential", "messages"}), "attr": "value"])
typedef string|mapping(string:mixed)|array(_echoable_message)|zero _echoable_message;
//To avoid recompilation ambiguities, the added constant is simply a reference to the
//(private) recursive typedef above.
typedef _echoable_message echoable_message;
//DEPRECATED. If you actually need to support functions, do so explicitly. Support for
//functions is itself deprecated and should eventually be removed.
typedef echoable_message|function(object,object,string:echoable_message) command_handler;
//Case-fold command names. For consistency, use this everywhere; that way,
//commands will be created and found correctly. TODO eventually: Switch
//this out for a proper Unicode casefold.
string command_casefold(string cmd) {return lower_case(cmd);}
//Attempt to find a "likely command" for a given channel.
//If it returns 0, there's no such command. It may return a function
//that eventually fails, but it will attempt to do so as rarely as
//possible; returning nonzero will NORMALLY mean that the command is
//fully active.
echoable_message|function find_command(object channel, string cmdname, int is_mod, int|void is_vip)
{
//Prevent commands from containing a hash, as that was formerly used for
//per-channel commands. This can be relaxed in the future if needed.
if (has_value(cmdname, '#')) return 0;
if (has_value(cmdname, '!')) return 0; //Pseudo-commands can't be run as normal commands
mixed cmd = channel->commands[command_casefold(cmdname)];
if (!mappingp(cmd)) return cmd; //This includes if it's not found
if (cmd->access == "mod" && !is_mod) return 0;
if (cmd->access == "vip" && !is_mod && !is_vip) return 0;
if (cmd->access == "none") return 0;
//If we get here, the command is acceptable.
return cmd;
}
//Return a Second or a Fraction representing the given ISO time, or 0 if unparseable
Calendar.ISO.Second time_from_iso(string time) {
if (object ts = Calendar.ISO.parse("%Y-%M-%DT%h:%m:%s%z", time)) return ts;
return Calendar.ISO.parse("%Y-%M-%DT%h:%m:%s.%f%z", time);
}
//Handle potentially-asynchronous results. Can be used to paper over a distinction between
//async and sync functions (forcing them all to be async).
__async__ mixed spawn_task(mixed gen) {
return objectp(gen) && gen->on_await ? await(gen) : gen;
}
//task_sleep(seconds)->then() { ... }; //Delay efficiently (like sleep(delay) in an async func)
Concurrent.Future task_sleep(int|float delay) {
return Concurrent.resolve(0)->delay(delay);
}
//Run a subprocess and yield (["rc": returncode, "stdout": bytes sent to stdout])
//Similar to Process.run() but doesn't do stderr or stdin (and should only be used with small data).
class run_process {
inherit Concurrent.Promise;
Stdio.File stdout = Stdio.File();
protected void create(array(string) command, mapping|void modifiers) {
Process.create_process(command, (modifiers || ([]))
| (["callback": donecb, "stdout": stdout->pipe(Stdio.PROP_IPC)]));
}
void done(object proc) {
if (proc->status() == 2) success((["rc": proc->wait(), "stdout": stdout->read()]));
}
//NOTE: The callback is called from a signal context. This can cause issues in some
//narrow circumstances (eg process not found and run_process called during create())
//if we attempt to wait on the process in that context. Since this is asynchronous
//already, there's no great cost to bouncing it back to the event loop.
void donecb(object proc) {call_out(done, 0, proc);}
}
//Some bot features are available for commands to call on.
//The name is somewhat archaic now, and "builtin" would probably serve better.
//Or maybe something different again. Whatever. They're builtin_command for hysterical raisins.
@"G->G->builtins";
class builtin_command {
constant builtin_description = ""; //Label used in any human-readable context - one line description of purpose
constant command_description = ""; //Deprecated: Description for the default response
constant builtin_name = ""; //Short human-readable name for the drop-down
constant builtin_param = ""; //Label(s) for the parameter(s), or "/Label/option/option/option" to offer specific selections. If blank, has no parameter. May be an array for multiple params.
constant vars_provided = ([ ]); //List all available vars (it's okay if they aren't all always provided)
constant command_suggestions = 0; //Set this to provide some suggestions (which will show up as enableable features)
constant scope_required = ""; //If nonblank, will be offered as a suggestion any time this builtin is used. TODO: Make this more flexible, so some parameters can ask for scope, others not?
//Override this either as-is or as a continue function to return the useful params.
//Note that the person mapping may be as skeletal as (["user": "Nobody"]) - all
//other keys are optional.
//The params will be an array of as many strings as builtin_param contains (if it's
//a string directly, that's equivalent to an array of that one string).
mapping|Concurrent.Future message_params(object channel, mapping person, array params, mapping cfg) { }
protected void create(string name)
{
sscanf(explode_path(name)[-1],"%s.pike",name);
if (!name) return;
G->G->builtins[name] = this;
}
}
string describe_time_short(int tm)
{
string msg = "";
int secs = tm;
if (int t = secs/86400) {msg += sprintf("%d, ", t); secs %= 86400;}
if (tm >= 3600) msg += sprintf("%02d:%02d:%02d", secs/3600, (secs%3600)/60, secs%60);
else if (tm >= 60) msg += sprintf("%02d:%02d", secs/60, secs%60);
else msg += sprintf("%02d", tm);
return msg;
}
string describe_time(int tm)
{
string msg = "";
if (int t = tm/86400) {msg += sprintf(", %d day%s", t, t>1?"s":""); tm %= 86400;}
if (int t = tm/3600) {msg += sprintf(", %d hour%s", t, t>1?"s":""); tm %= 3600;}
if (int t = tm/60) {msg += sprintf(", %d minute%s", t, t>1?"s":""); tm %= 60;}
if (tm) msg += sprintf(", %d second%s", tm, tm>1?"s":"");
return msg[2..];
}
int channel_uptime(int channel)
{
if (object started = G->G->stream_online_since[channel])
return started->distance(Calendar.now())->how_many(Calendar.Second());
}
mapping G_G_(string ... path)
{
mapping ret = G->G;
foreach (path, string part)
{
if (!ret[part]) ret[part] = ([]);
ret = ret[part];
}
return ret;
}
//TODO: Make this callable, move the functionality for hooks into here, and maybe fold "inherit hook" into "inherit annotated"?
class _HookID(string event) {constant is_hook_annotation = 1;}
@"G->G->eventhooks";
class hook {
protected void create(string name) {
//1) Clear out any hooks for the same name
foreach (G->G->eventhooks;; mapping hooks) m_delete(hooks, name);
//2) Go through all annotations in this and add hooks as appropriate
foreach (Array.transpose(({indices(this), annotations(this)})), [string key, mixed ann]) {
if (ann) foreach (indices(ann), mixed anno) {
if (objectp(anno) && anno->is_hook_annotation) {
if (anno->event == "") {//Create or replace a hook definition
if (!G->G->eventhooks[key]) {
add_constant("hook_" + key, _HookID(key));
G->G->eventhooks[key] = ([]);
//NOTE: The object annotated (available as this[key]) is
//not currently used in any way. At some point I'll figure
//out what's needed, and give it meaning, but until then,
//be prepared to rewrite any hook provider objects.
}
continue;
}
//Otherwise, it's registering a function for an existing hook.
if (!G->G->eventhooks[anno->event]) {
if (has_value(anno->event, '=')) G->G->eventhooks[anno->event] = ([]); //EventSub hooks can be created implicitly.
else error("Unrecognized hook %s\n", anno->event);
}
G->G->eventhooks[anno->event][name] = this[key];
}
}
}
}
//Run all registered hook functions for the given event, in an arbitrary order
void event_notify(string event, mixed ... args) {
mapping hooks = G->G->eventhooks[event];
if (!hooks) error("Unrecognized hook %s\n", event);
foreach (hooks; string name; function func)
if (mixed ex = catch (func(@args)))
werror("Error in hook %s->%s: %s", name, event, describe_backtrace(ex));
}
//Establish hook notifications for the specified channel and all hooks in the current module
void establish_notifications(string|int channelid) {
foreach (Array.transpose(({indices(this), annotations(this)})), [string key, mixed ann]) {
if (ann) foreach (indices(ann), mixed anno) {
if (objectp(anno) && anno->is_hook_annotation) {
if (has_value(anno->event, '=')) G->G->establish_hook_notification(channelid, anno->event);
}
}
}
}
}
//Usage: @EventNotify("channel.subscription.gift=1"): void subgift(mapping info) { }
//Ties in with "inherit hook". (Or should it tie in with "inherit annotated" instead?)
class EventNotify(string event) {@constant; constant is_hook_annotation = 1;}
//Old way of implementing hooks. Was buggy in a number of ways. Use "inherit hook" instead (see above).
//Hook deregistration with register_hook("...event...", Program.defined(this_program)); is still permitted but useless.
void register_hook(string event, function|string handler) {
if (functionp(handler)) error("register_hook() has been removed - use 'inherit hook' instead\n");
}
/* Easily slide a delayed callback to the latest code
In create(), call register_bouncer(some_function)
In some_function, start with:
if (function f = bounce(this_function)) return f(...my args...);
If the code has been updated since the callback was triggered, it'll give back
the new function. Functions are identified by their %O descriptions.
*/
@"G->G->bouncers";
void register_bouncer(function f) {G->G->bouncers[sprintf("%O", f)] = f;}
function|void bounce(function f)
{
function current = G->G->bouncers[sprintf("%O", f)];
if (current != f) return current;
return UNDEFINED;
}
@"G->G->exports";
class annotated {
protected void create(string name) {
//TODO: Find a good way to move prev handling into the export class or object below
mapping prev = G->G->exports[name];
G->G->exports[name] = ([]);
foreach (Array.transpose(({indices(this), annotations(this)})), [string key, mixed ann]) {
if (ann) foreach (indices(ann), mixed anno) {
if (functionp(anno)) anno(this, name, key);
}
}
//Purge any that are no longer being exported (handles renames etc)
if (prev) foreach (prev - G->G->exports[name]; string key;)
add_constant(key);
}
}
void export(object module, string modname, string key) {
add_constant(key, module[key]);
G->G->exports[modname][key] = 1;
}
void retain(object module, string modname, string key) {
if (!G->G[key]) G->G[key] = module[key];
else module[key] = G->G[key];
}
//Decorate a function with this to have it called once G->G->irc is populated.
//If it's already populated (eg on code reload), function will be called immediately.
//NOTE: When this is called, G->G->irc will be populated, but not all configs are
//necessarily fully populated. See G->G->irc->loading.
void on_irc_loaded(object module, string modname, string key) {
if (sizeof(G->G->irc->?id || ({ }))) module[key]();
else G->G->awaiting_irc_loaded += ({module[key]});
}
@"G->G->enableable_modules";
class enableable_module {
constant ENABLEABLE_FEATURES = ([]); //Map keywords to mappings containing descriptions and other info
void enable_feature(object channel, string kwd, int state) { } //Enable/disable the given feature or reset it to default
int can_manage_feature(object channel, string kwd) {return 1;} //Optional UI courtesy: Return 1 if can be activated, 2 if can be deactivated, 3 if both
protected void create(string name)
{
sscanf(explode_path(name)[-1],"%s.pike",name);
if (!name) return;
G->G->enableable_modules[name] = this;
}
}
@"G->G->http_endpoints";
class http_endpoint
{
//Set to an sscanf pattern to handle multiple request URIs. Otherwise will handle just "/myname".
constant http_path_pattern = 0;
//A channel will be provided if and only if this is chan_foo.pike and the URL is /channels/spam/foo
//May be a continue function or may return a Future. May also return a string (recommended for
//debugging only, as it'll be an ugly text/plain document).
mapping(string:mixed)|string|Concurrent.Future http_request(Protocols.HTTP.Server.Request req) { }
//Whitelist query variables for redirects. Three options: 0 means error, don't allow the
//redirect at all; ([]) to allow redirect but suppress query vars; or vars&(<"...","...">)
//to filter the variables to a specific set of keys.
mapping(string:string|array) safe_query_vars(mapping(string:string|array) vars) {return ([]);}
protected void create(string name)
{
if (http_path_pattern)
{
G->G->http_endpoints[http_path_pattern] = http_request;
return;
}
sscanf(explode_path(name)[-1],"%s.pike",name);
if (!name) return;
G->G->http_endpoints[name] = http_request;
}
}
array(function|array) find_http_handler(string not_query) {
//Simple lookups are like http_endpoints["listrewards"], without the slash.
//Exclude eg http_endpoints["chan_vlc"] which are handled elsewhere.
if (function handler = !has_prefix(not_query, "/chan_") && G->G->http_endpoints[not_query[1..]])
return ({handler, ({ })});
//Try all the sscanf-based handlers, eg http_endpoints["/channels/%[^/]/%[^/]"], with the slash
//TODO: Look these up more efficiently (and deterministically)
foreach (G->G->http_endpoints; string pat; function handler) if (has_prefix(pat, "/"))
{
//Match against an sscanf pattern, and require that the entire
//string be consumed. If there's any left (the last piece is
//non-empty), it's not a match - look for a deeper pattern.
array pieces = array_sscanf(not_query, pat + "%s");
if (pieces && sizeof(pieces) && pieces[-1] == "") return ({handler, pieces[..<1]});
}
return ({0, ({ })});
}
@"G->G->websocket_types"; @"G->G->websocket_groups";
class websocket_handler
{
mapping(string|int:array(object)) websocket_groups;
//Generate a state mapping for a particular connection group. If state is 0, no
//information is sent; otherwise it must be a JSON-compatible mapping. An ID will
//be given if update_one was called, otherwise it will be 0. Type is rarely needed
//but is used only in conjunction with an ID.
mapping|Concurrent.Future get_state(string|int group, string|void id, string|void type) { }
//__async__ mapping get_state(string|int group, string|void id, string|void type) { } //Alternate (equivalent) signature
//Override to validate any init requests. Return 0 to allow the socket
//establishment, or an error message.
string websocket_validate(mapping(string:mixed) conn, mapping(string:mixed) msg) { }
//If msg->cmd is "init", it's a new client and base processing has already been done.
//If msg is 0, a client has disconnected and is about to be removed from its group.
//Use websocket_groups[conn->group] to find an array of related sockets.
void websocket_msg(mapping(string:mixed) conn, mapping(string:mixed) msg) {
if (msg->cmd == "refresh" || msg->cmd == "init") send_update(conn);
if (function f = this["websocket_cmd_" + msg->cmd]) {
mixed ret = f(conn, msg);
if (ret) spawn_task(ret)->then() {if (__ARGS__[0]) send_msg(conn, __ARGS__[0]);};
}
}
void websocket_cmd_chgrp(mapping(string:mixed) conn, mapping(string:mixed) msg) {
if (string err = websocket_validate(conn, msg)) {
conn->sock->send_text(Standards.JSON.encode((["cmd": "*DC*", "error": err])));
conn->sock->close();
return;
}
websocket_groups[conn->group] -= ({conn->sock});
websocket_groups[conn->group = msg->group] += ({conn->sock});
send_update(conn);
}
void _low_send_updates(mapping resp, array(object) socks) {
if (!resp) return;
string text = Standards.JSON.encode(resp | (["cmd": "update"]), 4);
foreach (socks, object sock)
if (sock && sock->state == 1) sock->send_text(text);
}
void _send_updates(array(object) socks, string|int group, mapping|void data) {
if (!data) data = get_state(group);
if (objectp(data) && data->then) data->then() {_low_send_updates(__ARGS__[0], socks);};
else _low_send_updates(data, socks);
}
//Send an update to a specific connection. If not provided, data will
//be generated by get_state(). TODO: Is this used anywhere? If not,
//replace it with send_msg() and have it not add cmd:update. Would
//be more useful.
void send_update(mapping(string:mixed) conn, mapping|void data) {
_send_updates(({conn->sock}), conn->group, data);
}
void send_msg(mapping(string:mixed) conn, mapping msg) {
if (conn->sock && conn->sock->state == 1) conn->sock->send_text(Standards.JSON.encode(msg, 4));
}
//Update all connections in a given group.
//Generates just one state object and sends it everywhere.
void send_updates_all(string|int group, mapping|void data) {
array dest = websocket_groups[group];
if (!dest) {
//If you attempt to send updates to "control#rosuav", send them instead
//to "control#49497888", assuming there weren't any sockets for the former.
[object channel, string subgroup] = split_channel(group);
if (channel) dest = websocket_groups[subgroup + "#" + channel->userid];
}
if (dest && sizeof(dest)) _send_updates(dest, group, data);
}
//Compatibility overlap variant form. Use this with a channel object to send to
//the correct group for that channel.
variant void send_updates_all(object chan, string|int group, mapping|void data) {
send_updates_all(group + "#" + chan->userid, data);
}
void update_one(string|int group, string id, string|void type) {
spawn_task(get_state(group, id, type))->then() {
send_updates_all(group, (["id": id, "data": __ARGS__[0], "type": type || "item"]));
};
}
//Compatibility overlap variant form, as above.
variant void update_one(object chan, string|int group, string id, string|void type) {
update_one(group + "#" + chan->userid, id, type);
}
//Returns ({channel, subgroup}) - if channel is 0, it's not valid
array(object|string) split_channel(string|void group) {
if (!stringp(group) || !has_value(group, '#')) return ({0, ""}); //Including if we don't have a group set yet
sscanf(group, "%s#%s", string subgroup, string chan);
//Allow the channel to be identified by Twitch ID
//Note: If the channel name is entirely digits, it cannot reliably be specified by name.
if ((string)(int)chan == chan) return ({G->G->irc->id[(int)chan], subgroup});
return ({G->G->irc->channels["#" + chan], subgroup});
}
protected void create(string name)
{
sscanf(explode_path(name)[-1],"%s.pike",name);
if (!name) return;
if (!(websocket_groups = G->G->websocket_groups[name]))
websocket_groups = G->G->websocket_groups[name] = ([]);
G->G->websocket_types[name] = this;
}
}
array(string) token_for_user_login(string login) {
mapping cred = login && G->G->user_credentials[lower_case(login)];
if (cred) return ({cred->token, cred->scopes * " "});
return ({"", ""});
}
array(string) token_for_user_id(int|string userid) {
mapping cred = userid && G->G->user_credentials[(int)userid];
if (cred) return ({cred->token, cred->scopes * " "});
return ({"", ""});
}
//Mod status is stored by ID, but attempt to find it (synchronously) based on usernames.
//Will return 0 if it isn't confident that user IS a mod.
//NOTE: chan MUST begin with a hash.
int(1bit) is_mod_for(string user, string chan) {
if ("#" + user == chan) return 1;
object channel = G->G->irc->channels[chan];
if (!channel) return 0; //Uncertain for any channel we don't monitor, or before channels loaded
int id = (int)G->G->user_info[user]->?id; //Note that we don't care about cache expiration here, but if it's not in cache, fail.
if (!id) return 0;
return channel->user_badges[id]->?_mod;
}
//Token bucket system, shared among all IRC connections.
float request_rate_token(string user, string chan, int|void lowprio) {
//By default, messages are limited to 20 every 30 seconds.
int bucket_size = 20;
int window_size = 30;
float safety_shave = 0.0; //For small windows, it's reasonable to shave the safety margin.
array bucket = G->G->irc_token_bucket[user + chan];
if (!bucket) G->G->irc_token_bucket[user + chan] = bucket = ({0, 0});
if (chan == "#!login") {
//Logins are limited to 20 every 10 seconds, per user. Probably never an issue.
window_size = 10; bucket_size = 20;
}
else if (chan == "#!join") {
//Channel joinings are limited to 20 every 10 seconds, per user; however, they are
//usually going to be batched (multiple channels in a single JOIN command), so for
//simplicity and safety, we instead lock to one JOIN command per user with a ten
//second cooldown before the next one is permitted.
int now = time();
if (bucket[0] > now) return bucket[0] - now;
bucket[0] = now + 11.0; //safety margin
return 0;
}
else if (is_mod_for(user, chan))
//You can spam harder in channels you mod for.
bucket_size = 100;
else if (has_suffix(chan, "-nonmod")) {
//HACK: Non-mods also have to restrict themselves to one message per second.
bucket_size = window_size = 1;
safety_shave = 0.875; //Safety margin of 1/8 sec is plenty when working with a window of one second.
}
else if (float wait = request_rate_token(user, chan + "-nonmod"))
//Other half of hack: If you're not a mod, check both limits.
return wait;
int now = time() / window_size; //I'm pretty sure "number of half-minutes since 1970" isn't the way most humans think about time.
if (now != bucket[0]) {bucket[0] = now; bucket[1] = 0;} //New time period, fresh bucket of tokens.
if (bucket[1] < bucket_size) {bucket[1]++; return 0;} //Tokens available - take one and pass it on, like your IQ was normal
//We're out of tokens. Notify the caller to wait.
//For safety's sake, we wait until one second past the next window.
//Note that we do not automatically consume a token from the next window;
//if you get a float back from this function, you do NOT have permission
//yet, and must re-request a token after the delay.
//To calculate the required delay, we find the time_t of the next window,
//that being the (now+1)th window, plus the safety second. Asking Pike
//how many seconds since an epoch in the future returns a negative number,
//and negating that number gives us the time until that instant.
return -time(window_size * (now + 1) + 1) - safety_shave + (lowprio * window_size / 10.0);
}
#ifdef IRCTRACE
#define _IRCTRACE werror
#else
void _IRCTRACE(mixed ... ignore) { }
#endif
/* Available options:
module Override the default selection of callbacks and module version
user User to log in as. With module, defines connection caching.
pass OAuth password. If omitted, uses the user token.
capabilities Optional array of caps to request
join Optional array of channels to join (include the hashes)
login_commands Optional commands to be sent after (re)connection
encrypt Set to 1 to require encryption, -1 to require unencrypted.
The default will change at some point such that most are encrypted.
lowprio Reduce priority by some value 1-9, default 0 is highest priority
*/
@"G->G->irc_callbacks"; @"G->G->irc_token_bucket";
class _TwitchIRC(mapping options) {
constant server = "irc.chat.twitch.tv"; //Port 6667 or 6697 depending on SSL status
string ip; //Randomly selected from the A/AAAA records for the server.
string pass; //Pulled out of options in case options gets printed out
Stdio.File|SSL.File sock;
array(string) queue = ({ }); //Commands waiting to be sent, and callbacks
array(function) failure_notifs = ({ }); //All will be called on failure
int have_connection = 0;
int writing = 1; //If not writing and need to write, immediately write.
string readbuf = "";
int last_rcv_time = 0; //time_t of last line received
constant PING_INTERVAL = 300; //Time between PING messages sent
mixed ping_callout;
//Messages in this set will not be traced on discovery as we know them.
constant ignore_message_types = (<
"USERSTATE", "ROOMSTATE", "JOIN", "PONG",
"CAP", //We assume Twitch supports what they've documented
>);
protected void create() {
array ips = gethostbyname(server); //TODO: Support IPv6
if (!ips || !sizeof(ips[1])) error("Unable to gethostbyname for %O\n", server);
ip = random(ips[1]);
connect();
pass = m_delete(options, "pass");
}
void connect() {
sock = Stdio.File();
sock->open_socket();
sock->set_nonblocking(sockread, connected, connfailed);
sock->connect(ip, options->encrypt >= 1 ? 6697 : 6667); //Will throw on error
//Until we get connected, hold, waiting for our marker.
//The establishment of the connection will insert login
//commands before this.
have_connection = 0; queue += ({await_connection});
readbuf = "";
last_rcv_time = time();
}
void connected() {
if (!sock) {werror("ERROR IN IRC HANDLING: connected() with sock == 0!\n%O\n", options); fail("Didn't actually connect"); return;}
if (options->encrypt >= 1) { //Make this and the above ">= 0" to change default to be encrypted
sock = SSL.File(sock, SSL.Context());
sock->connect(server);
}
array login = ({
"PASS " + pass,
"NICK " + options->user,
"USER " + options->user + " localhost 127.0.0.1 :StilleBot",
});
//PREPEND onto the queue.
queue = login
+ sprintf("CAP REQ :twitch.tv/%s", Array.arrayify(options->capabilities)[*])
+ map(Array.arrayify(options->join) / 20.0) {return "JOIN :" + __ARGS__[0] * ",";}
+ Array.arrayify(options->login_commands)
+ ({"MARKER"})
+ queue;
sock->set_nonblocking(sockread, sockwrite, sockclosed);
if (!ping_callout) ping_callout = call_out(send_ping, PING_INTERVAL);
}
void connfailed() {fail("Connection failed.");}
void fail(string how) {
failure_notifs(({how + "\n", backtrace()}));
//Since we're rejecting all the promises, we should dispose of the
//queued success functions. But this is a failure mode anyway, so
//for simplicity, just dispose of the entire queue.
failure_notifs = queue = ({ });
sock->close();
}
void sockclosed() {
_IRCTRACE("Connection closed.\n");
//Look up the latest version of the callback container. If that isn't the one we were
//set up to call, don't reconnect.
object current_module = G->G->irc_callbacks[options->module->modulename];
if (!options->no_reconnect && options->module == current_module) call_out(connect, 0);
else if (!options->outdated) options->module->irc_closed(options);
sock = 0;
remove_call_out(ping_callout);
}
void sockread(mixed _, string data) {
readbuf += data;
while (sscanf(readbuf, "%s\n%s", string line, readbuf)) {
line -= "\r";
if (line == "") continue;
line = utf8_to_string(line);
if (options->verbose) werror("IRC < %O\n", line);
last_rcv_time = time();
//Twitch messages with TAGS capability begin with the tags
sscanf(line, "@%s %s", string tags, line);
//Most messages from the server begin with a prefix. It's
//irrelevant to many Twitch messages, but for where it's
//wanted, it is passed along to the raw command handlers.
//The only part that is usually interesting is the user
//name, which we add to the attrs.
sscanf(line, ":%s %s", string prefix, line);
//A lot of messages end with a colon-prefixed string.
sscanf(line, "%s :%s", line, string str);
//With all that removed, what's left must be the command and
//its parameters. (Only the last parameter is allowed to be
//an arbitrary string, the rest must be atoms.)
array args = line / " " - ({""});
if (str) args += ({str});
if (!sizeof(args)) continue; //Broken command
mapping attrs = ([]);
if (tags) foreach (tags / ";", string att) {
sscanf(att, "%s=%s", string name, string val);
attrs[replace(name, "-", "_")] = replace(val || "", "\\s", " ");
}
if (prefix) sscanf(prefix, "%s%*[!.]", attrs->user);
if (!have_connection && args * " " == "NOTICE * Login authentication failed") {
//Don't pass this failure along to the module; it's a failure to connect.
fail("Login authentication failed.");
return;
}
if (!have_connection && args * " " == "NOTICE * Improperly formatted auth") {
//This is also a failure to connect, but a code bug rather than auth failure.
fail("Login authentication format error, check oauth: prefix.");
return;
}
if (function f = this["command_" + args[0]]) f(attrs, prefix, args);
else if ((int)args[0]) command_0000(attrs, prefix, args);
else if (has_value(options->module->messagetypes, args[0])) {
//Pass these on to the module
if (sizeof(args) == 1) args += ({""}); //No channel. What should happen here?
else if (!has_prefix(args[1], "#")) args[1] = "#" + args[1]; //Some messages, for unknown reason, have channels without the leading hash. Why?!
if (sizeof(args) == 2) args += ({""}); //No message, pass an empty string along
options->module->irc_message(@args, attrs);
}
else if (!ignore_message_types[args[0]])
_IRCTRACE("Unrecognized command received: %O\n", line);
}
}
void sockwrite() {
//Send the next thing from the queue
if (!sizeof(queue) || !sock) {writing = 0; return;}
[mixed next, queue] = Array.shift(queue);
if (stringp(next)) {
//Automatic rate limiting
string autolim;
if (has_prefix(next, "JOIN ")) autolim = "#!join";
else if (sscanf(next, "PRIVMSG %s :", string c) && c) autolim = c;
if (float wait = autolim && request_rate_token(options->user, autolim, options->lowprio)) {
queue = ({next}) + queue;
call_out(sockwrite, wait);
return;
}
if (options->verbose) werror("IRC > %O\n", replace(next, pass, "<password>")); //hunter2 :)
int sent = sock->write(next + "\n");
if (sent < sizeof(next) + 1) {
//Partial send. Requeue all but the part that got sent.
//In the unusual case that we send the entire message apart
//from the newline at the end, we will store an empty string
//into the queue, which will then cause a "blank line" to be
//sent, thus finishing the line correctly.
_IRCTRACE("Partial write, requeueing\n");
queue = ({next[sent..]}) + queue;
}
return;
}
else if (intp(next) || floatp(next)) {call_out(sockwrite, next); return;} //Delay.
else if (functionp(next)) next(this); //func <=> ({func, this}), because the queue could get migrated to a new object
else if (arrayp(next) && sizeof(next) && functionp(next[0])) next[0](@next[1..]);
else error("Unknown entry in queue: %t\n", next);
call_out(sockwrite, 0.125); //TODO: Figure out a safe rate limit. Or do we even need one?
}
void enqueue(mixed ... items) {
if (!writing) {writing = 1; call_out(sockwrite, 0);}
queue += items;
}
Concurrent.Future promise() {
if (!sizeof(queue)) return Concurrent.resolve(this);
return Concurrent.Promise(lambda(function res, function rej) {
enqueue() {failure_notifs -= ({rej}); res(@__ARGS__);};
failure_notifs += ({rej});
});
}
void send(string channel, string msg, mapping(string:string)|void tags) {
//Tags can be client-nonce and/or reply-parent-msg-id
string pfx = "";
if (tags && sizeof(tags)) pfx = "@" + map((array)tags) {
//Invert the transformation of incoming messages
return replace(__ARGS__[0][0], "_", "-") + "=" + replace(__ARGS__[0][1], " ", "\\s");
} * " " + " ";
enqueue(pfx + "PRIVMSG #" + (channel - "#") + " :" + string_to_utf8(replace(msg, "\n", " ")));
}
void send_ping() {
if (!sock) return;
if (last_rcv_time < time() - PING_INTERVAL * 2) {
//It's been two ping intervals since we last heard from the server.
//Note that this counts PONG messages, but also everything else.
sock->close();
sockclosed();
return;
}
ping_callout = call_out(send_ping, PING_INTERVAL);
enqueue(); queue = ({"ping :stillebot"}) + queue; //Prepend to queue
}
int(0..1) update_options(mapping opt) {
if (!sock || !sock->is_open()) return 1; //We've lost the connection. Fresh connect.
werror("update_options mod %O user %O - sock is %O\n", options->module, options->user, sock);
//If the IRC handling code has changed incompatibly, reconnect.
if (opt->version != options->version) return 1;
//If you explicitly ask to be reconnected, do so.
if (opt->force_reconnect) return 1;
//If credentials have changed, reconnect.
if (opt->pass != pass) return 1; //The user is the same, or cache wouldn't have pulled us up.
if (Array.arrayify(opt->login_commands) * "\n" !=
Array.arrayify(options->login_commands) * "\n") return 1; //No way of knowing whether it's compatible or not
//Capabilities can be added, but not removed. Since the client might be
//expecting results based on the exact set given, if any are removed, we
//just disconnect.
array haveopt = Array.arrayify(options->capabilities);
array wantopt = Array.arrayify(opt->capabilities);
if (sizeof(haveopt - wantopt)) return 1;
//Channels can be parted freely. For dependability, we (re)join all
//the channels currently wanted, but first part what's not.
array havechan = Array.arrayify(options->join);
array wantchan = Array.arrayify(opt->join);
//For some reason, these automaps are raising warnings about indexing
//empty strings. I don't get it.
array commands = ("CAP REQ :twitch.tv/" + (wantopt - haveopt)[*]);
if (sizeof(havechan - wantchan)) commands += ({"PART :" + (havechan - wantchan) * ","});
if (sizeof(wantchan - havechan)) commands += map((wantchan - havechan) / 20.0) {return "JOIN :" + __ARGS__[0] * ",";};
if (sizeof(commands)) enqueue(@commands);
options = opt; m_delete(options, "pass"); //Transfer all options. Anything unchecked is assumed to be okay to change like this.
}
void close() {sock->close();} //Close the socket immediately
void queueclose() {enqueue(close);} //Close the socket once we empty what's currently in queue
void quit() {enqueue("quit", no_reconnect);} //Ask the server to close once the queue is done
void no_reconnect() {options->no_reconnect = 1;}
void yes_reconnect() {options->no_reconnect = 0;}
void await_connection() {
//Wait until we have seen the error response to the MARKER
if (!have_connection) queue = ({.25, this_function}) + queue;
}
void command_421(mapping attrs, string pfx, array(string) args) {
//We send this command after the credentials. If we get this without
//first seeing any failures of login, then we assume the login worked.
if (sizeof(args) > 2 && args[2] == "MARKER") have_connection = 1;
}
void command_473(mapping attrs, string pfx, array(string) args) {
//Failed to join channel. Reject promise?
werror("IRC: Failed to join channel: %O %O %O %O\n", options->user, attrs, pfx, args);
}
multiset command_0000_ignore = (<"001", "002", "003", "004", "353", "366", "372", "375", "376">);
void command_0000(mapping attrs, string pfx, array(string) args) {
//Handle all unknown numeric responses
if (command_0000_ignore[args[0]]) return;
werror("IRC: Unknown numeric response: %O %O %O %O\n", options->user, attrs, pfx, args);
}
void command_PING(mapping attrs, string pfx, array(string) args) {
enqueue("pong :" + args[1]); //Enqueue or prepend to queue?
}
void command_RECONNECT(mapping attrs, string pfx, array(string) args) {
werror("#### Got a RECONNECT signal #### %s %s", options->user, ctime(time()));
if (sock) sock->close();
sockclosed();
}
//Insert ({get_token, "#some_channel"}) into the queue to grab a token before
//proceeding. This is done automatically for PRIVMSG and JOIN commands, but for
//anything else, the same token buckets can be used.
void get_token(string chan) {
float wait = request_rate_token(options->user, chan, options->lowprio);
//No token available? Delay, then re-request.
if (wait) queue = ({wait, ({get_token, chan})}) + queue;
}
//TODO: If msg_ratelimit comes in, retry last message????
}
//Inherit this to listen to connection responses
class irc_callback {
mapping connection_cache;
string modulename;
constant messagetypes = ({ });
protected void create(string name) {
modulename = name;
connection_cache = G->G->irc_callbacks[name]->?connection_cache || ([]);
G->G->irc_callbacks[name] = this;
}
//The type is one of the ones in messagetypes; chan begins "#"; attrs may be empty mapping but will not be null
void irc_message(string type, string chan, string msg, mapping attrs) { }
//Called only if we're not reconnecting. Be sure to call the parent.
void irc_closed(mapping options) {m_delete(connection_cache, options->user);}
Concurrent.Future irc_connect(mapping options) {
//Bump this version number when there's an incompatible change. Old
//connections will all be severed.
options = (["module": this, "version": 11]) | (options || ([]));
if (!options->user) {
//Default credentials from the bot's main configs
mapping cred = G->G->dbsettings->credentials;
if (!cred->token) return Concurrent.reject(({"IRC authentication not configured\n", backtrace()}));
options->user = cred->username; options->pass = "oauth:" + cred->token;
}
options->user = lower_case(options->user); //Casefold for the cache, don't have multiples kthx
if (!options->pass) {
string chan = lower_case(options->user);
mapping cred = G->G->user_credentials[chan];
if (!cred) return Concurrent.reject(({"No broadcaster auth for " + chan + "\n", backtrace()}));
//Note that we accept chat:read even if there are commands that would require
//chat:edit permission. This isn't meant to be a thorough check, just a quick
//confirmation that we really are trying to work with chat here.
if (!has_value(cred->scopes, "chat:edit") && !has_value(cred->scopes, "chat:read"))
return Concurrent.reject(({"No chat auth for " + chan + "\n", backtrace()}));
options->pass = "oauth:" + cred->token;
}
object|zero conn = connection_cache[options->user];
//If the connection exists, give it a chance to update itself. Normally
//it will do so, and return 0; otherwise, it'll return 1, we disconnect
//it, and start fresh. Problem: We could have multiple connections in
//parallel for a short while. Alternate problem: Waiting for the other
//to disconnect could leave us stalled if anything goes wrong. Partial
//solution: The old connection is kept, but flagged as outdated. This
//can be seen in callbacks.
if (conn && conn->update_options(options)) {
_IRCTRACE("Update failed, reconnecting\n");
conn->options->outdated = 1;
conn->quit();
conn = 0;
}
else if (conn) _IRCTRACE("Retaining across update\n");
if (!conn) conn = _TwitchIRC(options);
connection_cache[options->user] = conn;
return conn->promise();
}
}
string emote_url(string id, int|void size) {
if (!intp(size) || !size || size > 3) size = 3;
else if (size < 1) size = 1;
if (has_prefix(id, "/")) {
//Cheer emotes use a different URL pattern.
if (size == 3) size = 4; //Cheer emotes have sizes 1, 1.5, 2, 3, 4, but 3 is really 2.5, and 4 is really 3.
sscanf(id, "/%s/%d", string pfx, int n);
//Cheering N bits will choose the nearest non-larger emote to N.
//TODO maybe: Get these from the lookup table Twitch returns?
if (n >= 10000) n = 10000;
else if (n >= 5000) n = 5000;
else if (n >= 1000) n = 1000;
else if (n >= 100) n = 100;
else n = 1;
return sprintf("https://d3aqoihi2n8ty8.cloudfront.net/actions/%s/light/animated/%d/%d.gif",
lower_case(pfx), n, size);
}
return sprintf("https://static-cdn.jtvnw.net/emoticons/v2/%s/default/light/%d.0", id, size);
}
class user_text
{
/* Instantiate one of these, then call it with any user-defined text
(untrusted text) to be inserted into the output. Render whatever it
gives back. Then, provide this as the "user text" option (yes, with
the space) to render_template, and the texts will be safely inserted
into the resulting output file. */
array texts = ({ });
protected string `()(string text)
{
texts += ({text});
return sprintf("\uFFFAu%d\uFFFB", sizeof(texts) - 1);
}
}
#if constant(Parser.Markdown)
bool _parse_attrs(string text, mapping tok) //Used in renderer and lexer - ideally would be just lexer, but whatevs
{
if (sscanf(text, "{:%[^{}\n]}%s", string attrs, string empty) && empty == "")
{
attrs = String.trim(attrs);
while (attrs != "") {
sscanf(attrs, "%[^= ]%s", string att, attrs);
if (att == "") {sscanf(attrs, "%*[= ]%s", attrs); continue;} //Malformed, ignore
if (att[0] == '.') {
if (tok["attr_class"]) tok["attr_class"] += " " + att[1..];
else tok["attr_class"] = att[1..];
}
else if (att[0] == '#')
tok["attr_id"] = att[1..];
//Note that the more intuitive notation asdf="qwer zxcv" is NOT supported, as it
//conflicts with Markdown's protections. So we use a weird at-quoting notation
//instead. (Think "AT"-tribute? I dunno.)
else if (sscanf(attrs, "=@%s@%*[ ]%s", string val, attrs) //Quoted value asdf=@qwer zxcv@
|| sscanf(attrs, "=%s%*[ ]%s", val, attrs)) //Unquoted value asdf=qwer
tok["attr_" + att] = val;
else if (sscanf(attrs, "%*[ ]%s", attrs)) //No value at all (should always match, but will trim for consistency)
tok["attr_" + att] = "1";
}
return 1;
}
}
class Renderer
{
inherit Parser.Markdown.Renderer;
//Put borders on all tables
string table(string header, string body, mapping token)
{
return ::table(header, body, (["attr_border": "1"]) | token);
}
//Allow cell spanning by putting just a hyphen in a cell (it will
//be joined to the NEXT cell, not the preceding one)
int spancount = 0;
string tablerow(string row, mapping token)
{
spancount = 0; //Can't span across rows
if (row == "") return ""; //Suppress the entire row if all cells were suppressed
return ::tablerow(row, token);
}
string tablecell(string cell, mapping flags, mapping token)
{
if (String.trim(cell) == "-") {++spancount; return "";} //A cell with just a hyphen will not be rendered, and the next cell spans.
if (spancount) token |= (["attr_colspan": (string)(spancount + 1)]);
spancount = 0;
return ::tablecell(cell, flags, token);
}
//Interpolate magic markers
string text(string t)
{
if (!options->user_text) return t;
array texts = options->user_text->texts;
string output = "";
while (sscanf(t, "%s\uFFFA%c%s\uFFFB%s", string before, int type, string info, string after)) {
output += before;
switch (type)
{
case 'u': output += replace(texts[(int)info], (["<": "<", "&": "&"])); break;
case 'e': {
sscanf(info, "%s:%s", string id, string text);
output += sprintf("<img src=%q title=%q alt=%<q>", emote_url(id, 1), text);
}
default: break; //Should this put a noisy error in?
}
t = after;
}
return output + t;
}
//Allow a blockquote to become a dialog
string blockquote(string text, mapping token)
{
if (string tag = m_delete(token, "attr_tag")) {
//If the blockquote starts with an H3, it is some form of title.
if (sscanf(text, "<h3%*[^>]>%s</h3>%s", string title, string main)) switch (tag) {
//For dialogs, the title is outside the scroll context, and also gets a close button added.
case "dialogform": case "formdialog": //(allow this to be spelled both ways)
case "dialog": return sprintf("<dialog%s><section>"
"<header><h3>%s</h3><div><button type=button class=dialog_cancel>x</button></div></header>"
"<div>%s%s%s</div>"
"</section></dialog>",
attrs(token), title || "",
tag == "dialog" ? "" : "<form method=dialog>",
main,
tag == "dialog" ? "" : "</form>",
);
case "details": return sprintf("<details%s><summary>%s</summary>%s</details>",
attrs(token), title || "Details", main);
default: break; //No special title handling
}
return sprintf("<%s%s>%s</%[0]s>", tag, attrs(token), text);