To-do List Printer

So it all started with this:

The eBay Listing

Ohhhhh, the beautiful and the wonderful joys of eBay...

So anyway, I started blasting bought it.

Well, I did have a project in mind, which was to print out to-do task sheets for myself. You see, I often write down ideas and notes on my signal's "Note to self" chat, especially when I'm away, but I never go back to look at it. I thought that if I had a physical printout of those waiting for me at home, I would be more likely to get through some of them. Plus, it'd be satisfying to crumple them up and toss it once the task is finished.

Previous Signal Bot

I've recently wrote up a Signal bot that's hooked up to a self-hosted LLM, just for shits and giggles. It was quite... amusing. (Maybe I'll write up a blog post about it)

Most LLMs are not great with ASCII art ???

I let it loose on a group chat with some friends, and chaos followed soon after

ohmygod

Aaaaaaaaaaaaaaaaanyways, I'll just do a similar thing, but with less AI.

Signal-API

That previous bot (and this bot) uses signalbot framework for interacting with the Signal REST API, which is provided by signal-cli-rest-api project (which uses signal-cli).

So basically, you first set up a docker container and then link it to your Signal account (like you do with desktop clients), and now you can interact with Signal over HTTP. Then, you can install and use the signalbot like it describes on its project page, and voilà! You can now interact with Signal in python programs.

For me, I have a docker swarm cluster set up, so the config looks something like this:

services:
  server:
    image: bbernhard/signal-cli-rest-api:latest
    deploy:
      mode: replicated
      replicas: 1
      labels:
        traefik.enable: "true"
        # ...Traefik related config...
    environment:
      - 'MODE=json-rpc'
    volumes:
      - /path/to/signal-config:/home/.local/share/signal-cli
# Docker Swarm mode
$ docker stack deploy -c docker-compose.yaml signal-api

# Standalone Docker
$ docker compose up -d

Checking the /v1/about endpoint:

Testing the about endpoint

I then navigate to https://my-signal-api/v1/qrcodelink?device_name=signal-api and link my Signal account.

Bot Code

The library is pretty easy to work with, and there are a few examples provided on the repo.

With that, I created the basics of the TodoCommand:

class TodoCommand(Command):
    """Command handler for printing to-do slips."""

    # @triggered("!todo")
    async def handle(self, ctx: Context) -> None:
        await self.bot.receipt(ctx.message, "read")

        msg = ctx.message.text
        
        # Ignore empty message
        if not msg:
            return

        # Check for command action
        if msg.split()[0].lower() != "!todo":
            return

        # Check if todo message is given
        if len(msg.split()) < 2:
            await self.bot.react(ctx.message, "❗")
            await ctx.send("What todo?")
            return

        # Remove "!todo " to get just the message
        msg = msg[5:]

        # TODO: Print message
        print(msg)
        # not ^ that print, actually print onto receipt paper
        
        # React when printing successful
        await self.bot.react(ctx.message, "🖨")


def main():
    # Load configuration from config.json
    with open("config.json", "r") as f:
        cfg = json.load(f)

    bot = SignalBot(cfg["bot_config"])
    bot.register(TodoCommand())
    bot.start()


if __name__ == "__main__":
    main()

And the contents of config.json looks like:

{
  "bot_config": {
    "signal_service": "your-signal-api-url",
    "phone_number": "+15555555555",
    "storage": {
      "type": "sqlite",
      "sqlite_db": "signal.db"
    }
  }
}

Notice the @triggered decorator1 on def handle() is commented out. That decorator only matches if the entire message matches the keyword (or string) entirely. That is not what I want. There is also the @regex_triggered decorator, but I don't think I really need full regex processing here, just a string compare on the first few characters.

Printer Interface

Now, I did take a look before just buying the receipt printer. There is the ESC/POS project which provides tools and libraries for "Epson Standard Code for Point of Sale" systems. And guess what? There is a python library and my printer (TM-T88IV) is supported.

You can install the escpos library:

$ pip install escpos

# Or if using uv
$ uv add escpos

This project also had pretty great documentation, so I just went ahead and wrote something up:

from escpos.printer import File

device = config.get("device", "/dev/usb/lp0")
profile = config.get("profile", "TM-T88IV")

printer = File(device, profile=profile)
printer.set(align="left")
printer.textln("TEST")
printer.cut()

The printer was turned on and plugged into the computer via USB. The printer showed up as /dev/usb/lp0, so I used the escpos.printer.File class, but later, I remembered I wanted to run this on docker on my cluster, so I changed it to use the Network class.

from escpos.printer import Network

device = config.get("device", "address.to.my.printer")
profile = config.get("profile", "TM-T88IV")

printer = Network(device, profile=profile)
printer.set(align="left")
printer.textln("TEST")
printer.cut()

Putting it together

It's actually quite simple. Basically take the code block above and insert into the previous TodoCommand's "#TODO" block, massage things around, and add some formatting:

class TodoCommand(Command):
    """Command handler for printing to-do slips."""

    def __init__(self, config: dict) -> None:
        super().__init__()
        device = config.get("device", "")
        profile = config.get("profile", "TM-T88IV")
        self.printer = Network(device, profile=profile)
        self.printer.set(align="left")
        self.printer.close()

    # @triggered("!todo")
    async def handle(self, ctx: Context) -> None:

        # ...omitted...

        # Format and print message
        ts = datetime.fromtimestamp(ctx.message.timestamp / 1000)
        ts = ts.strftime("[%m/%d/%Y (%a) -- %H:%M:%S]\n")

        self.printer.open()
        self.printer.textln(ts)

        self.printer.set(bold=True)
        self.printer.textln("TODO: ")
        self.printer.set(bold=False)
        self.printer.textln(msg)

        self.printer.cut()
        self.printer.close()
        await self.bot.react(ctx.message, "🖨")

I did my first test:

Test message Test print

Images (attachments)

They say "a picture is worth a hundred words" or something like that... What I'm trying to say is that sometimes, I also attach images or diagrams as reference. It would be nice to print those out to.

The signalbot library does provide us the list of attachments, but base642 encoded. I also filter for image files, and use Pillow (fork of Python Imaging Library) to resize the image.

# Print images
if ctx.message.base64_attachments:
  for a, name in zip(ctx.message.base64_attachments, ctx.message.attachments_local_filenames):
    match Path(name).suffix:
      case ".png" | ".jpg" | ".jpeg" | ".gif" | ".bmp":
        with tempfile.NamedTemporaryFile() as f:
          f.write(base64.b64decode(a))
          img = Image.open(f)
          ratio = img.height / img.width
          new_height = int(ratio * 512)
          new_img = img.resize((512, new_height), Image.Resampling.LANCZOS)

          self.printer.image(new_img)
        self.printer.textln()

With that added, another test:

Important message Important receipt

End

With some cleanup, comments, and logging, the current version of the code lives over at my forgejo instance. It also auto-builds the docker image and pushes it to Forgejo's integrated container registry, where my docker swarm cluster can fetch the newly built images from.

The TodoCommand itself is not too complicated, but I build out a basic structure that I could extend and add more commands to.

I built this on a Monday morning. I had finished about 4 mini-projects by the end of Thursday. So I guess my plan is working. In fact, I have a pile of these todo tasks with blog posts I plan on writing.

We'll see how long that lasts...

Laterzz,
-B

  1. Think of decorators in Python as function "wrappers" / modifiers. geeksforgeeks

  2. Base64 is a binary-to-text encoding that uses 64 printable characters to represent each 6-bit segment of a sequence of byte values. As for all binary-to-text encodings, Base64 encoding enables transmitting binary data on a communication channel that only supports text. wikipedia