Skip to content

Commands

Chat commands

The base ChatCommand abstract class, and some commonly used derivatives S.D.G.

ChatCommand

Chat command abstract class

Source code in rumchat_actor/commands.py
 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
class ChatCommand():
    """Chat command abstract class"""
    def __init__(self, name, actor, target = None, **kwargs):
        """Chat command abstract class
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        name (str): The !name of the command.
        actor (RumleChatActor): The RumleChatActor host object.
        cooldown (int | float): How long to wait before allowing the command to be run again.
            Defaults to static.Message.send_cooldown
        amount_cents (int): The minimum cost of the command.
            Defaults to free.
        exclusive (bool): If this command can only be run by users with allowed badges.
            Defaults to False.
        allowed_badges (list): Badges that are allowed to run this command (if it is exclusive); ignored if exclusive is False.
            "admin" is added internally.
            Defaults to ["subscriber"]
        free_badges (list): Badges which, if borne, give the user free-of-charge command access even if amount_cents > 0.
            "admin" is added internally.
            Defaults to ["moderator"]
        target (callable): The command function(message, act_props, actor) to call.
            Defaults to self.run"""

        assert " " not in name, "Name cannot contain spaces"
        self.name = name
        self.actor = actor

        #Don't let the cooldown be shorter than we can send messages
        self.cooldown = kwargs.get("cooldown", static.Message.send_cooldown)
        assert self.cooldown >= static.Message.send_cooldown, \
            f"Cannot set a cooldown shorter than {static.Message.send_cooldown}"

        #Cost of the command
        self.amount_cents = kwargs.get("amount_cents", 0)

        #Is this command exclusive to only certain user badges?
        self.exclusive = kwargs.get("exclusive", False)

        #Allowed badges if this is exclusive
        #Admin can always run any command
        self.allowed_badges = ["admin"] + kwargs.get("allowed_badges", ["subscriber"])

        #Free-of-charge access badges if this command is paid
        #Admin always has free-of-charge usage
        self.free_badges = ["admin"] + kwargs.get("free_badges", ["moderator"])

        self.last_use_time = 0 #Last time the command was called
        self.target = target #Callable to run
        self.__set_help_message = None #The externally set help message of this command

    @property
    def help_message(self):
        """The help message for this command"""
        if self.__set_help_message:
            return self.__set_help_message

    @help_message.setter
    def help_message(self, new):
        """Set the help message for this command externally

    Args:
        new (str): The new help message."""

        self.__set_help_message = str(new)

    def call(self, message, act_props: dict):
        """The command was called

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        #this command is exclusive, and the user does not have the required badge
        if self.exclusive and \
            not (True in [badge.slug in self.allowed_badges for badge in message.user.badges]):

            self.actor.send_message(f"@{message.user.username} That command is exclusive to: " +
                                    ", ".join(self.allowed_badges)
                                    )

            return

        #The command is still on cooldown
        if (curtime := time.time()) - self.last_use_time < self.cooldown:
            self.actor.send_message(
                f"@{message.user.username} That command is still on cooldown. " +
                f"Try again in {int(self.last_use_time + self.cooldown - curtime + 0.5)} seconds."
                )

            return

        #the user did not pay enough for the command and they do not have a free pass
        if message.rant_price_cents < self.amount_cents and \
            not (True in [badge.slug in self.free_badges for badge in message.user.badges]):

            self.actor.send_message("@" + message.user.username +
                                    f" That command costs ${self.amount_cents/100:.2f}."
                                    )
            return

        #the command was called successfully
        self.run(message, act_props)

        #Mark the last use time for cooldown
        self.last_use_time = time.time()

    def run(self, message, act_props: dict):
        """Dummy run method, for when calling the command was successful.

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        if self.target:
            self.target(message, act_props, self.actor)
            return

        #Run method was never defined
        self.actor.send_message("@" + message.user.username +
                                "This command never had a target defined, so it doesn't do anything. :-)"
                                )

help_message property writable

The help message for this command

__init__(name, actor, target=None, **kwargs)

Chat command abstract class Instance this object, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
name str

The !name of the command.

required
actor RumleChatActor

The RumleChatActor host object.

required
cooldown int | float

How long to wait before allowing the command to be run again. Defaults to static.Message.send_cooldown

required
amount_cents int

The minimum cost of the command. Defaults to free.

required
exclusive bool

If this command can only be run by users with allowed badges. Defaults to False.

required
allowed_badges list

Badges that are allowed to run this command (if it is exclusive); ignored if exclusive is False. "admin" is added internally. Defaults to ["subscriber"]

required
free_badges list

Badges which, if borne, give the user free-of-charge command access even if amount_cents > 0. "admin" is added internally. Defaults to ["moderator"]

required
target callable

The command function(message, act_props, actor) to call. Defaults to self.run

None
Source code in rumchat_actor/commands.py
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
def __init__(self, name, actor, target = None, **kwargs):
    """Chat command abstract class
Instance this object, then pass it to RumbleChatActor().register_command().

Args:
    name (str): The !name of the command.
    actor (RumleChatActor): The RumleChatActor host object.
    cooldown (int | float): How long to wait before allowing the command to be run again.
        Defaults to static.Message.send_cooldown
    amount_cents (int): The minimum cost of the command.
        Defaults to free.
    exclusive (bool): If this command can only be run by users with allowed badges.
        Defaults to False.
    allowed_badges (list): Badges that are allowed to run this command (if it is exclusive); ignored if exclusive is False.
        "admin" is added internally.
        Defaults to ["subscriber"]
    free_badges (list): Badges which, if borne, give the user free-of-charge command access even if amount_cents > 0.
        "admin" is added internally.
        Defaults to ["moderator"]
    target (callable): The command function(message, act_props, actor) to call.
        Defaults to self.run"""

    assert " " not in name, "Name cannot contain spaces"
    self.name = name
    self.actor = actor

    #Don't let the cooldown be shorter than we can send messages
    self.cooldown = kwargs.get("cooldown", static.Message.send_cooldown)
    assert self.cooldown >= static.Message.send_cooldown, \
        f"Cannot set a cooldown shorter than {static.Message.send_cooldown}"

    #Cost of the command
    self.amount_cents = kwargs.get("amount_cents", 0)

    #Is this command exclusive to only certain user badges?
    self.exclusive = kwargs.get("exclusive", False)

    #Allowed badges if this is exclusive
    #Admin can always run any command
    self.allowed_badges = ["admin"] + kwargs.get("allowed_badges", ["subscriber"])

    #Free-of-charge access badges if this command is paid
    #Admin always has free-of-charge usage
    self.free_badges = ["admin"] + kwargs.get("free_badges", ["moderator"])

    self.last_use_time = 0 #Last time the command was called
    self.target = target #Callable to run
    self.__set_help_message = None #The externally set help message of this command

call(message, act_props)

The command was called

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
 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
def call(self, message, act_props: dict):
    """The command was called

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    #this command is exclusive, and the user does not have the required badge
    if self.exclusive and \
        not (True in [badge.slug in self.allowed_badges for badge in message.user.badges]):

        self.actor.send_message(f"@{message.user.username} That command is exclusive to: " +
                                ", ".join(self.allowed_badges)
                                )

        return

    #The command is still on cooldown
    if (curtime := time.time()) - self.last_use_time < self.cooldown:
        self.actor.send_message(
            f"@{message.user.username} That command is still on cooldown. " +
            f"Try again in {int(self.last_use_time + self.cooldown - curtime + 0.5)} seconds."
            )

        return

    #the user did not pay enough for the command and they do not have a free pass
    if message.rant_price_cents < self.amount_cents and \
        not (True in [badge.slug in self.free_badges for badge in message.user.badges]):

        self.actor.send_message("@" + message.user.username +
                                f" That command costs ${self.amount_cents/100:.2f}."
                                )
        return

    #the command was called successfully
    self.run(message, act_props)

    #Mark the last use time for cooldown
    self.last_use_time = time.time()

run(message, act_props)

Dummy run method, for when calling the command was successful.

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def run(self, message, act_props: dict):
    """Dummy run method, for when calling the command was successful.

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    if self.target:
        self.target(message, act_props, self.actor)
        return

    #Run method was never defined
    self.actor.send_message("@" + message.user.username +
                            "This command never had a target defined, so it doesn't do anything. :-)"
                            )

ClipDownloadingCommand

Bases: ChatCommand

Save clips of the livestream by downloading stream chunks from Rumble, works remotely

