Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ms-teams): two-way emoji reaction sync by KrisBraun · Pull Request #200 · plotday/plot · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,21 +261,48 @@ export class GraphApi {
}

/**
* Set a reaction on a Teams channel message. `reactionType` is either
* a Unicode emoji ("💯") or a legacy enum value ("like", "heart",
* "laugh", "surprised", "sad", "angry"). Graph allows one reaction per
* user per message; calling setReaction with a different type replaces
* the user's prior reaction.
* Builds the Graph path for a channel-message reaction. A channel reply
* is addressed through its parent message's `replies` collection, not at
* the top `messages` level, so when `parentMessageId` is supplied and
* differs from `messageId` the reply-scoped endpoint is used.
*/
private channelReactionPath(
teamId: string,
channelId: string,
messageId: string,
action: "setReaction" | "unsetReaction",
parentMessageId?: string
): string {
const base = `${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages`;
if (parentMessageId && parentMessageId !== messageId) {
return `${base}/${parentMessageId}/replies/${messageId}/${action}`;
}
return `${base}/${messageId}/${action}`;
}

/**
* Set a reaction on a Teams channel message or reply. `reactionType` is
* supplied as Unicode ("💯"); Graph also accepts the six legacy enum
* values ("like", "heart", "laugh", "surprised", "sad", "angry"). Pass
* `parentMessageId` when reacting on a reply so the reply-scoped endpoint
* is used.
*/
async setChannelReaction(
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/setReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"setReaction",
parentMessageId
),
{ reactionType }
);
}
Expand All@@ -284,11 +311,18 @@ export class GraphApi {
teamId: string,
channelId: string,
messageId: string,
reactionType: string
reactionType: string,
parentMessageId?: string
): Promise<void> {
await this.call(
"POST",
`${this.baseUrl}/teams/${teamId}/channels/${channelId}/messages/${messageId}/unsetReaction`,
this.channelReactionPath(
teamId,
channelId,
messageId,
"unsetReaction",
parentMessageId
),
{ reactionType }
);
}
Expand Down
111 changes: 95 additions & 16 deletions connectors/ms-teams/src/ms-teams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import {
type NoteWriteBackResult,
type ToolBuilder,
} from "@plotday/twister";
import type { NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import type { Actor, NewActor, NewContact, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -69,14 +69,13 @@ export class MsTeams extends Connector<MsTeams> {
"Sends messages and replies you write in Plot, and can start new chats",
"Reads your team and user profiles to show who's who",
];
// Teams Graph API accepts both legacy enum reaction types (like,
// heart, laugh, surprised, sad, angry) and Unicode emoji directly.
// Teams allows only one reaction per user per message; bidirectional
// write-back is wired through inbound today. For outbound, implement
// `onNoteReactionChanged` — it dispatches per (note, actor, emoji)
// on the reacting user's own connector instance, so the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// can be called under the right user's token.
// Teams accepts any Unicode emoji as a reaction (and the six legacy enum
// types — like/heart/laugh/surprised/sad/angry — on the way in, which
// extractTeamsMessageReactions normalizes to Unicode). Reactions sync
// both ways: inbound through the message transforms, outbound through
// onNoteReactionChanged, which dispatches per (note, actor, emoji) on the
// reacting user's own connector instance and calls the GraphApi
// set/unset reaction helpers under that user's token.
readonly reactionCapabilities = {
mode: "open-unicode" as const,
customEmoji: "none" as const,
Expand DownExpand Up@@ -1003,13 +1002,8 @@ export class MsTeams extends Connector<MsTeams> {
): Promise<NoteWriteBackResult | void> {
if (!note.key) return;

// Reaction write-back: deferred to a follow-up. Teams allows only
// one reaction per user per message via Graph, so a diff-and-apply
// pass needs each reaction dispatched on the acting user's own
// connector instance to attribute it correctly. Wire the new
// GraphApi.setChannelReaction / setChatReaction / unset* helpers
// once note-reaction dispatch is routed per-actor (parallel to
// the schedule_contact → twist_instance_for_actor pattern).
// Reactions are written back separately via onNoteReactionChanged so
// each emoji change is attributed to the user who made it.

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;
Expand DownExpand Up@@ -1057,6 +1051,91 @@ export class MsTeams extends Connector<MsTeams> {
}
}
}

/**
* Pushes a single emoji add/remove the user made in Plot back to Teams.
*
* The runtime dispatches this on the reacting user's own connector
* instance (routed via `twist_instance_for_actor` on the reaction's
* actor), so the token from `getApi` is already that user's — the Graph
* `setReaction`/`unsetReaction` call is attributed correctly with no
* `actAs` step. Teams accepts the Unicode emoji directly, and the
* required scopes (`ChannelMessage.Send` for channels, `Chat.ReadWrite`/
* `ChatMessage.Send` for chats) are already granted.
*
* `note.key` is the reacted message's id. Channel replies are addressed
* through their parent's `replies` collection, so the channel path passes
* `meta.messageId` (the thread's parent message) as the parent — the
* GraphApi helper switches to the reply endpoint when it differs.
*/
async onNoteReactionChanged(
note: Note,
thread: Thread,
_actor: Actor,
emoji: string,
added: boolean
): Promise<void> {
const messageId = note.key;
if (!messageId || !emoji) return;

const meta = thread.meta ?? {};
const syncableId = meta.syncableId as string;

if (syncableId === DM_CHANNEL_ID) {
const chatId = meta.chatId as string;
if (!chatId) {
console.error("No chatId in meta for Teams DM reaction");
return;
}
const api = await this.getApi(DM_CHANNEL_ID);
try {
if (added) {
await api.setChatReaction(chatId, messageId, emoji);
} else {
await api.unsetChatReaction(chatId, messageId, emoji);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams DM reaction:`,
error
);
}
return;
}

const channelId = meta.channelId as string;
const teamId = meta.teamId as string;
if (!channelId || !teamId) {
console.error("Missing meta for Teams channel reaction");
return;
}
const parentMessageId = meta.messageId as string | undefined;
const api = await this.getApi(channelId);
try {
if (added) {
await api.setChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
} else {
await api.unsetChannelReaction(
teamId,
channelId,
messageId,
emoji,
parentMessageId
);
}
} catch (error) {
console.warn(
`Failed to ${added ? "set" : "unset"} Teams channel reaction:`,
error
);
}
}
}

export default MsTeams;
Loading