Skip to content

Commit 71e09a1

Browse files
authored
Merge pull request #28 from filipre/private-chats
Version 0.8.0 Release
2 parents 27bddb0 + 4c1067c commit 71e09a1

18 files changed

+524
-177
lines changed

README.md

+99-9
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,91 @@ Python package to build your own Signal bots. To run the the bot you need to sta
44

55
## Getting Started
66

7-
Please see https://github.com/filipre/signalbot-example for an example how to use the package and how to build a simple bot.
7+
Below you can find a minimal example on how to use the package. There is also a bigger example in the `example` folder.
8+
9+
```python
10+
import os
11+
from signalbot import SignalBot, Command, Context
12+
from commands import PingCommand
13+
14+
15+
class PingCommand(Command):
16+
async def handle(self, c: Context):
17+
if c.message.text == "Ping":
18+
await c.send("Pong")
19+
20+
21+
if __name__ == "__main__":
22+
bot = SignalBot({
23+
"signal_service": os.environ["SIGNAL_SERVICE"],
24+
"phone_number": os.environ["PHONE_NUMBER"]
25+
})
26+
bot.register(PingCommand()) # all contacts and groups
27+
bot.start()
28+
```
29+
30+
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.
31+
32+
1. Run signal-cli-rest-api in `normal` mode first.
33+
```bash
34+
docker run -p 8080:8080 \
35+
-v $(PWD)/signal-cli-config:/home/.local/share/signal-cli \
36+
-e 'MODE=normal' bbernhard/signal-cli-rest-api:0.57
37+
```
38+
39+
2. Open http://127.0.0.1:8080/v1/qrcodelink?device_name=local to link your account with the signal-cli-rest-api server
40+
41+
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`.
42+
43+
4. Restart the server in `json-rpc` mode.
44+
```bash
45+
docker run -p 8080:8080 \
46+
-v $(PWD)/signal-cli-config:/home/.local/share/signal-cli \
47+
-e 'MODE=json-rpc' bbernhard/signal-cli-rest-api:0.57
48+
```
49+
50+
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.
51+
```
52+
...
53+
time="2022-03-07T13:02:22Z" level=info msg="Found number +491234567890 and added it to jsonrpc2.yml"
54+
...
55+
time="2022-03-07T13:02:24Z" level=info msg="Started Signal Messenger REST API"
56+
```
57+
58+
6. Use the following snippet to get a group's `id`:
59+
```bash
60+
curl -X GET 'http://127.0.0.1:8080/v1/groups/+49123456789' | python -m json.tool
61+
```
62+
63+
7. Install `signalbot` and start your python script. You need to pass following environment variables to make the example run:
64+
- `SIGNAL_SERVICE`: Address of the signal service without protocol, e.g. `127.0.0.1:8080`
65+
- `PHONE_NUMBER`: Phone number of the bot, e.g. `+49123456789`
66+
67+
```bash
68+
export SIGNAL_SERVICE="127.0.0.1"
69+
export PHONE_NUMBER="+49123456789"
70+
pip install signalbot
71+
python bot.py
72+
```
73+
74+
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.
75+
```
76+
INFO:root:[Bot] Producer #1 started
77+
INFO:root:[Bot] Consumer #1 started
78+
INFO:root:[Bot] Consumer #2 started
79+
INFO:root:[Bot] Consumer #3 started
80+
```
81+
82+
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.
83+
```
84+
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}
85+
INFO:root:[Bot] Consumer #2 got new job in 0.00046 seconds
86+
INFO:root:[Bot] Consumer #2 got new job in 0.00079 seconds
87+
INFO:root:[Bot] Consumer #2 got new job in 0.00093 seconds
88+
INFO:root:[Bot] Consumer #2 got new job in 0.00106 seconds
89+
INFO:root:[Bot] New message 1646000000000 sent:
90+
Pong
91+
```
892

993
## Classes and API
1094

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

1599
### Signalbot
16100

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

32116
- `setup(self)`: Start any task that requires to send messages already, optional
33117
- `describe(self)`: String to describe your command, optional
34-
- `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`.
118+
- `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`.
35119

36120
### Unit Testing
37121

122+
*Note: deprecated, I want to switch to pytest eventually*
123+
38124
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:
39125
```python
40126
class PingChatTest(ChatTestCase):
@@ -79,8 +165,12 @@ There are a few other related projects similar to this one. You may want to chec
79165
|Project|Description|Language|
80166
|-------|-----------|--------|
81167
|https://github.com/lwesterhof/semaphore|Bot Framework|Python|
82-
|https://github.com/signalapp/libsignal-service-java|Signal Library|Java|
168+
|https://git.sr.ht/~nicoco/aiosignald|signald Library / Bot Framework|Python|
169+
|https://gitlab.com/stavros/pysignald/|signald Library / Bot Framework|Python|
170+
|https://gitlab.com/signald/signald-go|signald Library|Go|
171+
|https://github.com/signal-bot/signal-bot|Bot Framework using Signal CLI|Python|
172+
|https://github.com/bbernhard/signal-cli-rest-api|REST API Wrapper for Signal CLI|Go|
173+
|https://github.com/bbernhard/pysignalclirestapi|Python Wrapper for REST API|Python|
83174
|https://github.com/AsamK/signal-cli|A CLI and D-Bus interface for Signal|Java|
84-
|https://github.com/bbernhard/signal-cli-rest-api|REST API Wrapper for Signal CLI|
175+
|https://github.com/signalapp/libsignal-service-java|Signal Library|Java|
85176
|https://github.com/aaronetz/signal-bot|Bot Framework|Java|
86-
|https://github.com/signal-bot/signal-bot|Bot Framework|Python|

