Skip to content

Commit

Permalink
Merge pull request #28 from filipre/private-chats
Browse files Browse the repository at this point in the history
Version 0.8.0 Release
  • Loading branch information
filipre authored Sep 16, 2023
2 parents 27bddb0 + 4c1067c commit 71e09a1
Show file tree
Hide file tree
Showing 18 changed files with 524 additions and 177 deletions.
108 changes: 99 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,91 @@ Python package to build your own Signal bots. To run the the bot you need to sta

## Getting Started

Please see https://github.com/filipre/signalbot-example for an example how to use the package and how to build a simple bot.
Below you can find a minimal example on how to use the package. There is also a bigger example in the `example` folder.

```python
import os
from signalbot import SignalBot, Command, Context
from commands import PingCommand


class PingCommand(Command):
async def handle(self, c: Context):
if c.message.text == "Ping":
await c.send("Pong")


if __name__ == "__main__":
bot = SignalBot({
"signal_service": os.environ["SIGNAL_SERVICE"],
"phone_number": os.environ["PHONE_NUMBER"]
})
bot.register(PingCommand()) # all contacts and groups
bot.start()
```

Please check out https://github.com/bbernhard/signal-cli-rest-api#getting-started to learn about [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) and [signal-cli](https://github.com/AsamK/signal-cli). A good first step is to make the example above work.

1. Run signal-cli-rest-api in `normal` mode first.
```bash
docker run -p 8080:8080 \
-v $(PWD)/signal-cli-config:/home/.local/share/signal-cli \
-e 'MODE=normal' bbernhard/signal-cli-rest-api:0.57
```

2. Open http://127.0.0.1:8080/v1/qrcodelink?device_name=local to link your account with the signal-cli-rest-api server

3. In your Signal app, open settings and scan the QR code. The server can now receive and send messages. The access key will be stored in `$(PWD)/signal-cli-config`.

4. Restart the server in `json-rpc` mode.
```bash
docker run -p 8080:8080 \
-v $(PWD)/signal-cli-config:/home/.local/share/signal-cli \
-e 'MODE=json-rpc' bbernhard/signal-cli-rest-api:0.57
```

5. The logs should show something like this. You can also confirm that the server is running in the correct mode by visiting http://127.0.0.1:8080/v1/about.
```
...
time="2022-03-07T13:02:22Z" level=info msg="Found number +491234567890 and added it to jsonrpc2.yml"
...
time="2022-03-07T13:02:24Z" level=info msg="Started Signal Messenger REST API"
```

6. Use the following snippet to get a group's `id`:
```bash
curl -X GET 'http://127.0.0.1:8080/v1/groups/+49123456789' | python -m json.tool
```

7. Install `signalbot` and start your python script. You need to pass following environment variables to make the example run:
- `SIGNAL_SERVICE`: Address of the signal service without protocol, e.g. `127.0.0.1:8080`
- `PHONE_NUMBER`: Phone number of the bot, e.g. `+49123456789`

```bash
export SIGNAL_SERVICE="127.0.0.1"
export PHONE_NUMBER="+49123456789"
pip install signalbot
python bot.py
```

8. The logs should indicate that one "producer" and three "consumers" have started. The producer checks for new messages sent to the linked account using a web socket connection. It creates a task for every registered command and the consumers work off the tasks. In case you are working with many blocking function calls, you may need to adjust the number of consumers such that the bot stays reactive.
```
INFO:root:[Bot] Producer #1 started
INFO:root:[Bot] Consumer #1 started
INFO:root:[Bot] Consumer #2 started
INFO:root:[Bot] Consumer #3 started
```

9. Send the message `Ping` (case sensitive) to the group that the bot is listening to. The bot (i.e. the linked account) should respond with a `Pong`. Confirm that the bot received a raw message, that the consumer worked on the message and that a new message has been sent.
```
INFO:root:[Raw Message] {"envelope":{"source":"+49123456789","sourceNumber":"+49123456789","sourceUuid":"fghjkl-asdf-asdf-asdf-dfghjkl","sourceName":"René","sourceDevice":3,"timestamp":1646000000000,"syncMessage":{"sentMessage":{"destination":null,"destinationNumber":null,"destinationUuid":null,"timestamp":1646000000000,"message":"Pong","expiresInSeconds":0,"viewOnce":false,"groupInfo":{"groupId":"asdasdfweasdfsdfcvbnmfghjkl=","type":"DELIVER"}}}},"account":"+49123456789","subscription":0}
INFO:root:[Bot] Consumer #2 got new job in 0.00046 seconds
INFO:root:[Bot] Consumer #2 got new job in 0.00079 seconds
INFO:root:[Bot] Consumer #2 got new job in 0.00093 seconds
INFO:root:[Bot] Consumer #2 got new job in 0.00106 seconds
INFO:root:[Bot] New message 1646000000000 sent:
Pong
```

