Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

/newtask #31

Open
wants to merge 7 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
"Superbase",
"SUPABASE",
"CODEOWNER",
"nosniff"
"nosniff",
"newtask",
"supergroup"
],
"dictionaries": ["typescript", "node", "software-terms"],
"import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"],
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/compute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ jobs:
env:
TELEGRAM_BOT_ENV: ${{ secrets.TELEGRAM_BOT_ENV }}
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
4 changes: 2 additions & 2 deletions .github/workflows/update-configuration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ jobs:
steps:
- uses: ubiquity-os/action-deploy-plugin@main
with:
pluginEntry: '${{ github.workspace }}/src/workflow-entry.ts'
schemaPath: '${{ github.workspace }}/src/types/plugin-inputs.ts'
pluginEntry: "${{ github.workspace }}/src/workflow-entry.ts"
schemaPath: "${{ github.workspace }}/src/types/plugin-inputs.ts"
env:
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default tsEslint.config({
"@typescript-eslint": tsEslint.plugin,
"check-file": checkFile,
},
ignores: [".github/knip.ts", "tests/**/*.ts", "eslint.config.mjs", ".wrangler/**/*.{js,ts}", "coverage/**/*.js"],
ignores: [".github/knip.ts", "tests/**/*.ts", "eslint.config.mjs", ".wrangler/**/*.{js,ts}", "coverage/**/*.js", "dist/**/*.js"],
extends: [eslint.configs.recommended, ...tsEslint.configs.recommended, sonarjs.configs.recommended],
languageOptions: {
parser: tsEslint.parser,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"grammy-guard": "0.5.0",
"hono": "^4.5.9",
"octokit": "^4.0.2",
"openai": "^4.70.2",
"telegram": "^2.24.11",
"typebox-validators": "0.3.5"
},
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Context } from "../types";
import { SessionManagerFactory } from "../bot/mtproto-api/bot/session/session-manager";
import { UserBaseStorage, ChatAction, HandleChatParams, StorageTypes, RetrievalHelper, Chat } from "../types/storage";
import { Completions } from "./openai/openai";