example/bot.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import os
2+
from signalbot import SignalBot
3+
from commands import (
4+
PingCommand,
5+
FridayCommand,
6+
TypingCommand,
7+
TriggeredCommand,
8+
ReplyCommand,
9+
)
10+
import logging
11+
12+
logging.getLogger().setLevel(logging.INFO)
13+
logging.getLogger("apscheduler").setLevel(logging.WARNING)
14+
15+
16+
def main():
17+
signal_service = os.environ["SIGNAL_SERVICE"]
18+
phone_number = os.environ["PHONE_NUMBER"]
19+
20+
config = {
21+
"signal_service": signal_service,
22+
"phone_number": phone_number,
23+
"storage": None,
24+
}
25+
bot = SignalBot(config)
26+
27+
# enable a chat command for all contacts and all groups
28+
bot.register(PingCommand())
29+
bot.register(ReplyCommand())
30+
31+
# enable a chat command only for groups
32+
bot.register(FridayCommand(), contacts=False, groups=True)
33+
34+
# enable a chat command for one specific group with the name "My Group"
35+
bot.register(TypingCommand(), groups=["My Group"])
36+
37+
# chat command is enabled for all groups and one specific contact
38+
bot.register(TriggeredCommand(), contacts=["+490123456789"], groups=True)
39+
40+
bot.start()
41+
42+
43+
if __name__ == "__main__":
44+
main()

example/commands/__init__.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .ping import PingCommand
2+
from .friday import FridayCommand
3+
from .typing import TypingCommand
4+
from .triggered import TriggeredCommand
5+
from .reply import ReplyCommand
6+
7+
__all__ = [
8+
"PingCommand",
9+
"FridayCommand",
10+
"TypingCommand",
11+
"TriggeredCommand",
12+
"ReplyCommand",
13+
]

example/commands/friday.py

+17
Large diffs are not rendered by default.

example/commands/ping.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from signalbot import Command, Context
2+
3+
4+
class PingCommand(Command):
5+
def describe(self) -> str:
6+
return "🏓 Ping Command: Listen for a ping"
7+
8+
async def handle(self, c: Context):
9+
command = c.message.text
10+
11+
if command == "ping":
12+
await c.send("pong")
13+
return

example/commands/reply.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from signalbot import Command, Context
2+
3+
4+
class ReplyCommand(Command):
5+
async def handle(self, c: Context):
6+
if "reply" in c.message.text.lower():
7+
await c.reply(
8+
"i ain't reading all that. i'm happy for u tho or sorry that happened"
9+
)

example/commands/tests/__init__.py

Whitespace-only changes.