Source code in rumchat_actor/commands.py
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
class ClipDownloadingCommand(ChatCommand):
    """Save clips of the livestream by downloading stream chunks from Rumble, works remotely"""
    def __init__(self, actor, name = "clip", default_duration = 60, max_duration = 120, clip_save_path = "." + os.sep):
        """Save clips of the livestream by downloading stream chunks from Rumble, works remotely.
    Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumchat Actor.
        name (str): The name of the command.
            Defaults to "clip"
        default_duration (int): How long the clip will last in seconds if no duration is specified on run.
            Defaults to 60
        max_duration (int): How long the clip can be set to be in seconds on run.
            Defaults to 120
        clip_save_path (str): Where to save clips to when they are made.
            Defaults to "."
        """

        super().__init__(name = name, actor = actor, cooldown = default_duration)
        self.default_duration = default_duration
        self.max_duration = max_duration
        self.clip_save_path = clip_save_path.removesuffix(os.sep) + os.sep #Where to save the completed clips
        self.ready_to_clip = False
        #self.streamer_main_page_url = self.actor.streamer_main_page_url #Make sure we have this before we try to start recording
        #self.stream_is_live = False #Wether or not the stream is live, we find this later
        self.running_clipsaves = 0 #How many clip save operations are running
        self.unavailable_qualities = [] #Stream qualities that are not available (cause a 404)
        self.average_ts_download_times = {} #The average time it takes to download a TS chunk of a given stream quality
        self.ts_durations = {} #The duration of a TS chunk of a given stream quality
        self.is_dvr = False #Wether the stream is a DVR or not, detected later. No TS cache is needed if it is
        self.use_quality = None #The quality of stream to use, detected later, based on download speeds
        self.ts_url_start = "" #The start of the m3u8 and TS URLs, to be formatted with the selected quality, detected later
        self.m3u8_filename = "" #The filename of the m3u8 playlist, will be either chunklist.m3u8 or chunklist_DVR.m3u8, detected later
        self.saved_ts = {} #TS filenames : Tempfile objects containing TS chunks
        self.discarded_ts = [] #TS names that were saved then deleted
        self.save_format = static.Clip.save_extension #Format that clips are saved in. For ClipUploader
        self.clip_uploader = None #An object to upload the clips when they are complete
        self.recorder_thread = threading.Thread(target = self.record_loop, daemon = True)
        self.run_recorder = True
        self.recorder_thread.start()

    @property
    def help_message(self):
        """The help message for this command"""
        return f"Save a clip from the livestream. Use {static.Message.command_prefix}{self.name} [duration] [custom clip name]." + \
            f"Default duration is {self.default_duration}, max duration is {self.max_duration}."

    def get_ts_list(self, quality):
        """Download an m3u8 playlist and parse it for TS filenames

    Args:
        quality (str): The quality specifier used in the TS URL.

    Returns:
        Files (list): Lines of the TS playlist that do not start with #"""

        assert self.ts_url_start and self.m3u8_filename, \
            "Must have the TS URL start and the m3u8 filename before this runs"
        m3u8 = requests.get(self.ts_url_start.format(quality = quality) + \
            self.m3u8_filename, timeout = static.REQUEST_TIMEOUT).text
        return [line for line in m3u8.splitlines() if not line.startswith("#")]

    def record_loop(self):
        """Start and run the recorder system"""

        #Get the base URL for the wualities listing
        m3u8_qualities_url = static.URI.m3u8_qualities_list.format(stream_id_b36 = self.actor.stream_id_b36)

        m3u8_qualities_raw = requests.get(m3u8_qualities_url, timeout = static.REQUEST_TIMEOUT).text

        m3u8_quality_urls_all = [line for line in m3u8_qualities_raw.splitlines() if not line.startswith("#")]
        ts_url_default = m3u8_quality_urls_all[-1]

        #Is this a DVR stream?
        self.is_dvr = ts_url_default.endswith("DVR.m3u8")

        self.ts_url_start = ts_url_default[:ts_url_default.rfind("/")] + "_{quality}/"

        self.m3u8_filename = ts_url_default[ts_url_default.rfind("/") + 1 ]

        self.get_quality_info()

        if self.is_dvr:
            self.use_quality = [q for q in static.Clip.Download.stream_qualities if q not in self.unavailable_qualities][-1]
            print("Not using TS cache for clips since stream is DVR. Ready to clip.")
            self.run_recorder = False
            self.ready_to_clip = True
            return

        #Find the best quality we can use
        for quality in static.Clip.Download.stream_qualities:
            if quality in self.unavailable_qualities:
                continue
            if self.average_ts_download_times[quality] > self.ts_durations[quality] / static.Clip.Download.speed_factor_req:
                continue
            self.use_quality = quality

        if not self.use_quality:
            print("No available TS qualities for cache")
            return

        self.ready_to_clip = True
        print("Starting ring buffer TS cache...")
        while self.run_recorder:
            #Get the list of TS chunks, filtering out TS chunks that we already have / had
            try:
                new_ts_list = [ts for ts in self.get_ts_list(self.use_quality) if ts not in self.saved_ts.values() and ts not in self.discarded_ts]
            except (AttributeError, requests.exceptions.ReadTimeout):
                print("Failed to get m3u8 playlist")
                continue

            #We just started recording, only download the latest TS
            if not self.saved_ts:
                self.discarded_ts = new_ts_list[:-1]
                new_ts_list = new_ts_list[-1:]

            #Save the unsaved TS chunks to temporary files
            for ts_name in new_ts_list:
                try:
                    data = requests.get(self.ts_url_start.format(quality = self.use_quality) + ts_name, timeout = static.REQUEST_TIMEOUT).content
                except (AttributeError, requests.exceptions.ReadTimeout): #The request failed or has no content
                    print("Failed to save ", ts_name)
                    continue
                f = tempfile.NamedTemporaryFile()
                f.write(data)
                f.file.close()
                self.saved_ts[ts_name] = f

            #We should be deleting old clips, and we have more than enough to fill the max duration
            while not self.running_clipsaves and (len(self.saved_ts) - 1) * self.ts_durations[self.use_quality] > self.max_duration:
                oldest_ts = list(self.saved_ts.keys())[0]
                self.saved_ts[oldest_ts].close() #close the tempfile
                del self.saved_ts[oldest_ts]
                self.discarded_ts.append(oldest_ts)

            #Wait a moment before the next m3u8 download
            time.sleep(1)

    def get_quality_info(self):
        """Get information on the stream quality options: Download time, TS length, availability"""

        print("Getting info on stream qualities")
        assert self.ts_url_start, "Must have start of TS URL before this runs"
        for quality in static.Clip.Download.stream_qualities:
            download_times = []
            chunk_content = None #The content of a successful chunk download. used for duration checking
            for _ in range(static.Clip.Download.speed_test_iter):
                r1 = None
                try:
                    r1 = requests.get(self.ts_url_start.format(quality = quality) + self.m3u8_filename, timeout = static.REQUEST_TIMEOUT)
                except requests.exceptions.ReadTimeout:
                    print("Timeout for m3u8 playlist download")
                    download_times.append(static.REQUEST_TIMEOUT + 1)
                    continue

                if r1.status_code == 404:
                    print("404 for", self.ts_url_start.format(quality = quality) + self.m3u8_filename, "so assuming", quality, "quality is not available.")
                    self.unavailable_qualities.append(quality)
                    break

                #Download a chunk and time it
                ts_chunk_names = [l for l in r1.text if not l.startswith("#")]
                start_time = time.time()
                r2 = None
                try:
                    r2 = requests.get(self.ts_url_start.format(quality = quality) + ts_chunk_names[-1], timeout = static.REQUEST_TIMEOUT)
                except requests.exceptions.ReadTimeout:
                    print("Timeout for TS chunk download")
                    download_times.append(static.REQUEST_TIMEOUT + 1)
                    continue
                if r2.status_code != 200 or not r2.content:
                    print("TS chunk download unsuccessful:", r2.status_code)
                    continue
                download_times.append(time.time() - start_time)
                chunk_content = r2.content

            if not download_times and quality not in self.unavailable_qualities:
                print("No successful chunk downloads for", quality, "so setting it as unavailable")
                self.unavailable_qualities.append(quality)
                continue

            #Get chunk duration
            ts = tempfile.NamedTemporaryFile()
            ts.write(chunk_content)
            ts.file.close()
            self.ts_durations[quality] = VideoFileClip(ts.name).duration
            ts.close()

            #Calculate average download time
            self.average_ts_download_times[quality] = sum(download_times) / len(download_times)

    def run(self, message, act_props: dict):
        """Make a clip

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        #We are not ready for clipping
        if not self.ready_to_clip or not (self.is_dvr or self.saved_ts):
            self.actor.send_message(f"@{message.user.username} Not ready for clip saving yet.")
            return

        segs = message.text.split()
        #Only called clip, no arguments
        if len(segs) == 1:
            self.save_clip(self.default_duration)

        #Arguments were passed
        else:
            #The first argument is a number
            if segs[1].isnumeric():
                #Invalid length passed
                if not 0 < int(segs[1]) <= self.max_duration:
                    self.actor.send_message(f"@{message.user.username} Invalid clip length.")
                    return

                #Only length was specified
                if len(segs) == 2:
                    self.save_clip(int(segs[1]))

                #A name was also specified
                else:
                    self.save_clip(int(segs[1]), "_".join(segs[2:]))

            #The first argument is not a number, treat it as a filename
            else:
                self.save_clip(self.default_duration, "_".join(segs[1:]))

    def save_clip(self, duration, filename = None):
        """Start a clip saver thread with the given parameters

    Args:
        duration (int): How long the clip should be.
        filename (str): What to name the saved clip file.
            Defaults to None, auto-generate a filename.
    """

        self.running_clipsaves += 1

        #This is a DVR stream
        if self.is_dvr:
            available_chunks = self.get_ts_list(self.use_quality)

        #this is a passthrough stream
        else:
            available_chunks = list(self.saved_ts.keys())

        #Not enough TS for the full clip duration
        if len(available_chunks) * self.ts_durations[self.use_quality] < duration:
            print("Not enough TS to fulfil full duration")
            use_ts = available_chunks

        #We have enough TS
        else:
            use_ts = available_chunks[- int(duration / self.ts_durations[self.use_quality] + 0.5):]

        #No filename specified, construct from time values
        if not filename:
            t = time.time()
            filename = f"{round(t - self.ts_durations[self.use_quality] * len(use_ts))}-{round(t)}"

        #Avoid overwriting other clips
        safe_filename = utils.get_safe_filename(self.clip_save_path, filename)

        self.actor.send_message(f"Saving clip {safe_filename}, duration of {round(self.ts_durations[self.use_quality] * len(use_ts))} seconds.")
        saveclip_thread = threading.Thread(target = self.form_ts_into_clip, args = (safe_filename, use_ts), daemon = True)
        saveclip_thread.start()

    def form_ts_into_clip(self, filename, use_ts):
        """Do the actual TS [down]loading and processing, and save the video clip.
    This method should be a thread target.

    Args:
        filename (str): The base name to save the clip file with, with no extension or path.
        use_ts (list): The list of TS file names to use for this clip."""

        #Download the TS chunks if this is a DVR stream
        if self.is_dvr:
            print("Downloading TS for clip")
            tempfiles = []
            for ts_name in use_ts:
                try:
                    data = requests.get(self.ts_url_start.format(quality = self.use_quality) + ts_name, timeout = static.REQUEST_TIMEOUT).content
                    if not data:
                        raise ValueError
                except (ValueError, requests.exceptions.ReadTimeout): #The request failed or has no content
                    print("Failed to get", ts_name)
                    continue
                tf = tempfile.NamedTemporaryFile()
                tf.write(data)
                tf.file.close()
                tempfiles.append(tf)

        #Select the tempfiles from the TS cache
        else:
            tempfiles = [self.saved_ts[ts_name] for ts_name in use_ts]

        #Load the TS chunks
        chunks = [VideoFileClip(tf.name) for tf in tempfiles]

        #Concatenate the chunks into a clip
        clip = concatenate_videoclips(chunks)

        #Save
        print("Saving clip")
        complete_filepath = os.path.join(self.clip_save_path, filename + "." + static.Clip.save_extension)
        clip.write_videofile(
            complete_filepath,
            bitrate = static.Clip.Download.stream_qualities[self.use_quality],
            logger = None
        )

        self.running_clipsaves -= 1
        if self.running_clipsaves < 0:
            print("ERROR: Running clipsaves is now negative. Resetting it to zero, but this should not happen.")
            self.running_clipsaves = 0

        #We are responsible for DVR tempfile closing
        if self.is_dvr:
            for tf in tempfiles:
                tf.close()

        #Upload the clip
        if self.clip_uploader:
            self.clip_uploader.upload_clip(filename, complete_filepath)

        print("Complete")

help_message property

The help message for this command

__init__(actor, name='clip', default_duration=60, max_duration=120, clip_save_path='.' + os.sep)

Save clips of the livestream by downloading stream chunks from Rumble, works remotely. Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The Rumchat Actor.

required
name str

The name of the command. Defaults to "clip"

'clip'
default_duration int

How long the clip will last in seconds if no duration is specified on run. Defaults to 60

60
max_duration int

How long the clip can be set to be in seconds on run. Defaults to 120

120
clip_save_path str

Where to save clips to when they are made. Defaults to "."

'.' + sep
Source code in rumchat_actor/commands.py
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
def __init__(self, actor, name = "clip", default_duration = 60, max_duration = 120, clip_save_path = "." + os.sep):
    """Save clips of the livestream by downloading stream chunks from Rumble, works remotely.
Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumbleChatActor): The Rumchat Actor.
    name (str): The name of the command.
        Defaults to "clip"
    default_duration (int): How long the clip will last in seconds if no duration is specified on run.
        Defaults to 60
    max_duration (int): How long the clip can be set to be in seconds on run.
        Defaults to 120
    clip_save_path (str): Where to save clips to when they are made.
        Defaults to "."
    """

    super().__init__(name = name, actor = actor, cooldown = default_duration)
    self.default_duration = default_duration
    self.max_duration = max_duration
    self.clip_save_path = clip_save_path.removesuffix(os.sep) + os.sep #Where to save the completed clips
    self.ready_to_clip = False
    #self.streamer_main_page_url = self.actor.streamer_main_page_url #Make sure we have this before we try to start recording
    #self.stream_is_live = False #Wether or not the stream is live, we find this later
    self.running_clipsaves = 0 #How many clip save operations are running
    self.unavailable_qualities = [] #Stream qualities that are not available (cause a 404)
    self.average_ts_download_times = {} #The average time it takes to download a TS chunk of a given stream quality
    self.ts_durations = {} #The duration of a TS chunk of a given stream quality
    self.is_dvr = False #Wether the stream is a DVR or not, detected later. No TS cache is needed if it is
    self.use_quality = None #The quality of stream to use, detected later, based on download speeds
    self.ts_url_start = "" #The start of the m3u8 and TS URLs, to be formatted with the selected quality, detected later
    self.m3u8_filename = "" #The filename of the m3u8 playlist, will be either chunklist.m3u8 or chunklist_DVR.m3u8, detected later
    self.saved_ts = {} #TS filenames : Tempfile objects containing TS chunks
    self.discarded_ts = [] #TS names that were saved then deleted
    self.save_format = static.Clip.save_extension #Format that clips are saved in. For ClipUploader
    self.clip_uploader = None #An object to upload the clips when they are complete
    self.recorder_thread = threading.Thread(target = self.record_loop, daemon = True)
    self.run_recorder = True
    self.recorder_thread.start()

form_ts_into_clip(filename, use_ts)

Do the actual TS [down]loading and processing, and save the video clip. This method should be a thread target.

Parameters:

Name Type Description Default
filename str

The base name to save the clip file with, with no extension or path.

required
use_ts list

The list of TS file names to use for this clip.

required
Source code in rumchat_actor/commands.py
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
def form_ts_into_clip(self, filename, use_ts):
    """Do the actual TS [down]loading and processing, and save the video clip.
