Skip to content
Open
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
219 changes: 219 additions & 0 deletions services/dingtalk__aitable/bin/service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/usr/bin/env node
/**
* DingTalk AI Table Service — OctoBus 服务包
*/
import { defineService, runServiceMain } from '@chaitin-ai/octobus-sdk';
import { execFile } from 'child_process';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// 加载看板配置(从 kanban.json 读取,避免 js-yaml 依赖)
let kanbanConfig = null;
function loadKanbanConfig(configPath) {
if (kanbanConfig) return kanbanConfig;
try {
const resolved = configPath || join(__dirname, '..', 'config', 'kanban.json');
const raw = readFileSync(resolved, 'utf-8');
kanbanConfig = JSON.parse(raw);
} catch (err) {
console.error(`[aitable] Failed to load kanban.json: ${err.message}`);
kanbanConfig = {};
}
return kanbanConfig;
}

function runDws(command, timeout = 60000) {
const dwsPath = process.env.DWS_PATH || 'dws';
const escapedPath = `'${dwsPath.replace(/'/g, "'\\''")}'`;
return new Promise((resolve) => {
execFile('sh', ['-c', `${escapedPath} ${command} --yes --format json`], { timeout, maxBuffer: 10*1024*1024 },
(error, stdout) => {
const raw = stdout.trim();
let data = null;
try { data = JSON.parse(raw); } catch { data = raw; }
// dws 退出码 0 不代表业务成功,要看 data.status
const status = data && typeof data === 'object' ? String(data.status || '').toLowerCase() : '';
const businessFailed = status === 'error' || status === 'failed';
if (error && error.code !== 0) {
resolve({ success: false, data, error: (data && data.summary) || error.message });
} else if (businessFailed) {
const errMsg = (data && data.error && (data.error.message || data.error)) || (data && data.summary) || 'dws business error';
resolve({ success: false, data, error: typeof errMsg === 'object' ? JSON.stringify(errMsg) : String(errMsg) });
} else {
resolve({ success: true, data, error: '' });
}
});
});
}

const shellEscape = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";

const service = defineService({
handlers: {
'dingtalk.aitable.v1.AITableService/CreateRecord': async (ctx) => {
const { baseId, tableId, fields } = ctx.request;
// dws CLI expects --fields as array: [{"cells": {"fieldId": value, ...}}]
// Values can be strings, or JSON-stringified objects/arrays for complex fields
const cells = {};
for (const [key, val] of Object.entries(fields || {})) {
// Try to parse JSON strings back to objects/arrays for complex fields
if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) {
try { cells[key] = JSON.parse(val); } catch { cells[key] = val; }
} else {
cells[key] = val;
}
}
const recordsArray = JSON.stringify([{ cells }]);
const cmd = `aitable record create --base-id ${shellEscape(baseId)} --table-id ${shellEscape(tableId)} --fields ${shellEscape(recordsArray)}`;

const res = await runDws(cmd);
if (!res.success) return { success: false, recordId: '', error: res.error };

// dws 把结果包在 data.data 里;newRecordIds 是新建记录 ID 列表
const r = res.data?.data || res.data?.result || res.data;
const recordId = (Array.isArray(r?.newRecordIds) && r.newRecordIds[0])
|| (Array.isArray(r) && r[0]?.recordId)
|| r?.recordId || '';
return { success: true, recordId, error: '' };
},

'dingtalk.aitable.v1.AITableService/QueryRecords': async (ctx) => {
const { baseId, tableId, limit } = ctx.request;
const cmd = `aitable record list --base-id ${shellEscape(baseId)} --table-id ${shellEscape(tableId)} --limit ${shellEscape(String(parseInt(limit || 10, 10)))}`;

const res = await runDws(cmd);
if (!res.success) return { success: false, records: [], error: res.error };

// dws 把结果包在 data.data.records;每条记录字段在 cells(不是 fields)
const rawRecords = res.data?.data?.records || res.data?.result?.records || res.data?.records || [];
const list = Array.isArray(rawRecords) ? rawRecords : [];
const records = list.map((r) => ({
recordId: r.recordId || r.id || '',
fields: r.cells || r.fields || {},
}));
return { success: true, records, error: '' };
},

/**
* 看板记录创建(业务封装)
* 读 projects.yaml 的 kanban 配置,自动映射字段 ID → 构造 fields → 调用 CreateRecord
*/
'dingtalk.aitable.v1.AITableService/CreateKanbanRecord': async (ctx) => {
const { summary, category, customer, date, taskType, description, salesman, owner } = ctx.request;

// 1. 加载配置
const kanban = loadKanbanConfig(process.env.KANBAN_CONFIG_PATH);
if (!kanban) {
return { success: false, recordId: '', error: 'projects.yaml 中缺少 kanban 配置' };
}

// 2. 映射任务类型
const taskTypes = kanban.task_types || {};
let taskTypeOption = null;
// 先精确匹配 taskType,再回退到 category
const lookupKey = taskType || category;
if (taskTypes[lookupKey]) {
taskTypeOption = taskTypes[lookupKey];
} else {
// 模糊匹配:key 包含 category 的关键词
for (const [key, val] of Object.entries(taskTypes)) {
if (key.includes(lookupKey) || lookupKey.includes(key)) {
taskTypeOption = val;
break;
}
}
}
if (!taskTypeOption) {
// 最终回退:用 category 本身作为名称
taskTypeOption = { id: '', name: lookupKey };
}

// 3. 映射客户分类
const customers = kanban.customers || {};
let customerOption = null;
if (customers[customer]) {
customerOption = customers[customer];
} else {
// 模糊匹配:客户名中是否包含已知的 key
for (const [key, val] of Object.entries(customers)) {
if (customer.includes(key) || key.includes(customer)) {
customerOption = val;
break;
}
}
}
if (!customerOption && customer) {
customerOption = { id: '', name: customer };
}

// 4. 提取二级部门(客户名中"-"后的部分)
let department2 = '';
if (customer && customer.includes('-')) {
department2 = customer.split('-').slice(1).join('-');
}

// 5. 构造 fields
const fields = {};
const kf = kanban.fields || {};
// 日期字段(两个日期列)
fields[kf.date_start || '5wlytmi'] = date || '';
fields[kf.date_end || '5sxy4yq'] = date || '';
// 任务类型
fields[kf.task_type || 'wiisqll'] = taskTypeOption;
// 任务描述
fields[kf.description || 'q3aj1jn'] = summary || '';
// Owner — 从 user-config 读取,无配置时不设置默认值
const ownerUser = owner || '';
if (ownerUser) fields[kf.owner || 'kkcfjj6'] = [{ userId: ownerUser }];
// 销售 — 从 user-config 读取,无配置时不设置默认值
const salesUser = salesman || '';
if (salesUser) fields[kf.salesman || '6olqudb'] = [{ userId: salesUser }];
// 周报摘要/说明
fields[kf.weekly_summary || 'x2ysb28'] = description || summary || '';
// 客户分类
if (customerOption) {
fields[kf.customer_tier || 'dacnewp'] = customerOption;
}
// 二级部门
if (department2) {
fields[kf.sub_department || 'cgrfhw7'] = department2;
}
// 状态(固定:已完成)
if (kanban.status_done) {
fields[kf.status || 'v5j63kh'] = kanban.status_done;
}

// 6. 调用 CreateRecord
const cells = {};
for (const [key, val] of Object.entries(fields)) {
if (typeof val === 'object') {
cells[key] = val;
} else {
// Try to parse JSON strings back to objects/arrays for complex fields
if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) {
try { cells[key] = JSON.parse(val); } catch { cells[key] = val; }
} else {
cells[key] = val;
}
}
}
const recordsArray = JSON.stringify([{ cells }]);
const cmd = `aitable record create --base-id ${shellEscape(kanban.base_id)} --table-id ${shellEscape(kanban.table_id)} --fields ${shellEscape(recordsArray)}`;

const res = await runDws(cmd);
if (!res.success) return { success: false, recordId: '', error: res.error };

const r = res.data?.data || res.data?.result || res.data;
const recordId = (Array.isArray(r?.newRecordIds) && r.newRecordIds[0])
|| (Array.isArray(r) && r[0]?.recordId)
|| r?.recordId || '';
return { success: true, recordId, error: '' };
},
},
});

