├── 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 |
--------------------------------------------------------------------------------