This method should be a thread target.

Args:
    filename (str): The base name to save the clip file with, with no extension or path.
    use_ts (list): The list of TS file names to use for this clip."""

    #Download the TS chunks if this is a DVR stream
    if self.is_dvr:
        print("Downloading TS for clip")
        tempfiles = []
        for ts_name in use_ts:
            try:
                data = requests.get(self.ts_url_start.format(quality = self.use_quality) + ts_name, timeout = static.REQUEST_TIMEOUT).content
                if not data:
                    raise ValueError
            except (ValueError, requests.exceptions.ReadTimeout): #The request failed or has no content
                print("Failed to get", ts_name)
                continue
            tf = tempfile.NamedTemporaryFile()
            tf.write(data)
            tf.file.close()
            tempfiles.append(tf)

    #Select the tempfiles from the TS cache
    else:
        tempfiles = [self.saved_ts[ts_name] for ts_name in use_ts]

    #Load the TS chunks
    chunks = [VideoFileClip(tf.name) for tf in tempfiles]

    #Concatenate the chunks into a clip
    clip = concatenate_videoclips(chunks)

    #Save
    print("Saving clip")
    complete_filepath = os.path.join(self.clip_save_path, filename + "." + static.Clip.save_extension)
    clip.write_videofile(
        complete_filepath,
        bitrate = static.Clip.Download.stream_qualities[self.use_quality],
        logger = None
    )

    self.running_clipsaves -= 1
    if self.running_clipsaves < 0:
        print("ERROR: Running clipsaves is now negative. Resetting it to zero, but this should not happen.")
        self.running_clipsaves = 0

    #We are responsible for DVR tempfile closing
    if self.is_dvr:
        for tf in tempfiles:
            tf.close()

    #Upload the clip
    if self.clip_uploader:
        self.clip_uploader.upload_clip(filename, complete_filepath)

    print("Complete")

get_quality_info()

Get information on the stream quality options: Download time, TS length, availability

Source code in rumchat_actor/commands.py
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
def get_quality_info(self):
    """Get information on the stream quality options: Download time, TS length, availability"""

    print("Getting info on stream qualities")
    assert self.ts_url_start, "Must have start of TS URL before this runs"
    for quality in static.Clip.Download.stream_qualities:
        download_times = []
        chunk_content = None #The content of a successful chunk download. used for duration checking
        for _ in range(static.Clip.Download.speed_test_iter):
            r1 = None
            try:
                r1 = requests.get(self.ts_url_start.format(quality = quality) + self.m3u8_filename, timeout = static.REQUEST_TIMEOUT)
            except requests.exceptions.ReadTimeout:
                print("Timeout for m3u8 playlist download")
                download_times.append(static.REQUEST_TIMEOUT + 1)
                continue

            if r1.status_code == 404:
                print("404 for", self.ts_url_start.format(quality = quality) + self.m3u8_filename, "so assuming", quality, "quality is not available.")
                self.unavailable_qualities.append(quality)
                break

            #Download a chunk and time it
            ts_chunk_names = [l for l in r1.text if not l.startswith("#")]
            start_time = time.time()
            r2 = None
            try:
                r2 = requests.get(self.ts_url_start.format(quality = quality) + ts_chunk_names[-1], timeout = static.REQUEST_TIMEOUT)
            except requests.exceptions.ReadTimeout:
                print("Timeout for TS chunk download")
                download_times.append(static.REQUEST_TIMEOUT + 1)
                continue
            if r2.status_code != 200 or not r2.content:
                print("TS chunk download unsuccessful:", r2.status_code)
                continue
            download_times.append(time.time() - start_time)
            chunk_content = r2.content

        if not download_times and quality not in self.unavailable_qualities:
            print("No successful chunk downloads for", quality, "so setting it as unavailable")
            self.unavailable_qualities.append(quality)
            continue

        #Get chunk duration
        ts = tempfile.NamedTemporaryFile()
        ts.write(chunk_content)
        ts.file.close()
        self.ts_durations[quality] = VideoFileClip(ts.name).duration
        ts.close()

        #Calculate average download time
        self.average_ts_download_times[quality] = sum(download_times) / len(download_times)

get_ts_list(quality)

Download an m3u8 playlist and parse it for TS filenames

Parameters:

Name Type Description Default
quality str

The quality specifier used in the TS URL.

required

Returns:

Name Type Description
Files list

Lines of the TS playlist that do not start with #

Source code in rumchat_actor/commands.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def get_ts_list(self, quality):
    """Download an m3u8 playlist and parse it for TS filenames

Args:
    quality (str): The quality specifier used in the TS URL.

Returns:
    Files (list): Lines of the TS playlist that do not start with #"""

    assert self.ts_url_start and self.m3u8_filename, \
        "Must have the TS URL start and the m3u8 filename before this runs"
    m3u8 = requests.get(self.ts_url_start.format(quality = quality) + \
        self.m3u8_filename, timeout = static.REQUEST_TIMEOUT).text
    return [line for line in m3u8.splitlines() if not line.startswith("#")]

record_loop()

Start and run the recorder system