runServiceMain(service);
15 changes: 15 additions & 0 deletions services/dingtalk__aitable/config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"dwsPath": {
"type": "string",
"description": "dws CLI path",
"default": "dws"
},
"kanbanConfigPath": {
"type": "string",
"description": "看板配置文件路径(kanban.json),用于字段映射",
"default": "./config/kanban.json"
}
}
}
28 changes: 28 additions & 0 deletions services/dingtalk__aitable/config/kanban.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"_README": "Kanban config template — replace with your own AITable base/table ID and field option IDs",
"base_id": "YOUR_AITABLE_BASE_ID",
"table_id": "YOUR_AITABLE_TABLE_ID",
"fields": {
"date_start": "5wlytmi",
"date_end": "5sxy4yq",
"task_type": "wiisqll",
"description": "q3aj1jn",
"owner": "kkcfjj6",
"salesman": "6olqudb",
"weekly_summary": "x2ysb28",
"customer_tier": "dacnewp",
"sub_department": "cgrfhw7",
"status": "v5j63kh"
},
"task_types": {
"communication": { "id": "OPTION_ID", "name": "Communication" },
"documentation": { "id": "OPTION_ID", "name": "Documentation" },
"bidding": { "id": "OPTION_ID", "name": "Bidding" },
"poc": { "id": "OPTION_ID", "name": "POC" }
},
"customers": {
"customer_a": { "id": "OPTION_ID", "name": "Customer A Name" },
"customer_b": { "id": "OPTION_ID", "name": "Customer B Name" }
},
"status_done": { "id": "OPTION_ID", "name": "Done" }
}
15 changes: 15 additions & 0 deletions services/dingtalk__aitable/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@office-agent/dingtalk-aitable",
"displayName": "DingTalk AITable",
"version": "0.1.0",
"description": "DingTalk AITable — record CRUD, kanban operations, field management",
"type": "module",
"main": "bin/service.js",
"bin": "bin/service.js",
"scripts": {
"start": "node bin/service.js"
},
"dependencies": {
"@chaitin-ai/octobus-sdk": "^0.1.0"
}
}
56 changes: 56 additions & 0 deletions services/dingtalk__aitable/proto/aitable.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
syntax = "proto3";
package dingtalk.aitable.v1;

service AITableService {
rpc CreateRecord(CreateRecordRequest) returns (CreateRecordResponse);
rpc QueryRecords(QueryRecordsRequest) returns (QueryRecordsResponse);
rpc CreateKanbanRecord(CreateKanbanRecordRequest) returns (CreateKanbanRecordResponse);
}

message CreateRecordRequest {
string base_id = 1; // AI Table base ID
string table_id = 2; // Table ID
map<string, string> fields = 3; // 字段名 → 值
}

message CreateRecordResponse {
bool success = 1;
string record_id = 2;
string error = 3;
}

message QueryRecordsRequest {
string base_id = 1;
string table_id = 2;
string filter = 3; // 筛选条件(可选)
int32 limit = 4;
}

message AITableRecord {
string record_id = 1;
map<string, string> fields = 2;
}

message QueryRecordsResponse {
bool success = 1;
repeated AITableRecord records = 2;
string error = 3;
}

// 看板记录创建(业务封装,自动处理字段映射)
message CreateKanbanRecordRequest {
string summary = 1; // 任务描述,如"【交流】与XX沟通XX项目"
string category = 2; // 任务类型:沟通交流/文档材料/投标谈判/POC及其他
string customer = 3; // 一级客户分类
string date = 4; // 日期 YYYY-MM-DD
string task_type = 5; // 具体任务类型名(对应 projects.yaml 中 task_types 的 key)
string description = 6; // 必要说明
string salesman = 7; // 销售 userId(从 user-config 读取,不设硬编码默认值)
string owner = 8; // 任务 Owner userId
}

message CreateKanbanRecordResponse {
bool success = 1;
string record_id = 2;
string error = 3;
}
4 changes: 4 additions & 0 deletions services/dingtalk__aitable/secret.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "object",
"properties": {}
}
12 changes: 12 additions & 0 deletions services/dingtalk__aitable/service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schema": "chaitin.octobus.service.v1",
"name": "dingtalk__aitable",
"displayName": "DingTalk AITable Service",
"description": "DingTalk AITable integration — record creation, querying, and kanban board writing via dws CLI.",
"proto": {
"roots": ["proto"],
"files": ["proto/aitable.proto"]
},
"configSchema": "config.schema.json",
"secretSchema": "secret.schema.json"
}
Loading