Skip to content

Commit

Permalink
Transcription (#32)
Browse files Browse the repository at this point in the history
* refactor: some improves

* build; add nodemon and remove y18n

* chre: remove locales

* feat: adding whisper transcription

* refactor: moving code into controller

* refactor: moving code

* refactor: extracting types

* feature: add middleware to let pass only authorized channels

* refactor: some improves

* refactor: min improves

* chore: dummy change

* a try

* revert

* trying something

* trying++

* trying+2

* ??

* trying bla

* bla2
  • Loading branch information
laendoor authored Jan 11, 2024
1 parent 15799c0 commit 53f79ff
Show file tree
Hide file tree
Showing 27 changed files with 318 additions and 141 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ LOG_CONSOLE_LEVEL=debug
EXPRESS_PORT=3000

# espiDev
OWNER_CHANNEL=123456
MAIN_CHANNEL=454545
AI_CHANNELS='123456,454545'
BOT_TOKEN=12345:ABCDEF
BOT_DOMAIN=https://espi.localhost
OPENAI_API_KEY=qw-1234567
12 changes: 8 additions & 4 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
],
"env": {
"node": true,
"es2021": true
"es2016": true,
"jest": true
},
"parserOptions": {
"project": "./tsconfig.eslint.json",
Expand All @@ -23,8 +24,11 @@
},
"import/resolver": {
"node": {
"extensions": [".js", ".d.ts", ".ts"],
"moduleDirectory": ["src", "node_modules"]
"extensions": [".d.ts", ".ts"]
},
"typescript": {
"alwaysTryTypes": true,
"project": "./tsconfig.eslint.json"
}
}
},
Expand Down Expand Up @@ -58,6 +62,6 @@
}
}
],
"import/no-unresolved": ["error", { "commonjs": true }]
"import/no-unresolved": "error"
}
}
2 changes: 1 addition & 1 deletion .scripts/start-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ echo "API_DOMAIN: $API_DOMAIN"
echo "BOT_DOMAIN: $BOT_DOMAIN"

# Start the bot
ts-node src/index.ts
nodemon --watch 'src/**/*.ts' --exec 'ts-node' ./src/index.ts
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ WORKDIR /app

COPY package.json yarn.lock /app/
COPY tsconfig.json /app/
COPY locales/ /app/locales/
COPY src/ /app/src/

RUN yarn install --frozen-lockfile
Expand Down
3 changes: 0 additions & 3 deletions locales/en.json

This file was deleted.

3 changes: 0 additions & 3 deletions locales/es.json