## Classes and API

Expand All @@ -14,11 +98,11 @@ The package provides methods to easily listen for incoming messages and respondi

### Signalbot

- `bot.listen(group_id, internal_id)`: Listen for messages in a group chat. `group_id` must be prefixed with `group.`
- `bot.listen(phone_number)`: Listen for messages in a user chat.
- `bot.register(command)`: Register a new command
- `bot.register(command, contacts=True, groups=True)`: Register a new command, listen in all contacts and groups, same as `bot.register(command)`
- `bot.register(command, contacts=False, groups=["Hello World"])`: Only listen in the "Hello World" group
- `bot.register(command, contacts=["+49123456789"], groups=False)`: Only respond to one contact
- `bot.start()`: Start the bot
- `bot.send(receiver, text, listen=False)`: Send a new message
- `bot.send(receiver, text)`: Send a new message
- `bot.react(message, emoji)`: React to a message
- `bot.start_typing(receiver)`: Start typing
- `bot.stop_typing(receiver)`: Stop typing
Expand All @@ -31,10 +115,12 @@ To implement your own commands, you need to inherent `Command` and overwrite fol

- `setup(self)`: Start any task that requires to send messages already, optional
- `describe(self)`: String to describe your command, optional
- `handle(self, c: Context)`: Handle an incoming message. By default, any command will read any incoming message. `Context` can be used to easily reply (`c.send(text)`), react (`c.react(emoji)`) and to type in a group (`c.start_typing()` and `c.stop_typing()`). You can use the `@triggered` decorator to listen for specific commands or you can inspect `c.message.text`.
- `handle(self, c: Context)`: Handle an incoming message. By default, any command will read any incoming message. `Context` can be used to easily send (`c.send(text)`), reply (`c.reply(text)`), react (`c.react(emoji)`) and to type in a group (`c.start_typing()` and `c.stop_typing()`). You can use the `@triggered` decorator to listen for specific commands or you can inspect `c.message.text`.

### Unit Testing

*Note: deprecated, I want to switch to pytest eventually*

