diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ca0c53d6..6b79f302 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,15 @@ name: Docker on: push: + branches: + - master tags: - - '*' + - 'v*' + pull_request: + branches: + - master + schedule: + - cron: '0 0 * * 6' # every Saturday jobs: publish: @@ -18,3 +25,4 @@ jobs: repository: codingteam/emulsion tag_with_ref: true tags: latest + push: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') && 'true' || 'false' }} diff --git a/.gitignore b/.gitignore index dab70302..0996b939 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,10 @@ out/ emulsion.json +*.db +*.db-shm +*.db-wal *.user .fake -.ionide \ No newline at end of file +.ionide diff --git a/CHANGELOG.md b/CHANGELOG.md index 760bd2f9..15d058c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning v2.0.0](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- [#147: Telegram content redirector support](https://github.com/codingteam/emulsion/issues/147). Emulsion is now able to generate redirects to t.me from its own embedded web server, knowing the internal content id. + + This feature is not very useful, yet (earlier, the same t.me links were already available to the users directly), but it is the first step for further development of more valuable forms of content proxying. + ## [1.9.0] - 2022-06-01 ### Changed - Runtime: upgrade to .NET 6 diff --git a/Dockerfile b/Dockerfile index 9d869b3c..13508a58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env WORKDIR /app +COPY ./Emulsion/Emulsion.fsproj ./Emulsion/ COPY ./Emulsion.ContentProxy/Emulsion.ContentProxy.fsproj ./Emulsion.ContentProxy/ COPY ./Emulsion.Database/Emulsion.Database.fsproj ./Emulsion.Database/ -COPY ./Emulsion/Emulsion.fsproj ./Emulsion/ +COPY ./Emulsion.Settings/Emulsion.Settings.fsproj ./Emulsion.Settings/ +COPY ./Emulsion.Web/Emulsion.Web.fsproj ./Emulsion.Web/ RUN dotnet restore Emulsion diff --git a/Emulsion.ContentProxy/ContentStorage.fs b/Emulsion.ContentProxy/ContentStorage.fs index 8fc76717..11604e17 100644 --- a/Emulsion.ContentProxy/ContentStorage.fs +++ b/Emulsion.ContentProxy/ContentStorage.fs @@ -32,9 +32,9 @@ let getOrCreateMessageRecord (context: EmulsionDbContext) (id: MessageContentIde | Some item -> return item } -let getById (context: EmulsionDbContext) (id: int64): Async = async { +let getById (context: EmulsionDbContext) (id: int64): Async = async { return! query { for content in context.TelegramContents do where (content.Id = id) - } |> exactlyOneAsync + } |> tryExactlyOneAsync } diff --git a/Emulsion.Settings/Emulsion.Settings.fsproj b/Emulsion.Settings/Emulsion.Settings.fsproj new file mode 100644 index 00000000..bc1dee39 --- /dev/null +++ b/Emulsion.Settings/Emulsion.Settings.fsproj @@ -0,0 +1,16 @@ + + + + net6.0 + true + + + + + + + + + + + diff --git a/Emulsion/Settings.fs b/Emulsion.Settings/Settings.fs similarity index 84% rename from Emulsion/Settings.fs rename to Emulsion.Settings/Settings.fs index 9ede62d6..bc89149d 100644 --- a/Emulsion/Settings.fs +++ b/Emulsion.Settings/Settings.fs @@ -29,7 +29,8 @@ type LogSettings = { } type HostingSettings = { - BaseUri: Uri + ExternalUriBase: Uri + BindUri: Uri HashIdSalt: string } @@ -78,16 +79,18 @@ let read (config : IConfiguration) : EmulsionSettings = |> Option.ofObj |> Option.map(fun dataSource -> { DataSource = dataSource }) let readHosting(section: IConfigurationSection) = - let baseUri = Option.ofObj section["baseUri"] + let externalUriBase = Option.ofObj section["externalUriBase"] + let bindUri = Option.ofObj section["bindUri"] let hashIdSalt = Option.ofObj section["hashIdSalt"] - match baseUri, hashIdSalt with - | Some baseUri, Some hashIdSalt -> + match externalUriBase, bindUri, hashIdSalt with + | Some externalUriBase, Some bindUri, Some hashIdSalt -> Some { - BaseUri = Uri baseUri + ExternalUriBase = Uri externalUriBase + BindUri = Uri bindUri HashIdSalt = hashIdSalt } - | None, None -> None - | other -> failwith $"Pair {other} is not valid for hosting settings" + | None, None, None -> None + | other -> failwith $"Parameter pack {other} represents invalid hosting settings." { Xmpp = readXmpp <| config.GetSection("xmpp") Telegram = readTelegram <| config.GetSection("telegram") diff --git a/Emulsion.Tests/Emulsion.Tests.fsproj b/Emulsion.Tests/Emulsion.Tests.fsproj index 724056e0..bcb0b36e 100644 --- a/Emulsion.Tests/Emulsion.Tests.fsproj +++ b/Emulsion.Tests/Emulsion.Tests.fsproj @@ -21,7 +21,6 @@ - @@ -33,6 +32,7 @@ + diff --git a/Emulsion.Tests/SettingsTests.fs b/Emulsion.Tests/SettingsTests.fs index d1546adc..abfc40bf 100644 --- a/Emulsion.Tests/SettingsTests.fs +++ b/Emulsion.Tests/SettingsTests.fs @@ -81,7 +81,8 @@ let ``Extended settings read properly``(): Task = task { ""dataSource"": "":memory:"" }, ""hosting"": { - ""baseUri"": ""https://example.com"", + ""externalUriBase"": ""https://example.com"", + ""bindUri"": ""http://localhost:5555"", ""hashIdSalt"": ""123123123"" }" let expectedConfiguration = @@ -90,7 +91,8 @@ let ``Extended settings read properly``(): Task = task { DataSource = ":memory:" } Hosting = Some { - BaseUri = Uri("https://example.com") + ExternalUriBase = Uri "https://example.com" + BindUri = Uri "http://localhost:5555" HashIdSalt = "123123123" } } diff --git a/Emulsion.Tests/Telegram/FunogramTests.fs b/Emulsion.Tests/Telegram/FunogramTests.fs index 337db4e2..7bb1ecff 100644 --- a/Emulsion.Tests/Telegram/FunogramTests.fs +++ b/Emulsion.Tests/Telegram/FunogramTests.fs @@ -18,32 +18,34 @@ let private groupId = 200600L [] let private chatName = "test_room" -let private createUser username firstName lastName = { - Id = 0L - FirstName = firstName - LastName = lastName - Username = username - LanguageCode = None - IsBot = false -} - -let private currentChat = { - defaultChat with - Id = groupId - Username = Some chatName - Type = SuperGroup -} +let private createUser username firstName lastName = User.Create( + id = 0L, + isBot = false, + firstName = firstName, + ?lastName = lastName, + ?username = username +) + +let private currentChat = Chat.Create( + id = groupId, + ``type`` = ChatType.SuperGroup, + username = chatName +) + +let private defaultMessage = Message.Create( + messageId = 1L, + date = DateTime.MinValue, + chat = currentChat +) let private createMessage from text : Funogram.Telegram.Types.Message = { defaultMessage with From = from - Chat = currentChat Text = text } let private createEmptyMessage from : Funogram.Telegram.Types.Message = { defaultMessage with - From = Some from - Chat = currentChat } + From = Some from } let private createReplyMessage from text replyTo : Funogram.Telegram.Types.Message = { createMessage from text with @@ -52,7 +54,6 @@ let private createReplyMessage from text replyTo : Funogram.Telegram.Types.Messa let private createForwardedMessage from (forwarded: Funogram.Telegram.Types.Message) = { defaultMessage with From = Some from - Chat = currentChat ForwardFrom = forwarded.From Text = forwarded.Text } @@ -62,6 +63,7 @@ let private createStickerMessage from emoji = Chat = currentChat Sticker = Some { FileId = "" + FileUniqueId = "" Width = 0 Height = 0 Thumb = None @@ -70,31 +72,36 @@ let private createStickerMessage from emoji = SetName = None MaskPosition = None IsAnimated = false + IsVideo = false + PremiumAnimation = None } } -let private createPhoto() = seq { - { FileId = "" - Width = 0 - Height = 0 - FileSize = None +let private createPhoto() = [| + { + FileId = "" + FileUniqueId = "" + Width = 0 + Height = 0 + FileSize = None } +|] + +let private createAnimation(): Animation = { + FileId = "" + FileUniqueId = "" + Width = 0 + Height = 0 + Duration = 0 + Thumb = None + FileName = None + MimeType = None + FileSize = None } -let private createAnimation() = - { FileId = "" - Width = 0 - Height = 0 - Duration = 0 - Thumb = None - FileName = None - MimeType = None - FileSize = None } - let private createMessageWithCaption from caption = { defaultMessage with From = Some from - Chat = currentChat Caption = Some caption } let private createPoll from (question: string) (options: string[]) = @@ -105,11 +112,16 @@ let private createPoll from (question: string) (options: string[]) = VoterCount = 0 }) - let poll: Poll = - { Id = "" - Question = question - Options = options - IsClosed = false } + let poll= Poll.Create( + id = "", + question = question, + options = options, + totalVoterCount = 0L, + isClosed = false, + isAnonymous = false, + ``type`` = "", + allowsMultipleAnswers = false + ) { defaultMessage with From = Some from @@ -131,11 +143,12 @@ let private createEntity t offset length url = { Length = length Url = Some url User = None + Language = None } -let private createEntities t offset length url = Some <| seq { +let private createEntities t offset length url = Some <| [| createEntity t offset length url -} +|] let private originalUser = createUser (Some "originalUser") "" None let private replyingUser = createUser (Some "replyingUser") "" None @@ -293,7 +306,8 @@ module ReadMessageTests = Url = None Offset = 0L Length = 5L - User = None } |] } + User = None + Language = None } |] } Assert.Equal( authoredTelegramMessage "@originalUser" "Original text", readMessage message @@ -459,7 +473,7 @@ module ReadMessageTests = [] let readUserEntersChat() = let message = { createEmptyMessage originalUser with - NewChatMembers = Some <| seq { originalUser } } + NewChatMembers = Some <| [| originalUser |] } Assert.Equal( eventTelegramMessage "@originalUser has entered the chat", readMessage message @@ -477,7 +491,7 @@ module ReadMessageTests = let readAddedChatMember() = let newUser = createUser None "FirstName1" None let message = { createEmptyMessage originalUser with - NewChatMembers = Some <| seq { newUser } } + NewChatMembers = Some <| [| newUser |] } Assert.Equal( eventTelegramMessage "@originalUser has added FirstName1 the chat", readMessage message @@ -485,10 +499,10 @@ module ReadMessageTests = [] let readNewChatMembers() = - let newUsers = seq { + let newUsers = [| createUser None "FirstName1" None createUser None "FirstName2" None - } + |] let message = { createEmptyMessage originalUser with NewChatMembers = Some newUsers } Assert.Equal( @@ -498,11 +512,11 @@ module ReadMessageTests = [] let readMoreChatMembers() = - let newUsers = seq { + let newUsers = [| createUser None "FirstName1" None createUser None "FirstName2" None createUser None "FirstName3" None - } + |] let message = { createEmptyMessage originalUser with NewChatMembers = Some newUsers } Assert.Equal( @@ -526,12 +540,17 @@ module ReadMessageTests = From = Some { createUser None "" None with Id = selfUserId } Text = Some "Myself\nTests" - Entities = Some <| seq { - yield { Type = "bold" - Offset = 0L - Length = int64 "Myself".Length - Url = None - User = None } } } + Entities = Some <| [| + { + Type = "bold" + Offset = 0L + Length = int64 "Myself".Length + Url = None + User = None + Language = None + } + |] + } let reply = createReplyMessage (Some replyingUser) (Some "reply text") originalMessage Assert.Equal( authoredTelegramReplyMessage "@replyingUser" "reply text" (authoredTelegramMessage "Myself" "Tests").main, @@ -545,7 +564,10 @@ module ProcessMessageTests = [] let messageFromOtherChatShouldBeIgnored(): unit = let message = { createMessage (Some originalUser) (Some "test") with - Chat = defaultChat } + Chat = Chat.Create( + id = 0L, + ``type`` = ChatType.SuperGroup + ) } Assert.Equal(None, processMessage message) module ProcessSendResultTests = diff --git a/Emulsion.Tests/Telegram/LinkGeneratorTests.fs b/Emulsion.Tests/Telegram/LinkGeneratorTests.fs index 04cbfcdf..474a4df9 100644 --- a/Emulsion.Tests/Telegram/LinkGeneratorTests.fs +++ b/Emulsion.Tests/Telegram/LinkGeneratorTests.fs @@ -13,7 +13,8 @@ open Emulsion.Telegram open Emulsion.Tests.TestUtils let private hostingSettings = { - BaseUri = Uri "https://example.com" + ExternalUriBase = Uri "https://example.com" + BindUri = Uri "http://localhost:5556" HashIdSalt = "mySalt" } let private chatName = "test_chat" @@ -21,18 +22,21 @@ let private fileId1 = "123456" let private fileId2 = "654321" let private messageTemplate = - { defaultMessage with - Chat = - { defaultChat with - Type = SuperGroup - Username = Some chatName - } - } + Message.Create( + messageId = 0L, + date = DateTime.MinValue, + chat = Chat.Create( + id = 0L, + ``type`` = ChatType.SuperGroup, + username = chatName + ) + ) let private messageWithDocument = { messageTemplate with Document = Some { FileId = fileId1 + FileUniqueId = fileId1 Thumb = None FileName = None MimeType = None @@ -44,6 +48,8 @@ let private messageWithAudio = { messageTemplate with Audio = Some { FileId = fileId1 + FileUniqueId = fileId1 + FileName = None Duration = 0 Performer = None Title = None @@ -57,6 +63,7 @@ let private messageWithAnimation = { messageTemplate with Animation = Some { FileId = fileId1 + FileUniqueId = fileId1 Width = 0 Height = 0 Duration = 0 @@ -71,6 +78,7 @@ let private messageWithPhoto = { messageTemplate with Photo = Some([|{ FileId = fileId1 + FileUniqueId = fileId1 Width = 0 Height = 0 FileSize = None @@ -79,10 +87,11 @@ let private messageWithPhoto = let private messageWithMultiplePhotos = { messageWithPhoto with - Photo = Some(Seq.append (Option.get messageWithPhoto.Photo) [|{ + Photo = Some(Array.append (Option.get messageWithPhoto.Photo) [|{ FileId = fileId2 - Width = 0 - Height = 0 + FileUniqueId = fileId2 + Width = 1000 + Height = 2000 FileSize = None }|]) } @@ -91,6 +100,7 @@ let private messageWithSticker = { messageTemplate with Sticker = Some { FileId = fileId1 + FileUniqueId = fileId1 Width = 0 Height = 0 IsAnimated = false @@ -99,6 +109,8 @@ let private messageWithSticker = SetName = None MaskPosition = None FileSize = None + IsVideo = false + PremiumAnimation = None } } @@ -106,6 +118,8 @@ let private messageWithVideo = { messageTemplate with Video = Some { FileId = fileId1 + FileUniqueId = fileId1 + FileName = None Width = 0 Height = 0 Duration = 0 @@ -119,6 +133,7 @@ let private messageWithVoice = { messageTemplate with Voice = Some { FileId = fileId1 + FileUniqueId = fileId1 Duration = 0 MimeType = None FileSize = None @@ -129,6 +144,7 @@ let private messageWithVideoNote = { messageTemplate with VideoNote = Some { FileId = fileId1 + FileUniqueId = fileId1 Length = 0 Duration = 0 Thumb = None @@ -148,13 +164,14 @@ let private doDatabaseLinksTest (fileIds: string[]) message = let contentLinks = Seq.toArray links.ContentLinks for fileId, link in Seq.zip fileIds contentLinks do let link = link.ToString() - let baseUri = hostingSettings.BaseUri.ToString() + let baseUri = hostingSettings.ExternalUriBase.ToString() Assert.StartsWith(baseUri, link) - let emptyLinkLength = (Proxy.getLink hostingSettings.BaseUri "").ToString().Length + let emptyLinkLength = (Proxy.getLink hostingSettings.ExternalUriBase "").ToString().Length let id = link.Substring(emptyLinkLength) let! content = DataStorage.transaction databaseSettings (fun context -> ContentStorage.getById context (Proxy.decodeHashId hostingSettings.HashIdSalt id) ) + let content = Option.get content Assert.Equal(message.MessageId, content.MessageId) Assert.Equal(message.Chat.Username, Some content.ChatUserName) @@ -195,4 +212,4 @@ let databaseVoiceTest(): unit = doDatabaseLinkTest fileId1 messageWithVoice let databaseVideoNoteTest(): unit = doDatabaseLinkTest fileId1 messageWithVideoNote [] -let databaseMultiplePhotosTest(): unit = doDatabaseLinksTest [|fileId1; fileId2|] messageWithMultiplePhotos +let databaseMultiplePhotosTest(): unit = doDatabaseLinksTest [|fileId2|] messageWithMultiplePhotos diff --git a/Emulsion.Tests/Web/ContentControllerTests.fs b/Emulsion.Tests/Web/ContentControllerTests.fs new file mode 100644 index 00000000..564b485c --- /dev/null +++ b/Emulsion.Tests/Web/ContentControllerTests.fs @@ -0,0 +1,80 @@ +namespace Emulsion.Tests.Web + +open System +open System.Threading.Tasks + +open Microsoft.AspNetCore.Mvc +open Microsoft.Extensions.Logging +open Serilog.Extensions.Logging +open Xunit +open Xunit.Abstractions + +open Emulsion.ContentProxy +open Emulsion.Database +open Emulsion.Database.Entities +open Emulsion.Settings +open Emulsion.Tests.TestUtils +open Emulsion.Tests.TestUtils.Logging +open Emulsion.Web + +type ContentControllerTests(output: ITestOutputHelper) = + + let hostingSettings = { + ExternalUriBase = Uri "https://example.com/emulsion" + BindUri = Uri "http://localhost:5557" + HashIdSalt = "test_salt" + } + + let logger = xunitLogger output + + let performTestWithPreparation prepareAction testAction = Async.StartAsTask(async { + return! TestDataStorage.doWithDatabase(fun databaseSettings -> async { + do! prepareAction databaseSettings + + use loggerFactory = new SerilogLoggerFactory(logger) + let logger = loggerFactory.CreateLogger() + use context = new EmulsionDbContext(databaseSettings.ContextOptions) + let controller = ContentController(logger, hostingSettings, context) + return! testAction controller + }) + }) + + let performTest = performTestWithPreparation(fun _ -> async.Return()) + + [] + member _.``ContentController returns BadRequest on hashId deserialization error``(): Task = + performTest (fun controller -> async { + let hashId = "z-z-z-z-z" + let! result = Async.AwaitTask <| controller.Get hashId + Assert.IsType result |> ignore + }) + + [] + member _.``ContentController returns NotFound if the content doesn't exist``(): Task = + performTest (fun controller -> async { + let hashId = Proxy.encodeHashId hostingSettings.HashIdSalt 667L + let! result = Async.AwaitTask <| controller.Get hashId + Assert.IsType result |> ignore + }) + + [] + member _.``ContentController returns a correct result``(): Task = + let contentId = 343L + let chatUserName = "MySuperExampleChat" + let messageId = 777L + performTestWithPreparation (fun databaseOptions -> async { + use context = new EmulsionDbContext(databaseOptions.ContextOptions) + let content = { + Id = contentId + ChatUserName = chatUserName + MessageId = messageId + FileId = "foobar" + } + do! DataStorage.addAsync context.TelegramContents content + return! Async.Ignore <| Async.AwaitTask(context.SaveChangesAsync()) + }) (fun controller -> async { + let hashId = Proxy.encodeHashId hostingSettings.HashIdSalt contentId + let! result = Async.AwaitTask <| controller.Get hashId + let redirect = Assert.IsType result + Assert.Equal($"https://t.me/{chatUserName}/{string messageId}", redirect.Url) + }) diff --git a/Emulsion.Web/ContentController.fs b/Emulsion.Web/ContentController.fs new file mode 100644 index 00000000..a7b1552a --- /dev/null +++ b/Emulsion.Web/ContentController.fs @@ -0,0 +1,45 @@ +namespace Emulsion.Web + +open System.Threading.Tasks + +open Microsoft.AspNetCore.Mvc +open Microsoft.Extensions.Logging + +open Emulsion.ContentProxy +open Emulsion.Database +open Emulsion.Settings + +[] +[] +type ContentController(logger: ILogger, + configuration: HostingSettings, + context: EmulsionDbContext) = + inherit ControllerBase() + + let decodeHashId hashId = + try + Some <| Proxy.decodeHashId configuration.HashIdSalt hashId + with + | ex -> + logger.LogWarning(ex, "Error during hashId deserializing") + None + + let produceRedirect contentId: Async = async { + let! content = ContentStorage.getById context contentId + return + content + |> Option.map(fun c -> + let url = $"https://t.me/{c.ChatUserName}/{string c.MessageId}" + RedirectResult url + ) + } + + [] + member this.Get(hashId: string): Task = task { + match decodeHashId hashId with + | None -> return this.BadRequest() + | Some contentId -> + match! produceRedirect contentId with + | None -> return this.NotFound() :> IActionResult + | Some redirect -> return redirect + } diff --git a/Emulsion.Web/Emulsion.Web.fsproj b/Emulsion.Web/Emulsion.Web.fsproj new file mode 100644 index 00000000..eb58afb6 --- /dev/null +++ b/Emulsion.Web/Emulsion.Web.fsproj @@ -0,0 +1,22 @@ + + + Library + net6.0 + + + + + + + + + + + + + + + + + + diff --git a/Emulsion.Web/WebServer.fs b/Emulsion.Web/WebServer.fs new file mode 100644 index 00000000..24a71a21 --- /dev/null +++ b/Emulsion.Web/WebServer.fs @@ -0,0 +1,26 @@ +module Emulsion.Web.WebServer + +open System.Threading.Tasks + +open Emulsion.Database +open Emulsion.Settings +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.DependencyInjection +open Serilog + +let run (logger: ILogger) (hostingSettings: HostingSettings) (databaseSettings: DatabaseSettings): Task = + let builder = WebApplication.CreateBuilder(WebApplicationOptions()) + + builder.Host.UseSerilog(logger) + |> ignore + + builder.Services + .AddSingleton(hostingSettings) + .AddTransient(fun _ -> new EmulsionDbContext(databaseSettings.ContextOptions)) + .AddControllers() + .AddApplicationPart(typeof.Assembly) + |> ignore + + let app = builder.Build() + app.MapControllers() |> ignore + app.RunAsync(hostingSettings.BindUri.ToString()) diff --git a/Emulsion.sln b/Emulsion.sln index 0235560d..1c4da5bd 100644 --- a/Emulsion.sln +++ b/Emulsion.sln @@ -40,6 +40,10 @@ EndProjectSection EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Emulsion.ContentProxy", "Emulsion.ContentProxy\Emulsion.ContentProxy.fsproj", "{A520FD41-A1CB-4062-AFBC-62A8BED12E81}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Emulsion.Web", "Emulsion.Web\Emulsion.Web.fsproj", "{8EFD8F6C-43D3-4CE0-ADB8-63401E4A64FE}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Emulsion.Settings", "Emulsion.Settings\Emulsion.Settings.fsproj", "{88331E40-EF70-4AF1-99CA-8A849208CAB7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +69,14 @@ Global {A520FD41-A1CB-4062-AFBC-62A8BED12E81}.Debug|Any CPU.Build.0 = Debug|Any CPU {A520FD41-A1CB-4062-AFBC-62A8BED12E81}.Release|Any CPU.ActiveCfg = Release|Any CPU {A520FD41-A1CB-4062-AFBC-62A8BED12E81}.Release|Any CPU.Build.0 = Release|Any CPU + {8EFD8F6C-43D3-4CE0-ADB8-63401E4A64FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EFD8F6C-43D3-4CE0-ADB8-63401E4A64FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EFD8F6C-43D3-4CE0-ADB8-63401E4A64FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EFD8F6C-43D3-4CE0-ADB8-63401E4A64FE}.Release|Any CPU.Build.0 = Release|Any CPU + {88331E40-EF70-4AF1-99CA-8A849208CAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88331E40-EF70-4AF1-99CA-8A849208CAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88331E40-EF70-4AF1-99CA-8A849208CAB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88331E40-EF70-4AF1-99CA-8A849208CAB7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {7D1ADF47-BF1C-4007-BB9B-08C283044467} = {973131E1-E645-4A50-A0D2-1886A1A8F0C6} diff --git a/Emulsion/Emulsion.fsproj b/Emulsion/Emulsion.fsproj index b1e52c65..17fd7ace 100644 --- a/Emulsion/Emulsion.fsproj +++ b/Emulsion/Emulsion.fsproj @@ -7,7 +7,6 @@ - @@ -34,17 +33,18 @@ - - + + - - + + + \ No newline at end of file diff --git a/Emulsion/Program.fs b/Emulsion/Program.fs index 52284261..71cdc01c 100644 --- a/Emulsion/Program.fs +++ b/Emulsion/Program.fs @@ -11,6 +11,7 @@ open Emulsion.Actors open Emulsion.Database open Emulsion.MessageSystem open Emulsion.Settings +open Emulsion.Web open Emulsion.Xmpp let private getConfiguration directory (fileName: string) = @@ -50,6 +51,13 @@ let private startApp config = | Some dbSettings -> do! migrateDatabase logger dbSettings | None -> () + let webServerTask = + match config.Hosting, config.Database with + | Some hosting, Some database -> + logger.Information "Initializing web server…" + Some <| WebServer.run logger hosting database + | _ -> None + logger.Information "Actor system preparation…" use system = ActorSystem.Create("emulsion") logger.Information "Clients preparation…" @@ -73,11 +81,18 @@ let private startApp config = let! xmppSystem = startMessageSystem logger xmpp core.Tell logger.Information "System ready" - logger.Information "Waiting for actor system termination…" - do! Async.AwaitTask system.WhenTerminated - logger.Information "Waiting for message systems termination…" - do! telegramSystem - do! xmppSystem + logger.Information "Waiting for the systems to terminate…" + let! _ = Async.Parallel(seq { + yield Async.AwaitTask system.WhenTerminated + yield telegramSystem + yield xmppSystem + + match webServerTask with + | Some task -> yield Async.AwaitTask task + | None -> () + }) + + logger.Information "Terminated successfully." with | error -> logger.Fatal(error, "General application failure") diff --git a/Emulsion/Telegram/Client.fs b/Emulsion/Telegram/Client.fs index 02aa86ef..bf42b1ef 100644 --- a/Emulsion/Telegram/Client.fs +++ b/Emulsion/Telegram/Client.fs @@ -13,7 +13,7 @@ type Client(ctx: ServiceContext, hostingSettings: HostingSettings option) = inherit MessageSystemBase(ctx, cancellationToken) - let botConfig = { Funogram.Telegram.Bot.defaultConfig with Token = telegramSettings.Token } + let botConfig = { Funogram.Telegram.Bot.Config.defaultConfig with Token = telegramSettings.Token } override _.RunUntilError receiver = async { // Run loop of Telegram is in no need of any complicated start, so just return an async that will perform it: diff --git a/Emulsion/Telegram/Funogram.fs b/Emulsion/Telegram/Funogram.fs index e3451f54..fbf7166b 100644 --- a/Emulsion/Telegram/Funogram.fs +++ b/Emulsion/Telegram/Funogram.fs @@ -348,8 +348,8 @@ let internal prepareHtmlMessage: Message -> string = function | Event {text = text} -> Html.escape text let send (settings: TelegramSettings) (botConfig: BotConfig) (OutgoingMessage content): Async = - let sendHtmlMessage groupId text = - Api.sendMessageBase groupId text (Some ParseMode.HTML) None None None None + let sendHtmlMessage (groupId: ChatId) text = + Req.SendMessage.Make(groupId, text, ParseMode.HTML) let groupId = Int(int64 settings.GroupId) let message = prepareHtmlMessage content diff --git a/Emulsion/Telegram/LinkGenerator.fs b/Emulsion/Telegram/LinkGenerator.fs index 167efec8..8be513e9 100644 --- a/Emulsion/Telegram/LinkGenerator.fs +++ b/Emulsion/Telegram/LinkGenerator.fs @@ -35,13 +35,19 @@ let private getFileIds(message: FunogramMessage): string seq = let inline extractFileId(o: ^a option) = Option.iter(fun o -> allFileIds.Add((^a) : (member FileId: string) o)) o - let extractPhotoFileIds: PhotoSize seq option -> unit = - Option.iter(Seq.iter(fun photoSize -> allFileIds.Add(photoSize.FileId))) + let extractPhotoFileId: PhotoSize[] option -> unit = + Option.iter( + // Telegram may send several differently-sized thumbnails in one message. Pick the biggest one of them. + Seq.sortByDescending(fun size -> size.Height * size.Width) + >> Seq.map(fun photoSize -> photoSize.FileId) + >> Seq.tryHead + >> Option.iter(allFileIds.Add) + ) extractFileId message.Document extractFileId message.Audio extractFileId message.Animation - extractPhotoFileIds message.Photo + extractPhotoFileId message.Photo extractFileId message.Sticker extractFileId message.Video extractFileId message.Voice @@ -49,7 +55,7 @@ let private getFileIds(message: FunogramMessage): string seq = allFileIds -let private getContentIdentities message: ContentStorage.MessageContentIdentity seq = +let private getContentIdentities(message: FunogramMessage): ContentStorage.MessageContentIdentity seq = match message.Chat with | { Type = SuperGroup Username = Some chatName } -> @@ -79,7 +85,7 @@ let gatherLinks (logger: ILogger) ) let hashId = Proxy.encodeHashId hostingSettings.HashIdSalt content.Id - return Proxy.getLink hostingSettings.BaseUri hashId + return Proxy.getLink hostingSettings.ExternalUriBase hashId }) |> Async.Parallel return links diff --git a/README.md b/README.md index 9e286f7a..8e9e4bec 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ Note that `pingInterval` of `null` disables XMPP ping support. ### Telegram Content Proxy -There's **unfinished** Telegram content proxy support. To enable it, configure the `database` and `hosting` configuration file sections: +There's **unfinished** Telegram content proxy support, for XMPP users to access Telegram content without directly opening links on t.me. Right now, it will only generate a redirect to the corresponding t.me URI, so it doesn't help a lot. But in the future, proper content proxy will be supported. + +To enable it, configure the `database` and `hosting` configuration file sections: ```json { @@ -44,7 +46,8 @@ There's **unfinished** Telegram content proxy support. To enable it, configure t "dataSource": "sqliteDatabase.db" }, "hosting": { - "baseUri": "https://example.com/api/content", + "externalUriBase": "https://example.com/api/", + "bindUri": "http://localhost:5000/", "hashIdSalt": "test" } } @@ -52,10 +55,20 @@ There's **unfinished** Telegram content proxy support. To enable it, configure t `dataSource` may be a path to the SQLite database file on disk. If set, Emulsion will automatically apply necessary migrations to this database on startup. -If all the parameters are set, then Emulsion will save the incoming messages into the database, and will then insert links to `{baseUri}/content/{contentId}` instead of links to `https://t.me/{messageId}`. +If all the parameters are set, then Emulsion will save the incoming messages into the database, and will then insert links to `{externalUriBase}/content/{contentId}` instead of links to `https://t.me/{messageId}`. + +`bindUri` designates the URI the web server will listen locally (which may or may not be the same as the `externalUriBase`). The content identifiers in question are generated from the database ones using the [hashids.net][hashids.net] library, `hashIdSalt` is used in generation. This should complicate guessing of content ids for any external party not reading the chat directly. +### Recommended Network Configuration + +Current configuration system allows the following: + +1. Set up a reverse proxy for, say, `https://example.com/telegram` taking the content from `http://localhost/`. +2. When receiving a piece of Telegram content (a file, a photo, an audio message), the bot will send a link to `https://example.com/telegram/content/` to the XMPP chat. +3. When anyone visits the link, the reverse proxy will send a request to `http://localhost/content/`, which will take a corresponding content from the database. + Test ---- diff --git a/emulsion.example.json b/emulsion.example.json index 8263c050..0b7e79b6 100644 --- a/emulsion.example.json +++ b/emulsion.example.json @@ -21,7 +21,8 @@ "dataSource": "sqliteDatabase.db" }, "hosting": { - "baseUri": "https://example.com/api/content", + "externalUriBase": "https://example.com/api/", + "bindUri": "https://localhost:5000", "hashIdSalt": "test" } }