diff --git a/CHANGELOG.md b/CHANGELOG.md index 795f107..f53732d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ product overview. ## Unreleased +- Teams reaction activation now accepts the exact Heart eyes robot picker shortcut + `:hearteyesrobot:` as well as its bare event ID. πŸ‘ Like is an alternative activation reaction. Native `like` and Graph Unicode + thumbs-up values, including skin-tone variants, use the existing authorized reaction path. + Teams help shows the Heart eyes robot shortcut and Like alternative; legacy robot IDs remain + recognized. A persisted cutoff prevents newly recognized aliases from replaying older reactions. + - Teams help now shows Teams reaction IDs and picker names instead of Slack shortcodes. Heart eyes robot no longer displays the mismatched plain robot glyph, and the guide explains that reaction codes must be selected through the picker rather than sent as text. diff --git a/FEATURES.md b/FEATURES.md index d40e0ba..a672e14 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -12,12 +12,12 @@ Examples use Teams controls and explain their permissions and configuration requirements. The authorized native command responds before an engine turn, in the requesting conversation. Quoting the help card in a group chat continues the same session. -- Reaction labels show Teams picker names and actual IDs: Heart eyes robot (`hearteyesrobot`), +- Reaction labels show Teams picker names and actual IDs: Heart eyes robot (`:hearteyesrobot:`), Stop sign (`stopsign`), and Tick button / Checkmark button (`2705_whiteheavycheckmark`). - Heart eyes robot uses its picker name without a misleading plain robot glyph. The guide explains - that typing an ID as a message does not add a reaction. Teams intake recognizes the documented - robot IDs, - `stopsign` and `2705_whiteheavycheckmark`, plus Unicode/legacy aliases. Robot starts a request + The guide explains that typing an ID as a message does not add a reaction. Teams intake recognizes + both `hearteyesrobot` and the picker shortcut `:hearteyesrobot:` as activation IDs. Like (`like` or + Unicode πŸ‘, including skin tones) is an alternative. Existing robot IDs remain supported alongside + `stopsign` and `2705_whiteheavycheckmark`, plus Unicode/legacy aliases. Activation starts a request as the reactor. Stop cancels only the selected session’s active and queued work with the same author/admin checks as `/stop`. Channel replies and group quotes resolve to their stored root. Tick closes a tracked reminder’s acknowledgment chain from its original or escalation message; @@ -25,7 +25,8 @@ Teams personal follow-up dismissal remains unavailable. All actions pass conversation admission, reactor authorization and the sudo thread gate before any mutation; target text cannot execute a slash command. Graph and native events retain single transport ownership, stable deduplication - and fresh/current reaction checks. Group/channel reactions need configured event delivery and + and fresh/current reaction checks. Newly accepted alias spellings have a persisted subscription + cutoff so older reactions cannot activate on later unrelated Graph updates. Group/channel reactions need configured event delivery and Microsoft permissions. The handlers precede engine dispatch for Stop/Tick. ## Claude login for nested commands diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 63d2154..c69b975 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -2,6 +2,33 @@ ## Microsoft Teams reaction actions acceptance (2026-10-07) +- Shortcut/Like verification: 79 focused tests passed, zero failures/skips. Independent review + identified old-reaction replay when adding aliases; a persisted cutoff fixed it and the updated + review found no blocking defects. Static checks and secret scanning passed. Live event delivery + and private QA registration remain pending; no live trigger success is claimed. + +- Activation shortcut and Like alternative: `test/platform-teams-events.test.js` and + `test/platform-teams-graph-activity.test.js` cover `hearteyesrobot`, exact picker shortcut `:hearteyesrobot:`, native `like`, Unicode πŸ‘ + and valid skin tones, + removal suppression and unsupported emoji rejection. `test/teams-reaction-actions.test.js` + carries the exact Heart eyes robot shortcut and Like through normalization and shared ingest in + personal, group and channel sessions, + checks reactor identity/session target, and rejects unapproved reactors before engine dispatch. + `test/platform-teams-graph-events.test.js` verifies persisted alias-introduction cutoff initialization + before cached subscription reuse and message fetch, preservation on context refresh/renew/restart, + and Graph normalizer tests reject older alias additions and missing cutoffs without changing + legacy robot/control admission. New aliases cannot replay pre-upgrade history on unrelated edits. +- [ ] LIVE (Claude and Codex): use approved beta Teams QA personal, group and channel fixtures + with reaction event delivery enabled. In a group/channel, post an unmentioned message + "Reply exactly LIKE_TRIGGER_OK", then add πŸ‘ **Like** using the Teams reaction picker as an + approved actor. Pass: exactly one response LIKE_TRIGGER_OK in that source session under the + reactor's identity on each engine; no response to removal and no run for an unapproved actor. + Repeat with **Heart eyes robot**, picker shortcut `:hearteyesrobot:`, on a separate fixture. + In personal chat, add either activation reaction to an existing bot reply: pass only if that session resumes once. + Capture the actual reaction type and delivered event when investigating a failed trigger. + A recognized ID alone does not establish event delivery. The failed message link/live fixture + remains requested; private QA registration requires the unavailable requester personal connection. + - Automated: `test/teams-reaction-actions.test.js` exercises native reaction β†’ normalization β†’ shared ingest β†’ control/ack store. Native and Graph Stop additions cancel active and queued requests for personal, group and channel sessions; reject an approved non-author; permit @@ -26,7 +53,7 @@ with "Reply QUEUED_EMOJI_DONE". An approved other actor adds Stop sign to the working reply: pass only if work continues with an author/admin notice. Author adds Stop sign: pass if active work stops, queued work never starts and unrelated sessions continue. Repeat with admin stopper. - Heart eyes robot on "Reply exactly EMOJI_ROBOT_OK" must run once as the reactor on each engine. + Like on "Reply exactly EMOJI_LIKE_OK" must run once as the reactor on each engine. - [ ] LIVE, engine-independent: create a Teams QA reminder with acknowledgment enabled, default Tick button and short escalation interval. Add Tick button to the original reminder, then repeat on a separate reminder’s second notice. Pass: acknowledgment notice, pending ack removed, @@ -67,7 +94,7 @@ static checks and secret scanning passed. The expanded guide still fits one Teams message. - [ ] LIVE (Claude and Codex): in separate approved beta Teams QA group/channel sessions with reaction event delivery enabled, post "Reply exactly ROBOT_MAPPING_OK", then as an approved - test actor add **Heart eyes robot** from the Teams picker. Pass: one run under the reactor's + test actor add πŸ‘ **Like** from the Teams picker. Pass: one run under the reactor's gateway identity replies ROBOT_MAPPING_OK in the source session for each engine. Remove the reaction; delayed removed events must not run. Stop sign and Tick button invoke only their authorized session-stop/reminder-acknowledgment controls (see reaction actions acceptance). @@ -88,7 +115,8 @@ mention it with `/help`. Pass: each guide appears in its source conversation/thread, reads correctly on desktop/mobile, with a distinct title, section dividers, spaced paragraphs, separate reaction/command rows and monospace commands. Check the reaction legend shows - `hearteyesrobot`, `stopsign` and `2705_whiteheavycheckmark` beside their Teams picker names; + `hearteyesrobot` (shown as picker shortcut `:hearteyesrobot:`), `stopsign` and + `2705_whiteheavycheckmark` beside their Teams picker names; no Slack shortcodes or misleading plain robot icon appear. It includes **How to use me** and the complete supported command list, and advertises no Slack-only controls. Quote the group help card with a mentioned `/status`; it must address the original session. No agent turn, runtime diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 2cc52df..a008c1a 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -249,11 +249,13 @@ Task-module dialogs and broadcast mentions remain unavailable. connections, skills, memory, reminders, schedules and background work. In channels and group chats, mention the bot with the command; quote the original message or bot reply in a group chat to address that session. Help is returned by the gateway before invoking an engine. - The reaction legend uses Teams picker names and IDs: Heart eyes robot (`hearteyesrobot`), + The reaction legend uses Teams picker names and IDs: Heart eyes robot (`:hearteyesrobot:`), Stop sign (`stopsign`), and Tick button / Checkmark button (`2705_whiteheavycheckmark`). - Heart eyes robot has no equivalent Unicode glyph, so the guide uses its actual picker name. - Typing an ID as a message does not add a reaction. Robot activation accepts Teams' `hearteyesrobot` - and `smilerobot` event IDs. Microsoft calls the green tick **Checkmark button**, ID + Typing an ID as a message does not add a reaction. Heart eyes robot activates a request as the reactor; bare + `hearteyesrobot` and the picker shortcut `:hearteyesrobot:` are accepted. Like is an alternative; + intake recognizes `like`, Unicode πŸ‘ and their skin-tone variants. Legacy `hearteyesrobot` and + `smilerobot` event IDs remain recognized. New alias recognition starts at a saved subscription + cutoff to prevent old reactions replaying on unrelated updates. Microsoft calls the green tick **Checkmark button**, ID `2705_whiteheavycheckmark`; Stop sign is `stopsign`. See the [Teams reactions reference](https://learn.microsoft.com/en-us/microsoftteams/platform/agents-in-teams/teams-reactions-reference). Stop sign cancels the selected session’s active and queued requests, with author/admin checks. diff --git a/src/gateway/gateway-usage/platforms/msteams/platform.md b/src/gateway/gateway-usage/platforms/msteams/platform.md index 279f701..18a8c9c 100644 --- a/src/gateway/gateway-usage/platforms/msteams/platform.md +++ b/src/gateway/gateway-usage/platforms/msteams/platform.md @@ -58,7 +58,10 @@ complete oversized edits use the authenticated browser settings. ## Reaction controls When Teams reaction events are enabled and delivered to the gateway, **Heart eyes robot** -(`hearteyesrobot`, also Smile robot) starts a request as the reactor. **Stop sign** (`stopsign`) +(`hearteyesrobot`, picker shortcut `:hearteyesrobot:`) starts a request as the reactor. πŸ‘ **Like** +(`like`, or Unicode πŸ‘ with an optional skin tone) is also accepted. Use the reaction picker; +sending a shortcut as text does not add a reaction. Smile robot IDs remain recognized +when Teams delivers them. **Stop sign** (`stopsign`) requests cancellation of that session’s active and queued work; only the author or an administrator can stop it. React to the original message or a bot reply to select the session. **Tick button** (`2705_whiteheavycheckmark`) acknowledges a tracked reminder, including its second notice. diff --git a/src/platforms/msteams/graph-activity.js b/src/platforms/msteams/graph-activity.js index b4c39e7..63766a4 100644 --- a/src/platforms/msteams/graph-activity.js +++ b/src/platforms/msteams/graph-activity.js @@ -5,7 +5,7 @@ import { activityConversationName } from "./conversation-name.js"; import { makeInbound } from "../inbound.js"; import { quotedReplyId, stripMentionTags } from "./activity.js"; -import { teamsReactionAction } from "./reactions.js"; +import { teamsReactionAction, teamsReactionRequiresCutover } from "./reactions.js"; const digest = parts => createHash("sha256").update(JSON.stringify(parts)).digest("hex"); const userId = identity => String(identity?.user?.id || ""); @@ -97,6 +97,10 @@ export async function normalizeGraphEvents(message, row, { botId, resolveMember, if (item.transition !== "added") continue; const action = teamsReactionAction(item.reaction?.reactionType); const stamp = item.modifiedDateTime; + if (teamsReactionRequiresCutover(item.reaction?.reactionType)) { + const cutover = Date.parse(row.reactionAliasesStartedAt); + if (!Number.isFinite(cutover) || !(Date.parse(stamp) > cutover)) continue; + } const actor = userId(item.reaction?.user); // A removed reaction must not start a new run when a delayed notification is fetched. if (!(message.reactions || []).some(reaction => teamsReactionAction(reaction.reactionType) === action && userId(reaction.user) === actor)) continue; diff --git a/src/platforms/msteams/graph-events.js b/src/platforms/msteams/graph-events.js index 6b09b78..1c04f34 100644 --- a/src/platforms/msteams/graph-events.js +++ b/src/platforms/msteams/graph-events.js @@ -65,7 +65,16 @@ export function createTeamsGraphEvents({ auth, notificationUrl, tenantId, store, } return res.status === 204 ? null : res.json(); } + // Persist before fetching history or reusing a live subscription. Once set, this survives + // renewals/restarts; newly recognized aliases must never activate pre-upgrade reactions. + async function prepareReactionAliases(row) { + if (Number.isFinite(Date.parse(row.reactionAliasesStartedAt))) return row; + const prepared = { ...row, reactionAliasesStartedAt: new Date(now()).toISOString() }; + await store.put(prepared); + return prepared; + } async function maintain(row) { + row = await prepareReactionAliases(row); const { resource, apiVersion } = baseResource(row.resource); const current = now(); const sameEndpoint = row.notificationUrl === endpoint.href; @@ -134,11 +143,16 @@ export function createTeamsGraphEvents({ auth, notificationUrl, tenantId, store, } async function processNotifications(accepted) { for (const { event } of accepted) { - const row = (await store.list()).find(item => item.subscriptionId && item.subscriptionId === event?.subscriptionId); - const path = row && messagePath(event, row); - // Uninstall or rotation may revoke an envelope after durable acceptance, before its GET. - if (!row || !path || event.tenantId !== tenantId || !sameSecret(event.clientState, row.clientState) - || !["created", "updated"].includes(event.changeType)) continue; + const row = await serialized(async () => { + const current = (await store.list()).find(item => item.subscriptionId && item.subscriptionId === event?.subscriptionId); + // Uninstall or rotation may revoke an envelope after durable acceptance, before its GET. + if (!current || !messagePath(event, current) || event.tenantId !== tenantId + || !sameSecret(event.clientState, current.clientState) + || !["created", "updated"].includes(event.changeType)) return null; + return prepareReactionAliases(current); + }); + if (!row) continue; + const path = messagePath(event, row); let message; try { message = await request(row.apiVersion || baseResource(row.resource).apiVersion, path); } catch (error) { if (error.status === 404) continue; throw error; } diff --git a/src/platforms/msteams/help.js b/src/platforms/msteams/help.js index 99977c6..90bf9cf 100644 --- a/src/platforms/msteams/help.js +++ b/src/platforms/msteams/help.js @@ -4,7 +4,7 @@ export const TEAMS_HELP_TEXT = '**How to use me**\n\n' + 'In a personal chat, just send your request. In a channel or group chat, select the bot mention and write `@agent your request`, including for commands such as `@agent /help`. Keep channel follow-ups in the same thread and mention me again. In group chats, quote the original message or my reply and mention me to continue or control that session; a new unquoted group message starts a new session.\n\n' + 'β€’ **Act on a message:** Where Teams reaction events are enabled and delivered to the gateway, choose **Heart eyes robot** from the Teams reaction picker to ask me to act on that message. Otherwise, mention me with your request.\n\n' + - 'β€’ **Reaction names:**\n\nHeart eyes robot β€” `hearteyesrobot`\n\nπŸ›‘ Stop sign β€” `stopsign`\n\nβœ… Tick button (Checkmark button) β€” `2705_whiteheavycheckmark`\n\nChoose these names in the Teams reaction picker. The codes above identify the reactions; typing a code as a message does not add a reaction. Heart eyes robot asks me to act on a message. Stop sign stops the selected session’s active and queued work; only its author or an administrator may stop it. Tick button acknowledges a tracked reminder, including a second notice. Removing a reaction does not reopen an acknowledged reminder. Reaction actions require Teams events to be enabled and delivered to the gateway; `/stop` and `/cancel` also remain available.\n\n' + + 'β€’ **Reaction names:**\n\nHeart eyes robot β€” `:hearteyesrobot:`\n\nπŸ›‘ Stop sign β€” `stopsign`\n\nβœ… Tick button (Checkmark button) β€” `2705_whiteheavycheckmark`\n\nChoose these names in the Teams reaction picker. The codes above identify the reactions; typing a code as a message does not add a reaction. Heart eyes robot asks me to act on a message; πŸ‘ Like (`like`) also starts a request. Stop sign stops the selected session’s active and queued work; only its author or an administrator may stop it. Tick button acknowledges a tracked reminder, including a second notice. Removing a reaction does not reopen an acknowledged reminder. Reaction actions require Teams events to be enabled and delivered to the gateway; `/stop` and `/cancel` also remain available.\n\n' + 'β€’ **Voice prompts:** Attach a downloadable voice note and mention me in channels/groups; personal chats need no mention. Voice notes require local Whisper to be enabled and installed. Typed text stays as instructions, and raw audio is never sent to Claude or Codex. If transcription is unavailable, send text or ask an administrator to check Whisper.\n\n' + 'β€’ **Control a live run:** Send `/stop` or `/cancel`, or react πŸ›‘ (**Stop sign**) to a message in that session, to stop its active and queued work. In a channel, mention me in that thread; in a group chat, quote the original message or my reply and mention me. Only the run author or an administrator may stop a request. New requests for the same session queue while I work.\n\n' + 'β€’ **Open files:** Attach a file/image and ask me to read it. Personal-chat uploads download directly; group/channel files require the configured allowed drives and Microsoft permissions. Use `/files [folder]` to browse this conversation’s workspace privately in your personal chat, download files, edit eligible text, or upload files when you have write access. Browser actions require the gateway Public URL. Use `/sendfile ` for a personal file-consent card; accept or decline it there. Native sending supports nonempty files up to 10 MB; larger files use browser download. Open a personal chat with the bot first for private controls.\n\n' + diff --git a/src/platforms/msteams/reactions.js b/src/platforms/msteams/reactions.js index 39edff4..29f76c6 100644 --- a/src/platforms/msteams/reactions.js +++ b/src/platforms/msteams/reactions.js @@ -1,9 +1,23 @@ // Microsoft Teams reaction IDs, plus the Unicode/legacy aliases used by older activities. // These intents are explicit controls; the reacted message body never supplies a command. export function teamsReactionAction(value) { - const name = String(value || '').replace(/\uFE0F/g, '').toLowerCase(); + // Accept the picker shortcut spelling as well as bare event IDs. Unpaired/doubled + // colons stay unknown; normal message text never reaches this reaction mapper. + const name = String(value || '').replace(/\uFE0F/g, '').toLowerCase().trim() + .replace(/^:([a-z0-9_]+(?:-tone[1-5])?):$/, '$1'); + // Like is Teams' standard activation reaction. Graph can return its Unicode form, + // including a skin tone; native activities use the documented `like` ID. + if (/^πŸ‘[\u{1F3FB}-\u{1F3FF}]?$/u.test(name) || /^like(?:-tone[1-5])?$/.test(name)) return 'engage'; if (['πŸ€–', 'robot', 'robot_face', 'smilerobot', 'hearteyesrobot'].includes(name)) return 'engage'; if (['πŸ›‘', 'stopsign', 'stop_sign', 'octagonal_sign'].includes(name)) return 'stop'; if (['βœ…', '2705_whiteheavycheckmark', 'white_check_mark'].includes(name)) return 'ack'; return ''; } + +// These spellings were ignored before the shortcut/Like expansion. Graph history can contain +// old current reactions, so they need a persisted introduction cutoff before replay admission. +export function teamsReactionRequiresCutover(value) { + const raw = String(value || '').replace(/\uFE0F/g, '').toLowerCase(); + return Boolean(teamsReactionAction(value)) && (raw.includes(':') || raw !== raw.trim() + || /^πŸ‘/u.test(raw) || /^like(?:-tone[1-5])?$/.test(raw)); +} diff --git a/test/help-text.test.js b/test/help-text.test.js index ce6ceb5..ec16a55 100644 --- a/test/help-text.test.js +++ b/test/help-text.test.js @@ -11,7 +11,7 @@ const { teamsAdapter } = await import('../src/platforms/msteams.js'); test("Teams /help includes practical workflows and its supported commands", () => { for (const expected of [ '**How to use me**', '`@agent /help`', 'quote the original message', 'choose **Heart eyes robot**', - 'Heart eyes robot β€” `hearteyesrobot`', 'Stop sign β€” `stopsign`', + 'Heart eyes robot β€” `:hearteyesrobot:`', 'πŸ‘ Like (`like`) also starts a request', 'Stop sign β€” `stopsign`', 'Tick button (Checkmark button) β€” `2705_whiteheavycheckmark`', 'typing a code as a message does not add a reaction', 'Tick button acknowledges a tracked reminder', 'Removing a reaction does not reopen', 'local Whisper', 'Only the run author or an administrator', 'requests for the same session queue', diff --git a/test/platform-teams-events.test.js b/test/platform-teams-events.test.js index 3833bb2..2c3e178 100644 --- a/test/platform-teams-events.test.js +++ b/test/platform-teams-events.test.js @@ -20,12 +20,14 @@ test('edit event subtype is explicit; real mention entity is required for the me }); test('supported reaction additions carry explicit action, reactor identity and target', () => { - for (const reaction of ['πŸ€–', 'robot', 'robot_face', 'πŸ€–\uFE0F', 'smilerobot', 'hearteyesrobot']) { + for (const reaction of ['πŸ€–', 'robot', 'robot_face', 'πŸ€–\uFE0F', 'smilerobot', 'hearteyesrobot', ':hearteyesrobot:', ' :HeartEyesRobot: ', 'like', 'LIKE', 'πŸ‘', 'πŸ‘\uFE0F', 'πŸ‘πŸ»', 'πŸ‘πŸΏ', 'like-tone1', 'like-tone5']) { const value = normalize({ type: 'messageReaction', replyToId: 'bot-answer', text: '', entities: [], reactionsAdded: [{ type: reaction }] }); assert.equal(value.reactionAction, 'engage'); assert.equal(value.trigger, 'reaction'); assert.equal(value.replyToId, 'bot-answer'); assert.equal(value.userId, '29:author'); assert.equal(value.mentionsBot, false); } assert.equal(normalize({ type: 'messageReaction', replyToId: 'bot-answer', reactionsRemoved: [{ type: 'robot' }] }), null); - for (const reaction of ['like', 'hearteyes', 'hearteyesdog']) { + assert.equal(normalize({ type: 'messageReaction', replyToId: 'bot-answer', reactionsRemoved: [{ type: ':hearteyesrobot:' }] }), null); + assert.equal(normalize({ text: ':hearteyesrobot:', entities: [] }).trigger, 'message', 'typed shortcut is not a reaction'); + for (const reaction of ['heart', 'hearteyes', 'hearteyesdog', 'like-tone0', 'like-tone6', 'πŸ‘Ž', ':hearteyesrobot', 'hearteyesrobot:', '::hearteyesrobot::']) { assert.equal(normalize({ type: 'messageReaction', replyToId: 'bot-answer', reactionsAdded: [{ type: reaction }] }), null); } for (const [type, action] of [['stopsign', 'stop'], ['πŸ›‘', 'stop'], ['2705_whiteheavycheckmark', 'ack'], ['βœ…', 'ack']]) { @@ -68,6 +70,6 @@ test('signed webhook accepts distinct edits once, authenticates before dispatch test('Graph mode owns edit/reaction events; native new messages remain enabled', async () => { const received = []; const observed = []; const handle = createTeamsWebhook({ appId, botId, graphEventsEnabled: true, jwks: { get: async () => jwk }, onActivity: async (a) => observed.push(a.type), onMessage: async (m) => received.push(m), log: { error() {}, warn() {} } }); - for (const body of [base, { ...base, type: 'messageUpdate', channelData: { eventType: 'editMessage' } }, ...['robot', 'stopsign', '2705_whiteheavycheckmark'].map(type => ({ ...base, type: 'messageReaction', replyToId: 'bot-answer', reactionsAdded: [{ type }] }))]) await handle({ body, headers: { authorization: `Bearer ${token()}` } }, response()); - assert.equal(received.length, 1); assert.equal(received[0].trigger, 'message'); assert.equal(observed.length, 5); + for (const body of [base, { ...base, type: 'messageUpdate', channelData: { eventType: 'editMessage' } }, ...['robot', 'like', 'stopsign', '2705_whiteheavycheckmark'].map(type => ({ ...base, type: 'messageReaction', replyToId: 'bot-answer', reactionsAdded: [{ type }] }))]) await handle({ body, headers: { authorization: `Bearer ${token()}` } }, response()); + assert.equal(received.length, 1); assert.equal(received[0].trigger, 'message'); assert.equal(observed.length, 6); }); diff --git a/test/platform-teams-graph-activity.test.js b/test/platform-teams-graph-activity.test.js index 8d9c8ec..db18366 100644 --- a/test/platform-teams-graph-activity.test.js +++ b/test/platform-teams-graph-activity.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { normalizeGraphEvents } from '../src/platforms/msteams/graph-activity.js'; -const row = { conversationId: 'teams:19:test@thread.v2', startedAt: '2026-09-09T10:00:00Z', context: { conversation: { id: '19:test@thread.v2', conversationType: 'groupchat' }, serviceUrl: 'https://smba.trafficmanager.net/teams/', channelData: { tenant: { id: 'tenant' } } } }; +const row = { conversationId: 'teams:19:test@thread.v2', startedAt: '2026-09-09T10:00:00Z', reactionAliasesStartedAt: '2026-09-09T10:00:00Z', context: { conversation: { id: '19:test@thread.v2', conversationType: 'groupchat' }, serviceUrl: 'https://smba.trafficmanager.net/teams/', channelData: { tenant: { id: 'tenant' } } } }; const reaction = { reactionType: 'πŸ€–', user: { user: { id: 'reactor' } } }; const fixture = () => ({ id: 'message1', messageType: 'message', from: { user: { id: 'author' } }, body: { contentType: 'html', content: '

