使用Chatgpt API和Whisper构建电报语音聊天机器人
#javascript #chatgpt #whisper

在本文中,我将为您提供有关如何在电报中创建自己的语音聊天机器人的分步指南。这样,您将能够以自然而直观的方式与聊天机器人进行对话。

您可以与电报机器人聊天或发送语音文件,它将与语音文件一起发送回复作为答复。对话可以继续进行,直到您选择重置为止。

Image description

您可以在此处检查源代码。

https://github.com/ngviethoang/telegram-voice-chatbot

本指南要求您使用公共IP在自己的服务器上部署

另外,这是我为Messenger和Telegram构建的个人机器人,您可以在此处检查。

https://github.com/ngviethoang/ai-chatbot

它还具有集成的DALL-E 2,并重复进行其他模型。如果您对此感到好奇,我将写另一个博客来谈论它。

设置项目

我们将使用Bottender-更快地编写电报机器人的框架。它还支持我们存储过去的对话消息和其他数据的会话,因此使用对话记忆构建聊天机器人更加方便。

要创建一个项目,请运行此命令:

npx create-bottender-app telegram-bot

在此步骤中,选择电报平台。

Image description
您可以在此处查看他们的文档以获取更多详细信息。 https://bottender.js.org/docs/

我们还需要添加以服务静态文件的Express Server。并添加打字稿,以便以后更容易开发和维护。

npm install body-parser express
npm install typescript ts-node nodemon --save-dev
// or yarn
yarn add body-parser express
yarn add typescript ts-node nodemon --dev

package.json文件中更新脚本<​​br>

{
    "scripts": {
        "build": "tsc",
    "dev": "nodemon --exec ts-node src/server.ts",
    "lint": "eslint . --ext=js,ts",
    "start": "tsc && node dist/server.js",
    "test": "jest"
  },
}

添加tsconfig.json文件

{
  "include": ["src/**/*"],
  "exclude": ["**/__tests__", "**/*.{spec,test}.ts"],
  "compilerOptions": {
    "target": "es2016",
    "lib": ["es2017", "es2018", "es2019", "es2020", "esnext.asynciterable"],
    "module": "commonjs",
    "skipLibCheck": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["node", "jest"]
  }
}

安装NPM软件包

在此项目中,我们将使用OpenAI API(例如Model gpt-3.5-turbo)进行聊天完成,然后耳语从音频转录文本。

随着文本的生成语音,我将使用Azure服务,因此我们将安装软件包microsoft-cognitiveservices-speech-sdk。你们可以使用其他任何服务,例如Google,Amazon,�!

还安装其他软件包以用于帮助辅助功能:axios util uuid

npm install openai gpt-3-encoder microsoft-cognitiveservices-speech-sdk axios util uuid
npm install @types/uuid --save-dev
// Or use yarn
yarn add openai gpt-3-encoder microsoft-cognitiveservices-speech-sdk axios util uuid
yarn add @types/uuid --dev

电报设置

编辑文件bottender.config.js带有电信频道启用

module.exports = {
  channels: {
    telegram: {
      enabled: true,
      path: '/webhooks/telegram',
      accessToken: process.env.TELEGRAM_ACCESS_TOKEN,
    },
  },
};

确保将channels.telegram.enabled字段设置为true

创建一个机器人并生成访问令牌

您可以通过将/newbot的命令发送到电报上的Abiaoqian 10来获取电报机器人帐户和机器人令牌。

获得电报bot token ,将值粘贴到.envTELEGRAM_ACCESS_TOKEN字段中:

TELEGRAM_ACCESS_TOKEN=<Your Telegram Bot Token>

设置bot命令

在botfather中运行/setcommands为我们的bot创建命令。

new - Clear old conversation and create a new one
voice - Set up voice for bot to speak
language - Set up whisper language

设置Express服务器

使用此代码更改root目录中的文件index.js中的代码。

/* eslint-disable import/no-unresolved */
module.exports = require('./dist').default;

src目录中创建一个server.ts文件,然后将此代码复制到其中。

import bodyParser from 'body-parser';
import express from 'express';
import { bottender } from 'bottender';

const app = bottender({
  dev: process.env.NODE_ENV !== 'production',
});

const port = Number(process.env.PORT) || 5000;