example/commands/tests/test_ping.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import unittest
2+
from signalbot.utils import ChatTestCase, chat
3+
from commands.ping import PingCommand
4+
5+
6+
class PingChatTest(ChatTestCase):
7+
def setUp(self):
8+
super().setUp()
9+
self.signal_bot.register(PingCommand())
10+
11+
@chat("ping")
12+
async def test_ping(self, query, replies, reactions):
13+
self.assertEqual(replies.call_count, 1)
14+
for recipient, message in replies.results():
15+
self.assertEqual(recipient, ChatTestCase.group_secret)
16+
self.assertEqual(message, "pong")
17+
18+
19+
if __name__ == "__main__":
20+
unittest.main()

example/commands/triggered.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from signalbot import Command, Context, triggered
2+
3+
4+
class TriggeredCommand(Command):
5+
def describe(self) -> str:
6+
return "😤 Decorator example, matches command_1, command_2 and command_3"
7+
8+
# add case_sensitive=True for case sensitive triggers
9+
@triggered("command_1", "Command_2", "CoMmAnD_3")
10+
async def handle(self, c: Context):
11+
await c.send("I am triggered")

example/commands/typing.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import asyncio
2+
from signalbot import Command, Context
3+
4+
5+
class TypingCommand(Command):
6+
def describe(self) -> str:
7+
return None
8+
9+
async def handle(self, c: Context):
10+
if c.message.text == "typing":
11+
await c.start_typing()
12+
seconds = 5
13+
await asyncio.sleep(seconds)
14+
await c.stop_typing()
15+
await c.send(f"Typed for {seconds}s")

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ maintainers = ["René Filip"]
1818
name = "signalbot"
1919
readme = "README.md"
2020
repository = "https://github.com/filipre/signalbot"
21-
version = "0.7.0"
21+
version = "0.8.0"
2222

2323
[tool.poetry.dependencies]
2424
APScheduler = "^3.9.1"

signalbot/api.py

+36-2
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,32 @@ async def send(
2929
receiver: str,
3030
message: str,
3131
base64_attachments: list = None,
32-
mentions: list = None, # Added this line
32+
quote_author: str = None,
33+
quote_mentions: list = None,
34+
quote_message: str = None,
35+
quote_timestamp: str = None,
36+
mentions: list = None,
3337
) -> aiohttp.ClientResponse:
3438
uri = self._send_rest_uri()
3539
if base64_attachments is None:
3640
base64_attachments = []
41+
3742
payload = {
3843
"base64_attachments": base64_attachments,
3944
"message": message,
4045
"number": self.phone_number,
4146
"recipients": [receiver],
4247
}
43-
if mentions: # Add mentions to the payload if they exist
48+
49+
if quote_author:
50+
payload["quote_author"] = quote_author
51+
if quote_mentions:
52+
payload["quote_mentions"] = quote_mentions
53+
if quote_message:
54+
payload["quote_message"] = quote_message
55+
if quote_timestamp:
56+
payload["quote_timestamp"] = quote_timestamp
57+
if mentions:
4458
payload["mentions"] = mentions
4559

4660
try:
@@ -108,6 +122,19 @@ async def stop_typing(self, receiver: str):
108122
):
109123
raise StopTypingError
110124

125+
async def get_groups(self):
126+
uri = self._groups_uri()
127+
try:
128+
async with aiohttp.ClientSession() as session:
129+
resp = await session.get(uri)
130+
resp.raise_for_status()
131+
return await resp.json()
132+
except (
133+
aiohttp.ClientError,
134+
aiohttp.http_exceptions.HttpProcessingError,
135+
):
136+
raise GroupsError
137+
111138
def _receive_ws_uri(self):
112139
return f"ws://{self.signal_service}/v1/receive/{self.phone_number}"
113140

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

150+
def _groups_uri(self):
151+
return f"http://{self.signal_service}/v1/groups/{self.phone_number}"
152+
123153

124154
class ReceiveMessagesError(Exception):
125155
pass
@@ -143,3 +173,7 @@ class StopTypingError(TypingError):
143173

144174
class ReactionError(Exception):
145175
pass
176+
177+
178+
class GroupsError(Exception):
179+
pass

0 commit comments

Comments
 (0)