Source code in rumchat_actor/commands.py
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
def record_loop(self):
    """Start and run the recorder system"""

    #Get the base URL for the wualities listing
    m3u8_qualities_url = static.URI.m3u8_qualities_list.format(stream_id_b36 = self.actor.stream_id_b36)

    m3u8_qualities_raw = requests.get(m3u8_qualities_url, timeout = static.REQUEST_TIMEOUT).text

    m3u8_quality_urls_all = [line for line in m3u8_qualities_raw.splitlines() if not line.startswith("#")]
    ts_url_default = m3u8_quality_urls_all[-1]

    #Is this a DVR stream?
    self.is_dvr = ts_url_default.endswith("DVR.m3u8")

    self.ts_url_start = ts_url_default[:ts_url_default.rfind("/")] + "_{quality}/"

    self.m3u8_filename = ts_url_default[ts_url_default.rfind("/") + 1 ]

    self.get_quality_info()

    if self.is_dvr:
        self.use_quality = [q for q in static.Clip.Download.stream_qualities if q not in self.unavailable_qualities][-1]
        print("Not using TS cache for clips since stream is DVR. Ready to clip.")
        self.run_recorder = False
        self.ready_to_clip = True
        return

    #Find the best quality we can use
    for quality in static.Clip.Download.stream_qualities:
        if quality in self.unavailable_qualities:
            continue
        if self.average_ts_download_times[quality] > self.ts_durations[quality] / static.Clip.Download.speed_factor_req:
            continue
        self.use_quality = quality

    if not self.use_quality:
        print("No available TS qualities for cache")
        return

    self.ready_to_clip = True
    print("Starting ring buffer TS cache...")
    while self.run_recorder:
        #Get the list of TS chunks, filtering out TS chunks that we already have / had
        try:
            new_ts_list = [ts for ts in self.get_ts_list(self.use_quality) if ts not in self.saved_ts.values() and ts not in self.discarded_ts]
        except (AttributeError, requests.exceptions.ReadTimeout):
            print("Failed to get m3u8 playlist")
            continue

        #We just started recording, only download the latest TS
        if not self.saved_ts:
            self.discarded_ts = new_ts_list[:-1]
            new_ts_list = new_ts_list[-1:]

        #Save the unsaved TS chunks to temporary files
        for ts_name in new_ts_list:
            try:
                data = requests.get(self.ts_url_start.format(quality = self.use_quality) + ts_name, timeout = static.REQUEST_TIMEOUT).content
            except (AttributeError, requests.exceptions.ReadTimeout): #The request failed or has no content
                print("Failed to save ", ts_name)
                continue
            f = tempfile.NamedTemporaryFile()
            f.write(data)
            f.file.close()
            self.saved_ts[ts_name] = f

        #We should be deleting old clips, and we have more than enough to fill the max duration
        while not self.running_clipsaves and (len(self.saved_ts) - 1) * self.ts_durations[self.use_quality] > self.max_duration:
            oldest_ts = list(self.saved_ts.keys())[0]
            self.saved_ts[oldest_ts].close() #close the tempfile
            del self.saved_ts[oldest_ts]
            self.discarded_ts.append(oldest_ts)

        #Wait a moment before the next m3u8 download
        time.sleep(1)

run(message, act_props)

Make a clip

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
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
def run(self, message, act_props: dict):
    """Make a clip

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    #We are not ready for clipping
    if not self.ready_to_clip or not (self.is_dvr or self.saved_ts):
        self.actor.send_message(f"@{message.user.username} Not ready for clip saving yet.")
        return

    segs = message.text.split()
    #Only called clip, no arguments
    if len(segs) == 1:
        self.save_clip(self.default_duration)

    #Arguments were passed
    else:
        #The first argument is a number
        if segs[1].isnumeric():
            #Invalid length passed
            if not 0 < int(segs[1]) <= self.max_duration:
                self.actor.send_message(f"@{message.user.username} Invalid clip length.")
                return

            #Only length was specified
            if len(segs) == 2:
                self.save_clip(int(segs[1]))

            #A name was also specified
            else:
                self.save_clip(int(segs[1]), "_".join(segs[2:]))

        #The first argument is not a number, treat it as a filename
        else:
            self.save_clip(self.default_duration, "_".join(segs[1:]))

save_clip(duration, filename=None)

Start a clip saver thread with the given parameters

Parameters:

Name Type Description Default
duration int

How long the clip should be.

required
filename str

What to name the saved clip file. Defaults to None, auto-generate a filename.

None
Source code in rumchat_actor/commands.py
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
def save_clip(self, duration, filename = None):
    """Start a clip saver thread with the given parameters

Args:
    duration (int): How long the clip should be.
    filename (str): What to name the saved clip file.
        Defaults to None, auto-generate a filename.
"""

    self.running_clipsaves += 1

    #This is a DVR stream
    if self.is_dvr:
        available_chunks = self.get_ts_list(self.use_quality)

    #this is a passthrough stream
    else:
        available_chunks = list(self.saved_ts.keys())

    #Not enough TS for the full clip duration
    if len(available_chunks) * self.ts_durations[self.use_quality] < duration:
        print("Not enough TS to fulfil full duration")
        use_ts = available_chunks

    #We have enough TS
    else:
        use_ts = available_chunks[- int(duration / self.ts_durations[self.use_quality] + 0.5):]

    #No filename specified, construct from time values
    if not filename:
        t = time.time()
        filename = f"{round(t - self.ts_durations[self.use_quality] * len(use_ts))}-{round(t)}"

    #Avoid overwriting other clips
    safe_filename = utils.get_safe_filename(self.clip_save_path, filename)

    self.actor.send_message(f"Saving clip {safe_filename}, duration of {round(self.ts_durations[self.use_quality] * len(use_ts))} seconds.")
    saveclip_thread = threading.Thread(target = self.form_ts_into_clip, args = (safe_filename, use_ts), daemon = True)
    saveclip_thread.start()

ClipRecordingCommand

Bases: ChatCommand

Save clips of the livestream by duplicating then trimming an in-progress recording by OBS

Source code in rumchat_actor/commands.py
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
class ClipRecordingCommand(ChatCommand):
    """Save clips of the livestream by duplicating then trimming an in-progress recording by OBS"""
    def __init__(self, actor, name = "clip", default_duration = 60, max_duration = 120, recording_load_path = ".", clip_save_path = "." + os.sep):
        """Save clips of the livestream by duplicating then trimming an in-progress recording by OBS.
    Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumchat Actor.
        name (str): The name of the command.
            Defaults to "clip"
        default_duration (int): How long the clip will last in seconds if no duration is specified on run.
            Defaults to 60
        max_duration (int): How long the clip can be set to be in seconds on run.
            Defaults to 120
        recording_load_path (str): Where recordings from OBS are stored, used for filedialog init.
            Defaults to "."
        clip_save_path (str): Where to save clips to when they are made.
            Defaults to "."
        """

        super().__init__(name = name, actor = actor, cooldown = default_duration)
        self.default_duration = default_duration
        self.max_duration = max_duration
        self.recording_load_path = recording_load_path.removesuffix(os.sep) #Where to first look for the OBS recording
        self.clip_save_path = clip_save_path.removesuffix(os.sep) + os.sep #Where to save the completed clips
        self.running_clipsaves = 0 #How many clip save operations are running
        self.__recording_filename = None #The filename of the running OBS recording, asked later
        print(self.recording_filename) #...now is later
        self.clip_uploader = None #An object to upload the clips when they are complete

    @property
    def help_message(self):
        """The help message for this command"""
        return f"Save a clip from the livestream. Use {static.Message.command_prefix}{self.name} [duration] [custom clip name]." + \
            f"Default duration is {self.default_duration}, max duration is {self.max_duration}."

    @property
    def recording_filename(self):
        """The filename of the running OBS recording"""
        #We do not know the filename yet
        while not self.__recording_filename:
            #Make and hide a background Tk window to allow filedialogs to appear
            root = Tk()
            root.withdraw()

            #Ask for the OBS recording in progress
            self.__recording_filename = filedialog.askopenfilename(
                title = "Select OBS recording in progress",
                initialdir = self.recording_load_path,
                filetypes = static.Clip.Record.input_options,
                )

            #Destroy the background window
            root.destroy()

        return self.__recording_filename

    @property
    def recording_container(self):
        """The container format of the recording"""
        return self.recording_filename.split(".")[-1]

    @property
    def recording_copy_fn(self):
        """The filename of the temporary recording copy"""
        return static.Clip.Record.temp_copy_fn + "." + self.recording_container

    def run(self, message, act_props: dict):
        """Make a clip. TODO mostly identical to ClipDownloadingCommand().run()

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        segs = message.text.split()
        #Only called clip, no arguments
        if len(segs) == 1:
            self.save_clip(self.default_duration)

        #Arguments were passed
        else:
            #The first argument is a number
            if segs[1].isnumeric():
                #Invalid length passed
                if not 0 < int(segs[1]) <= self.max_duration:
                    self.actor.send_message(f"@{message.user.username} Invalid clip length.")
                    return

                #Only length was specified
                if len(segs) == 2:
                    self.save_clip(int(segs[1]))

                #A name was also specified
                else:
                    self.save_clip(int(segs[1]), "_".join(segs[2:]))

            #The first argument is not a number, treat it as a filename
            else:
                self.save_clip(self.default_duration, "_".join(segs[1:]))

    def save_clip(self, duration, filename = None):
        """Start a clip saver thread with the given parameters

    Args:
        duration (int): The length of the clip in seconds.
        filename (str): The base filename of the clip, with no path or extension.
            Defaults to None, auto-generate the filename."""

        #No filename specified, construct from time values
        if not filename:
            t = time.time()
            filename = f"{round(t - duration)}-{round(t)}"

        #Avoid overwriting other clips
        safe_filename = utils.get_safe_filename(self.clip_save_path, filename)

        #Report clip save
        self.actor.send_message(f"Saving clip {safe_filename}, duration of {duration} seconds.")

        #Run the clip save in a thread
        saveclip_thread = threading.Thread(target = self.form_recording_into_clip, args = (duration, safe_filename), daemon = True)
        saveclip_thread.start()

    def form_recording_into_clip(self, duration, filename):
        """Do the actual file operations to save a clip.
    This method should be a thread target.

    Args:
        duration (int): The length of the clip in seconds.
        filename (str): The base filename of the clip, with no path or extension."""

        #Keep a counter of running clipsaves, may not be needed
        self.running_clipsaves += 1

        print("Making frozen copy of recording")
        shutil.copy(self.recording_filename, self.recording_copy_fn)
        print("Loading copy")
        recording = VideoFileClip(self.recording_copy_fn)
        print("Saving trimmed clip")
        complete_path = os.path.join(self.clip_save_path, filename + "." + static.Clip.save_extension)
        ffmpeg_extract_subclip(self.recording_copy_fn, max((recording.duration - duration, 0)), recording.duration, targetname = complete_path)
        print("Closing and deleting frozen copy")
        recording.close()
        os.system("rm " + self.recording_copy_fn)
        print("Done.")

        #Make note that the clipsave has finished
        self.running_clipsaves -= 1
        if self.running_clipsaves < 0:
            print("ERROR: Running clipsaves is now negative. Resetting it to zero, but this should not happen.")
            self.running_clipsaves = 0

        if self.clip_uploader:
            self.clip_uploader.upload_clip(filename, complete_path)

help_message property

The help message for this command