export interface Storage {
userSnapshot(chatId: number, userIds: number[]): Promise<void>;
Expand All @@ -20,8 +21,10 @@ export interface Storage {
export function createAdapters(ctx: Context) {
const {
config: { shouldUseGithubStorage },
env: { OPENAI_API_KEY },
} = ctx;
return {
storage: SessionManagerFactory.createSessionManager(shouldUseGithubStorage, ctx).storage,
ai: new Completions(OPENAI_API_KEY),
};
}
102 changes: 102 additions & 0 deletions src/adapters/openai/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import OpenAI from "openai";
import { PluginContext } from "../../types/plugin-context-single";

export interface ResponseFromLlm {
answer: string;
tokenUsage: {
input: number;
output: number;
total: number;
};
}

export class Completions {
protected client: OpenAI;

constructor(apiKey: string) {
this.client = new OpenAI({ apiKey: apiKey });
}

createSystemMessage({
additionalContext,
constraints,
directives,
embeddingsSearch,
outputStyle,
query,
}: {
directives: string[];
constraints: string[];
query: string;
embeddingsSearch: string[];
additionalContext: string[];
outputStyle: string;
}): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
return [
{
role: "system",
content: `You are UbiquityOS, a Telegram-integrated GitHub-first assistant for UbiquityDAO.

# Directives
${directives.join("\n- ")}

# Constraints
${constraints.join("\n- ")}

${embeddingsSearch.length > 0 ? `## Embeddings Search Results\n${embeddingsSearch.join("\n- ")}` : ""}

${additionalContext.length > 0 ? `### Additional Context\n${additionalContext.join("\n- ")}` : ""}

# Output Style
${outputStyle}
`
.replace(/ {16}/g, "")
.trim(),
},
{
role: "user",
content: query,
},
];
}

async createCompletion({
directives,
constraints,
additionalContext,
embeddingsSearch,
outputStyle,
query,
model,
}: {
directives: string[];
constraints: string[];
additionalContext: string[];
embeddingsSearch: string[];
outputStyle: string;
query: string;
model: string;
}): Promise<ResponseFromLlm | undefined> {
const config = PluginContext.getInstance().config;
const res: OpenAI.Chat.Completions.ChatCompletion = await this.client.chat.completions.create({
model: model,
messages: this.createSystemMessage({ directives, constraints, query, embeddingsSearch, additionalContext, outputStyle }),
temperature: 0.2,
max_completion_tokens: config.maxCompletionTokens,
top_p: 0.5,
frequency_penalty: 0,
presence_penalty: 0,
response_format: {
type: "text",
},
});
const answer = res.choices[0].message;
if (answer?.content && res.usage) {
const { prompt_tokens, completion_tokens, total_tokens } = res.usage;
return {
answer: answer.content,
tokenUsage: { input: prompt_tokens, output: completion_tokens, total: total_tokens },
};
}
}
}
126 changes: 126 additions & 0 deletions src/bot/features/commands/shared/task-creation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { chatAction } from "@grammyjs/auto-chat-action";
import { Composer } from "grammy";
import { GrammyContext } from "../../../helpers/grammy-context";
import { logHandle } from "../../../helpers/logging";
import { isAdmin } from "../../../filters/is-admin";
import { logger } from "../../../../utils/logger";

const composer = new Composer<GrammyContext>();

const feature = composer.chatType(["group", "private", "supergroup", "channel"]);

/**
* This is responsible for creating a task on GitHub. It's going to be a direct reply
* callback to the user who wrote the comment that we'll turn into a fully featured github
* task specification.
*/

feature.command("newtask", logHandle("task-creation"), chatAction("typing"), async (ctx: GrammyContext) => {
if (!ctx.message || !ctx.message.reply_to_message) {
logger.info(`No message or reply to message`);
return await ctx.reply("To create a new task, reply to the message with `/newtask <owner>/<repo>`");
}

const taskToCreate = ctx.message.reply_to_message.text;

if (!taskToCreate || taskToCreate.length < 10) {
return await ctx.reply("A new task needs substantially more content than that");
}

const repoToCreateIn = ctx.message.text?.split(" ")[1];

if (!repoToCreateIn) {
logger.info(`No repo to create task in`);
return await ctx.reply("To create a new task, reply to the message with `/newtask <owner>/<repo>`");
}

const [owner, repo] = repoToCreateIn.split("/");

if (!owner || !repo) {
return await ctx.reply("To create a new task, reply to the message with `/newtask <owner>/<repo>`");
}

const fromId = ctx.message.from.id;
const isReplierAdmin = isAdmin([fromId])(ctx);
// a cheap workaround for ctx being inferred as never if not an admin fsr, needs looked into.
// ctx types are complex here with mixins and such and the grammy ctx is highly dynamic.
// my assumption is that the ctx returned by isAdmin is replacing the initial ctx type.
const replyFn = ctx.reply;

if (!isReplierAdmin) {
logger.info(`User ${fromId} is not an admin`);
return await replyFn("Only admins can create tasks");
}

const directives = [
"Consume the user's message and begin to transform it into a GitHub task specification",
"Include a relevant short title for opening the task with",
"Include the task's description based on the user's message",
"Include any relevant context or constraints",
"Use a structured approach to writing the task",
"Do so without comment or directive, just the requested 'outputStyle'",
];

const constraints = [
"Never hallucinate details into the specification",
"Ensure the task is clear and actionable",
"Use GitHub flavoured markdown by default",
"Return the markdown within a code block to maintain formatting on GitHub",
"DO NOT use backticks in the markdown",
];

const additionalContext = [
"The task will be created via the GitHub app under your name; UbiquityOS",
"The correct repository will be selected by the admin who invoked this intent",
"Your output will be JSON parsed for the 'title' and 'body' keys",
"The user credit will be injected into the footer of your spec, so always leave it blank following a '---' separator",
];

const outputStyle = `{ title: "Task Title", body: "Task Body" }`;

const llmResponse = await ctx.adapters.ai.createCompletion({
embeddingsSearch: [],
directives,
constraints,
additionalContext,
outputStyle,
model: "gpt-4o",
query: taskToCreate,
});

if (!llmResponse) {
return await ctx.reply("Failed to create task");
}

const taskFromLlm = llmResponse.answer;

let taskDetails;

try {
taskDetails = JSON.parse(taskFromLlm);
} catch {
return await ctx.reply("Failed to parse task");
}

const userCredits = await ctx.adapters.storage.retrieveUserByTelegramId(fromId);

const username = userCredits?.github_username ?? "Anonymous";
const chatLink = ctx.chat?.type !== "private" && (await ctx.createChatInviteLink());

const chatLinkText = chatLink ? ` [here](${chatLink.invite_link})` : "";
const fullSpec = `${taskDetails.body}\n\n_Originally created by @${username} via Telegram${chatLinkText}_`;
const task = await ctx.octokit.rest.issues.create({
owner,
repo,
title: taskDetails.title,
body: fullSpec,
});

if (!task) {
return await ctx.reply("Failed to create task");
}

return await ctx.reply(`Task created: ${task.data.html_url}`);
});

export { composer as newTaskFeature };
2 changes: 2 additions & 0 deletions src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { welcomeFeature } from "./features/start-command";
import { unhandledFeature } from "./features/helpers/unhandled";
import { Context } from "../types";
import { session } from "./middlewares/session";
import { newTaskFeature } from "./features/commands/shared/task-creation";

interface Dependencies {
config: Context["env"];
Expand Down Expand Up @@ -80,6 +81,7 @@ export async function createBot(token: string, dependencies: Dependencies, optio
bot.use(userIdFeature);
bot.use(chatIdFeature);
bot.use(botIdFeature);
bot.use(newTaskFeature);

// Private chat commands
bot.use(registerFeature);
Expand Down
8 changes: 8 additions & 0 deletions src/bot/setcommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ function getPrivateChatAdminCommands(): BotCommand[] {
command: "setwebhook",
description: "Set the webhook URL",
},
{
command: "newtask",
description: "Create a new task",
},
];
}

Expand All @@ -112,6 +116,10 @@ function getGroupChatCommands(): BotCommand[] {
command: "ban",
description: "Ban a user",
},
{
command: "newtask",
description: "Create a new task",
},
];
}

Expand Down
1 change: 1 addition & 0 deletions src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const env = T.Object({
APP_ID: T.String(),
APP_PRIVATE_KEY: T.String(),
TEMP_SAFE_PAT: T.Optional(T.String()),
OPENAI_API_KEY: T.String(),
});

export type Env = StaticDecode<typeof env>;
Expand Down
1 change: 1 addition & 0 deletions src/types/plugin-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const pluginSettingsSchema = T.Object({
.Encode((value) => value.toString()),
shouldUseGithubStorage: T.Boolean({ default: false }),
storageOwner: T.String({ default: "ubiquity-os-marketplace" }),
maxCompletionTokens: T.Number({ default: 7000 }),
});

export const pluginSettingsValidator = new StandardValidator(pluginSettingsSchema);
Expand Down
Loading