From a034756c9b2cdf18ddb21463a6cc87a0c9c9b4f4 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 14 Oct 2023 14:40:19 +0300 Subject: [PATCH 1/3] Create docker-image.yml --- .github/workflows/docker-image.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..0c9196e --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,24 @@ +name: Docker Deploy + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Build and push Docker image + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + docker build -t serf-bot-image . + docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD + docker tag serf-bot-image boogiedk/serf-bot-image:latest + docker push boogiedk/serf-bot-image:latest From 611a02c4d7c7b2ac4f824fd0ba0604baed58c2d2 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 14 Oct 2023 15:18:09 +0300 Subject: [PATCH 2/3] Delete .github/workflows/docker-image.yml --- .github/workflows/docker-image.yml | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 0c9196e..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Docker Deploy - -on: - pull_request: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Build and push Docker image - env: - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} - run: | - docker build -t serf-bot-image . - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD - docker tag serf-bot-image boogiedk/serf-bot-image:latest - docker push boogiedk/serf-bot-image:latest From 201d1a91522a6264e083ab4daa77bc36451788cc Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 11 Dec 2023 15:43:02 +0300 Subject: [PATCH 3/3] Major commit * upgrade workflow * fix * fix * fix * fix port * refactor * refactor * add context command * refactoring commands * fix * fix * fix * redeploy * fix token * add vision * logs error * refactor * fix vision * fix fileId get * external appsettings.json * fix docker * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix * fix --- .github/workflows/docker-deploy.yml | 40 +++++++-- Dockerfile | 1 + SerfBot/Commands.fs | 27 ++++++ SerfBot/OpenAiApi.fs | 119 +++++++++++++++++++++----- SerfBot/Program.fs | 3 +- SerfBot/SerfBot.fsproj | 8 +- SerfBot/TelegramBot.fs | 125 +++++++++++++--------------- SerfBot/Types.fs | 9 +- SerfBot/appsettings.json | 6 +- 9 files changed, 235 insertions(+), 103 deletions(-) create mode 100644 SerfBot/Commands.fs diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml index f175650..5303877 100644 --- a/.github/workflows/docker-deploy.yml +++ b/.github/workflows/docker-deploy.yml @@ -1,4 +1,4 @@ -name: Docker Deploy +name: CI/CD Pipline on: pull_request: @@ -12,13 +12,39 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/serf-bot:latest + + - name: Install Expect + run: sudo apt-get install -y expect + + - name: Deploy to VPS env: - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USERNAME }} + VPS_SSH_PRIVATE_KEY: ${{ secrets.VPS_SSH_PRIVATE_KEY }} + VPS_SSH_PRIVATE_KEY_PASSPHRASE: ${{ secrets.VPS_SSH_PRIVATE_KEY_PASSPHRASE }} run: | - docker build -t serf-bot-image . - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD - docker tag serf-bot-image boogiedk/serf-bot-image:latest - docker push boogiedk/serf-bot-image:latest + echo "${{ env.VPS_SSH_PRIVATE_KEY }}" > private_key + chmod 600 private_key + eval "$(ssh-agent -s)" + echo "${{ env.VPS_SSH_PRIVATE_KEY_PASSPHRASE }}" | expect -c "spawn ssh-add private_key; expect \"Enter passphrase:\"; send -- \"${{ env.VPS_SSH_PRIVATE_KEY_PASSPHRASE }}\r\"; expect eof" + rm -f private_key + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ env.VPS_USER }}@${{ env.VPS_HOST }} <<-EOF + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker pull ${{ secrets.DOCKER_USERNAME }}/serf-bot:latest + docker stop serf-bot || true + docker rm serf-bot || true + docker run -d --name serf-bot -p 3005:3005 -v /tmp/serf-bot/appsettings.json:/app/appsettings.json ${{ secrets.DOCKER_USERNAME }}/serf-bot:latest + EOF diff --git a/Dockerfile b/Dockerfile index ca29eac..644279b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /src COPY ["SerfBot/SerfBot.fsproj", "SerfBot/"] RUN dotnet restore "SerfBot/SerfBot.fsproj" COPY . . + WORKDIR "/src/SerfBot" RUN dotnet build "SerfBot.fsproj" -c Release -o /app/build diff --git a/SerfBot/Commands.fs b/SerfBot/Commands.fs new file mode 100644 index 0000000..938ad62 --- /dev/null +++ b/SerfBot/Commands.fs @@ -0,0 +1,27 @@ +module SerfBot.Commands + +open ExtCore.Control.Collections +open SerfBot.OpenAiApi; +open SerfBot.Types + +let commandHandler command = + try + match command with + | Ping -> "pong" + | Vision (userText, base64Img) -> + descriptionAnalyzedImage userText base64Img + |> Async.RunSynchronously + | Context userText -> + setupContext userText + |> ignore + "Контекст сменен" + | Question userText -> + gptAnswer userText + |> Async.RunSynchronously + | Weather city -> + let weather = WeatherApi.getWeatherAsync city + |> Async.RunSynchronously + $"Погода в %s{city}: %s{weather}" + | _ -> "Некорректная команда" + with + | ex -> sprintf "Ошибка: %s" ex.Message \ No newline at end of file diff --git a/SerfBot/OpenAiApi.fs b/SerfBot/OpenAiApi.fs index ca70766..a0e2631 100644 --- a/SerfBot/OpenAiApi.fs +++ b/SerfBot/OpenAiApi.fs @@ -1,30 +1,105 @@ module SerfBot.OpenAiApi -open OpenAI_API -open OpenAI_API.Models -open SerfBot.Log - -let context = "Ты персональный помощник-бот в telegram. Чаще всего тебе нужно генерировать C#, F# или SQL код" - -let conversationGPT userText = - let openApiClient = OpenAIAPI(Configuration.config.OpenAiApiToken) - let conversation = openApiClient.Chat.CreateConversation() - conversation.AppendSystemMessage(context) - conversation.AppendUserInput(userText); - conversation.RequestParameters.Temperature <- 0.9; - conversation.RequestParameters.MaxTokens <- 1024; - conversation.Model <- Model.ChatGPTTurbo; - conversation; +open OpenAI +open OpenAI.Managers +open OpenAI.ObjectModels +open OpenAI.ObjectModels.RequestModels +open SerfBot.Types +open OpenAI.Chat +open System +open Log + + +let defaultContext = "Ты персональный помощник-бот в telegram. Чаще всего тебе нужно генерировать C#, F# или SQL код, но иногда нужно и отвечать на бытовые вопросы." +let mutable currentContext = Some(defaultContext) + +let setupContext (newContext: string) = + match newContext with + | null -> + match currentContext with + | None -> defaultContext + | Some x -> x + | _ -> + currentContext <- Some newContext + newContext + +let conversationGPT = + let token = Configuration.config.OpenAiApiToken + let options = OpenAiOptions() + options.ApiKey <- token + + let openApiClient = new OpenAIService(options) + + openApiClient + +let gptQuestionRequest userText = + let messages = + [ + ChatMessage.FromSystem(Option.get currentContext) + ChatMessage.FromUser(userText) + ] |> List.toArray + + ChatCompletionCreateRequest(Messages = messages, Model = Models.Gpt_4) let gptAnswer userQuestion = async { try - let conv = conversationGPT userQuestion - let! result = conv.GetResponseFromChatbotAsync() |> Async.AwaitTask + let conv = conversationGPT + let request = gptQuestionRequest userQuestion + + let! completionResult = conv.CreateCompletion(request) |> Async.AwaitTask + + let result = + if completionResult.Successful then + completionResult.Choices + |> Seq.head + |> fun c -> c.Message.Content + else + match completionResult.Error with + | null -> + let errorMessage = "Unknown Error" + errorMessage |> logInfo + errorMessage + | error -> + let errorMessage = $"{error.Code} {error.Message}" + errorMessage |> logInfo + errorMessage + return result + + with + | ex -> + ex.Message |> logInfo + return ex.Message + } + +let descriptionAnalyzedImage userText base64Img = + async { + try + let api = OpenAIClient(Configuration.config.OpenAiApiToken) + let userText2 = if userText == null then "Что на фото?" else userText + let messages = + [ + Message(Role.System, Option.get currentContext) + Message(Role.User, + [ + Content(ContentType.Text, userText2) + Content(ContentType.ImageUrl, $"data:image/jpeg;base64,{base64Img}") + ]) + ] + + let result = + async { + let! completionResult = api.ChatEndpoint.GetCompletionAsync(ChatRequest(messages, model = "gpt-4-vision-preview", maxTokens = 500)) |> Async.AwaitTask + return completionResult + } + |> Async.RunSynchronously + + let answer = result.FirstChoice.Message.Content.ToString() + return answer + with - | ex -> - let errorText = sprintf "Exception text: %s" (ex.Message) - logErr errorText - return errorText - } \ No newline at end of file + | ex -> + ex.Message.ToString() |> logInfo + return ex.Message.ToString() + } \ No newline at end of file diff --git a/SerfBot/Program.fs b/SerfBot/Program.fs index 0472d3a..a4c0f36 100644 --- a/SerfBot/Program.fs +++ b/SerfBot/Program.fs @@ -8,7 +8,6 @@ open SerfBot.Log open SerfBot.TelegramBot open Types open Serilog -open Log; module Program = @@ -24,7 +23,7 @@ module Program = let! _ = Api.deleteWebhookBase () |> api telegramBotConfig logInfo "SerfBot start" - return! startBot telegramBotConfig updateArrived None + return! startBot telegramBotConfig updateArrivedMessage None } |> Async.RunSynchronously logInfo "SefBot stopped" diff --git a/SerfBot/SerfBot.fsproj b/SerfBot/SerfBot.fsproj index b8bbcad..1b7cd93 100644 --- a/SerfBot/SerfBot.fsproj +++ b/SerfBot/SerfBot.fsproj @@ -15,23 +15,27 @@ + + + + - + - + diff --git a/SerfBot/TelegramBot.fs b/SerfBot/TelegramBot.fs index 7f16340..a603f21 100644 --- a/SerfBot/TelegramBot.fs +++ b/SerfBot/TelegramBot.fs @@ -1,87 +1,80 @@ module SerfBot.TelegramBot open System -open System.Runtime.CompilerServices +open System.IO +open System.Net.Http open ExtCore.Control.Collections open Funogram.Api open Funogram.Telegram open Funogram.Telegram.Bot -open System.Collections.Generic open Funogram.Telegram.Types open SerfBot.Log -open SerfBot.OpenAiApi; open SerfBot.Types -open Telegram.Bot.Types; -open System.Text.RegularExpressions open SerfBot.TelegramApi -type CommandHandler = string -> string +let extractCommand (str: string) = + match str.Split(" ", 2) with + | [| command; inputText |] -> + (command, inputText) + | [| command; |] -> + (command, null) -let commandHandlers : Dictionary = Dictionary() - -let addCommandHandler (command: string) (handler: CommandHandler) = - commandHandlers.Add(command.ToLower(), handler) - -let handlePingCommand (command: string) = - match command.ToLower() with - | "ping" -> "pong" - | _ -> "Неизвестная команда" - -let handleWeatherCommand (command: string) = - match command.Split(" ", 2) with - | [| "погода"; location |] -> - try - let weather = WeatherApi.getWeatherAsync location |> Async.RunSynchronously - $"Погода в %s{location}: %s{weather}" - with - | ex -> sprintf "Ошибка при получении погоды: %s" ex.Message - - | _ -> "Неизвестная команда" - -let handleGPTCommand (command: string) = - match command.Split(" ", 2) with - | [| "гпт"; inputText |] -> - let replayText = gptAnswer inputText |> Async.RunSynchronously; - $"%s{replayText}" - | _ -> "Неизвестная команда" - -let extractCommand (str: string) = (str.Split(" ")[0]).Trim().ToLower(); - -addCommandHandler "ping" handlePingCommand -addCommandHandler "погода" handleWeatherCommand -addCommandHandler "гпт" handleGPTCommand - -// obsolet -//let processCommand (ctx: UpdateContext, command: MessageReplayCommand) = -// Api.sendMessageReply command.Chat.Id command.ReplayText command.MessageId -// |> api ctx.Config -// |> Async.Ignore -// |> Async.Start +let isValidUser (userId: int64) = + if Array.contains userId Configuration.config.UserIds then Some () + else None let processCommand (ctx: UpdateContext, command: MessageReplayCommand) = sendReplayMessageFormatted command.ReplayText ParseMode.Markdown ctx.Config api command.Chat.Id command.MessageId |> Async.RunSynchronously |> ignore -let isValidUser (userId: int64) = - if Array.contains userId Configuration.config.UserIds then Some () - else None +let streamToBase64 (stream: Stream) = + use ms = new MemoryStream() + stream.CopyTo(ms) + let buffer = ms.ToArray() + Convert.ToBase64String(buffer) + +let extractFileDataAsBase64 (fileResult: Result) = + match fileResult with + | Ok(file) -> + let filePath = Option.get file.FilePath + let apiUrl = $"https://api.telegram.org/file/bot{Configuration.config.TelegramBotToken}/{filePath}" + use httpStream = new HttpClient() + let f = httpStream.GetStreamAsync(apiUrl) |> Async.AwaitTask |> Async.RunSynchronously + let base64 = streamToBase64 f + base64 -let updateArrived (ctx: UpdateContext) = - match ctx.Update.Message with - | Some { MessageId = messageId; Chat = chat; Text = text } -> - let user = ctx.Update.Message.Value.From.Value - match isValidUser user.Id with - | Some () -> - logInfo $"Message from user {Option.get user.Username} received: {Option.get text}" - let userMessage = text.Value; - let command = extractCommand userMessage - match commandHandlers.TryGetValue command with - | true, handler -> - let replyText = handler userMessage - processCommand(ctx, { Chat = chat; MessageId = messageId; Text = text; ReplayText = replyText; }) + + +let handleFiles fileId ctx = + let file = Req.GetFile.Make fileId + |> api ctx.Config + |> Async.RunSynchronously + let base64Img = extractFileDataAsBase64 file + base64Img + + +let updateArrivedMessage (ctx: UpdateContext) = + match ctx.Update.Message with + | Some { MessageId = messageId; Chat = chat; Text = text; Photo = photo; Caption = caption } -> + let user = ctx.Update.Message.Value.From.Value + let base64Img = if photo.IsSome then handleFiles (Array.last photo.Value).FileId ctx else "" + let message = if text.IsSome then text.Value elif caption.IsSome then caption.Value else "" + match isValidUser user.Id with + | Some () -> + logInfo $"Message from user {Option.get user.Username} received: {message}" + let command, userMessage = extractCommand message + let commandType = + match command with + | "!ping" -> Ping + | "погода" -> Weather userMessage + | "!context" -> Context userMessage + | "!vision" -> Vision (userMessage, base64Img) + | "гпт" -> Question userMessage + | _ -> Other userMessage + + let replyText = Commands.commandHandler commandType + processCommand(ctx, { Chat = chat; MessageId = messageId; Text = text; ReplayText = replyText }) | _ -> () - | None -> - sprintf "Authorize error." |> logInfo - | _ -> () - \ No newline at end of file + | None -> sprintf "Authorize error." |> logInfo + | _ -> () \ No newline at end of file diff --git a/SerfBot/Types.fs b/SerfBot/Types.fs index 891433f..9d56f26 100644 --- a/SerfBot/Types.fs +++ b/SerfBot/Types.fs @@ -1,7 +1,6 @@ module SerfBot.Types open Funogram.Telegram -open Funogram.Telegram.Types type CityCoordinates = { CityName: string @@ -32,3 +31,11 @@ type ApplicationConfiguration = { UserIds: int64[] } +type Command = + | Ping + | Question of string + | Context of string + | Vision of string * string + | Weather of string + | Other of string + diff --git a/SerfBot/appsettings.json b/SerfBot/appsettings.json index dc3b570..97c4024 100644 --- a/SerfBot/appsettings.json +++ b/SerfBot/appsettings.json @@ -1,7 +1,7 @@ { "UserIds": [ - 202224486 + 123456 ], - "TelegramBotToken": "270652017:AAG5O6FvMgMhB9MlOBsIF_yoKXaoiHD1GGs", - "OpenAiApiToken": "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" + "TelegramBotToken": "telegram-api-token", + "OpenAiApiToken": "open-ai-token" } \ No newline at end of file