recording_container property

The container format of the recording

recording_copy_fn property

The filename of the temporary recording copy

recording_filename property

The filename of the running OBS recording

__init__(actor, name='clip', default_duration=60, max_duration=120, recording_load_path='.', clip_save_path='.' + os.sep)

Save clips of the livestream by duplicating then trimming an in-progress recording by OBS. Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The Rumchat Actor.

required
name str

The name of the command. Defaults to "clip"

'clip'
default_duration int

How long the clip will last in seconds if no duration is specified on run. Defaults to 60

60
max_duration int

How long the clip can be set to be in seconds on run. Defaults to 120

120
recording_load_path str

Where recordings from OBS are stored, used for filedialog init. Defaults to "."

'.'
clip_save_path str

Where to save clips to when they are made. Defaults to "."

'.' + sep
Source code in rumchat_actor/commands.py
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
def __init__(self, actor, name = "clip", default_duration = 60, max_duration = 120, recording_load_path = ".", clip_save_path = "." + os.sep):
    """Save clips of the livestream by duplicating then trimming an in-progress recording by OBS.
Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumbleChatActor): The Rumchat Actor.
    name (str): The name of the command.
        Defaults to "clip"
    default_duration (int): How long the clip will last in seconds if no duration is specified on run.
        Defaults to 60
    max_duration (int): How long the clip can be set to be in seconds on run.
        Defaults to 120
    recording_load_path (str): Where recordings from OBS are stored, used for filedialog init.
        Defaults to "."
    clip_save_path (str): Where to save clips to when they are made.
        Defaults to "."
    """

    super().__init__(name = name, actor = actor, cooldown = default_duration)
    self.default_duration = default_duration
    self.max_duration = max_duration
    self.recording_load_path = recording_load_path.removesuffix(os.sep) #Where to first look for the OBS recording
    self.clip_save_path = clip_save_path.removesuffix(os.sep) + os.sep #Where to save the completed clips
    self.running_clipsaves = 0 #How many clip save operations are running
    self.__recording_filename = None #The filename of the running OBS recording, asked later
    print(self.recording_filename) #...now is later
    self.clip_uploader = None #An object to upload the clips when they are complete

form_recording_into_clip(duration, filename)

Do the actual file operations to save a clip. This method should be a thread target.

Parameters:

Name Type Description Default
duration int

The length of the clip in seconds.

required
filename str

The base filename of the clip, with no path or extension.

required
Source code in rumchat_actor/commands.py
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
def form_recording_into_clip(self, duration, filename):
    """Do the actual file operations to save a clip.
This method should be a thread target.

Args:
    duration (int): The length of the clip in seconds.
    filename (str): The base filename of the clip, with no path or extension."""

    #Keep a counter of running clipsaves, may not be needed
    self.running_clipsaves += 1

    print("Making frozen copy of recording")
    shutil.copy(self.recording_filename, self.recording_copy_fn)
    print("Loading copy")
    recording = VideoFileClip(self.recording_copy_fn)
    print("Saving trimmed clip")
    complete_path = os.path.join(self.clip_save_path, filename + "." + static.Clip.save_extension)
    ffmpeg_extract_subclip(self.recording_copy_fn, max((recording.duration - duration, 0)), recording.duration, targetname = complete_path)
    print("Closing and deleting frozen copy")
    recording.close()
    os.system("rm " + self.recording_copy_fn)
    print("Done.")

    #Make note that the clipsave has finished
    self.running_clipsaves -= 1
    if self.running_clipsaves < 0:
        print("ERROR: Running clipsaves is now negative. Resetting it to zero, but this should not happen.")
        self.running_clipsaves = 0

    if self.clip_uploader:
        self.clip_uploader.upload_clip(filename, complete_path)

run(message, act_props)

Make a clip. TODO mostly identical to ClipDownloadingCommand().run()

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
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
def run(self, message, act_props: dict):
    """Make a clip. TODO mostly identical to ClipDownloadingCommand().run()

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    segs = message.text.split()
    #Only called clip, no arguments
    if len(segs) == 1:
        self.save_clip(self.default_duration)

    #Arguments were passed
    else:
        #The first argument is a number
        if segs[1].isnumeric():
            #Invalid length passed
            if not 0 < int(segs[1]) <= self.max_duration:
                self.actor.send_message(f"@{message.user.username} Invalid clip length.")
                return

            #Only length was specified
            if len(segs) == 2:
                self.save_clip(int(segs[1]))

            #A name was also specified
            else:
                self.save_clip(int(segs[1]), "_".join(segs[2:]))

        #The first argument is not a number, treat it as a filename
        else:
            self.save_clip(self.default_duration, "_".join(segs[1:]))

save_clip(duration, filename=None)

Start a clip saver thread with the given parameters

Parameters:

Name Type Description Default
duration int

The length of the clip in seconds.

required
filename str

The base filename of the clip, with no path or extension. Defaults to None, auto-generate the filename.

None
Source code in rumchat_actor/commands.py
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
def save_clip(self, duration, filename = None):
    """Start a clip saver thread with the given parameters

Args:
    duration (int): The length of the clip in seconds.
    filename (str): The base filename of the clip, with no path or extension.
        Defaults to None, auto-generate the filename."""

    #No filename specified, construct from time values
    if not filename:
        t = time.time()
        filename = f"{round(t - duration)}-{round(t)}"

    #Avoid overwriting other clips
    safe_filename = utils.get_safe_filename(self.clip_save_path, filename)

    #Report clip save
    self.actor.send_message(f"Saving clip {safe_filename}, duration of {duration} seconds.")

    #Run the clip save in a thread
    saveclip_thread = threading.Thread(target = self.form_recording_into_clip, args = (duration, safe_filename), daemon = True)
    saveclip_thread.start()

ClipReplayBufferCommand

Bases: ChatCommand

Save clips of the livestream by triggering OBS to save its replay buffer

Source code in rumchat_actor/commands.py
 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
1001
class ClipReplayBufferCommand(ChatCommand):
    """Save clips of the livestream by triggering OBS to save its replay buffer"""
    def __init__(self, actor, name = "clip", cooldown = 120, addr = "localhost", port = 4455, password = "", save_format = static.Clip.save_extension):
        """Save clips of the livestream by triggering OBS to save its replay buffer.
    Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumchat Actor.
        name (str): The name of the command.
            Defaults to "clip"
        cooldown (int): Command cooldown in seconds.
        addr (str): IP address of the computer running OBS.
            Defaults to "localhost", meaning this computer.
        port (4455): Port that OBS WebSocket is listening on.
            Defaults to 4455, currently OBS's default.
        password (str): OBS WebSocket password, if you have one set.
            Defaults to empty.
        save_format (str): Filename extension for the format that replay buffers are saved in.
            Defaults to static.Clip.save_extension"""

        super().__init__(name = name, actor = actor, cooldown = cooldown)
        self.addr, self.port, self.password = addr, port, password
        self.save_format = save_format.removeprefix(".")
        self.__running_clipsaves = 0 #How many clip save operations are running
        self.clip_uploader = None #An object to upload the clips when they are complete

        #Connect to OBS
        self.obsclient = obs.ReqClient(host = self.addr, port = self.port, password = self.password, timeout = 3)

        #Make sure the replay buffer is running
        if not self.obsclient.get_replay_buffer_status().output_active:
            self.obsclient.start_replay_buffer()
            print("Replay buffer was not started, autostarting. OK.")

        else:
            print("Replay buffer was already started. OK.")

        #Query the clip save location automatically while we're at it
        self.clip_save_path = self.obsclient.get_record_directory().record_directory + os.sep
        print("OBS says recordings will save to", self.clip_save_path)

    @property
    def help_message(self):
        """The help message for this command"""
        return f"Save a clip from the livestream. Use {static.Message.command_prefix}{self.name} [custom clip name]." + \
            "Duration is defined by OBS replay buffer settings."

    @property
    def running_clipsaves(self):
        """How many clip save threads are currently running"""
        return self.__running_clipsaves

    @running_clipsaves.setter
    def running_clipsaves(self, new):
        """How many clip save threads are currently running

    Args:
        new (int): The new number of running clipsaves"""

        assert int(new) == new, "Running clipsaves count must be an integer value"
        if new < 0:
            print("ERROR: Running clipsaves is now negative. Resetting it to zero, but this should not happen.")
            self.__running_clipsaves = 0
            return
        self.__running_clipsaves = new

    def run(self, message, act_props: dict):
        """Make a clip. TODO mostly identical to ClipDownloadingCommand().run()

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        segs = message.text.split()
        #Only called clip, no arguments
        if len(segs) == 1:
            self.save_clip()

        #A name was passed
        else:
            self.save_clip("_".join(segs[1:]))

    def save_clip(self, filename = None):
        """Start a clip saver thread with the given parameters

    Args:
        filename (str): The base filename of the clip, with no path or extension.
            Defaults to None, auto-generate the filename."""

        if filename:
            #Avoid overwriting other clips
            safe_filename = utils.get_safe_filename(self.clip_save_path, filename, extension = self.save_format)

            #Report clip save
            self.actor.send_message(f"Saving clip {safe_filename}.")

            filename = safe_filename

        else:
            #Report clip save
            self.actor.send_message("Saving clip with default filename.")

        #Run the clip save in a thread
        saveclip_thread = threading.Thread(target = self.save_buffer_as_clip, args = [filename], daemon = True)
        saveclip_thread.start()

    def save_buffer_as_clip(self, desired_filename):
        """Do the actual file operations to save a clip.
    This method should be a thread target.

    Args:
        filename (str): The base filename of the clip, with no path or extension, renamed from whatever OBS named it."""

        #Keep a counter of running clipsaves, may not be needed
        self.running_clipsaves += 1

        print("Signaling OBS to save the replay buffer")
        self.obsclient.save_replay_buffer()

        #Time the clip was saved
        marktime = time.time()

        print("Egg timer for save to initialize")
        time.sleep(static.Clip.ReplayBuffer.save_start_delay)

        print("Locating saved replay buffer")
        search_string = f"{static.Clip.ReplayBuffer.save_name_format_notime}**.{self.save_format}"
        potential_files = glob.glob(search_string, root_dir = self.clip_save_path)
        potential_files.sort()

        if not potential_files:
            print(f"ERROR: No files matched search for '{search_string}' in {self.clip_save_path}")
            self.running_clipsaves -= 1
            return

        believed_filename = time.strftime(static.Clip.ReplayBuffer.save_name_format, time.localtime(marktime))
        if believed_filename in potential_files:
            print("Found exact match for", believed_filename)
            filename_wext = believed_filename
        else:
            print("Did not find exact match for", believed_filename)
            filename_wext = potential_files[-1]

        filename = filename_wext.removesuffix("." + self.save_format)
        complete_path = os.path.join(self.clip_save_path, filename_wext)

        print("Waiting for file to finish saving")
        old_size = 0
        while (new_size := os.path.getsize(complete_path)) != old_size:
            time.sleep(static.Clip.ReplayBuffer.size_check_delay)
            old_size = new_size

        if desired_filename:
            print(f"Renaming {filename} to {desired_filename}")
            old_complete_path = complete_path
            complete_path = os.path.join(self.clip_save_path, desired_filename + "." + self.save_format)
            shutil.move(old_complete_path, complete_path)
            filename = desired_filename
            del filename_wext #This is no longer valid, so remove from memory for debug

        #Make note that the clipsave has finished
        self.running_clipsaves -= 1

        if self.clip_uploader:
            self.clip_uploader.upload_clip(filename, complete_path)

