No description
Find a file
2026-07-27 16:36:56 +02:00
docs Genesis commit 2026-07-27 16:36:56 +02:00
examples Genesis commit 2026-07-27 16:36:56 +02:00
scripts Genesis commit 2026-07-27 16:36:56 +02:00
src/pysignal Genesis commit 2026-07-27 16:36:56 +02:00
tests Genesis commit 2026-07-27 16:36:56 +02:00
.gitignore Genesis commit 2026-07-27 16:36:56 +02:00
.python-version Genesis commit 2026-07-27 16:36:56 +02:00
pyproject.toml Genesis commit 2026-07-27 16:36:56 +02:00
README.md Genesis commit 2026-07-27 16:36:56 +02:00
uv.lock Genesis commit 2026-07-27 16:36:56 +02:00

pysignal

A pythonic DSL for Signal, built on signal-cli-rest-api.

from pysignal import Message, Poll, Signal

signal = Signal("127.0.0.1", 5000)

group = signal.groups()[0]
alice = signal.user(name="Alice")

group.send(Message("Yo ").mention(alice).text(" how you doing?"))
group.send(Message("This is ").bold("bold").text(" and ").italic("italic"))

Fully typed, mypy --strict clean, and available in both synchronous and asynchronous flavours.

Install

uv add pysignal        # or: pip install pysignal

Requires Python 3.13+ and a running signal-cli-rest-api instance.

Two clients, one DSL

Like httpx, pysignal ships a synchronous and an asynchronous client. They run the same implementation, so behaviour never diverges.

from pysignal import Signal, AsyncSignal

signal = Signal("127.0.0.1", 5000)          # blocking
group = signal.groups()[0]
group.send("hello")

async with AsyncSignal("127.0.0.1", 5000) as signal:   # async
    group = (await signal.groups())[0]
    await group.send("hello")

Both start receiving in the background as soon as they are created, which is what makes this read naturally:

poll = group.poll(Poll("Pizza tonight?", ["Yes", "No"]))
sleep(10)                      # votes stream in on a background thread
if poll.winner == "Yes":
    group.send("Ordering now")

Users and groups

Membership arrives with the group, so iterating it costs nothing:

group = signal.group("Friends")
print(group)                       # <Group "Friends" with ID abc123...>
print(len(group))                  # 4
print([u.name for u in group])     # ['Alice', 'Bob', 'Carol', 'Dave']
print(alice in group)              # True
print(group.admins_list())         # [<User "Alice" with ID ...>]

alice = signal.user(phone="+15551234567")
alice = signal.user(uuid="aaaa-bbbb-...")
alice = signal.user(name="Alice")      # fuzzy, raises AmbiguousUser on a tie
alice = signal.user("alice")           # any of the above

group = signal.create_group("Friends", alice, bob)
group.invite(carol)
group.add_admin(carol)
group.remove_admin(carol)
group.leave()

Composing messages

Message is an immutable value object; every call returns a new one.

user.send("Hi!")
user.send(Message("My name is ") + "Alice")
user.send(Message.bold("This message is bold!"))
user.send(Message("This word is ").italic("italic"))

user.send(
    Message("This contains ")
    .bold("bold").text(", ")
    .italic("italic").text(" and ")
    .monospace("monospace")
)

# Or style by span index, which reads better for long messages
user.send(
    Message("This contains ", "bold", ", ", "italic", "!")
    .bold(1)
    .italic(3)
)

# Or hand it raw markdown - `_italic_` and `~~strike~~` are accepted too
user.send(Message.markdown("Contains **bold**, _italic_ and `mono`"))

Mentions, attachments, quotes, previews and stickers:

group.send(alice + ", wake up!")                    # "@Alice, wake up!"
group.send(Message("Yo ").mention(alice).text("!"))
group.send(Message("Not a virus").file(Path("virus.exe")))
group.send(Message("see this").preview("https://example.com", title="Example"))

msg = group.send("Yo")
msg.reply("gurt")
msg.react("👍")
msg.edit("Yo!")
msg.delete()

Text you pass is escaped, so "2 * 3" arrives as 2 * 3 rather than turning into italics. See docs/formatting.md for how this works and the one combination Signal cannot represent.

Polls

Options may carry any Python value, so results read in your own vocabulary:

poll = group.poll(
    Poll(
        "How many air friers should I buy?",
        options=[("Only one", 1), ("Two", 2), ("FIVE HUNDRED", 500)],
    )
)
sleep(10)

for user in poll.option(500):
    group.send(user + " FYM 500?????")

if poll.winner == 2:
    group.send("Alright, two it is")

poll.close()

Reacting to events

@group.on_message()
def on_message(message):
    message.reply("Sybau")

@signal.on_message(regex=r"^!ping\b")
def ping(message):
    message.reply("pong")

@signal.on_reaction(emoji="👍")
def thumbed(event): ...

@signal.on_error
def oops(exc, event): ...

signal.loop()      # park the main thread; events keep arriving

Filters compose: regex, prefix, sender, group, direct, mentions_me, predicate, and arbitrary Filter objects combined with &, |, ~.

Command bots

from pysignal import CommandBot

bot = CommandBot(prefix="!")

@bot.command(help="Mute a user")
def mute(ctx, user: User, minutes: int = 10, *, reason: str = ""): ...

Arguments are parsed and coerced from the handler's type annotations, and !help is generated for you. See docs/events.md.

Documentation

Getting started Connecting, the two clients, the receive loop
Messages & formatting The Message builder, styling, mentions, escaping
Groups & users Lookup, membership, administration
Polls Creating, voting, reading results
Events & bots Handlers, filters, the command framework
Architecture The adapter seam, adding a backend
Limitations What the REST API cannot do, and why

Limitations in brief

Some things signal-cli supports are simply not exposed by signal-cli-rest-api, so pysignal cannot do them either. They raise NotSupported with an explanation rather than failing obscurely:

  • Calls — no endpoint exists. Signal calling is also 1:1 voice only and needs a separate native binary; group calls do not exist at any layer.
  • Posting stories — receive-only.
  • Creating sticker packs — you can install and send, but not upload.
  • Blocking contacts — groups can be blocked, contacts cannot.

Full detail, including what would be needed to support each, is in docs/limitations.md.

Development

uv sync
uv run pytest                 # unit tests, no network
uv run mypy                   # strict
uv run ruff check src tests
uv run python scripts/smoke.py --read-only    # against a live server

Licence

MIT.