-
Notifications
You must be signed in to change notification settings - Fork 0
/
connection.pike
1744 lines (1669 loc) · 85 KB
/
connection.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
inherit hook;
inherit irc_callback;
inherit annotated;
mapping simple_regex_cache = ([]); //Emptied on code reload.
object substitutions = Regexp.PCRE("(\\$[*?A-Za-z0-9|:]*\\$)|({[A-Za-z0-9_@|]+})");
constant messagetypes = ({"PRIVMSG", "NOTICE", "WHISPER", "USERNOTICE", "CLEARMSG", "CLEARCHAT", "USERSTATE"});
mapping irc_connections = ([]); //Not persisted across code reloads, but will be repopulated (after checks) from the connection_cache.
@retain: mapping channelcolor = ([]);
@retain: mapping cooldown_timeout = ([]);
@retain: mapping nonce_callbacks = ([]);
int(1bit) is_active; //Cache of is_active_bot() since we need to check it freuqently.
constant badge_aliases = ([ //Fold a few badges together, and give shorthands for others
"broadcaster": "_mod", "moderator": "_mod", "staff": "_mod",
"subscriber": "_sub", "founder": "_sub", //Founders (the first 10 or 25 subs) have a special badge.
]);
//Go through a message's parameters/tags to get the info about the person
//There may be some non-person info gathered into here too, just for
//convenience; in fact, the name "person" here is kinda orphanned. (Tragic.)
mapping(string:mixed) gather_person_info(mapping params, string msg)
{
string user = params->login || params->user;
mapping ret = (["nick": user, "user": user]); //TODO: Is nick used anywhere? If not, remove.
if (params->user_id && user) //Should always be the case
{
ret->uid = (int)params->user_id;
notice_user_name(user, params->user_id);
}
ret->displayname = params->display_name || user;
ret->msgid = params->id;
ret->badges = ([]);
if (params->badges) foreach (params->badges / ",", string badge) if (badge != "") {
sscanf(badge, "%s/%d", badge, int status);
ret->badges[badge] = status;
if (string flag = badge_aliases[badge]) ret->badges[flag] = status;
}
if (params->emotes)
{
ret->emotes = ({ });
foreach (params->emotes / "/", string emote) if (emote != "")
{
sscanf(emote, "%s:%s", string id, string pos);
foreach (pos / ",", string p) {
sscanf(p, "%d-%d", int start, int end);
if (end < start) continue; //Shouldn't happen (probably a parse failure)
ret->emotes += ({({id, start, end})});
}
}
//Also list all cheer emotes as emotes
int ofs = 0;
foreach (msg / " ", string word) {
//4Head is the only cheeremote with non-alphabetics in the prefix.
//Since we don't want to misparse "4head4000", we special-case it
//by accepting a 4 at the start of the letters (but nowhere else).
sscanf(word, "%[4]%[A-Za-z]%[0-9]%s", string four, string letters, string digits, string blank);
mapping cheer = G->G->cheeremotes[lower_case(four + letters)];
if (cheer && digits != "" && digits != "0" && blank == "") {
//Synthesize an emote ID with a leading space so we know that
//it can't be a normal emote. This may cause some broken images,
//but it should at least allow cheeremotes to be suppressed with
//other emotes.
ret->emotes += ({({"/" + cheer->prefix + "/" + digits, ofs, ofs + sizeof(word) - 1})});
}
ofs += sizeof(word) + 1;
}
sort(ret->emotes[*][1], ret->emotes); //Sort the emotes by start position
}
if (int bits = (int)params->bits) ret->bits = bits;
//ret->raw = params; //For testing
return ret;
}
//May receive up to three parameters: the value to be formatted, the default (which can be a parameter),
//and the channel object
mapping(string:function(string:string)) text_filters = ([
"time_hms": lambda(string tm) {return describe_time_short((int)tm);},
"time_english": lambda(string tm) {return describe_time((int)tm);},
"date_dmy": lambda(string tm, string dflt, object channel) {
object ts = Calendar.Gregorian.Second("unix", (int)tm);
if (string tz = channel->config->timezone) ts = ts->set_timezone(tz) || ts;
return sprintf("%d %s %d", ts->month_day(), ts->month_name(), ts->year_no());
},
"upper": upper_case, "lower": lower_case,
]);
__async__ void raidwatch(int channel, string raiddesc) {
await(task_sleep(30)); //It seems common for streamers to be offline after about 30 seconds
string status = "error";
mixed ex = catch {status = await(channel_still_broadcasting(channel));};
Stdio.append_file("raidwatch.log", sprintf("[%s] %s: %s\n", ctime(time())[..<1], raiddesc, status));
}
@create_hook:
constant allmsgs = ({"object channel", "mapping person", "string msg"});
@create_hook:
constant subscription = ({"object channel", "string type", "mapping person", "string tier", "int qty", "mapping extra", "string msg"});
@create_hook:
constant cheer = ({"object channel", "mapping person", "int bits", "mapping extra", "string msg"});
@create_hook:
constant deletemsg = ({"object channel", "object person", "string target", "string msgid"});
@create_hook:
constant deletemsgs = ({"object channel", "object person", "string target"});
__async__ void voice_enable(string voiceid, string chan, mapping|void tags) {
mapping tok = G->G->user_credentials[(int)voiceid];
werror("Connecting to voice %O...\n", voiceid);
object conn = await(irc_connect(([
"user": tok->login, "pass": "oauth:" + tok->token,
"voiceid": voiceid, //Triggers auto-cleanup when the voice is no longer in use
"capabilities": ({"commands"}),
])));
werror("Voice %O connected, sending to channel %O\n", voiceid, chan);
array msgs = irc_connections[voiceid]; //Note that the queue may be added to while we're connecting.
if (!arrayp(msgs)) {conn->queueclose(); return;} //We've been kicked for auth change. Ignore it.
irc_connections[voiceid] = conn;
conn->yes_reconnect(); //Mark that we need this connection
conn->send(chan, msgs[*], tags);
conn->enqueue(conn->no_reconnect); //Once everything's sent, it's okay to disconnect
}
string subtier(string plan) {
if (plan == "Prime") return "1";
return plan[0..0]; //Plans are usually 1000, 2000, 3000 - I don't know if they're ever anything else?
}
@create_hook:
constant variable_changed = ({"object channel", "string varname", "string newval"});
class channel(mapping identity) {
string name; //name begins with a hash and is all lowercase. Preference: Use this->login (no hash) instead.
string color;
int userid; string login, display_name;
mapping raiders = ([]); //People who raided the channel this (or most recent) stream. Cleared on stream online.
mapping user_badges = ([]); //Latest-seen user badge status (see gather_person_info). Not guaranteed fresh.
//Command names are simple atoms (eg "foo" will handle the "!foo" command), or well-known
//bang-prefixed special triggers (eg "!resub" for a channel's resubscription trigger).
mapping(string:echoable_message) commands = ([]);
//Map a reward ID to the redemption triggers for that reward. Empty arrays should be expunged.
mapping(string:array(string)) redemption_commands = ([]);
mapping botconfig, config;
mapping(int:array) lastmsg = ([]); //The single most recent message from any particular user
protected void create(multiset|void loading, array|void commands) {
botconfig = m_delete(identity, "data") || ([]);
config = identity | botconfig;
name = "#" + config->login; userid = config->userid;
login = config->login; display_name = config->display_name;
if (config->chatlog)
{
if (!channelcolor[name]) {if (++G->G->nextcolor>7) G->G->nextcolor=1; channelcolor[name]=G->G->nextcolor;}
color = sprintf("\e[1;3%dm", channelcolor[name]);
}
else color = "\e[0m"; //Nothing will normally be logged, so don't allocate a color. If logging gets enabled, it'll take a reset to assign one.
//The streamer counts as a mod. Everyone else has to speak in chat to
//show us the badge, after which we'll acknowledge mod status. (For a
//mod-only command, that's trivially easy; for web access, just "poke
//the bot" in chat first.) The helix/moderation/moderators endpoint
//might look like the perfect solution, but it requires broadcaster's
//permission, so it's not actually dependable.
user_badges = G_G_("channel_user_badges", (string)userid);
if (!user_badges[userid]) user_badges[userid] = (["_mod": 1, "broadcaster": 1]);
if (name == "#!demo") user_badges[3141592653589793] = (["_mod": 1, "moderator": 1]); //Fake mod status for the fake mod
/* TODO:
if (have the right permission) twitch_api_request("https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=49497888")->then() {
foreach (__ARGS__[0]->data || ({ }), mapping mod)
if (!user_badges[(int)mod->user_id]) user_badges[(int)mod->user_id] = (["_mod": 1, "moderator": 1]);
};
This won't quite work due to timing and the way credentials get loaded asynchronously.
*/
//Note that !demo has no userid and can't re-fetch login/display name.
if (userid) {
G->G->recent_user_sightings[userid] = login;
get_user_info(userid)->then() {
if (config->login != __ARGS__[0]->login || config->display_name != __ARGS__[0]->display_name) {
//Note: This is asynchronous, but we'll be triggering a reconnect shortly.
werror("User details updated for %O - login %O->%O disp %O->%O\n",
userid, config->login, __ARGS__[0]->login,
config->display_name, __ARGS__[0]->display_name);
G->G->DB->save_sql("update stillebot.botservice set login = :login, display_name = :display_name where twitchid = :id", __ARGS__[0])
->then() {werror("Saved user details\n");}
->thencatch() {werror("Unable to save user details:\n%s\n", describe_backtrace(__ARGS__[0]));};
}
};
}
load_commands(loading, commands);
establish_notifications(userid); //CHECK ME: Is this too soon on initial startup?
}
__async__ void load_commands(multiset|void loading, array|void cmds) {
//Load up the channel's commands. Note that aliases are not stored in the database,
//but are individually available here in the lookup mapping.
commands = ([]);
if (!cmds) cmds = await(G->G->DB->load_commands(userid));
foreach (cmds, mapping cmd) {
echoable_message response = commands[cmd->cmdname] = cmd->content;
if (!mappingp(response)) continue; //No top-level flags, nothing to handle specially.
if (response->aliases) {
mapping duplicate = (response - (<"aliases">)) | (["alias_of": cmd->cmdname]);
foreach (response->aliases / " ", string alias) {
alias -= "!";
if (alias != "") commands[alias] = duplicate;
}
}
if (response->redemption) redemption_commands[response->redemption] += ({cmd->cmdname});
}
//Indicate that we're now done loading. If other things than commands are done
//asynchronously but in parallel, don't remove from loading till ALL are done.
if (loading) loading[userid] = 0;
}
void botconfig_save() {
config = identity | botconfig; //Update the mapping synchronously - we'll also be notified via reconfigure shortly
G->G->DB->save_config(userid, "botconfig", botconfig);
}
void reconfigure(mapping data) { //Called after a bot config change is noticed in the database
config = identity | (botconfig = data);
}
//Drill down into any mapping. Could become global?? maybe??
mapping _path(mapping base, string ... parts) {
mapping ret = base;
foreach (parts, string idx) {
if (undefinedp(ret[idx])) ret[idx] = ([]);
ret = ret[idx];
}
return ret;
}
void remove_bot_from_channel() {
G->G->DB->save_sql("update stillebot.botservice set deactivated = now() where twitchid = :userid and deactivated is null", (["userid": userid]));
}
void channel_online(int uptime) {
//Purge the raider list of anyone who didn't raid since the stream went online.
//This signal comes through a minute or six after the channel actually goes
//online, so we use the current uptime as a signal to know who raided THIS stream
//as opposed to LAST stream.
int went_online = time() - uptime;
raiders = filter(raiders) {return __ARGS__[0] >= went_online;};
}
array(echoable_message|function|string) locate_command(mapping person, string msg)
{
if (mixed f = sscanf(msg, "!%[^# ] %s", string cmd, string param)
&& find_command(this, cmd, person->badges->?_mod, person->badges->?vip))
return ({f, param||""});
return ({0, ""});
}
//TODO: Figure out what this function's purpose is. I frankly have no idea why some
//code is in here, and other code is down in "case PRIVMSG" below. Whatever.
void handle_command(mapping person, string msg, mapping defaults, mapping params)
{
if (person->user) {
mapping p = G_G_("participants", name[1..], person->user);
p->lastnotice = time();
p->userid = person->uid;
}
person->vars = ([
"%s": msg, "{@mod}": person->badges->?_mod ? "1" : "0", "{@sub}": person->badges->?_sub ? "1" : "0",
//Even without broadcaster permissions, it's possible to see the UUID of a reward.
//You can't see the redemption ID, and definitely can't complete/reject it, but you
//would be able to craft a trigger that responds to it.
"{rewardid}": params->custom_reward_id || "",
"{msgid}": params->id || "",
"{usernamecolor}": params->color || "", //Undocumented, mainly here as a toy
]);
event_notify("allmsgs", this, person, msg);
trigger_special("!trigger", person, person->vars);
[mixed cmd, string param] = locate_command(person, msg);
int offset = sizeof(msg) - sizeof(param);
if (msg[offset..offset+sizeof(param)] != param) offset = -1; //TODO: Strip whites from around param without breaking this
person->measurement_offset = offset;
string emoted = "", residue = param;
foreach (person->emotes || ({ }), [string id, int start, int end]) {
emoted += sprintf("%s\uFFFAe%s:%s\uFFFB",
residue[..start - offset - 1], //Text before the emote
id, residue[start-offset..end-offset], //Emote ID and name
);
residue = residue[end - offset + 1..];
offset = end + 1;
}
person->vars["%s"] = param;
person->vars["{@emoted}"] = emoted + residue;
//Functions do not get %s handling. If they want it, they can do it themselves,
//and if they don't want it, it would mess things up badly to do it here.
//(They still get other variable handling.) NOTE: This may change in the future.
//If a function specifically does not want %s handling, it should:
//m_delete(person->vars, "%s");
if (functionp(cmd)) send(person, cmd(this, person, param));
else send(person, cmd, person->vars);
}
__async__ void delete_msg(string uid, string msgid) {
await(G->G->DB->mutate_config(userid, "private") {m_delete(__ARGS__[0][uid] || ([]), msgid);});
G->G->websocket_types->chan_messages->update_one(uid + "#" + userid, msgid);
}
__async__ void send_private_message(string login, echoable_message msg, string destcfg) {
string uid = (string)await(get_user_id(login));
mapping priv = await(G->G->DB->load_config(userid, "private"));
mapping msgs = priv[uid]; if (!msgs) msgs = priv[uid] = ([]);
mapping meta = msgs["_meta"]; if (!meta) meta = msgs["_meta"] = ([]);
int id = ++meta->lastid;
msgs[(string)id] = (["received": time(), "message": msg]);
//NOTE: The destcfg has already been var-substituted, and then it gets reprocessed
//when it gets sent. That's a bit awkward. Maybe the ideal would be to retain it
//unprocessed, but keep the local vars, and then when it's sent, set _changevars?
if (destcfg != "") {
//TODO maybe: make destcfg accept non-string values, then it can just have
//multiple parts.
sscanf(destcfg, ":%d:%s", int timeout, string ack);
msgs[(string)id]->acknowledgement = ack || destcfg;
if (timeout) {
msgs[(string)id]->expiry = time() + timeout;
call_out(delete_msg, timeout, uid, (string)id);
}
}
await(G->G->DB->save_config(userid, "private", priv));
G->G->websocket_types->chan_messages->update_one(uid + "#" + userid, (string)id);
}
mapping(string:string) get_channel_variables(int|string|void uid) {
mapping vars = G->G->DB->load_cached_config(userid, "variables");
mapping ephemvars = G->G->variables[?(string)userid];
if (ephemvars) return vars | ephemvars;
return vars;
}
string set_variable(string var, string val, string action, mapping|void users)
{
//Per-user variable. If you try this without a user context, it will
//use uid 0 aka "root" which doesn't exist in Twitch.
int per_user = sscanf(var, "%s*%s", string user, var);
int ephemeral = sscanf(var, "%s?", var);
var = "$" + var + "?" * ephemeral + "$";
mapping basevars = ephemeral ? G_G_("variables", (string)userid) : G->G->DB->load_cached_config(userid, "variables");
mapping vars = per_user ? _path(basevars, "*", (string)users[?user]) : basevars;
if (action == "add") {
//Add to a variable, REXX-style (decimal digits in strings).
//Anything unparseable is considered to be zero.
val = (string)((int)vars[var] + (int)val);
} else if (action == "spend") {
//Inverse of add, but will fail (and return 0) if the variable
//doesn't have enough value in it.
int cur = (int)vars[var];
if (cur < (int)val) return 0;
val = (string)(cur - (int)val);
}
//Otherwise, keep the string exactly as-is.
vars[var] = val;
if (val == "" && per_user) {
//Per-user variables don't need to store blank
m_delete(vars, var);
if (!sizeof(vars)) m_delete(basevars["*"], (string)users[?user]);
}
if (ephemeral) return val; //Ephemeral variables are not pushed out to listeners.
//Notify those that depend on this. Note that an unadorned per-user variable is
//probably going to behave bizarrely in a monitor, so don't do that; use either
//global variables or namespace to a particular user eg "$49497888*varname$".
G->G->DB->save_config(userid, "variables", basevars);
if (per_user) var = "$" + (string)users[?user] + "*" + var[1..];
else if (!has_value(var, ':')) G->G->websocket_types->chan_variables->update_one(name, var - "$");
//Note that we don't currently push out signals relating to grouped variables.
//The only signal that would matter is "hey, there's a new grouped var", but
//that's going to be fairly rare anyway. The update_one handler would need to
//be enhanced to handle groups, and it's more complexity than it's worth. We
//do, however, use variable_changed, so it's only the /c/variables page that
//is missing out on this information (eg monitors work fine).
event_notify("variable_changed", this, var, val);
return val;
}
//For consistency, this is used for all vars substitutions. If, in the future,
//we make $UNKNOWN$ into an error, or an empty string, or something, this would
//be the place to do it.
string|array _substitute_vars(string|array text, mapping vars, mapping person, mapping users) {
if (arrayp(text)) return _substitute_vars(text[*], vars, person, users);
//Replace shorthands with their long forms. They are exactly equivalent, but the
//long form can be enhanced with filters and/or defaults.
text = replace(text, (["%s": "{param}", "$participant$": "{participant}"]));
if (!vars["{participant}"] && has_value(text, "{participant}") && person->user)
{
//Note that {participant} with a delay will invite people to be active
//before the timer runs out, but only if there's no {participant} prior
//to the delay.
array users = ({ });
int limit = time() - 300; //Find people active within the last five minutes
foreach (G_G_("participants", name[1..]); string name; mapping info)
if (info->lastnotice >= limit && name != person->user) users += ({name});
//If there are no other chat participants, pick the person speaking.
string chosen = sizeof(users) ? random(users) : person->user;
vars["{participant}"] = chosen;
}
//TODO: Don't use the shortforms internally anywhere
vars["{param}"] = vars["%s"]; vars["{username}"] = vars["$$"];
//Scan for two types of substitution - variables and parameters
return substitutions->replace(text) {
sscanf(__ARGS__[0], "%[${]%[^|$}]%[^$}]%[$}]", string type, string kwd, string filterdflt, string tail);
//TODO: Have the absence of a default be actually different from an empty one
//So $var||$ would give an empty string if var doesn't exist, but $var$ might
//throw an error or something. For now, they're equivalent, and $var$ will be
//an empty string if the var isn't found.
[string _, string filter, string dflt] = ((filterdflt + "||") / "|")[..2];
string value;
if (type == "$" && sscanf(kwd, "%s*%s", string user, string basename) && basename) {
//If the kwd is of the format "49497888*varname", and the type is "$",
//look up a per-user variable called "*varname" for that user.
user = users[user] || user;
if (basename == "") value = user; //"$*$" or "$kwd*$" will give you the ID of that user.
else if (mappingp(vars["*"])) value = vars["*"][user][?type + basename + tail];
}
else value = vars[type + kwd + tail];
if (!value || value == "") return dflt;
if (function f = filter != "" && text_filters[filter]) return f(value, dflt, this);
return value;
};
}
//Mutually recursive with _send_recursive. Call this rather than _send_recursive directly
//when you want an error boundary that sends to cfg->error_handler, or if the call is being
//delayed in some way.
void _send_with_catch(mapping person, echoable_message message, mapping vars, mapping cfg) {
if (mixed ex = catch {_send_recursive(person, message, vars, cfg);}) {
//TODO: Carry context around, eg who triggered what command
if (arrayp(ex) && sizeof(ex) && stringp(ex[0])) {
if (cfg->error_handler) {
vars["{error}"] = String.trim(ex[0]);
_send_with_catch(person, cfg->error_handler->message, vars, cfg->error_handler->cfg);
}
report_error("ERROR", String.trim(ex[0]), "");
}
else werror("**** Error in command handling, unexpected format ****\n%s\n", describe_backtrace(ex));
}
}
//Changes to vars[] will propagate linearly. Changes to cfg[] will propagate
//within a subtree only. Change it only with |=.
void _send_recursive(mapping person, echoable_message message, mapping vars, mapping cfg)
{
if (!message) return;
if (!mappingp(message)) message = (["message": message]);
if (message->dest == "//") return; //Comments are ignored. Not even side effects.
if (message->delay && message->delay != "") {
int|string delay = message->delay;
if (!intp(delay)) delay = (int)_substitute_vars((string)delay, vars, person, cfg->users);
//Note that, if a string delay evaluates to zero - say, it has a bad variable
//reference that falls to blank - this will call_out zero, which should work fine.
call_out(_send_with_catch, delay, person, message | (["delay": 0, "_changevars": 1]), vars, cfg);
return;
}
if (message->_changevars)
{
//When a delayed message gets sent, override any channel variables
//with their new values. There are some bizarre corner cases that
//could result from this (eg if you delete a variable, it'll still
//exist in the delayed version), but there's no right way to do it.
vars = vars | get_channel_variables(person->uid);
message->_changevars = 0; //It's okay to mutate this one, since it'll only ever be a bookkeeping mapping from delay handling.
}
if (message->dest == "/builtin") { //Deprecated way to call on a builtin
//NOTE: Prior to 2021-05-16, variable substitution was done on the entire
//target. It's now done only on the builtin_param (below).
sscanf(message->target, "!%[^ ]%*[ ]%s", string cmd, string param);
message = (message - (<"dest", "target">)) | (["builtin": cmd, "builtin_param": param]);
}
if (message->voice) cfg |= (["voice": message->voice]);
//Legacy mode: dest is dest + " " + target, target doesn't exist
if (has_value(message->dest || "", ' ') && !message->target) {
sscanf(message->dest, "%s %s", string d, string t);
cfg |= (["dest": d, "target": _substitute_vars(t, vars, person, cfg->users), "destcfg": message->action || ""]);
}
//Normal mode: Destination and target are separate fields
//Note that message->action was a variables-only form of destcfg, so it is merged in too.
else if (message->dest) cfg |= ([
"dest": message->dest,
"target": String.trim(_substitute_vars(message->target || "", vars, person, cfg->users)),
"destcfg": _substitute_vars(message->action || message->destcfg || "", vars, person, cfg->users),
]);
if (message->builtin) {
object handler = G->G->builtins[message->builtin] || message->builtin; //Chaining can be done by putting the object itself in the mapping
if (objectp(handler)) {
string|array param = _substitute_vars(message->builtin_param || "", vars, person, cfg->users);
if (stringp(param)) param = ({param});
spawn_task(handler->message_params(this, person, param, cfg))->then() {
if (!__ARGS__[0]) return; //No params? No output.
mapping cfg_changes = m_delete(__ARGS__[0], "cfg") || ([]);
_send_with_catch(person, message->message, vars | __ARGS__[0], cfg | cfg_changes);
};
return;
}
else message = (["message": sprintf("Bad builtin name %O", message->builtin)]); //FIXME: Log error instead?
}
echoable_message msg = message->message;
string expr(string input) {
if (!input) return "";
string ret = _substitute_vars(input, vars, person, cfg->users);
if (message->casefold) return command_casefold(ret); //Use the same case-folding algorithm as !command lookups use
return ret;
}
switch (message->conditional) {
case "string": //String comparison. If (after variable substitution) expr1 == expr2, continue.
{
if (expr(message->expr1) == expr(message->expr2)) break; //The condition passes!
msg = message->otherwise;
break;
}
case "contains": //String containment. Similar.
{
if (has_value(expr(message->expr2), expr(message->expr1))) break; //The condition passes!
msg = message->otherwise;
break;
}
case "regexp":
{
if (!message->expr1) break; //A null regexp matches everything
//Note that expr1 does not get variable substitution done. The
//notation for variables would potentially conflict with the
//regexp's own syntax.
object re = simple_regex_cache[message->expr1];
if (!re) re = simple_regex_cache[message->expr1] = Regexp.PCRE(message->expr1);
string matchtext = expr(message->expr2);
int|array result = re->exec(matchtext);
if (arrayp(result)) { //The regexp passes!
//NOTE: Other {regexpNN} vars are not cleared. This may mean
//that nested regexps can both contribute. I may change this
//in the future, if I allow an easy way to set a local var.
foreach (result / 2; int i; [int start, int end])
vars["{regexp" + i + "}"] = matchtext[start..end-1];
break;
}
//Otherwise, the return code is probably NOMATCH (-1). If it isn't, should we
//show something to the user?
msg = message->otherwise;
break;
}
case "number": //Integer/float expression evaluator. Subst into expr, then evaluate. If nonzero, pass. If non-numeric, error out.
{
if (!G->G->evaluate_expr) msg = "ERROR: Expression evaluator unavailable";
else if (mixed ex = catch {
int|float|string value = G->G->evaluate_expr(expr(message->expr1), ({this, cfg}));
if (value != 0 && value != 0.0 && value != "") break; //But I didn't fire an arrow...
//Note that all strings are currently considered true. This MAY change
//in the future, with empty strings being considered false.
msg = message->otherwise;
}) msg = "ERROR: " + (describe_error(ex)/"\n")[0];
break;
}
case "spend":
{
string var = message->expr1;
if (!var || var == "") break; //Blank/missing variable name? Not a functional condition.
string val = set_variable(var, message->expr2, "spend", cfg->users);
if (!val) msg = message->otherwise; //The condition DOESN'T pass if the spending failed.
else if (!has_value(var, '*')) vars["$" + var + "$"] = val; //It usually WILL have an asterisk.
break;
}
case "cooldown": //Timeout (defined in seconds, although the front end may show it as mm:ss or hh:mm:ss)
{
string key = message->cdname + name;
if (has_prefix(key, "*")) key = vars["{uid}"] + key; //Cooldown of "*foo" will be a per-user cooldown.
int delay = cooldown_timeout[key] - time();
if (delay < 0) { //The time has passed!
cooldown_timeout[key] = time() + message->cdlength; //But reset it.
vars["{cooldown}"] = vars["{cooldown_hms}"] = "0";
break;
}
if (message->cdqueue) {
//As well as bouncing to the Otherwise, we queue the original message
//after the delay. (You probably don't often need an Otherwise here.)
cooldown_timeout[key] = time() + delay + message->cdlength;
call_out(_send_with_catch, delay, person, message | (["conditional": 0, "_changevars": 1]), vars, cfg);
}
//Yes, it's possible for the timeout to be 0 seconds.
msg = message->otherwise;
vars["{cooldown}"] = (string)delay;
//Note that the hms format is defined by the span of the cooldown in total,
//not the remaining time. A ten minute timeout, down to its last seconds,
//will still show "00:15". If you want conditional rendering based on the
//remaining time, use {cooldown} in a numeric condition.
vars["{cooldown_hms}"] =
(int)message->cdlength < 60 ? (string)delay :
(int)message->cdlength < 3600 ? sprintf("%d:%02d", delay / 60, delay % 60) :
sprintf("%d:%02d:%02d", delay / 3600, (delay / 60) % 60, delay % 60);
break;
}
case "catch": { //Exception handling
_send_with_catch(person, message | (["conditional": 0]), vars,
cfg | (["error_handler": (["message": message->otherwise, "cfg": cfg])]));
return;
}
default: break; //including UNDEFINED which means unconditional, and 0 which means "condition already processed"
}
if (!msg) return; //If a message doesn't have an Otherwise, it'll end up null.
//cfg->voice could be absent, blank, "0", or a voice ID eg "279141671"
//Absent and blank mean "use the channel default" - config->defvoice - which
//might be zero (meaning that the channel default is the global default).
//"0" means "use the global default, even if it's not the channel default"
//Otherwise, it's the ID of a Twitch user whose voice we should use. Note that
//there's no check to ensure that we have permission to do so; if you add a
//voice but don't grant permission to use chat, all chat messages will just
//fail silently. (This would be fine if it's only used for slash commands.)
string|zero voice = (cfg->voice && cfg->voice != "") ? cfg->voice : config->defvoice;
if (!G->G->DB->load_cached_config(userid, "voices")[voice]) voice = 0; //Ensure that the voice hasn't been deauthenticated since the command was edited
if (!voice) {
//No voice has been selected (either explicitly or as the channel default).
//Use the bot's global default voice, or the intrinsic voice (implicitly zero).
voice = G->G->irc->id[0]->?config->?defvoice;
//Even if this voice hasn't been activated for this channel, that's fine - it is
//implicitly permitted for use by all channels.
}
if (message->mode == "foreach") {
//For now, this only iterates over participants. To expand and generalize this,
//create a builtin that gathers a collection of participants, and then foreach
//will iterate over any collection. The builtin's args would specify the timeout
//(or "no timeout" for all in chat), and would probably collect into something
//akin to a variable. Then foreach mode would iterate over that variable.
//NOTE: Due to Twitch API restrictions, the "everyone in chat" version of this
//requires that the currently-selected voice be a moderator with scope permission
//to read chatters (moderator:read:chatters). This is a tad awkward. Fortunately,
//we can use the "active chatter" mode simply based on sightings in chat. This IS
//going to create a bizarre disconnect; for example, you could say "everyone who
//has been active within the last 15 minutes", and this may well include people
//who are no longer listed in the "all chatters" list.
//TODO: Allow the iteration to do other things than just select a user into "each"
array users = ({ });
if (string varname = message->variable) {
//Iterate over every user for whom there are variables.
//Note that there may not be a specific variable; you'll need to do some
//sort of check eg "$each*varname$" == "" to see if it's actually set.
mapping vars = G->G->DB->load_cached_config(userid, "variables")["*"] || ([]);
if (varname == "*") users = sort(indices(vars)); //Everyone who has any variable, in ID order.
else {
//Everyone who has this variable set, in descending order by the intification of
//that variable. NOTE: If the variable is set to "0" or "foo", the person will be
//included in the result, sorted at position 0; but if the variable is absent,
//they will not be included at all. Thus this can be used - albeit without useful
//sorting - for non-numeric variables too.
array(int) values = ({ });
varname = "$" + replace(varname, "$", "") + "$";
foreach (vars; string uid; mapping v) if (v[varname]) {
users += ({uid});
values += ({-(int)v[varname]}); //Descending sort
}
sort(values, users);
}
} else if (message->participant_activity) {
int limit = time() - (int)message->participant_activity; //eg find people active within the last five minutes
foreach (G_G_("participants", name[1..]); string name; mapping info)
if (info->lastnotice >= limit) users += ({info->userid});
} else {
//Ask Twitch who's currently in chat.
mapping tok = G->G->user_credentials[(int)voice];
get_helix_paginated(
"https://api.twitch.tv/helix/chat/chatters",
(["broadcaster_id": (string)userid, "moderator_id": (string)voice]),
(["Authorization": "Bearer " + tok->token]),
)->then() {
cfg |= (["users": cfg->users | (["each": "0"])]);
//Now that we've disconnected both cfg and cfg->users, it's okay to mutate.
foreach (__ARGS__[0], mapping user) {
cfg->users->each = user->user_id;
_send_recursive(person, msg, vars, cfg);
}
};
}
cfg |= (["users": cfg->users | (["each": "0"])]);
//Ditto, mutation is okay now.
foreach (users, cfg->users->each) _send_recursive(person, msg, vars, cfg);
return;
}
if (mappingp(msg)) {_send_recursive(person, (["conditional": 0]) | msg, vars, cfg); return;} //CJA 20230623: See other of this datemark.
if (arrayp(msg))
{
if (message->mode == "random") {
//Are there any options that are mappings with a "weight" attribute?
//If so, do a weighted random.
int totweight = 0;
foreach (msg, echoable_message m)
totweight += (mappingp(m) && m->weight) || 1;
if (totweight == sizeof(msg)) msg = random(msg); //No weighting given, nice and easy.
else {
int selection = random(totweight);
//If this turns out to be really expensive, precalculate the cumulative sums above
foreach (msg, msg) { //This is sufficiently insane, I think.
selection -= (mappingp(msg) && msg->weight) || 1;
if (selection < 0) break;
}
}
}
else if (message->mode == "rotate") {
string varname = message->rotatename;
if (!varname || varname == "") varname = ".borked"; //Shouldn't happen, just guard against crashes
int val = (int)vars["$" + varname + "$"];
if (val >= sizeof(msg)) val = 0;
msg = msg[val];
vars["$" + varname + "$"] = set_variable(varname, (string)(val + 1), "", cfg->users);
} else {
//CJA 20230623: This previously kept all attributes from the current
//message except for conditional, and merged that with the message.
//Why? I don't understand what I was thinking at the time (see fbd850)
//and it's causing issues with a random that contains groups. If it is
//needed, it may be better to whitelist attributes to retain, rather
//than blacklisting those to remove.
foreach (msg, echoable_message m)
_send_recursive(person, m, vars, cfg);
return;
}
_send_recursive(person, (["conditional": 0, "message": msg]), vars, cfg); //CJA 20230623: See other.
return;
}
//And now we have just a single string to send.
string prefix = _substitute_vars(message->prefix || "", vars, person, cfg->users);
msg = _substitute_vars(msg, vars, person, cfg->users);
string dest = cfg->dest || "", target = cfg->target || "", destcfg = cfg->destcfg || "";
//Variable management. Note that these are silent, so commands may want to pair
//these with public messages. (Silence is perfectly acceptable for triggers.)
if (dest == "/set" && sscanf(target, "%[*?A-Za-z0-9:]", string var) && var && var != "")
{
string val = set_variable(var, msg, destcfg, cfg->users);
//Variable names with asterisks are per-user (possibly this, possibly another),
//and should not be stuffed back into the vars mapping.
if (!has_value(var, '*')) vars["$" + var + "$"] = val;
return;
}
if (echoable_message cmd = dest == "/chain" && commands[target]) {
//You know what the chain of command is? It's a chain that I get, and then
//I BREAK so that nobody else can ever be in command.
if (cfg->chaindepth) return; //For now, no chaining if already chaining - hard and fast rule.
//Note that cfg is largely independent in the chained-to command; the
//only values retained are the chaindepth itself, and the "current user"
//(normally the one who invoked it). Everything else - voice selection,
//destination, etc - is reset to defaults as per the normal start of a
//command. This does mean that a "User Vars" with no keyword will carry,
//where one with a keyword won't. Unsure if this is good or bad.
_send_recursive(person, cmd, vars | (["%s": destcfg]),
(["users": (["": cfg->users[""]]), "chaindepth": cfg->chaindepth + 1]));
return;
}
if (msg == "") return; //All other message destinations make no sense if there's no message.
if (dest == "/web")
{
if (target == "") return; //Attempting to send to a borked destination just silences it
send_private_message(target - "@", msg, destcfg);
return; //Nothing more to send here.
}
//Whispers are currently handled with a command prefix. The actual sending
//is done via twitch_apis.pike which hooks the slash commands.
if (dest == "/w") prefix = sprintf("%s %s %s", dest, target, prefix);
//Any other destination, just send it to open chat (there used to be a facility
//for sending to other channels, but this is no longer the case).
//Wrap to 500 characters to fit inside the Twitch limit
array msgs = ({ });
while (sizeof(msg) > 500)
{
int pos = 500 - sizeof(prefix);
while (msg[pos] != ' ' && pos--) ;
if (!pos) pos = 500 - sizeof(prefix);
msgs += ({prefix + String.trim(msg[..pos-1])});
msg = String.trim(msg[pos+1..]);
}
msgs += ({prefix + msg});
if (G->G->send_chat_command) {
//Attempt to send the message(s) via the Twitch APIs if they have slash commands
//Any that can't be sent that way will be sent the usual way.
msgs = filter(msgs, G->G->send_chat_command, this, voice);
if (!sizeof(msgs)) return;
}
mapping tags = ([]);
if (dest == "/reply") tags->reply_parent_msg_id = target;
if (cfg->callback) {
//Provide a nonce for the messages, so we call the callback later.
//Note that the vars could be mutated between here and the callback,
//so we copy them. Note also that we'll use the same nonce for them
//all, and only call the callback once.
string nonce = sprintf("stillebot-%d", ++G->G->nonce_counter);
tags->client_nonce = nonce;
nonce_callbacks[nonce] = ({cfg->callback, vars | ([])});
}
if (voice == (string)G->G->bot_uid) voice = 0; //Use the intrinsic connection if possible.
if (objectp(irc_connections[voice])) irc_connections[voice]->send(name, msgs[*], tags);
else {
if (!arrayp(irc_connections[voice])) spawn_task(voice_enable(voice, name, tags));
irc_connections[voice] += msgs;
}
}
//Send any sort of echoable message.
//The vars will be augmented by channel variables, and can be changed in flight.
//NOTE: To specify a default destination but allow it to be overridden, just wrap
//the entire message up in another layer: (["message": message, "dest": "..."])
//NOTE: Messages are a hybrid of a tree and a sequence. Attributes apply to the
//tree (eg setting a dest applies it to that branch, and setting a delay will
//defer sending of that entire subtree), but vars apply linearly, EXCEPT that
//changes apply at time of sending. This creates a minor wart in the priority of
//variables; normally, something in the third arg takes precedence over a channel
//var of the same name, but if the message is delayed, the inverse is true. This
//should never actually affect anything, though, as vars should contain things
//like "%s", and channel variables are like "$foo$".
//NOTE: Variables should be able to be used in any user-editable text. This means
//that all messages involving user-editable text need to come through send(), and
//any that don't should be considered buggy.
//If the ID of the message is needed, pass a callback (note that this is intended
//for single-message sends, and if multiple messages are sent, it will only be
//called once). Example: void cb(mapping vars, mapping params) --> params->id is
//the message ID that just got sent.
void send(mapping person, echoable_message message, mapping|void vars, function|void callback)
{
if (!is_active) Stdio.append_file("inactivebot.log", sprintf(#"==================\n%sInactive bot sending message:\nperson %O\nmessage %O\nvars %O\n%s\n",
ctime(time()), person, message, vars, describe_backtrace(backtrace())));
vars = get_channel_variables(person->uid) | (vars || ([]));
vars["$$"] = person->displayname || person->user;
vars["{uid}"] = (string)person->uid; //Will be "0" if no UID known
_send_with_catch(person, message, vars, (["callback": callback, "users": (["": (string)person->uid])]));
}
//Expand all channel variables, except for {participant} which usually won't
//make sense anyway. If you want $$ or %s or any of those, provide them in the
//second parameter; otherwise, just expand_variables("Foo is $foo$.") is enough.
string expand_variables(string text, mapping|void vars, mapping|void users)
{
vars = get_channel_variables() | (vars || ([]));
return _substitute_vars(text, vars, ([]), users || ([]));
}
__async__ void record_raid(int fromid, string fromname, int toid, string toname, int|void ts, int|void viewers)
{
write("Detected a raid: %O %O %O %O %O\n", fromid, fromname, toid, toname, ts);
if (!ts) ts = time();
//JavaScript timestamps seem to be borked (given in ms instead of seconds).
//Real timestamps won't hit this threshold until September 33658. At some
//point close to that date (!), adjust this threshold.
else if (ts > 1000000000000) ts /= 1000;
if (!fromid) fromid = await(get_user_id(fromname));
if (!toid) toid = await(get_user_id(toname));
raidwatch(fromid, sprintf("%s raided %s", fromname, toname));
await(G->G->DB->add_raid(fromid, toid, ([
"time": ts,
"from": fromname, "to": toname,
"viewers": undefinedp(viewers) ? -1 : (int)viewers,
])));
}
mapping subbomb_ids = ([]);
void irc_message(string type, string chan, string msg, mapping params) {
mapping(string:mixed) person = gather_person_info(params, msg);
if (person->uid && person->badges) user_badges[person->uid] = person->badges;
lastmsg[(int)person->uid] = ({msg, params});
if (!is_active) return;
mapping responsedefaults;
//For some unknown reason, certain types of notification come through
//as PRIVMSG when they would more logically be a NOTICE. They're usually
//suppressed from the default chat view, but are visible to bots.
if (params->user == "jtv" && type == "PRIVMSG") type = "NOTICE";
switch (type)
{
case "NOTICE": case "USERNOTICE": switch (params->msg_id)
{
case "unrecognized_cmd": werror("NOTICE: %O\n", msg); break; //The message already says "Unrecognized command"
case "slow_on": case "slow_off": break; //Channel is now/no longer in slow mode
case "emote_only_on": case "emote_only_off": break; //Channel is now/no longer in emote-only mode
case "subs_on": case "subs_off": break; //Channel is now/no longer in sub-only mode
case "followers_on": case "followers_off": break; //Channel is now/no longer in follower-only mode (regardless of minimum time)
case "followers_on_zero": break; //Regardless? Not quite; if it's zero-second followers-only mode, it's separate.
case "msg_duplicate": case "msg_slowmode": case "msg_timedout": case "msg_banned": case "msg_requires_verified_phone_number":
/* Last message wasn't sent, for some reason. There seems to be no additional info in the tags.
- Your message was not sent because it is identical to the previous one you sent, less than 30 seconds ago.
- This room is in slow mode and you are sending messages too quickly. You will be able to talk again in %d seconds.
- You are banned from talking in %*s for %d more seconds.
All of these indicate that the most recent message wasn't sent. Is it worth trying to retrieve that message?
*/
break;
case "ban_success": break; //Just banned someone. Probably only a response to an autoban.
case "raid": //Incoming raids already get announced and we don't get any more info
{
//Stdio.append_file("incoming_raids.log", sprintf("%s Debug incoming raid: chan %s user %O params %O\n",
// ctime(time())[..<1], name, person->displayname, params));
//NOTE: The destination "room ID" might not remain forever.
//If it doesn't, we'll need to get the channel's user id instead.
raiders[(int)params->user_id] = time();
record_raid((int)params->user_id, person->displayname,
(int)params->room_id, name[1..], (int)params->tmi_sent_ts,
(int)params->msg_param_viewerCount);
trigger_special("!raided", person, (["{viewers}": params->msg_param_viewerCount]));
break;
}
case "unraid": break; //Raid has been cancelled, nothing to see here.
case "rewardgift": //Used for special promo messages eg "so-and-so's cheer just gave X people a bonus emote"
{
//write("DEBUG REWARDGIFT: chan %s disp %O user %O params %O\n",
// name, person->displayname, person->user, params);
break;
}
case "sub": {
string tier = subtier(params->msg_param_sub_plan);
Stdio.append_file("subs.log", sprintf("\n%sDEBUG SUB: chan %s person %O params %O\n", ctime(time()), name, person->user, params)); //Where is the multimonth info?
trigger_special("!sub", person, ([
"{tier}": tier,
"{multimonth}": params->msg_param_multimonth_duration || "1",
//There's also msg_param_multimonth_tenure - what happens when they get announced? Does duration remain and tenure count up?
]));
event_notify("subscription", this, "sub", person, tier, 1, params, "");
break;
}
case "resub": {
string tier = subtier(params->msg_param_sub_plan);
Stdio.append_file("subs.log", sprintf("\n%sDEBUG RESUB: chan %s person %O params %O\n", ctime(time()), name, person->user, params)); //Where is the multimonth info?
trigger_special("!resub", person, ([
"{tier}": tier,
"{months}": params->msg_param_cumulative_months,
"{streak}": params->msg_param_streak_months || "",
"{multimonth}": params->msg_param_multimonth_duration || "1", //Ditto re tenure
"{msg}": msg, "{msgid}": params->id || "",
]));
event_notify("subscription", this, "resub", person, tier, 1, params, msg);
break;
}
case "giftpaidupgrade": break; //Pledging to continue a subscription (first introduced for the Subtember special in 2018, and undocumented)
case "anongiftpaidupgrade": break; //Ditto but when the original gift was anonymous
case "primepaidupgrade": break; //Similar to the above - if you were on Prime but now pledge to continue, which could be done half price Subtember 2019.
case "standardpayforward": break; //X is paying forward the Gift they got from Y to Z!
case "communitypayforward": break; //X is paying forward the Gift they got from Y to the community!
case "viewermilestone": switch (params->msg_param_category) {
case "watch-streak": //"X sparked a watch streak!" params->msg_param_category == "watch-streak", also has a msg_param_value == months
trigger_special("!watchstreak", person, ([
"{months}": params->msg_param_value,
"{reward}": params->msg_param_copoReward,
]));
break;
default: break;
}
break;
case "subgift":
{
string tier = subtier(params->msg_param_sub_plan);
Stdio.append_file("subs.log", sprintf("\n%sDEBUG SUBGIFT: chan %s id %O origin %O bomb %d\n", ctime(time()), name, params->id, params->msg_param_origin_id, subbomb_ids[params->msg_param_origin_id]));
//Note: Sub bombs get announced first, followed by their individual gifts.
//It may be that the msg_param_origin_id is guaranteed unique, but in case
//it can't, we count down the messages as we see them.
if (subbomb_ids[params->msg_param_origin_id] > 0) {
subbomb_ids[params->msg_param_origin_id]--;
params->came_from_subbomb = "1"; //Hack in an extra parameter
}
/*write("DEBUG SUBGIFT: chan %s disp %O user %O mon %O recip %O multi %O\n",
name, person->displayname, person->user,
params->msg_param_months, params->msg_param_recipient_display_name,
params->msg_param_gift_months);*/
trigger_special("!subgift", person, ([
"{tier}": tier,
"{months}": params->msg_param_cumulative_months || params->msg_param_months || "1",
"{streak}": params->msg_param_streak_months || "",
"{recipient}": params->msg_param_recipient_display_name,
"{multimonth}": params->msg_param_gift_months || "1",
"{from_subbomb}": params->came_from_subbomb || "0",
]));
//Other params: login, user_id, msg_param_recipient_user_name, msg_param_recipient_id,
//msg_param_sender_count (the total gifts this person has given in this channel)
//Remember that all params are strings, even those that look like numbers
event_notify("subscription", this, "subgift", person, tier, 1, params, "");
break;
}
case "submysterygift":
{
string tier = subtier(params->msg_param_sub_plan);
Stdio.append_file("subs.log", sprintf("\n%sDEBUG SUBBOMB: chan %s person %O count %O id %O\n", ctime(time()), name, person, params->msg_param_mass_gift_count, params->msg_param_origin_id));
subbomb_ids[params->msg_param_origin_id] += (int)params->msg_param_mass_gift_count;
/*write("DEBUG SUBGIFT: chan %s disp %O user %O gifts %O multi %O\n",
name, person->displayname, person->user,
params->msg_param_mass_gift_count,
params->msg_param_gift_months);*/
trigger_special("!subbomb", person, ([
"{tier}": tier,
"{gifts}": params->msg_param_mass_gift_count,
//TODO: See if this can actually happen, and if not, drop it
"{multimonth}": params->msg_param_gift_months || "1",
]));
event_notify("subscription", this, "subbomb", person, tier,
(int)params->msg_param_mass_gift_count, params, "");
break;
}
case "extendsub":
{