├── RedditChatBot ├── Readme.md ├── RedditChatBot.fsproj ├── Startup.fs ├── Chat.fs ├── Reddit.fs └── Bot.fs ├── Readme.md ├── FriendlyChatBot ├── Readme.md ├── local.settings.json ├── function.json ├── host.json ├── metadata.json ├── FriendlyChatBot.fsproj └── FriendlyChatBot.fs ├── ChatTest ├── Program.fs ├── ChatTest.fsproj ├── ChatTest.fs └── RedditTest.fs ├── TypicalChatBot ├── Readme.md ├── local.settings.json ├── function.json ├── host.json ├── metadata.json ├── TypicalChatBot.fsproj └── TypicalChatBot.fs ├── .gitattributes ├── RedditChatBot.sln └── .gitignore /RedditChatBot/Readme.md: -------------------------------------------------------------------------------- 1 | This is the shared framework for all Reddit-ChatGPT bots. 2 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | Currently running on Reddit as [/u/friendly-chat-bot](https://www.reddit.com/user/friendly-chat-bot/). -------------------------------------------------------------------------------- /FriendlyChatBot/Readme.md: -------------------------------------------------------------------------------- 1 | A friendly bot that runs every 30 minutes on Reddit as [/u/friendly-chat-bot](https://www.reddit.com/user/friendly-chat-bot/). 2 | -------------------------------------------------------------------------------- /ChatTest/Program.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | module Program = 4 | 5 | System.Console.OutputEncoding <- System.Text.Encoding.UTF8 6 | // ChatTest.test () 7 | RedditTest.test () 8 | -------------------------------------------------------------------------------- /TypicalChatBot/Readme.md: -------------------------------------------------------------------------------- 1 | A bot that I currently use for development, on Reddit as [/u/typical-chat-bot](https://www.reddit.com/user/typical-chat-bot/). It responds more often than /u/friendly-chat-bot. 2 | -------------------------------------------------------------------------------- /FriendlyChatBot/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=True", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet" 6 | } 7 | } -------------------------------------------------------------------------------- /TypicalChatBot/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=True", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet" 6 | } 7 | } -------------------------------------------------------------------------------- /FriendlyChatBot/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "name": "timer", 6 | "type": "timerTrigger", 7 | "direction": "in", 8 | "schedule": "0 */5 * * * *" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /TypicalChatBot/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "name": "timer", 6 | "type": "timerTrigger", 7 | "direction": "in", 8 | "schedule": "0 */5 * * * *" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /FriendlyChatBot/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "functionTimeout": "00:10:00" 12 | } -------------------------------------------------------------------------------- /TypicalChatBot/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "functionTimeout": "00:10:00" 12 | } -------------------------------------------------------------------------------- /FriendlyChatBot/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultFunctionName": "MonitorUnreadMessages", 3 | "description": "$TimerTrigger_description", 4 | "name": "Timer trigger", 5 | "language": "F#", 6 | "category": [ 7 | "$temp_category_core", 8 | "$temp_category_dataProcessing" 9 | ], 10 | "categoryStyle": "timer", 11 | "enabledInTryMode": true, 12 | "userPrompt": [ 13 | "schedule" 14 | ] 15 | } -------------------------------------------------------------------------------- /TypicalChatBot/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultFunctionName": "MonitorUnreadMessages", 3 | "description": "$TimerTrigger_description", 4 | "name": "Timer trigger", 5 | "language": "F#", 6 | "category": [ 7 | "$temp_category_core", 8 | "$temp_category_dataProcessing" 9 | ], 10 | "categoryStyle": "timer", 11 | "enabledInTryMode": true, 12 | "userPrompt": [ 13 | "schedule" 14 | ] 15 | } -------------------------------------------------------------------------------- /ChatTest/ChatTest.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /RedditChatBot/RedditChatBot.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | v4 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /TypicalChatBot/TypicalChatBot.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | v4 6 | 7 | 8 | 9 | 10 | 11 | PreserveNewest 12 | 13 | 14 | PreserveNewest 15 | Never 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /FriendlyChatBot/FriendlyChatBot.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | v4 6 | 7 | 8 | 9 | 10 | 11 | PreserveNewest 12 | 13 | 14 | PreserveNewest 15 | Never 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ChatTest/ChatTest.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open System 4 | open Microsoft.Extensions.Configuration 5 | open OpenAI.ObjectModels 6 | 7 | module ChatTest = 8 | 9 | let prompt = 10 | "Choose a random noun, then write a one-sentence thought about it to post on Reddit. The thought should be in the form of a statement, not a question. Output as JSON: { \"Noun\" : string, \"Story\" : string }." 11 | 12 | let chatBot = 13 | 14 | let settings = 15 | let cs = 16 | Environment.GetEnvironmentVariable("ConnectionString") 17 | ConfigurationBuilder() 18 | .AddAzureAppConfiguration(cs) 19 | .Build() 20 | .Get() 21 | 22 | let botDef = ChatBotDef.create prompt Models.Gpt_4 23 | 24 | ChatBot.create settings.OpenAi botDef 25 | 26 | let test () = 27 | let history = [] 28 | ChatBot.complete history chatBot 29 | |> printfn "%s" 30 | -------------------------------------------------------------------------------- /RedditChatBot/Startup.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open System 4 | 5 | open Microsoft.Azure.Functions.Extensions.DependencyInjection 6 | open Microsoft.Extensions.Configuration 7 | 8 | (* https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-azure-functions-csharp *) 9 | 10 | /// Incorporates the Azure app configuration service into our Azure 11 | /// functions app. 12 | type Startup() = 13 | inherit FunctionsStartup() 14 | 15 | /// Connects to Azure app configuration. 16 | override _.ConfigureAppConfiguration(builder) = 17 | let cs = Environment.GetEnvironmentVariable("ConnectionString") 18 | if isNull cs then 19 | failwith "ConnectionString environment variable must be set and point to an Azure app configuration" 20 | builder 21 | .ConfigurationBuilder 22 | .AddAzureAppConfiguration(cs) 23 | |> ignore 24 | 25 | override _.Configure(_ : IFunctionsHostBuilder) = 26 | () 27 | 28 | [)>] 29 | do () 30 | -------------------------------------------------------------------------------- /TypicalChatBot/TypicalChatBot.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open Microsoft.Azure.WebJobs 4 | open Microsoft.Extensions.Configuration 5 | open Microsoft.Extensions.Logging 6 | 7 | open OpenAI.ObjectModels 8 | 9 | /// Azure function type for dependency injection. 10 | type TypicalChatBot(config : IConfiguration) = 11 | 12 | /// System-level prompt. 13 | let prompt = 14 | "You are a typical Reddit user. Respond to the last user in the following thread. If you receive a comment that seems strange or irrelevant, do your best to play along." 15 | 16 | /// Creates a bot. 17 | let createBot log = 18 | let settings = config.Get() 19 | let redditBotDef = 20 | RedditBotDef.create 21 | "typical-chat-bot" 22 | "1.0" 23 | "brianberns" 24 | let chatBotDef = 25 | ChatBotDef.create prompt Models.ChatGpt3_5Turbo 26 | let bot = Bot.create settings redditBotDef chatBotDef log 27 | log.LogInformation("Bot initialized") 28 | bot 29 | 30 | /// Monitors unread messages. 31 | [] 32 | member _.MonitorUnreadMessages( 33 | [] // every minute 34 | timer : TimerInfo, 35 | log : ILogger) = 36 | use bot = createBot log 37 | Bot.monitorUnreadMessages bot 38 | |> ignore 39 | -------------------------------------------------------------------------------- /ChatTest/RedditTest.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open System 4 | open Microsoft.Extensions.Configuration 5 | 6 | module RedditTest = 7 | 8 | let redditBot = 9 | 10 | let settings = 11 | let cs = 12 | Environment.GetEnvironmentVariable("ConnectionString") 13 | ConfigurationBuilder() 14 | .AddAzureAppConfiguration(cs) 15 | .Build() 16 | .Get() 17 | let botDef = 18 | RedditBotDef.create 19 | "friendly-chat-bot" 20 | "1.0" 21 | "brianberns" 22 | RedditBot.create settings.Reddit botDef 23 | 24 | let rec getPost fullname = 25 | match Thing.getType fullname with 26 | | ThingType.Post -> 27 | redditBot.Client.Post(fullname).About() 28 | | ThingType.Comment -> 29 | let comment = 30 | redditBot.Client.Comment(fullname).About() 31 | getPost comment.ParentFullname 32 | | _ -> failwith "Unexpected" 33 | 34 | let test () = 35 | let messages = 36 | RedditBot.getAllUnreadMessages redditBot 37 | printfn $"{messages.Length} unread message(s)" 38 | for message in messages do 39 | let post = getPost message.ParentId 40 | printfn "" 41 | printfn "**********************************************" 42 | printfn "" 43 | printfn $"{message.Body}" 44 | printfn "" 45 | printfn $"- Post: {post.Title}" 46 | printfn $"- Author: {message.Author}" 47 | printfn $"- Time: {message.CreatedUTC.ToLocalTime()}" 48 | printfn $"- Score: {message.Score}" 49 | printfn $"- Link: https://www.reddit.com{message.Context}" 50 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /RedditChatBot.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.33424.131 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "RedditChatBot", "RedditChatBot\RedditChatBot.fsproj", "{211A62DC-349D-4CEB-9DF2-FDBC0E5348E8}" 7 | EndProject 8 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FriendlyChatBot", "FriendlyChatBot\FriendlyChatBot.fsproj", "{C9B0A221-A9F4-42F3-8B3A-12C42FB43B9A}" 9 | EndProject 10 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TypicalChatBot", "TypicalChatBot\TypicalChatBot.fsproj", "{699575DF-077C-40EE-8554-76D3D73D1B9D}" 11 | EndProject 12 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "ChatTest", "ChatTest\ChatTest.fsproj", "{925B070C-50FE-494B-BA85-8CF8A24B0A8F}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1C775845-5CD6-462C-AAE9-3B3A59A6F828}" 15 | ProjectSection(SolutionItems) = preProject 16 | Readme.md = Readme.md 17 | EndProjectSection 18 | EndProject 19 | Global 20 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 21 | Debug|Any CPU = Debug|Any CPU 22 | Release|Any CPU = Release|Any CPU 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {211A62DC-349D-4CEB-9DF2-FDBC0E5348E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {211A62DC-349D-4CEB-9DF2-FDBC0E5348E8}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {211A62DC-349D-4CEB-9DF2-FDBC0E5348E8}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {211A62DC-349D-4CEB-9DF2-FDBC0E5348E8}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {C9B0A221-A9F4-42F3-8B3A-12C42FB43B9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {C9B0A221-A9F4-42F3-8B3A-12C42FB43B9A}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {C9B0A221-A9F4-42F3-8B3A-12C42FB43B9A}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {C9B0A221-A9F4-42F3-8B3A-12C42FB43B9A}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {699575DF-077C-40EE-8554-76D3D73D1B9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {699575DF-077C-40EE-8554-76D3D73D1B9D}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {699575DF-077C-40EE-8554-76D3D73D1B9D}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {699575DF-077C-40EE-8554-76D3D73D1B9D}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {925B070C-50FE-494B-BA85-8CF8A24B0A8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {925B070C-50FE-494B-BA85-8CF8A24B0A8F}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {925B070C-50FE-494B-BA85-8CF8A24B0A8F}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {925B070C-50FE-494B-BA85-8CF8A24B0A8F}.Release|Any CPU.Build.0 = Release|Any CPU 41 | EndGlobalSection 42 | GlobalSection(SolutionProperties) = preSolution 43 | HideSolutionNode = FALSE 44 | EndGlobalSection 45 | GlobalSection(ExtensibilityGlobals) = postSolution 46 | SolutionGuid = {8487DC58-1227-4397-AC08-467236CE5DA1} 47 | EndGlobalSection 48 | EndGlobal 49 | -------------------------------------------------------------------------------- /RedditChatBot/Chat.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open System 4 | 5 | open OpenAI 6 | open OpenAI.Managers 7 | open OpenAI.ObjectModels.RequestModels 8 | 9 | /// OpenAI settings associated with this app. Don't share these! 10 | [] // https://github.com/dotnet/runtime/issues/77677 11 | type OpenAiSettings = 12 | { 13 | ApiKey : string 14 | } 15 | 16 | /// Chat bot definition. 17 | type ChatBotDef = 18 | { 19 | /// System-level prompt. 20 | Prompt : string 21 | 22 | /// GPT model. 23 | Model : string 24 | } 25 | 26 | module ChatBotDef = 27 | 28 | /// Creates a chat bot definition. 29 | let create prompt model = 30 | { 31 | Prompt = prompt 32 | Model = model 33 | } 34 | 35 | /// Message roles. 36 | [] 37 | type Role = 38 | 39 | /// User query. 40 | | User 41 | 42 | /// ChatGPT response. 43 | | Assistant 44 | 45 | /// F# chat message type. 46 | type FChatMessage = 47 | { 48 | /// Role of message originator. 49 | Role : Role 50 | 51 | /// Message content. 52 | Content : string 53 | } 54 | 55 | module FChatMessage = 56 | 57 | /// Creates a chat message. 58 | let create role content = 59 | { 60 | Role = role 61 | Content = content 62 | } 63 | 64 | /// Converts a chat message to native format. 65 | let toNative msg = 66 | let create = 67 | match msg.Role with 68 | | Role.User -> ChatMessage.FromUser 69 | | Role.Assistant -> ChatMessage.FromAssistant 70 | create msg.Content 71 | 72 | /// Chonological sequence of chat messages. 73 | type ChatHistory = List 74 | 75 | /// A chat bot. 76 | type ChatBot = 77 | { 78 | /// Bot definition. 79 | BotDef : ChatBotDef 80 | 81 | /// OpenAI API client. 82 | Client : OpenAIService 83 | } 84 | 85 | member bot.Dispose() = bot.Client.Dispose() 86 | 87 | interface IDisposable with 88 | member bot.Dispose() = bot.Dispose() 89 | 90 | module ChatBot = 91 | 92 | /// Creates a chat bot. 93 | let create settings botDef = 94 | let client = 95 | let options = OpenAiOptions(ApiKey = settings.ApiKey) 96 | new OpenAIService(options) 97 | { 98 | BotDef = botDef 99 | Client = client 100 | } 101 | 102 | /// Gets a response to the given chat history. 103 | let complete (history : ChatHistory) bot = 104 | 105 | // build the request 106 | let req = 107 | let messages = 108 | [| 109 | ChatMessage.FromSystem bot.BotDef.Prompt 110 | for msg in history do 111 | FChatMessage.toNative msg 112 | |] 113 | ChatCompletionCreateRequest( 114 | Messages = messages, 115 | Model = bot.BotDef.Model, 116 | Temperature = 1.0f) 117 | 118 | // wait for the response (single-threaded, no point in getting fancy) 119 | let resp = 120 | bot.Client.ChatCompletion 121 | .CreateCompletion(req) 122 | .Result 123 | if resp.Successful then 124 | let choice = Seq.exactlyOne resp.Choices 125 | choice.Message.Content.Trim() // some responses start with whitespace - why? 126 | elif resp.Error.Code = "context_length_exceeded" then // e.g. "This model's maximum context length is 4097 tokens. However, your messages resulted in 4174 tokens. Please reduce the length of the messages." 127 | "Sorry, we've exceeded ChatGPT's maximum context length. Please start a new thread." 128 | else 129 | failwith $"{resp.Error.Message}" 130 | -------------------------------------------------------------------------------- /RedditChatBot/Reddit.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open Reddit 4 | 5 | (* 6 | * To create a Reddit bot: 7 | * 8 | * - Use https://ssl.reddit.com/prefs/apps/ to create app ID and 9 | * secret. 10 | * 11 | * - Use https://not-an-aardvark.github.io/reddit-oauth-helper/ 12 | * to create refresh token. Choose desired scopes and make 13 | * permanent. 14 | *) 15 | 16 | /// Reddit settings associated with this app. Don't share these! 17 | [] // https://github.com/dotnet/runtime/issues/77677 18 | type RedditSettings = 19 | { 20 | /// App unique identifier. 21 | ApiKey : string 22 | 23 | /// App secret. 24 | AppSecret : string 25 | 26 | /// App authentication refresh token. 27 | RefreshToken : string 28 | } 29 | 30 | /// Reddit bot definition. 31 | type RedditBotDef = 32 | { 33 | /// Bot's Reddit account name. 34 | BotName : string 35 | 36 | /// Bot version. E.g. "1.0". 37 | Version : string 38 | 39 | /// Bot author's Reddit account name. 40 | AuthorName : string 41 | } 42 | 43 | module RedditBotDef = 44 | 45 | /// Creates a Reddit bot definition. 46 | let create botName version authorName = 47 | { 48 | BotName = botName 49 | Version = version 50 | AuthorName = authorName 51 | } 52 | 53 | /// Bot's user agent string, in format suggested by Reddit (more 54 | /// or less). 55 | let toUserAgent botDef = 56 | $"{botDef.BotName}:v{botDef.Version} (by /u/{botDef.AuthorName})" 57 | 58 | /// A Reddit bot. 59 | type RedditBot = 60 | { 61 | /// Bot definition. 62 | BotDef : RedditBotDef 63 | 64 | /// Reddit API client. 65 | Client : RedditClient 66 | } 67 | 68 | module RedditBot = 69 | 70 | /// Creates a Reddit bot. 71 | let create settings botDef = 72 | { 73 | BotDef = botDef 74 | Client = 75 | RedditClient( 76 | appId = settings.ApiKey, 77 | refreshToken = settings.RefreshToken, 78 | appSecret = settings.AppSecret, 79 | userAgent = RedditBotDef.toUserAgent botDef) 80 | } 81 | 82 | /// Fetches all of the bot's unread messages. 83 | let getAllUnreadMessages bot = 84 | 85 | let rec loop count after = 86 | 87 | // get a batch of messages 88 | let messages = 89 | bot.Client.Account.Messages 90 | .GetMessagesUnread( 91 | limit = 100, 92 | after = after, 93 | count = count) 94 | 95 | seq { 96 | yield! messages 97 | 98 | // try to get more messages? 99 | if messages.Count > 0 then 100 | let count' = messages.Count + count 101 | let after' = (Seq.last messages).Name 102 | yield! loop count' after' 103 | } 104 | 105 | loop 0 "" 106 | |> Seq.sortBy (fun message -> 107 | -message.Score, message.CreatedUTC) 108 | |> Seq.toArray 109 | 110 | /// Type of a reddit thing. 111 | [] 112 | type ThingType = 113 | 114 | /// A post (aka "link"). 115 | | Post 116 | 117 | /// A comment. 118 | | Comment 119 | 120 | /// Some other thing. 121 | | Other 122 | 123 | module Thing = 124 | 125 | /// Answers the type of the thing with the given full name. 126 | (* 127 | t1_: Comment 128 | t2_: Account 129 | t3_: Link 130 | t4_: Message 131 | t5_: Subreddit 132 | t6_: Award 133 | *) 134 | let getType (fullname : string) = 135 | match fullname.Substring(0, 3) with 136 | | "t1_" -> ThingType.Comment 137 | | "t3_" -> ThingType.Post 138 | | _ -> ThingType.Other 139 | -------------------------------------------------------------------------------- /FriendlyChatBot/FriendlyChatBot.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open System 4 | open System.Text.Json 5 | 6 | open Microsoft.Azure.WebJobs 7 | open Microsoft.Extensions.Configuration 8 | open Microsoft.Extensions.Logging 9 | 10 | open OpenAI.ObjectModels 11 | 12 | module Post = 13 | 14 | /// Gets a random seed. 15 | let getSeed (log : ILogger) = 16 | let seed = DateTime.Now.Ticks % 1000000L 17 | log.LogWarning($"Seed: {seed}") 18 | seed 19 | 20 | /// # of retry attempts. 21 | let numTries = 3 22 | 23 | /// Submits a post 24 | let submit subredditName title body bot = 25 | Bot.tryN 3 (fun iTry -> 26 | try 27 | let post = 28 | let subreddit = 29 | bot.RedditBot.Client 30 | .Subreddit(subredditName : string) 31 | .About() 32 | subreddit 33 | .SelfPost(title, body) 34 | .Submit() 35 | bot.Log.LogWarning($"Post submitted: {title}") // use warning for emphasis in log 36 | true, Some post 37 | 38 | with exn -> 39 | bot.Log.LogError($"Error on post attempt #{iTry+1} of {numTries}") 40 | Bot.handleException exn bot.Log 41 | false, None) 42 | 43 | module RandomThought = 44 | 45 | /// Random thought prompt. 46 | let getPrompt log = 47 | let seed = Post.getSeed log 48 | $"Using random seed {seed}, write a one-sentence thought to post on Reddit. Avoid politics and religion. The thought should be in the form of a statement, not a question. Output as JSON: {{ \"Thought\" : string }}." 49 | 50 | /// Structure of a completion. Must be public for serialization. 51 | type Completion = { Thought : string } 52 | 53 | /// Tries to post a random thought. 54 | let tryPost bot = 55 | Bot.tryN Post.numTries (fun _ -> 56 | let json = ChatBot.complete [] bot.ChatBot 57 | try 58 | let completion = 59 | JsonSerializer.Deserialize(json) 60 | let thought = completion.Thought.ToLower() 61 | if thought.Contains("random") || thought.Contains("seed") then 62 | bot.Log.LogError($"Not a random thought: {completion.Thought}") 63 | false, None 64 | else 65 | true, Post.submit "RandomThoughts" completion.Thought "" bot 66 | with exn -> 67 | bot.Log.LogError(json) 68 | Bot.handleException exn bot.Log 69 | false, None) 70 | 71 | module SixWordStory = 72 | 73 | /// Num-word story prompt. 74 | let getPrompt log = 75 | let seed = Post.getSeed log 76 | $"Using random seed {seed}, write a six-word story to post on Reddit. Output as JSON: {{ \"Story\" : string }}." 77 | 78 | /// Structure of a completion. Must be public for serialization. 79 | type Completion = { Story : string } 80 | 81 | /// Tries to post a six-word story. 82 | let tryPost bot = 83 | Bot.tryN Post.numTries (fun _ -> 84 | let json = 85 | ChatBot.complete [] bot.ChatBot 86 | try 87 | let completion = 88 | JsonSerializer.Deserialize(json) 89 | if completion.Story.Split(' ').Length = 6 then 90 | true, Post.submit $"sixwordstories" completion.Story "" bot 91 | else 92 | bot.Log.LogError($"Not a six-word story: {completion.Story}") 93 | false, None 94 | with exn -> 95 | bot.Log.LogError(json) 96 | Bot.handleException exn bot.Log 97 | false, None) 98 | 99 | /// Azure function type for dependency injection. 100 | type FriendlyChatBot(config : IConfiguration) = 101 | 102 | /// Reply prompt. 103 | let replyPrompt = 104 | "You are a friendly Reddit user. Respond to the last user in the following thread. If you receive a comment that seems strange or irrelevant, do your best to play along." 105 | 106 | /// Creates a bot. 107 | let createBot prompt log = 108 | let settings = config.Get() 109 | let redditBotDef = 110 | RedditBotDef.create 111 | "friendly-chat-bot" 112 | "1.0" 113 | "brianberns" 114 | let chatBotDef = 115 | ChatBotDef.create prompt Models.Gpt_4 116 | let bot = Bot.create settings redditBotDef chatBotDef log 117 | log.LogInformation("Bot initialized") 118 | bot 119 | 120 | /// Monitors unread messages. 121 | [] 122 | member _.MonitorUnreadMessages( 123 | [] // every 30 minutes at :00 and :30 after the hour 124 | timer : TimerInfo, 125 | log : ILogger) = 126 | use bot = createBot replyPrompt log 127 | Bot.monitorUnreadMessages bot 128 | |> ignore 129 | 130 | /// Posts a random thought. 131 | [] 132 | member _.PostRandomThought( 133 | [] // four times a day at 00:15, 06:15, 12:15, and 18:15 134 | timer : TimerInfo, 135 | log : ILogger) = 136 | use bot = 137 | let prompt = RandomThought.getPrompt log 138 | createBot prompt log 139 | RandomThought.tryPost bot 140 | |> ignore 141 | 142 | /// Posts a six-word story. 143 | [] 144 | member _.PostSixWordStory( 145 | [] // once a day at 23:15 146 | timer : TimerInfo, 147 | log : ILogger) = 148 | use bot = 149 | let prompt = SixWordStory.getPrompt log 150 | createBot prompt log 151 | SixWordStory.tryPost bot 152 | |> ignore 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd 364 | 365 | # Azure configuration information 366 | **/Properties/launchSettings.json 367 | **/Properties/serviceDependencies.* 368 | **/Properties/ServiceDependencies/* 369 | -------------------------------------------------------------------------------- /RedditChatBot/Bot.fs: -------------------------------------------------------------------------------- 1 | namespace RedditChatBot 2 | 3 | open System 4 | open System.Threading 5 | 6 | open Microsoft.Extensions.Logging 7 | 8 | open Reddit.Controllers 9 | 10 | /// Application settings. The fields of this type should 11 | /// correspond to the keys of the Azure app configuration. 12 | /// E.g. Key "OpenAi:ApiKey" corresponds to field 13 | /// AppSettings.OpenAi.ApiKey. 14 | [] // https://github.com/dotnet/runtime/issues/77677 15 | type AppSettings = 16 | { 17 | /// Reddit settings. 18 | Reddit : RedditSettings 19 | 20 | /// OpenAI settings. 21 | OpenAi : OpenAiSettings 22 | } 23 | 24 | /// A Reddit chat bot. 25 | type Bot = 26 | { 27 | /// Reddit bot. 28 | RedditBot : RedditBot 29 | 30 | /// Chat bot. 31 | ChatBot : ChatBot 32 | 33 | /// Maximum number of bot comments in a nested thread. 34 | MaxCommentDepth : int 35 | 36 | /// Logger. 37 | Log : ILogger 38 | } 39 | 40 | member bot.Dispose() = bot.ChatBot.Dispose() 41 | 42 | interface IDisposable with 43 | member bot.Dispose() = bot.Dispose() 44 | 45 | module Bot = 46 | 47 | (* 48 | * The Reddit.NET API presents a very leaky abstraction. As a 49 | * general rule, we call Post.About() and Comment.Info() 50 | * defensively to make sure we have the full details of a thing. 51 | * (Unfortunately, Comment.About() seems to have a race condition.) 52 | *) 53 | 54 | /// Creates a bot with the given values. 55 | let create settings redditBotDef chatBotDef log = 56 | 57 | // connect to Reddit 58 | let redditBot = 59 | RedditBot.create settings.Reddit redditBotDef 60 | 61 | // connect to chat service 62 | let chatBot = 63 | ChatBot.create settings.OpenAi chatBotDef 64 | 65 | { 66 | RedditBot = redditBot 67 | ChatBot = chatBot 68 | MaxCommentDepth = 4 69 | Log = log 70 | } 71 | 72 | /// Determines the role of the given author. 73 | let private getRole author bot = 74 | if author = bot.RedditBot.BotDef.BotName then 75 | Role.Assistant 76 | else Role.User 77 | 78 | /// Does the given text contain any content? 79 | let private hasContent = 80 | String.IsNullOrWhiteSpace >> not 81 | 82 | /// Says the given text as the given author. 83 | let private say author text = 84 | assert(hasContent author) 85 | assert(hasContent text) 86 | $"{author} says {text}" 87 | 88 | /// Converts the given text to a chat message based on its 89 | /// author's role. 90 | let private createChatMessage author text bot = 91 | let role = getRole author bot 92 | let content = 93 | match role with 94 | | Role.User -> say author text 95 | | _ -> text 96 | FChatMessage.create role content 97 | 98 | /// Subreddit details. 99 | type private SubredditDetail = 100 | { 101 | /// Bot can post autonomously? 102 | AutonomousPost : bool 103 | 104 | /// Comment prompt, if any. 105 | CommentPromptOpt : Option 106 | 107 | /// Reply to /u/AutoModerator? 108 | ReplyToAutoModerator : bool 109 | } 110 | 111 | /// Subreddit detail map. 112 | type private SubredditDetailMap = 113 | Map 114 | 115 | module private SubredditDetailMap = 116 | 117 | /// Bot can post autonomously? 118 | let isAutonomousPost subreddit (map : SubredditDetailMap) = 119 | map 120 | |> Map.tryFind subreddit 121 | |> Option.map (fun detail -> 122 | detail.AutonomousPost) 123 | |> Option.defaultValue false 124 | 125 | /// Gets comment prompt for the given subreddit, if any. 126 | let tryGetCommentPrompt subreddit (map : SubredditDetailMap) = 127 | map 128 | |> Map.tryFind subreddit 129 | |> Option.bind (fun detail -> 130 | detail.CommentPromptOpt) 131 | 132 | /// Bot should reply to comment? 133 | let shouldReply (comment : Comment) (map : SubredditDetailMap) = 134 | if comment.Author = "AutoModerator" then 135 | map 136 | |> Map.tryFind comment.Subreddit 137 | |> Option.map (fun detail -> 138 | detail.ReplyToAutoModerator) 139 | |> Option.defaultValue true 140 | else true 141 | 142 | /// Specifies a comment prompt. 143 | let private withCommentPrompt prompt detail = 144 | { 145 | detail with 146 | CommentPromptOpt = Some prompt 147 | } 148 | 149 | /// Subreddit details. 150 | let private subredditDetailMap : SubredditDetailMap = 151 | let autonomous = 152 | { 153 | AutonomousPost = true 154 | CommentPromptOpt = None 155 | ReplyToAutoModerator = true 156 | } 157 | Map [ 158 | "RandomThoughts", 159 | { autonomous with ReplyToAutoModerator = false } 160 | "self", autonomous 161 | "sixwordstories", 162 | { 163 | autonomous with 164 | CommentPromptOpt = 165 | Some "It is customary, but not mandatory, to write a six-word response." 166 | } 167 | "testingground4bots", autonomous 168 | ] 169 | 170 | /// Creates messages describing the given subreddit. 171 | let private getSubredditMessages subreddit = 172 | let createMsg = FChatMessage.create Role.User 173 | seq { 174 | // subreddit name 175 | yield createMsg $"Subreddit: {subreddit}" 176 | 177 | // comment prompt? 178 | match SubredditDetailMap.tryGetCommentPrompt subreddit subredditDetailMap with 179 | | Some prompt -> yield createMsg prompt 180 | | None -> () 181 | } 182 | 183 | /// Converts the given post's content into a message. 184 | let private getPostMessage (post : SelfPost) bot = 185 | let content = 186 | if hasContent post.SelfText then 187 | post.Title + Environment.NewLine + post.SelfText 188 | else 189 | post.Title 190 | createChatMessage post.Author content bot 191 | 192 | /// Gets ancestor comments in chronological order. 193 | let private getHistory comment bot : ChatHistory = 194 | 195 | let rec loop (comment : Comment) = 196 | let comment = comment.Info() 197 | [ 198 | // this comment 199 | let body = comment.Body.Trim() 200 | let botName = bot.RedditBot.BotDef.BotName 201 | if body <> $"/u/{botName}" && body <> $"u/{botName}" then // skip summons if there's no other content 202 | yield createChatMessage 203 | comment.Author body bot 204 | 205 | // ancestors 206 | match Thing.getType comment.ParentFullname with 207 | 208 | | ThingType.Comment -> 209 | let parent = 210 | bot.RedditBot.Client 211 | .Comment(comment.ParentFullname) 212 | yield! loop parent 213 | 214 | | ThingType.Post -> 215 | let post = 216 | bot.RedditBot.Client 217 | .SelfPost(comment.ParentFullname) 218 | .About() 219 | let isUserPost = getRole post.Author bot = Role.User 220 | let isAutonomousSubreddit = 221 | SubredditDetailMap.isAutonomousPost 222 | post.Subreddit 223 | subredditDetailMap 224 | 225 | if isUserPost || isAutonomousSubreddit then 226 | yield! List.rev [ // will be unreversed at the end 227 | yield! getSubredditMessages post.Subreddit 228 | yield getPostMessage post bot 229 | ] 230 | 231 | | _ -> () 232 | ] 233 | 234 | loop comment 235 | |> List.rev 236 | 237 | /// Result of attempting submitting a reply comment. 238 | [] 239 | type private CommentResult = 240 | 241 | /// Reply submitted successfully. 242 | | Replied 243 | 244 | /// No need to reply. 245 | | Ignored 246 | 247 | /// Error while attempting to submit a reply. 248 | | Error 249 | 250 | /// Replies to the given comment, if necessary. 251 | let private submitReply (comment : Comment) bot = 252 | 253 | // ensure we have full, current details 254 | let comment = comment.Info() 255 | 256 | // don't reply to bot's own comments 257 | if getRole comment.Author bot <> Role.Assistant 258 | && comment.Body <> "[deleted]" then // no better way to check this? 259 | 260 | // has bot already replied to this comment? 261 | let handled = 262 | comment.Replies 263 | |> Seq.exists (fun child -> 264 | getRole child.Author bot = Role.Assistant) 265 | 266 | // should reply to comment? 267 | let shouldReply = 268 | SubredditDetailMap.shouldReply comment subredditDetailMap 269 | 270 | // begin to create a reply? 271 | if handled || not shouldReply then 272 | CommentResult.Ignored 273 | else 274 | // avoid deeply nested threads 275 | let history = getHistory comment bot 276 | let nBot = 277 | history 278 | |> Seq.where (fun msg -> 279 | msg.Role = Role.Assistant) 280 | |> Seq.length 281 | if nBot < bot.MaxCommentDepth then 282 | 283 | // obtain chat completion 284 | let completion = 285 | ChatBot.complete history bot.ChatBot 286 | 287 | // submit reply 288 | let body = 289 | if completion = "" then "#" // Reddit requires a non-empty string 290 | else completion 291 | comment.Reply(body) |> ignore 292 | bot.Log.LogWarning($"Comment submitted: {body}") // use warning for emphasis in log 293 | CommentResult.Replied 294 | 295 | else CommentResult.Ignored 296 | 297 | else CommentResult.Ignored 298 | 299 | /// Runs the given function repeatedly until it succeeds or 300 | /// we run out of tries. 301 | let tryN numTries f = 302 | 303 | let rec loop numTriesRemaining = 304 | let success, value = 305 | let iTry = numTries - numTriesRemaining 306 | f iTry 307 | if not success && numTriesRemaining > 1 then 308 | loop (numTriesRemaining - 1) 309 | else value 310 | 311 | loop numTries 312 | 313 | /// Handles the given exception. 314 | let handleException exn (log : ILogger) = 315 | 316 | let rec loop (exn : exn) = 317 | match exn with 318 | | :? AggregateException as aggExn -> 319 | for innerExn in aggExn.InnerExceptions do 320 | loop innerExn 321 | | _ -> 322 | if isNull exn.InnerException then 323 | log.LogError(exn, exn.Message) 324 | else 325 | loop exn.InnerException 326 | 327 | loop exn 328 | Thread.Sleep(10000) // wait for problem to clear up, hopefully 329 | 330 | /// Replies safely to the given comment, if necessary. 331 | let private submitReplySafe comment bot = 332 | let numTries = 3 333 | tryN numTries (fun iTry -> 334 | try 335 | true, submitReply comment bot 336 | with exn -> 337 | bot.Log.LogError($"Error on reply attempt #{iTry+1} of {numTries}") 338 | handleException exn bot.Log 339 | false, CommentResult.Error) 340 | 341 | /// Monitors unread messages. 342 | let monitorUnreadMessages bot = 343 | 344 | // get candidate messages that we might reply to 345 | let messages = RedditBot.getAllUnreadMessages bot.RedditBot 346 | let logger = 347 | if messages.Length > 0 then bot.Log.LogWarning // use warning for emphasis in log 348 | else bot.Log.LogInformation 349 | logger $"{messages.Length} unread message(s) found" 350 | 351 | // reply to no more than one message 352 | messages 353 | |> Seq.map (fun message -> message.Name) // fullname of the thing the message is about 354 | |> Seq.tryFind (fun fullname -> 355 | match Thing.getType fullname with 356 | | ThingType.Comment -> 357 | 358 | // attempt to reply to message 359 | let comment = 360 | bot.RedditBot.Client.Comment(fullname) 361 | let result = submitReplySafe comment bot 362 | 363 | // mark message read? 364 | if result <> CommentResult.Error then 365 | bot.RedditBot.Client.Account.Messages 366 | .ReadMessage(fullname) // this is weird, but apparently correct 367 | 368 | // stop looking? 369 | result = CommentResult.Replied 370 | | _ -> false) 371 | --------------------------------------------------------------------------------