help_message property

The help message for this command

running_clipsaves property writable

How many clip save threads are currently running

__init__(actor, name='clip', cooldown=120, addr='localhost', port=4455, password='', save_format=static.Clip.save_extension)

Save clips of the livestream by triggering OBS to save its replay buffer. Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The Rumchat Actor.

required
name str

The name of the command. Defaults to "clip"

'clip'
cooldown int

Command cooldown in seconds.

120
addr str

IP address of the computer running OBS. Defaults to "localhost", meaning this computer.

'localhost'
port 4455

Port that OBS WebSocket is listening on. Defaults to 4455, currently OBS's default.

4455
password str

OBS WebSocket password, if you have one set. Defaults to empty.

''
save_format str

Filename extension for the format that replay buffers are saved in. Defaults to static.Clip.save_extension

save_extension
Source code in rumchat_actor/commands.py
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
def __init__(self, actor, name = "clip", cooldown = 120, addr = "localhost", port = 4455, password = "", save_format = static.Clip.save_extension):
    """Save clips of the livestream by triggering OBS to save its replay buffer.
Instance this object, optionally pass it to the init method of a ClipUploader, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumbleChatActor): The Rumchat Actor.
    name (str): The name of the command.
        Defaults to "clip"
    cooldown (int): Command cooldown in seconds.
    addr (str): IP address of the computer running OBS.
        Defaults to "localhost", meaning this computer.
    port (4455): Port that OBS WebSocket is listening on.
        Defaults to 4455, currently OBS's default.
    password (str): OBS WebSocket password, if you have one set.
        Defaults to empty.
    save_format (str): Filename extension for the format that replay buffers are saved in.
        Defaults to static.Clip.save_extension"""

    super().__init__(name = name, actor = actor, cooldown = cooldown)
    self.addr, self.port, self.password = addr, port, password
    self.save_format = save_format.removeprefix(".")
    self.__running_clipsaves = 0 #How many clip save operations are running
    self.clip_uploader = None #An object to upload the clips when they are complete

    #Connect to OBS
    self.obsclient = obs.ReqClient(host = self.addr, port = self.port, password = self.password, timeout = 3)

    #Make sure the replay buffer is running
    if not self.obsclient.get_replay_buffer_status().output_active:
        self.obsclient.start_replay_buffer()
        print("Replay buffer was not started, autostarting. OK.")

    else:
        print("Replay buffer was already started. OK.")

    #Query the clip save location automatically while we're at it
    self.clip_save_path = self.obsclient.get_record_directory().record_directory + os.sep
    print("OBS says recordings will save to", self.clip_save_path)

run(message, act_props)

Make a clip. TODO mostly identical to ClipDownloadingCommand().run()

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
def run(self, message, act_props: dict):
    """Make a clip. TODO mostly identical to ClipDownloadingCommand().run()

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    segs = message.text.split()
    #Only called clip, no arguments
    if len(segs) == 1:
        self.save_clip()

    #A name was passed
    else:
        self.save_clip("_".join(segs[1:]))

save_buffer_as_clip(desired_filename)

Do the actual file operations to save a clip. This method should be a thread target.

Parameters:

Name Type Description Default
filename str

The base filename of the clip, with no path or extension, renamed from whatever OBS named it.

required
Source code in rumchat_actor/commands.py
 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
1001
def save_buffer_as_clip(self, desired_filename):
    """Do the actual file operations to save a clip.
This method should be a thread target.

Args:
    filename (str): The base filename of the clip, with no path or extension, renamed from whatever OBS named it."""

    #Keep a counter of running clipsaves, may not be needed
    self.running_clipsaves += 1

    print("Signaling OBS to save the replay buffer")
    self.obsclient.save_replay_buffer()

    #Time the clip was saved
    marktime = time.time()

    print("Egg timer for save to initialize")
    time.sleep(static.Clip.ReplayBuffer.save_start_delay)

    print("Locating saved replay buffer")
    search_string = f"{static.Clip.ReplayBuffer.save_name_format_notime}**.{self.save_format}"
    potential_files = glob.glob(search_string, root_dir = self.clip_save_path)
    potential_files.sort()

    if not potential_files:
        print(f"ERROR: No files matched search for '{search_string}' in {self.clip_save_path}")
        self.running_clipsaves -= 1
        return

    believed_filename = time.strftime(static.Clip.ReplayBuffer.save_name_format, time.localtime(marktime))
    if believed_filename in potential_files:
        print("Found exact match for", believed_filename)
        filename_wext = believed_filename
    else:
        print("Did not find exact match for", believed_filename)
        filename_wext = potential_files[-1]

    filename = filename_wext.removesuffix("." + self.save_format)
    complete_path = os.path.join(self.clip_save_path, filename_wext)

    print("Waiting for file to finish saving")
    old_size = 0
    while (new_size := os.path.getsize(complete_path)) != old_size:
        time.sleep(static.Clip.ReplayBuffer.size_check_delay)
        old_size = new_size

    if desired_filename:
        print(f"Renaming {filename} to {desired_filename}")
        old_complete_path = complete_path
        complete_path = os.path.join(self.clip_save_path, desired_filename + "." + self.save_format)
        shutil.move(old_complete_path, complete_path)
        filename = desired_filename
        del filename_wext #This is no longer valid, so remove from memory for debug

    #Make note that the clipsave has finished
    self.running_clipsaves -= 1

    if self.clip_uploader:
        self.clip_uploader.upload_clip(filename, complete_path)

save_clip(filename=None)

Start a clip saver thread with the given parameters

Parameters:

Name Type Description Default
filename str

The base filename of the clip, with no path or extension. Defaults to None, auto-generate the filename.

None
Source code in rumchat_actor/commands.py
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
def save_clip(self, filename = None):
    """Start a clip saver thread with the given parameters

Args:
    filename (str): The base filename of the clip, with no path or extension.
        Defaults to None, auto-generate the filename."""

    if filename:
        #Avoid overwriting other clips
        safe_filename = utils.get_safe_filename(self.clip_save_path, filename, extension = self.save_format)

        #Report clip save
        self.actor.send_message(f"Saving clip {safe_filename}.")

        filename = safe_filename

    else:
        #Report clip save
        self.actor.send_message("Saving clip with default filename.")

    #Run the clip save in a thread
    saveclip_thread = threading.Thread(target = self.save_buffer_as_clip, args = [filename], daemon = True)
    saveclip_thread.start()

HelpCommand

Bases: ChatCommand

List available commands, or show help for a specific command

Source code in rumchat_actor/commands.py
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
class HelpCommand(ChatCommand):
    """List available commands, or show help for a specific command"""
    def __init__(self, actor, name = "help"):
        """List available commands, or show help for a specific command.
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumble chat actor host.
        name (str): The command name."""

        super().__init__(name = name, actor = actor)

    @property
    def help_message(self):
        """The help message for this command"""
        return "Run alone to list all available commands, or get " + \
            "help on a specific command with " + \
            f"{static.Message.command_prefix}{self.name} [command_name]"

    def run(self, message, act_props: dict):
        """Run the help command

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        segs = message.text.split()

        #Command was run without arguments
        if len(segs) == 1:
            self.actor.send_message(
                "The following commands are registered: " + \
                ", ".join(self.actor.chat_commands)
            )

        #Command had one argument
        elif len(segs) == 2:
            #Argument is a valid command
            if segs[-1] in self.actor.chat_commands:
                hm = self.actor.chat_commands[segs[-1]].help_message
                if not hm:
                    hm = "No specific help for this command."
                self.actor.send_message(segs[-1] + " command: " + hm)

            #Argument is something else
            else:
                self.actor.send_message(f"Cannot provide help for '{segs[-1]}' as it is not a registered command.")

        #Command has more than one argument
        else:
            self.actor.send_message("Invalid number of arguments for help command.")

help_message property

The help message for this command

__init__(actor, name='help')

List available commands, or show help for a specific command. Instance this object, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The Rumble chat actor host.

required
name str

The command name.

'help'
Source code in rumchat_actor/commands.py
269
270
271
272
273
274
275
276
277
def __init__(self, actor, name = "help"):
    """List available commands, or show help for a specific command.
Instance this object, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumbleChatActor): The Rumble chat actor host.
    name (str): The command name."""

    super().__init__(name = name, actor = actor)

run(message, act_props)

