From 7b4a10c58230f8b802bfee170124d2613888870e Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 4 Feb 2025 21:13:02 +0000 Subject: [PATCH 01/12] add teams runtime --- packages/cli/src/runtime.ts | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index d340267a94..a413ccec14 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 } @@ -309,3 +310,144 @@ export async function markdownifyPdf( return { pages, images, markdowns } } + +function parseTeamsChannelUrl(url: string) { + const m = + /^https:\/\/teams.microsoft.com\/*.\/channel\/(?.+)\/.*\?groupId=(?([a-z0-9\-])+)$/.exec( + url + ) + if (!m) throw new Error("Invalid Teams channel URL") + const { teamId, channelId } = m.groups + return { teamId, channelId } +} + +let _azureToken: string +async function azureGetToken(): Promise { + if (!_azureToken) { // TODO: refresh? + const { DefaultAzureCredential } = await import("@azure/identity") + const credential = new DefaultAzureCredential() + const tokenResponse = await credential.getToken( + "https://graph.microsoft.com/.default" + ) + if (!tokenResponse) throw new Error("Failed to retrieve access token.") + _azureToken = tokenResponse.token + } + return _azureToken +} + +export interface MicrosoftTeamsEntity { + webUrl: string + name: string +} + +/** + * Uploads a file to the files storage of a Microsoft Teams channel. + * @param channelUrl + * @param folder + * @param filename + * @returns + */ +export async function microsoftTeamsUploadFile( + channelUrl: string, + folder: string, + filename: string +) { + const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) + const token = await azureGetToken() + + // resolve channel folder name + const file = await readFile(filename) + const folder = "folder" + const url = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${folder}/${path.basename( + filename + )}:/content` + const res = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/octet-stream", + }, + body: file, + }) + if (!res.ok) { + console.debug(await res.text()) + throw new Error( + `Failed to upload file: ${res.status} ${res.statusText}` + ) + } + const j = (await res.json()) as MicrosoftTeamsEntity + return j +} + +export async function teamsPostMessage( + channelUrl: string, + subject: string, + message: string, + videoFilename: string +): Promise { + const token = await azureGetToken() + + const body = { + body: { + contentType: "html", + content: message, + }, + subject, + attachments: [], + } + + if (videoFilename) { + console.debug(`Uploading video ${videoFilename}...`) + const videoRes = await microsoftTeamsUploadFile(channelUrl, videoFilename) + console.log(videoRes) + const guid = crypto.randomUUID() + body.body.content += "\n" + `` + body.attachments = [ + { + id: guid, + contentType: "reference", + contentUrl: videoRes.webUrl, + name: videoRes.name, + thumbnailUrl: null, + }, + ] + } + + const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` + 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() + console.debug(err) + throw new Error( + `Failed to post message: ${response.status} ${response.statusText}` + ) + } + + { + const data: any = await response.json() + const { webUrl } = data + console.log(`message created at ${webUrl}`) + console.debug(data) + return data + } +} + +const segments = await workspace.readJSON(env.files[0]) +for (const segment of segments) { + const { id, summary, video } = segment + + const lines = summary.split(/\n/g) + const subject = lines[0] + const message = lines.slice(1).join("\n
").trim() + + console.log(`Uploading ${id} to Teams...`) + await teamsPostMessage(subject, message, video) +} From e85e57f423f533ecedbf5d72bdcebe23a241ad1a Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Wed, 5 Feb 2025 06:38:01 +0000 Subject: [PATCH 02/12] upload feature --- packages/cli/src/runtime.ts | 43 ++++++----- packages/sample/genaisrc/teams.genai.mts | 98 +----------------------- 2 files changed, 29 insertions(+), 112 deletions(-) diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index a413ccec14..ace07bd987 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -313,7 +313,7 @@ export async function markdownifyPdf( function parseTeamsChannelUrl(url: string) { const m = - /^https:\/\/teams.microsoft.com\/*.\/channel\/(?.+)\/.*\?groupId=(?([a-z0-9\-])+)$/.exec( + /^https:\/\/teams.microsoft.com\/[^\/]{1,32}\/channel\/(?.+)\/.*\?groupId=(?([a-z0-9\-])+)$/.exec( url ) if (!m) throw new Error("Invalid Teams channel URL") @@ -323,7 +323,7 @@ function parseTeamsChannelUrl(url: string) { let _azureToken: string async function azureGetToken(): Promise { - if (!_azureToken) { // TODO: refresh? + if (!_azureToken) { const { DefaultAzureCredential } = await import("@azure/identity") const credential = new DefaultAzureCredential() const tokenResponse = await credential.getToken( @@ -349,22 +349,36 @@ export interface MicrosoftTeamsEntity { */ export async function microsoftTeamsUploadFile( channelUrl: string, - folder: string, filename: string ) { const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) const token = await azureGetToken() + const Authorization = `Bearer ${token}` + + const channelInfoUrl = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}` + 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() + console.log(channelInfo) + const folder = channelInfo.displayName // resolve channel folder name const file = await readFile(filename) - const folder = "folder" const url = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${folder}/${path.basename( filename )}:/content` const res = await fetch(url, { method: "PUT", headers: { - Authorization: `Bearer ${token}`, + Authorization, "Content-Type": "application/octet-stream", }, body: file, @@ -378,7 +392,7 @@ export async function microsoftTeamsUploadFile( const j = (await res.json()) as MicrosoftTeamsEntity return j } - +/* export async function teamsPostMessage( channelUrl: string, subject: string, @@ -398,7 +412,10 @@ export async function teamsPostMessage( if (videoFilename) { console.debug(`Uploading video ${videoFilename}...`) - const videoRes = await microsoftTeamsUploadFile(channelUrl, videoFilename) + const videoRes = await microsoftTeamsUploadFile( + channelUrl, + videoFilename + ) console.log(videoRes) const guid = crypto.randomUUID() body.body.content += "\n" + `` @@ -440,14 +457,4 @@ export async function teamsPostMessage( } } -const segments = await workspace.readJSON(env.files[0]) -for (const segment of segments) { - const { id, summary, video } = segment - - const lines = summary.split(/\n/g) - const subject = lines[0] - const message = lines.slice(1).join("\n
").trim() - - console.log(`Uploading ${id} to Teams...`) - await teamsPostMessage(subject, message, video) -} +*/ diff --git a/packages/sample/genaisrc/teams.genai.mts b/packages/sample/genaisrc/teams.genai.mts index 2ca2390faf..ecc6de0931 100644 --- a/packages/sample/genaisrc/teams.genai.mts +++ b/packages/sample/genaisrc/teams.genai.mts @@ -1,95 +1,5 @@ -import { DefaultAzureCredential } from "@azure/identity" +import { microsoftTeamsUploadFile } from "genaiscript/runtime" -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 - } -} - -// 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) +const teams = + "https://teams.microsoft.com/l/channel/19%3Ac9392f56d1e940b5bed9abe766832aeb%40thread.tacv2/Test?groupId=5d76383e-5d3a-4b63-b36a-62f01cff2806" +await microsoftTeamsUploadFile(teams, "src/rag/markdown.md") From 01da0b5b25adae4179dd210941698076e885c264 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Tue, 4 Feb 2025 22:58:58 -0800 Subject: [PATCH 03/12] added helper to upload teams messages --- packages/cli/src/runtime.ts | 45 ++++++++++++------------ packages/sample/genaisrc/teams.genai.mts | 11 ++++-- yarn.lock | 1 + 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index ace07bd987..3f0d56c515 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -324,6 +324,7 @@ function parseTeamsChannelUrl(url: string) { let _azureToken: string async function azureGetToken(): Promise { if (!_azureToken) { + console.debug(`Azure: retreiving token...`) const { DefaultAzureCredential } = await import("@azure/identity") const credential = new DefaultAzureCredential() const tokenResponse = await credential.getToken( @@ -342,17 +343,18 @@ export interface MicrosoftTeamsEntity { /** * Uploads a file to the files storage of a Microsoft Teams channel. - * @param channelUrl - * @param folder + * @param channelUrl Shared channel link in the format https://teams.microsoft.com/l/channel//?groupId= * @param filename * @returns */ -export async function microsoftTeamsUploadFile( +async function microsoftTeamsChannelUploadFile( + token: string, channelUrl: string, filename: string -) { +): Promise { + console.debug(`Uploading ${filename}...`) + const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) - const token = await azureGetToken() const Authorization = `Bearer ${token}` const channelInfoUrl = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}` @@ -367,7 +369,6 @@ export async function microsoftTeamsUploadFile( ) } const channelInfo = await channelInfoRes.json() - console.log(channelInfo) const folder = channelInfo.displayName // resolve channel folder name @@ -392,13 +393,17 @@ export async function microsoftTeamsUploadFile( const j = (await res.json()) as MicrosoftTeamsEntity return j } -/* -export async function teamsPostMessage( + +export async function microsoftTeamsChannelPostMessage( channelUrl: string, subject: string, message: string, - videoFilename: string -): Promise { + options?: { + files?: string[] + } +): Promise { + const { files = [] } = options || {} + const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) const token = await azureGetToken() const body = { @@ -407,24 +412,23 @@ export async function teamsPostMessage( content: message, }, subject, - attachments: [], + attachments: [] as any[], } - if (videoFilename) { - console.debug(`Uploading video ${videoFilename}...`) - const videoRes = await microsoftTeamsUploadFile( + for (const file of files) { + const fres = await microsoftTeamsChannelUploadFile( + token, channelUrl, - videoFilename + file ) - console.log(videoRes) const guid = crypto.randomUUID() body.body.content += "\n" + `` body.attachments = [ { id: guid, contentType: "reference", - contentUrl: videoRes.webUrl, - name: videoRes.name, + contentUrl: fres.webUrl, + name: fres.name, thumbnailUrl: null, }, ] @@ -451,10 +455,7 @@ export async function teamsPostMessage( { const data: any = await response.json() const { webUrl } = data - console.log(`message created at ${webUrl}`) - console.debug(data) + console.debug(`message created at ${webUrl}`) return data } } - -*/ diff --git a/packages/sample/genaisrc/teams.genai.mts b/packages/sample/genaisrc/teams.genai.mts index ecc6de0931..0bd7047019 100644 --- a/packages/sample/genaisrc/teams.genai.mts +++ b/packages/sample/genaisrc/teams.genai.mts @@ -1,5 +1,12 @@ -import { microsoftTeamsUploadFile } from "genaiscript/runtime" +import { microsoftTeamsChannelPostMessage } from "genaiscript/runtime" const teams = "https://teams.microsoft.com/l/channel/19%3Ac9392f56d1e940b5bed9abe766832aeb%40thread.tacv2/Test?groupId=5d76383e-5d3a-4b63-b36a-62f01cff2806" -await microsoftTeamsUploadFile(teams, "src/rag/markdown.md") +await microsoftTeamsChannelPostMessage( + teams, + "Hello World", + "This message was sent from genaiscript", + { + files: ["src/rag/markdown.md"], + } +) 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: From a515e0de95a8e99382fbedf1a353227ecfa49741 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Wed, 5 Feb 2025 08:14:07 -0800 Subject: [PATCH 04/12] bulitin to api --- .../content/docs/reference/cli/commands.md | 1 + packages/cli/src/cli.ts | 4 + packages/cli/src/nodehost.ts | 6 + packages/cli/src/run.ts | 22 +++ packages/cli/src/runtime.ts | 149 ---------------- packages/core/src/host.ts | 4 + packages/core/src/server/messages.ts | 1 + packages/core/src/teams.ts | 162 ++++++++++++++++++ packages/core/src/testhost.ts | 2 + 9 files changed, 202 insertions(+), 149 deletions(-) create mode 100644 packages/core/src/teams.ts 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/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..9cc839f548 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,26 @@ 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, + undefined, + 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 3f0d56c515..d73f89c3f2 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -310,152 +310,3 @@ export async function markdownifyPdf( return { pages, images, markdowns } } - -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 } -} - -let _azureToken: string -async function azureGetToken(): Promise { - if (!_azureToken) { - console.debug(`Azure: retreiving token...`) - const { DefaultAzureCredential } = await import("@azure/identity") - const credential = new DefaultAzureCredential() - const tokenResponse = await credential.getToken( - "https://graph.microsoft.com/.default" - ) - if (!tokenResponse) throw new Error("Failed to retrieve access token.") - _azureToken = tokenResponse.token - } - return _azureToken -} - -export interface MicrosoftTeamsEntity { - webUrl: string - name: string -} - -/** - * 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, - filename: string -): Promise { - console.debug(`Uploading ${filename}...`) - - const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) - const Authorization = `Bearer ${token}` - - const channelInfoUrl = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}` - 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 folder = channelInfo.displayName - - // resolve channel folder name - const file = await readFile(filename) - const url = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${folder}/${path.basename( - filename - )}:/content` - const res = await fetch(url, { - method: "PUT", - headers: { - Authorization, - "Content-Type": "application/octet-stream", - }, - body: file, - }) - if (!res.ok) { - console.debug(await res.text()) - throw new Error( - `Failed to upload file: ${res.status} ${res.statusText}` - ) - } - const j = (await res.json()) as MicrosoftTeamsEntity - return j -} - -export async function microsoftTeamsChannelPostMessage( - channelUrl: string, - subject: string, - message: string, - options?: { - files?: string[] - } -): Promise { - const { files = [] } = options || {} - const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) - const token = await azureGetToken() - - const body = { - body: { - contentType: "html", - content: message, - }, - subject, - attachments: [] as any[], - } - - for (const file of files) { - const fres = await microsoftTeamsChannelUploadFile( - token, - channelUrl, - file - ) - const guid = crypto.randomUUID() - body.body.content += "\n" + `` - body.attachments = [ - { - id: guid, - contentType: "reference", - contentUrl: fres.webUrl, - name: fres.name, - thumbnailUrl: null, - }, - ] - } - - const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` - 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() - console.debug(err) - throw new Error( - `Failed to post message: ${response.status} ${response.statusText}` - ) - } - - { - const data: any = await response.json() - const { webUrl } = data - console.debug(`message created at ${webUrl}`) - return data - } -} 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/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.ts b/packages/core/src/teams.ts new file mode 100644 index 0000000000..4509b4df6f --- /dev/null +++ b/packages/core/src/teams.ts @@ -0,0 +1,162 @@ +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" + +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 }) { + return `\n
AI-generated message 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, + filename: string, + options?: TraceOptions & CancellationOptions +): Promise { + 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) + 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 folder = channelInfo.displayName + + // resolve channel folder name + const file = await runtimeHost.readFile(filename) + const url = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${folder}/${path.basename( + filename + )}:/content` + const mime = await fileTypeFromBuffer(file) + const res = await fetch(url, { + method: "PUT", + headers: { + Authorization, + "Content-Type": mime?.mime || "application/octet-stream", + }, + body: file, + }) + 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 + return j +} + +export async function microsoftTeamsChannelPostMessage( + channelUrl: string, + subject: string, + message: string, + options?: { + script: PromptScript + info: { runUrl?: string } + files?: 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 body = deleteUndefinedValues({ + body: { + contentType: "html", + content: message, + }, + subject, + attachments: [] as any[], + }) + + for (const file of files) { + const fres = await microsoftTeamsChannelUploadFile( + token, + channelUrl, + file, + options + ) + const guid = crypto.randomUUID() + body.body.content += "\n" + `` + body.attachments = [ + { + id: guid, + contentType: "reference", + contentUrl: fres.webUrl, + name: fres.name, + thumbnailUrl: null, + }, + ] + } + + // finalize message + body.body.content += generatedByFooter(options?.script, options?.info) + + const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` + const fetch = await createFetch(options) + 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 +} 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() From 2cefae0e944b8e53a9133d919fc818053b5fe486 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Wed, 5 Feb 2025 17:15:28 +0000 Subject: [PATCH 05/12] basic html formatting --- packages/cli/src/run.ts | 17 +++----- packages/core/src/teams.test.ts | 72 +++++++++++++++++++++++++++++++++ packages/core/src/teams.ts | 24 ++++++++++- 3 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/teams.test.ts diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 9cc839f548..9217fb08cc 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -532,17 +532,12 @@ export async function runScriptInternal( process.env.GENAISCRIPT_TEAMS_CHANNEL_URL || process.env.TEAMS_CHANNEL_URL if (channelURL) { - await microsoftTeamsChannelPostMessage( - channelURL, - undefined, - result.text, - { - script, - info: ghInfo, - cancellationToken, - trace, - } - ) + await microsoftTeamsChannelPostMessage(channelURL, result.text, { + script, + info: ghInfo, + cancellationToken, + trace, + }) } } diff --git a/packages/core/src/teams.test.ts b/packages/core/src/teams.test.ts new file mode 100644 index 0000000000..64f7cc4971 --- /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, + "

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
" + ) + }) + 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, + "

