Skip to content

Commit cefdc92

Browse files
committed
Revert "feat: add inlineData parameter to SSI custom function (deferred — Drive auth blocked) (#19)"
This reverts commit 4882a8b.
1 parent 4882a8b commit cefdc92

6 files changed

Lines changed: 27 additions & 230 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ The Gemini API key must be set as a Script Property (`GEMINI_API_KEY`) in Apps S
109109
- **No Node.js built-ins** — everything runs on Google's servers
110110
- `appsscript.json` must be in `dist/` for clasp push (the build script copies it)
111111
- Drive Advanced Service must be enabled in the Apps Script editor AND declared in `appsscript.json`
112-
- Custom functions run in `AuthMode.CUSTOM_FUNCTION`, which provides only the `spreadsheets.currentonly` scope. `PropertiesService` works (no scope needed). `UrlFetchApp` works for API-key-based calls (e.g. Gemini). `ScriptApp.getOAuthToken()` returns a token, but it is scoped to `spreadsheets.currentonly` only — not to `drive` or any other declared scope. `DriveApp` and other OAuth-requiring services fail with a permissions error.
113-
- **Drive file access from `SSI()` is not supported.** `fetchAndEncodeFile` uses `UrlFetchApp` + `ScriptApp.getOAuthToken()`, but the custom function token lacks the `drive` scope, so Drive API calls return 401. Drive file processing belongs to the `runBatchAI` batch tool (menu-triggered, full auth context). A service account workaround exists but requires sharing every file with the service account email — impractical for a cell formula.
112+
- `PropertiesService.getScriptProperties()` is available in custom functions once the add-on has been authorized by the user (opening the menu triggers authorization)
114113
- `.clasp.json` is generated at deploy time by copying `.clasp.dev.json` or `.clasp.prod.json`
115114

116115
## Code Style

__tests__/customFunctions.test.ts

Lines changed: 3 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,12 @@
1111
fetch: jest.fn(),
1212
};
1313

14-
(globalThis as any).Utilities = {
15-
base64Encode: jest.fn().mockReturnValue("base64data=="),
16-
};
17-
1814
(globalThis as any).PropertiesService = {
1915
getScriptProperties: jest.fn().mockReturnValue({
2016
getProperty: jest.fn().mockReturnValue("test-api-key"),
2117
}),
2218
};
2319

24-
(globalThis as any).ScriptApp = {
25-
getOAuthToken: jest.fn().mockReturnValue("mock-oauth-token"),
26-
};
27-
2820
// ── Import after mocks ─────────────────────────────────────────
2921

3022
import { SSI, TOOL_REGISTRY } from "../src/server/customFunctions";
@@ -41,29 +33,6 @@ function mockOkResponse(text: string): void {
4133
mockFetchResponse({ candidates: [{ content: { parts: [{ text }] } }] });
4234
}
4335

44-
/** Mock one Drive REST API file fetch (metadata + content calls via UrlFetchApp). */
45-
function mockDriveApiFile(): void {
46-
(UrlFetchApp.fetch as jest.Mock)
47-
.mockReturnValueOnce({
48-
getResponseCode: () => 200,
49-
getContentText: () => JSON.stringify({ mimeType: "application/pdf", size: "1024" }),
50-
})
51-
.mockReturnValueOnce({
52-
getResponseCode: () => 200,
53-
getContent: () => [1, 2, 3],
54-
});
55-
}
56-
57-
/** Return the payload sent to the Gemini API, regardless of how many Drive calls preceded it. */
58-
function getGeminiPayload(): Record<string, unknown> {
59-
const calls = (UrlFetchApp.fetch as jest.Mock).mock.calls as unknown[][];
60-
const geminiCall = calls.find((c) => String(c[0]).includes("generativelanguage.googleapis.com"));
61-
return JSON.parse((geminiCall as [string, { payload: string }])[1].payload) as Record<
62-
string,
63-
unknown
64-
>;
65-
}
66-
6736
// ── Tests ──────────────────────────────────────────────────────
6837