Run the help command

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
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
def run(self, message, act_props: dict):
    """Run the help command

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    segs = message.text.split()

    #Command was run without arguments
    if len(segs) == 1:
        self.actor.send_message(
            "The following commands are registered: " + \
            ", ".join(self.actor.chat_commands)
        )

    #Command had one argument
    elif len(segs) == 2:
        #Argument is a valid command
        if segs[-1] in self.actor.chat_commands:
            hm = self.actor.chat_commands[segs[-1]].help_message
            if not hm:
                hm = "No specific help for this command."
            self.actor.send_message(segs[-1] + " command: " + hm)

        #Argument is something else
        else:
            self.actor.send_message(f"Cannot provide help for '{segs[-1]}' as it is not a registered command.")

    #Command has more than one argument
    else:
        self.actor.send_message("Invalid number of arguments for help command.")

KillswitchCommand

Bases: ChatCommand

A killswitch for Rumchat Actor, in case moderators or admin need to shut it down from the chat

Source code in rumchat_actor/commands.py
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
class KillswitchCommand(ChatCommand):
    """A killswitch for Rumchat Actor, in case moderators or admin need to shut it down from the chat"""
    def __init__(self, actor, name = "killswitch", allowed_badges = ["moderator"]):
        """A killswitch for Rumchat Actor, in case moderators or admin need to shut it down from the chat.
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The RumleChatActor host object.
        name (str): The !name of the command.
        allowed_badges (list): Badges that are allowed to run this command.
            "admin" is added internally.
            Defaults to ["moderator"]"""

        super().__init__(name = name, actor = actor, exclusive = True, allowed_badges = allowed_badges)

    @property
    def help_message(self):
        """The help message for this command"""
        return "Shut down RumChat Actor."

    def run(self, message, act_props: dict):
        """Shut down Rumchat Actor

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        try:
            self.actor.send_message("Shutting down.")
            self.actor.quit()
        finally:
            print("Killswitch thrown.")
            sys.exit()

help_message property

The help message for this command

__init__(actor, name='killswitch', allowed_badges=['moderator'])

A killswitch for Rumchat Actor, in case moderators or admin need to shut it down from the chat. Instance this object, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The RumleChatActor host object.

required
name str

The !name of the command.

'killswitch'
allowed_badges list

Badges that are allowed to run this command. "admin" is added internally. Defaults to ["moderator"]

['moderator']
Source code in rumchat_actor/commands.py
321
322
323
324
325
326
327
328
329
330
331
332
def __init__(self, actor, name = "killswitch", allowed_badges = ["moderator"]):
    """A killswitch for Rumchat Actor, in case moderators or admin need to shut it down from the chat.
Instance this object, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumbleChatActor): The RumleChatActor host object.
    name (str): The !name of the command.
    allowed_badges (list): Badges that are allowed to run this command.
        "admin" is added internally.
        Defaults to ["moderator"]"""

    super().__init__(name = name, actor = actor, exclusive = True, allowed_badges = allowed_badges)

run(message, act_props)

Shut down Rumchat Actor

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
339
340
341
342
343
344
345
346
347
348
349
350
351
def run(self, message, act_props: dict):
    """Shut down Rumchat Actor

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    try:
        self.actor.send_message("Shutting down.")
        self.actor.quit()
    finally:
        print("Killswitch thrown.")
        sys.exit()

MessageCommand

Bases: ChatCommand

Post a single message in chat

Source code in rumchat_actor/commands.py
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
class MessageCommand(ChatCommand):
    """Post a single message in chat"""
    def __init__(self, actor, name, text, help_message = None):
        """Post a single message in chat.
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumble chat actor host.
        name (str): The command name.
        text (str): A message to format with the commander's username.
        help_message (str): The message that the help command will display.
            Defaults to None"""

        super().__init__(name = name, actor = actor)
        self.text = text
        if help_message:
            self.help_message = help_message

    def run(self, message, act_props: dict):
        """Post the message

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        self.actor.send_message(self.text.format(message.user.username))

__init__(actor, name, text, help_message=None)

Post a single message in chat. Instance this object, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The Rumble chat actor host.

required
name str

The command name.

required
text str

A message to format with the commander's username.

required
help_message str

The message that the help command will display. Defaults to None

None
Source code in rumchat_actor/commands.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def __init__(self, actor, name, text, help_message = None):
    """Post a single message in chat.
Instance this object, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumbleChatActor): The Rumble chat actor host.
    name (str): The command name.
    text (str): A message to format with the commander's username.
    help_message (str): The message that the help command will display.
        Defaults to None"""

    super().__init__(name = name, actor = actor)
    self.text = text
    if help_message:
        self.help_message = help_message

run(message, act_props)

Post the message

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
258
259
260
261
262
263
264
265
def run(self, message, act_props: dict):
    """Post the message

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    self.actor.send_message(self.text.format(message.user.username))

RaffleCommand

Bases: ChatCommand

Create, enter, and draw from raffles

Source code in rumchat_actor/commands.py
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
class RaffleCommand(ChatCommand):
    """Create, enter, and draw from raffles"""
    def __init__(self, actor, name = "raffle"):
        """Create, enter, and draw from raffles.
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumchat Actor.
        name (str): The name of the command.
            Defaults to "raffle"
"""

        super().__init__(name = name, actor = actor)

        #Username entries in the raffle
        self.entries = []

        #Winner of last raffle
        self.winner = None

        #Arguments we can take and associated methods
        self.operations = {
            "enter" : self.make_entry,
            "remove" : self.remove_entry,
            "count" : self.count_entries,
            "draw" : self.draw_entry,
            "winner" : self.report_winner,
            "reset" : self.reset,
            }

    @property
    def help_message(self):
        """The help message for this command"""
        return "Do raffles in the chat. " + \
            f"Use {static.Message.command_prefix}{self.name} [argument]. " + \
            f"Valid arguments are: {", ".join(self.operations)}"

    def run(self, message, act_props: dict):
        """Run the raffle command

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        segs = message.text.split()
        #Only called command, no arguments
        if len(segs) == 1:
            #self.actor.send_message(self.help_message)
            print(f"{message.user.username} called the raffle command but without an argument. No action taken.")
            return

        #Valid argument
        if segs[1] in self.operations:
            self.operations[segs[1]](message)

        #Invalid argument
        else:
            print(f"{message.user.username} called the raffle command but with invalid argument(s): {", ".join(segs[1:])}. No action taken.")

    def make_entry(self, message):
        """Make an entry

    Args:
        message (cocorum.ChatAPI.Message): The message of the user who wishes to enter."""

        if message.user.username in self.entries:
            print(f"{message.user.username} is already in the raffle.")
            return

        self.entries.append(message.user.username)
        print(f"{message.user.username} has entered the raffle.")

    def remove_entry(self, message):
        """Remove an entry

    Args:
        message (cocorum.ChatAPI.Message): The message of the removal request."""

        segs = message.text.split()
        #No username argument, the user wishes to remove themselves
        if len(segs) == 2:
            removal = message.user.username
        else:
            removal = segs[2].removesuffix("@")

        #Non-staff is trying to remove someone besides themselves
        if not utils.is_staff(message.user) and removal != message.user.username:
            #self.actor.send_message(f"@{message.user.username} You cannot remove another user from the raffle since you are not staff.")
            print(f"{message.user.username} Tried to remove {removal} from the raffle without the authority to do so.")
            return

        if removal not in self.entries:
            self.actor.send_message(f"@{message.user.username} The user {removal} was not entered in the raffle.")
            return

        self.entries.remove(removal)
        self.actor.send_message(f"@{message.user.username} The user {removal} was removed from the raffle.")

    def count_entries(self, message):
        """Report the number of entries made so far

    Args:
        message (cocorum.ChatAPI.Message): The message of the count request."""

        count = len(self.entries)

        #Some formatting here to make the grammar of the message always correct
        self.actor.send_message(f"@{message.user.username} There {("are", "is")[count == 1]} currently {("no", count)[count != 0]} {("entries", "entry")[count == 1]} in the raffle.")

    def draw_entry(self, message):
        """Draw a winner

    Args:
        message (cocorum.ChatAPI.Message): The message of the winner draw request."""

        if not utils.is_staff(message.user):
            print(f"{message.user.username} tried to draw a raffle winner without the authority to do so.")
            return

        if len(self.entries) < 2:
            self.actor.send_message(f"@{message.user.username} Cannot draw from raffle yet, need at least two entries.")
            return

        self.winner = random.choice(self.entries)
        self.report_winner(message)

    def report_winner(self, message):
        """Report the current winner

    Args:
        message (cocorum.ChatAPI.Message): The message of the winner display request."""

        if not self.winner:
            self.actor.send_message(f"@{message.user.username} There is no current winner.")
            return

        self.actor.send_message(f"@{message.user.username} The winner of the raffle is @{self.winner}")

    def reset(self, message):
        """Reset the raffle

    Args:
        message (cocorum.ChatAPI.Message): The message of the reset request."""

        if not utils.is_staff(message.user):
            print(f"{message.user.username} tried to reset the raffle without the authority to do so.")
            return

        self.entries = []
        self.winner = None
        self.actor.send_message(f"@{message.user.username} Raffle reset.")

help_message property

The help message for this command

__init__(actor, name='raffle')

Create, enter, and draw from raffles. Instance this object, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumbleChatActor

The Rumchat Actor.

required
name str

The name of the command. Defaults to "raffle"

'raffle'
Source code in rumchat_actor/commands.py
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
    def __init__(self, actor, name = "raffle"):
        """Create, enter, and draw from raffles.
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumbleChatActor): The Rumchat Actor.
        name (str): The name of the command.
            Defaults to "raffle"
"""

        super().__init__(name = name, actor = actor)

        #Username entries in the raffle
        self.entries = []

        #Winner of last raffle
        self.winner = None

        #Arguments we can take and associated methods
        self.operations = {
            "enter" : self.make_entry,
            "remove" : self.remove_entry,
            "count" : self.count_entries,
            "draw" : self.draw_entry,
            "winner" : self.report_winner,
            "reset" : self.reset,
            }

count_entries(message)

Report the number of entries made so far

Parameters:

Name Type Description Default
message Message

The message of the count request.

required
Source code in rumchat_actor/commands.py
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
def count_entries(self, message):
    """Report the number of entries made so far

Args:
    message (cocorum.ChatAPI.Message): The message of the count request."""

    count = len(self.entries)

    #Some formatting here to make the grammar of the message always correct
    self.actor.send_message(f"@{message.user.username} There {("are", "is")[count == 1]} currently {("no", count)[count != 0]} {("entries", "entry")[count == 1]} in the raffle.")

draw_entry(message)

Draw a winner

Parameters:

Name Type Description Default
message Message

The message of the winner draw request.

