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

add teams runtime #1095

Merged
merged 18 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
-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

Check warning on line 41 in docs/src/content/docs/reference/cli/commands.md

View workflow job for this annotation

GitHub Actions / build

The `-tm` option for posting a message to the teams channel is missing a description.
pelikhan marked this conversation as resolved.
Show resolved Hide resolved
-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
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/nodehost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ModelConfigurations> {
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export interface Host {
export interface RuntimeHost extends Host {
project: Project
workspace: Omit<WorkspaceFileSystem, "grep" | "writeCached">

azureToken: AzureTokenResolver
azureServerlessToken: AzureTokenResolver
microsoftGraphToken: AzureTokenResolver

modelAliases: Readonly<ModelConfigurations>
clientLanguageModel?: LanguageModel

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/server/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface PromptScriptRunOptions {
pullRequestComment: string | boolean
pullRequestDescription: string | boolean
pullRequestReviews: boolean
teamsMessage: boolean
outData: string
label: string
temperature: string | number
Expand Down
162 changes: 162 additions & 0 deletions packages/core/src/teams.ts
Original file line number Diff line number Diff line change
@@ -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\/(?<channelId>.+)\/.*\?groupId=(?<teamId>([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<blockquote>AI-generated message by ${info.runUrl ? `<a href="${HTMLEscape(info.runUrl)}">${HTMLEscape(script.id)}</a>` : HTMLEscape(script.id)} may be incorrect</blockquote>\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/<channelId>/<channelName>?groupId=<teamId>
* @param filename
* @returns
*/
async function microsoftTeamsChannelUploadFile(
token: string,
channelUrl: string,
filename: string,
options?: TraceOptions & CancellationOptions
): Promise<MicrosoftTeamsEntity> {
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<MicrosoftTeamsEntity> {
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" + `<attachment id=\"${guid}\"></attachment>`
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
}
2 changes: 2 additions & 0 deletions packages/core/src/testhost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
107 changes: 12 additions & 95 deletions packages/sample/genaisrc/teams.genai.mts
Original file line number Diff line number Diff line change
@@ -1,95 +1,12 @@
import { DefaultAzureCredential } from "@azure/identity"

const { teamId, channelId } =
/^https:\/\/teams.microsoft.com\/*.\/channel\/(?<channelId>.+)\/.*\?groupId=(?<teamId>([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<string> {
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<any> {
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<void> {
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)
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 microsoftTeamsChannelPostMessage(
teams,
"Hello World",
"This message was sent from genaiscript",
{
files: ["src/rag/markdown.md"],
}
)
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading