Skip to content

Commit 08fea72

Browse files
author
Botium
authored
Merge pull request #18 from codeforequity-at/BOT-2087-rasa-channel
BOT-2087-rasa-channel
2 parents 0853cbf + 079f768 commit 08fea72

File tree

10 files changed

+389
-2
lines changed

10 files changed

+389
-2
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
logs
2-
docker-compose.override.yml
2+
docker-compose.override.yml
3+
__pycache__

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Some examples what you can do with this:
2424

2525
* Synthesize audio tracks for Youtube tutorials
2626
* Build voice-enabled chatbot services (for example, IVR systems)
27+
* see the [Rasa Custom Voice Channel](./connectors/rasa)
2728
* Classification of audio file transcriptions
2829
* [Automated Testing](https://chatbotslife.com/testing-alexa-skills-with-avs-mocha-and-botium-f6c22549f66e) of Voice services with [Botium](https://medium.com/@floriantreml/botium-in-a-nutshell-part-1-overview-f8d0ceaf8fb4)
2930

@@ -154,6 +155,11 @@ This project is standing on the shoulders of giants.
154155

155156
## Changelog
156157

158+
### 2021-01-26
159+
160+
* Added several profiles for adding noise or other audio artifacts to your files
161+
* Added custom channel for usage with Rasa
162+
157163
### 2020-12-18
158164

159165
* Adding support for Google Text-To-Speech

connectors/rasa/Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
RASADIR=/home/ftreml/dev/rasa/examples/concertbot
2+
3+
rasa_run:
4+
cd $(RASADIR) && PYTHONPATH=$(PWD) rasa run -vv --cors "*" --credentials $(PWD)/credentials.yml

connectors/rasa/Readme.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Rasa Custom Voice Channel
2+
3+
This channel is an extension of the Socket.io-Channel and will
4+
5+
* accept input as audio and convert it to text before handing it down the Rasa pipeline
6+
* convert text content received from the Rasa pipeline as response to audio and add it to the response
7+
8+
## Installation
9+
10+
Clone or download this repository.
11+
12+
> git clone https://github.com/codeforequity-at/botium-speech-processing.git
13+
14+
Make this directory available for Python loading by pointing PYTHONPATH environment variable here.
15+
16+
> export PYHTONPATH=$PYTHONPATH:<clone-dir>/connectors/rasa
17+
18+
Use the _credentials.yml_ file when launching Rasa.
19+
20+
> rasa run --credentials <clone-dir>/connectors/rasa/credentials.yml
21+
22+
Or when using it with docker-compose, first copy the _connectors_ folder to your Rasa installation, and you can use a _docker-compose.yml_ file like this one:
23+
24+
```
25+
version: '3.0'
26+
services:
27+
rasa:
28+
image: rasa/rasa:latest-full
29+
ports:
30+
- 5005:5005
31+
volumes:
32+
- ./:/app
33+
environment:
34+
PYTHONPATH: "/app/connectors/rasa:/app"
35+
RASA_DUCKLING_HTTP_URL: http://rasa-duckling:8000
36+
command: run -vv --cors "*" --credentials /app/connectors/rasa/credentials.yml --enable-api --model models/dialogue --endpoints endpoints.yml -t B0tium1234
37+
rasa-actions:
38+
build:
39+
context: .
40+
ports:
41+
- 5055:5055
42+
rasa-duckling:
43+
image: rasa/duckling
44+
ports:
45+
- 8000:8000
46+
```
47+
48+
## Testing
49+
50+
There is a simple test client based on the [Rasa Voice Interface](https://github.com/RasaHQ/rasa-voice-interface) available.
51+
52+
In the _client_ directory, change the Rasa endpoint in the _docker-compose.yml_ file, then launch the client and access the Web interface to give a chat to your Rasa chatbot.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import logging
2+
import uuid
3+
import base64
4+
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Text
5+
6+
from rasa.core.channels.channel import InputChannel, OutputChannel, UserMessage
7+
import rasa.shared.utils.io
8+
from sanic import Blueprint, response
9+
from sanic.request import Request
10+
from sanic.response import HTTPResponse
11+
from socketio import AsyncServer
12+
13+
from urllib.request import urlopen, Request
14+
from urllib.parse import urlencode
15+
import json
16+
17+
logger = logging.getLogger(__name__)
18+
19+
print('Hello from SocketVoice')
20+
21+
class SocketVoiceBlueprint(Blueprint):
22+
def __init__(self, sio: AsyncServer, socketio_path, *args, **kwargs):
23+
self.sio = sio
24+
self.socketio_path = socketio_path
25+
super().__init__(*args, **kwargs)
26+
27+
def register(self, app, options) -> None:
28+
self.sio.attach(app, self.socketio_path)
29+
super().register(app, options)
30+
31+
32+
class SocketIOVoiceOutput(OutputChannel):
33+
@classmethod
34+
def name(cls) -> Text:
35+
return "socketiovoice"
36+
37+
def __init__(self, sio: AsyncServer, bot_message_evt: Text, botium_speech_url: Text, botium_speech_apikey: Text, botium_speech_language: Text, botium_speech_voice: Text) -> None:
38+
self.sio = sio
39+
self.bot_message_evt = bot_message_evt
40+
self.botium_speech_url = botium_speech_url
41+
self.botium_speech_apikey = botium_speech_apikey
42+
self.botium_speech_language = botium_speech_language
43+
self.botium_speech_voice = botium_speech_voice
44+
45+
async def _send_message(self, socket_id: Text, response: Any) -> None:
46+
"""Sends a message to the recipient using the bot event."""
47+
48+
if response.get("text"):
49+
q = {
50+
'text': response['text']
51+
}
52+
if self.botium_speech_voice:
53+
q['voice'] = self.botium_speech_voice
54+
55+
audioEndpoint = f"{self.botium_speech_url}/api/tts/{self.botium_speech_language}?{urlencode(q)}"
56+
audio = urlopen(audioEndpoint).read()
57+
logger.debug(f"_send_message- Calling Speech Endpoint: {audioEndpoint}")
58+
59+
audioBase64 = base64.b64encode(audio).decode('ascii')
60+
audioUri = "data:audio/wav;base64," + audioBase64
61+
response['link'] = audioUri
62+
63+
await self.sio.emit(self.bot_message_evt, response, room=socket_id)
64+
65+
async def send_text_message(
66+
self, recipient_id: Text, text: Text, **kwargs: Any
67+
) -> None:
68+
"""Send a message through this channel."""
69+
70+
for message_part in text.strip().split("\n\n"):
71+
await self._send_message(recipient_id, {"text": message_part})
72+
73+
async def send_image_url(
74+
self, recipient_id: Text, image: Text, **kwargs: Any
75+
) -> None:
76+
"""Sends an image to the output"""
77+
78+
message = {"attachment": {"type": "image", "payload": {"src": image}}}
79+
await self._send_message(recipient_id, message)
80+
81+
async def send_text_with_buttons(
82+
self,
83+
recipient_id: Text,
84+
text: Text,
85+
buttons: List[Dict[Text, Any]],
86+
**kwargs: Any,
87+
) -> None:
88+
"""Sends buttons to the output."""
89+
90+
# split text and create a message for each text fragment
91+
# the `or` makes sure there is at least one message we can attach the quick
92+
# replies to
93+
message_parts = text.strip().split("\n\n") or [text]
94+
messages = [{"text": message, "quick_replies": []} for message in message_parts]
95+
96+
# attach all buttons to the last text fragment
97+
for button in buttons:
98+
messages[-1]["quick_replies"].append(
99+
{
100+
"content_type": "text",
101+
"title": button["title"],
102+
"payload": button["payload"],
103+
}
104+
)
105+
106+
for message in messages:
107+
await self._send_message(recipient_id, message)
108+
109+
async def send_elements(
110+
self, recipient_id: Text, elements: Iterable[Dict[Text, Any]], **kwargs: Any
111+
) -> None:
112+
"""Sends elements to the output."""
113+
114+
for element in elements:
115+
message = {
116+
"attachment": {
117+
"type": "template",
118+
"payload": {"template_type": "generic", "elements": element},
119+
}
120+
}
121+
122+
await self._send_message(recipient_id, message)
123+
124+
async def send_custom_json(
125+
self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any
126+
) -> None:
127+
"""Sends custom json to the output"""
128+
129+
json_message.setdefault("room", recipient_id)
130+
131+
await self.sio.emit(self.bot_message_evt, **json_message)
132+
133+
async def send_attachment(
134+
self, recipient_id: Text, attachment: Dict[Text, Any], **kwargs: Any
135+
) -> None:
136+
"""Sends an attachment to the user."""
137+
await self._send_message(recipient_id, {"attachment": attachment})
138+
139+
140+
class SocketIOVoiceInput(InputChannel):
141+
"""A socket.io input channel."""
142+
143+
@classmethod
144+
def name(cls) -> Text:
145+
return "socketiovoice"
146+
147+
@classmethod
148+
def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel:
149+
credentials = credentials or {}
150+
return cls(
151+
credentials.get("user_message_evt", "user_uttered"),
152+
credentials.get("bot_message_evt", "bot_uttered"),
153+
credentials.get("namespace"),
154+
credentials.get("session_persistence", False),
155+
credentials.get("socketio_path", "/socket.io"),
156+
credentials.get("botium_speech_url"),
157+
credentials.get("botium_speech_apikey"),
158+
credentials.get("botium_speech_language", "en"),
159+
credentials.get("botium_speech_voice"),
160+
)
161+
162+
def __init__(
163+
self,
164+
user_message_evt: Text = "user_uttered",
165+
bot_message_evt: Text = "bot_uttered",
166+
namespace: Optional[Text] = None,
167+
session_persistence: bool = False,
168+
socketio_path: Optional[Text] = "/socket.io",
169+
botium_speech_url: Text = None,
170+
botium_speech_apikey: Optional[Text] = None,
171+
botium_speech_language: Text = "en",
172+
botium_speech_voice: Optional[Text] = False,
173+
):
174+
self.bot_message_evt = bot_message_evt
175+
self.session_persistence = session_persistence
176+
self.user_message_evt = user_message_evt
177+
self.namespace = namespace
178+
self.socketio_path = socketio_path
179+
self.botium_speech_url = botium_speech_url
180+
self.botium_speech_apikey = botium_speech_apikey
181+
self.botium_speech_language = botium_speech_language
182+
self.botium_speech_voice = botium_speech_voice
183+
self.sio = None
184+
185+
def get_output_channel(self) -> Optional["OutputChannel"]:
186+
if self.sio is None:
187+
rasa.shared.utils.io.raise_warning(
188+
"SocketIO output channel cannot be recreated. "
189+
"This is expected behavior when using multiple Sanic "
190+
"workers or multiple Rasa Open Source instances. "
191+
"Please use a different channel for external events in these "
192+
"scenarios."
193+
)
194+
return
195+
return SocketIOVoiceOutput(self.sio, self.bot_message_evt, self.botium_speech_url, self.botium_speech_apikey, self.botium_speech_language, self.botium_speech_voice)
196+
197+
def blueprint(
198+
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
199+
) -> Blueprint:
200+
# Workaround so that socketio works with requests from other origins.
201+
# https://github.com/miguelgrinberg/python-socketio/issues/205#issuecomment-493769183
202+
sio = AsyncServer(async_mode="sanic", cors_allowed_origins=[])
203+
socketio_webhook = SocketVoiceBlueprint(
204+
sio, self.socketio_path, "socketio_webhook", __name__
205+
)
206+
207+
# make sio object static to use in get_output_channel
208+
self.sio = sio
209+
210+
@socketio_webhook.route("/", methods=["GET"])
211+
async def health(_: Request) -> HTTPResponse:
212+
return response.json({"status": "ok"})
213+
214+
@sio.on("connect", namespace=self.namespace)
215+
async def connect(sid: Text, _) -> None:
216+
logger.debug(f"User {sid} connected to socketIO endpoint.")
217+
218+
@sio.on("disconnect", namespace=self.namespace)
219+
async def disconnect(sid: Text) -> None:
220+
logger.debug(f"User {sid} disconnected from socketIO endpoint.")
221+
222+
@sio.on("session_request", namespace=self.namespace)
223+
async def session_request(sid: Text, data: Optional[Dict]):
224+
if data is None:
225+
data = {}
226+
if "session_id" not in data or data["session_id"] is None:
227+
data["session_id"] = uuid.uuid4().hex
228+
if self.session_persistence:
229+
sio.enter_room(sid, data["session_id"])
230+
await sio.emit("session_confirm", data["session_id"], room=sid)
231+
logger.debug(f"User {sid} connected to socketIO endpoint.")
232+
233+
@sio.on(self.user_message_evt, namespace=self.namespace)
234+
async def handle_message(sid: Text, data: Dict) -> Any:
235+
output_channel = SocketIOVoiceOutput(sio, self.bot_message_evt, self.botium_speech_url, self.botium_speech_apikey, self.botium_speech_language, self.botium_speech_voice)
236+
237+
if self.session_persistence:
238+
if not data.get("session_id"):
239+
rasa.shared.utils.io.raise_warning(
240+
"A message without a valid session_id "
241+
"was received. This message will be "
242+
"ignored. Make sure to set a proper "
243+
"session id using the "
244+
"`session_request` socketIO event."
245+
)
246+
return
247+
sender_id = data["session_id"]
248+
else:
249+
sender_id = sid
250+
251+
if data['message'].startswith('data:'):
252+
header, encoded = data['message'].split(",", 1)
253+
254+
audioData = base64.b64decode(encoded.encode('ascii'))
255+
256+
convertEndpoint = f"{self.botium_speech_url}/api/convert/WAVTOMONOWAV"
257+
logger.debug(f"handle_message - Calling Convert Endpoint: {convertEndpoint}")
258+
res = urlopen(Request(url=convertEndpoint, data=audioData, method='POST', headers= { 'content-type': 'audio/wav' }))
259+
audioDataWav = res.read()
260+
261+
#with open('decoded_image.wav', 'wb') as file_to_save:
262+
# file_to_save.write(audioData)
263+
264+
audioEndpoint = f"{self.botium_speech_url}/api/stt/{self.botium_speech_language}"
265+
logger.debug(f"handle_message - Calling Speech Endpoint: {audioEndpoint}")
266+
res = urlopen(Request(url=audioEndpoint, data=audioDataWav, method='POST', headers= { 'content-type': 'audio/wav' }))
267+
resJson = json.loads(res.read().decode('utf-8'))
268+
logger.debug(f"handle_message - Calling Speech Endpoint: {audioEndpoint} => {resJson}")
269+
message = resJson["text"]
270+
271+
await sio.emit(self.user_message_evt, {"text": message}, room=sid)
272+
else:
273+
message = data['message']
274+
275+
message = UserMessage(
276+
message, output_channel, sender_id, input_channel=self.name()
277+
)
278+
await on_new_message(message)
279+
280+
return socketio_webhook

connectors/rasa/botium/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .SocketIOVoiceInput import SocketIOVoiceInput

connectors/rasa/client/Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM node:lts-alpine
2+
ARG RASA_ENDPOINT=http://localhost:5005
3+
ARG RASA_PATH=/socket.io
4+
ARG PUBLIC_PATH=/
5+
6+
RUN apk add --no-cache --virtual .build-deps curl sed python make g++
7+
8+
WORKDIR /app/
9+
RUN curl -L -o rvi.zip "https://github.com/RasaHQ/rasa-voice-interface/archive/master.zip" && unzip rvi.zip && rm rvi.zip
10+
WORKDIR /app/rasa-voice-interface-master
11+
RUN chown -R node /app/rasa-voice-interface-master \
12+
&& sed -i "s|'http://localhost:5005'|'${RASA_ENDPOINT}', options: { path: '${RASA_PATH}' }|g" src/main.js \
13+
&& sed -i "s|integrity: false|integrity: false, publicPath: '${PUBLIC_PATH}'|g" vue.config.js \
14+
&& npm install --no-optional && npm install serve && npm run-script build
15+
RUN apk del .build-deps
16+
17+
EXPOSE 8080
18+
USER node
19+
CMD PORT=8080 npx serve -s dist

0 commit comments

Comments
 (0)