- Python 100%
| docs | ||
| examples | ||
| scripts | ||
| src/pysignal | ||
| tests | ||
| .gitignore | ||
| .python-version | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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.