Browse Source

init project

Andrew Druzhinin 2 years ago
commit
498baaacd3

+ 5 - 0
.env.sample

@@ -0,0 +1,5 @@
+TELEGRAM_BOT_TOKEN=your_telegram_bot_token
+YANDEX_SPEECHKIT_API_KEY=your_yandex_speechkit_api_key
+AWS_ACCESS_KEY_ID=your_aws_access_key_id
+AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
+AWS_BUCKET=your_aws_bucket

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.env

+ 15 - 0
.rubocop.yml

@@ -0,0 +1,15 @@
+AllCops:
+  NewCops: enable
+  SuggestExtensions: false
+
+Layout/SpaceInLambdaLiteral:
+  EnforcedStyle: require_space
+
+Layout/FirstHashElementIndentation:
+  EnforcedStyle: consistent
+
+Style/Documentation:
+  Enabled: false
+
+Style/StringLiterals:
+  EnforcedStyle: double_quotes

+ 15 - 0
Dockerfile

@@ -0,0 +1,15 @@
+FROM ruby:2.7
+
+RUN apt-get update && apt-get install --yes \
+  libssl-dev
+
+WORKDIR /bot
+
+# Install gems
+ADD Gemfile Gemfile.lock ./
+RUN bundle install
+
+# Copy our source code
+COPY . .
+
+CMD ["bin/bot"]

+ 9 - 0
Gemfile

@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
+
+gem "aws-sdk-s3"
+gem "faraday"
+gem "telegram-bot-ruby", "~> 1.0"

+ 66 - 0
Gemfile.lock

@@ -0,0 +1,66 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    aws-eventstream (1.2.0)
+    aws-partitions (1.716.0)
+    aws-sdk-core (3.170.0)
+      aws-eventstream (~> 1, >= 1.0.2)
+      aws-partitions (~> 1, >= 1.651.0)
+      aws-sigv4 (~> 1.5)
+      jmespath (~> 1, >= 1.6.1)
+    aws-sdk-kms (1.62.0)
+      aws-sdk-core (~> 3, >= 3.165.0)
+      aws-sigv4 (~> 1.1)
+    aws-sdk-s3 (1.119.1)
+      aws-sdk-core (~> 3, >= 3.165.0)
+      aws-sdk-kms (~> 1)
+      aws-sigv4 (~> 1.4)
+    aws-sigv4 (1.5.2)
+      aws-eventstream (~> 1, >= 1.0.2)
+    concurrent-ruby (1.2.2)
+    dry-core (1.0.0)
+      concurrent-ruby (~> 1.0)
+      zeitwerk (~> 2.6)
+    dry-inflector (1.0.0)
+    dry-logic (1.5.0)
+      concurrent-ruby (~> 1.0)
+      dry-core (~> 1.0, < 2)
+      zeitwerk (~> 2.6)
+    dry-struct (1.6.0)
+      dry-core (~> 1.0, < 2)
+      dry-types (>= 1.7, < 2)
+      ice_nine (~> 0.11)
+      zeitwerk (~> 2.6)
+    dry-types (1.7.1)
+      concurrent-ruby (~> 1.0)
+      dry-core (~> 1.0)
+      dry-inflector (~> 1.0)
+      dry-logic (~> 1.4)
+      zeitwerk (~> 2.6)
+    faraday (2.7.4)
+      faraday-net_http (>= 2.0, < 3.1)
+      ruby2_keywords (>= 0.0.4)
+    faraday-multipart (1.0.4)
+      multipart-post (~> 2)
+    faraday-net_http (3.0.2)
+    ice_nine (0.11.2)
+    jmespath (1.6.2)
+    multipart-post (2.3.0)
+    ruby2_keywords (0.0.5)
+    telegram-bot-ruby (1.0.0)
+      dry-struct (~> 1.6)
+      faraday (~> 2.0)
+      faraday-multipart (~> 1.0)
+      zeitwerk (~> 2.6)
+    zeitwerk (2.6.7)
+
+PLATFORMS
+  arm64-darwin-21
+
+DEPENDENCIES
+  aws-sdk-s3
+  faraday
+  telegram-bot-ruby (~> 1.0)
+
+BUNDLED WITH
+   2.2.27

+ 33 - 0
Makefile