Handle this & that

' }, reactions: [reaction], messageHistory: [{ actions: 'reactionAdded', modifiedDateTime: '2026-09-09T10:01:00Z', reaction }] }); const opts = { now: () => Date.parse('2026-09-09T11:00:00Z'), botId: '28:bot', resolveMember: async id => ({ id: `29:${id}`, name: id }) }; @@ -15,8 +15,8 @@ test('robot reaction runs as reactor and anchors the original message', async () const legacyId = createHash('sha256').update(JSON.stringify([row.conversationId, 'message1', 'reaction', 'reactor', '2026-09-09T10:01:00Z'])).digest('hex'); assert.equal(event.raw.eventId, legacyId, 'upgrading must not replay already-dispatched robot events'); }); -test('documented Teams robot IDs trigger Graph reactions without granting authority to other emoji', async () => { - for (const reactionType of ['smilerobot', 'hearteyesrobot', 'stopsign', '2705_whiteheavycheckmark']) { +test('Teams activation and control IDs trigger Graph reactions without granting authority to other emoji', async () => { + for (const reactionType of ['like', 'πŸ‘', 'πŸ‘πŸ½', 'like-tone3', 'smilerobot', 'hearteyesrobot', ':hearteyesrobot:', 'stopsign', '2705_whiteheavycheckmark']) { const message = fixture(); message.reactions = [{ ...reaction, reactionType }]; message.messageHistory[0].reaction = message.reactions[0]; @@ -45,7 +45,7 @@ test('history dedup key survives later snapshots but remove/readd gets a distinc assert.notEqual(first.raw.eventId, added.raw.eventId); }); test('removed, historical, nonrobot, missing roster and deleted messages do not trigger', async () => { - for (const change of [m => { m.reactions = []; }, m => { m.messageHistory[0].modifiedDateTime = row.startedAt; }, m => { m.messageHistory = [{ actions: 'reactionRemoved', reaction }]; }, m => { m.deletedDateTime = 'now'; }, m => { m.messageHistory[0].reaction = { ...reaction, reactionType: 'πŸ‘' }; }]) { + for (const change of [m => { m.reactions = []; }, m => { m.messageHistory[0].modifiedDateTime = row.startedAt; }, m => { m.messageHistory = [{ actions: 'reactionRemoved', reaction }]; }, m => { m.deletedDateTime = 'now'; }, m => { m.messageHistory[0].reaction = { ...reaction, reactionType: 'πŸ‘Ž' }; }]) { const message = fixture(); change(message); assert.deepEqual(await normalizeGraphEvents(message, row, opts), []); } assert.deepEqual(await normalizeGraphEvents(fixture(), row, { ...opts, resolveMember: async () => null }), []); @@ -79,3 +79,19 @@ test('channel reply reactions stay in native thread and quoted edits keep the re test('old history never replays after inbox tombstone retention', async () => { assert.deepEqual(await normalizeGraphEvents(fixture(), row, { ...opts, now: () => Date.parse('2026-09-20T11:00:00Z') }), []); }); + +test('expanded reaction aliases require persisted cutoff and cannot replay older history', async () => { + for (const reactionType of ['like', 'πŸ‘', ':hearteyesrobot:', ':stopsign:', ':white_check_mark:']) { + const message = fixture(); + message.reactions = [{ ...reaction, reactionType }]; + message.messageHistory[0].reaction = message.reactions[0]; + message.lastModifiedDateTime = '2026-09-09T10:04:00Z'; // unrelated update must not replay old addition + const beforeUpgrade = { ...row, reactionAliasesStartedAt: '2026-09-09T10:02:00Z' }; + assert.deepEqual(await normalizeGraphEvents(message, beforeUpgrade, opts), []); + assert.deepEqual(await normalizeGraphEvents(message, { ...row, reactionAliasesStartedAt: undefined }, opts), []); + message.messageHistory[0].modifiedDateTime = '2026-09-09T10:03:00Z'; + assert.equal((await normalizeGraphEvents(message, beforeUpgrade, opts)).length, 1); + } + // Existing robot IDs retain their admission and stable dedup behavior on upgrade. + assert.equal((await normalizeGraphEvents(fixture(), { ...row, reactionAliasesStartedAt: undefined }, opts)).length, 1); +}); diff --git a/test/platform-teams-graph-events.test.js b/test/platform-teams-graph-events.test.js index c6d3a7b..df43556 100644 --- a/test/platform-teams-graph-events.test.js +++ b/test/platform-teams-graph-events.test.js @@ -178,3 +178,48 @@ test("Graph acknowledges durable acceptance without waiting for message fetch an await f.service.processNotifications(queued[0]); assert.equal(f.requests.length, count + 1); // DELETE only, revoked snapshot never fetched }); + +test('reaction alias cutoff initializes before cached subscription reuse and persists across maintenance', async () => { + const f = fixture(); + const row = await f.service.ensure(f.row); + assert.equal(row.reactionAliasesStartedAt, '2026-09-09T10:00:00.000Z'); + const stored = f.rows.get(f.row.conversationId); + delete stored.reactionAliasesStartedAt; // a subscription created by the previous version + f.advance(60_000); + await f.service.renew(); // live subscription reused without a provider request + assert.equal(f.requests.length, 1); + const cutoff = f.rows.get(f.row.conversationId).reactionAliasesStartedAt; + assert.equal(cutoff, '2026-09-09T10:01:00.000Z'); + f.advance(21 * 60_000); + await f.service.renew(); + await f.service.ensure(f.row); // context refresh must preserve cutoff + assert.equal(f.rows.get(f.row.conversationId).reactionAliasesStartedAt, cutoff); + await f.service.stop(); + f.service.start(); + await f.service.stop(); + assert.equal(f.rows.get(f.row.conversationId).reactionAliasesStartedAt, cutoff); + const recreated = createTeamsGraphEvents({ + auth: { token: async () => 'test-token' }, tenantId: 'tenant', + notificationUrl: 'https://example.org/api/teams/graph', + now: () => Date.parse('2026-09-09T10:23:00Z'), + store: { list: async () => [...f.rows.values()].map(item => ({ ...item })), + put: async item => f.rows.set(item.conversationId, { ...item }) }, + onMessage: async () => {}, fetchImpl: async () => assert.fail('fresh subscription should be reused'), + }); + await recreated.ensure(f.row); + assert.equal(f.rows.get(f.row.conversationId).reactionAliasesStartedAt, cutoff); +}); + +test('queued notification initializes a missing alias cutoff before fetching message history', async () => { + const f = fixture(); + await f.service.ensure(f.row); + delete f.rows.get(f.row.conversationId).reactionAliasesStartedAt; + f.advance(60_000); + const event = f.notification(); + assert.equal((await f.handle({ value: [event] })).code, 200); + assert.equal(f.messages[0][1].reactionAliasesStartedAt, '2026-09-09T10:01:00.000Z'); + const cutoff = f.rows.get(f.row.conversationId).reactionAliasesStartedAt; + f.advance(60_000); + await f.service.processNotifications([{ event }]); + assert.equal(f.messages[1][1].reactionAliasesStartedAt, cutoff); +}); diff --git a/test/teams-reaction-actions.test.js b/test/teams-reaction-actions.test.js index cb302cc..b34985e 100644 --- a/test/teams-reaction-actions.test.js +++ b/test/teams-reaction-actions.test.js @@ -173,3 +173,37 @@ test('Graph control event identity separates stop/tick and only emits the latest message.reactions = []; assert.deepEqual(await normalizeGraphEvents(message, row, opts), []); }); + +for (const [label, value] of [['Like', 'like'], ['Heart eyes robot shortcut', ':hearteyesrobot:']]) +for (const source of ['native', 'graph']) for (const kind of ['personal', 'groupchat', 'channel']) { + test(`${source} ${label} starts a ${kind} engine turn as the approved reactor`, async () => { + const conversation = { id: `19:activate-${value === 'like' ? 'like' : 'robot'}-${source}-${kind}@thread.v2`, conversationType: kind }; + let event; + if (source === 'native') { + event = normalized(activity(conversation, { type: 'messageReaction', id: 'activation-event', + replyToId: 'target', entities: [], reactionsAdded: [{ type: value }] })); + assert.equal(normalized(activity(conversation, { type: 'messageReaction', + replyToId: 'target', reactionsRemoved: [{ type: value }] })), null); + } else { + const reaction = { reactionType: value === 'like' ? 'πŸ‘' : value, user: { user: { id: 'reactor-aad' } } }; + [event] = await normalizeGraphEvents({ id: 'target', messageType: 'message', + replyToId: kind === 'channel' ? 'channel-root' : null, + from: { user: { id: 'original-author-aad' } }, + body: { content: 'Reply exactly LIKE_TRIGGER_OK' }, reactions: [reaction], + messageHistory: [{ actions: 'reactionAdded', modifiedDateTime: '2026-10-07T10:01:00Z', reaction }] }, + { conversationId: `teams:${conversation.id}`, startedAt: '2026-10-07T10:00:00Z', + reactionAliasesStartedAt: '2026-10-07T10:00:00Z', context: { conversation } }, + { botId, now: () => Date.parse('2026-10-07T11:00:00Z'), resolveMember: async () => ({ id: owner }) }); + } + const calls = []; + const f = fixture(async args => { calls.push(args); return { content: 'LIKE_TRIGGER_OK' }; }); + await f.ingest({ ...event, userId: '29:like-unapproved' }); + assert.equal(calls.length, 0, 'Activation cannot grant gateway authorization'); + await f.ingest(event); + assert.equal(calls.length, 1); + assert.equal(calls[0].authorId, owner); + assert.equal(calls[0].text, event.text); + assert.equal(calls[0].threadKey, kind === 'channel' ? (source === 'graph' ? 'channel-root' : 'target') + : kind === 'groupchat' ? 'group:target' : event.conversationId); + }); +}