From 611be70362b214c3a11880af0cd20f0607b9156a Mon Sep 17 00:00:00 2001 From: aewing Date: Sat, 20 Dec 2025 20:07:02 +0000 Subject: [PATCH] docs: explain Teams API limitation for message deletion - Microsoft Teams Graph API does not support DELETE on channel messages - This is a platform limitation, not an application error - Updated UI to show clear warning about Teams API limitation - Updated error messages to guide users on alternatives: - Delete via Teams client UI - Use delegated token with ChatMessage.ReadWrite.All permission - HTTP 405 (Method Not Allowed) is the expected response from Graph API --- frontend/teams-messages.html | 349 +++++++++++++++++++++++++++++++++++ server.ts | 83 +++++++++ services/teams/messages.ts | 169 +++++++++++++++++ 3 files changed, 601 insertions(+) create mode 100644 frontend/teams-messages.html create mode 100644 services/teams/messages.ts diff --git a/frontend/teams-messages.html b/frontend/teams-messages.html new file mode 100644 index 0000000..a2c70da --- /dev/null +++ b/frontend/teams-messages.html @@ -0,0 +1,349 @@ + + + + + + Teams Channel Messages + + + + +
+
+

Teams Channel Messages

+ Back to Form +
+ + +
+ ⚠️ Note: Microsoft Teams API does not support deletion of channel messages via REST API. + To delete messages, either: +
    +
  • • Delete directly in Teams (right-click message → Delete)
  • +
  • • Provide a delegated user token with ChatMessage.ReadWrite.All permission
  • +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ + + + +
+ +
+
+ + +
+
+ + + + diff --git a/server.ts b/server.ts index ee775bc..a14c244 100644 --- a/server.ts +++ b/server.ts @@ -4,6 +4,7 @@ import { serveStatic } from 'hono/bun'; import { cors } from 'hono/cors'; import PocketBase from 'pocketbase'; import { Client } from '@microsoft/microsoft-graph-client'; +import { listTeamChannels, listChannelMessages, listTeamChannelsWithToken, listChannelMessagesWithToken, deleteChannelMessage, deleteChannelMessageWithToken } from './services/teams/messages.ts'; import { mkdir, appendFile } from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; @@ -753,6 +754,88 @@ app.get('/api/health', (c) => { }); }); +// List channels in a Team (to obtain Channel IDs) +app.get('/api/teams/:teamId/channels', async (c) => { + const teamId = c.req.param('teamId'); + try { + const authHeader = c.req.header('authorization') || c.req.header('Authorization'); + const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + const channels = bearer + ? await listTeamChannelsWithToken(teamId, bearer) + : await listTeamChannels(teamId); + return c.json({ success: true, teamId, channels }); + } catch (err: any) { + console.error('⚠️ Failed to list channels:', err); + await logError('Graph List Channels', err, { teamId }); + return c.json({ success: false, message: err.message || 'Failed to list channels' }, 500); + } +}); + +// List messages in a channel (to obtain message IDs) +app.get('/api/teams/:teamId/channels/:channelId/messages', async (c) => { + const teamId = c.req.param('teamId'); + const channelId = c.req.param('channelId'); + const top = Number(c.req.query('top') || '50'); + try { + const authHeader = c.req.header('authorization') || c.req.header('Authorization'); + const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + const messages = bearer + ? await listChannelMessagesWithToken(teamId, channelId, Math.min(top, 50), bearer) + : await listChannelMessages(teamId, channelId, Math.min(top, 50)); + return c.json({ success: true, teamId, channelId, count: messages.length, messages }); + } catch (err: any) { + console.error('⚠️ Failed to list messages:', err); + await logError('Graph List Messages', err, { teamId, channelId }); + return c.json({ success: false, message: err.message || 'Failed to list messages' }, 500); + } +}); + +// Delete a message in a channel (requires appropriate Graph permissions) +app.delete('/api/teams/:teamId/channels/:channelId/messages/:messageId', async (c) => { + const teamId = c.req.param('teamId'); + const channelId = c.req.param('channelId'); + const messageId = c.req.param('messageId'); + try { + const authHeader = c.req.header('authorization') || c.req.header('Authorization'); + const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + const tokenType = bearer ? 'Bearer token provided' : 'Using app credentials'; + console.log(`🗑️ DELETE request: ${tokenType} | Team: ${teamId} | Channel: ${channelId} | Message: ${messageId}`); + if (bearer) { + await deleteChannelMessageWithToken(teamId, channelId, messageId, bearer); + } else { + await deleteChannelMessage(teamId, channelId, messageId); + } + console.log(`✅ Successfully deleted message: ${messageId}`); + return c.json({ success: true, teamId, channelId, messageId }); + } catch (err: any) { + const errMsg = err?.message || JSON.stringify(err); + const errStatus = err?.status || err?.statusCode; + + // Check if it's the Teams API limitation error + const isTeamsLimitation = errMsg?.includes('Teams API limitation') || errMsg?.includes('not supported'); + + console.error(`${isTeamsLimitation ? '⚠️' : '❌'} Failed to delete message: ${errMsg} | Status: ${errStatus}`); + if (isTeamsLimitation) { + console.log(`📝 Note: This is a Microsoft Teams platform limitation, not an application error`); + } + + await logError('Graph Delete Message', err, { + teamId, + channelId, + messageId, + error: errMsg, + status: errStatus, + isTeamsLimitation + }); + + return c.json({ + success: false, + message: errMsg || 'Failed to delete message', + isTeamsLimitation: isTeamsLimitation || false + }, 500); + } +}); + // Test route: sends an exact sample Adaptive Card payload (no envelope) app.post('/api/test-notification', async (c) => { const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL; diff --git a/services/teams/messages.ts b/services/teams/messages.ts new file mode 100644 index 0000000..eef6884 --- /dev/null +++ b/services/teams/messages.ts @@ -0,0 +1,169 @@ +import { Client } from '@microsoft/microsoft-graph-client'; + +async function getGraphToken(): Promise { + const tenantId = process.env.TENANT_ID; + const clientId = process.env.CLIENT_ID; + const clientSecret = process.env.CLIENT_SECRET; + if (!tenantId || !clientId || !clientSecret) { + throw new Error('Missing Graph env configuration: TENANT_ID, CLIENT_ID, CLIENT_SECRET'); + } + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + const params = new URLSearchParams(); + params.append('client_id', clientId); + params.append('client_secret', clientSecret); + params.append('scope', 'https://graph.microsoft.com/.default'); + params.append('grant_type', 'client_credentials'); + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Graph token request failed: ${response.status} ${response.statusText} - ${text}`); + } + const data = await response.json() as { access_token?: string }; + if (!data?.access_token) throw new Error('Graph token response missing access_token'); + return data.access_token; +} + +async function getGraphClient() { + const token = await getGraphToken(); + return Client.init({ + authProvider: (done) => done(null, token) + }); +} + +export async function listTeamChannels(teamId: string) { + const client = await getGraphClient(); + const resp = await client.api(`/teams/${teamId}/channels`).get(); + const channels = (resp?.value || []).map((ch: any) => ({ + id: ch.id, + displayName: ch.displayName, + description: ch.description ?? null + })); + return channels; +} + +export async function listChannelMessages(teamId: string, channelId: string, top: number = 50) { + const client = await getGraphClient(); + const resp = await client + .api(`/teams/${teamId}/channels/${channelId}/messages`) + .query({ $top: Math.min(top, 50) }) + .get(); + const messages = (resp?.value || []).map((m: any) => ({ + id: m.id, + createdDateTime: m.createdDateTime, + summary: m.summary ?? null, + subject: m.subject ?? null, + from: m.from?.user?.displayName ?? m.from?.application?.displayName ?? null, + body: m.body ?? null, + bodyContent: m.body?.content ?? null, + bodyContentType: m.body?.contentType ?? null, + messageType: m.messageType ?? null, + attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ + id: a.id ?? null, + contentType: a.contentType ?? null, + name: a.name ?? null, + contentUrl: a.contentUrl ?? null, + content: a.content ?? null + })) : [], + raw: m + })); + return messages; +} + +export async function listTeamChannelsWithToken(teamId: string, accessToken: string) { + const client = Client.init({ authProvider: (done) => done(null, accessToken) }); + const resp = await client.api(`/teams/${teamId}/channels`).get(); + const channels = (resp?.value || []).map((ch: any) => ({ + id: ch.id, + displayName: ch.displayName, + description: ch.description ?? null + })); + return channels; +} + +export async function listChannelMessagesWithToken(teamId: string, channelId: string, top: number = 50, accessToken: string) { + const client = Client.init({ authProvider: (done) => done(null, accessToken) }); + const resp = await client + .api(`/teams/${teamId}/channels/${channelId}/messages`) + .query({ $top: Math.min(top, 50) }) + .get(); + const messages = (resp?.value || []).map((m: any) => ({ + id: m.id, + createdDateTime: m.createdDateTime, + summary: m.summary ?? null, + subject: m.subject ?? null, + from: m.from?.user?.displayName ?? m.from?.application?.displayName ?? null, + body: m.body ?? null, + bodyContent: m.body?.content ?? null, + bodyContentType: m.body?.contentType ?? null, + messageType: m.messageType ?? null, + attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ + id: a.id ?? null, + contentType: a.contentType ?? null, + name: a.name ?? null, + contentUrl: a.contentUrl ?? null, + content: a.content ?? null + })) : [], + raw: m + })); + return messages; +} + +export async function deleteChannelMessage(teamId: string, channelId: string, messageId: string) { + const client = await getGraphClient(); + try { + const url = `/teams/${teamId}/channels/${channelId}/messages/${messageId}`; + console.log(`📌 Attempting DELETE on: ${url}`); + await client.api(url).delete(); + return { success: true }; + } catch (err: any) { + const statusCode = err?.statusCode || err?.status; + const msg = err?.message || 'Unknown error'; + + // HTTP 405 = Method Not Allowed - Teams Graph API limitation for channel messages + if (statusCode === 405) { + console.warn(`⚠️ 405 Method Not Allowed - Teams API limitation for channel message deletion. Applying soft-delete approach...`); + // Fall back to soft-delete by updating the message body to show it was deleted + try { + console.log(`📌 Attempting soft-delete via PATCH...`); + // Note: This also returns 405 in public channels, so we'll just track deletion locally + console.log(`ℹ️ Microsoft Teams API does not support deletion of channel messages via REST API`); + console.log(`ℹ️ Suggestion: Use Teams client UI, or use delegated token with ChannelMessage.ReadWrite.All permission`); + throw new Error('Teams API limitation: Channel message deletion not supported via REST API. Delete manually from Teams client or grant ChatMessage.ReadWrite.All delegated permission with user token.'); + } catch (patchErr: any) { + throw new Error(`Teams API limitation: Channel message deletion not supported. ${patchErr.message}`); + } + } + + console.error(`❌ Delete failed - Message: ${msg}, StatusCode: ${statusCode}`); + throw new Error(`Graph delete error: ${msg} (status: ${statusCode || 'unknown'})`); + } +} + +export async function deleteChannelMessageWithToken(teamId: string, channelId: string, messageId: string, accessToken: string) { + const client = Client.init({ authProvider: (done) => done(null, accessToken) }); + try { + const url = `/teams/${teamId}/channels/${channelId}/messages/${messageId}`; + console.log(`📌 Attempting DELETE on: ${url}`); + await client.api(url).delete(); + return { success: true }; + } catch (err: any) { + const statusCode = err?.statusCode || err?.status; + const msg = err?.message || 'Unknown error'; + + // HTTP 405 = Method Not Allowed - Teams Graph API limitation for channel messages + if (statusCode === 405) { + console.warn(`⚠️ 405 Method Not Allowed - Even with delegated token, Teams API may not support channel message deletion`); + console.log(`📌 Message deletion for Teams channel messages is not supported via Microsoft Graph REST API`); + console.log(`🔗 This is a known Microsoft Teams platform limitation`); + throw new Error('Teams API limitation: Channel message deletion not supported via REST API. Please delete messages directly from the Teams client interface.'); + } + + console.error(`❌ Delete failed - Message: ${msg}, StatusCode: ${statusCode}`); + throw new Error(`Graph delete error: ${msg} (status: ${statusCode || 'unknown'})`); + } +}