@@ -0,0 +1,33 @@
+# Set the name of the Docker image
+IMAGE_NAME=citripio-bot
+
+# Set the version of the bot
+VERSION=0.1.0
+
+# Build the Docker image
+build:
+	docker build -t $(IMAGE_NAME):$(VERSION) .
+
+# Start the bot container
+start:
+	docker run -d --env-file .env --name $(IMAGE_NAME) $(IMAGE_NAME):$(VERSION)
+
+# Start the bot container in development mode :)
+devmode:
+	docker run -it --env-file .env --name $(IMAGE_NAME) $(IMAGE_NAME):$(VERSION)
+
+# Stop the bot container
+stop:
+	docker stop $(IMAGE_NAME)
+
+# Remove the bot container
+remove:
+	docker rm $(IMAGE_NAME)
+
+# View the logs of the bot container
+logs:
+	docker logs $(IMAGE_NAME)
+
+# View the running Docker containers
+ps:
+	docker ps

+ 34 - 0
README.md

@@ -0,0 +1,34 @@
+# Telegram bot + Yandex Speechkit
+The bot listens for messages and responds to voice messages by transcribing the message using Yandex SpeechKit.
+
+## How to
+
+Create a file named .env in the project directory and set the following environment variables:
+
+```
+  TELEGRAM_BOT_TOKEN=<your telegram bot token>
+  YANDEX_SPEECHKIT_API_KEY=<your Yandex SpeechKit API key>
+  AWS_ACCESS_KEY_ID=<your AWS access key ID>
+  AWS_SECRET_ACCESS_KEY=<your AWS secret access key>
+  AWS_BUCKET=<your AWS S3 bucket name>
+```
+- You need create a Yandex Cloud account at https://cloud.yandex.com.
+- Go to the Telegram website or use the Telegram mobile app and create a new bot by messaging the "BotFather" account and follow the instructions to obtain the bot token.
+
+To build the Docker image, run the following command:
+```bash
+  make build
+```
+
+To start the bot container, run the following command:
+
+```bash
+  make start
+```
+
+To view the logs of the bot container, run the following command:
+```bash
+  make logs
+```
+
+

+ 30 - 0
bin/bot

@@ -0,0 +1,30 @@
+#!/usr/bin/env ruby
+
+# frozen_string_literal: true
+
+require "telegram/bot"
+
+require "./lib/yandex_speechkit_api"
+require "./lib/utils/telegram_file_downloader"
+Dir.glob("./lib/utils/*.rb").sort.each { |file| require file }
+
+Telegram::Bot::Client.run(ENV["TELEGRAM_BOT_TOKEN"]) do |bot|
+  bot.listen do |message|
+    if message.voice
+      audio_file = TelegramFileDownloader.download_file(bot, message.voice)
+      text = if message.voice.duration > 30
+               audio_url = AwsUtils.upload_file(audio_file)
+               YandexSpeechkitAPI.async_recognize(audio_url)
+             else
+               YandexSpeechkitAPI.sync_recognize(audio_file)
+             end
+      if text.empty?
+        bot.api.send_message(chat_id: message.chat.id, text: "I didn't hear what you said, please repeat again.")
+      else
+        bot.api.send_message(chat_id: message.chat.id, text: text)
+      end
+    else
+      bot.api.send_message(chat_id: message.chat.id, text: "I only understand voice messages")
+    end
+  end
+end

+ 16 - 0
lib/utils/aws_utils.rb

@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "aws-sdk-s3"
+
+module AwsUtils
+  def self.upload_file(file)
+    client = Aws::S3::Client.new(
+      region: "ru-central1",
+      credentials: Aws::Credentials.new(ENV["AWS_ACCESS_KEY_ID"], ENV["AWS_SECRET_ACCESS_KEY"]),
+      endpoint: "https://storage.yandexcloud.net"
+    )
+    filename = SecureRandom.uuid
+    client.put_object({ bucket: ENV["AWS_BUCKET"], key: filename, body: file })
+    "https://storage.yandexcloud.net/#{ENV['AWS_BUCKET']}/#{filename}"
+  end
+end

+ 12 - 0
lib/utils/telegram_file_downloader.rb

@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require "faraday"
+
+module TelegramFileDownloader
+  def self.download_file(bot, message)
+    file_path = bot.api.get_file(file_id: message.file_id)["result"]["file_path"]
+    url = "https://api.telegram.org/file/bot#{ENV['TELEGRAM_BOT_TOKEN']}/#{file_path}"
+    response = Faraday.get(url)
+    response.body
+  end
+end

