talon-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Microsoft Graph API integration — device code auth + chat message polling.
3
+ *
4
+ * Uses the Microsoft Graph PowerShell well-known client ID (no app registration needed).
5
+ * Authenticates via device code flow, then polls a Teams GROUP CHAT for new messages.
6
+ *
7
+ * Key: Chat.Read scope does NOT require admin consent (unlike ChannelMessage.Read.All).
8
+ * Tokens are persisted to disk so re-auth is only needed when refresh tokens expire.
9
+ */
10
+
11
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import writeFileAtomic from "write-file-atomic";
14
+ import { log, logError, logWarn } from "../../util/log.js";
15
+ import { proxyFetch } from "./proxy-fetch.js";
16
+ import { dirs } from "../../util/paths.js";
17
+ import { stripHtml } from "./formatting.js";
18
+
19
+ // ── Constants ────────────────────────────────────────────────────────────────
20
+
21
+ /** Microsoft Graph PowerShell — supports arbitrary delegated scopes, no app registration. */
22
+ const CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
23
+ const TENANT = "organizations";
24
+ const AUTH_BASE = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0`;
25
+ const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
26
+ const SCOPES = "Chat.Read Chat.ReadBasic User.Read offline_access";
27
+
28
+ const TOKEN_FILE = resolve(dirs.data, "teams-tokens.json");
29
+
30
+ // ── Types ────────────────────────────────────────────────────────────────────
31
+
32
+ type StoredTokens = {
33
+ accessToken: string;
34
+ refreshToken: string;
35
+ expiresAt: number; // epoch ms
36
+ chatId?: string;
37
+ chatTopic?: string;
38
+ userId?: string;
39
+ };
40
+
41
+ type DeviceCodeResponse = {
42
+ device_code: string;
43
+ user_code: string;
44
+ verification_uri: string;
45
+ expires_in: number;
46
+ interval: number;
47
+ message: string;
48
+ };
49
+
50
+ type TokenResponse = {
51
+ access_token: string;
52
+ refresh_token?: string;
53
+ expires_in: number;
54
+ scope?: string;
55
+ error?: string;
56
+ error_description?: string;
57
+ };
58
+
59
+ export type ChatMessage = {
60
+ id: string;
61
+ text: string;
62
+ senderName: string;
63
+ senderId: string;
64
+ chatId: string;
65
+ createdDateTime: string;
66
+ messageType: string;
67
+ edited: boolean;
68
+ };
69
+
70
+ // ── Token storage ────────────────────────────────────────────────────────────
71
+
72
+ function loadTokens(): StoredTokens | null {
73
+ try {
74
+ if (existsSync(TOKEN_FILE)) {
75
+ return JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
76
+ }
77
+ } catch { /* corrupt */ }
78
+ return null;
79
+ }
80
+
81
+ function saveTokens(tokens: StoredTokens): void {
82
+ if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
83
+ writeFileAtomic.sync(TOKEN_FILE, JSON.stringify(tokens, null, 2) + "\n");
84
+ }
85
+
86
+ // ── OAuth helpers ────────────────────────────────────────────────────────────
87
+
88
+ async function postForm(url: string, params: Record<string, string>): Promise<Record<string, unknown>> {
89
+ const body = new URLSearchParams(params).toString();
90
+ const resp = await proxyFetch(url, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
93
+ body,
94
+ signal: AbortSignal.timeout(15_000),
95
+ });
96
+ return (await resp.json()) as Record<string, unknown>;
97
+ }
98
+
99
+ async function refreshAccessToken(refreshToken: string): Promise<StoredTokens | null> {
100
+ const data = (await postForm(`${AUTH_BASE}/token`, {
101
+ grant_type: "refresh_token",
102
+ client_id: CLIENT_ID,
103
+ refresh_token: refreshToken,
104
+ scope: SCOPES,
105
+ })) as TokenResponse;
106
+
107
+ if (data.error) {
108
+ logError("teams", `Token refresh failed: ${data.error_description || data.error}`);
109
+ return null;
110
+ }
111
+
112
+ return {
113
+ accessToken: data.access_token,
114
+ refreshToken: data.refresh_token || refreshToken,
115
+ expiresAt: Date.now() + data.expires_in * 1000 - 60_000, // 1 min buffer
116
+ };
117
+ }
118
+
119
+ // ── Device code flow ─────────────────────────────────────────────────────────
120
+
121
+ export async function deviceCodeAuth(): Promise<StoredTokens> {
122
+ const dcResp = (await postForm(`${AUTH_BASE}/devicecode`, {
123
+ client_id: CLIENT_ID,
124
+ scope: SCOPES,
125
+ })) as unknown as DeviceCodeResponse;
126
+
127
+ // Print instructions for the user
128
+ console.log();
129
+ console.log(` To sign in, open: ${dcResp.verification_uri}`);
130
+ console.log(` Enter code: ${dcResp.user_code}`);
131
+ console.log();
132
+ log("teams", `Device code auth: go to ${dcResp.verification_uri} and enter ${dcResp.user_code}`);
133
+
134
+ // Poll for token
135
+ const pollInterval = (dcResp.interval || 5) * 1000;
136
+ const deadline = Date.now() + dcResp.expires_in * 1000;
137
+
138
+ while (Date.now() < deadline) {
139
+ await new Promise((r) => setTimeout(r, pollInterval));
140
+
141
+ const tokenResp = (await postForm(`${AUTH_BASE}/token`, {
142
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
143
+ client_id: CLIENT_ID,
144
+ device_code: dcResp.device_code,
145
+ })) as TokenResponse;
146
+
147
+ if (tokenResp.access_token) {
148
+ log("teams", "Device code auth successful");
149
+ const tokens: StoredTokens = {
150
+ accessToken: tokenResp.access_token,
151
+ refreshToken: tokenResp.refresh_token || "",
152
+ expiresAt: Date.now() + tokenResp.expires_in * 1000 - 60_000,
153
+ };
154
+ saveTokens(tokens);
155
+ return tokens;
156
+ }
157
+
158
+ if (tokenResp.error === "authorization_pending") continue;
159
+ if (tokenResp.error === "slow_down") {
160
+ await new Promise((r) => setTimeout(r, 5000));
161
+ continue;
162
+ }
163
+
164
+ throw new Error(`Auth failed: ${tokenResp.error_description || tokenResp.error}`);
165
+ }
166
+
167
+ throw new Error("Device code auth timed out (15 minutes)");
168
+ }
169
+
170
+ // ── Graph API client ─────────────────────────────────────────────────────────
171
+
172
+ export class GraphClient {
173
+ private tokens: StoredTokens;
174
+
175
+ constructor(tokens: StoredTokens) {
176
+ this.tokens = tokens;
177
+ }
178
+
179
+ private async ensureValidToken(): Promise<string> {
180
+ if (Date.now() < this.tokens.expiresAt) {
181
+ return this.tokens.accessToken;
182
+ }
183
+
184
+ log("teams", "Refreshing access token...");
185
+ const refreshed = await refreshAccessToken(this.tokens.refreshToken);
186
+ if (!refreshed) throw new Error("Token refresh failed — re-authentication needed");
187
+
188
+ this.tokens = { ...this.tokens, ...refreshed };
189
+ saveTokens(this.tokens);
190
+ return this.tokens.accessToken;
191
+ }
192
+
193
+ private async graphGet(path: string): Promise<Record<string, unknown>> {
194
+ const token = await this.ensureValidToken();
195
+ const resp = await proxyFetch(`${GRAPH_BASE}${path}`, {
196
+ headers: { Authorization: `Bearer ${token}` },
197
+ signal: AbortSignal.timeout(15_000),
198
+ });
199
+
200
+ if (!resp.ok) {
201
+ const body = await resp.text().catch(() => "");
202
+ throw new Error(`Graph API ${path} failed: ${resp.status} ${body}`);
203
+ }
204
+
205
+ return (await resp.json()) as Record<string, unknown>;
206
+ }
207
+
208
+ // ── User info ──────────────────────────────────────────────────────────
209
+
210
+ async getMe(): Promise<{ id: string; displayName: string }> {
211
+ const data = await this.graphGet("/me?$select=id,displayName");
212
+ return { id: data.id as string, displayName: data.displayName as string };
213
+ }
214
+
215
+ // ── Chat discovery ─────────────────────────────────────────────────────
216
+
217
+ async listChats(): Promise<Array<{ id: string; topic: string | null; chatType: string }>> {
218
+ const data = await this.graphGet("/me/chats?$top=50");
219
+ const chats = data.value as Array<Record<string, unknown>>;
220
+ return chats.map((c) => ({
221
+ id: c.id as string,
222
+ topic: (c.topic as string) || null,
223
+ chatType: c.chatType as string,
224
+ }));
225
+ }
226
+
227
+ // ── Message reading ────────────────────────────────────────────────────
228
+
229
+ async getChatMessages(chatId: string, top = 20): Promise<ChatMessage[]> {
230
+ const data = await this.graphGet(
231
+ `/me/chats/${chatId}/messages?$top=${top}`,
232
+ );
233
+
234
+ const raw = data.value as Array<Record<string, unknown>>;
235
+ return raw
236
+ .filter((m) => (m.messageType as string) === "message")
237
+ .map((m) => {
238
+ const from = m.from as Record<string, unknown> | null;
239
+ const user = from?.user as Record<string, unknown> | null;
240
+ const body = m.body as { contentType: string; content: string } | null;
241
+
242
+ let text = body?.content || "";
243
+ if (body?.contentType === "html") {
244
+ text = stripHtml(text);
245
+ }
246
+
247
+ return {
248
+ id: m.id as string,
249
+ text,
250
+ senderName: (user?.displayName as string) || "Unknown",
251
+ senderId: (user?.id as string) || "",
252
+ chatId,
253
+ createdDateTime: m.createdDateTime as string,
254
+ messageType: m.messageType as string,
255
+ edited: m.lastEditedDateTime != null,
256
+ };
257
+ });
258
+ }
259
+
260
+ // ── Stored config ──────────────────────────────────────────────────────
261
+
262
+ getStoredChatId(): string | undefined { return this.tokens.chatId; }
263
+ getStoredChatTopic(): string | undefined { return this.tokens.chatTopic; }
264
+ getStoredUserId(): string | undefined { return this.tokens.userId; }
265
+
266
+ saveChatConfig(chatId: string, chatTopic: string, userId: string): void {
267
+ this.tokens.chatId = chatId;
268
+ this.tokens.chatTopic = chatTopic;
269
+ this.tokens.userId = userId;
270
+ saveTokens(this.tokens);
271
+ }
272
+ }
273
+
274
+ // ── Init helper ──────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Initialize the Graph client — loads stored tokens or runs device code flow.
278
+ */
279
+ export async function initGraphClient(): Promise<GraphClient> {
280
+ // Clear old tokens that used channel scopes
281
+ const stored = loadTokens();
282
+
283
+ if (stored && stored.refreshToken) {
284
+ log("teams", "Found stored tokens, validating...");
285
+ const refreshed = await refreshAccessToken(stored.refreshToken);
286
+ if (refreshed) {
287
+ const tokens = { ...stored, ...refreshed };
288
+ saveTokens(tokens);
289
+ log("teams", "Tokens refreshed successfully");
290
+ return new GraphClient(tokens);
291
+ }
292
+ logWarn("teams", "Stored tokens expired, re-authenticating...");
293
+ }
294
+
295
+ const tokens = await deviceCodeAuth();
296
+ return new GraphClient(tokens);
297
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Teams frontend — bidirectional messaging via Power Automate + Graph API.
3
+ *
4
+ * SEND (Talon → Teams): POST Adaptive Cards to a Power Automate workflow webhook URL.
5
+ * RECEIVE (Teams → Talon): Poll group chat messages via Microsoft Graph API
6
+ * using Chat.Read scope (no admin consent needed).
7
+ *
8
+ * No Azure AD app registration, no Bot Framework, no admin consent.
9
+ */
10
+
11
+ import type { TalonConfig } from "../../util/config.js";
12
+ import type { ContextManager } from "../../core/types.js";
13
+ import type { Gateway } from "../../core/gateway.js";
14
+ import { log, logError } from "../../util/log.js";
15
+ import { deriveNumericChatId } from "../../util/chat-id.js";
16
+ import { createTeamsActionHandler } from "./actions.js";
17
+ import { splitTeamsMessage, buildAdaptiveCard } from "./formatting.js";
18
+ import { initGraphClient, type GraphClient, type ChatMessage } from "./graph.js";
19
+ import { proxyFetch } from "./proxy-fetch.js";
20
+
21
+ // ── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export type TeamsFrontend = {
24
+ context: ContextManager;
25
+ sendTyping: (chatId: number) => Promise<void>;
26
+ sendMessage: (chatId: number, text: string) => Promise<void>;
27
+ getBridgePort: () => number;
28
+ init: () => Promise<void>;
29
+ start: () => Promise<void>;
30
+ stop: () => Promise<void>;
31
+ };
32
+
33
+ // ── Frontend factory ─────────────────────────────────────────────────────────
34
+
35
+ export function createTeamsFrontend(
36
+ config: TalonConfig,
37
+ gateway: Gateway,
38
+ ): TeamsFrontend {
39
+ const webhookUrl = (config as Record<string, unknown>).teamsWebhookUrl as string;
40
+ const botDisplayName = ((config as Record<string, unknown>).teamsBotDisplayName as string) || "";
41
+ const pollIntervalMs = ((config as Record<string, unknown>).teamsGraphPollMs as number) || 10_000;
42
+ const configChatTopic = ((config as Record<string, unknown>).teamsChatTopic as string) || "";
43
+
44
+ let graphClient: GraphClient | null = null;
45
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
46
+ let lastSeenMessageId: string | null = null;
47
+ let myUserId: string | null = null;
48
+ let polling = false;
49
+
50
+ const context: ContextManager = {
51
+ acquire: (chatId: number, stringId?: string) => gateway.setContext(chatId, stringId),
52
+ release: (chatId: number) => gateway.clearContext(chatId),
53
+ getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
54
+ };
55
+
56
+ return {
57
+ context,
58
+
59
+ // Teams has no typing indicator via webhooks
60
+ sendTyping: async () => {},
61
+
62
+ sendMessage: async (_chatId: number, text: string) => {
63
+ if (!text.trim()) return;
64
+ try {
65
+ const chunks = splitTeamsMessage(text);
66
+ for (const chunk of chunks) {
67
+ const card = buildAdaptiveCard(chunk);
68
+ await proxyFetch(webhookUrl, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify(card),
72
+ signal: AbortSignal.timeout(15_000),
73
+ });
74
+ }
75
+ } catch (err) {
76
+ logError("teams", `sendMessage failed: ${err instanceof Error ? err.message : err}`);
77
+ }
78
+ },
79
+
80
+ getBridgePort: () => gateway.getPort(),
81
+
82
+ async init() {
83
+ // Register action handler with the gateway
84
+ gateway.setFrontendHandler(createTeamsActionHandler(webhookUrl, gateway));
85
+ const port = await gateway.start(19876);
86
+ log("teams", `Gateway on port ${port}`);
87
+
88
+ // Authenticate with Microsoft Graph
89
+ log("teams", "Initializing Microsoft Graph client...");
90
+ graphClient = await initGraphClient();
91
+
92
+ // Get our own user ID (to filter out our own messages)
93
+ const me = await graphClient.getMe();
94
+ myUserId = me.id;
95
+ log("teams", `Authenticated as: ${me.displayName} (${me.id})`);
96
+
97
+ // Discover or load chat
98
+ let chatId = graphClient.getStoredChatId();
99
+
100
+ if (!chatId) {
101
+ log("teams", "No chat configured, discovering...");
102
+ const chats = await graphClient.listChats();
103
+
104
+ if (chats.length === 0) throw new Error("No chats found");
105
+
106
+ // Try to match by topic name if configured
107
+ let selectedChat = chats[0];
108
+ if (configChatTopic) {
109
+ const match = chats.find((c) =>
110
+ c.topic?.toLowerCase().includes(configChatTopic.toLowerCase()),
111
+ );
112
+ if (match) selectedChat = match;
113
+ else log("teams", `No chat matching topic "${configChatTopic}", using most recent`);
114
+ }
115
+
116
+ chatId = selectedChat.id;
117
+ const topic = selectedChat.topic || "(unnamed chat)";
118
+
119
+ graphClient.saveChatConfig(chatId, topic, myUserId);
120
+ log("teams", `Configured chat: ${topic} [${selectedChat.chatType}]`);
121
+ } else {
122
+ log("teams", `Using chat: ${graphClient.getStoredChatTopic() || chatId}`);
123
+ }
124
+
125
+ // Seed lastSeenMessageId from current messages (don't process old messages)
126
+ try {
127
+ const existing = await graphClient.getChatMessages(chatId, 5);
128
+ if (existing.length > 0) {
129
+ lastSeenMessageId = existing[0].id;
130
+ log("teams", `Seeded last message ID: ${lastSeenMessageId}`);
131
+ }
132
+ } catch (err) {
133
+ logError("teams", `Failed to seed messages: ${err instanceof Error ? err.message : err}`);
134
+ }
135
+ },
136
+
137
+ async start() {
138
+ if (!graphClient) throw new Error("Graph client not initialized");
139
+
140
+ const chatId = graphClient.getStoredChatId()!;
141
+ const { execute } = await import("../../core/dispatcher.js");
142
+
143
+ log("teams", "Teams frontend running");
144
+ log("teams", `Send: Power Automate webhook`);
145
+ log("teams", `Receive: Graph API chat polling every ${pollIntervalMs / 1000}s`);
146
+
147
+ // ── Poll loop ──────────────────────────────────────────────────────
148
+ async function poll(): Promise<void> {
149
+ if (polling) return;
150
+ polling = true;
151
+
152
+ try {
153
+ if (!graphClient) return;
154
+ const messages = await graphClient.getChatMessages(chatId, 20);
155
+
156
+ // Find new messages — messages are returned newest first
157
+ // IDs are opaque strings, so compare by createdDateTime
158
+ const newMessages: ChatMessage[] = [];
159
+ for (const msg of messages) {
160
+ if (lastSeenMessageId && msg.id === lastSeenMessageId) break;
161
+ newMessages.push(msg);
162
+ }
163
+
164
+ if (newMessages.length > 0) {
165
+ lastSeenMessageId = newMessages[0].id;
166
+ }
167
+
168
+ // Process in chronological order (oldest first)
169
+ for (const msg of newMessages.reverse()) {
170
+ if (!msg.text.trim()) continue;
171
+ if (msg.edited) continue;
172
+
173
+ // Skip bot/workflow messages by display name (echo loop prevention).
174
+ // We do NOT filter by user ID — the authenticated user also sends
175
+ // real messages that Talon should respond to.
176
+ if (botDisplayName && msg.senderName.toLowerCase() === botDisplayName.toLowerCase()) {
177
+ continue;
178
+ }
179
+
180
+ const numericChatId = deriveNumericChatId(msg.chatId);
181
+ const talonChatId = `teams_chat_${msg.chatId}`;
182
+
183
+ // ── Slash commands ──
184
+ const trimmed = msg.text.trim().toLowerCase();
185
+ if (trimmed === "/reset") {
186
+ const { resetSession } = await import("../../storage/sessions.js");
187
+ const { clearHistory } = await import("../../storage/history.js");
188
+ resetSession(talonChatId);
189
+ clearHistory(talonChatId);
190
+ log("teams", `Session reset by ${msg.senderName}`);
191
+ const card = buildAdaptiveCard("Session cleared.");
192
+ await proxyFetch(webhookUrl, {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify(card),
196
+ signal: AbortSignal.timeout(15_000),
197
+ }).catch(() => {});
198
+ continue;
199
+ }
200
+ if (trimmed === "/status") {
201
+ const { getSessionInfo } = await import("../../storage/sessions.js");
202
+ const info = getSessionInfo(talonChatId);
203
+ const u = info.usage;
204
+ const cacheHit = (u.totalInputTokens + u.totalCacheRead) > 0
205
+ ? Math.round((u.totalCacheRead / (u.totalInputTokens + u.totalCacheRead)) * 100) : 0;
206
+ const { getChatSettings } = await import("../../storage/chat-settings.js");
207
+ const model = (getChatSettings(talonChatId).model ?? config.model as string).replace("claude-", "");
208
+ const avgMs = info.turns > 0 ? Math.round(u.totalResponseMs / info.turns) : 0;
209
+ const contextUsed = u.lastPromptTokens;
210
+ const contextMax = model.includes("opus") ? 1_000_000 : model.includes("sonnet") ? 1_000_000 : 200_000;
211
+ const contextPct = contextMax > 0 ? Math.round((contextUsed / contextMax) * 100) : 0;
212
+ const card = {
213
+ type: "message",
214
+ attachments: [{
215
+ contentType: "application/vnd.microsoft.card.adaptive",
216
+ contentUrl: null,
217
+ content: {
218
+ type: "AdaptiveCard",
219
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
220
+ version: "1.4",
221
+ body: [
222
+ { type: "TextBlock", text: "**Session**", wrap: true, size: "Medium", weight: "Bolder" },
223
+ { type: "FactSet", facts: [
224
+ { title: "Model", value: model },
225
+ { title: "Turns", value: String(info.turns) },
226
+ { title: "Context", value: `${(contextUsed / 1000).toFixed(0)}K / ${(contextMax / 1000).toFixed(0)}K (${contextPct}%)` },
227
+ { title: "Cache", value: `${cacheHit}% hit` },
228
+ { title: "Input", value: `${u.totalInputTokens.toLocaleString()} tokens` },
229
+ { title: "Output", value: `${u.totalOutputTokens.toLocaleString()} tokens` },
230
+ { title: "Avg response", value: avgMs > 0 ? `${(avgMs / 1000).toFixed(1)}s` : "—" },
231
+ ]},
232
+ ],
233
+ },
234
+ }],
235
+ };
236
+ await proxyFetch(webhookUrl, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json" },
239
+ body: JSON.stringify(card),
240
+ signal: AbortSignal.timeout(15_000),
241
+ }).catch(() => {});
242
+ continue;
243
+ }
244
+ if (trimmed === "/help") {
245
+ const helpText = "**Commands:**\n- `/reset` — clear session & history\n- `/status` — session stats\n- `/help` — this message";
246
+ const card = buildAdaptiveCard(helpText);
247
+ await proxyFetch(webhookUrl, {
248
+ method: "POST",
249
+ headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify(card),
251
+ signal: AbortSignal.timeout(15_000),
252
+ }).catch(() => {});
253
+ continue;
254
+ }
255
+
256
+ log("teams", `[${msg.senderName}]: ${msg.text.slice(0, 80)}${msg.text.length > 80 ? "..." : ""}`);
257
+
258
+ execute({
259
+ chatId: talonChatId,
260
+ numericChatId,
261
+ prompt: `[${msg.senderName}]: ${msg.text}`,
262
+ senderName: msg.senderName,
263
+ isGroup: true,
264
+ source: "message",
265
+ onStreamDelta: (_accumulated, phase) => {
266
+ if (phase) log("teams", ` phase: ${phase}`);
267
+ },
268
+ onToolUse: (toolName, input) => {
269
+ const detail = (input.description ?? input.command ?? input.action ?? input.query ?? input.url ?? input.name ?? "") as string;
270
+ log("teams", ` tool: ${toolName}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`);
271
+ },
272
+ }).then(async (result) => {
273
+ // Only deliver messages sent via the send_message tool.
274
+ // Do NOT send fallback text — if Claude chose not to use send_message,
275
+ // it's either choosing not to respond or outputting internal reasoning
276
+ // that shouldn't be shown to users.
277
+ if (result.bridgeMessageCount === 0 && result.text?.trim()) {
278
+ log("teams", `Suppressed fallback text (${result.text.length} chars) — no send_message tool used`);
279
+ }
280
+ }).catch((err) => {
281
+ logError("teams", `execute failed: ${err instanceof Error ? err.message : err}`);
282
+ });
283
+ }
284
+ } catch (err) {
285
+ logError("teams", `Poll error: ${err instanceof Error ? err.message : err}`);
286
+ } finally {
287
+ polling = false;
288
+ }
289
+ }
290
+
291
+ // Initial poll, then interval
292
+ await poll();
293
+ pollTimer = setInterval(poll, pollIntervalMs);
294
+
295
+ // Hold process open
296
+ await new Promise(() => {});
297
+ },
298
+
299
+ async stop() {
300
+ if (pollTimer) {
301
+ clearInterval(pollTimer);
302
+ pollTimer = null;
303
+ }
304
+ await gateway.stop();
305
+ log("teams", "Teams frontend stopped");
306
+ },
307
+ };
308
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Proxy-aware fetch — uses HTTP_PROXY/HTTPS_PROXY env vars if set.
3
+ * Node's built-in fetch() does NOT respect proxy env vars by default.
4
+ */
5
+
6
+ import { ProxyAgent } from "undici";
7
+
8
+ const proxyUrl =
9
+ process.env.https_proxy ||
10
+ process.env.HTTPS_PROXY ||
11
+ process.env.http_proxy ||
12
+ process.env.HTTP_PROXY ||
13
+ "";
14
+
15
+ const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
16
+
17
+ /**
18
+ * Drop-in replacement for global fetch() that routes through the system proxy.
19
+ */
20
+ export async function proxyFetch(
21
+ url: string | URL,
22
+ init?: RequestInit & { dispatcher?: unknown },
23
+ ): Promise<Response> {
24
+ return fetch(url, {
25
+ ...init,
26
+ ...(dispatcher ? { dispatcher } : {}),
27
+ } as RequestInit);
28
+ }