Skip to content

Commit

Permalink
Add repro requester service (#196)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim <[email protected]>
  • Loading branch information
IMax153 and tim-smart authored Oct 31, 2024
1 parent 583b608 commit f0d9c6f
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 162 deletions.
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,27 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@effect/ai": "^0.2.9",
"@effect/ai-openai": "^0.2.10",
"@effect/experimental": "^0.30.10",
"@effect/ai": "^0.2.12",
"@effect/ai-openai": "^0.2.13",
"@effect/experimental": "^0.30.13",
"@effect/language-service": "^0.2.0",
"@effect/opentelemetry": "^0.39.4",
"@effect/platform": "^0.69.9",
"@effect/platform-node": "^0.64.10",
"@effect/opentelemetry": "^0.39.7",
"@effect/platform": "^0.69.12",
"@effect/platform-node": "^0.64.13",
"@octokit/types": "^13.6.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.54.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.54.0",
"@opentelemetry/sdk-metrics": "^1.27.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@opentelemetry/sdk-trace-node": "^1.27.0",
"@types/node": "^22.8.1",
"@types/node": "^22.8.6",
"dfx": "^0.106.0",
"effect": "^3.10.4",
"effect": "^3.10.7",
"html-entities": "^2.5.2",
"octokit": "^4.0.2",
"prettier": "^3.3.3",
"tsup": "^8.3.5",
"tsx": "^4.19.1",
"tsx": "^4.19.2",
"typescript": "5.6.3"
}
}
196 changes: 98 additions & 98 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