+ 15 - 0
lib/yandex_speechkit_api.rb

@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+Dir.glob("./lib/yandex_speechkit_api/sync/*.rb").sort.each { |file| require file }
+Dir.glob("./lib/yandex_speechkit_api/async/*.rb").sort.each { |file| require file }
+
+module YandexSpeechkitAPI
+  def self.sync_recognize(audio_data)
+    Sync::Recognize.new.call(audio_data)
+  end
+
+  def self.async_recognize(audio_data)
+    operaion_id = Async::Operations.new.create(audio_data)
+    Async::Operations.new.show(operaion_id)
+  end
+end

+ 16 - 0
lib/yandex_speechkit_api/async/error_middleware.rb

@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class RetryMiddleware < Faraday::Middleware
+  def call(env)
+    response = @app.call(env)
+    response.on_complete do |resp|
+      done = JSON.parse(resp.body)["done"]
+      return response if done
+
+      unless done
+        sleep(2)
+        response = call(env)
+      end
+    end
+  end
+end

+ 52 - 0
lib/yandex_speechkit_api/async/operations.rb

@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "./lib/utils/aws_utils"
+require "./lib/yandex_speechkit_api/async/error_middleware"
+
+module YandexSpeechkitAPI
+  module Async
+    class Operations
+      CREATE_URL = "https://transcribe.api.cloud.yandex.net/speech/stt/v2/longRunningRecognize"
+      SHOW_URL = "https://operation.api.cloud.yandex.net/"
+
+      def create(audio_url)
+        response = connection_for_create.post do |req|
+          req.body = {
+            "config" => { "specification" => { "languageCode" => "ru-RU" } },
+            "audio" => { "uri" => audio_url }
+          }.to_json
+        end
+        JSON.parse(response.body)["id"]
+      end
+
+      def show(operaion_id)
+        response = connection_for_show.get do |req|
+          req.url "/operations/#{operaion_id}"
+        end
+        JSON.parse(response.body)["response"]["chunks"]&.map do |part|
+          part.dig("alternatives", 0, "words")&.map do |hash|
+            hash["word"]
+          end
+        end&.join(" ")
+      end
+
+      private
+
+      def connection_for_create
+        Faraday.new(CREATE_URL) do |conn|
+          conn.headers["Authorization"] = "Api-Key #{ENV['YANDEX_SPEECHKIT_API_KEY']}"
+          conn.headers["Content-Type"] = "application/json"
+          conn.adapter Faraday.default_adapter
+        end
+      end
+
+      def connection_for_show
+        Faraday.new(SHOW_URL) do |conn|
+          conn.headers["Authorization"] = "Api-Key #{ENV['YANDEX_SPEECHKIT_API_KEY']}"
+          conn.use RetryMiddleware
+          conn.adapter Faraday.default_adapter
+        end
+      end
+    end
+  end
+end

+ 32 - 0
lib/yandex_speechkit_api/sync/connection.rb

@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module YandexSpeechkitAPI
+  module Sync
+    class Connection
+      extend Forwardable
+
+      def_delegators :@connection, :post, :get
+
+      attr_reader :connection
+
+      BASE_URL = "https://stt.api.cloud.yandex.net/speech/v1/stt:recognize"
+
+      def initialize
+        @connection = build_connection
+      end
+
+      private
+
+      def build_connection
+        Faraday.new(BASE_URL) do |connection|
+          connection.headers["Authorization"] = "Api-Key #{ENV['YANDEX_SPEECHKIT_API_KEY']}"
+          connection.headers["Content-Type"] = "audio/x-wav"
+          connection.headers["LanguageCode"] = "ru-RU"
+          connection.request :multipart
+          connection.request :url_encoded
+          connection.adapter :net_http
+        end
+      end
+    end
+  end
+end

+ 22 - 0
lib/yandex_speechkit_api/sync/recognize.rb

@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module YandexSpeechkitAPI
+  module Sync
+    class Recognize
+      def call(audio_data)
+        response = connection.post do |req|
+          req.body = audio_data
+        end
+        raise "Error recognizing audio: #{response.status} - #{response.body}" unless response.success?
+
+        JSON.parse(response.body)["result"]
+      end
+
+      private
+
+      def connection
+        @connection ||= YandexSpeechkitAPI::Sync::Connection.new
+      end
+    end
+  end
+end