This file was deleted.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@
"morgan": "^1.10.0",
"openai": "4.24.1",
"telegraf": "^4.15.3",
"winston": "^3.11.0",
"y18n": "^5.0.8"
"winston": "^3.11.0"
},
"devDependencies": {
"@babel/core": "^7.23.7",
Expand All @@ -61,6 +60,7 @@
"husky": "^8.0.3",
"jest": "^29.7.0",
"lint-staged": "15.2.0",
"nodemon": "3.0.2",
"prettier": "3.1.1",
"release-it": "^17.0.1",
"ts-jest": "^29.1.1",
Expand Down
47 changes: 26 additions & 21 deletions src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { Telegraf } from 'telegraf';
import { message } from 'telegraf/filters';

import AIController from './controllers/AIController';
import GPTController from './controllers/GPT/GPTController';
import StickersController from './controllers/StickersController';
import Holidays from './features/holidays';
import Holidays from './core/holidays';
import logger from './lib/logger';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const y18n = require('y18n');

// eslint-disable-next-line @typescript-eslint/naming-convention
const { __ } = y18n({ locale: 'es' });
import GPTMiddleware from './middlewares/GPTMiddleware';

if (!process.env.BOT_TOKEN) {
logger.error('You have to define BOT_TOKEN env var');
Expand All @@ -19,28 +15,37 @@ if (!process.env.BOT_TOKEN) {

const bot = new Telegraf(process.env.BOT_TOKEN);

// Handler for /start command.
// Handler for /start command
bot.start(ctx => ctx.reply('Welcome!'));

// Handler for /help command.
// Handler for /help command
bot.help(ctx => ctx.reply('Send me a sticker'));

// Registers middleware for provided update type.
// bot.on('sticker', ctx => ctx.reply('👍'));

// Registers middleware for handling text messages.
bot.hears('ping', ctx => ctx.reply('ACK'));
bot.hears('hi', ctx => ctx.reply(__`hi`));

// Version
// Basic and Info
bot.hears(/^ping$/i, ctx => ctx.reply('ACK'));
bot.hears(/(hi|hola)/, ctx => ctx.reply('👋'));
bot.hears(/^espi +version/i, ctx => ctx.reply(`Soy ${ctx.botInfo?.username}@${process.env.npm_package_version}`));

// Espi commands
bot.hears(/^espi +id/i, ctx => {
const name = ctx.chat.type === 'private' ? `${ctx.chat.first_name} (@${ctx.chat.username})` : ctx.chat.title;
ctx.reply(`
- Nombre: ${name}
- ID: ${ctx.chat.id} (${ctx.chat.type})
`);
});

// Espi Featuring
bot.hears(/^espi +feriados/i, Holidays.holidaysAR);
bot.hears(/^espi +(férié|ferie)/i, Holidays.holidaysCA);
bot.hears(/^espi +(finde +largo|fl)/i, Holidays.nextLongWeekendAR);
bot.hears(/^espi +(findes +largos|ffll)/i, Holidays.nextThreeLongWeekendsAR);
bot.hears(AIController.shouldRespond, AIController.handleQuestion);
bot.hears(/^espi +(?<question>.+)/i, GPTMiddleware.authorizedChannel, GPTController.handleQuestion);

// Audio
bot.on(
message('voice'),
(ctx, next) => GPTMiddleware.authorizedChannel(ctx, next),
ctx => GPTController.transcriptAudio(ctx)
);

// Reply With Stickers
bot.hears(/\bfacuuu\b/i, StickersController.replyWithMaybeFacu);
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const apisCA = {

export const mainChannel = toNumber(process.env.MAIN_CHANNEL || '-1');

export const ownerChannel = toNumber(process.env.OWNER_CHANNEL || '-1');

export const aiChannels = (process.env.AI_CHANNELS ?? `${mainChannel}`).split(',').map(toNumber) || [];

export const espiId = toNumber(process.env.BOT_TOKEN?.split(':')[0] || '-1');
Expand Down
51 changes: 0 additions & 51 deletions src/controllers/AIController.ts

This file was deleted.

7 changes: 7 additions & 0 deletions src/controllers/BaseController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BaseContext } from './types';

export default class BaseController {
static showTypingAction(ctx: BaseContext) {
ctx.telegram.sendChatAction(ctx.chat.id, 'typing');
}
}
53 changes: 53 additions & 0 deletions src/controllers/GPT/GPTController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ChatCompletionMessageParam } from 'openai/resources';
import { Message } from 'telegraf/typings/core/types/typegram';

import { TextMatchedContext, TranscriptContext } from './types';
import { espiId } from '../../config';
import GPT from '../../core/GPT';
import api from '../../lib/api';
import BaseController from '../BaseController';

export default class GPTController extends BaseController {
private static maxAudioSize = 10 * 1024 * 1024; // 10MB

/**
* Handle incoming question asking to ChatGPT API
*/
static async handleQuestion(ctx: TextMatchedContext) {
this.showTypingAction(ctx);
const question = (ctx.match?.groups?.question || '').trim();
if (question.length < 7) {
ctx.reply('Muy cortito amigo');
return;
}

const reply = ctx.message.reply_to_message as Message.TextMessage;
const contextText = reply?.text || '';
const contextRole = reply?.from?.id === espiId ? 'system' : 'user';
const context: ChatCompletionMessageParam | undefined = contextText
? { role: contextRole, content: contextText }
: undefined;
const answer = await GPT.ask(question, context);
ctx.reply(answer);
}

/**
* Transcript audio using ChatGPT API
*/
static async transcriptAudio(ctx: TranscriptContext) {
this.showTypingAction(ctx);
const voice = ctx.message?.voice;
const link = await ctx.telegram.getFileLink(voice.file_id);
const { data: buffer } = await api.get<Uint8Array>(link.href, { responseType: 'arraybuffer' });

// If audio is too big, don't transcribe it and return
if (buffer.length > this.maxAudioSize) {
ctx.reply('El audio es muy grande, no puedo transcribirlo');
return;
}

// Transcript audio and reply with it
const transcription = await GPT.transcript(buffer, ctx.message?.message_id);
ctx.reply(transcription);
}
}
9 changes: 9 additions & 0 deletions src/controllers/GPT/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Context, NarrowedContext } from 'telegraf';
import { Message, Update } from 'telegraf/typings/core/types/typegram';

export type TextMatchedContext = NarrowedContext<
Context & { match: RegExpExecArray },
Update.MessageUpdate<Message.TextMessage>
>;

export type TranscriptContext = NarrowedContext<Context, Update.MessageUpdate<Message.VoiceMessage>>;
25 changes: 12 additions & 13 deletions src/controllers/StickersController.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
import { Context } from 'telegraf';
import { Update } from 'telegraf/typings/core/types/typegram';

import stickers from '../features/stickers';
import BaseController from './BaseController';
import stickers from '../core/stickers';

const StickersController = {
replyWithSticker: async (ctx: Context<Update>, stickerPromise: Promise<string>) => {
export default class StickersController extends BaseController {
static async replyWithSticker(ctx: Context<Update>, stickerPromise: Promise<string>) {
const sticker = await stickerPromise;
if (!sticker) {
await ctx.reply('Acá debería venir un sticker pero no lo encontré :(');
}

await ctx.replyWithSticker(sticker);
},
}

replyWithMaybeFacu: async (ctx: Context<Update>) => {
static async replyWithMaybeFacu(ctx: Context<Update>) {
await StickersController.replyWithSticker(ctx, stickers.maybeFacu());
},
}

replyWithPaintedDog: async (ctx: Context<Update>) => {
static async replyWithPaintedDog(ctx: Context<Update>) {
await StickersController.replyWithSticker(ctx, stickers.paintedDog());
},
}

replyWithPatternMatchingDan: async (ctx: Context<Update>) => {
static async replyWithPatternMatchingDan(ctx: Context<Update>) {
await StickersController.replyWithSticker(ctx, stickers.patternMatchingDan());
},
};

export default StickersController;
}
}
4 changes: 4 additions & 0 deletions src/controllers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Context, NarrowedContext } from 'telegraf';
import { Update } from 'telegraf/typings/core/types/typegram';