48 changes: 47 additions & 1 deletion src/Ai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AiInput, Completions } from "@effect/ai"
import { AiInput, AiRole, Completions } from "@effect/ai"
import {
OpenAiClient,
OpenAiCompletions,
Expand All @@ -8,6 +8,8 @@ import { NodeHttpClient } from "@effect/platform-node"
import { Chunk, Config, Effect, Layer, pipe } from "effect"
import * as Str from "./utils/String.js"
import { Tokenizer } from "@effect/ai/Tokenizer"
import { Discord, DiscordREST } from "dfx"
import { DiscordApplication } from "./Discord.js"

export const OpenAiLive = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY"),
Expand All @@ -22,9 +24,52 @@ export const CompletionsLive = OpenAiCompletions.layer({

export class AiHelpers extends Effect.Service<AiHelpers>()("app/AiHelpers", {
effect: Effect.gen(function* () {
const rest = yield* DiscordREST
const completions = yield* Completions.Completions
const tokenizer = yield* Tokenizer

const application = yield* DiscordApplication
const botUser = application.bot!

const generateAiInput = (
thread: Discord.Channel,
message?: Discord.MessageCreateEvent,
) =>
pipe(
Effect.all(
{
openingMessage: rest.getChannelMessage(thread.parent_id!, thread.id)
.json,
messages: rest.getChannelMessages(thread.id, {
before: message?.id,
limit: 10,
}).json,
},
{ concurrency: "unbounded" },
),
Effect.map(({ openingMessage, messages }) =>
AiInput.make(
[...(message ? [message] : []), ...messages, openingMessage]
.reverse()
.filter(
msg =>
msg.type === Discord.MessageType.DEFAULT ||
msg.type === Discord.MessageType.REPLY,
)
.filter(msg => msg.content.trim().length > 0)
.map(
(msg): AiInput.Message =>
AiInput.Message.fromInput(
msg.content,
msg.author.id === botUser.id
? AiRole.model
: AiRole.userWithName(msg.author.username),
),
),
),
),
)

const generateTitle = (prompt: string) =>
completions.create(prompt).pipe(
AiInput.provideSystem(
Expand Down Expand Up @@ -70,6 +115,7 @@ The title of this chat is "${title}".`,
generateTitle,
generateDocs,
generateSummary,
generateAiInput,
} as const
}),
dependencies: [CompletionsLive],
Expand Down
18 changes: 15 additions & 3 deletions src/Discord.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { NodeHttpClient, NodeSocket } from "@effect/platform-node"
import { DiscordConfig, Intents } from "dfx"
import { DiscordConfig, DiscordREST, Intents } from "dfx"
import { DiscordIxLive } from "dfx/gateway"
import { Config, Layer } from "effect"
import { Config, Effect, Layer } from "effect"

export const DiscordLive = DiscordIxLive.pipe(
const DiscordLayer = DiscordIxLive.pipe(
Layer.provideMerge(NodeHttpClient.layerUndici),
Layer.provide(NodeSocket.layerWebSocketConstructor),
Layer.provide(
Expand All @@ -17,3 +17,15 @@ export const DiscordLive = DiscordIxLive.pipe(
}),
),
)

export class DiscordApplication extends Effect.Service<DiscordApplication>()(
"app/DiscordApplication",
{
effect: DiscordREST.pipe(
Effect.flatMap(_ => _.getCurrentBotApplicationInformation().json),
),
dependencies: [DiscordLayer],
},
) {}

export const DiscordLive = Layer.merge(DiscordLayer, DiscordApplication.Default)
8 changes: 4 additions & 4 deletions src/Issueifier.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AiInput, AiRole } from "@effect/ai"
import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordLive } from "bot/Discord"
import { DiscordApplication, DiscordLive } from "bot/Discord"
import { Github } from "bot/Github"
import { Messages } from "bot/Messages"
import { Discord, DiscordREST, Ix } from "dfx"
Expand Down Expand Up @@ -39,7 +39,7 @@ const make = Effect.gen(function* () {

const createGithubIssue = github.wrap(_ => _.issues.create)

const application = yield* rest.getCurrentBotApplicationInformation().json
const application = yield* DiscordApplication

const createIssue = (channel: Discord.Channel, repo: GithubRepo) =>
pipe(
Expand Down Expand Up @@ -172,9 +172,9 @@ https://discord.com/channels/${channel.guild_id}/${channel.id}
})

export const IssueifierLive = Layer.scopedDiscard(make).pipe(
Layer.provide(DiscordLive),
Layer.provide(AiHelpers.Default),
Layer.provide(ChannelsCache.Default),
Layer.provide(DiscordLive),
Layer.provide(Messages.Default),
Layer.provide(AiHelpers.Default),
Layer.provide(Github.Default),
)
50 changes: 7 additions & 43 deletions src/Mentions.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,31 @@
import { AiInput, AiRole, Completions } from "@effect/ai"
import { AiHelpers, CompletionsLive } from "bot/Ai"
import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordLive } from "bot/Discord"
import { DiscordApplication, DiscordLive } from "bot/Discord"
import * as Str from "bot/utils/String"
import { Discord, DiscordREST } from "dfx"
import { DiscordGateway } from "dfx/DiscordGateway"
import { Data, Effect, Layer, pipe } from "effect"
import { CompletionsLive } from "./Ai.js"

class NonEligibleMessage extends Data.TaggedError("NonEligibleMessage")<{
readonly reason: "non-mentioned" | "not-in-thread" | "from-bot"
}> {}

const make = Effect.gen(function* () {
const ai = yield* AiHelpers
const rest = yield* DiscordREST
const gateway = yield* DiscordGateway
const channels = yield* ChannelsCache
const completions = yield* Completions.Completions

const botUser = yield* rest.getCurrentUser().json

const generateAiInput = (
thread: Discord.Channel,
message: Discord.MessageCreateEvent,
) =>
pipe(
Effect.all(
{
openingMessage: rest.getChannelMessage(thread.parent_id!, thread.id)
.json,
messages: rest.getChannelMessages(message.channel_id, {
before: message.id,
limit: 10,
}).json,
},
{ concurrency: "unbounded" },
),
Effect.map(({ openingMessage, messages }) =>
AiInput.make(
[message, ...messages, openingMessage]
.reverse()
.filter(
msg =>
msg.type === Discord.MessageType.DEFAULT ||
msg.type === Discord.MessageType.REPLY,
)
.filter(msg => msg.content.trim().length > 0)
.map(
(msg): AiInput.Message =>
AiInput.Message.fromInput(
msg.content,
msg.author.id === botUser.id
? AiRole.model
: AiRole.userWithName(msg.author.username),
),
),
),
),
)
const application = yield* DiscordApplication
const botUser = application.bot!

const generateCompletion = (
thread: Discord.Channel,
message: Discord.MessageCreateEvent,
) =>
generateAiInput(thread, message).pipe(
ai.generateAiInput(thread, message).pipe(
Effect.flatMap(completions.create),
AiInput.provideSystem(`You are Effect Bot, a funny, helpful assistant for the Effect Discord community.
Expand Down Expand Up @@ -108,6 +71,7 @@ The title of this conversation is "${thread.name ?? "A thread"}".`),
})

export const MentionsLive = Layer.scopedDiscard(make).pipe(
Layer.provide(AiHelpers.Default),
Layer.provide(ChannelsCache.Default),
Layer.provide(DiscordLive),
Layer.provide(CompletionsLive),
Expand Down
97 changes: 97 additions & 0 deletions src/ReproRequester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { AiInput, Completions, Tokenizer } from "@effect/ai"
import { AiHelpers, CompletionsLive } from "bot/Ai"
import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordApplication, DiscordLive } from "bot/Discord"
import { NotInThreadError } from "bot/Summarizer"
import { Discord, DiscordREST, Ix } from "dfx"
import { InteractionsRegistry } from "dfx/gateway"
import { Effect, Layer } from "effect"

const systemInstruction = `You are Effect Bot, a helpful assistant for the Effect Discord community.
Generate a light-hearted, comedic message to the user based on the included conversation indicating that, without a minimal reproduction of the described issue, the Effect team will not be able to further investigate.
Your message should in no way be offensive to the user.`

const make = Effect.gen(function* () {
const ai = yield* AiHelpers
const channels = yield* ChannelsCache
const completions = yield* Completions.Completions
const registry = yield* InteractionsRegistry
const tokenizer = yield* Tokenizer.Tokenizer
const discord = yield* DiscordREST
const application = yield* DiscordApplication
const scope = yield* Effect.scope

const command = Ix.global(
{
name: "repro",
description:
"Generate a message indicating that reproduction is required",
},
Effect.gen(function* () {
const context = yield* Ix.Interaction
const channel = yield* channels.get(
context.guild_id!,
context.channel_id!,
)
if (channel.type !== Discord.ChannelType.PUBLIC_THREAD) {
return yield* new NotInThreadError()
}
yield* respond(channel, context).pipe(Effect.forkIn(scope))
return Ix.response({
type: Discord.InteractionCallbackType
.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
})
}).pipe(
Effect.annotateLogs("command", "repro"),
Effect.withSpan("ReproRequester.command"),
),
)

const respond = (channel: Discord.Channel, context: Discord.Interaction) =>
Effect.gen(function* () {
const input = yield* ai.generateAiInput(channel)
const content = yield* tokenizer.truncate(input, 30_000).pipe(
Effect.flatMap(completions.create),
Effect.map(response => response.text),
AiInput.provideSystem(systemInstruction),
Effect.annotateLogs({
thread: channel.id,
}),
)
yield* discord.editOriginalInteractionResponse(
application.id,
context.token,
{ content },
)
}).pipe(
Effect.withSpan("ReproRequester.respond", {
attributes: { channel: channel.id },
}),
)

const ix = Ix.builder
.add(command)
.catchTagRespond("NotInThreadError", () =>
Effect.succeed(
Ix.response({
type: Discord.InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "This command can only be used in a thread",
flags: Discord.MessageFlag.EPHEMERAL,
},
}),
),
)
.catchAllCause(Effect.logError)

yield* registry.register(ix)
})

export const ReproRequesterLive = Layer.scopedDiscard(make).pipe(
Layer.provide(AiHelpers.Default),
Layer.provide(ChannelsCache.Default),
Layer.provide(CompletionsLive),
Layer.provide(DiscordLive),
)
4 changes: 2 additions & 2 deletions src/Summarizer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpBody } from "@effect/platform"
import { ChannelsCache } from "bot/ChannelsCache"
import { DiscordLive } from "bot/Discord"
import { DiscordApplication, DiscordLive } from "bot/Discord"
import { MemberCache } from "bot/MemberCache"
import { Messages } from "bot/Messages"
import { Discord, DiscordREST, Ix } from "dfx"
Expand All @@ -19,7 +19,7 @@ export class Summarizer extends Effect.Service<Summarizer>()("app/Summarizer", {
const members = yield* MemberCache
const messages = yield* Messages
const scope = yield* Effect.scope
const application = yield* rest.getCurrentBotApplicationInformation().json
const application = yield* DiscordApplication

const summarizeThread = (channel: Discord.Channel, small = true) =>
pipe(
Expand Down
6 changes: 4 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { NodeRuntime } from "@effect/platform-node"
import { AutoThreadsLive } from "bot/AutoThreads"
import { DadJokesLive } from "bot/DadJokes"
import { DocsLookupLive } from "bot/DocsLookup"
import { IssueifierLive } from "bot/Issueifier"
import { NoEmbedLive } from "bot/NoEmbed"
import { RemindersLive } from "bot/Reminders"
import { ReproRequesterLive } from "bot/ReproRequester"
import { Summarizer } from "bot/Summarizer"
import { TracingLive } from "bot/Tracing"
import { Config, Effect, Layer, LogLevel, Logger } from "effect"
import { DadJokesLive } from "./DadJokes.js"
import { RemindersLive } from "./Reminders.js"

const LogLevelLive = Layer.unwrapEffect(
Effect.gen(function* () {
Expand All @@ -24,6 +25,7 @@ const MainLive = Layer.mergeAll(
DocsLookupLive,
IssueifierLive,
RemindersLive,
ReproRequesterLive,
Summarizer.Default,
).pipe(Layer.provide(TracingLive), Layer.provide(LogLevelLive))

Expand Down

0 comments on commit f0d9c6f

Please sign in to comment.