required
Source code in rumchat_actor/commands.py
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
def draw_entry(self, message):
    """Draw a winner

Args:
    message (cocorum.ChatAPI.Message): The message of the winner draw request."""

    if not utils.is_staff(message.user):
        print(f"{message.user.username} tried to draw a raffle winner without the authority to do so.")
        return

    if len(self.entries) < 2:
        self.actor.send_message(f"@{message.user.username} Cannot draw from raffle yet, need at least two entries.")
        return

    self.winner = random.choice(self.entries)
    self.report_winner(message)

make_entry(message)

Make an entry

Parameters:

Name Type Description Default
message Message

The message of the user who wishes to enter.

required
Source code in rumchat_actor/commands.py
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
def make_entry(self, message):
    """Make an entry

Args:
    message (cocorum.ChatAPI.Message): The message of the user who wishes to enter."""

    if message.user.username in self.entries:
        print(f"{message.user.username} is already in the raffle.")
        return

    self.entries.append(message.user.username)
    print(f"{message.user.username} has entered the raffle.")

remove_entry(message)

Remove an entry

Parameters:

Name Type Description Default
message Message

The message of the removal request.

required
Source code in rumchat_actor/commands.py
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
def remove_entry(self, message):
    """Remove an entry

Args:
    message (cocorum.ChatAPI.Message): The message of the removal request."""

    segs = message.text.split()
    #No username argument, the user wishes to remove themselves
    if len(segs) == 2:
        removal = message.user.username
    else:
        removal = segs[2].removesuffix("@")

    #Non-staff is trying to remove someone besides themselves
    if not utils.is_staff(message.user) and removal != message.user.username:
        #self.actor.send_message(f"@{message.user.username} You cannot remove another user from the raffle since you are not staff.")
        print(f"{message.user.username} Tried to remove {removal} from the raffle without the authority to do so.")
        return

    if removal not in self.entries:
        self.actor.send_message(f"@{message.user.username} The user {removal} was not entered in the raffle.")
        return

    self.entries.remove(removal)
    self.actor.send_message(f"@{message.user.username} The user {removal} was removed from the raffle.")

report_winner(message)

Report the current winner

Parameters:

Name Type Description Default
message Message

The message of the winner display request.

required
Source code in rumchat_actor/commands.py
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
def report_winner(self, message):
    """Report the current winner

Args:
    message (cocorum.ChatAPI.Message): The message of the winner display request."""

    if not self.winner:
        self.actor.send_message(f"@{message.user.username} There is no current winner.")
        return

    self.actor.send_message(f"@{message.user.username} The winner of the raffle is @{self.winner}")

reset(message)

Reset the raffle

Parameters:

Name Type Description Default
message Message

The message of the reset request.

required
Source code in rumchat_actor/commands.py
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
def reset(self, message):
    """Reset the raffle

Args:
    message (cocorum.ChatAPI.Message): The message of the reset request."""

    if not utils.is_staff(message.user):
        print(f"{message.user.username} tried to reset the raffle without the authority to do so.")
        return

    self.entries = []
    self.winner = None
    self.actor.send_message(f"@{message.user.username} Raffle reset.")

run(message, act_props)

Run the raffle command

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
def run(self, message, act_props: dict):
    """Run the raffle command

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    segs = message.text.split()
    #Only called command, no arguments
    if len(segs) == 1:
        #self.actor.send_message(self.help_message)
        print(f"{message.user.username} called the raffle command but without an argument. No action taken.")
        return

    #Valid argument
    if segs[1] in self.operations:
        self.operations[segs[1]](message)

    #Invalid argument
    else:
        print(f"{message.user.username} called the raffle command but with invalid argument(s): {", ".join(segs[1:])}. No action taken.")

TTSCommand

Bases: ChatCommand

Text-to-speech command

Source code in rumchat_actor/commands.py
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
class TTSCommand(ChatCommand):
    """Text-to-speech command"""
    def __init__(self, *args, name = "tts", no_double_sound = True, voices = {}, **kwargs):
        """Text-to-speech command.
    Instance this object, then pass it to RumbleChatActor().register_command().

    Args:
        actor (RumleChatActor): The RumleChatActor host object.
        name (str): The !name of the command.
            Defaults to "tts"
        no_double_sound (bool): Do not play if act_props["sound"] is True.
            Defaults to True
        voices (dict): Dict of voice_name : say(text) callable.
        cooldown (int | float): How long to wait before allowing the command to be run again.
            Defaults to static.Message.send_cooldown
        amount_cents (int): The minimum cost of the command.
            Defaults to free.
        exclusive (bool): If this command can only be run by users with allowed badges.
            Defaults to False.
        allowed_badges (list): Badges that are allowed to run this command (if it is exclusive); ignored if exclusive is False.
            "admin" is added internally.
            Defaults to ["subscriber"]
        free_badges (list): Badges which, if borne, give the user free-of-charge command access even if amount_cents > 0.
            "admin" is added internally.
            Defaults to ["moderator"]
    """

        super().__init__(*args, name = name, **kwargs)

        self.no_double_sound = no_double_sound
        self.voices = voices

        #Make sure we have a default voice
        if "default" not in self.voices:
            self.voices["default"] = talkey.Talkey().say

    @property
    def help_message(self):
        """The help message for this command"""
        return f"Speak your message{f" for ${self.amount_cents/100: .2%}" if self.amount_cents else ""}." + \
            f"Use {static.Message.command_prefix}{self.name} [voice] Your message. Available voices are: " + ", ".join(self.voices)

    @property
    def default_voice(self):
        """The default TTS voice as a say(text) callable"""
        return self.voices["default"]

    def speak(self, text, voice = None):
        """Speak text with voice

    Args:
        voice (str): The key of the voice in our voices dict.
            Defaults to None"""

        if not voice:
            self.default_voice(text)

        #Voice was not actually in our list of voices
        elif voice not in self.voices:
            self.default_voice(voice + " " + text)

        else:
            self.voices[voice](text)

    def run(self, message, act_props: dict):
        """Run the TTS

    Args:
        message (cocorum.ChatAPI.Message): The chat message that called us.
        act_props (dict): Message action recorded properties."""

        #Do not create more sound if a message action already made some
        if self.no_double_sound and act_props.get("sound"):
            return

        segs = message.text.split()

        #No args for the tts command
        if len(segs) < 2:
            return

        #Only one word for tts
        if len(segs) == 2:
            self.speak(segs[1])
            return

        #A voice was selected
        if segs[1] in self.voices:
            self.speak(" ".join(segs[2:]), segs[1])
            return

        #No voice was selected
        self.speak(" ".join(segs[1:]))

default_voice property

The default TTS voice as a say(text) callable

help_message property

The help message for this command

__init__(*args, name='tts', no_double_sound=True, voices={}, **kwargs)

Text-to-speech command. Instance this object, then pass it to RumbleChatActor().register_command().

Parameters:

Name Type Description Default
actor RumleChatActor

The RumleChatActor host object.

required
name str

The !name of the command. Defaults to "tts"

'tts'
no_double_sound bool

Do not play if act_props["sound"] is True. Defaults to True

True
voices dict

Dict of voice_name : say(text) callable.

{}
cooldown int | float

How long to wait before allowing the command to be run again. Defaults to static.Message.send_cooldown

required
amount_cents int

The minimum cost of the command. Defaults to free.

required
exclusive bool

If this command can only be run by users with allowed badges. Defaults to False.

required
allowed_badges list

Badges that are allowed to run this command (if it is exclusive); ignored if exclusive is False. "admin" is added internally. Defaults to ["subscriber"]

required
free_badges list

Badges which, if borne, give the user free-of-charge command access even if amount_cents > 0. "admin" is added internally. Defaults to ["moderator"]

required
Source code in rumchat_actor/commands.py
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
def __init__(self, *args, name = "tts", no_double_sound = True, voices = {}, **kwargs):
    """Text-to-speech command.
Instance this object, then pass it to RumbleChatActor().register_command().

Args:
    actor (RumleChatActor): The RumleChatActor host object.
    name (str): The !name of the command.
        Defaults to "tts"
    no_double_sound (bool): Do not play if act_props["sound"] is True.
        Defaults to True
    voices (dict): Dict of voice_name : say(text) callable.
    cooldown (int | float): How long to wait before allowing the command to be run again.
        Defaults to static.Message.send_cooldown
    amount_cents (int): The minimum cost of the command.
        Defaults to free.
    exclusive (bool): If this command can only be run by users with allowed badges.
        Defaults to False.
    allowed_badges (list): Badges that are allowed to run this command (if it is exclusive); ignored if exclusive is False.
        "admin" is added internally.
        Defaults to ["subscriber"]
    free_badges (list): Badges which, if borne, give the user free-of-charge command access even if amount_cents > 0.
        "admin" is added internally.
        Defaults to ["moderator"]
"""

    super().__init__(*args, name = name, **kwargs)

    self.no_double_sound = no_double_sound
    self.voices = voices

    #Make sure we have a default voice
    if "default" not in self.voices:
        self.voices["default"] = talkey.Talkey().say

run(message, act_props)

Run the TTS

Parameters:

Name Type Description Default
message Message

The chat message that called us.

required
act_props dict

Message action recorded properties.

required
Source code in rumchat_actor/commands.py
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
def run(self, message, act_props: dict):
    """Run the TTS

Args:
    message (cocorum.ChatAPI.Message): The chat message that called us.
    act_props (dict): Message action recorded properties."""

    #Do not create more sound if a message action already made some
    if self.no_double_sound and act_props.get("sound"):
        return

    segs = message.text.split()

    #No args for the tts command
    if len(segs) < 2:
        return

    #Only one word for tts
    if len(segs) == 2:
        self.speak(segs[1])
        return

    #A voice was selected
    if segs[1] in self.voices:
        self.speak(" ".join(segs[2:]), segs[1])
        return

    #No voice was selected
    self.speak(" ".join(segs[1:]))

speak(text, voice=None)

Speak text with voice

Parameters:

Name Type Description Default
voice str

The key of the voice in our voices dict. Defaults to None

None
Source code in rumchat_actor/commands.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def speak(self, text, voice = None):
    """Speak text with voice

Args:
    voice (str): The key of the voice in our voices dict.
        Defaults to None"""

    if not voice:
        self.default_voice(text)

    #Voice was not actually in our list of voices
    elif voice not in self.voices:
        self.default_voice(voice + " " + text)

    else:
        self.voices[voice](text)