6938
describe("SSI", () => {
@@ -112,45 +81,12 @@ describe("SSI", () => {
11281
});
11382
});
11483

115-
// ── inlineData normalization ─────────────────────────────────
116-
117-
describe("inlineData normalization", () => {
118-
const driveUrl = "https://drive.google.com/file/d/abc123defgh456ijklm789nop/view";
119-
const driveUrl2 = "https://drive.google.com/file/d/xyz789defgh456ijklm012abc/view";
120-
121-
it("attaches a single Drive URL as one inline_data part", () => {
122-
mockDriveApiFile();
123-
mockOkResponse("ok");
124-
SSI("prompt", driveUrl);
125-
const payload = getGeminiPayload();
126-
expect((payload.contents as any)[0].parts).toHaveLength(2); // text + inline_data
127-
expect((payload.contents as any)[0].parts[1].inline_data.mime_type).toBe("application/pdf");
128-
});
129-
130-
it("attaches multiple Drive URLs as multiple inline_data parts", () => {
131-
mockDriveApiFile();
132-
mockDriveApiFile();
133-
mockOkResponse("ok");
134-
SSI("prompt", [[driveUrl, driveUrl2]]);
135-
const payload = getGeminiPayload();
136-
expect((payload.contents as any)[0].parts).toHaveLength(3); // text + 2 inline_data
137-
});
138-
139-
it("omits inline_data parts when inlineData is not provided", () => {
140-
mockOkResponse("ok");
141-
SSI("prompt");
142-
const payload = getGeminiPayload();
143-
expect((payload.contents as any)[0].parts).toHaveLength(1);
144-
expect((payload.contents as any)[0].parts[0].inline_data).toBeUndefined();
145-
});
146-
});
147-
14884
// ── systemPrompt ─────────────────────────────────────────────
14985

