diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..38d9270aa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,6 +3,11 @@ - any-glob-to-any-file: - "extensions/bluebubbles/**" - "docs/channels/bluebubbles.md" +"channel: dingtalk": + - changed-files: + - any-glob-to-any-file: + - "extensions/dingtalk/**" + - "docs/channels/dingtalk.md" "channel: discord": - changed-files: - any-glob-to-any-file: diff --git a/docs/channels/dingtalk.md b/docs/channels/dingtalk.md new file mode 100644 index 000000000..1b3ffce50 --- /dev/null +++ b/docs/channels/dingtalk.md @@ -0,0 +1,61 @@ +--- +summary: "DingTalk bot support status, capabilities, and configuration" +read_when: + - Working on DingTalk channel features +--- +# DingTalk + +Status: beta; inbound via WebSocket using `dingtalk-stream` SDK. + +## Quick setup +1) Create an enterprise internal robot in DingTalk Open Platform. +2) Enable "Stream Mode" in robot settings. +3) Configure `channels.dingtalk.clientId` and `channels.dingtalk.clientSecret`. +4) Start the gateway. + +Minimal config: +```json5 +{ + channels: { + dingtalk: { + enabled: true, + clientId: "YOUR_APP_KEY", + clientSecret: "YOUR_APP_SECRET" + } + } +} +``` + +Multi-account example: +```json5 +{ + channels: { + dingtalk: { + accounts: { + work: { clientId: "APP_KEY", clientSecret: "APP_SECRET" }, + personal: { clientId: "APP_KEY", clientSecret: "APP_SECRET" } + } + } + } +} +``` + +## How it works +- Messages are received over the DingTalk WebSocket stream (no public URL needed). +- Replies are sent back via the session webhook provided in each message. +- DMs use pairing by default (`channels.dingtalk.dm.policy`). +- Group chats can be restricted with `channels.dingtalk.groupPolicy` and `channels.dingtalk.groups`. + +## Target formats +- `dingtalk:` for group chats. +- `dingtalk:` for direct messages. + +## Configuration reference (DingTalk) +- `channels.dingtalk.clientId`: DingTalk AppKey (Client ID). +- `channels.dingtalk.clientSecret`: DingTalk AppSecret (Client Secret). +- `channels.dingtalk.dm.policy`: DM policy (`pairing`, `allowlist`, `open`, `disabled`). +- `channels.dingtalk.dm.allowFrom`: allowlist for DMs when policy is `allowlist` or `open`. +- `channels.dingtalk.groupPolicy`: `open`, `allowlist`, or `disabled`. +- `channels.dingtalk.groups`: per-chat overrides keyed by `conversation_id` (supports `requireMention`, `tools`, `users`). +- `channels.dingtalk.requireMention`: whether the bot must be @mentioned in groups (default: `true`). +- `channels.dingtalk.textChunkLimit`: max characters per message chunk (default: `4000`). diff --git a/extensions/dingtalk/index.ts b/extensions/dingtalk/index.ts new file mode 100644 index 000000000..8d4b5bb9b --- /dev/null +++ b/extensions/dingtalk/index.ts @@ -0,0 +1,18 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { dingtalkDock, dingtalkPlugin } from "./src/channel.js"; +import { setDingTalkRuntime } from "./src/runtime.js"; + +const plugin = { + id: "dingtalk", + name: "DingTalk", + description: "Moltbot DingTalk channel plugin (Stream mode)", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setDingTalkRuntime(api.runtime); + api.registerChannel({ plugin: dingtalkPlugin, dock: dingtalkDock }); + }, +}; + +export default plugin; diff --git a/extensions/dingtalk/moltbot.plugin.json b/extensions/dingtalk/moltbot.plugin.json new file mode 100644 index 000000000..7f8643f1f --- /dev/null +++ b/extensions/dingtalk/moltbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "dingtalk", + "channels": [ + "dingtalk" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/dingtalk/package.json b/extensions/dingtalk/package.json new file mode 100644 index 000000000..4cb778b91 --- /dev/null +++ b/extensions/dingtalk/package.json @@ -0,0 +1,39 @@ +{ + "name": "@moltbot/dingtalk", + "version": "2026.1.27-beta.1", + "type": "module", + "description": "Moltbot DingTalk channel plugin", + "moltbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "dingtalk", + "label": "DingTalk", + "selectionLabel": "DingTalk (钉钉)", + "detailLabel": "DingTalk", + "docsPath": "/channels/dingtalk", + "docsLabel": "dingtalk", + "blurb": "DingTalk enterprise robot via Stream mode.", + "aliases": [ + "dingding", + "ding" + ], + "order": 56 + }, + "install": { + "npmSpec": "@moltbot/dingtalk", + "localPath": "extensions/dingtalk", + "defaultChoice": "npm" + } + }, + "dependencies": { + "dingtalk-stream": "^2.1.4" + }, + "devDependencies": { + "moltbot": "workspace:*" + }, + "peerDependencies": { + "moltbot": ">=2026.1.26" + } +} diff --git a/extensions/dingtalk/src/accounts.ts b/extensions/dingtalk/src/accounts.ts new file mode 100644 index 000000000..fbff4128b --- /dev/null +++ b/extensions/dingtalk/src/accounts.ts @@ -0,0 +1,125 @@ +import type { DingTalkAccountConfig, MoltbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +import type { DingTalkConfig } from "./types.js"; + +export type DingTalkCredentialSource = "config" | "env" | "none"; + +export type ResolvedDingTalkAccount = { + accountId: string; + name?: string; + enabled: boolean; + config: DingTalkAccountConfig; + credentialSource: DingTalkCredentialSource; + /** Stream mode: Client ID (AppKey) */ + clientId?: string; + /** Stream mode: Client Secret (AppSecret) */ + clientSecret?: string; +}; + +// Stream mode environment variables +const ENV_CLIENT_ID = "DINGTALK_CLIENT_ID"; +const ENV_CLIENT_SECRET = "DINGTALK_CLIENT_SECRET"; + +function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { + const accounts = (cfg.channels?.dingtalk as DingTalkConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listDingTalkAccountIds(cfg: MoltbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultDingTalkAccountId(cfg: MoltbotConfig): string { + const channel = cfg.channels?.dingtalk as DingTalkConfig | undefined; + if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim(); + const ids = listDingTalkAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: MoltbotConfig, + accountId: string, +): DingTalkAccountConfig | undefined { + const accounts = (cfg.channels?.dingtalk as DingTalkConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as DingTalkAccountConfig | undefined; +} + +function mergeDingTalkAccountConfig( + cfg: MoltbotConfig, + accountId: string, +): DingTalkAccountConfig { + const raw = (cfg.channels?.dingtalk ?? {}) as DingTalkConfig; + const { accounts: _ignored, ...base } = raw; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account } as DingTalkAccountConfig; +} + +function resolveCredentialsFromConfig(params: { + accountId: string; + account: DingTalkAccountConfig; +}): { + clientId?: string; + clientSecret?: string; + source: DingTalkCredentialSource; +} { + const { account, accountId } = params; + + // Check Stream mode credentials from config + if (account.clientId?.trim() && account.clientSecret?.trim()) { + return { + clientId: account.clientId.trim(), + clientSecret: account.clientSecret.trim(), + source: "config", + }; + } + + // Check environment variables for default account + if (accountId === DEFAULT_ACCOUNT_ID) { + const envClientId = process.env[ENV_CLIENT_ID]?.trim(); + const envClientSecret = process.env[ENV_CLIENT_SECRET]?.trim(); + if (envClientId && envClientSecret) { + return { + clientId: envClientId, + clientSecret: envClientSecret, + source: "env", + }; + } + } + + return { source: "none" }; +} + +export function resolveDingTalkAccount(params: { + cfg: MoltbotConfig; + accountId?: string | null; +}): ResolvedDingTalkAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = + (params.cfg.channels?.dingtalk as DingTalkConfig | undefined)?.enabled !== false; + const merged = mergeDingTalkAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const credentials = resolveCredentialsFromConfig({ accountId, account: merged }); + + return { + accountId, + name: merged.name?.trim() || undefined, + enabled, + config: merged, + credentialSource: credentials.source, + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + }; +} + +export function listEnabledDingTalkAccounts(cfg: MoltbotConfig): ResolvedDingTalkAccount[] { + return listDingTalkAccountIds(cfg) + .map((accountId) => resolveDingTalkAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/dingtalk/src/api.ts b/extensions/dingtalk/src/api.ts new file mode 100644 index 000000000..960c3c1e1 --- /dev/null +++ b/extensions/dingtalk/src/api.ts @@ -0,0 +1,250 @@ +import crypto from "node:crypto"; + +import type { ResolvedDingTalkAccount } from "./accounts.js"; +import type { + DingTalkApiResponse, + DingTalkOutboundMessage, + DingTalkTextMessage, + DingTalkMarkdownMessage, +} from "./types.js"; + +const DINGTALK_ROBOT_SEND_URL = "https://oapi.dingtalk.com/robot/send"; + +/** + * Generate HMAC-SHA256 signature for DingTalk webhook authentication. + * Format: timestamp + "\n" + secret, then HMAC-SHA256, base64, URL encode + * @deprecated Legacy webhook mode - use Stream mode instead + */ +export function generateSignature(timestamp: number, secret: string): string { + const stringToSign = `${timestamp}\n${secret}`; + const hmac = crypto.createHmac("sha256", secret); + hmac.update(stringToSign, "utf8"); + const digest = hmac.digest("base64"); + return encodeURIComponent(digest); +} + +/** + * Verify incoming webhook signature from DingTalk. + * The signature is in request headers: timestamp and sign. + * @deprecated Legacy webhook mode - use Stream mode instead + */ +export function verifySignature(params: { + timestamp: string; + sign: string; + secret: string; +}): { ok: boolean; error?: string } { + const { timestamp, sign, secret } = params; + + // Check timestamp is within 1 hour + const ts = Number.parseInt(timestamp, 10); + if (!Number.isFinite(ts)) { + return { ok: false, error: "invalid timestamp" }; + } + const now = Date.now(); + if (Math.abs(now - ts) > 60 * 60 * 1000) { + return { ok: false, error: "timestamp expired" }; + } + + // Verify signature + const expected = generateSignature(ts, secret); + // URL decode the incoming sign for comparison + const decodedSign = decodeURIComponent(sign); + const decodedExpected = decodeURIComponent(expected); + + if (decodedSign !== decodedExpected) { + return { ok: false, error: "signature mismatch" }; + } + + return { ok: true }; +} + +/** + * Build the full webhook URL with authentication parameters. + * @deprecated Legacy webhook mode - use Stream mode instead + */ +export function buildWebhookUrl(params: { + accessToken: string; + secret?: string; +}): string { + const { accessToken, secret } = params; + const url = new URL(DINGTALK_ROBOT_SEND_URL); + url.searchParams.set("access_token", accessToken); + + if (secret) { + const timestamp = Date.now(); + const sign = generateSignature(timestamp, secret); + url.searchParams.set("timestamp", String(timestamp)); + url.searchParams.set("sign", sign); + } + + return url.toString(); +} + +/** + * Send a message to DingTalk robot webhook. + */ +export async function sendDingTalkMessage(params: { + accessToken: string; + secret?: string; + message: DingTalkOutboundMessage; +}): Promise<{ ok: boolean; response?: DingTalkApiResponse; error?: string }> { + const { accessToken, secret, message } = params; + + const url = buildWebhookUrl({ accessToken, secret }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + + const data = (await response.json()) as DingTalkApiResponse; + + if (data.errcode !== 0) { + return { + ok: false, + response: data, + error: `DingTalk API error: ${data.errmsg} (code: ${data.errcode})`, + }; + } + + return { ok: true, response: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Send a text message to DingTalk. + */ +export async function sendTextMessage(params: { + accessToken: string; + secret?: string; + content: string; + atUserIds?: string[]; + atMobiles?: string[]; + isAtAll?: boolean; +}): Promise<{ ok: boolean; error?: string }> { + const { accessToken, secret, content, atUserIds, atMobiles, isAtAll } = params; + + const message: DingTalkTextMessage = { + msgtype: "text", + text: { + content, + }, + }; + + if (atUserIds?.length || atMobiles?.length || isAtAll) { + message.at = { + atUserIds, + atMobiles, + isAtAll, + }; + } + + return sendDingTalkMessage({ accessToken, secret, message }); +} + +/** + * Send a markdown message to DingTalk. + */ +export async function sendMarkdownMessage(params: { + accessToken: string; + secret?: string; + title: string; + text: string; + atUserIds?: string[]; + atMobiles?: string[]; + isAtAll?: boolean; +}): Promise<{ ok: boolean; error?: string }> { + const { accessToken, secret, title, text, atUserIds, atMobiles, isAtAll } = params; + + const message: DingTalkMarkdownMessage = { + msgtype: "markdown", + markdown: { + title, + text, + }, + }; + + if (atUserIds?.length || atMobiles?.length || isAtAll) { + message.at = { + atUserIds, + atMobiles, + isAtAll, + }; + } + + return sendDingTalkMessage({ accessToken, secret, message }); +} + +/** + * Reply to a message using the session webhook. + * This is the preferred method when replying to incoming messages. + */ +export async function replyWithSessionWebhook(params: { + sessionWebhook: string; + message: DingTalkOutboundMessage; +}): Promise<{ ok: boolean; response?: DingTalkApiResponse; error?: string }> { + const { sessionWebhook, message } = params; + + try { + const response = await fetch(sessionWebhook, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + + const data = (await response.json()) as DingTalkApiResponse; + + if (data.errcode !== 0) { + return { + ok: false, + response: data, + error: `DingTalk API error: ${data.errmsg} (code: ${data.errcode})`, + }; + } + + return { ok: true, response: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Probe DingTalk connection. + * For Stream mode, we validate clientId and clientSecret are configured. + */ +export async function probeDingTalk(account: ResolvedDingTalkAccount): Promise<{ + ok: boolean; + error?: string; + configured: boolean; + mode: "stream" | "none"; +}> { + // Check Stream mode credentials + if (account.clientId?.trim() && account.clientSecret?.trim()) { + return { + ok: true, + configured: true, + mode: "stream", + }; + } + + return { + ok: false, + configured: false, + mode: "none", + error: "DingTalk credentials not configured. Set clientId and clientSecret for Stream mode.", + }; +} diff --git a/extensions/dingtalk/src/channel.ts b/extensions/dingtalk/src/channel.ts new file mode 100644 index 000000000..372538361 --- /dev/null +++ b/extensions/dingtalk/src/channel.ts @@ -0,0 +1,430 @@ +import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, + type ChannelDock, + type ChannelPlugin, + type MoltbotConfig, +} from "clawdbot/plugin-sdk"; + +import { + listDingTalkAccountIds, + resolveDefaultDingTalkAccountId, + resolveDingTalkAccount, + type ResolvedDingTalkAccount, +} from "./accounts.js"; +import { probeDingTalk } from "./api.js"; +import { dingtalkOnboardingAdapter } from "./onboarding.js"; +import { getDingTalkRuntime } from "./runtime.js"; +import { startDingTalkMonitor } from "./monitor.js"; + +const meta = getChatChannelMeta("dingtalk"); + +const formatAllowFromEntry = (entry: string) => + entry + .trim() + .replace(/^(dingtalk|dingding|ding):/i, "") + .toLowerCase(); + +export const dingtalkDock: ChannelDock = { + id: "dingtalk", + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + media: false, // DingTalk robot API has limited media support + threads: false, + blockStreaming: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveDingTalkAccount({ cfg: cfg as MoltbotConfig, accountId }).config.dm?.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry)) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + groups: { + resolveRequireMention: ({ cfg, accountId }) => { + const account = resolveDingTalkAccount({ cfg: cfg as MoltbotConfig, accountId }); + return account.config.requireMention ?? true; + }, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.["dingtalk"]?.replyToMode ?? "off", + buildToolContext: ({ context, hasRepliedRef }) => ({ + currentChannelId: context.To?.trim() || undefined, + hasRepliedRef, + }), + }, +}; + +export const dingtalkPlugin: ChannelPlugin = { + id: "dingtalk", + meta: { ...meta }, + onboarding: dingtalkOnboardingAdapter, + pairing: { + idLabel: "dingtalkUserId", + normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), + notifyApproval: async ({ cfg, id }) => { + const account = resolveDingTalkAccount({ cfg: cfg as MoltbotConfig }); + if (account.credentialSource === "none" || !account.clientId) return; + // Note: DingTalk doesn't support sending DMs directly via robot API + // The user will need to @mention the robot first to establish a session + // This is a limitation of the DingTalk platform + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: false, + nativeCommands: false, + blockStreaming: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.dingtalk"] }, + configSchema: emptyPluginConfigSchema(), + config: { + listAccountIds: (cfg) => listDingTalkAccountIds(cfg as MoltbotConfig), + resolveAccount: (cfg, accountId) => + resolveDingTalkAccount({ cfg: cfg as MoltbotConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultDingTalkAccountId(cfg as MoltbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "dingtalk", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "dingtalk", + accountId, + clearBaseFields: ["accessToken", "accessTokenFile", "secret", "secretFile", "webhookPath", "webhookUrl", "name"], + }), + isConfigured: (account) => account.credentialSource !== "none", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveDingTalkAccount({ + cfg: cfg as MoltbotConfig, + accountId, + }).config.dm?.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry)) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg as MoltbotConfig).channels?.["dingtalk"]?.accounts?.[resolvedAccountId], + ); + const allowFromPath = useAccountPath + ? `channels.dingtalk.accounts.${resolvedAccountId}.dm.` + : "channels.dingtalk.dm."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("dingtalk"), + normalizeEntry: (raw) => formatAllowFromEntry(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const warnings: string[] = []; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "open") { + warnings.push( + `- DingTalk groups: groupPolicy="open" allows any group to trigger (mention-gated). Set channels.dingtalk.groupPolicy="allowlist" and configure channels.dingtalk.groups.`, + ); + } + if (account.config.dm?.policy === "open") { + warnings.push( + `- DingTalk DMs are open to anyone. Set channels.dingtalk.dm.policy="pairing" or "allowlist".`, + ); + } + return warnings; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId }) => { + const account = resolveDingTalkAccount({ cfg: cfg as MoltbotConfig, accountId }); + return account.config.requireMention ?? true; + }, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.["dingtalk"]?.replyToMode ?? "off", + }, + messaging: { + normalizeTarget: (raw) => { + const trimmed = raw?.trim(); + if (!trimmed) return null; + return trimmed.replace(/^(dingtalk|dingding|ding):/i, ""); + }, + targetResolver: { + looksLikeId: (raw, normalized) => { + const value = normalized ?? raw.trim(); + return Boolean(value); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveDingTalkAccount({ + cfg: cfg as MoltbotConfig, + accountId, + }); + const q = query?.trim().toLowerCase() || ""; + const allowFrom = account.config.dm?.allowFrom ?? []; + const peers = Array.from( + new Set( + allowFrom + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*"), + ), + ) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + return peers; + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveDingTalkAccount({ + cfg: cfg as MoltbotConfig, + accountId, + }); + const groups = account.config.groups ?? {}; + const q = query?.trim().toLowerCase() || ""; + const entries = Object.keys(groups) + .filter((key) => key && key !== "*") + .filter((key) => (q ? key.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + return entries; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "dingtalk", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DINGTALK_ACCESS_TOKEN env var can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "DingTalk requires access token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "dingtalk", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig as MoltbotConfig, + channelKey: "dingtalk", + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { accessTokenFile: input.tokenFile } + : input.token + ? { accessToken: input.token } + : {}; + const webhookPath = input.webhookPath?.trim(); + const webhookUrl = input.webhookUrl?.trim(); + const configPatch = { + ...patch, + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + dingtalk: { + ...(next.channels?.["dingtalk"] ?? {}), + enabled: true, + ...configPatch, + }, + }, + } as MoltbotConfig; + } + return { + ...next, + channels: { + ...next.channels, + dingtalk: { + ...(next.channels?.["dingtalk"] ?? {}), + enabled: true, + accounts: { + ...(next.channels?.["dingtalk"]?.accounts ?? {}), + [accountId]: { + ...(next.channels?.["dingtalk"]?.accounts?.[accountId] ?? {}), + enabled: true, + ...configPatch, + }, + }, + }, + }, + } as MoltbotConfig; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text: string, limit: number) => + getDingTalkRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text, accountId }) => { + const account = resolveDingTalkAccount({ + cfg: cfg as MoltbotConfig, + accountId, + }); + // Stream mode doesn't support outbound messages via accessToken + // Messages are sent via sessionWebhook in response to inbound messages + if (!account.clientId) { + throw new Error("DingTalk clientId not configured"); + } + // For outbound messages, we need to use a different API + // This is a limitation of DingTalk's robot API + throw new Error( + "DingTalk Stream mode does not support proactive outbound messages. Messages can only be sent in response to user messages.", + ); + }, + sendMedia: async () => { + // DingTalk robot API has very limited media support + // Media messages typically require different API endpoints + throw new Error("DingTalk robot API does not support media attachments"); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts: Array<{ accountId?: string; enabled?: boolean; configured?: boolean; clientId?: string }>) => + accounts.flatMap((entry) => { + const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); + const enabled = entry.enabled !== false; + const configured = entry.configured === true; + if (!enabled || !configured) return []; + const issues: Array<{ channel: string; accountId: string; kind: string; message: string; fix: string }> = []; + if (!entry.clientId) { + issues.push({ + channel: "dingtalk", + accountId, + kind: "config", + message: "DingTalk clientId (AppKey) is missing.", + fix: "Set channels.dingtalk.clientId.", + }); + } + return issues; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + credentialSource: snapshot.credentialSource ?? "none", + mode: "stream", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account }) => probeDingTalk(account), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + mode: "stream", + clientId: account.clientId, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dm?.policy ?? "pairing", + probe, + }), + }, + gateway: { + startAccount: async (ctx: { + account: ResolvedDingTalkAccount; + cfg: unknown; + runtime: { log?: (msg: string) => void; error?: (msg: string) => void }; + abortSignal: AbortSignal; + log?: { info: (msg: string) => void }; + setStatus: (patch: Record) => void; + }) => { + const account = ctx.account; + ctx.log?.info(`[${account.accountId}] starting DingTalk Stream mode`); + ctx.setStatus({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + mode: "stream", + }); + const unregister = await startDingTalkMonitor({ + account, + config: ctx.cfg as MoltbotConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), + }); + return () => { + unregister?.(); + ctx.setStatus({ + accountId: account.accountId, + running: false, + lastStopAt: Date.now(), + }); + }; + }, + }, +}; diff --git a/extensions/dingtalk/src/dingtalk-stream.d.ts b/extensions/dingtalk/src/dingtalk-stream.d.ts new file mode 100644 index 000000000..3a3c36a7a --- /dev/null +++ b/extensions/dingtalk/src/dingtalk-stream.d.ts @@ -0,0 +1,40 @@ +/** + * Type declarations for dingtalk-stream SDK + */ + +declare module "dingtalk-stream" { + export interface DWClientOptions { + clientId: string; + clientSecret: string; + } + + export interface StreamResponse { + headers: { + messageId: string; + [key: string]: string; + }; + data: string; + } + + export class DWClient { + constructor(options: DWClientOptions); + registerCallbackListener( + path: string, + callback: (res: StreamResponse) => Promise, + ): DWClient; + connect(): Promise; + disconnect?(): void; + socketCallBackResponse(messageId: string, response: string): void; + } + + export const EventAck: { + SUCCESS: string; + LATER: string; + }; + + export const GATEWAY_URL: string; + export const GET_TOKEN_URL: string; + export const TOPIC_AI_GRAPH_API: string; + export const TOPIC_CARD: string; + export const TOPIC_ROBOT: string; +} diff --git a/extensions/dingtalk/src/monitor.ts b/extensions/dingtalk/src/monitor.ts new file mode 100644 index 000000000..2d205a2c6 --- /dev/null +++ b/extensions/dingtalk/src/monitor.ts @@ -0,0 +1,443 @@ +import type { DingTalkAccountConfig, MoltbotConfig } from "clawdbot/plugin-sdk"; + +import type { ResolvedDingTalkAccount } from "./accounts.js"; +import { replyWithSessionWebhook } from "./api.js"; +import { getDingTalkRuntime } from "./runtime.js"; +import type { + DingTalkStreamMessage, + DingTalkMarkdownMessage, + DingTalkGroupConfig, +} from "./types.js"; + +export type DingTalkRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type DingTalkMonitorOptions = { + account: ResolvedDingTalkAccount; + config: MoltbotConfig; + runtime: DingTalkRuntimeEnv; + abortSignal: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +type DingTalkCoreRuntime = ReturnType; + +function logVerbose(core: DingTalkCoreRuntime, runtime: DingTalkRuntimeEnv, message: string) { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[dingtalk] ${message}`); + } +} + +/** + * Resolve group entry from config for allowlist checking + */ +function resolveGroupEntry( + config: DingTalkAccountConfig, + conversationId: string, +): { + entry: DingTalkGroupConfig | undefined; + wildcard: DingTalkGroupConfig | undefined; + allowlistConfigured: boolean; +} { + const groups = config.groups ?? {}; + const entry = groups[conversationId]; + const wildcard = groups["*"]; + const allowlistConfigured = Object.keys(groups).length > 0; + return { entry, wildcard, allowlistConfigured }; +} + +/** + * Start DingTalk Stream mode monitor using dingtalk-stream SDK. + * Stream mode uses WebSocket connection, no public URL needed. + */ +export async function startDingTalkMonitor(params: DingTalkMonitorOptions): Promise<() => void> { + const { account, config, runtime, abortSignal, statusSink } = params; + const core = getDingTalkRuntime(); + + // Validate Stream mode credentials + if (!account.clientId || !account.clientSecret) { + runtime.error?.( + `[${account.accountId}] DingTalk Stream mode requires clientId and clientSecret`, + ); + return () => {}; + } + + runtime.log?.(`[${account.accountId}] Starting DingTalk Stream mode connection...`); + + let client: DingTalkStreamClient | null = null; + let isConnected = false; + + try { + // Dynamic import of dingtalk-stream SDK + const { DWClient, EventAck } = await import("dingtalk-stream"); + + // Create DingTalk Stream client + client = new DWClient({ + clientId: account.clientId, + clientSecret: account.clientSecret, + }) as DingTalkStreamClient; + + // Register robot message callback listener + // The callback path for robot messages is '/v1.0/im/bot/messages/get' + client.registerCallbackListener( + "/v1.0/im/bot/messages/get", + async (res: DingTalkStreamResponse) => { + try { + statusSink?.({ lastInboundAt: Date.now() }); + + const message = JSON.parse(res.data) as DingTalkStreamMessage; + logVerbose(core, runtime, `received stream message: ${JSON.stringify(message)}`); + + await processStreamMessage({ + message, + account, + config, + runtime, + core, + statusSink, + }); + + // Acknowledge the message was processed + client?.socketCallBackResponse(res.headers.messageId, EventAck.SUCCESS); + } catch (err) { + runtime.error?.(`[${account.accountId}] Stream message processing error: ${String(err)}`); + // Still acknowledge to prevent redelivery + client?.socketCallBackResponse(res.headers.messageId, EventAck.SUCCESS); + } + }, + ); + + // Connect to DingTalk Stream + await client.connect(); + isConnected = true; + runtime.log?.(`[${account.accountId}] DingTalk Stream mode connected successfully`); + + // Handle abort signal + const handleAbort = () => { + if (client && isConnected) { + runtime.log?.(`[${account.accountId}] Disconnecting DingTalk Stream...`); + try { + client.disconnect?.(); + } catch { + // Ignore disconnect errors + } + isConnected = false; + } + }; + + abortSignal.addEventListener("abort", handleAbort); + + return () => { + abortSignal.removeEventListener("abort", handleAbort); + handleAbort(); + }; + } catch (err) { + runtime.error?.(`[${account.accountId}] DingTalk Stream connection failed: ${String(err)}`); + return () => {}; + } +} + +// Type definitions for dingtalk-stream SDK +interface DingTalkStreamClient { + registerCallbackListener: ( + path: string, + callback: (res: DingTalkStreamResponse) => Promise, + ) => DingTalkStreamClient; + connect: () => Promise; + disconnect?: () => void; + socketCallBackResponse: (messageId: string, response: string) => void; +} + +interface DingTalkStreamResponse { + headers: { + messageId: string; + [key: string]: string; + }; + data: string; +} + +/** + * Process incoming DingTalk Stream message + */ +async function processStreamMessage(params: { + message: DingTalkStreamMessage; + account: ResolvedDingTalkAccount; + config: MoltbotConfig; + runtime: DingTalkRuntimeEnv; + core: DingTalkCoreRuntime; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): Promise { + const { message, account, config, runtime, core, statusSink } = params; + + // Only handle text messages for now + if (message.msgtype !== "text") { + logVerbose(core, runtime, `skip non-text message (type=${message.msgtype})`); + return; + } + + const messageText = message.text?.content?.trim() ?? ""; + if (!messageText) { + logVerbose(core, runtime, "skip empty message"); + return; + } + + const isGroup = message.conversationType === "2"; + const senderId = message.senderStaffId; + const senderName = message.senderNick ?? ""; + const conversationId = message.conversationId; + const conversationTitle = message.conversationTitle ?? ""; + + // Check if sender is allowed (simplified DM policy check) + const dmPolicy = account.config.dm?.policy ?? "pairing"; + const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); + + if (!isGroup && dmPolicy !== "open") { + const allowed = isSenderAllowed(senderId, configAllowFrom); + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "dingtalk", + id: senderId, + meta: { name: senderName || undefined }, + }); + if (created) { + logVerbose(core, runtime, `dingtalk pairing request sender=${senderId}`); + try { + await replyWithSessionWebhook({ + sessionWebhook: message.sessionWebhook, + message: { + msgtype: "text", + text: { + content: core.channel.pairing.buildPairingReply({ + channel: "dingtalk", + idLine: `Your DingTalk user ID: ${senderId}`, + code, + }), + }, + }, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } else { + logVerbose(core, runtime, `Blocked unauthorized DingTalk sender ${senderId}`); + } + return; + } + } + + // Group message handling + if (isGroup) { + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + if (groupPolicy === "disabled") { + logVerbose(core, runtime, `drop group message (groupPolicy=disabled)`); + return; + } + + const groupInfo = resolveGroupEntry(account.config, conversationId); + const groupEntry = groupInfo.entry; + + // Allowlist check + if (groupPolicy === "allowlist") { + if (!groupInfo.allowlistConfigured) { + logVerbose( + core, + runtime, + `drop group message (allowlist empty, chat=${conversationId})`, + ); + return; + } + const groupAllowed = Boolean(groupEntry) || Boolean(groupInfo.wildcard); + if (!groupAllowed) { + logVerbose( + core, + runtime, + `drop group message (not allowlisted, chat=${conversationId})`, + ); + return; + } + } + + // Check if group is disabled + if (groupEntry?.enabled === false || groupEntry?.allow === false) { + logVerbose(core, runtime, `drop group message (disabled, chat=${conversationId})`); + return; + } + + // Check group user allowlist + if (groupEntry?.users && groupEntry.users.length > 0) { + const ok = isSenderAllowed( + senderId, + groupEntry.users.map((v) => String(v)), + ); + if (!ok) { + logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); + return; + } + } + + // @mention check + const requireMention = + groupEntry?.requireMention ?? account.config.requireMention ?? true; + const wasMentioned = message.isInAtList === true; + + if (requireMention && !wasMentioned) { + logVerbose(core, runtime, `drop group message (mention required)`); + return; + } + } + + // Build route and session + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "dingtalk", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: conversationId, + }, + }); + + const fromLabel = isGroup + ? conversationTitle || `group:${conversationId}` + : senderName || `user:${senderId}`; + + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + + const body = core.channel.reply.formatAgentEnvelope({ + channel: "DingTalk", + from: fromLabel, + timestamp: message.createAt ?? Date.now(), + previousTimestamp, + envelope: envelopeOptions, + body: messageText, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: messageText, + CommandBody: messageText, + From: `dingtalk:${senderId}`, + To: `dingtalk:${conversationId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "channel" : "direct", + ConversationLabel: fromLabel, + SenderName: senderName || undefined, + SenderId: senderId, + WasMentioned: isGroup ? message.isInAtList : undefined, + Provider: "dingtalk", + Surface: "dingtalk", + MessageSid: message.msgId, + MessageSidFull: message.msgId, + GroupSpace: isGroup ? conversationTitle ?? undefined : undefined, + OriginatingChannel: "dingtalk", + OriginatingTo: `dingtalk:${conversationId}`, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err: unknown) => { + runtime.error?.(`dingtalk: failed updating session meta: ${String(err)}`); + }); + + // Dispatch reply + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + await deliverDingTalkReply({ + payload, + sessionWebhook: message.sessionWebhook, + runtime, + core, + config, + account, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.( + `[${account.accountId}] DingTalk ${info.kind} reply failed: ${String(err)}`, + ); + }, + }, + }); +} + +function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { + if (allowFrom.includes("*")) return true; + const normalizedSenderId = senderId.toLowerCase(); + return allowFrom.some((entry) => { + const normalized = String(entry).trim().toLowerCase(); + if (!normalized) return false; + if (normalized === normalizedSenderId) return true; + if (normalized.replace(/^dingtalk:/i, "") === normalizedSenderId) return true; + return false; + }); +} + +async function deliverDingTalkReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + sessionWebhook: string; + runtime: DingTalkRuntimeEnv; + core: DingTalkCoreRuntime; + config: MoltbotConfig; + account: ResolvedDingTalkAccount; + statusSink?: (patch: { lastOutboundAt?: number }) => void; +}): Promise { + const { payload, sessionWebhook, runtime, core, config, account, statusSink } = params; + + // DingTalk doesn't support media attachments via session webhook in the same way + // We'll send text messages and note media limitations + if (payload.text) { + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode(config, "dingtalk", account.accountId); + const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode); + + for (const chunk of chunks) { + try { + // Use markdown format for better formatting + const message: DingTalkMarkdownMessage = { + msgtype: "markdown", + markdown: { + title: "Reply", + text: chunk, + }, + }; + await replyWithSessionWebhook({ sessionWebhook, message }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`DingTalk message send failed: ${String(err)}`); + } + } + } + + // Note: Media handling would require additional API endpoints + // DingTalk's robot API has limited media support compared to other platforms +} + +// Legacy exports for backward compatibility +export function resolveDingTalkWebhookPath(_params: { + account: ResolvedDingTalkAccount; +}): string { + // Stream mode doesn't use webhooks, but return a placeholder for compatibility + return "/dingtalk-stream"; +} diff --git a/extensions/dingtalk/src/onboarding.ts b/extensions/dingtalk/src/onboarding.ts new file mode 100644 index 000000000..451707563 --- /dev/null +++ b/extensions/dingtalk/src/onboarding.ts @@ -0,0 +1,230 @@ +import type { MoltbotConfig, DmPolicy } from "clawdbot/plugin-sdk"; +import { + addWildcardAllowFrom, + formatDocsLink, + promptAccountId, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + migrateBaseNameToDefaultAccount, +} from "clawdbot/plugin-sdk"; + +import { + listDingTalkAccountIds, + resolveDefaultDingTalkAccountId, + resolveDingTalkAccount, +} from "./accounts.js"; + +const channel = "dingtalk" as const; + +// Stream mode environment variables +const ENV_CLIENT_ID = "DINGTALK_CLIENT_ID"; +const ENV_CLIENT_SECRET = "DINGTALK_CLIENT_SECRET"; + +function setDingTalkDmPolicy(cfg: MoltbotConfig, policy: DmPolicy) { + const allowFrom = + policy === "open" + ? addWildcardAllowFrom(cfg.channels?.dingtalk?.dm?.allowFrom) + : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + dingtalk: { + ...(cfg.channels?.dingtalk ?? {}), + dm: { + ...(cfg.channels?.dingtalk?.dm ?? {}), + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function promptAllowFrom(params: { + cfg: MoltbotConfig; + prompter: WizardPrompter; +}): Promise { + const current = params.cfg.channels?.dingtalk?.dm?.allowFrom ?? []; + const entry = await params.prompter.text({ + message: "DingTalk allowFrom (user ID)", + placeholder: "user123, user456", + initialValue: current[0] ? String(current[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + const unique = [...new Set(parts)]; + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + dingtalk: { + ...(params.cfg.channels?.dingtalk ?? {}), + enabled: true, + dm: { + ...(params.cfg.channels?.dingtalk?.dm ?? {}), + policy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "DingTalk", + channel, + policyKey: "channels.dingtalk.dm.policy", + allowFromKey: "channels.dingtalk.dm.allowFrom", + getCurrent: (cfg) => cfg.channels?.dingtalk?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setDingTalkDmPolicy(cfg, policy), + promptAllowFrom, +}; + +function applyAccountConfig(params: { + cfg: MoltbotConfig; + accountId: string; + patch: Record; +}): MoltbotConfig { + const { cfg, accountId, patch } = params; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + dingtalk: { + ...(cfg.channels?.dingtalk ?? {}), + enabled: true, + ...patch, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + dingtalk: { + ...(cfg.channels?.dingtalk ?? {}), + enabled: true, + accounts: { + ...(cfg.channels?.dingtalk?.accounts ?? {}), + [accountId]: { + ...(cfg.channels?.dingtalk?.accounts?.[accountId] ?? {}), + enabled: true, + ...patch, + }, + }, + }, + }, + }; +} + +async function promptCredentials(params: { + cfg: MoltbotConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const envReady = + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env[ENV_CLIENT_ID]) && + Boolean(process.env[ENV_CLIENT_SECRET]); + + if (envReady) { + const useEnv = await prompter.confirm({ + message: "Use DINGTALK_CLIENT_ID/DINGTALK_CLIENT_SECRET env vars?", + initialValue: true, + }); + if (useEnv) { + return applyAccountConfig({ cfg, accountId, patch: {} }); + } + } + + const clientId = await prompter.text({ + message: "DingTalk AppKey (Client ID)", + placeholder: "dingxxxxxxxx", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const clientSecret = await prompter.text({ + message: "DingTalk AppSecret (Client Secret)", + placeholder: "xxxxxxxx", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + return applyAccountConfig({ + cfg, + accountId, + patch: { + clientId: String(clientId).trim(), + clientSecret: String(clientSecret).trim(), + }, + }); +} + +async function noteDingTalkSetup(prompter: WizardPrompter) { + await prompter.note( + [ + "DingTalk Stream mode uses WebSocket connection (no public URL needed).", + "1. Create an enterprise internal robot in DingTalk Open Platform", + "2. Enable 'Stream Mode' in robot settings", + "3. Copy the AppKey (Client ID) and AppSecret (Client Secret)", + `Docs: ${formatDocsLink("/channels/dingtalk", "channels/dingtalk")}`, + ].join("\n"), + "DingTalk setup", + ); +} + +export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + dmPolicy, + getStatus: async ({ cfg }) => { + const configured = listDingTalkAccountIds(cfg).some( + (accountId) => resolveDingTalkAccount({ cfg, accountId }).credentialSource !== "none", + ); + return { + channel, + configured, + statusLines: [`DingTalk: ${configured ? "configured" : "needs credentials"}`], + selectionHint: configured ? "configured" : "needs auth", + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const override = accountOverrides["dingtalk"]?.trim(); + const defaultAccountId = resolveDefaultDingTalkAccountId(cfg); + let accountId = override ? normalizeAccountId(override) : defaultAccountId; + + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "DingTalk", + currentId: accountId, + listAccountIds: listDingTalkAccountIds, + defaultAccountId, + }); + } + + let next = cfg; + await noteDingTalkSetup(prompter); + next = await promptCredentials({ cfg: next, prompter, accountId }); + + const namedConfig = migrateBaseNameToDefaultAccount({ + cfg: next, + channelKey: "dingtalk", + }); + + return { cfg: namedConfig, accountId }; + }, +}; diff --git a/extensions/dingtalk/src/runtime.ts b/extensions/dingtalk/src/runtime.ts new file mode 100644 index 000000000..849166420 --- /dev/null +++ b/extensions/dingtalk/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setDingTalkRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getDingTalkRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("DingTalk runtime not initialized"); + } + return runtime; +} diff --git a/extensions/dingtalk/src/types.ts b/extensions/dingtalk/src/types.ts new file mode 100644 index 000000000..8dc78d375 --- /dev/null +++ b/extensions/dingtalk/src/types.ts @@ -0,0 +1,209 @@ +/** + * DingTalk Bot API Types + * Based on: https://open.dingtalk.com/document/orgapp/robot-message-types-and-data-format + */ + +// Re-export core types for convenience +export type { + DingTalkAccountConfig, + DingTalkConfig, + DingTalkDmConfig, + DingTalkGroupConfig, +} from "clawdbot/plugin-sdk"; + +// Inbound message types (received from DingTalk webhook callback) + +export type DingTalkUser = { + /** User ID in DingTalk */ + dingtalkId: string; + /** Staff ID in enterprise */ + staffId?: string; + /** User nickname */ + nick?: string; +}; + +export type DingTalkAtUser = { + /** DingTalk ID of the mentioned user */ + dingtalkId: string; + /** Staff ID of the mentioned user */ + staffId?: string; +}; + +/** + * @deprecated Legacy webhook callback event from DingTalk + * Use Stream mode instead (DingTalkStreamMessage) + */ +export type DingTalkCallbackEvent = { + /** Message ID */ + msgId: string; + /** Message type: text, richText, picture, audio, video, file */ + msgtype: string; + /** Conversation ID */ + conversationId: string; + /** Conversation type: "1" = single chat, "2" = group chat */ + conversationType: "1" | "2"; + /** Conversation title (group name for group chat) */ + conversationTitle?: string; + /** Chat bot corp ID */ + chatbotCorpId: string; + /** Chat bot user ID */ + chatbotUserId: string; + /** Sender ID */ + senderId: string; + /** Sender corp ID */ + senderCorpId?: string; + /** Sender nickname */ + senderNick: string; + /** Sender staff ID */ + senderStaffId?: string; + /** Whether sender is admin */ + isAdmin?: boolean; + /** Session webhook URL for replying */ + sessionWebhook: string; + /** Session webhook expiration timestamp */ + sessionWebhookExpiredTime: number; + /** Message creation timestamp */ + createAt: number; + /** Robot code */ + robotCode?: string; + /** Text content for text messages */ + text?: { + content: string; + }; + /** Content for rich text messages */ + content?: string; + /** At users list */ + atUsers?: DingTalkAtUser[]; + /** Whether the robot was @mentioned */ + isInAtList?: boolean; +}; + +// Outbound message types (sent to DingTalk) + +export type DingTalkTextMessage = { + msgtype: "text"; + text: { + content: string; + }; + at?: { + atMobiles?: string[]; + atUserIds?: string[]; + isAtAll?: boolean; + }; +}; + +export type DingTalkLinkMessage = { + msgtype: "link"; + link: { + title: string; + text: string; + messageUrl: string; + picUrl?: string; + }; +}; + +export type DingTalkMarkdownMessage = { + msgtype: "markdown"; + markdown: { + title: string; + text: string; + }; + at?: { + atMobiles?: string[]; + atUserIds?: string[]; + isAtAll?: boolean; + }; +}; + +export type DingTalkActionCardMessage = { + msgtype: "actionCard"; + actionCard: { + title: string; + text: string; + singleTitle?: string; + singleURL?: string; + btnOrientation?: "0" | "1"; + btns?: Array<{ + title: string; + actionURL: string; + }>; + }; +}; + +export type DingTalkFeedCardMessage = { + msgtype: "feedCard"; + feedCard: { + links: Array<{ + title: string; + messageURL: string; + picURL: string; + }>; + }; +}; + +export type DingTalkOutboundMessage = + | DingTalkTextMessage + | DingTalkLinkMessage + | DingTalkMarkdownMessage + | DingTalkActionCardMessage + | DingTalkFeedCardMessage; + +// API response types + +export type DingTalkApiResponse = { + errcode: number; + errmsg: string; +}; + +// Stream mode message types (from dingtalk-stream SDK) + +export type DingTalkStreamMessage = { + /** Platform of sender */ + senderPlatform?: string; + /** Conversation ID */ + conversationId: string; + /** At users list */ + atUsers?: Array<{ + dingtalkId: string; + staffId?: string; + }>; + /** Chat bot corp ID */ + chatbotCorpId?: string; + /** Message ID */ + msgId: string; + /** Sender nickname */ + senderNick?: string; + /** Whether sender is admin */ + isAdmin?: boolean; + /** Sender staff ID (user ID) */ + senderStaffId: string; + /** Session webhook URL for replying */ + sessionWebhook: string; + /** Session webhook expiration timestamp */ + sessionWebhookExpiredTime?: number; + /** Conversation type: "1" = single chat, "2" = group chat */ + conversationType?: "1" | "2"; + /** Conversation title (group name for group chat) */ + conversationTitle?: string; + /** Text content */ + text?: { + content: string; + }; + /** Robot code */ + robotCode?: string; + /** Message type: text, image, voice, file, link, markdown, etc. */ + msgtype: string; + /** Whether robot was @mentioned (in the atUsers list) */ + isInAtList?: boolean; + /** Sender corp ID */ + senderCorpId?: string; + /** Message creation timestamp */ + createAt?: number; +}; + +// Local config type with additional legacy fields (for backward compatibility) +export type DingTalkLocalConfig = { + accounts?: Record; + defaultAccount?: string; + replyToMode?: "off" | "first" | "all"; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..c8589209f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,16 @@ importers: specifier: ^1.39.0 version: 1.39.0 + extensions/dingtalk: + dependencies: + dingtalk-stream: + specifier: ^2.1.4 + version: 2.1.4 + devDependencies: + moltbot: + specifier: workspace:* + version: link:../.. + extensions/discord: {} extensions/google-antigravity-auth: {} @@ -383,12 +393,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1126,89 +1136,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1277,48 +1303,6 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@lancedb/lancedb-darwin-arm64@0.23.0': - resolution: {integrity: sha512-8w0sMCNMwBv2kv5+fczGeSVlNOL+BOKChSsO4usM0hMw3PmxasONPctQBsESDuPS8lQ6/AKAQc2HT/ddd5Mg5w==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [darwin] - - '@lancedb/lancedb-linux-arm64-gnu@0.23.0': - resolution: {integrity: sha512-+xse2IspO7hbuHT4H62q8Ct00fTojnuBxXp1X1I3/27dDvW8E+/itFiJuTZ0YMaJc7nNr9qh9YFXZ9hZdEmReg==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [linux] - - '@lancedb/lancedb-linux-arm64-musl@0.23.0': - resolution: {integrity: sha512-c2UCtGoYjA3oDdw5y3RLK7J2th3rSjYBng+1I03vU9g092y8KATAJO/lV2AtyxSC+esSuyY1dMEaj8ADcXjZAA==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [linux] - - '@lancedb/lancedb-linux-x64-gnu@0.23.0': - resolution: {integrity: sha512-OPL7tK3JCTx43ZxvbVs+CljfCer0KrojANQbcJ2V4VAp6XBhKx1sBAlIVGuCrd93pA8UOUP3iHsM7aglPo6rCg==} - engines: {node: '>= 18'} - cpu: [x64] - os: [linux] - - '@lancedb/lancedb-linux-x64-musl@0.23.0': - resolution: {integrity: sha512-1ZEoQDwOrKvwPyAG+95/r1NYqX8Ca5bRek8Vr62CzWCEmHd/pFeEGWZ5STrkh+Bt3GLdi2JOivFtRbmuBAJypQ==} - engines: {node: '>= 18'} - cpu: [x64] - os: [linux] - - '@lancedb/lancedb-win32-arm64-msvc@0.23.0': - resolution: {integrity: sha512-OuD1mkrgXvijRlXdbx3LvfuorO04FD5qHegnTOWGXh1sIwwrvvhcJAvXUGBNLY4n/lsWvA+xTjtMwRjUitvPKg==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [win32] - - '@lancedb/lancedb-win32-x64-msvc@0.23.0': - resolution: {integrity: sha512-5ve1hvVtp8zWxSE9A+MOQaicXl2Rn0ZG/NUaMTjTD3/CQHPKFmtrqDnM5khoPICTj2O2b10F6mn4cUzl5PASgA==} - engines: {node: '>= 18'} - cpu: [x64] - os: [win32] - '@lancedb/lancedb@0.23.0': resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==} engines: {node: '>= 18'} @@ -1398,24 +1382,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.0': resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.0': resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.0': resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.0': resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==} @@ -1516,30 +1504,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.88': resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.88': resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.88': resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.88': resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==} @@ -1585,36 +1578,28 @@ packages: engines: {node: '>=20.0.0'} cpu: [arm64, x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-armv7l@3.15.0': resolution: {integrity: sha512-ZuZ3q6mejQnEP4o22la7zBv7jNR+IZfgItDm3KjAl04HUXTKJ43HpNwjnf9GyYYd+dEgtoX0MESvWz4RnGH8Jw==} engines: {node: '>=20.0.0'} cpu: [arm, x64] os: [linux] - - '@node-llama-cpp/linux-x64-cuda-ext@3.15.0': - resolution: {integrity: sha512-wQwgSl7Qm8vH56oBt7IuWWDNNsDECkVMS000C92wl3PkbzjwZFiWzehwa+kF8Lr2BBMiCJNkI5nEabhYH3RN2Q==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - - '@node-llama-cpp/linux-x64-cuda@3.15.0': - resolution: {integrity: sha512-mDjyVulCTRYilm9Emm3lDMx7dbI1vzGqk28Pj28shartjERTUu8aUNDYOmVKNMLpUKS1akw7vy0lMF8t4qswxQ==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] + libc: [glibc] '@node-llama-cpp/linux-x64-vulkan@3.15.0': resolution: {integrity: sha512-htVIthQKq/rr8v5e7NiVtcHsstqTBAAC50kUymmDMbrzAu6d/EHacCJpNbU57b1UUa1nKN5cBqr6Jr+QqEalMA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@node-llama-cpp/linux-x64@3.15.0': resolution: {integrity: sha512-etUuTqSyNefRObqc5+JZviNTkuef2XEtHcQLaamEIWwjI1dj7nTD2YMZPBP7H3M3E55HSIY82vqCQ1bp6ZILiA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@node-llama-cpp/mac-arm64-metal@3.15.0': resolution: {integrity: sha512-3Vkq6bpyQZaIzoaLLP7H2Tt8ty5BS0zxUY2pX0ox2S9P4fp8Au0CCJuUJF4V+EKi+/PTn70A6R1QCkRMfMQJig==} @@ -1634,24 +1619,6 @@ packages: cpu: [arm64, x64] os: [win32] - '@node-llama-cpp/win-x64-cuda-ext@3.15.0': - resolution: {integrity: sha512-KQoNH9KsVtqGVXaRdPrnHPrg5w3KOM7CfynPmG1m16gmjmDSIspdPg/Dbg6DgHBfkdAzt+duRZEBk8Bg8KbDHw==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - - '@node-llama-cpp/win-x64-cuda@3.15.0': - resolution: {integrity: sha512-2Kyu1roDwXwFLaJgGZQISIXP9lCDZtJCx/DRcmrYRHcSUFCzo5ikOuAECyliSSQmRUAvvlRCuD+GrTcegbhojA==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - - '@node-llama-cpp/win-x64-vulkan@3.15.0': - resolution: {integrity: sha512-sH+K7lO49WrUvCCC3RPddCBrn2ZQwKCXKL90P/NZicMRgxTPFZEVSU2jXR/bu1K8B+4lNN+z5OEbjSYs7cKEcA==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [win32] - '@node-llama-cpp/win-x64@3.15.0': resolution: {integrity: sha512-gWhtc8l3HOry5guO46YfFohLQnF0NfL4On0GAO8E27JiYYxHO9nHSCfFif4+U03+FfHquZXL0znJ1qPVOiwOPw==} engines: {node: '>=20.0.0'} @@ -1962,21 +1929,25 @@ packages: resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/linux-arm64-musl@0.26.0': resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/linux-x64-gnu@0.26.0': resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/linux-x64-musl@0.26.0': resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/win32-arm64@0.26.0': resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==} @@ -2032,21 +2003,25 @@ packages: resolution: {integrity: sha512-Fow7H84Bs8XxuaK1yfSEWBC8HI7rfEQB9eR2A0J61un1WgCas7jNrt1HbT6+p6KmUH2bhR+r/RDu/6JFAvvj4g==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/linux-arm64-musl@1.41.0': resolution: {integrity: sha512-WoRRDNwgP5W3rjRh42Zdx8ferYnqpKoYCv2QQLenmdrLjRGYwAd52uywfkcS45mKEWHeY1RPwPkYCSROXiGb2w==} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/linux-x64-gnu@1.41.0': resolution: {integrity: sha512-75k3CKj3fOc/a/2aSgO81s3HsTZOFROthPJ+UI2Oatic1LhvH6eKjKfx3jDDyVpzeDS2qekPlc/y3N33iZz5Og==} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/linux-x64-musl@1.41.0': resolution: {integrity: sha512-8r82eBwGPoAPn67ZvdxTlX/Z3gVb+ZtN6nbkyFzwwHWAh8yGutX+VBcVkyrePSl6XgBP4QAaddPnHmkvJjqY0g==} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/win32-arm64@1.41.0': resolution: {integrity: sha512-aK+DAcckQsNCOXKruatyYuY/ROjNiRejQB1PeJtkZwM21+8rV9ODYbvKNvt0pW+YCws7svftBSFMCpl3ke2unw==} @@ -2118,24 +2093,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@reflink/reflink-linux-arm64-musl@0.1.19': resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@reflink/reflink-linux-x64-gnu@0.1.19': resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@reflink/reflink-linux-x64-musl@0.1.19': resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@reflink/reflink-win32-arm64-msvc@0.1.19': resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} @@ -2188,24 +2167,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} @@ -2267,66 +2250,79 @@ packages: resolution: {integrity: sha512-YeGUhkN1oA+iSPzzhEjVPS29YbViOr8s4lSsFaZKLHswgqP911xx25fPOyE9+khmN6W4VeM0aevbDp4kkEoHiA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.3': resolution: {integrity: sha512-eo0iOIOvcAlWB3Z3eh8pVM8hZ0oVkK3AjEM9nSrkSug2l15qHzF3TOwT0747omI6+CJJvl7drwZepT+re6Fy/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.3': resolution: {integrity: sha512-DJay3ep76bKUDImmn//W5SvpjRN5LmK/ntWyeJs/dcnwiiHESd3N4uteK9FDLf0S0W8E6Y0sVRXpOCoQclQqNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.3': resolution: {integrity: sha512-BKKWQkY2WgJ5MC/ayvIJTHjy0JUGb5efaHCUiG/39sSUvAYRBaO3+/EK0AZT1RF3pSj86O24GLLik9mAYu0IJg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.3': resolution: {integrity: sha512-Q9nVlWtKAG7ISW80OiZGxTr6rYtyDSkauHUtvkQI6TNOJjFvpj4gcH+KaJihqYInnAzEEUetPQubRwHef4exVg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.3': resolution: {integrity: sha512-2H5LmhzrpC4fFRNwknzmmTvvyJPHwESoJgyReXeFoYYuIDfBhP29TEXOkCJE/KxHi27mj7wDUClNq78ue3QEBQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.3': resolution: {integrity: sha512-9S542V0ie9LCTznPYlvaeySwBeIEa7rDBgLHKZ5S9DBgcqdJYburabm8TqiqG6mrdTzfV5uttQRHcbKff9lWtA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.3': resolution: {integrity: sha512-ukxw+YH3XXpcezLgbJeasgxyTbdpnNAkrIlFGDl7t+pgCxZ89/6n1a+MxlY7CegU+nDgrgdqDelPRNQ/47zs0g==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.3': resolution: {integrity: sha512-Iauw9UsTTvlF++FhghFJjqYxyXdggXsOqGpFBylaRopVpcbfyIIsNvkf9oGwfgIcf57z3m8+/oSYTo6HutBFNw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.3': resolution: {integrity: sha512-3OqKAHSEQXKdq9mQ4eajqUgNIK27VZPW3I26EP8miIzuKzCJ3aW3oEn2pzF+4/Hj/Moc0YDsOtBgT5bZ56/vcA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.3': resolution: {integrity: sha512-0CM8dSVzVIaqMcXIFej8zZrSFLnGrAE8qlNbbHfTw1EEPnFTg1U1ekI0JdzjPyzSfUsHWtodilQQG/RA55berA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.3': resolution: {integrity: sha512-+fgJE12FZMIgBaKIAGd45rxf+5ftcycANJRWk8Vz0NnMTM5rADPGuRFTYar+Mqs560xuART7XsX2lSACa1iOmQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.3': resolution: {integrity: sha512-tMD7NnbAolWPzQlJQJjVFh/fNH3K/KnA7K8gv2dJWCwwnaK6DFCYST1QXYWfu5V0cDwarWC8Sf/cfMHniNq21A==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.3': resolution: {integrity: sha512-u5KsqxOxjEeIbn7bUK1MPM34jrnPwjeqgyin4/N6e/KzXKfpE9Mi0nCxcQjaM9lLmPcHmn/xx1yOjgTMtu1jWQ==} @@ -3214,11 +3210,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3407,6 +3398,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dingtalk-stream@2.1.4: + resolution: {integrity: sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A==} + discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} @@ -4097,24 +4091,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6848,39 +6846,10 @@ snapshots: '@kwsites/promise-deferred@1.1.1': optional: true - '@lancedb/lancedb-darwin-arm64@0.23.0': - optional: true - - '@lancedb/lancedb-linux-arm64-gnu@0.23.0': - optional: true - - '@lancedb/lancedb-linux-arm64-musl@0.23.0': - optional: true - - '@lancedb/lancedb-linux-x64-gnu@0.23.0': - optional: true - - '@lancedb/lancedb-linux-x64-musl@0.23.0': - optional: true - - '@lancedb/lancedb-win32-arm64-msvc@0.23.0': - optional: true - - '@lancedb/lancedb-win32-x64-msvc@0.23.0': - optional: true - '@lancedb/lancedb@0.23.0(apache-arrow@18.1.0)': dependencies: apache-arrow: 18.1.0 reflect-metadata: 0.2.2 - optionalDependencies: - '@lancedb/lancedb-darwin-arm64': 0.23.0 - '@lancedb/lancedb-linux-arm64-gnu': 0.23.0 - '@lancedb/lancedb-linux-arm64-musl': 0.23.0 - '@lancedb/lancedb-linux-x64-gnu': 0.23.0 - '@lancedb/lancedb-linux-x64-musl': 0.23.0 - '@lancedb/lancedb-win32-arm64-msvc': 0.23.0 - '@lancedb/lancedb-win32-x64-msvc': 0.23.0 '@line/bot-sdk@10.6.0': dependencies: @@ -7189,12 +7158,6 @@ snapshots: '@node-llama-cpp/linux-armv7l@3.15.0': optional: true - '@node-llama-cpp/linux-x64-cuda-ext@3.15.0': - optional: true - - '@node-llama-cpp/linux-x64-cuda@3.15.0': - optional: true - '@node-llama-cpp/linux-x64-vulkan@3.15.0': optional: true @@ -7210,15 +7173,6 @@ snapshots: '@node-llama-cpp/win-arm64@3.15.0': optional: true - '@node-llama-cpp/win-x64-cuda-ext@3.15.0': - optional: true - - '@node-llama-cpp/win-x64-cuda@3.15.0': - optional: true - - '@node-llama-cpp/win-x64-vulkan@3.15.0': - optional: true - '@node-llama-cpp/win-x64@3.15.0': optional: true @@ -9098,84 +9052,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -9349,6 +9225,16 @@ snapshots: diff@8.0.3: {} + dingtalk-stream@2.1.4: + dependencies: + axios: 1.13.2(debug@4.4.3) + debug: 4.4.3 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + discord-api-types@0.38.37: {} docx-preview@0.3.7: @@ -10526,16 +10412,11 @@ snapshots: '@node-llama-cpp/linux-arm64': 3.15.0 '@node-llama-cpp/linux-armv7l': 3.15.0 '@node-llama-cpp/linux-x64': 3.15.0 - '@node-llama-cpp/linux-x64-cuda': 3.15.0 - '@node-llama-cpp/linux-x64-cuda-ext': 3.15.0 '@node-llama-cpp/linux-x64-vulkan': 3.15.0 '@node-llama-cpp/mac-arm64-metal': 3.15.0 '@node-llama-cpp/mac-x64': 3.15.0 '@node-llama-cpp/win-arm64': 3.15.0 '@node-llama-cpp/win-x64': 3.15.0 - '@node-llama-cpp/win-x64-cuda': 3.15.0 - '@node-llama-cpp/win-x64-cuda-ext': 3.15.0 - '@node-llama-cpp/win-x64-vulkan': 3.15.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 77f4f1bc3..54b44eb0a 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,3 +1,4 @@ +import type { DingTalkConfig } from "./types.dingtalk.js"; import type { DiscordConfig } from "./types.discord.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; @@ -25,6 +26,7 @@ export type ChannelDefaultsConfig = { export type ChannelsConfig = { defaults?: ChannelDefaultsConfig; + dingtalk?: DingTalkConfig; whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; diff --git a/src/config/types.dingtalk.ts b/src/config/types.dingtalk.ts new file mode 100644 index 000000000..df8697e8e --- /dev/null +++ b/src/config/types.dingtalk.ts @@ -0,0 +1,90 @@ +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, + ReplyToMode, +} from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; + +export type DingTalkDmConfig = { + /** If false, ignore all incoming DingTalk DMs. Default: true. */ + enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; + /** Allowlist for DM senders (user IDs). */ + allowFrom?: Array; +}; + +export type DingTalkGroupConfig = { + /** If false, disable the bot in this group. (Alias for allow: false.) */ + enabled?: boolean; + /** Legacy group allow toggle; prefer enabled. */ + allow?: boolean; + /** Require mentioning the bot to trigger replies. */ + requireMention?: boolean; + /** Optional tool policy overrides for this group. */ + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + /** Allowlist of users that can invoke the bot in this group. */ + users?: Array; + /** Optional system prompt for this group. */ + systemPrompt?: string; +}; + +export type DingTalkAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this DingTalk account. Default: true. */ + enabled?: boolean; + /** Stream mode: Client ID (AppKey). */ + clientId?: string; + /** Stream mode: Client Secret (AppSecret). */ + clientSecret?: string; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; + /** Default mention requirement for group chats (default: true). */ + requireMention?: boolean; + /** + * Controls how group chats are handled: + * - "open": groups bypass allowlists; mention-gating applies + * - "disabled": block all group chats + * - "allowlist": only allow groups present in channels.dingtalk.groups + */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Merge streamed block replies before sending. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Max media size in MB. */ + mediaMaxMb?: number; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + dm?: DingTalkDmConfig; + groups?: Record; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; +}; + +export type DingTalkConfig = { + /** Optional per-account DingTalk configuration (multi-account). */ + accounts?: Record; +} & DingTalkAccountConfig; diff --git a/src/config/types.ts b/src/config/types.ts index 424ccaf26..9c32d62f2 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -9,6 +9,7 @@ export * from "./types.browser.js"; export * from "./types.channels.js"; export * from "./types.clawdbot.js"; export * from "./types.cron.js"; +export * from "./types.dingtalk.js"; export * from "./types.discord.js"; export * from "./types.googlechat.js"; export * from "./types.gateway.js"; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..dd1540bec 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -770,3 +770,65 @@ export const MSTeamsConfigSchema = z 'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"', }); }); + +// DingTalk schemas +export const DingTalkDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + }) + .strict() + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.policy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.dingtalk.dm.policy="open" requires channels.dingtalk.dm.allowFrom to include "*"', + }); + }); + +export const DingTalkGroupSchema = z + .object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, + users: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +export const DingTalkAccountSchema = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema.optional(), + configWrites: z.boolean().optional(), + enabled: z.boolean().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + allowBots: z.boolean().optional(), + requireMention: z.boolean().optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + replyToMode: ReplyToModeSchema.optional(), + dm: DingTalkDmSchema.optional(), + groups: z.record(z.string(), DingTalkGroupSchema.optional()).optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + }) + .strict(); + +export const DingTalkConfigSchema = DingTalkAccountSchema.extend({ + accounts: z.record(z.string(), DingTalkAccountSchema.optional()).optional(), +}); diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 0a7ec9db8..cf8f31bdb 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { BlueBubblesConfigSchema, + DingTalkConfigSchema, DiscordConfigSchema, GoogleChatConfigSchema, IMessageConfigSchema, @@ -27,6 +28,7 @@ export const ChannelsSchema = z }) .strict() .optional(), + dingtalk: DingTalkConfigSchema.optional(), whatsapp: WhatsAppConfigSchema.optional(), telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 0813caefd..225b7fd36 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -84,6 +84,10 @@ export type { GroupToolPolicyBySenderConfig, MarkdownConfig, MarkdownTableMode, + DingTalkAccountConfig, + DingTalkConfig, + DingTalkDmConfig, + DingTalkGroupConfig, GoogleChatAccountConfig, GoogleChatConfig, GoogleChatDmConfig, @@ -95,6 +99,8 @@ export type { MSTeamsTeamConfig, } from "../config/types.js"; export { + DingTalkConfigSchema, + DingTalkAccountSchema, DiscordConfigSchema, GoogleChatConfigSchema, IMessageConfigSchema,