const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = express();

  server.use(
    bodyParser.json({
      verify: (req, _, buf) => {
        (req as any).rawBody = buf.toString();
      },
    })
  );

    server.use('/static', express.static('static'));

  server.get('/api', (req, res) => {
    res.json({ ok: true });
  });

  server.all('*', (req, res) => {
    return handle(req, res);
  });

  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`);
  });
});

您可以看到,我有一个static目录来存储所有静态文件。我们将使用此目录来存储我们在接下来的步骤中生成的语音文件。

让S在根目录中创建static目录,其中voices目录。

mkdir static
mkdir static/voices

处理电报活动

我们需要从电报中处理3个事件

  • 命令
  • 短信
  • 语音消息

我们将详细介绍这些事件。

删除旧文件index.jsindex.test.js。在src目录中创建一个index.ts文件。

让我们使用router将不同的事件路由到每个处理程序。

import { Action, TelegramContext } from 'bottender';
import { router, text } from 'bottender/router';

export default async function App(
  context: TelegramContext
): Promise<Action<any> | void> {
  if (context.event.voice) {
    return HandleVoice;
  }
  return router([
    text(/^[/.](?<command>\w+)(?:\s(?<content>.+))?/i, HandleCommand),
    text('*', HandleText),
  ])
};

处理短信

首先,我们将通过将用户发送到chatgpt API,将响应发送给用户然后保存这些消息来处理这些消息。

async function HandleText(context: TelegramContext) {
  await context.sendChatAction(ChatAction.Typing);
  let { text, replyToMessage } = context.event;
    // Add reply message to text content
  const { text: replyText } = replyToMessage || {}
  if (replyText) {
    text += `\n${replyText}`
  }

  await handleChat(context, text)
}

接下来,让我们写一个函数以处理chatgpt api的聊天完成。

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

export const createCompletion = async (messages: ChatCompletionRequestMessage[], max_tokens?: number, temperature?: number) => {
  const response = await openai.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages,
    max_tokens,
    temperature,
  });
  return response.data.choices;
};

export const createCompletionFromConversation = async (
  context: TelegramContext,
  messages: ChatCompletionRequestMessage[]) => {
  try {
    // limit response to avoid message length limit, you can change this if you want
    const response_max_tokens = 500
    const GPT3_MAX_TOKENS = 4096
    const max_tokens = Math.min(getTokens(messages) + response_max_tokens, GPT3_MAX_TOKENS)

    const response = await createCompletion(messages, max_tokens);
    return response[0].message?.content;
  } catch (e) {
    return null;
  }
};

我们需要将OPENAI_API_KEY变量添加到我们的.env文件中。您可以从这里获取API键。

https://platform.openai.com/account/api-keys

OPENAI_API_KEY=<Your OpenAI API key>

您还可以使用自己的提示设置系统角色消息。这样,您的机器人将具有自己的角色,可以发挥自己的目的,例如私人教练,顾问或电影,小说中的任何角色...

现在,我们将以降价格式的消息将响应发送给用户。

我们还希望在用户发送新消息时继续进行此对话。因此,我们将将这些消息保存在数据库中。 Bottender通过会话状态支持这一点。您可以在这里检查一下。

https://bottender.js.org/docs/the-basics-session

export const handleChat = async (context: TelegramContext, text: string) => {
  const response = await createCompletionFromConversation(context, [
    ...context.state.context as any,
    { role: 'user', content: text },
  ]);
  if (!response) {
    await context.sendText(
      'Sorry! Please try again`'
    );
    return;
  }
  let content = response.trim()

  await context.sendMessage(content, { parseMode: ParseMode.Markdown });
  await handleTextToSpeech(context, content, getAzureVoiceName(context))

    // save current conversation in session
  context.setState({
    ...context.state,
    context: [
      ...context.state.context as any,
      { role: 'user', content: text },
      { role: 'assistant', content },
    ],
  });
}

您还可以通过编辑会话来设置不同的会话驱动程序。驱动程序在bottender.config.js文件中。

// bottender.config.js

module.exports = {
  session: {
    driver: 'memory',
    stores: {
      memory: {
        maxSize: 500,
      },
      file: {
        dirname: '.sessions',
      },
      redis: {
        port: 6379,
        host: '127.0.0.1',
        password: 'auth',
        db: 0,
      },
      mongo: {
        url: 'mongodb://localhost:27017',
        collectionName: 'sessions',
      },
    },
  },
};

将Bot的响应发送为声音

接下来,我们想将此响应转换为电报中的语音文件,并像机器人在与他们交谈时将其发送给用户。为此,我将使用Azure语音服务。

您可以在此处遵循文档来设置Azure服务。

Text-to-speech quickstart - Speech service - Azure Cognitive Services | Microsoft Learn

创建语音资源后,让S设置.env文件中的环境变量。

AZURE_SPEECH_KEY=
AZURE_SPEECH_REGION=

它将以OGG格式将消息从机器人转换为音频文件。

import { SpeechConfig, AudioConfig, SpeechSynthesizer, ResultReason } from 'microsoft-cognitiveservices-speech-sdk'

