Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ The Gemini API key must be set as a Script Property (`GEMINI_API_KEY`) in Apps S
- **No Node.js built-ins** — everything runs on Google's servers
- `appsscript.json` must be in `dist/` for clasp push (the build script copies it)
- Drive Advanced Service must be enabled in the Apps Script editor AND declared in `appsscript.json`
- `PropertiesService.getScriptProperties()` is available in custom functions once the add-on has been authorized by the user (opening the menu triggers authorization)
- 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.
- **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.
- `.clasp.json` is generated at deploy time by copying `.clasp.dev.json` or `.clasp.prod.json`

## Code Style
Expand Down
70 changes: 67 additions & 3 deletions __tests__/customFunctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@
fetch: jest.fn(),
};

(globalThis as any).Utilities = {
base64Encode: jest.fn().mockReturnValue("base64data=="),
};

(globalThis as any).PropertiesService = {
getScriptProperties: jest.fn().mockReturnValue({
getProperty: jest.fn().mockReturnValue("test-api-key"),
}),
};

(globalThis as any).ScriptApp = {
getOAuthToken: jest.fn().mockReturnValue("mock-oauth-token"),
};

// ── Import after mocks ─────────────────────────────────────────

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

/** Mock one Drive REST API file fetch (metadata + content calls via UrlFetchApp). */
function mockDriveApiFile(): void {
(UrlFetchApp.fetch as jest.Mock)
.mockReturnValueOnce({
getResponseCode: () => 200,
getContentText: () => JSON.stringify({ mimeType: "application/pdf", size: "1024" }),
})
.mockReturnValueOnce({
getResponseCode: () => 200,
getContent: () => [1, 2, 3],
});
}

/** Return the payload sent to the Gemini API, regardless of how many Drive calls preceded it. */
function getGeminiPayload(): Record<string, unknown> {
const calls = (UrlFetchApp.fetch as jest.Mock).mock.calls as unknown[][];
const geminiCall = calls.find((c) => String(c[0]).includes("generativelanguage.googleapis.com"));
return JSON.parse((geminiCall as [string, { payload: string }])[1].payload) as Record<
string,
unknown
>;
}

// ── Tests ──────────────────────────────────────────────────────

describe("SSI", () => {
Expand Down Expand Up @@ -81,12 +112,45 @@ describe("SSI", () => {
});
});

// ── inlineData normalization ─────────────────────────────────

describe("inlineData normalization", () => {
const driveUrl = "https://drive.google.com/file/d/abc123defgh456ijklm789nop/view";
const driveUrl2 = "https://drive.google.com/file/d/xyz789defgh456ijklm012abc/view";

it("attaches a single Drive URL as one inline_data part", () => {
mockDriveApiFile();
mockOkResponse("ok");
SSI("prompt", driveUrl);
const payload = getGeminiPayload();
expect((payload.contents as any)[0].parts).toHaveLength(2); // text + inline_data
expect((payload.contents as any)[0].parts[1].inline_data.mime_type).toBe("application/pdf");
});

it("attaches multiple Drive URLs as multiple inline_data parts", () => {
mockDriveApiFile();
mockDriveApiFile();
mockOkResponse("ok");
SSI("prompt", [[driveUrl, driveUrl2]]);
const payload = getGeminiPayload();
expect((payload.contents as any)[0].parts).toHaveLength(3); // text + 2 inline_data
});

it("omits inline_data parts when inlineData is not provided", () => {
mockOkResponse("ok");
SSI("prompt");
const payload = getGeminiPayload();
expect((payload.contents as any)[0].parts).toHaveLength(1);
expect((payload.contents as any)[0].parts[0].inline_data).toBeUndefined();
});
});

// ── systemPrompt ─────────────────────────────────────────────

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

describe("toolNames", () => {
it("returns an error string for an unknown tool name", () => {
const result = SSI("prompt", undefined, "nonExistentTool");
const result = SSI("prompt", undefined, undefined, "nonExistentTool");
expect(result).toMatch(/\[SSI Error:.*nonExistentTool/);
});

it("includes a known tool declaration in the API payload", () => {
mockOkResponse("ok");
TOOL_REGISTRY["testTool"] = { name: "testTool", description: "A test tool" };
SSI("prompt", undefined, "testTool");
SSI("prompt", undefined, undefined, "testTool");
delete TOOL_REGISTRY["testTool"];
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
expect(payload.tools[0].function_declarations[0].name).toBe("testTool");
Expand Down
92 changes: 77 additions & 15 deletions __tests__/drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const mockUi = {
ButtonSet: { OK: "OK" },
};

(globalThis as any).UrlFetchApp = {
fetch: jest.fn(),
};

(globalThis as any).ScriptApp = {
getOAuthToken: jest.fn().mockReturnValue("mock-oauth-token"),
};

(globalThis as any).Drive = {
Files: {},
};
Expand Down Expand Up @@ -139,27 +147,81 @@ describe("extractTextUniversal", () => {
describe("fetchAndEncodeFile", () => {
beforeEach(() => jest.clearAllMocks());

it("returns mime_type and base64-encoded data for a valid file", () => {
const mockFile = {
getMimeType: () => "application/pdf",
getSize: () => 1024,
getBlob: () => ({ getBytes: () => [1, 2, 3] }),
};
(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile);
function mockDriveApi(mimeType: string, sizeBytes: number): void {
(UrlFetchApp.fetch as jest.Mock)
.mockReturnValueOnce({
getResponseCode: () => 200,
getContentText: () => JSON.stringify({ mimeType, size: String(sizeBytes) }),
})
.mockReturnValueOnce({
getResponseCode: () => 200,
getContent: () => [1, 2, 3],
});
}

it("returns mime_type and base64-encoded data for a valid file", () => {
mockDriveApi("application/pdf", 1024);
const result = fetchAndEncodeFile("file123");
expect(result.mime_type).toBe("application/pdf");
expect(result.data).toBe("base64data=="); // matches Utilities mock in this file
expect(result.data).toBe("base64data==");
});

it("throws when file exceeds 25MB", () => {
const mockFile = {
getMimeType: () => "application/pdf",
getSize: () => 30 * 1024 * 1024,
getBlob: () => ({ getBytes: () => [] }),
};
(DriveApp.getFileById as jest.Mock).mockReturnValue(mockFile);

// Only need the metadata call — the function throws before fetching content.
(UrlFetchApp.fetch as jest.Mock).mockReturnValueOnce({
getResponseCode: () => 200,
getContentText: () =>
JSON.stringify({ mimeType: "application/pdf", size: String(30 * 1024 * 1024) }),
});
expect(() => fetchAndEncodeFile("bigfile")).toThrow("File too large");
});

it("throws a clear error when the OAuth token is null", () => {
(ScriptApp.getOAuthToken as jest.Mock).mockReturnValueOnce(null);
expect(() => fetchAndEncodeFile("anyId")).toThrow(
"Drive file access requires full OAuth authorization",
);
});

it("throws on Drive metadata API error with message", () => {
(UrlFetchApp.fetch as jest.Mock).mockReturnValueOnce({
getResponseCode: () => 403,
getContentText: () => JSON.stringify({ error: { message: "Insufficient permission" } }),
});
expect(() => fetchAndEncodeFile("badId")).toThrow("Insufficient permission");
});

it("throws a fallback message on Drive metadata error with no body message", () => {
(UrlFetchApp.fetch as jest.Mock).mockReturnValueOnce({
getResponseCode: () => 500,
getContentText: () => JSON.stringify({}),
});
expect(() => fetchAndEncodeFile("badId")).toThrow("Drive metadata request failed (500)");
});

it("throws on Drive content API error with message", () => {
(UrlFetchApp.fetch as jest.Mock)
.mockReturnValueOnce({
getResponseCode: () => 200,
getContentText: () => JSON.stringify({ mimeType: "application/pdf", size: "1024" }),
})
.mockReturnValueOnce({
getResponseCode: () => 404,
getContentText: () => JSON.stringify({ error: { message: "File not found" } }),
});
expect(() => fetchAndEncodeFile("missingId")).toThrow("File not found");
});

it("throws a fallback message on Drive content error with no body message", () => {
(UrlFetchApp.fetch as jest.Mock)
.mockReturnValueOnce({
getResponseCode: () => 200,
getContentText: () => JSON.stringify({ mimeType: "application/pdf", size: "1024" }),
})
.mockReturnValueOnce({
getResponseCode: () => 403,
getContentText: () => JSON.stringify({}),
});
expect(() => fetchAndEncodeFile("missingId")).toThrow("Drive download failed (403)");
});
});
4 changes: 3 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ function sampleRowsToEvaluation() { _GASEntry.sampleRowsToEvaluation(); }
* Call the Gemini API from a spreadsheet cell.
* @param {string|Array} userTexts One or more text parts for the user message.
* Pass a single string, a cell reference, or a range / array literal.
* @param {string|Array} inlineData Drive URL(s) or file ID(s) to attach as inline data.
* Pass a single URL, a cell reference, or a range / array literal.
* @param {string} systemPrompt System-level instruction for the model.
* @param {string|Array} toolNames Names of pre-registered tools to enable.
* @return {string} The model's text response, or "[SSI Error: ...]" on failure.
* @customfunction
*/
function SSI(userTexts, systemPrompt, toolNames) { return _GASEntry.SSI(userTexts, systemPrompt, toolNames); }
function SSI(userTexts, inlineData, systemPrompt, toolNames) { return _GASEntry.SSI(userTexts, inlineData, systemPrompt, toolNames); }
`,
},
plugins: [
Expand Down
30 changes: 27 additions & 3 deletions src/server/customFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
* - Errors must be returned as strings — thrown exceptions show as generic
* script errors in the cell with no useful message
* - PropertiesService.getScriptProperties() is available after the add-on
* has been authorized by the user (opening the menu triggers authorization)
* has been authorized (triggered by opening the ⚡ SSI Toolkit menu).
* - The inlineData parameter is NOT supported from custom function context:
* ScriptApp.getOAuthToken() returns null in bound-script custom functions,
* so Drive file fetching is unavailable. Use the Run AI menu tool instead.
* - Range arguments arrive as unknown[][], single cells as raw scalars
*/

import { CONFIG } from "./config";
import { callGeminiAPI } from "./api";
import { fetchAndEncodeFile } from "./drive";
import { extractId } from "./utils";
import type { GeminiFunctionDeclaration } from "../shared/types";

// ── Tool Registry ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -44,14 +49,22 @@ function flattenArg(val: unknown): string[] {
* @param {string|Array} userTexts One or more text parts for the user message.
* Pass a single string, a cell reference, or a range / array literal.
* Example: "Summarize this" or A1 or A1:A3 or {A1,B4,B10}
* @param {string} [systemPrompt] (Optional) System-level instruction for the model.
* @param {string|Array} inlineData Drive URL(s) or file ID(s) to attach as
* inline data. Pass a single URL, a cell reference, or a range / array literal.
* Example: A2 or {A2,A3}
* @param {string} systemPrompt System-level instruction for the model.
* Example: "You are a concise summarizer."
* @param {string|Array} [toolNames] (Optional) Names of pre-registered tools to enable.
* Example: "myTool" or {A5,A6}
* @return {string} The model's text response, or "[SSI Error: ...]" on failure.
* @customfunction
*/
export function SSI(userTexts: unknown, systemPrompt?: string, toolNames?: unknown): string {
export function SSI(
userTexts: unknown,
inlineData?: unknown,
systemPrompt?: string,
toolNames?: unknown,
): string {
try {
// Resolve API key from Script Properties (set via Project Settings)
const apiKey = PropertiesService.getScriptProperties().getProperty(CONFIG.API_KEY_PROPERTY);
Expand All @@ -66,10 +79,21 @@ export function SSI(userTexts: unknown, systemPrompt?: string, toolNames?: unkno
return decl;
});

// Normalize inlineData: fetch and encode each Drive URL / file ID
const resolvedInlineData =
inlineData != null
? flattenArg(inlineData).map((url) => {
const id = extractId(url);
if (!id) throw new Error(`Could not extract a Drive file ID from: "${url}"`);
return fetchAndEncodeFile(id);
})
: undefined;

return callGeminiAPI({
apiKey,
systemPrompt: systemPrompt || undefined,
userTexts: flattenArg(userTexts),
inlineData: resolvedInlineData?.length ? resolvedInlineData : undefined,
tools: resolvedTools.length ? resolvedTools : undefined,
});
} catch (e) {
Expand Down
58 changes: 54 additions & 4 deletions src/server/drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,64 @@ export function extractTextUniversal(fileId: string): string {
/**
* Fetch a Drive file by ID and return it as base64-encoded inline data
* ready for the Gemini API. Throws if the file exceeds the 25MB limit.
*
* Uses the Drive REST API via UrlFetchApp rather than DriveApp directly,
* because DriveApp is unavailable in custom function execution contexts.
*
* NOTE: Custom functions in bound scripts run in AuthMode.CUSTOM_FUNCTION,
* which gives ScriptApp.getOAuthToken() a token scoped only to
* spreadsheets.currentonly — not drive. Drive file fetching therefore only
* works from menu-triggered functions (e.g. runBatchAI). Calling this from
* the SSI() custom function will throw with a clear error pointing users to
* runBatchAI. A service account key in Script Properties could bypass this
* (see Google's fact-check sample), but every file would need to be shared
* with the service account email — poor UX for a cell formula.
*
* Requires oauth scope: https://www.googleapis.com/auth/drive.readonly
*/
export function fetchAndEncodeFile(fileId: string): GeminiInlineData {
const file = DriveApp.getFileById(fileId);
if (file.getSize() > CONFIG.MAX_FILE_SIZE_BYTES) {
const token = ScriptApp.getOAuthToken();
if (!token) {
throw new Error(
"Drive file access requires full OAuth authorization, which is not available " +
"in spreadsheet formula context (AuthMode.CUSTOM_FUNCTION). " +
"Use the ⚡ SSI Toolkit menu > Run AI to process Drive files.",
);
}
const headers = { Authorization: `Bearer ${token}` };

const metaResp = UrlFetchApp.fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=mimeType%2Csize`,
{ headers, muteHttpExceptions: true },
);
if (metaResp.getResponseCode() !== 200) {
const body = JSON.parse(metaResp.getContentText()) as { error?: { message: string } };
throw new Error(
body.error?.message ?? `Drive metadata request failed (${metaResp.getResponseCode()})`,
);
}
const { mimeType, size } = JSON.parse(metaResp.getContentText()) as {
mimeType: string;
size: string;
};

if (parseInt(size, 10) > CONFIG.MAX_FILE_SIZE_BYTES) {
throw new Error("File too large (>25MB).");
}

const contentResp = UrlFetchApp.fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`,
{ headers, muteHttpExceptions: true },
);
if (contentResp.getResponseCode() !== 200) {
const body = JSON.parse(contentResp.getContentText()) as { error?: { message: string } };
throw new Error(
body.error?.message ?? `Drive download failed (${contentResp.getResponseCode()})`,
);
}

return {
mime_type: file.getMimeType(),
data: Utilities.base64Encode(file.getBlob().getBytes()),
mime_type: mimeType,
data: Utilities.base64Encode(contentResp.getContent()),
};
}