15086
describe("systemPrompt", () => {
15187
it("sets system_instruction when provided", () => {
15288
mockOkResponse("ok");
153-
SSI("prompt", undefined, "Be concise");
89+
SSI("prompt", "Be concise");
15490
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
15591
expect(payload.system_instruction.parts[0].text).toBe("Be concise");
15692
});
@@ -167,14 +103,14 @@ describe("SSI", () => {
167103

168104
describe("toolNames", () => {
169105
it("returns an error string for an unknown tool name", () => {
170-
const result = SSI("prompt", undefined, undefined, "nonExistentTool");
106+
const result = SSI("prompt", undefined, "nonExistentTool");
171107
expect(result).toMatch(/\[SSI Error:.*nonExistentTool/);
172108
});
173109

174110
it("includes a known tool declaration in the API payload", () => {
175111
mockOkResponse("ok");
176112
TOOL_REGISTRY["testTool"] = { name: "testTool", description: "A test tool" };
177-
SSI("prompt", undefined, undefined, "testTool");
113+
SSI("prompt", undefined, "testTool");
178114
delete TOOL_REGISTRY["testTool"];
179115
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
180116
expect(payload.tools[0].function_declarations[0].name).toBe("testTool");

__tests__/drive.test.ts

Lines changed: 15 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,6 @@ const mockUi = {
1010
ButtonSet: { OK: "OK" },
1111
};
1212

13-
(globalThis as any).UrlFetchApp = {
14-
fetch: jest.fn(),
15-
};
16-
17-
(globalThis as any).ScriptApp = {
18-
getOAuthToken: jest.fn().mockReturnValue("mock-oauth-token"),
19-
};
20-
2113
(globalThis as any).Drive = {
2214
Files: {},
2315
};
@@ -147,81 +139,27 @@ describe("extractTextUniversal", () => {
147139
describe("fetchAndEncodeFile", () => {
148140
beforeEach(() => jest.clearAllMocks());
149141

150-
function mockDriveApi(mimeType: string, sizeBytes: number): void {
151-
(UrlFetchApp.fetch as jest.Mock)
152-
.mockReturnValueOnce({
153-
getResponseCode: () => 200,
154-
getContentText: () => JSON.stringify({ mimeType, size: String(sizeBytes) }),
155-
})
156-
.mockReturnValueOnce({
157-
getResponseCode: () => 200,
158-
getContent: () => [1, 2, 3],
159-
});
160-
}
161-
162142
it("returns mime_type and base64-encoded data for a valid file", () => {
163-
mockDriveApi("application/pdf", 1024);
143+
const mockFile = {
144+
getMimeType: () => "application/pdf",
145+
getSize: () => 1024,
146+
getBlob: () => ({ getBytes: () => [1, 2, 3] }),
147+
};
148+
(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile);
149+
164150
const result = fetchAndEncodeFile("file123");
165151
expect(result.mime_type).toBe("application/pdf");
166-
expect(result.data).toBe("base64data==");
152+
expect(result.data).toBe("base64data=="); // matches Utilities mock in this file
167153
});
168154

169155
it("throws when file exceeds 25MB", () => {
170-
// Only need the metadata call — the function throws before fetching content.
171-
(UrlFetchApp.fetch as jest.Mock).mockReturnValueOnce({
172-
getResponseCode: () => 200,
173-
getContentText: () =>
174-
JSON.stringify({ mimeType: "application/pdf", size: String(30 * 1024 * 1024) }),
175-
});
176-
expect(() => fetchAndEncodeFile("bigfile")).toThrow("File too large");
177-
});
178-
179-
it("throws a clear error when the OAuth token is null", () => {
180-
(ScriptApp.getOAuthToken as jest.Mock).mockReturnValueOnce(null);
181-
expect(() => fetchAndEncodeFile("anyId")).toThrow(
182-
"Drive file access requires full OAuth authorization",
183-
);
184-
});
185-
186-
it("throws on Drive metadata API error with message", () => {
187-
(UrlFetchApp.fetch as jest.Mock).mockReturnValueOnce({
188-
getResponseCode: () => 403,
189-
getContentText: () => JSON.stringify({ error: { message: "Insufficient permission" } }),
190-
});
191-
expect(() => fetchAndEncodeFile("badId")).toThrow("Insufficient permission");
192-
});
193-
194-
it("throws a fallback message on Drive metadata error with no body message", () => {
195-
(UrlFetchApp.fetch as jest.Mock).mockReturnValueOnce({
196-
getResponseCode: () => 500,
197-
getContentText: () => JSON.stringify({}),
198-
});
199-
expect(() => fetchAndEncodeFile("badId")).toThrow("Drive metadata request failed (500)");
200-
});
201-
202-
it("throws on Drive content API error with message", () => {
203-
(UrlFetchApp.fetch as jest.Mock)
204-
.mockReturnValueOnce({
205-
getResponseCode: () => 200,
206-
getContentText: () => JSON.stringify({ mimeType: "application/pdf", size: "1024" }),
207-
})
208-
.mockReturnValueOnce({
209-
getResponseCode: () => 404,
210-
getContentText: () => JSON.stringify({ error: { message: "File not found" } }),
211-
});
212-
expect(() => fetchAndEncodeFile("missingId")).toThrow("File not found");
213-
});
156+
const mockFile = {
157+
getMimeType: () => "application/pdf",
158+
getSize: () => 30 * 1024 * 1024,
159+
getBlob: () => ({ getBytes: () => [] }),
160+
};
161+
(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile);
214162

215-
it("throws a fallback message on Drive content error with no body message", () => {
216-
(UrlFetchApp.fetch as jest.Mock)
217-
.mockReturnValueOnce({
218-
getResponseCode: () => 200,
219-
getContentText: () => JSON.stringify({ mimeType: "application/pdf", size: "1024" }),
220-
})
221-
.mockReturnValueOnce({
222-
getResponseCode: () => 403,
223-
getContentText: () => JSON.stringify({}),
224-
});
225-
expect(() => fetchAndEncodeFile("missingId")).toThrow("Drive download failed (403)");
163+
expect(() => fetchAndEncodeFile("bigfile")).toThrow("File too large");
226164
});
227165
});

rollup.config.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,12 @@ function sampleRowsToEvaluation() { _GASEntry.sampleRowsToEvaluation(); }
5858
* Call the Gemini API from a spreadsheet cell.
5959
* @param {string|Array} userTexts One or more text parts for the user message.
6060
* Pass a single string, a cell reference, or a range / array literal.
61-
* @param {string|Array} inlineData Drive URL(s) or file ID(s) to attach as inline data.
62-
* Pass a single URL, a cell reference, or a range / array literal.
6361
* @param {string} systemPrompt System-level instruction for the model.
6462
* @param {string|Array} toolNames Names of pre-registered tools to enable.
6563
* @return {string} The model's text response, or "[SSI Error: ...]" on failure.
6664
* @customfunction
6765
*/
68-
function SSI(userTexts, inlineData, systemPrompt, toolNames) { return _GASEntry.SSI(userTexts, inlineData, systemPrompt, toolNames); }
66+
function SSI(userTexts, systemPrompt, toolNames) { return _GASEntry.SSI(userTexts, systemPrompt, toolNames); }
6967
`,
7068
},
7169
plugins: [

src/server/customFunctions.ts

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,12 @@
77
* - Errors must be returned as strings — thrown exceptions show as generic
88
* script errors in the cell with no useful message
99
* - PropertiesService.getScriptProperties() is available after the add-on
10-
* has been authorized (triggered by opening the ⚡ SSI Toolkit menu).
11-
* - The inlineData parameter is NOT supported from custom function context:
12-
* ScriptApp.getOAuthToken() returns null in bound-script custom functions,
13-
* so Drive file fetching is unavailable. Use the Run AI menu tool instead.
10+
* has been authorized by the user (opening the menu triggers authorization)
1411
* - Range arguments arrive as unknown[][], single cells as raw scalars
1512
*/
1613

1714
import { CONFIG } from "./config";
1815
import { callGeminiAPI } from "./api";
19-
import { fetchAndEncodeFile } from "./drive";
20-
import { extractId } from "./utils";
2116
import type { GeminiFunctionDeclaration } from "../shared/types";
2217

2318
// ── Tool Registry ────────────────────────────────────────────────────────────
@@ -49,22 +44,14 @@ function flattenArg(val: unknown): string[] {
4944
* @param {string|Array} userTexts One or more text parts for the user message.
5045
* Pass a single string, a cell reference, or a range / array literal.
5146
* Example: "Summarize this" or A1 or A1:A3 or {A1,B4,B10}
52-
* @param {string|Array} inlineData Drive URL(s) or file ID(s) to attach as
53-
* inline data. Pass a single URL, a cell reference, or a range / array literal.
54-
* Example: A2 or {A2,A3}
55-
* @param {string} systemPrompt System-level instruction for the model.
47+
* @param {string} [systemPrompt] (Optional) System-level instruction for the model.
5648
* Example: "You are a concise summarizer."
5749
* @param {string|Array} [toolNames] (Optional) Names of pre-registered tools to enable.
5850
* Example: "myTool" or {A5,A6}
5951
* @return {string} The model's text response, or "[SSI Error: ...]" on failure.
6052
* @customfunction
6153
*/
62-
export function SSI(
63-
userTexts: unknown,
64-
inlineData?: unknown,
65-
systemPrompt?: string,
66-
toolNames?: unknown,
67-
): string {
54+
export function SSI(userTexts: unknown, systemPrompt?: string, toolNames?: unknown): string {
6855
try {
6956
// Resolve API key from Script Properties (set via Project Settings)
7057
const apiKey = PropertiesService.getScriptProperties().getProperty(CONFIG.API_KEY_PROPERTY);
@@ -79,21 +66,10 @@ export function SSI(
7966
return decl;
8067
});
8168

82-
// Normalize inlineData: fetch and encode each Drive URL / file ID
83-
const resolvedInlineData =
84-
inlineData != null
85-
? flattenArg(inlineData).map((url) => {
86-
const id = extractId(url);
87-
if (!id) throw new Error(`Could not extract a Drive file ID from: "${url}"`);
88-
return fetchAndEncodeFile(id);
89-
})
90-
: undefined;
91-
9269
return callGeminiAPI({
9370
apiKey,
9471
systemPrompt: systemPrompt || undefined,
9572
userTexts: flattenArg(userTexts),
96-
inlineData: resolvedInlineData?.length ? resolvedInlineData : undefined,
9773
tools: resolvedTools.length ? resolvedTools : undefined,
9874
});
9975
} catch (e) {

src/server/drive.ts

Lines changed: 4 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -68,64 +68,14 @@ export function extractTextUniversal(fileId: string): string {
6868
/**
6969
* Fetch a Drive file by ID and return it as base64-encoded inline data
7070
* ready for the Gemini API. Throws if the file exceeds the 25MB limit.
71-
*
72-
* Uses the Drive REST API via UrlFetchApp rather than DriveApp directly,
73-
* because DriveApp is unavailable in custom function execution contexts.
74-
*
75-
* NOTE: Custom functions in bound scripts run in AuthMode.CUSTOM_FUNCTION,
76-
* which gives ScriptApp.getOAuthToken() a token scoped only to
77-
* spreadsheets.currentonly — not drive. Drive file fetching therefore only
78-
* works from menu-triggered functions (e.g. runBatchAI). Calling this from
79-
* the SSI() custom function will throw with a clear error pointing users to
80-
* runBatchAI. A service account key in Script Properties could bypass this
81-
* (see Google's fact-check sample), but every file would need to be shared
82-
* with the service account email — poor UX for a cell formula.
83-
*
84-
* Requires oauth scope: https://www.googleapis.com/auth/drive.readonly
8571
*/
8672
export function fetchAndEncodeFile(fileId: string): GeminiInlineData {
87-
const token = ScriptApp.getOAuthToken();
88-
if (!token) {
89-
throw new Error(
90-
"Drive file access requires full OAuth authorization, which is not available " +
91-
"in spreadsheet formula context (AuthMode.CUSTOM_FUNCTION). " +
92-
"Use the ⚡ SSI Toolkit menu > Run AI to process Drive files.",
93-
);
94-
}
95-
const headers = { Authorization: `Bearer ${token}` };
96-
97-
const metaResp = UrlFetchApp.fetch(
98-
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=mimeType%2Csize`,
99-
{ headers, muteHttpExceptions: true },
100-
);
101-
if (metaResp.getResponseCode() !== 200) {
102-
const body = JSON.parse(metaResp.getContentText()) as { error?: { message: string } };
103-
throw new Error(
104-
body.error?.message ?? `Drive metadata request failed (${metaResp.getResponseCode()})`,
105-
);
106-
}
107-
const { mimeType, size } = JSON.parse(metaResp.getContentText()) as {
108-
mimeType: string;
109-
size: string;
110-
};
111-
112-
if (parseInt(size, 10) > CONFIG.MAX_FILE_SIZE_BYTES) {
73+
const file = DriveApp.getFileById(fileId);
74+
if (file.getSize() > CONFIG.MAX_FILE_SIZE_BYTES) {
11375
throw new Error("File too large (>25MB).");
11476
}
115-
116-
const contentResp = UrlFetchApp.fetch(
117-
`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`,
118-
{ headers, muteHttpExceptions: true },
119-
);
120-
if (contentResp.getResponseCode() !== 200) {
121-
const body = JSON.parse(contentResp.getContentText()) as { error?: { message: string } };
122-
throw new Error(
123-
body.error?.message ?? `Drive download failed (${contentResp.getResponseCode()})`,
124-
);
125-
}
126-
12777
return {
128-
mime_type: mimeType,
129-
data: Utilities.base64Encode(contentResp.getContent()),
78+
mime_type: file.getMimeType(),
79+
data: Utilities.base64Encode(file.getBlob().getBytes()),
13080
};
13181
}

0 commit comments

Comments
 (0)