Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9abb4cae | |||
| 65fb9a52d9 | |||
| 513b3ca76f | |||
| 611be70362 |
@@ -2,19 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## v1.0.0-beta3 - 2025-12-20
|
|
||||||
|
|
||||||
- Teams Notifications
|
|
||||||
- Fixed webhook 400 error by adding `contentUrl: null` to Adaptive Card attachment payload.
|
|
||||||
- Reformatted card from FactSet to TextBlocks with markdown bold labels (`**Label:** value`) and `spacing: 'None'` for compact rows.
|
|
||||||
- Added timestamp to notification card.
|
|
||||||
- Removed folder button/actions from card - notifications now show job info only.
|
|
||||||
- Removed unused `buildMessageCard` function and simplified `buildAdaptiveCard`/`notifyTeamsChannel` signatures.
|
|
||||||
- Excel Sync
|
|
||||||
- Fixed Active formula being overwritten: `updateExcelFolderLink` now uses Graph API `cell(row,column)` method to update only the Job_Folder_Link cell, preserving formulas in other columns.
|
|
||||||
- Versioning
|
|
||||||
- Bumped `package.json` to `1.0.0-beta3`.
|
|
||||||
|
|
||||||
## v1.0.0-beta2 - 2025-12-19
|
## v1.0.0-beta2 - 2025-12-19
|
||||||
|
|
||||||
- UI
|
- UI
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Microsoft Teams API Limitations & Workarounds
|
||||||
|
|
||||||
|
## Problem: Cannot Delete Channel Messages
|
||||||
|
|
||||||
|
When attempting to delete Teams channel messages via the Microsoft Graph REST API, you'll receive an **HTTP 405 (Method Not Allowed)** error.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
Microsoft Teams Graph API **does not support deletion of channel messages** through the standard REST API endpoint:
|
||||||
|
```
|
||||||
|
DELETE /teams/{teamId}/channels/{channelId}/messages/{messageId}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **platform limitation by Microsoft**, not an application error.
|
||||||
|
|
||||||
|
### Why This Limitation Exists
|
||||||
|
|
||||||
|
- **Security & Compliance**: Teams maintains message immutability for audit/compliance purposes in many organizations.
|
||||||
|
- **Design Decision**: Microsoft intentionally restricts programmatic message deletion to prevent data loss.
|
||||||
|
- **Channel vs. Chat**: The limitation applies specifically to *channel messages*; 1:1 chat messages have different permission models.
|
||||||
|
|
||||||
|
## Workarounds
|
||||||
|
|
||||||
|
### Option 1: Delete via Teams Client (Recommended for Testing)
|
||||||
|
1. Open Microsoft Teams
|
||||||
|
2. Navigate to the channel
|
||||||
|
3. Right-click on the message
|
||||||
|
4. Select "Delete"
|
||||||
|
5. Confirm deletion
|
||||||
|
|
||||||
|
This works because Teams client has special privileges that REST API doesn't.
|
||||||
|
|
||||||
|
### Option 2: Use Delegated Permissions (For Production Apps)
|
||||||
|
|
||||||
|
If you need programmatic deletion, you can:
|
||||||
|
|
||||||
|
1. **Configure Azure AD App Registration** with delegated permissions:
|
||||||
|
- Add permission: `ChatMessage.ReadWrite.All` (delegated)
|
||||||
|
- Requires admin consent
|
||||||
|
- Must use user authentication (not app-only)
|
||||||
|
|
||||||
|
2. **Obtain User's Access Token** (instead of app-only token):
|
||||||
|
```typescript
|
||||||
|
// Example: Interactive login or on-behalf-of flow
|
||||||
|
const userToken = await acquireTokenInteractive();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update Delete Endpoint** to accept delegated tokens:
|
||||||
|
```typescript
|
||||||
|
const client = Client.init({
|
||||||
|
authProvider: (done) => done(null, userToken)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test Deletion**:
|
||||||
|
- Provide the user token in the Authorization header
|
||||||
|
- Note: May still fail if user lacks permissions in the team/channel
|
||||||
|
|
||||||
|
### Option 3: Use Microsoft Teams Bot
|
||||||
|
|
||||||
|
Create a Teams bot with `ChatMessage.ReadWrite.All` permission in bot-specific context. This requires:
|
||||||
|
- Registering a Teams bot
|
||||||
|
- Setting up bot credentials
|
||||||
|
- Handling message events through bot framework
|
||||||
|
|
||||||
|
More complex but potentially the most flexible long-term solution.
|
||||||
|
|
||||||
|
## Current Application Status
|
||||||
|
|
||||||
|
### What Works ✅
|
||||||
|
- List Teams channels
|
||||||
|
- List channel messages with full details
|
||||||
|
- Preview message content and Adaptive Cards
|
||||||
|
- Select and mark messages for deletion
|
||||||
|
|
||||||
|
### What Doesn't Work ❌
|
||||||
|
- Delete channel messages via REST API (Microsoft limitation)
|
||||||
|
- No workaround without changing auth strategy
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
For **testing/cleanup scenarios**:
|
||||||
|
- Use the Teams client UI to manually delete test messages
|
||||||
|
- Or use the UI to identify which messages to delete, then delete them manually
|
||||||
|
|
||||||
|
For **production scenarios**:
|
||||||
|
- Implement delegated token authentication with user consent
|
||||||
|
- OR use a Teams bot with appropriate permissions
|
||||||
|
- OR accept that message deletion requires Teams client interaction
|
||||||
|
|
||||||
|
## Error Response
|
||||||
|
|
||||||
|
When deletion is attempted:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "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.",
|
||||||
|
"isTeamsLimitation": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `isTeamsLimitation` flag helps distinguish between actual errors and known platform limitations.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Microsoft Teams Documentation: [Update a chatMessage](https://learn.microsoft.com/en-us/graph/api/chatmessage-update)
|
||||||
|
- Graph API Permissions: [ChatMessage Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference#chat-message-permissions)
|
||||||
|
- Teams Bot Framework: [Microsoft Teams Bot Framework](https://learn.microsoft.com/en-us/azure/bot-service/channel-connect-teams)
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
Monitor Microsoft's Graph API changelog for potential changes to message deletion restrictions. This limitation may be relaxed in future API versions.
|
||||||
+1
-12
@@ -536,15 +536,6 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Log and display the folder link when available
|
|
||||||
if (result.jobFolderLink) {
|
|
||||||
console.log(`📁 Folder link: ${result.jobFolderLink}`);
|
|
||||||
const linkContainer = document.getElementById('folderLinkContainer');
|
|
||||||
if (linkContainer) {
|
|
||||||
linkContainer.innerHTML = `<a href="${result.jobFolderLink}" target="_blank" rel="noopener" class="text-blue-600 underline">Open job folder</a>`;
|
|
||||||
linkContainer.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.className = 'p-4 rounded-lg font-medium bg-green-50 text-green-700 border border-green-200';
|
message.className = 'p-4 rounded-lg font-medium bg-green-50 text-green-700 border border-green-200';
|
||||||
message.textContent = result.message;
|
message.textContent = result.message;
|
||||||
message.classList.remove('hidden');
|
message.classList.remove('hidden');
|
||||||
@@ -565,10 +556,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Version Info -->
|
<!-- Version Info -->
|
||||||
<!-- Folder link container -->
|
|
||||||
<div id="folderLinkContainer" class="fixed bottom-16 right-4 text-sm text-blue-600 hidden"></div>
|
|
||||||
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
|
<div class="fixed bottom-4 right-4 text-xs text-gray-400">
|
||||||
v1.0.0-beta2
|
v1.0.0-beta3
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Teams Channel Messages</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: { extend: { colors: { primary: '#4c51bf', secondary: '#5b21b6' } } }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gray-50">
|
||||||
|
<div class="max-w-5xl mx-auto p-4 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">Teams Channel Messages</h1>
|
||||||
|
<a href="/index.html" class="text-sm text-blue-600 underline">Back to Form</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info about Teams API Limitation -->
|
||||||
|
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
|
||||||
|
<strong>⚠️ Note:</strong> Microsoft Teams API does not support deletion of channel messages via REST API.
|
||||||
|
To delete messages, either:
|
||||||
|
<ul class="ml-4 mt-2 space-y-1">
|
||||||
|
<li>• Delete directly in Teams (right-click message → Delete)</li>
|
||||||
|
<li>• Provide a delegated user token with <code class="bg-yellow-100 px-1 rounded">ChatMessage.ReadWrite.All</code> permission</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-4 mb-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Team ID</label>
|
||||||
|
<input id="teamId" type="text" placeholder="e.g. e45f..." class="w-full px-3 py-2 border rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Channel ID</label>
|
||||||
|
<input id="channelId" type="text" placeholder="e.g. 19:...@thread.tacv2" class="w-full px-3 py-2 border rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer Token (optional)</label>
|
||||||
|
<input id="bearerToken" type="text" placeholder="Paste delegated token if needed" class="w-full px-3 py-2 border rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Max Results</label>
|
||||||
|
<input id="top" type="number" min="1" max="50" value="50" class="w-full px-3 py-2 border rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button id="loadBtn" class="w-full py-2 bg-primary text-white rounded-lg hover:opacity-95">Load Messages</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="mt-3 text-sm text-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<button id="selectAllBtn" class="px-3 py-2 bg-gray-200 rounded-lg text-sm">Select All</button>
|
||||||
|
<button id="clearSelectionBtn" class="px-3 py-2 bg-gray-200 rounded-lg text-sm">Clear</button>
|
||||||
|
<button id="deleteSelectedBtn" class="px-3 py-2 bg-red-600 text-white rounded-lg text-sm">Delete Selected</button>
|
||||||
|
<span id="deleteSummary" class="ml-2 text-sm text-gray-600"></span>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-700">
|
||||||
|
<input id="toggleRaw" type="checkbox" class="mr-1 align-middle" /> Show raw message JSON
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages List -->
|
||||||
|
<div id="messagesContainer" class="bg-white rounded-xl shadow divide-y"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const teamIdEl = document.getElementById('teamId');
|
||||||
|
const channelIdEl = document.getElementById('channelId');
|
||||||
|
const bearerEl = document.getElementById('bearerToken');
|
||||||
|
const topEl = document.getElementById('top');
|
||||||
|
const loadBtn = document.getElementById('loadBtn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const messagesContainer = document.getElementById('messagesContainer');
|
||||||
|
const selectAllBtn = document.getElementById('selectAllBtn');
|
||||||
|
const clearSelectionBtn = document.getElementById('clearSelectionBtn');
|
||||||
|
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
|
||||||
|
const deleteSummaryEl = document.getElementById('deleteSummary');
|
||||||
|
|
||||||
|
// Helper: URL params
|
||||||
|
function getParams() {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
const p = Object.fromEntries(u.searchParams.entries());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill from last-used IDs if available (from localStorage)
|
||||||
|
const LS_KEYS = { team: 'teamsMsg.teamId', channel: 'teamsMsg.channelId', top: 'teamsMsg.top' };
|
||||||
|
const params = getParams();
|
||||||
|
teamIdEl.value = params.teamId || localStorage.getItem(LS_KEYS.team) || '';
|
||||||
|
channelIdEl.value = params.channelId || localStorage.getItem(LS_KEYS.channel) || '';
|
||||||
|
topEl.value = params.top || localStorage.getItem(LS_KEYS.top) || '50';
|
||||||
|
|
||||||
|
function savePrefs() {
|
||||||
|
localStorage.setItem(LS_KEYS.team, teamIdEl.value.trim());
|
||||||
|
localStorage.setItem(LS_KEYS.channel, channelIdEl.value.trim());
|
||||||
|
localStorage.setItem(LS_KEYS.top, topEl.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(messages) {
|
||||||
|
messagesContainer.innerHTML = '';
|
||||||
|
if (!messages || !messages.length) {
|
||||||
|
messagesContainer.innerHTML = '<div class="p-4 text-sm text-gray-600">No messages found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messages.forEach((m) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'p-4 flex gap-3 items-start';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.className = 'mt-1';
|
||||||
|
checkbox.dataset.messageId = m.id;
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'flex-1';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'flex flex-wrap items-center gap-2 text-sm text-gray-700';
|
||||||
|
const idEl = document.createElement('span');
|
||||||
|
idEl.className = 'font-mono text-xs bg-gray-100 px-1 rounded';
|
||||||
|
idEl.textContent = m.id;
|
||||||
|
const timeEl = document.createElement('span');
|
||||||
|
const dt = m.createdDateTime ? new Date(m.createdDateTime) : null;
|
||||||
|
timeEl.textContent = dt ? dt.toLocaleString() : '';
|
||||||
|
const fromEl = document.createElement('span');
|
||||||
|
fromEl.textContent = m.from ? `by ${m.from}` : '';
|
||||||
|
header.append(idEl, timeEl, fromEl);
|
||||||
|
|
||||||
|
const subjectEl = document.createElement('div');
|
||||||
|
subjectEl.className = 'text-sm font-semibold';
|
||||||
|
subjectEl.textContent = m.subject || m.summary || '';
|
||||||
|
|
||||||
|
// Extract and display Adaptive Card data prominently
|
||||||
|
const cardWrap = document.createElement('div');
|
||||||
|
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||||
|
m.attachments.forEach(a => {
|
||||||
|
if (a.contentType === 'application/vnd.microsoft.card.adaptive' && a.content) {
|
||||||
|
try {
|
||||||
|
const card = typeof a.content === 'string' ? JSON.parse(a.content) : a.content;
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'mt-2 text-xs font-semibold text-gray-700';
|
||||||
|
title.textContent = 'Job Card Details';
|
||||||
|
cardWrap.appendChild(title);
|
||||||
|
|
||||||
|
// Extract key text blocks from card body
|
||||||
|
if (card.body && Array.isArray(card.body)) {
|
||||||
|
card.body.forEach((section) => {
|
||||||
|
if (section.type === 'Container' && Array.isArray(section.items)) {
|
||||||
|
section.items.forEach((item) => {
|
||||||
|
if (item.type === 'TextBlock' && item.text) {
|
||||||
|
const textEl = document.createElement('div');
|
||||||
|
textEl.className = 'text-sm text-gray-800 mt-1';
|
||||||
|
textEl.textContent = item.text;
|
||||||
|
cardWrap.appendChild(textEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show full card JSON in a collapsed details element
|
||||||
|
const cardDetails = document.createElement('details');
|
||||||
|
cardDetails.className = 'mt-2';
|
||||||
|
const cardSummary = document.createElement('summary');
|
||||||
|
cardSummary.className = 'text-xs text-blue-700 cursor-pointer';
|
||||||
|
cardSummary.textContent = 'Show full Adaptive Card JSON';
|
||||||
|
const cardJsonPre = document.createElement('pre');
|
||||||
|
cardJsonPre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-green-50 p-2 rounded border border-green-200';
|
||||||
|
cardJsonPre.textContent = JSON.stringify(card, null, 2);
|
||||||
|
cardDetails.append(cardSummary, cardJsonPre);
|
||||||
|
cardWrap.appendChild(cardDetails);
|
||||||
|
} catch (e) {
|
||||||
|
// If not valid JSON, show raw content
|
||||||
|
const att = document.createElement('div');
|
||||||
|
att.className = 'mt-2 text-xs text-gray-700';
|
||||||
|
att.textContent = `${a.contentType}`;
|
||||||
|
cardWrap.appendChild(att);
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-green-50 p-2 rounded border border-green-200';
|
||||||
|
pre.textContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content, null, 2);
|
||||||
|
cardWrap.appendChild(pre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body content (if present)
|
||||||
|
const bodyWrap = document.createElement('div');
|
||||||
|
if (m.bodyContent) {
|
||||||
|
const bodyType = document.createElement('div');
|
||||||
|
bodyType.className = 'text-[11px] text-gray-500 mt-2';
|
||||||
|
bodyType.textContent = `Body Text (${m.bodyContentType || 'unknown'})`;
|
||||||
|
const bodyText = document.createElement('div');
|
||||||
|
bodyText.className = 'text-xs text-gray-800 whitespace-pre-wrap';
|
||||||
|
// Derive plain text from HTML if needed
|
||||||
|
const tmpDiv = document.createElement('div');
|
||||||
|
tmpDiv.innerHTML = m.bodyContent || '';
|
||||||
|
const plain = (m.bodyContentType === 'html') ? (tmpDiv.textContent || '') : (m.bodyContent || '');
|
||||||
|
bodyText.textContent = plain;
|
||||||
|
const bodyRaw = document.createElement('pre');
|
||||||
|
bodyRaw.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-gray-50 p-2 rounded';
|
||||||
|
bodyRaw.textContent = m.bodyContent || '';
|
||||||
|
bodyWrap.append(bodyType, bodyText, bodyRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw JSON toggle for full fidelity (closed by default)
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'mt-2';
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.className = 'text-xs text-blue-700 cursor-pointer';
|
||||||
|
summary.textContent = 'Show raw message JSON';
|
||||||
|
const rawPre = document.createElement('pre');
|
||||||
|
rawPre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-gray-100 p-2 rounded';
|
||||||
|
rawPre.textContent = JSON.stringify(m.raw || m, null, 2);
|
||||||
|
details.append(summary, rawPre);
|
||||||
|
|
||||||
|
// Keep raw JSON closed by default
|
||||||
|
details.open = false;
|
||||||
|
|
||||||
|
info.append(header, subjectEl, cardWrap, bodyWrap, details);
|
||||||
|
row.append(checkbox, info);
|
||||||
|
messagesContainer.append(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select loaded messages if requested
|
||||||
|
if (params.autoSelect === '1') {
|
||||||
|
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ids param provided, select matching ones (comma or newline separated)
|
||||||
|
if (params.ids) {
|
||||||
|
const ids = params.ids.split(/[,\n\r\s]+/).filter(Boolean);
|
||||||
|
const set = new Set(ids);
|
||||||
|
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||||
|
if (set.has(cb.dataset.messageId)) cb.checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
savePrefs();
|
||||||
|
const teamId = teamIdEl.value.trim();
|
||||||
|
const channelId = channelIdEl.value.trim();
|
||||||
|
const top = Math.min(parseInt(topEl.value || '50', 10) || 50, 50);
|
||||||
|
if (!teamId || !channelId) {
|
||||||
|
statusEl.textContent = 'Enter Team ID and Channel ID.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = 'Loading messages...';
|
||||||
|
try {
|
||||||
|
const url = `/api/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages?top=${top}`;
|
||||||
|
const headers = {};
|
||||||
|
const bearer = bearerEl.value.trim();
|
||||||
|
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
|
||||||
|
const resp = await fetch(url, { headers });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) throw new Error(data.message || 'Failed to fetch messages');
|
||||||
|
renderMessages(data.messages);
|
||||||
|
statusEl.textContent = `Loaded ${data.count} messages.`;
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = `Error: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
const teamId = teamIdEl.value.trim();
|
||||||
|
const channelId = channelIdEl.value.trim();
|
||||||
|
const checkboxes = messagesContainer.querySelectorAll('input[type="checkbox"]:checked');
|
||||||
|
const ids = Array.from(checkboxes).map(cb => cb.dataset.messageId);
|
||||||
|
if (!ids.length) {
|
||||||
|
deleteSummaryEl.textContent = 'No messages selected.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteSummaryEl.textContent = `Deleting ${ids.length}...`;
|
||||||
|
let ok = 0, fail = 0, teamsLimitationHit = false;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const url = `/api/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(id)}`;
|
||||||
|
const headers = {};
|
||||||
|
const bearer = bearerEl.value.trim();
|
||||||
|
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
|
||||||
|
const resp = await fetch(url, { method: 'DELETE', headers });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) {
|
||||||
|
if (data.isTeamsLimitation) {
|
||||||
|
teamsLimitationHit = true;
|
||||||
|
}
|
||||||
|
throw new Error(data.message || 'Delete failed');
|
||||||
|
}
|
||||||
|
ok++;
|
||||||
|
} catch (e) {
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = `Deleted ${ok}, failed ${fail}.`;
|
||||||
|
if (teamsLimitationHit) {
|
||||||
|
summary += ` ⚠️ Note: Microsoft Teams API does not support deletion of channel messages via REST API. `;
|
||||||
|
summary += `Please delete messages directly from Teams (right-click message → Delete). `;
|
||||||
|
summary += `Alternatively, use a delegated user token with ChatMessage.ReadWrite.All permission.`;
|
||||||
|
}
|
||||||
|
deleteSummaryEl.textContent = summary;
|
||||||
|
|
||||||
|
// Only refresh if any deletions succeeded
|
||||||
|
if (ok > 0) {
|
||||||
|
await loadMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBtn.addEventListener('click', loadMessages);
|
||||||
|
selectAllBtn.addEventListener('click', () => {
|
||||||
|
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||||
|
});
|
||||||
|
clearSelectionBtn.addEventListener('click', () => {
|
||||||
|
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||||
|
});
|
||||||
|
deleteSelectedBtn.addEventListener('click', deleteSelected);
|
||||||
|
|
||||||
|
// Global raw JSON toggle
|
||||||
|
document.getElementById('toggleRaw').addEventListener('change', (e) => {
|
||||||
|
const open = e.target.checked;
|
||||||
|
messagesContainer.querySelectorAll('details').forEach(d => {
|
||||||
|
// Only toggle the raw message JSON details (the last details in each row), not card details
|
||||||
|
if (d.querySelector('summary').textContent.includes('raw message JSON')) {
|
||||||
|
d.open = open;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-load when params present
|
||||||
|
if (params.teamId && params.channelId) {
|
||||||
|
// If top is present, ensure numeric cap 50
|
||||||
|
const t = Math.min(parseInt(params.top || '50', 10) || 50, 50);
|
||||||
|
topEl.value = String(t);
|
||||||
|
loadMessages();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,6 +4,7 @@ import { serveStatic } from 'hono/bun';
|
|||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
import { Client } from '@microsoft/microsoft-graph-client';
|
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 { mkdir, appendFile } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -272,7 +273,7 @@ function calculateJobFullName(record: any): string {
|
|||||||
const companyClient = record.Company_Client || record.company_client || '';
|
const companyClient = record.Company_Client || record.company_client || '';
|
||||||
|
|
||||||
const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim());
|
const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim());
|
||||||
return parts.join(' - ');
|
return parts.map(p => String(p).trim()).join(' - ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,6 +376,84 @@ function calculateActive(record: any): boolean {
|
|||||||
return activeStatuses.includes(jobStatus);
|
return activeStatuses.includes(jobStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLANNER TASK CREATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function createPlannerTask({
|
||||||
|
jobNumber,
|
||||||
|
jobFullName,
|
||||||
|
dueDate,
|
||||||
|
}: {
|
||||||
|
jobNumber: string;
|
||||||
|
jobFullName: string;
|
||||||
|
dueDate?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const client = await getGraphClient();
|
||||||
|
|
||||||
|
// Hardcoded assignee for testing: aewing@cardoza.construction
|
||||||
|
const assigneeId = '1a6e9d10-138a-43e4-a09e-1f50463148c9';
|
||||||
|
const assigneeName = 'Alex Ewing'; // Display name
|
||||||
|
|
||||||
|
// Parse due date if provided (ISO format to Date object)
|
||||||
|
let dueDateObj: any = undefined;
|
||||||
|
if (dueDate) {
|
||||||
|
const d = new Date(dueDate);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
dueDateObj = d.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task title: "Disposition New Job Folder for [Job_Number] [Job_Full_Name]"
|
||||||
|
const title = `Disposition New Job Folder for ${jobNumber} ${jobFullName}`;
|
||||||
|
|
||||||
|
// Create the Planner task
|
||||||
|
const groupId = process.env.PLANNER_GROUP_ID;
|
||||||
|
const planId = process.env.PLANNER_PLAN_ID;
|
||||||
|
const bucketId = process.env.PLANNER_BUCKET_ID;
|
||||||
|
|
||||||
|
if (!groupId || !planId || !bucketId) {
|
||||||
|
throw new Error('Missing Planner configuration: PLANNER_GROUP_ID, PLANNER_PLAN_ID, or PLANNER_BUCKET_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTask = await client.api('/planner/tasks').post({
|
||||||
|
planId,
|
||||||
|
bucketId,
|
||||||
|
title,
|
||||||
|
assignments: {
|
||||||
|
[assigneeId]: {
|
||||||
|
'@odata.type': 'microsoft.graph.plannerAssignment',
|
||||||
|
orderHint: ' !',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(dueDateObj ? { dueDateTime: dueDateObj } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the Notes/Description via task details (match the working test script)
|
||||||
|
const taskDetails = await client.api(`/planner/tasks/${newTask.id}/details`).get();
|
||||||
|
const detailsEtag = taskDetails['@odata.etag'];
|
||||||
|
console.log(`[Planner] Task created ${newTask.id}, fetched details ETag: ${detailsEtag}`);
|
||||||
|
await client
|
||||||
|
.api(`/planner/tasks/${newTask.id}/details`)
|
||||||
|
.header('If-Match', detailsEtag)
|
||||||
|
.patch({
|
||||||
|
description: 'Move the new folder to the correct Job Pages folder.',
|
||||||
|
});
|
||||||
|
console.log(`[Planner] Successfully patched description`);
|
||||||
|
|
||||||
|
console.log(`✓ Planner task created: "${title}" assigned to ${assigneeName}`);
|
||||||
|
return { success: true, taskId: newTask.id, title };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('✗ Planner task creation failed:', error.message);
|
||||||
|
await logError('Planner Task Creation', error, {
|
||||||
|
jobNumber,
|
||||||
|
jobFullName,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize table cache on startup
|
// Initialize table cache on startup
|
||||||
async function initializeTableCache() {
|
async function initializeTableCache() {
|
||||||
const client = await getGraphClient();
|
const client = await getGraphClient();
|
||||||
@@ -541,8 +620,19 @@ async function updateExcelFolderLink(pbId: string, link: string) {
|
|||||||
return vals && vals[pbIdColIndex] === pbId;
|
return vals && vals[pbIdColIndex] === pbId;
|
||||||
});
|
});
|
||||||
if (!target) {
|
if (!target) {
|
||||||
console.warn('⚠️ Excel row not found for pb_id:', pbId);
|
// Row was just inserted at index 0, try updating that directly
|
||||||
return;
|
console.warn('⚠️ Excel row not found by pb_id, trying index 0 (just inserted)');
|
||||||
|
try {
|
||||||
|
await client.api(`${excelTablePath}/rows/itemAt(index=0)/range/cell(row=0,column=${linkColIndex})`)
|
||||||
|
.patch({
|
||||||
|
values: [[link || '']]
|
||||||
|
});
|
||||||
|
console.log('✓ Updated Excel Job_Folder_Link cell at index 0 for pb_id:', pbId);
|
||||||
|
return;
|
||||||
|
} catch (fallbackErr: any) {
|
||||||
|
console.error('⚠️ Fallback update at index 0 also failed:', fallbackErr.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update only the Job_Folder_Link cell to preserve formulas in other columns (e.g., Active)
|
// Update only the Job_Folder_Link cell to preserve formulas in other columns (e.g., Active)
|
||||||
@@ -719,6 +809,25 @@ app.post('/api/submit', async (c) => {
|
|||||||
await logError('PocketBase Final Update', pbFinalizeErr, { recordId: record.id, jobFolderLink });
|
await logError('PocketBase Final Update', pbFinalizeErr, { recordId: record.id, jobFolderLink });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create Planner task for the new job (assigned to aewing@cardoza.construction)
|
||||||
|
try {
|
||||||
|
const jobFullName = (record as any).Job_Full_Name || '';
|
||||||
|
|
||||||
|
await createPlannerTask({
|
||||||
|
jobNumber: newJobNumber,
|
||||||
|
jobFullName,
|
||||||
|
dueDate: data.Due_Date || undefined,
|
||||||
|
});
|
||||||
|
console.log('✓ Planner task created successfully');
|
||||||
|
} catch (plannerError: any) {
|
||||||
|
console.error('⚠️ Planner task creation failed, continuing:', plannerError.message);
|
||||||
|
await logError('Planner Task Creation', plannerError, {
|
||||||
|
jobNumber: newJobNumber,
|
||||||
|
recordId: record.id,
|
||||||
|
});
|
||||||
|
// Don't fail the response; Planner is optional
|
||||||
|
}
|
||||||
|
|
||||||
// Send Teams notification (fire and forget, don't block response)
|
// Send Teams notification (fire and forget, don't block response)
|
||||||
notifyTeamsChannel(record).catch(err => {
|
notifyTeamsChannel(record).catch(err => {
|
||||||
console.error('⚠️ Teams notification error:', err.message);
|
console.error('⚠️ Teams notification error:', err.message);
|
||||||
@@ -753,6 +862,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)
|
// Test route: sends an exact sample Adaptive Card payload (no envelope)
|
||||||
app.post('/api/test-notification', async (c) => {
|
app.post('/api/test-notification', async (c) => {
|
||||||
const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL;
|
const webhook = process.env.POWER_AUTOMATE_WEBHOOK_URL || process.env.TEAMS_WEBHOOK_URL;
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Client } from '@microsoft/microsoft-graph-client';
|
||||||
|
|
||||||
|
async function getGraphToken(): Promise<string> {
|
||||||
|
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'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user