In many cases, we can mock receiving and sending messages to speed up development time. To do so, you can use `signalbot.utils.ChatTestCase` which sets up a "skeleton" bot. Then, you can send messages using the `@chat` decorator in `signalbot.utils` like this:
```python
class PingChatTest(ChatTestCase):
Expand Down Expand Up @@ -79,8 +165,12 @@ There are a few other related projects similar to this one. You may want to chec
|Project|Description|Language|
|-------|-----------|--------|
|https://github.com/lwesterhof/semaphore|Bot Framework|Python|
|https://github.com/signalapp/libsignal-service-java|Signal Library|Java|
|https://git.sr.ht/~nicoco/aiosignald|signald Library / Bot Framework|Python|
|https://gitlab.com/stavros/pysignald/|signald Library / Bot Framework|Python|
|https://gitlab.com/signald/signald-go|signald Library|Go|
|https://github.com/signal-bot/signal-bot|Bot Framework using Signal CLI|Python|
|https://github.com/bbernhard/signal-cli-rest-api|REST API Wrapper for Signal CLI|Go|
|https://github.com/bbernhard/pysignalclirestapi|Python Wrapper for REST API|Python|
|https://github.com/AsamK/signal-cli|A CLI and D-Bus interface for Signal|Java|
|https://github.com/bbernhard/signal-cli-rest-api|REST API Wrapper for Signal CLI|
|https://github.com/signalapp/libsignal-service-java|Signal Library|Java|
|https://github.com/aaronetz/signal-bot|Bot Framework|Java|
|https://github.com/signal-bot/signal-bot|Bot Framework|Python|
44 changes: 44 additions & 0 deletions example/bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import os
from signalbot import SignalBot
from commands import (
PingCommand,
FridayCommand,
TypingCommand,
TriggeredCommand,
ReplyCommand,
)
import logging

logging.getLogger().setLevel(logging.INFO)
logging.getLogger("apscheduler").setLevel(logging.WARNING)


def main():
signal_service = os.environ["SIGNAL_SERVICE"]
phone_number = os.environ["PHONE_NUMBER"]

config = {
"signal_service": signal_service,
"phone_number": phone_number,
"storage": None,
}
bot = SignalBot(config)

# enable a chat command for all contacts and all groups
bot.register(PingCommand())
bot.register(ReplyCommand())

# enable a chat command only for groups
bot.register(FridayCommand(), contacts=False, groups=True)

# enable a chat command for one specific group with the name "My Group"
bot.register(TypingCommand(), groups=["My Group"])

# chat command is enabled for all groups and one specific contact
bot.register(TriggeredCommand(), contacts=["+490123456789"], groups=True)

bot.start()


if __name__ == "__main__":
main()
13 changes: 13 additions & 0 deletions example/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from .ping import PingCommand
from .friday import FridayCommand
from .typing import TypingCommand
from .triggered import TriggeredCommand
from .reply import ReplyCommand

__all__ = [
"PingCommand",
"FridayCommand",
"TypingCommand",
"TriggeredCommand",
"ReplyCommand",
]
17 changes: 17 additions & 0 deletions example/commands/friday.py

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions example/commands/ping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from signalbot import Command, Context


class PingCommand(Command):
def describe(self) -> str:
return "🏓 Ping Command: Listen for a ping"

async def handle(self, c: Context):
command = c.message.text

if command == "ping":
await c.send("pong")
return
9 changes: 9 additions & 0 deletions example/commands/reply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from signalbot import Command, Context


class ReplyCommand(Command):
async def handle(self, c: Context):
if "reply" in c.message.text.lower():
await c.reply(
"i ain't reading all that. i'm happy for u tho or sorry that happened"
)
Empty file.
20 changes: 20 additions & 0 deletions example/commands/tests/test_ping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import unittest
from signalbot.utils import ChatTestCase, chat
from commands.ping import PingCommand


class PingChatTest(ChatTestCase):
def setUp(self):
super().setUp()
self.signal_bot.register(PingCommand())

@chat("ping")
async def test_ping(self, query, replies, reactions):
self.assertEqual(replies.call_count, 1)
for recipient, message in replies.results():
self.assertEqual(recipient, ChatTestCase.group_secret)
self.assertEqual(message, "pong")


if __name__ == "__main__":
unittest.main()
11 changes: 11 additions & 0 deletions example/commands/triggered.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from signalbot import Command, Context, triggered


class TriggeredCommand(Command):
def describe(self) -> str:
return "😤 Decorator example, matches command_1, command_2 and command_3"

# add case_sensitive=True for case sensitive triggers
@triggered("command_1", "Command_2", "CoMmAnD_3")
async def handle(self, c: Context):
await c.send("I am triggered")
15 changes: 15 additions & 0 deletions example/commands/typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import asyncio
from signalbot import Command, Context


class TypingCommand(Command):
def describe(self) -> str:
return None

async def handle(self, c: Context):
if c.message.text == "typing":
await c.start_typing()
seconds = 5
await asyncio.sleep(seconds)
await c.stop_typing()
await c.send(f"Typed for {seconds}s")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ maintainers = ["René Filip"]
name = "signalbot"
readme = "README.md"
repository = "https://github.com/filipre/signalbot"
version = "0.7.0"
version = "0.8.0"

[tool.poetry.dependencies]
APScheduler = "^3.9.1"
Expand Down
38 changes: 36 additions & 2 deletions signalbot/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,32 @@ async def send(
receiver: str,
message: str,
base64_attachments: list = None,
mentions: list = None, # Added this line
quote_author: str = None,
quote_mentions: list = None,
quote_message: str = None,
quote_timestamp: str = None,
mentions: list = None,
) -> aiohttp.ClientResponse:
uri = self._send_rest_uri()
if base64_attachments is None:
base64_attachments = []

payload = {
"base64_attachments": base64_attachments,
"message": message,
"number": self.phone_number,
"recipients": [receiver],
}
if mentions: # Add mentions to the payload if they exist

if quote_author:
payload["quote_author"] = quote_author
if quote_mentions:
payload["quote_mentions"] = quote_mentions
if quote_message:
payload["quote_message"] = quote_message
if quote_timestamp:
payload["quote_timestamp"] = quote_timestamp
if mentions:
payload["mentions"] = mentions

try:
Expand Down Expand Up @@ -108,6 +122,19 @@ async def stop_typing(self, receiver: str):
):
raise StopTypingError

async def get_groups(self):
uri = self._groups_uri()
try:
async with aiohttp.ClientSession() as session:
resp = await session.get(uri)
resp.raise_for_status()
return await resp.json()
except (
aiohttp.ClientError,
aiohttp.http_exceptions.HttpProcessingError,
):
raise GroupsError

def _receive_ws_uri(self):
return f"ws://{self.signal_service}/v1/receive/{self.phone_number}"

Expand All @@ -120,6 +147,9 @@ def _react_rest_uri(self):
def _typing_indicator_uri(self):
return f"http://{self.signal_service}/v1/typing-indicator/{self.phone_number}"

def _groups_uri(self):
return f"http://{self.signal_service}/v1/groups/{self.phone_number}"


class ReceiveMessagesError(Exception):
pass
Expand All @@ -143,3 +173,7 @@ class StopTypingError(TypingError):

class ReactionError(Exception):
pass


class GroupsError(Exception):
pass
Loading

0 comments on commit 71e09a1

Please sign in to comment.