export const textToSpeech = async (text: string, outputFile: string, voiceName?: string) => {
  return new Promise((resolve, reject) => {
    // This example requires environment variables named "SPEECH_KEY" and "SPEECH_REGION"
    const speechConfig = SpeechConfig.fromSubscription(process.env.AZURE_SPEECH_KEY || '', process.env.AZURE_SPEECH_REGION || '');
    const audioConfig = AudioConfig.fromAudioFileOutput(outputFile);

    // The language of the voice that speaks.
    speechConfig.speechSynthesisVoiceName = voiceName || "en-US-JennyNeural";

    // Create the speech synthesizer.
    const synthesizer = new SpeechSynthesizer(speechConfig, audioConfig);

    synthesizer?.speakTextAsync(text,
      function (result) {
        if (result.reason === ResultReason.SynthesizingAudioCompleted) {
          // console.log("synthesis finished.");
        } else {
          console.error("Speech synthesis canceled, " + result.errorDetails +
            "\nDid you set the speech resource key and region values?");
        }
        synthesizer?.close();
        resolve(result);
      },
      function (err) {
        console.trace("err - " + err);
        synthesizer?.close();
        reject(err);
      });
  });
}

export const handleTextToSpeech = async (context: TelegramContext, message: string, voiceName?: string) => {
  try {
    await context.sendChatAction(ChatAction.Typing);

    // set random filename
    const fileId = uuidv4().replaceAll('-', '')
    const outputDir = `static/voices`
    const outputFile = `${outputDir}/voice_${fileId}.ogg`
    const encodedOutputFile = `${outputDir}/voice_${fileId}_encoded.ogg`

    const result = await textToSpeech(
      message || '',
      outputFile,
      voiceName || getAzureVoiceName(context)
    )
    await encodeOggWithOpus(outputFile, encodedOutputFile)

    const voiceUrl = `${process.env.PROD_API_URL}/${encodedOutputFile}`

    await context.sendVoice(voiceUrl)
  } catch (err) {
    console.trace("err - " + err);
  }
}

为了将此音频文件作为电报中的语音发送,我们必须采取一个小步骤,以用opus编码此OGG文件。 here中的细节。我认为这样做的一种方法是ffmpeg软件包。

让我们首先在我们的机器上安装此软件包。您可以检查如何安装here

在Windows中,运行此命令

choco install ffmpeg

在Linux中,运行此命令

sudo apt install ffmpeg

接下来,我们将在JS代码中运行此命令,以将OGG文件转换为编码文件。

import { exec } from 'child_process';
import { promisify } from 'util';

const asyncExec = promisify(exec);

export const encodeOggWithOpus = async (inputFile: string, outputFile: string) => {
  try {
    const { stdout, stderr } = await asyncExec(`ffmpeg -loglevel error -i ${inputFile} -c:a libopus -b:a 96K ${outputFile}`);
    // console.log(stdout);

    if (stderr) {
      console.error(stderr);
    }
  } catch (err) {
    console.error(err);
  }
}

很棒,在转换此新文件后,我们将其发送给用户。

要注意的一件事是,我将此文件作为URL发送,因此我们将将这些文件存储在我们之前创建的static目录中。您将需要设置完整的URL,因此请记住要插入您使用此机器人的域。

const voiceUrl = `${process.env.PROD_API_URL}/${encodedOutputFile}`

await context.sendVoice(voiceUrl)

将环境设置为PROD_API_URL.env文件中带有您的域,例如:https://example.com

PROD_API_URL=<your api url>

处理命令

通过下面的代码处理所有命令。

async function HandleCommand(
  context: TelegramContext,
  {
    match: {
      groups: { command, content },
    },
  }: any
) {
  switch (command.toLowerCase()) {
    case 'new':
      await clearServiceData(context);
      break;
    case 'voice':
      await setAzureVoiceName(context, content)
      break;
    case 'language':
      await setWhisperLang(context, content)
      break;
    default:
      await context.sendText('Sorry! Command not found.');
      break;
  }
}

使用/new命令,我们将简单地从状态清除对话的数据。

export const clearServiceData = async (context: TelegramContext) => {
  context.setState({
    ...context.state,
    context: [],
  });
  await context.sendText('New conversation.');
};

使用/voice命令,我们将此选项保存到settings状态。

const getSettings = (context: TelegramContext): any => {
  return context.state.settings || {}
}

export const setSettings = async (context: TelegramContext, key: string, value: string) => {
  let newValue: any = value
  if (value === 'true') {
    newValue = true
  } else if (value === 'false') {
    newValue = false
  }
  context.setState({
    ...context.state,
    settings: {
      ...getSettings(context),
      [key]: newValue,
    },
  })
}

