-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCode.gs
More file actions
339 lines (291 loc) · 12.5 KB
/
Copy pathCode.gs
File metadata and controls
339 lines (291 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
// ─── v2 Constants ─────────────────────────────────────────────────────────────
var SERVICE_ACCOUNT_EMAIL = 'claude-assistant@claude-doc-assistant.iam.gserviceaccount.com';
var WATCH_DURATION_MS = 6 * 24 * 60 * 60 * 1000; // 6 days in ms
function getServiceBaseUrl() {
var url = PropertiesService.getScriptProperties().getProperty('SERVICE_BASE_URL');
if (!url) throw new Error('SERVICE_BASE_URL script property is not set.');
return url.replace(/\/$/, ''); // strip trailing slash
}
function getWorkerWebhookUrl() { return getServiceBaseUrl() + '/webhook'; }
function getWorkerRegisterUrl() { return getServiceBaseUrl() + '/register'; }
// ─── Menu ────────────────────────────────────────────────────────────────────
function onOpen(e) {
DocumentApp.getUi()
.createAddonMenu()
.addItem('Open Claude Assistant', 'showSidebar')
.addToUi();
}
function onInstall(e) {
onOpen(e);
}
function showSidebar() {
var html = HtmlService.createHtmlOutputFromFile('Sidebar')
.setTitle('Claude Assistant')
.setWidth(350);
DocumentApp.getUi().showSidebar(html);
}
// ─── Settings ────────────────────────────────────────────────────────────────
function getSettings() {
var props = PropertiesService.getUserProperties();
return {
apiKey: props.getProperty('anthropicApiKey') || ''
};
}
// ─── Watch Setup ──────────────────────────────────────────────────────────────
// ─── Per-doc watch helpers ────────────────────────────────────────────────────
function getDocWatch(docId) {
var props = PropertiesService.getUserProperties();
var channelId = props.getProperty('watch_' + docId + '_channelId');
var expiration = parseInt(props.getProperty('watch_' + docId + '_expiration') || '0', 10);
if (!channelId || !expiration) return null;
return {
channelId: channelId,
channelToken: props.getProperty('watch_' + docId + '_channelToken'),
resourceId: props.getProperty('watch_' + docId + '_resourceId'),
expiration: expiration
};
}
function setDocWatch(docId, channelId, channelToken, resourceId, expiration) {
var props = PropertiesService.getUserProperties();
props.setProperty('watch_' + docId + '_channelId', channelId);
props.setProperty('watch_' + docId + '_channelToken', channelToken);
props.setProperty('watch_' + docId + '_resourceId', resourceId);
props.setProperty('watch_' + docId + '_expiration', String(expiration));
// Keep a list of all activated docIds for renewal trigger
var raw = props.getProperty('watchDocIds');
var ids = raw ? JSON.parse(raw) : [];
if (ids.indexOf(docId) === -1) ids.push(docId);
props.setProperty('watchDocIds', JSON.stringify(ids));
}
function clearDocWatch(docId) {
var props = PropertiesService.getUserProperties();
props.deleteProperty('watch_' + docId + '_channelId');
props.deleteProperty('watch_' + docId + '_channelToken');
props.deleteProperty('watch_' + docId + '_resourceId');
props.deleteProperty('watch_' + docId + '_expiration');
var raw = props.getProperty('watchDocIds');
var ids = raw ? JSON.parse(raw) : [];
ids = ids.filter(function(id) { return id !== docId; });
props.setProperty('watchDocIds', JSON.stringify(ids));
}
// ─── Watch Setup ──────────────────────────────────────────────────────────────
function activate(apiKey) {
if (!apiKey || !apiKey.trim()) throw new Error('API key is required.');
apiKey = apiKey.trim();
var props = PropertiesService.getUserProperties();
var docId = DocumentApp.getActiveDocument().getId();
// Step 1: Share doc with service account as commenter
try {
Drive.Permissions.create(
{ role: 'commenter', type: 'user', emailAddress: SERVICE_ACCOUNT_EMAIL },
docId,
{ sendNotificationEmail: false, fields: 'id' }
);
} catch (e) {
if (e.message && e.message.toLowerCase().indexOf('already') === -1 &&
e.message.toLowerCase().indexOf('duplicate') === -1) {
throw new Error('Could not share document with Claude: ' + e.message);
}
}
// Stop any existing watch for this doc before registering a new one
var existing = getDocWatch(docId);
if (existing && existing.channelId && existing.resourceId) {
try {
Drive.Channels.stop({ id: existing.channelId, resourceId: existing.resourceId });
} catch (e) {
Logger.log('Could not stop old channel (may have already expired): ' + e.message);
}
}
// Step 2: Register Drive Changes watch
var channelId = Utilities.getUuid();
var channelToken = Utilities.getUuid();
var expiration = Date.now() + WATCH_DURATION_MS;
var startPageToken = Drive.Changes.getStartPageToken().startPageToken;
var watchResource = {
id: channelId,
type: 'web_hook',
address: getWorkerWebhookUrl(),
token: channelToken,
expiration: String(expiration)
};
var watchResponse = Drive.Changes.watch(watchResource, startPageToken);
var resourceId = watchResponse.resourceId;
// Step 3: Register with Cloud Run service (HMAC-signed)
var activatedAt = Date.now();
var body = JSON.stringify({
channelToken: channelToken,
channelId: channelId,
docId: docId,
anthropicApiKey: apiKey,
activatedAt: activatedAt
});
var registerSecret = PropertiesService.getScriptProperties().getProperty('REGISTER_SECRET');
var timestamp = String(Math.floor(activatedAt / 1000));
var signature = computeHmacSignature(registerSecret, timestamp, body);
var response = UrlFetchApp.fetch(getWorkerRegisterUrl(), {
method: 'post',
headers: {
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'application/json'
},
payload: body,
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) {
throw new Error('Worker registration failed: ' + response.getContentText());
}
// Step 4: Save API key and watch metadata now that registration succeeded
props.setProperty('anthropicApiKey', apiKey);
setDocWatch(docId, channelId, channelToken, resourceId, expiration);
// Step 5: Install daily renewal trigger (one trigger covers all docs)
installRenewalTrigger();
return { success: true, expiresAt: new Date(expiration).toLocaleDateString() };
}
// ─── HMAC-SHA256 request signing ──────────────────────────────────────────────
// Signs outbound requests to Cloud Run so the service can verify they originated
// from Apps Script and were not replayed. Format: HMAC-SHA256(secret, timestamp + "." + body).
function computeHmacSignature(secret, timestamp, body) {
var message = timestamp + '.' + body;
var signatureBytes = Utilities.computeHmacSha256Signature(message, secret);
return Utilities.base64Encode(signatureBytes);
}
function renewWatch() {
var props = PropertiesService.getUserProperties();
var raw = props.getProperty('watchDocIds');
if (!raw) return;
var docIds = JSON.parse(raw);
var now = Date.now();
var twentyFourHours = 24 * 60 * 60 * 1000;
for (var i = 0; i < docIds.length; i++) {
var docId = docIds[i];
var watch = getDocWatch(docId);
if (!watch) continue;
if (watch.expiration - now > twentyFourHours) continue;
// Stop old channel
if (watch.channelId && watch.resourceId) {
try {
Drive.Channels.stop({ id: watch.channelId, resourceId: watch.resourceId });
} catch (e) {
Logger.log('Could not stop old channel for ' + docId + ': ' + e.message);
}
}
// Re-register — needs apiKey, but we can't open a doc by ID from a trigger context.
// Clear the expired entry; user will need to reactivate from the sidebar.
clearDocWatch(docId);
Logger.log('Watch expired for docId=' + docId + ', cleared. User must reactivate.');
// Notify the user so they know to reactivate
try {
var userEmail = Session.getActiveUser().getEmail();
if (userEmail) {
MailApp.sendEmail({
to: userEmail,
subject: 'Claude Assistant: reactivation required',
body: 'Your Claude Assistant session for document ' + docId + ' has expired.\n\n' +
'Open the document and click "Open Claude Assistant" in the Extensions menu to reactivate.\n\n' +
'This is a one-time notification — Claude will not respond to @claude comments until you reactivate.'
});
}
} catch (e) {
Logger.log('Could not send expiry notification email: ' + e.message);
}
}
}
function installRenewalTrigger() {
var triggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < triggers.length; i++) {
if (triggers[i].getHandlerFunction() === 'renewWatch') {
ScriptApp.deleteTrigger(triggers[i]);
}
}
ScriptApp.newTrigger('renewWatch')
.timeBased()
.everyDays(1)
.create();
}
function getWatchStatus() {
var docId = DocumentApp.getActiveDocument().getId();
var watch = getDocWatch(docId);
if (!watch) return { active: false };
var now = Date.now();
if (watch.expiration < now) {
clearDocWatch(docId);
return { active: false, expired: true };
}
return {
active: true,
expiresAt: new Date(watch.expiration).toLocaleDateString(),
hoursRemaining: Math.floor((watch.expiration - now) / (1000 * 60 * 60))
};
}
function deactivateDoc() {
var docId = DocumentApp.getActiveDocument().getId();
var watch = getDocWatch(docId);
// Stop the Drive watch channel
if (watch && watch.channelId && watch.resourceId) {
try {
Drive.Channels.stop({ id: watch.channelId, resourceId: watch.resourceId });
} catch (e) {
Logger.log('Could not stop channel (may have already expired): ' + e.message);
}
}
// Tell the worker to clear the canonical entry for this doc
if (watch && watch.channelToken) {
try {
var registerSecret = PropertiesService.getScriptProperties().getProperty('REGISTER_SECRET');
var workerUnregisterUrl = getServiceBaseUrl() + '/unregister';
var unregBody = JSON.stringify({ channelToken: watch.channelToken, docId: docId });
var timestamp = String(Math.floor(Date.now() / 1000));
var signature = computeHmacSignature(registerSecret, timestamp, unregBody);
var unregResponse = UrlFetchApp.fetch(workerUnregisterUrl, {
method: 'post',
headers: {
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'application/json'
},
payload: unregBody,
muteHttpExceptions: true
});
if (unregResponse.getResponseCode() !== 200) {
Logger.log('Unregister failed (' + unregResponse.getResponseCode() + '): ' + unregResponse.getContentText());
}
} catch (e) {
Logger.log('Could not unregister from worker: ' + e.message);
}
}
// Clear watch state and API key for this doc's session
clearDocWatch(docId);
PropertiesService.getUserProperties().deleteProperty('anthropicApiKey');
return { success: true };
}
// ─── API Key Validation ───────────────────────────────────────────────────────
function testApiKey(apiKey) {
var options = {
method: 'post',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json'
},
payload: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 10,
messages: [{ role: 'user', content: 'Hi' }]
}),
muteHttpExceptions: true
};
try {
var response = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', options);
if (response.getResponseCode() === 200) return { ok: true };
var body = response.getContentText();
try {
var parsed = JSON.parse(body);
return { ok: false, error: (parsed.error && parsed.error.message) || body };
} catch (e) {
return { ok: false, error: body };
}
} catch (e) {
return { ok: false, error: e.message };
}
}