diff --git a/docs/src/content/docs/reference/cli/commands.md b/docs/src/content/docs/reference/cli/commands.md index 3a929a0c58..346483d9cf 100644 --- a/docs/src/content/docs/reference/cli/commands.md +++ b/docs/src/content/docs/reference/cli/commands.md @@ -38,6 +38,7 @@ Options: -prc, --pull-request-comment [string] create comment on a pull request with a unique id (defaults to script id) -prd, --pull-request-description [string] create comment on a pull request description with a unique id (defaults to script id) -prr, --pull-request-reviews create pull request reviews from annotations + -tm, --teams-message Posts a message to the teams channel -j, --json emit full JSON response to output -y, --yaml emit full YAML response to output -fe, --fail-on-errors fails on detected annotation error diff --git a/package.json b/package.json index 60a58c9612..2350473c67 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "genai:docify": "node packages/cli/built/genaiscript.cjs run docify", "gcm": "node packages/cli/built/genaiscript.cjs run gcm --model github:gpt-4o", "prd": "node packages/cli/built/genaiscript.cjs run prd -prd --model github:gpt-4o", - "prr": "node packages/cli/built/genaiscript.cjs run prr -prc --model github:gpt-4o", + "prr": "node packages/cli/built/genaiscript.cjs run prr -prr --model github:gpt-4o-mini", "genai": "node packages/cli/built/genaiscript.cjs run", "genai:convert": "node packages/cli/built/genaiscript.cjs convert", "genai:debug": "yarn compile-debug && node packages/cli/built/genaiscript.cjs run", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ae458b94a4..d4ad0aafd2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -145,6 +145,10 @@ export async function cli() { "-prr, --pull-request-reviews", "create pull request reviews from annotations" ) + .option( + "-tm, --teams-message", + "Posts a message to the teams channel" + ) .option("-j, --json", "emit full JSON response to output") .option("-y, --yaml", "emit full YAML response to output") .option(`-fe, --fail-on-errors`, `fails on detected annotation error`) diff --git a/packages/cli/src/nodehost.ts b/packages/cli/src/nodehost.ts index 124499a0a7..59e427e6ab 100644 --- a/packages/cli/src/nodehost.ts +++ b/packages/cli/src/nodehost.ts @@ -97,6 +97,7 @@ export class NodeHost implements RuntimeHost { readonly userInputQueue = new PLimitPromiseQueue(1) readonly azureToken: AzureTokenResolver readonly azureServerlessToken: AzureTokenResolver + readonly microsoftGraphToken: AzureTokenResolver constructor(dotEnvPath: string) { this.dotEnvPath = dotEnvPath @@ -110,6 +111,11 @@ export class NodeHost implements RuntimeHost { "AZURE_SERVERLESS_OPENAI_TOKEN_SCOPES", AZURE_AI_INFERENCE_TOKEN_SCOPES ) + this.microsoftGraphToken = createAzureTokenResolver( + "Microsoft Graph", + "MICROSOFT_GRAPH_TOKEN_SCOPES", + ["https://graph.microsoft.com/.default"] + ) } get modelAliases(): Readonly { diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 45c2dd51ab..9217fb08cc 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -91,6 +91,7 @@ import { parsePromptScriptMeta } from "../../core/src/template" import { Fragment } from "../../core/src/generation" import { randomHex } from "../../core/src/crypto" import { normalizeFloat, normalizeInt } from "../../core/src/cleaners" +import { microsoftTeamsChannelPostMessage } from "../../core/src/teams" function getRunDir(scriptId: string) { const runId = @@ -180,6 +181,7 @@ export async function runScriptInternal( const pullRequestComment = options.pullRequestComment const pullRequestDescription = options.pullRequestDescription const pullRequestReviews = options.pullRequestReviews + const teamsMessage = options.teamsMessage const outData = options.outData const label = options.label const temperature = normalizeFloat(options.temperature) @@ -524,6 +526,21 @@ export async function runScriptInternal( } let adoInfo: AzureDevOpsEnv = undefined + if (teamsMessage && result.text) { + const ghInfo = await resolveGitHubInfo() + const channelURL = + process.env.GENAISCRIPT_TEAMS_CHANNEL_URL || + process.env.TEAMS_CHANNEL_URL + if (channelURL) { + await microsoftTeamsChannelPostMessage(channelURL, result.text, { + script, + info: ghInfo, + cancellationToken, + trace, + }) + } + } + if (pullRequestReviews && result.annotations?.length) { // github action or repo const ghInfo = await resolveGitHubInfo() diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index d340267a94..d73f89c3f2 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -6,6 +6,7 @@ import { delay, uniq, uniqBy, chunk, groupBy } from "es-toolkit" import { z } from "zod" import { pipeline } from "@huggingface/transformers" +import { readFile } from "fs/promises" // symbols exported as is export { delay, uniq, uniqBy, z, pipeline, chunk, groupBy } diff --git a/packages/core/src/file.ts b/packages/core/src/file.ts index af3dcad6bd..9e324fbe49 100644 --- a/packages/core/src/file.ts +++ b/packages/core/src/file.ts @@ -196,9 +196,15 @@ ${CSVToMarkdown(tidyData(rows, options))} * @returns The Data URI string or undefined if the MIME type cannot be determined. */ export async function resolveFileBytes( - filename: string, + filename: string | WorkspaceFile, options?: TraceOptions ): Promise { + if (typeof filename === "object") { + if (filename.encoding === "base64" && filename.content) + return fromBase64(filename.content) + filename = filename.filename + } + if (/^data:/i.test(filename)) { const matches = filename.match(/^data:[^;]+;base64,(.*)$/i) if (!matches) throw new Error("Invalid data URI format") diff --git a/packages/core/src/host.ts b/packages/core/src/host.ts index 45165f3143..d130c6accb 100644 --- a/packages/core/src/host.ts +++ b/packages/core/src/host.ts @@ -128,7 +128,11 @@ export interface Host { export interface RuntimeHost extends Host { project: Project workspace: Omit + azureToken: AzureTokenResolver + azureServerlessToken: AzureTokenResolver + microsoftGraphToken: AzureTokenResolver + modelAliases: Readonly clientLanguageModel?: LanguageModel diff --git a/packages/core/src/promptcontext.ts b/packages/core/src/promptcontext.ts index 960f0851a7..4488b20442 100644 --- a/packages/core/src/promptcontext.ts +++ b/packages/core/src/promptcontext.ts @@ -30,6 +30,7 @@ import { DOCS_WEB_SEARCH_URL } from "./constants" import { fetch, fetchText } from "./fetch" import { fileWriteCached } from "./filecache" import { join } from "node:path" +import { createMicrosoftTeamsChannelClient } from "./teams" /** * Creates a prompt context for the given project, variables, trace, options, and model. @@ -304,6 +305,7 @@ export async function createPromptContext( }), python: async (options) => await runtimeHost.python({ trace, ...(options || {}) }), + teamsChannel: async (url) => createMicrosoftTeamsChannelClient(url), }) // Freeze project options to prevent modification diff --git a/packages/core/src/server/messages.ts b/packages/core/src/server/messages.ts index 2b6f1116c9..d78e61511e 100644 --- a/packages/core/src/server/messages.ts +++ b/packages/core/src/server/messages.ts @@ -132,6 +132,7 @@ export interface PromptScriptRunOptions { pullRequestComment: string | boolean pullRequestDescription: string | boolean pullRequestReviews: boolean + teamsMessage: boolean outData: string label: string temperature: string | number diff --git a/packages/core/src/teams.test.ts b/packages/core/src/teams.test.ts new file mode 100644 index 0000000000..0fc8166cb2 --- /dev/null +++ b/packages/core/src/teams.test.ts @@ -0,0 +1,72 @@ +import { convertMarkdownToTeamsHTML } from "./teams" +import { describe, test } from "node:test" +import assert from "node:assert/strict" + +describe("convertMarkdownToTeamsHTML", () => { + test("converts headers correctly", () => { + const markdown = + "# Subject\n## Heading 1\n### Heading 2\n#### Heading 3" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual(result.subject, "Subject") + assert.strictEqual( + result.content, + "
\n

Heading 1

\n

Heading 2

\n

Heading 3

" + ) + }) + + test("converts bold, italic, code, and strike correctly", () => { + const markdown = "**bold** *italic* `code` ~~strike~~" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual( + result.content, + "
bold italic code strike
" + ) + }) + + test("converts blockquotes correctly", () => { + const markdown = "> This is a blockquote" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual( + result.content, + "
This is a blockquote
\n
" + ) + }) + test("handles empty markdown string", () => { + const markdown = "" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual(result.content, "
") + assert.strictEqual(result.subject, undefined) + }) + + test("handles markdown without subject", () => { + const markdown = "## Heading 1\nContent" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual(result.subject, undefined) + assert.strictEqual(result.content, "

Heading 1

\nContent
") + }) + test("converts unordered lists correctly", () => { + const markdown = "- Item 1\n- Item 2\n- Item 3" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual( + result.content, + "

- Item 1\n
- Item 2\n
- Item 3
" + ) + }) + + test("converts mixed content correctly", () => { + const markdown = + "# Subject\n## Heading 1\nContent with **bold**, *italic*, `code`, and ~~strike~~.\n- List item 1\n- List item 2\n> Blockquote" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual(result.subject, "Subject") + assert.strictEqual( + result.content, + "
\n

Heading 1

\nContent with bold, italic, code, and strike.\n
- List item 1\n
- List item 2\n
Blockquote
\n
" + ) + }) + + test("converts multiple paragraphs correctly", () => { + const markdown = "Paragraph 1\n\nParagraph 2" + const result = convertMarkdownToTeamsHTML(markdown) + assert.strictEqual(result.content, "
Paragraph 1\n\nParagraph 2
") + }) +}) diff --git a/packages/core/src/teams.ts b/packages/core/src/teams.ts new file mode 100644 index 0000000000..b08beccff5 --- /dev/null +++ b/packages/core/src/teams.ts @@ -0,0 +1,286 @@ +import { fileTypeFromBuffer } from "file-type" +import { CancellationOptions } from "./cancellation" +import { deleteUndefinedValues } from "./cleaners" +import { createFetch } from "./fetch" +import { runtimeHost } from "./host" +import { HTMLEscape } from "./html" +import { TraceOptions } from "./trace" +import { logError, logVerbose } from "./util" +import { dedent } from "./indent" +import { TOOL_ID } from "./constants" +import { filenameOrFileToFilename } from "./unwrappers" +import { resolveFileBytes } from "./file" +import { basename } from "node:path" + +export function convertMarkdownToTeamsHTML(markdown: string) { + // using regexes, convert headers, lists, links, bold, italic, code, and quotes + let subject: string + let html = + "
" + + markdown + .replace(/^# (.*$)/gim, (m, t) => { + subject = t + return "" + }) + .replace(/^#### (.*$)/gim, "

$1

") + .replace(/^### (.*$)/gim, "

$1

") + .replace(/^## (.*$)/gim, "

$1

") + .replace(/^\> (.*$)/gim, "
$1
\n") + .replace(/\*\*(.*)\*\*/gim, "$1") + .replace(/\*(.*)\*/gim, "$1") + .replace(/__(.*)__/gim, "$1") + .replace(/`(.*?)`/gim, "$1") + .replace(/~~(.*?)~~/gim, "$1") + .replace(/^- (.*$)/gim, "
- $1") + + "
" + return { content: html.trim(), subject: subject?.trim() } +} + +function parseTeamsChannelUrl(url: string) { + const m = + /^https:\/\/teams.microsoft.com\/[^\/]{1,32}\/channel\/(?.+)\/.*\?groupId=(?([a-z0-9\-])+)$/.exec( + url + ) + if (!m) throw new Error("Invalid Teams channel URL") + const { teamId, channelId } = m.groups + return { teamId, channelId } +} + +export interface MicrosoftTeamsEntity { + webUrl: string + name: string +} + +function generatedByFooter(script: PromptScript, info: { runUrl?: string }) { + if (!script) + return `\n
AI-generated may be incorrect
\n` + return `\n
AI-generated by ${info?.runUrl ? `${HTMLEscape(script.id)}` : HTMLEscape(script.id)} may be incorrect
\n` +} + +/** + * Uploads a file to the files storage of a Microsoft Teams channel. + * @param channelUrl Shared channel link in the format https://teams.microsoft.com/l/channel//?groupId= + * @param filename + * @returns + */ +async function microsoftTeamsChannelUploadFile( + token: string, + channelUrl: string, + file: string | WorkspaceFileWithDescription, + options?: { folder?: string; disclaimer?: string } & TraceOptions & + CancellationOptions +): Promise { + const { disclaimer } = options || {} + + const filename = filenameOrFileToFilename(file) + const description = typeof file === "object" ? file.description : undefined + logVerbose(`teams: uploading ${filename}...`) + + const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) + const Authorization = `Bearer ${token}` + + const channelInfoUrl = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}` + const fetch = await createFetch({ ...(options || {}), retries: 1 }) + const channelInfoRes = await fetch(channelInfoUrl, { + headers: { + Authorization, + }, + }) + if (!channelInfoRes.ok) { + throw new Error( + `Failed to get channel info: ${channelInfoRes.status} ${channelInfoRes.statusText}` + ) + } + const channelInfo = await channelInfoRes.json() + const root = channelInfo.displayName + + // resolve channel folder name + const content = await resolveFileBytes(file, options) + if (!file) throw new Error(`${filename} not found`) + const folder = options?.folder || TOOL_ID + const itemUrl = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${root}/${folder}/${basename( + filename + )}` + const contentUrl = `${itemUrl}:/content` + const mime = await fileTypeFromBuffer(content) + const res = await fetch(contentUrl, { + method: "PUT", + headers: { + Authorization, + "Content-Type": mime?.mime || "application/octet-stream", + }, + body: content, + }) + if (!res.ok) { + logError(await res.text()) + throw new Error( + `Failed to upload file: ${res.status} ${res.statusText}` + ) + } + const j = (await res.json()) as MicrosoftTeamsEntity + if (description) { + const resg = await fetch(itemUrl, { + method: "GET", + headers: { + Authorization, + "Content-Type": "application/json", + }, + }) + const html = convertMarkdownToTeamsHTML(description) + if (disclaimer) html.content += disclaimer + + const dbody = deleteUndefinedValues({ + description: html.content, + title: html.subject, + }) + const resd = await fetch(itemUrl, { + method: "PATCH", + headers: { + Authorization, + "Content-Type": "application/json", + }, + body: JSON.stringify(dbody), + }) + if (!resd.ok) { + logVerbose(`description: ${dbody.description}`) + logVerbose(await resd.json()) + throw new Error( + `Failed to update file description: ${resd.status} ${resd.statusText}` + ) + } + } + + return j +} + +export async function microsoftTeamsChannelPostMessage( + channelUrl: string, + message: string, + options?: { + script?: PromptScript + info?: { runUrl?: string } + files?: (string | WorkspaceFileWithDescription)[] + folder?: string + disclaimer?: boolean | string + } & TraceOptions & + CancellationOptions +): Promise { + logVerbose(`teams: posting message to ${channelUrl}`) + + const { files = [] } = options || {} + const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) + const authToken = await runtimeHost.microsoftGraphToken.token("default") + const token = authToken?.token?.token + if (!token) { + logError("Microsoft Graph token not available") + return undefined + } + + // convert message to html + const { content, subject } = convertMarkdownToTeamsHTML(message) + const disclaimer = + typeof options.disclaimer === "string" + ? `\n
${HTMLEscape(options.disclaimer)}
\n` + : options.disclaimer !== false + ? generatedByFooter(options?.script, options?.info) + : undefined + + const body = deleteUndefinedValues({ + body: { + contentType: "html", + content, + }, + subject, + attachments: [] as any[], + }) + + for (const file of files) { + const fres = await microsoftTeamsChannelUploadFile( + token, + channelUrl, + file, + { + ...options, + disclaimer, + } + ) + const guid = crypto.randomUUID() + body.body.content += "\n" + `` + body.attachments.push({ + id: guid, + contentType: "reference", + contentUrl: fres.webUrl, + name: fres.name, + thumbnailUrl: null, + }) + } + + // finalize message + if (disclaimer) body.body.content += disclaimer + + const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` + const fetch = await createFetch({ ...(options || {}), retries: 1 }) + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const err: any = await response.text() + logError(err) + return undefined + } + + const data: any = await response.json() + const { webUrl } = data + logVerbose(`teams: message created at ${webUrl}`) + return data +} + +class MicrosoftTeamsChannelClient implements MessageChannelClient { + constructor(readonly channelUrl: string) {} + /** + * Posts a message with attachments to the channel + * @param message + * @param options + */ + async postMessage( + message: string, + options?: { + /** + * File attachments that will be added in the channel folder + */ + files?: string[] + /** + * Sets to false to remove AI generated disclaimer + */ + disclaimer?: boolean | string + } + ): Promise { + const { files, disclaimer } = options || {} + const res = await microsoftTeamsChannelPostMessage( + this.channelUrl, + dedent(message), + { + files, + disclaimer, + } + ) + return res.webUrl + } + + toString() { + return this.channelUrl + } +} + +export function createMicrosoftTeamsChannelClient( + url: string +): MessageChannelClient { + if (!parseTeamsChannelUrl(url)) throw new Error("Invalid Teams channel URL") + return new MicrosoftTeamsChannelClient(url) +} diff --git a/packages/core/src/testhost.ts b/packages/core/src/testhost.ts index 51bc889db9..19f0712e90 100644 --- a/packages/core/src/testhost.ts +++ b/packages/core/src/testhost.ts @@ -64,6 +64,8 @@ export class TestHost implements RuntimeHost { // File system for workspace workspace: WorkspaceFileSystem azureToken: AzureTokenResolver = undefined + azureServerlessToken: AzureTokenResolver = undefined + microsoftGraphToken: AzureTokenResolver = undefined // Default options for language models readonly modelAliases: ModelConfigurations = defaultModelConfigurations() diff --git a/packages/core/src/types/prompt_template.d.ts b/packages/core/src/types/prompt_template.d.ts index af2b16293b..31445e9317 100644 --- a/packages/core/src/types/prompt_template.d.ts +++ b/packages/core/src/types/prompt_template.d.ts @@ -4329,6 +4329,40 @@ interface PromptHost * Instantiates a python evaluation environment powered by pyodide (https://pyodide.org/) */ python(options?: PythonRuntimeOptions): Promise + + /** + * Gets a client to a Microsoft Teams channel from a share link URL. Uses Azure CLI login. + * @param url + */ + teamsChannel(shareUrl: string): Promise +} + +interface WorkspaceFileWithDescription extends WorkspaceFile { + /** + * File description used for videos. + */ + description?: string +} + +/** + * A client to a messaging channel + */ +interface MessageChannelClient { + /** + * Posts a message with attachments to the channel + * @param message + * @param options + */ + async postMessage(message: string, options?: { + /** + * File attachments that will be added in the channel folder + */ + files?: (string | WorkspaceFileWithDescription)[], + /** + * Sets to false to remove AI generated disclaimer + */ + disclaimer?: boolean | string + }): Promise } interface ContainerHost extends ShellHost { diff --git a/packages/sample/genaisrc/teams.genai.mts b/packages/sample/genaisrc/teams.genai.mts index 2ca2390faf..8044897db1 100644 --- a/packages/sample/genaisrc/teams.genai.mts +++ b/packages/sample/genaisrc/teams.genai.mts @@ -1,95 +1,28 @@ -import { DefaultAzureCredential } from "@azure/identity" - -const { teamId, channelId } = - /^https:\/\/teams.microsoft.com\/*.\/channel\/(?.+)\/.*\?groupId=(?([a-z0-9\-])+)$/.exec( - env.vars.link - ).groups -const message = "Hello from GenAIScript!" - -// Function to get Microsoft Graph API token using Managed Identity -async function getToken(): Promise { - const credential = new DefaultAzureCredential() - const tokenResponse = await credential.getToken( - "https://graph.microsoft.com/.default" - ) - if (!tokenResponse) { - throw new Error("Failed to retrieve access token.") - } - return tokenResponse.token -} - -// Function to read messages from a Teams channel -async function readMessages( - token: string, - teamId: string, - channelId: string -): Promise { - const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` - const headers = { - Authorization: `Bearer ${token}`, - } - - try { - const response = await fetch(url, { - method: "GET", - headers: headers, - }) - - if (!response.ok) { - throw new Error( - `Failed to read messages: ${response.status} ${response.statusText}` - ) - } - - const data = await response.json() - console.log("Messages retrieved successfully:", data) - return data - } catch (error) { - console.error("Error reading messages:", error) - return undefined +const teams = await host.teamsChannel( + "https://teams.microsoft.com/l/channel/19%3Ac9392f56d1e940b5bed9abe766832aeb%40thread.tacv2/Test?groupId=5d76383e-5d3a-4b63-b36a-62f01cff2806" +) +await teams.postMessage( + `# Hello world +This **message** _was_ sent from __genaiscript__. + +## YES! +`, + { + files: [ + "src/rag/markdown.md", + { + filename: "src/audio/helloworld.mp4", + description: `# This is the title! + +**Awesome** _video_! + +- __really epic__! + +## See also + +Other videos. +`, + }, + ], } -} - -// Function to post a message in a Teams channel -async function postMessage( - token: string, - teamId: string, - channelId: string, - message: string -): Promise { - const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` - const headers = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - } - const body = JSON.stringify({ - body: { - content: message, - }, - }) - - try { - const response = await fetch(url, { - method: "POST", - headers: headers, - body: body, - }) - - if (!response.ok) { - throw new Error( - `Failed to post message: ${response.status} ${response.statusText}` - ) - } - - const data = await response.json() - console.log("Message posted successfully:", data) - } catch (error) { - console.error("Error posting message:", error) - } -} - -const token = await getToken() - -const msgs = await readMessages(token, teamId, channelId) -console.log(msgs) -await postMessage(token, teamId, channelId, message) +) diff --git a/yarn.lock b/yarn.lock index 6fe63ba508..65b8cd2a0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10689,6 +10689,7 @@ xdg-basedir@^5.1.0: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version "0.20.2" + uid "0f64eeed3f1a46e64724620c3553f2dbd3cd2d7d" resolved "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz#0f64eeed3f1a46e64724620c3553f2dbd3cd2d7d" xml-parse-from-string@^1.0.0: