blob: 555d3c0f57c10d100de68b073d4731516e83f8fd [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { createCli, type CliConfig, type CliRun } from './cli.js';
type Kind = 'documents' | 'tasks' | 'calendar';
export type SourcesConfig = CliConfig & {
instanceId: string;
kinds?: Kind[];
containers?: any[];
startTime?: number;
endTime?: number;
messageSource?: any;
signingKey?: string;
messageStartTime?: number;
messageEndTime?: number;
calendarId?: string;
documentQuery?: string;
};
const hash = (x: any) => createHash('sha256').update(JSON.stringify(x)).digest('hex');
async function mapReads<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
const result: R[] = [];
for (let i = 0; i < items.length; i += 4)
result.push(...(await Promise.all(items.slice(i, i + 4).map(fn))));
return result;
}
const id = (value: any) => {
if (typeof value !== 'string' || !/^[\w@.\-]+$/.test(value))
throw Error('Invalid native source ID');
return value;
};
export function createCliSources(config: SourcesConfig, run?: CliRun) {
if (!/^[a-z0-9][a-z0-9._-]{0,40}$/.test(config.instanceId)) throw Error('Invalid instanceId');
const cli = createCli(config, run);
const result: any[] = [];
for (const kind of config.kinds ?? ['documents', 'tasks', 'calendar']) {
if (!['documents', 'tasks', 'calendar'].includes(kind))
throw Error('Unsupported CLI source kind');
if (
kind === 'calendar' &&
(!Number.isSafeInteger(config.startTime) ||
!Number.isSafeInteger(config.endTime) ||
config.endTime! <= config.startTime!)
)
throw Error('Calendar requires explicit startTime/endTime Unix seconds');
const scope = {
accountId: config.accountId,
appId: config.appId,
kind,
...(kind === 'calendar'
? {
calendarId: config.calendarId ?? 'primary',
startTime: config.startTime,
endTime: config.endTime,
}
: {}),
...(kind === 'documents'
? { query: config.documentQuery ?? '', types: ['docx', 'wiki'] }
: {}),
};
const key = config.signingKey ?? randomBytes(32);
const encode = (state: any) => {
const s = Buffer.from(JSON.stringify(state)).toString('base64url');
return s + '.' + createHmac('sha256', key).update(s).digest('hex');
};
const decode = (s: string) => {
const [v, mac] = s.split('.');
if (mac !== createHmac('sha256', key).update(v).digest('hex'))
throw Error('Invalid source cursor');
return JSON.parse(Buffer.from(v, 'base64url').toString());
};
const cache = new WeakMap<object, Map<string, any>>();
const memo = (caller: any) => {
let m = cache.get(caller.invocation);
if (!m) {
m = new Map();
cache.set(caller.invocation, m);
}
return m;
};
async function native(locator: any, caller: any) {
const identifier = id(locator.nativeId);
if (kind === 'documents')
return (
await cli.call(['api', 'GET', `/open-apis/docx/v1/documents/${identifier}`], caller)
).document;
if (kind === 'tasks')
return (await cli.call(['task', 'tasks', 'get', '--task-guid', identifier], caller)).task;
return await cli
.call(
[
'calendar',
'+get',
'--calendar-id',
config.calendarId ?? 'primary',
'--event-id',
identifier,
],
caller,
)
.then((x) => x.event ?? x);
}
function object(n: any, locator: any, url?: string) {
const identifier = String(n.document_id ?? n.guid ?? n.event_id);
if (!identifier || identifier === 'undefined') throw Error('Missing native object ID');
const revision = kind === 'documents' ? String(n.revision_id) : hash(n);
if (!revision || revision === 'undefined') throw Error('Missing document revision');
return {
id: identifier,
locator: { ...locator, nativeId: identifier },
revision,
kind: kind === 'documents' ? 'document' : kind === 'tasks' ? 'task' : 'calendar',
title: String(n.title ?? n.summary ?? identifier).slice(0, 200),
...(url || n.url || n.app_link ? { url: url ?? n.url ?? n.app_link } : {}),
...(Number(n.updated_at ?? locator.discoveredUpdatedAt) > 0
? { updatedAt: Number(n.updated_at ?? locator.discoveredUpdatedAt) }
: {}),
};
}
async function load(o: any, caller: any) {
if (o.id !== o.locator.nativeId) throw Error('Object locator does not match identity');
const c = memo(caller);
if (!c.has(o.id)) c.set(o.id, await native(o.locator, caller));
return c.get(o.id);
}
async function query(q: any, caller: any) {
if (Object.keys(q).some((k) => !['cursor', 'text', 'limit'].includes(k)))
throw Error('Unsupported source query field');
if (q.text !== undefined && typeof q.text !== 'string') throw Error('Invalid text');
const limit = q.limit ?? 20;
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw Error('limit must be 1..100');
const shape = hash({ text: q.text ?? '', limit, scope });
const state = q.cursor ? decode(q.cursor) : {};
if (q.cursor && state.shape !== shape) throw Error('Cursor query changed');
let data: any, rows: any[];
if (kind === 'documents') {
data = await cli.call(
[
'drive',
'+search',
'--doc-types',
'docx,wiki',
'--query',
config.documentQuery || q.text || '',
'--page-size',
String(Math.min(limit, 20)),
...(state.page ? ['--page-token', state.page] : []),
],
caller,
);
rows = data.results ?? [];
} else if (kind === 'tasks') {
data = await cli.call(
[
'task',
'tasks',
'list',
'--page-size',
String(limit),
...(state.page ? ['--page-token', state.page] : []),
],
caller,
);
rows = data.items ?? [];
} else {
data = await cli.call(
[
'calendar',
'+agenda',
'--calendar-id',
config.calendarId ?? 'primary',
'--start',
new Date(config.startTime! * 1000).toISOString(),
'--end',
new Date(config.endTime! * 1000).toISOString(),
],
caller,
);
if (!Array.isArray(data)) throw Error('Unexpected calendar agenda shape');
rows = data;
}
const items = (
await mapReads(rows, async (row) => {
let identifier: string, url: string | undefined;
let discovery: Record<string, unknown> = {};
if (kind === 'documents') {
const meta = row.result_meta;
url = meta.url;
discovery = {
discoveredAt: Date.now(),
discoveredCreatedAt: Number(meta.create_time) * 1000 || null,
discoveredUpdatedAt: Number(meta.update_time) * 1000 || null,
};
const found = await cli.call(['drive', '+inspect', '--url', url!], caller);
if (found.type !== 'docx') return undefined; // This source explicitly covers docx, including wiki-wrapped docx.
identifier = id(found.token);
} else identifier = id(row.guid ?? row.event_id);
const n = await native({ nativeId: identifier }, caller);
const o = object(n, { nativeId: identifier, ...discovery }, url);
memo(caller).set(o.id, n);
if (
!q.text ||
(kind === 'documents' && !config.documentQuery) ||
JSON.stringify(n).toLowerCase().includes(q.text.toLowerCase())
)
return o;
return undefined;
})
).filter(Boolean);
if (data.has_more && (!data.page_token || data.page_token === state.page))
throw Error('Invalid provider pagination');
return {
items,
...(data.has_more ? { next: encode({ shape, page: data.page_token }) } : {}),
};
}
result.push({
id: `feishu.${config.instanceId}.${kind}`,
description: `Feishu ${kind} via local CLI (read-only)`,
scope,
queryHelp:
'{text?:string, limit?:number (1..100), cursor?:string}. Keep text and limit unchanged while following next. Documents: docx and wiki-wrapped docx. Calendar: configured time window. Tasks: my tasks. Empty pages may have next. Original is fetched on demand.',
enumerate: (cursor: any, caller: any) => query({ cursor }, caller),
query,
async authorize(objects: readonly any[], caller: any) {
await cli.verify(caller);
return mapReads(objects, async (o) => {
await load(o, caller);
return o.id;
});
},
async read(o: any, caller: any) {
const n = await load(o, caller);
if (n.status === 'cancelled')
return { status: 'deleted', object: object(n, o.locator, o.url) };
if (kind === 'documents') {
if (String(n.revision_id) !== o.revision)
return { status: 'version_unavailable', object: object(n, o.locator, o.url) };
const d = await cli.call(
[
'docs',
'+fetch',
'--doc',
id(o.id),
'--doc-format',
'markdown',
'--revision-id',
String(n.revision_id),
],
caller,
);
const doc = d.document;
if (!doc || String(doc.revision_id) !== String(n.revision_id))
throw Error('Document changed during read; retry');
return {
status: 'ok',
object: object(n, o.locator, o.url),
content: doc,
};
}
return {
status: 'ok',
object: object(n, o.locator, o.url),
content: n,
};
},
});
}
if (config.messageSource) result.push(config.messageSource);
return result;
}