Heading 1

\nContent with bold, italic, code, and strike.\n
- List item 1\n
- List item 2\n
Blockquote
" + ) + }) + + 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 index 4509b4df6f..6b5c9084f3 100644 --- a/packages/core/src/teams.ts +++ b/packages/core/src/teams.ts @@ -7,6 +7,26 @@ import { HTMLEscape } from "./html" import { TraceOptions } from "./trace" import { logError, logVerbose } from "./util" +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") + return { content: html.trim(), subject } +} + function parseTeamsChannelUrl(url: string) { const m = /^https:\/\/teams.microsoft.com\/[^\/]{1,32}\/channel\/(?.+)\/.*\?groupId=(?([a-z0-9\-])+)$/.exec( @@ -84,7 +104,6 @@ async function microsoftTeamsChannelUploadFile( export async function microsoftTeamsChannelPostMessage( channelUrl: string, - subject: string, message: string, options?: { script: PromptScript @@ -105,11 +124,12 @@ export async function microsoftTeamsChannelPostMessage( } // convert message to html + const { content, subject } = convertMarkdownToTeamsHTML(message) const body = deleteUndefinedValues({ body: { contentType: "html", - content: message, + content, }, subject, attachments: [] as any[], From 07f4a97bc73d0d2f979288b52829d91136f1428d Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Wed, 5 Feb 2025 19:55:36 +0000 Subject: [PATCH 06/12] add api --- packages/core/src/promptcontext.ts | 2 + packages/core/src/teams.ts | 62 ++++++++++++++++++-- packages/core/src/types/prompt_template.d.ts | 27 +++++++++ packages/sample/genaisrc/teams.genai.mts | 12 ++-- 4 files changed, 91 insertions(+), 12 deletions(-) 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/teams.ts b/packages/core/src/teams.ts index 6b5c9084f3..5cd96418ff 100644 --- a/packages/core/src/teams.ts +++ b/packages/core/src/teams.ts @@ -6,6 +6,7 @@ import { runtimeHost } from "./host" import { HTMLEscape } from "./html" import { TraceOptions } from "./trace" import { logError, logVerbose } from "./util" +import { dedent } from "./indent" export function convertMarkdownToTeamsHTML(markdown: string) { // using regexes, convert headers, lists, links, bold, italic, code, and quotes @@ -43,7 +44,9 @@ export interface MicrosoftTeamsEntity { } function generatedByFooter(script: PromptScript, info: { runUrl?: string }) { - return `\n
AI-generated message by ${info.runUrl ? `${HTMLEscape(script.id)}` : HTMLEscape(script.id)} may be incorrect
\n` + if (!script) + return `\n
AI-generated message may be incorrect
\n` + return `\n
AI-generated message by ${info?.runUrl ? `${HTMLEscape(script.id)}` : HTMLEscape(script.id)} may be incorrect
\n` } /** @@ -106,15 +109,16 @@ export async function microsoftTeamsChannelPostMessage( channelUrl: string, message: string, options?: { - script: PromptScript - info: { runUrl?: string } + script?: PromptScript + info?: { runUrl?: string } files?: string[] + disclaimer?: boolean | string } & TraceOptions & CancellationOptions ): Promise { logVerbose(`teams: posting message to ${channelUrl}`) - const { files = [] } = options || {} + const { files = [], disclaimer } = options || {} const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) const authToken = await runtimeHost.microsoftGraphToken.token("default") const token = authToken?.token?.token @@ -156,7 +160,11 @@ export async function microsoftTeamsChannelPostMessage( } // finalize message - body.body.content += generatedByFooter(options?.script, options?.info) + if (disclaimer !== false) + body.body.content += + typeof disclaimer === "string" + ? `\n
${HTMLEscape(disclaimer)}
\n` + : generatedByFooter(options?.script, options?.info) const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` const fetch = await createFetch(options) @@ -180,3 +188,47 @@ export async function microsoftTeamsChannelPostMessage( 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/types/prompt_template.d.ts b/packages/core/src/types/prompt_template.d.ts index af2b16293b..96981c048e 100644 --- a/packages/core/src/types/prompt_template.d.ts +++ b/packages/core/src/types/prompt_template.d.ts @@ -4329,6 +4329,33 @@ 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 +} + +/** + * 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[], + /** + * 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 0bd7047019..8338ecaa1e 100644 --- a/packages/sample/genaisrc/teams.genai.mts +++ b/packages/sample/genaisrc/teams.genai.mts @@ -1,11 +1,9 @@ -import { microsoftTeamsChannelPostMessage } from "genaiscript/runtime" - -const teams = +const teams = await host.teamsChannel( "https://teams.microsoft.com/l/channel/19%3Ac9392f56d1e940b5bed9abe766832aeb%40thread.tacv2/Test?groupId=5d76383e-5d3a-4b63-b36a-62f01cff2806" -await microsoftTeamsChannelPostMessage( - teams, - "Hello World", - "This message was sent from genaiscript", +) +await teams.postMessage( + `# Hello world +This message was sent from genaiscript`, { files: ["src/rag/markdown.md"], } From 6fc9d7e642e8bfe4d1838bbcb80d193a28e918e3 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Wed, 5 Feb 2025 12:04:56 -0800 Subject: [PATCH 07/12] fix check --- packages/core/src/teams.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/teams.ts b/packages/core/src/teams.ts index 5cd96418ff..dd737bc8d6 100644 --- a/packages/core/src/teams.ts +++ b/packages/core/src/teams.ts @@ -83,6 +83,7 @@ async function microsoftTeamsChannelUploadFile( // resolve channel folder name const file = await runtimeHost.readFile(filename) + if (!file) throw new Error(`${filename} not found`) const url = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${folder}/${path.basename( filename )}:/content` @@ -229,6 +230,6 @@ class MicrosoftTeamsChannelClient implements MessageChannelClient { export function createMicrosoftTeamsChannelClient( url: string ): MessageChannelClient { - if (parseTeamsChannelUrl(url)) throw new Error("Invalid Teams channel URL") + if (!parseTeamsChannelUrl(url)) throw new Error("Invalid Teams channel URL") return new MicrosoftTeamsChannelClient(url) } From b06f4af2e5e64f09e11550a914b2bdaaa5d4bbf3 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Wed, 5 Feb 2025 13:14:55 -0800 Subject: [PATCH 08/12] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20Teams=20int?= =?UTF-8?q?egration=20with=20file=20descriptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/file.ts | 8 +- packages/core/src/teams.ts | 124 +++++++++++++------ packages/core/src/types/prompt_template.d.ts | 9 +- packages/sample/genaisrc/teams.genai.mts | 22 +++- 4 files changed, 123 insertions(+), 40 deletions(-) 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/teams.ts b/packages/core/src/teams.ts index dd737bc8d6..6b7faf1f75 100644 --- a/packages/core/src/teams.ts +++ b/packages/core/src/teams.ts @@ -7,25 +7,32 @@ 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" 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") - return { content: html.trim(), subject } + 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) { @@ -45,8 +52,8 @@ export interface MicrosoftTeamsEntity { function generatedByFooter(script: PromptScript, info: { runUrl?: string }) { if (!script) - return `\n
AI-generated message may be incorrect
\n` - return `\n
AI-generated message by ${info?.runUrl ? `${HTMLEscape(script.id)}` : HTMLEscape(script.id)} may be incorrect
\n` + 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` } /** @@ -58,16 +65,21 @@ function generatedByFooter(script: PromptScript, info: { runUrl?: string }) { async function microsoftTeamsChannelUploadFile( token: string, channelUrl: string, - filename: string, - options?: TraceOptions & CancellationOptions + 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) + const fetch = await createFetch({ ...(options || {}), retries: 1 }) const channelInfoRes = await fetch(channelInfoUrl, { headers: { Authorization, @@ -79,22 +91,24 @@ async function microsoftTeamsChannelUploadFile( ) } const channelInfo = await channelInfoRes.json() - const folder = channelInfo.displayName + const root = channelInfo.displayName // resolve channel folder name - const file = await runtimeHost.readFile(filename) + const content = await resolveFileBytes(file, options) if (!file) throw new Error(`${filename} not found`) - const url = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${folder}/${path.basename( + const folder = options?.folder || TOOL_ID + const itemUrl = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${root}/${folder}/${path.basename( filename - )}:/content` - const mime = await fileTypeFromBuffer(file) - const res = await fetch(url, { + )}` + 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: file, + body: content, }) if (!res.ok) { logError(await res.text()) @@ -103,6 +117,38 @@ async function microsoftTeamsChannelUploadFile( ) } 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 } @@ -112,14 +158,15 @@ export async function microsoftTeamsChannelPostMessage( options?: { script?: PromptScript info?: { runUrl?: string } - files?: string[] + files?: (string | WorkspaceFileWithDescription)[] + folder?: string disclaimer?: boolean | string } & TraceOptions & CancellationOptions ): Promise { logVerbose(`teams: posting message to ${channelUrl}`) - const { files = [], disclaimer } = options || {} + const { files = [] } = options || {} const { teamId, channelId } = parseTeamsChannelUrl(channelUrl) const authToken = await runtimeHost.microsoftGraphToken.token("default") const token = authToken?.token?.token @@ -130,6 +177,12 @@ export async function microsoftTeamsChannelPostMessage( // 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: { @@ -145,7 +198,10 @@ export async function microsoftTeamsChannelPostMessage( token, channelUrl, file, - options + { + ...options, + disclaimer, + } ) const guid = crypto.randomUUID() body.body.content += "\n" + `` @@ -161,14 +217,10 @@ export async function microsoftTeamsChannelPostMessage( } // finalize message - if (disclaimer !== false) - body.body.content += - typeof disclaimer === "string" - ? `\n
${HTMLEscape(disclaimer)}
\n` - : generatedByFooter(options?.script, options?.info) + if (disclaimer) body.body.content += disclaimer const url = `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages` - const fetch = await createFetch(options) + const fetch = await createFetch({ ...(options || {}), retries: 1 }) const response = await fetch(url, { method: "POST", headers: { diff --git a/packages/core/src/types/prompt_template.d.ts b/packages/core/src/types/prompt_template.d.ts index 96981c048e..31445e9317 100644 --- a/packages/core/src/types/prompt_template.d.ts +++ b/packages/core/src/types/prompt_template.d.ts @@ -4337,6 +4337,13 @@ interface PromptHost teamsChannel(shareUrl: string): Promise } +interface WorkspaceFileWithDescription extends WorkspaceFile { + /** + * File description used for videos. + */ + description?: string +} + /** * A client to a messaging channel */ @@ -4350,7 +4357,7 @@ interface MessageChannelClient { /** * File attachments that will be added in the channel folder */ - files?: string[], + files?: (string | WorkspaceFileWithDescription)[], /** * Sets to false to remove AI generated disclaimer */ diff --git a/packages/sample/genaisrc/teams.genai.mts b/packages/sample/genaisrc/teams.genai.mts index 8338ecaa1e..8044897db1 100644 --- a/packages/sample/genaisrc/teams.genai.mts +++ b/packages/sample/genaisrc/teams.genai.mts @@ -3,8 +3,26 @@ const teams = await host.teamsChannel( ) await teams.postMessage( `# Hello world -This message was sent from genaiscript`, +This **message** _was_ sent from __genaiscript__. + +## YES! +`, { - files: ["src/rag/markdown.md"], + files: [ + "src/rag/markdown.md", + { + filename: "src/audio/helloworld.mp4", + description: `# This is the title! + +**Awesome** _video_! + +- __really epic__! + +## See also + +Other videos. +`, + }, + ], } ) From d1bb874af5e6799a880335281e03cba2860ef566 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Wed, 5 Feb 2025 13:16:49 -0800 Subject: [PATCH 09/12] multi attachements --- packages/core/src/teams.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/core/src/teams.ts b/packages/core/src/teams.ts index 6b7faf1f75..7570002c00 100644 --- a/packages/core/src/teams.ts +++ b/packages/core/src/teams.ts @@ -205,15 +205,13 @@ export async function microsoftTeamsChannelPostMessage( ) const guid = crypto.randomUUID() body.body.content += "\n" + `` - body.attachments = [ - { - id: guid, - contentType: "reference", - contentUrl: fres.webUrl, - name: fres.name, - thumbnailUrl: null, - }, - ] + body.attachments.push({ + id: guid, + contentType: "reference", + contentUrl: fres.webUrl, + name: fres.name, + thumbnailUrl: null, + }) } // finalize message From 3ef4972e5617e6cfe6347c50dde38105ac3cbb38 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Wed, 5 Feb 2025 13:34:54 -0800 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=F0=9F=8E=89=20add=20node:path=20?= =?UTF-8?q?basename=20for=20file=20path=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/teams.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/teams.ts b/packages/core/src/teams.ts index 7570002c00..b08beccff5 100644 --- a/packages/core/src/teams.ts +++ b/packages/core/src/teams.ts @@ -10,6 +10,7 @@ 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 @@ -97,7 +98,7 @@ async function microsoftTeamsChannelUploadFile( 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}/${path.basename( + const itemUrl = `https://graph.microsoft.com/v1.0/groups/${teamId}/drive/root:/${root}/${folder}/${basename( filename )}` const contentUrl = `${itemUrl}:/content` From 4e488b9bbe9c6b4643e453c5642dbb52242d2f9f Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Wed, 5 Feb 2025 22:15:06 +0000 Subject: [PATCH 11/12] =?UTF-8?q?test:=20=E2=99=BB=EF=B8=8F=20wrap=20HTML?= =?UTF-8?q?=20output=20with=20
=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/teams.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/teams.test.ts b/packages/core/src/teams.test.ts index 64f7cc4971..0fc8166cb2 100644 --- a/packages/core/src/teams.test.ts +++ b/packages/core/src/teams.test.ts @@ -10,7 +10,7 @@ describe("convertMarkdownToTeamsHTML", () => { assert.strictEqual(result.subject, "Subject") assert.strictEqual( result.content, - "

Heading 1

\n

Heading 2

\n

Heading 3

" + "
\n

Heading 1

\n

Heading 2

\n

Heading 3

" ) }) @@ -19,7 +19,7 @@ describe("convertMarkdownToTeamsHTML", () => { const result = convertMarkdownToTeamsHTML(markdown) assert.strictEqual( result.content, - "bold italic code strike" + "
bold italic code strike
" ) }) @@ -28,13 +28,13 @@ describe("convertMarkdownToTeamsHTML", () => { const result = convertMarkdownToTeamsHTML(markdown) assert.strictEqual( result.content, - "
This is a blockquote
" + "
This is a blockquote
\n
" ) }) test("handles empty markdown string", () => { const markdown = "" const result = convertMarkdownToTeamsHTML(markdown) - assert.strictEqual(result.content, "") + assert.strictEqual(result.content, "
") assert.strictEqual(result.subject, undefined) }) @@ -42,14 +42,14 @@ describe("convertMarkdownToTeamsHTML", () => { const markdown = "## Heading 1\nContent" const result = convertMarkdownToTeamsHTML(markdown) assert.strictEqual(result.subject, undefined) - assert.strictEqual(result.content, "

Heading 1

\nContent") + 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" + "

- Item 1\n
- Item 2\n
- Item 3
" ) }) @@ -60,13 +60,13 @@ describe("convertMarkdownToTeamsHTML", () => { assert.strictEqual(result.subject, "Subject") assert.strictEqual( result.content, - "

Heading 1

\nContent with bold, italic, code, and strike.\n
- List item 1\n
- List item 2\n
Blockquote
" + "
\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") + assert.strictEqual(result.content, "
Paragraph 1\n\nParagraph 2
") }) }) From 9a1fa8db39d544f1a128ad2bcba0779acbfcbcd8 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Wed, 5 Feb 2025 22:20:04 +0000 Subject: [PATCH 12/12] updated prr --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",