export const setAzureVoiceName = async (context: TelegramContext, voiceName: string) => {
  await setSettings(context, 'azureVoiceName', voiceName)
}

您可以在此处检查可用的声音:

Language support - Speech service - Azure Cognitive Services | Microsoft Learn

使用/language命令,我们将与/voice命令一样。我们将使用它来设置Whisper API的语言参数。

export const setWhisperLang = async (context: TelegramContext, language: string) => {
  await setSettings(context, 'whisperLang', language)
}

处理语音消息

用户将语音文件发送到我们的机器人时,我们需要将此文件转录为文本。为了做到这一点,我们将使用Whisper API转录。

通过下面的代码处理语音事件。

async function HandleVoice(context: TelegramContext) {
  await handleAudioForChat(context)
}

export const handleAudioForChat = async (context: TelegramContext) => {
  let transcription: any
  const fileUrl = await getFileUrl(context.event.voice.fileId)
  if (fileUrl) {
    transcription = await getTranscription(context, fileUrl)
  }
  if (!transcription) {
    await context.sendText(`Error getting transcription!`);
    return
  }

  await context.sendMessage(`_${transcription}_`, { parseMode: ParseMode.Markdown });

  await context.sendChatAction(ChatAction.Typing);
  await handleChat(context, transcription)
}

当我们从Webhooks接收语音事件时,我们只会收到文件ID。因此,在下一步中,我们将使用Telegram API获取此语音文件的完整路径。

import axios from "axios"

export const getFileUrl = async (file_id: string) => {
  try {
    const response = await axios({
      method: 'GET',
      url: `https://api.telegram.org/bot${process.env.TELEGRAM_ACCESS_TOKEN}/getFile`,
      params: {
        file_id
      }
    })
    if (response.status !== 200) {
      console.error(response.data);
      return null;
    }

    const filePath = response.data.result.file_path;
    return `https://api.telegram.org/file/bot${process.env.TELEGRAM_ACCESS_TOKEN}/${filePath}`
  } catch (e) {
    console.error(e);
    return null;
  }
}

收到文件路径后,我们将使用OGA格式下载到static目录。但是,为了使用Whisper API,音频文件必须采用MP3格式。因此,我们需要通过此命令将其转换为MP3,也需要使用ffmpeg

const asyncExec = promisify(exec);

export const convertOggToMp3 = async (inputFile: string, outputFile: string) => {
  try {
    const { stdout, stderr } = await asyncExec(`ffmpeg -loglevel error -i ${inputFile} -c:a libmp3lame -q:a 2 ${outputFile}`);
    // console.log(stdout);

    if (stderr) {
      console.error(stderr);
    }
  } catch (err) {
    console.error(err);
  }
}

现在,让我们发送此mp3文件窃窃私语以获取转录。

const downloadsPath = './static/voices';

export const getTranscription = async (context: TelegramContext, url: string, language?: string) => {
  try {
    let filePath = await downloadFile(url, downloadsPath);
    if (filePath.endsWith('.oga')) {
      const newFilePath = filePath.replace('.oga', '.mp3')
      await convertOggToMp3(filePath, newFilePath)
      filePath = newFilePath
    }
    const response = await openai.createTranscription(
      fs.createReadStream(filePath) as any,
      'whisper-1',
      undefined, undefined, undefined,
      language,
    );
    return response.data.text
  } catch (e) {
    return null;
  }
}

获得转录后,我们的工作类似于上一步,我们将使用以前的函数handleChat来处理用户的消息并将Chatgpt的答复发送回用户。

export const handleAudioForChat = async (context: TelegramContext) => {
  let transcription: any
  const fileUrl = await getFileUrl(context.event.voice.fileId)
  if (fileUrl) {
    transcription = await getTranscription(context, fileUrl)
  }
  if (!transcription) {
    await context.sendText(`Error getting transcription!`);
    return
  }

  await context.sendMessage(`_${transcription}_`, { parseMode: ParseMode.Markdown });

  await context.sendChatAction(ChatAction.Typing);
  await handleChat(context, transcription)
}

您可以看到我们的机器人也将其回复转换为语音并回复用户。

您还可以在此处具有设置选项,以禁用文本消息响应,而仅将语音发送给用户。

部署

通过以下命令在服务器上运行bottender:

npm start
# or use yarn
yarn start

设置生产的Webhook

运行此命令为电报机器人设置Webhook,认为您的URL为https://example.com/webhooks/telegram

npx bottender telegram webhook set -w https://example.com/webhooks/telegram

现在您准备与您的机器人交谈。

发送语音文件并等待机器人响应。

Image description

结论

我希望在本指南中,您可以构建自己的聊天机器人,并与之聊天。

希望您喜欢它!