export type BaseContext = NarrowedContext<Context, Update.MessageUpdate>;
65 changes: 65 additions & 0 deletions src/core/GPT.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import fs from 'fs';

import OpenAI from 'openai';
import { ChatCompletionMessageParam } from 'openai/resources';

import { openAI } from '../config';
import logger from '../lib/logger';

/**
* Wrapper for OpenAI API
* @see https://platform.openai.com/docs/api-reference
*/
const openai = new OpenAI({ apiKey: openAI.apiKey });

/**
* Wrapper class for ChatGPT API
*/
export default class GPT {
/**
* Ask a question to ChatGPT API
* @param question Question to ask
* @param context Context to provide to the API
* @returns Answer from the API
*/
static async ask(question: string, context?: ChatCompletionMessageParam) {
const messages: ChatCompletionMessageParam[] = [
{ role: 'system', content: 'Sus un bot de Telegram llamado Espi. Estás en el grupo "12 Cactus".' },
];
if (context) messages.push(context);
messages.push({ role: 'user', content: question });

const response = await openai.chat.completions.create({
...openAI.defaultOptions,
messages,
});

const answer = response.choices[0].message?.content || '';
return answer || 'Ups... no se que decirte';
}

/**
* Transcribe an audio file
* @param buffer Audio file buffer
* @param messageId Message ID to use as filename
* @returns Transcription
*/
static async transcript(buffer: Uint8Array, messageId = 42) {
const filename = `./voice-${messageId}.mp3`;
try {
await fs.promises.writeFile(filename, buffer);
const file = fs.createReadStream(filename);
const transcript = await openai.audio.transcriptions.create({
file,
model: 'whisper-1',
language: 'es',
});
return transcript.text;
} catch (error: any) {
logger.error(error, { filename });
return 'Hubo un problema desconocido al transcribir el audio...';
} finally {
await fs.promises.rm(filename, { force: true });
}
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit 53f79ff

Please sign in to comment.