Compare commits

...

10 Commits

Author SHA1 Message Date
empty
79b946584e feat(qq): add QQ Bot channel plugin (Official Bot API)
Some checks are pending
CI / install-check (push) Waiting to run
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Waiting to run
CI / checks (bunx vitest run, bun, test) (push) Waiting to run
CI / checks (pnpm build, node, build) (push) Waiting to run
CI / checks (pnpm format, node, format) (push) Waiting to run
CI / checks (pnpm lint, node, lint) (push) Waiting to run
CI / checks (pnpm protocol:check, node, protocol) (push) Waiting to run
CI / checks (pnpm test, node, test) (push) Waiting to run
CI / secrets (push) Waiting to run
CI / checks-windows (pnpm build, node, build) (push) Waiting to run
CI / checks-windows (pnpm lint, node, lint) (push) Waiting to run
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Waiting to run
CI / checks-windows (pnpm test, node, test) (push) Waiting to run
CI / checks-macos (pnpm test, test) (push) Waiting to run
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Waiting to run
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Waiting to run
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Waiting to run
CI / ios (push) Waiting to run
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Waiting to run
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Waiting to run
Docker Release / build-amd64 (push) Waiting to run
Docker Release / build-arm64 (push) Waiting to run
Docker Release / create-manifest (push) Blocked by required conditions
Install Smoke / install-smoke (push) Waiting to run
Workflow Sanity / no-tabs (push) Waiting to run
- Implement QQ Bot API client with token caching
- Add WebSocket monitor for event handling
- Support C2C (single chat) and group messages
- Include pairing mechanism for DM authorization

Also fix memory-core peerDependencies to use workspace:*
2026-01-28 00:05:33 +08:00
Peter Steinberger
e4518d2271 fix: allow docker builds to skip missing a2ui assets 2026-01-27 15:16:20 +00:00
Peter Steinberger
0594ccf92a fix: skip a2ui bundling when sources are excluded 2026-01-27 15:01:57 +00:00
Peter Steinberger
3015e11fd7 fix: stabilize install smoke against clawdbot installer 2026-01-27 14:58:01 +00:00
Peter Steinberger
5eff33abe6 fix: sync pnpm lockfile for moltbot rename 2026-01-27 14:37:10 +00:00
Peter Steinberger
3f83afe4a6 chore: update a2ui bundle hash 2026-01-27 13:00:02 +00:00
Peter Steinberger
44f9017355 fix: include compat dist in npm package 2026-01-27 12:59:59 +00:00
Peter Steinberger
7e99311e1d chore: normalize io compat test newline 2026-01-27 12:49:23 +00:00
Peter Steinberger
58640e9ecb fix: load config from moltbot and legacy dirs 2026-01-27 12:49:07 +00:00
Peter Steinberger
735aea9efa refactor: align skills and loaders with moltbot rename 2026-01-27 12:21:02 +00:00
82 changed files with 2209 additions and 186 deletions

View File

@@ -37,5 +37,5 @@ jobs:
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
run: pnpm test:install:smoke

View File

@@ -24,7 +24,7 @@ COPY scripts ./scripts
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN CLAWDBOT_A2UI_SKIP_MISSING=1 pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV CLAWDBOT_PREFER_PNPM=1
RUN pnpm ui:install

View File

@@ -8,7 +8,7 @@
"./index.ts"
]
},
"peerDependencies": {
"moltbot": ">=2026.1.26"
"dependencies": {
"moltbot": "workspace:*"
}
}

38
extensions/qq/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Moltbot QQ Channel Plugin
QQ 机器人官方 API 渠道插件,支持:
- 单聊 (C2C) 消息
- 群聊 @机器人 消息
- 频道消息 (可选)
## 配置
`~/.clawdbot/clawdbot.json` 中添加:
```json5
{
channels: {
qq: {
appId: "YOUR_APP_ID",
appSecret: "YOUR_APP_SECRET",
enabled: true
}
}
}
```
或使用环境变量:
```bash
export QQ_APP_ID=your_app_id
export QQ_APP_SECRET=your_secret
```
## 获取凭据
1. 访问 [QQ 开放平台](https://q.qq.com/)
2. 创建机器人应用
3. 获取 AppID 和 AppSecret
## 事件订阅
需要在 QQ 开放平台配置 WebSocket 事件订阅 Intents。

View File

@@ -0,0 +1,11 @@
{
"id": "qq",
"channels": [
"qq"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

22
extensions/qq/index.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* QQ Bot Plugin Entry
*/
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { qqDock, qqPlugin } from "./src/channel.js";
import { setQQRuntime } from "./src/runtime.js";
const plugin = {
id: "qq",
name: "QQ",
description: "QQ channel plugin (Official Bot API)",
configSchema: emptyPluginConfigSchema(),
register(api: MoltbotPluginApi) {
setQQRuntime(api.runtime);
api.registerChannel({ plugin: qqPlugin, dock: qqDock });
},
};
export default plugin;

View File

@@ -0,0 +1,33 @@
{
"name": "@moltbot/qq",
"version": "2026.1.27",
"type": "module",
"description": "Moltbot QQ channel plugin (Official Bot API)",
"moltbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "qq",
"label": "QQ",
"selectionLabel": "QQ (Official Bot API)",
"docsPath": "/channels/qq",
"docsLabel": "qq",
"blurb": "QQ 机器人官方 API 渠道插件",
"aliases": [
"qq"
],
"order": 85,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@moltbot/qq",
"localPath": "extensions/qq",
"defaultChoice": "npm"
}
},
"dependencies": {
"moltbot": "workspace:*",
"ws": "^8.18.0"
}
}

View File

@@ -0,0 +1,114 @@
/**
* QQ Bot Account Management
*/
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { QQAccountConfig, QQConfig } from "./config-schema.js";
export interface ResolvedQQAccount {
accountId: string;
name?: string;
enabled: boolean;
appId?: string;
appSecret?: string;
tokenSource: "config" | "env" | "none";
config: QQAccountConfig;
}
/**
* List all configured QQ account IDs
*/
export function listQQAccountIds(cfg: MoltbotConfig): string[] {
const qqConfig = cfg.channels?.qq as QQConfig | undefined;
if (!qqConfig) return [];
const ids = new Set<string>();
// Check base level config
if (qqConfig.appId || qqConfig.appSecret || process.env.QQ_APP_ID) {
ids.add(DEFAULT_ACCOUNT_ID);
}
// Check named accounts
if (qqConfig.accounts) {
for (const accountId of Object.keys(qqConfig.accounts)) {
ids.add(accountId);
}
}
return Array.from(ids);
}
/**
* Resolve default account ID
*/
export function resolveDefaultQQAccountId(cfg: MoltbotConfig): string {
const ids = listQQAccountIds(cfg);
return ids.includes(DEFAULT_ACCOUNT_ID)
? DEFAULT_ACCOUNT_ID
: ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/**
* Resolve a specific QQ account configuration
*/
export function resolveQQAccount(params: {
cfg: MoltbotConfig;
accountId?: string;
}): ResolvedQQAccount {
const { cfg, accountId: rawAccountId } = params;
const accountId = rawAccountId ?? DEFAULT_ACCOUNT_ID;
const qqConfig = cfg.channels?.qq as QQConfig | undefined;
const accountConfig =
accountId !== DEFAULT_ACCOUNT_ID
? qqConfig?.accounts?.[accountId]
: undefined;
const baseConfig: QQAccountConfig = {
enabled: qqConfig?.enabled,
appId: qqConfig?.appId,
appSecret: qqConfig?.appSecret,
name: qqConfig?.name,
allowFrom: qqConfig?.allowFrom,
dmPolicy: qqConfig?.dmPolicy,
intents: qqConfig?.intents,
sandbox: qqConfig?.sandbox,
};
const mergedConfig: QQAccountConfig =
accountId !== DEFAULT_ACCOUNT_ID && accountConfig
? { ...baseConfig, ...accountConfig }
: baseConfig;
// Resolve credentials
let appId = mergedConfig.appId;
let appSecret = mergedConfig.appSecret;
let tokenSource: ResolvedQQAccount["tokenSource"] = "none";
if (appId && appSecret) {
tokenSource = "config";
} else if (accountId === DEFAULT_ACCOUNT_ID) {
// Fall back to environment variables for default account
const envAppId = process.env.QQ_APP_ID;
const envAppSecret = process.env.QQ_APP_SECRET;
if (envAppId && envAppSecret) {
appId = envAppId;
appSecret = envAppSecret;
tokenSource = "env";
}
}
return {
accountId,
name:
mergedConfig.name ??
(accountId === DEFAULT_ACCOUNT_ID ? "QQ" : accountId),
enabled: mergedConfig.enabled ?? false,
appId,
appSecret,
tokenSource,
config: mergedConfig,
};
}

200
extensions/qq/src/api.ts Normal file
View File

@@ -0,0 +1,200 @@
/**
* QQ Bot API Client
*
* Handles authentication and API calls to QQ Bot platform.
*/
import type {
AccessTokenResponse,
GatewayResponse,
SendMessageRequest,
SendMessageResponse,
QQApiError,
} from "./types.js";
const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
export type QQFetch = typeof fetch;
// Token cache
interface TokenCache {
token: string;
expiresAt: number;
}
const tokenCacheMap = new Map<string, TokenCache>();
/**
* Get Access Token with caching and auto-refresh
*/
export async function getAccessToken(
appId: string,
appSecret: string,
fetcher: QQFetch = fetch,
): Promise<string> {
const cacheKey = appId;
const cached = tokenCacheMap.get(cacheKey);
// Return cached token if still valid (with 60s buffer)
if (cached && cached.expiresAt > Date.now() + 60_000) {
return cached.token;
}
const response = await fetcher(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ appId, clientSecret: appSecret }),
});
if (!response.ok) {
const text = await response.text();
throw new QQApiException(`Failed to get access token: ${response.status} ${text}`);
}
const data = (await response.json()) as AccessTokenResponse;
const expiresIn = parseInt(data.expires_in, 10) * 1000;
tokenCacheMap.set(cacheKey, {
token: data.access_token,
expiresAt: Date.now() + expiresIn,
});
return data.access_token;
}
/**
* Clear token cache for an app
*/
export function clearTokenCache(appId: string): void {
tokenCacheMap.delete(appId);
}
/**
* Get WebSocket Gateway URL
*/
export async function getGatewayUrl(
token: string,
fetcher: QQFetch = fetch,
): Promise<string> {
const response = await fetcher(`${API_BASE}/gateway`, {
headers: {
Authorization: `QQBot ${token}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new QQApiException(`Failed to get gateway URL: ${response.status} ${text}`);
}
const data = (await response.json()) as GatewayResponse;
return data.url;
}
/**
* Send message to C2C (single chat)
*/
export async function sendC2CMessage(
token: string,
openId: string,
request: SendMessageRequest,
fetcher: QQFetch = fetch,
): Promise<SendMessageResult> {
return sendMessage(token, `/v2/users/${openId}/messages`, request, fetcher);
}
/**
* Send message to group
*/
export async function sendGroupMessage(
token: string,
groupOpenId: string,
request: SendMessageRequest,
fetcher: QQFetch = fetch,
): Promise<SendMessageResult> {
return sendMessage(token, `/v2/groups/${groupOpenId}/messages`, request, fetcher);
}
/**
* Send message to channel
*/
export async function sendChannelMessage(
token: string,
channelId: string,
request: SendMessageRequest,
fetcher: QQFetch = fetch,
): Promise<SendMessageResult> {
return sendMessage(token, `/channels/${channelId}/messages`, request, fetcher);
}
/**
* Send message to DMS (频道私信)
*/
export async function sendDmsMessage(
token: string,
guildId: string,
request: SendMessageRequest,
fetcher: QQFetch = fetch,
): Promise<SendMessageResult> {
return sendMessage(token, `/dms/${guildId}/messages`, request, fetcher);
}
export interface SendMessageResult {
ok: boolean;
messageId?: string;
timestamp?: string;
error?: string;
}
async function sendMessage(
token: string,
path: string,
request: SendMessageRequest,
fetcher: QQFetch,
): Promise<SendMessageResult> {
try {
const response = await fetcher(`${API_BASE}${path}`, {
method: "POST",
headers: {
Authorization: `QQBot ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as QQApiError;
return {
ok: false,
error: errorData.message || `HTTP ${response.status}`,
};
}
const data = (await response.json()) as SendMessageResponse;
return {
ok: true,
messageId: data.id,
timestamp: data.timestamp,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* QQ API Exception
*/
export class QQApiException extends Error {
constructor(
message: string,
public code?: number,
public data?: unknown,
) {
super(message);
this.name = "QQApiException";
}
}

View File

@@ -0,0 +1,416 @@
/**
* QQ Bot Channel Plugin
*
* Main channel plugin implementation for QQ Bot.
*/
import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
MoltbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import {
listQQAccountIds,
resolveDefaultQQAccountId,
resolveQQAccount,
type ResolvedQQAccount,
} from "./accounts.js";
import { QQConfigSchema } from "./config-schema.js";
import { probeQQ } from "./probe.js";
import { sendMessageQQ } from "./send.js";
const meta = {
id: "qq",
label: "QQ",
selectionLabel: "QQ (Official Bot API)",
docsPath: "/channels/qq",
docsLabel: "qq",
blurb: "QQ 机器人官方 API支持单聊、群聊",
aliases: ["qq"],
order: 85,
quickstartAllowFrom: true,
};
function normalizeQQMessagingTarget(raw: string): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
// Remove qq: or group: prefix
return trimmed.replace(/^(qq|group):/i, "");
}
export const qqDock: ChannelDock = {
id: "qq",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config
.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(qq|group):/i, ""))
.map((entry) => entry.toLowerCase()),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
export const qqPlugin: ChannelPlugin<ResolvedQQAccount> = {
id: "qq",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.qq"] },
configSchema: buildChannelConfigSchema(QQConfigSchema),
config: {
listAccountIds: (cfg) => listQQAccountIds(cfg as MoltbotConfig),
resolveAccount: (cfg, accountId) =>
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }),
defaultAccountId: (cfg) =>
resolveDefaultQQAccountId(cfg as MoltbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as MoltbotConfig,
sectionKey: "qq",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as MoltbotConfig,
sectionKey: "qq",
accountId,
clearBaseFields: ["appId", "appSecret", "name"],
}),
isConfigured: (account) =>
Boolean(account.appId?.trim() && account.appSecret?.trim()),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.appId?.trim() && account.appSecret?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config
.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(qq|group):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as MoltbotConfig).channels?.qq?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.qq.accounts.${resolvedAccountId}.`
: "channels.qq.";
return {
policy: account.config.dmPolicy ?? "open",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("qq"),
normalizeEntry: (raw) => raw.replace(/^(qq|group):/i, ""),
};
},
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: normalizeQQMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
// QQ OpenIDs are typically hex strings
return /^[A-F0-9]{32}$/i.test(trimmed) || /^\d{5,}$/.test(trimmed);
},
hint: "<openId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveQQAccount({
cfg: cfg as MoltbotConfig,
accountId,
});
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
(account.config.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => entry.replace(/^(qq|group):/i, "")),
),
)
.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 () => [],
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as MoltbotConfig,
channelKey: "qq",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "QQ_APP_ID/QQ_APP_SECRET can only be used for the default account.";
}
if (!input.useEnv && (!input.appId || !input.appSecret)) {
return "QQ requires appId and appSecret (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as MoltbotConfig,
channelKey: "qq",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "qq",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
qq: {
...next.channels?.qq,
enabled: true,
...(input.useEnv
? {}
: {
appId: input.appId,
appSecret: input.appSecret,
}),
},
},
} as MoltbotConfig;
}
return {
...next,
channels: {
...next.channels,
qq: {
...next.channels?.qq,
enabled: true,
accounts: {
...(next.channels?.qq?.accounts ?? {}),
[accountId]: {
...(next.channels?.qq?.accounts?.[accountId] ?? {}),
enabled: true,
appId: input.appId,
appSecret: input.appSecret,
},
},
},
},
} as MoltbotConfig;
},
},
pairing: {
idLabel: "qqOpenId",
normalizeAllowEntry: (entry) => entry.replace(/^(qq|group):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveQQAccount({ cfg: cfg as MoltbotConfig });
if (!account.appId || !account.appSecret) {
throw new Error("QQ appId/appSecret not configured");
}
await sendMessageQQ("c2c", id, PAIRING_APPROVED_MESSAGE, {
appId: account.appId,
appSecret: account.appSecret,
});
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const lastNewline = window.lastIndexOf("\n");
const lastSpace = window.lastIndexOf(" ");
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
if (breakIdx <= 0) breakIdx = limit;
const rawChunk = remaining.slice(0, breakIdx);
const chunk = rawChunk.trimEnd();
if (chunk.length > 0) chunks.push(chunk);
const brokeOnSeparator =
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(
remaining.length,
breakIdx + (brokeOnSeparator ? 1 : 0),
);
remaining = remaining.slice(nextStart).trimStart();
}
if (remaining.length) chunks.push(remaining);
return chunks;
},
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
// Determine chat type from target format
const isGroup = to.startsWith("group:");
const targetId = to.replace(/^(qq:|group:)/i, "");
const chatType = isGroup ? "group" : "c2c";
const result = await sendMessageQQ(chatType, targetId, text, {
accountId: accountId ?? undefined,
cfg: cfg as MoltbotConfig,
});
return {
channel: "qq",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
// TODO: Implement media sending via QQ rich media API
// For now, just send text with media URL
const isGroup = to.startsWith("group:");
const targetId = to.replace(/^(qq:|group:)/i, "");
const chatType = isGroup ? "group" : "c2c";
const messageText = mediaUrl ? `${text}\n${mediaUrl}` : text;
const result = await sendMessageQQ(chatType, targetId, messageText, {
accountId: accountId ?? undefined,
cfg: cfg as MoltbotConfig,
});
return {
channel: "qq",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: async () => [],
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: "websocket",
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
account.appId && account.appSecret
? probeQQ(account.appId, account.appSecret, timeoutMs)
: { ok: false, error: "appId/appSecret not configured" },
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(
account.appId?.trim() && account.appSecret?.trim(),
);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "websocket",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "open",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (!account.appId?.trim() || !account.appSecret?.trim()) {
throw new Error("QQ appId and appSecret are required");
}
ctx.log?.info(`[${account.accountId}] Starting QQ provider`);
const { monitorQQProvider } = await import("./monitor.js");
return monitorQQProvider({
account,
config: ctx.cfg as MoltbotConfig,
abortSignal: ctx.abortSignal,
statusSink: (patch) =>
ctx.setStatus({ accountId: ctx.accountId, ...patch }),
log: ctx.log,
});
},
},
};

View File

@@ -0,0 +1,41 @@
/**
* QQ Bot Configuration Schema
*/
import { Type } from "@sinclair/typebox";
const StringEnum = <T extends string[]>(values: [...T]) =>
Type.Unsafe<T[number]>({ type: "string", enum: values });
export const QQAccountConfigSchema = Type.Object({
enabled: Type.Optional(Type.Boolean()),
appId: Type.Optional(Type.String()),
appSecret: Type.Optional(Type.String()),
name: Type.Optional(Type.String()),
allowFrom: Type.Optional(Type.Array(Type.String())),
dmPolicy: Type.Optional(StringEnum(["open", "pairing", "disabled"])),
intents: Type.Optional(Type.Number()),
sandbox: Type.Optional(Type.Boolean()),
});
export const QQConfigSchema = Type.Object({
...QQAccountConfigSchema.properties,
accounts: Type.Optional(
Type.Record(Type.String(), QQAccountConfigSchema),
),
});
export type QQAccountConfig = {
enabled?: boolean;
appId?: string;
appSecret?: string;
name?: string;
allowFrom?: string[];
dmPolicy?: "open" | "pairing" | "disabled";
intents?: number;
sandbox?: boolean;
};
export type QQConfig = QQAccountConfig & {
accounts?: Record<string, QQAccountConfig>;
};

View File

@@ -0,0 +1,534 @@
/**
* QQ Bot WebSocket Monitor
*
* Handles WebSocket connection to QQ Bot Gateway for receiving events.
*/
import WebSocket from "ws";
import type { MoltbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
import type { ResolvedQQAccount } from "./accounts.js";
import {
getAccessToken,
getGatewayUrl,
sendC2CMessage,
sendGroupMessage,
} from "./api.js";
import { getQQRuntime } from "./runtime.js";
import { chunkQQText } from "./send.js";
import {
OpCode,
EventType,
DEFAULT_INTENTS,
type GatewayPayload,
type HelloData,
type ReadyData,
type QQMessageEvent,
type IdentifyData,
type ResumeData,
} from "./types.js";
export interface QQMonitorOptions {
account: ResolvedQQAccount;
config: MoltbotConfig;
abortSignal: AbortSignal;
statusSink?: (patch: {
lastInboundAt?: number;
lastOutboundAt?: number;
sessionId?: string;
}) => void;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
};
}
export interface QQMonitorResult {
stop: () => void;
}
const QQ_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
type QQCoreRuntime = ReturnType<typeof getQQRuntime>;
/**
* Start QQ Bot WebSocket monitor
*/
export async function monitorQQProvider(
options: QQMonitorOptions,
): Promise<QQMonitorResult> {
const { account, config, abortSignal, statusSink, log } = options;
if (!account.appId || !account.appSecret) {
throw new Error("QQ appId and appSecret are required");
}
const core = getQQRuntime();
let stopped = false;
let ws: WebSocket | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let sessionId: string | null = null;
let lastSeq: number | null = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 10;
const stop = () => {
stopped = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (ws) {
ws.close();
ws = null;
}
};
abortSignal.addEventListener("abort", stop, { once: true });
const connect = async () => {
if (stopped || abortSignal.aborted) return;
try {
const token = await getAccessToken(account.appId!, account.appSecret!);
const gatewayUrl = await getGatewayUrl(token);
log?.info(`[${account.accountId}] Connecting to gateway: ${gatewayUrl}`);
ws = new WebSocket(gatewayUrl);
ws.on("open", () => {
log?.info(`[${account.accountId}] WebSocket connected`);
reconnectAttempts = 0;
});
ws.on("message", async (data) => {
try {
const payload = JSON.parse(data.toString()) as GatewayPayload;
await handlePayload(payload, token);
} catch (err) {
log?.error(
`[${account.accountId}] Failed to parse message: ${String(err)}`,
);
}
});
ws.on("close", (code, reason) => {
log?.info(
`[${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`,
);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
scheduleReconnect();
});
ws.on("error", (err) => {
log?.error(`[${account.accountId}] WebSocket error: ${String(err)}`);
});
} catch (err) {
log?.error(`[${account.accountId}] Failed to connect: ${String(err)}`);
scheduleReconnect();
}
};
const scheduleReconnect = () => {
if (stopped || abortSignal.aborted) return;
if (reconnectAttempts >= maxReconnectAttempts) {
log?.error(`[${account.accountId}] Max reconnect attempts reached`);
return;
}
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
log?.info(
`[${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`,
);
setTimeout(connect, delay);
};
const handlePayload = async (
payload: GatewayPayload,
token: string,
): Promise<void> => {
// Update sequence number
if (payload.s !== undefined) {
lastSeq = payload.s;
}
switch (payload.op) {
case OpCode.Hello: {
const hello = payload.d as HelloData;
log?.info(
`[${account.accountId}] Received Hello, heartbeat interval: ${hello.heartbeat_interval}ms`,
);
// Start heartbeat
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => {
sendHeartbeat();
}, hello.heartbeat_interval);
// Send Identify or Resume
if (sessionId && lastSeq !== null) {
sendResume(token);
} else {
sendIdentify(token);
}
break;
}
case OpCode.HeartbeatAck:
// Heartbeat acknowledged
break;
case OpCode.Dispatch:
statusSink?.({ lastInboundAt: Date.now() });
await handleEvent(payload.t!, payload.d, token);
break;
case OpCode.Reconnect:
log?.info(`[${account.accountId}] Received Reconnect, reconnecting...`);
ws?.close();
break;
case OpCode.InvalidSession: {
const resumable = payload.d as boolean;
log?.info(
`[${account.accountId}] Invalid session, resumable: ${resumable}`,
);
if (!resumable) {
sessionId = null;
lastSeq = null;
}
// Wait a bit before reconnecting
setTimeout(() => {
if (resumable && sessionId) {
sendResume(token);
} else {
sendIdentify(token);
}
}, 1000 + Math.random() * 4000);
break;
}
}
};
const sendHeartbeat = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload: GatewayPayload<number | null> = {
op: OpCode.Heartbeat,
d: lastSeq,
};
ws.send(JSON.stringify(payload));
};
const sendIdentify = async (token: string) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const intents = account.config.intents ?? DEFAULT_INTENTS;
const payload: GatewayPayload<IdentifyData> = {
op: OpCode.Identify,
d: {
token: `QQBot ${token}`,
intents,
properties: {
$os: "linux",
$browser: "moltbot",
$device: "moltbot",
},
},
};
log?.info(`[${account.accountId}] Sending Identify with intents: ${intents}`);
ws.send(JSON.stringify(payload));
};
const sendResume = (token: string) => {
if (!ws || ws.readyState !== WebSocket.OPEN || !sessionId) return;
const payload: GatewayPayload<ResumeData> = {
op: OpCode.Resume,
d: {
token: `QQBot ${token}`,
session_id: sessionId,
seq: lastSeq ?? 0,
},
};
log?.info(`[${account.accountId}] Sending Resume for session: ${sessionId}`);
ws.send(JSON.stringify(payload));
};
const handleEvent = async (
eventType: string,
data: unknown,
token: string,
): Promise<void> => {
switch (eventType) {
case EventType.READY: {
const ready = data as ReadyData;
sessionId = ready.session_id;
statusSink?.({ sessionId });
log?.info(
`[${account.accountId}] Ready! Bot: ${ready.user.username} (${ready.user.id})`,
);
break;
}
case EventType.RESUMED:
log?.info(`[${account.accountId}] Session resumed`);
break;
case EventType.C2C_MESSAGE_CREATE:
await handleC2CMessage(data as QQMessageEvent, token);
break;
case EventType.GROUP_AT_MESSAGE_CREATE:
await handleGroupMessage(data as QQMessageEvent, token);
break;
case EventType.DIRECT_MESSAGE_CREATE:
case EventType.AT_MESSAGE_CREATE:
case EventType.MESSAGE_CREATE:
// TODO: Implement channel message handling
log?.info(`[${account.accountId}] Received channel event: ${eventType}`);
break;
default:
log?.info(`[${account.accountId}] Unhandled event: ${eventType}`);
}
};
const handleC2CMessage = async (
message: QQMessageEvent,
token: string,
): Promise<void> => {
const senderId = message.author.user_openid;
if (!senderId) return;
log?.info(
`[${account.accountId}] C2C message from ${senderId}: ${message.content?.slice(0, 50)}`,
);
await processMessageWithPipeline({
message,
token,
chatType: "c2c",
chatId: senderId,
senderId,
isGroup: false,
});
};
const handleGroupMessage = async (
message: QQMessageEvent,
token: string,
): Promise<void> => {
const groupId = message.group_openid;
const senderId = message.author.member_openid;
if (!groupId || !senderId) return;
log?.info(
`[${account.accountId}] Group message in ${groupId} from ${senderId}: ${message.content?.slice(0, 50)}`,
);
await processMessageWithPipeline({
message,
token,
chatType: "group",
chatId: groupId,
senderId,
isGroup: true,
});
};
const processMessageWithPipeline = async (params: {
message: QQMessageEvent;
token: string;
chatType: "c2c" | "group";
chatId: string;
senderId: string;
isGroup: boolean;
}): Promise<void> => {
const { message, token, chatType, chatId, senderId, isGroup } = params;
const rawBody = message.content?.trim() || "";
if (!rawBody) return;
const dmPolicy = account.config.dmPolicy ?? "open";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) =>
String(v),
);
// Check authorization for DMs
if (!isGroup && dmPolicy !== "open") {
const allowed =
configAllowFrom.includes("*") ||
configAllowFrom.some(
(entry) => entry.toLowerCase() === senderId.toLowerCase(),
);
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "qq",
id: senderId,
meta: {},
});
if (created) {
log?.info(`[${account.accountId}] Pairing request from ${senderId}`);
const replyText = core.channel.pairing.buildPairingReply({
channel: "qq",
idLine: `Your QQ OpenID: ${senderId}`,
code,
});
await sendC2CMessage(token, senderId, {
content: replyText,
msg_type: 0,
msg_id: message.id,
});
statusSink?.({ lastOutboundAt: Date.now() });
}
}
return;
}
}
// Resolve agent route
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "qq",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: chatId,
},
});
const fromLabel = isGroup ? `group:${chatId}` : `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: "QQ",
from: fromLabel,
timestamp: new Date(message.timestamp).getTime(),
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `qq:group:${chatId}` : `qq:${senderId}`,
To: `qq:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderId: senderId,
Provider: "qq",
Surface: "qq",
MessageSid: message.id,
OriginatingChannel: "qq",
OriginatingTo: `qq:${chatId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
log?.error(`[${account.accountId}] Failed updating session: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "qq",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverQQReply({
payload,
token,
chatType,
chatId,
msgId: message.id,
tableMode,
});
},
onError: (err, info) => {
log?.error(
`[${account.accountId}] QQ ${info.kind} reply failed: ${String(err)}`,
);
},
},
});
};
const deliverQQReply = async (params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
token: string;
chatType: "c2c" | "group";
chatId: string;
msgId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> => {
const { payload, token, chatType, chatId, msgId } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (!text) return;
const chunkMode = core.channel.text.resolveChunkMode(config, "qq", account.accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
QQ_TEXT_LIMIT,
chunkMode,
);
const sendFn = chatType === "c2c" ? sendC2CMessage : sendGroupMessage;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
await sendFn(token, chatId, {
content: chunk,
msg_type: 0,
msg_id: i === 0 ? msgId : undefined, // Only use msg_id for first chunk
msg_seq: i + 1,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
log?.error(`[${account.accountId}] QQ message send failed: ${String(err)}`);
}
}
};
// Start initial connection
await connect();
return { stop };
}

View File

@@ -0,0 +1,46 @@
/**
* QQ Bot Probe - Verify token validity
*/
import { getAccessToken, getGatewayUrl, type QQFetch } from "./api.js";
export interface ProbeResult {
ok: boolean;
error?: string;
gatewayUrl?: string;
}
/**
* Probe QQ Bot connection by verifying token and getting gateway URL
*/
export async function probeQQ(
appId: string,
appSecret: string,
timeoutMs = 5000,
fetcher?: QQFetch,
): Promise<ProbeResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const fetchWithTimeout: QQFetch = (input, init) =>
(fetcher ?? fetch)(input, { ...init, signal: controller.signal });
try {
const token = await getAccessToken(appId, appSecret, fetchWithTimeout);
const gatewayUrl = await getGatewayUrl(token, fetchWithTimeout);
clearTimeout(timeoutId);
return {
ok: true,
gatewayUrl,
};
} catch (err) {
clearTimeout(timeoutId);
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -0,0 +1,18 @@
/**
* QQ Bot Runtime Context
*/
import type { MoltbotCoreRuntime } from "clawdbot/plugin-sdk";
let qqRuntime: MoltbotCoreRuntime | null = null;
export function setQQRuntime(runtime: MoltbotCoreRuntime): void {
qqRuntime = runtime;
}
export function getQQRuntime(): MoltbotCoreRuntime {
if (!qqRuntime) {
throw new Error("QQ runtime not initialized");
}
return qqRuntime;
}

117
extensions/qq/src/send.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* QQ Bot Message Sending
*/
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
import {
getAccessToken,
sendC2CMessage,
sendGroupMessage,
sendChannelMessage,
sendDmsMessage,
type SendMessageResult,
} from "./api.js";
import { resolveQQAccount } from "./accounts.js";
const QQ_TEXT_LIMIT = 2000;
export interface SendQQMessageOptions {
accountId?: string;
cfg?: MoltbotConfig;
appId?: string;
appSecret?: string;
msgId?: string;
msgSeq?: number;
mediaUrl?: string;
}
export type ChatType = "c2c" | "group" | "channel" | "dms";
/**
* Send a message via QQ Bot API
*/
export async function sendMessageQQ(
chatType: ChatType,
targetId: string,
text: string | undefined,
options: SendQQMessageOptions,
): Promise<SendMessageResult> {
const { accountId, cfg, msgId, msgSeq } = options;
let appId = options.appId;
let appSecret = options.appSecret;
// Resolve from config if not provided directly
if ((!appId || !appSecret) && cfg) {
const account = resolveQQAccount({ cfg, accountId });
appId = account.appId;
appSecret = account.appSecret;
}
if (!appId || !appSecret) {
return { ok: false, error: "QQ appId or appSecret not configured" };
}
try {
const token = await getAccessToken(appId, appSecret);
const request = {
content: text,
msg_type: 0, // text message
msg_id: msgId,
msg_seq: msgSeq,
};
switch (chatType) {
case "c2c":
return sendC2CMessage(token, targetId, request);
case "group":
return sendGroupMessage(token, targetId, request);
case "channel":
return sendChannelMessage(token, targetId, request);
case "dms":
return sendDmsMessage(token, targetId, request);
default:
return { ok: false, error: `Unknown chat type: ${chatType}` };
}
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Chunk text for QQ message limit
*/
export function chunkQQText(text: string, limit = QQ_TEXT_LIMIT): string[] {
if (!text) return [];
if (text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const lastNewline = window.lastIndexOf("\n");
const lastSpace = window.lastIndexOf(" ");
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
if (breakIdx <= 0) breakIdx = limit;
const rawChunk = remaining.slice(0, breakIdx);
const chunk = rawChunk.trimEnd();
if (chunk.length > 0) chunks.push(chunk);
const brokeOnSeparator =
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(
remaining.length,
breakIdx + (brokeOnSeparator ? 1 : 0),
);
remaining = remaining.slice(nextStart).trimStart();
}
if (remaining.length) chunks.push(remaining);
return chunks;
}

193
extensions/qq/src/types.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* QQ Bot API Types
*/
// WebSocket OpCodes
export const OpCode = {
/** Server push event */
Dispatch: 0,
/** Client/Server heartbeat */
Heartbeat: 1,
/** Client identify */
Identify: 2,
/** Client resume */
Resume: 6,
/** Server reconnect hint */
Reconnect: 7,
/** Server invalid session */
InvalidSession: 9,
/** Server hello */
Hello: 10,
/** Server heartbeat ack */
HeartbeatAck: 11,
/** Server HTTP callback ack */
HTTPCallbackAck: 12,
} as const;
export type OpCodeType = (typeof OpCode)[keyof typeof OpCode];
// Gateway Payload
export interface GatewayPayload<T = unknown> {
op: OpCodeType;
d?: T;
s?: number;
t?: string;
id?: string;
}
// Hello Data
export interface HelloData {
heartbeat_interval: number;
}
// Ready Data
export interface ReadyData {
version: number;
session_id: string;
user: {
id: string;
username: string;
bot: boolean;
};
shard: [number, number];
}
// Identify Payload
export interface IdentifyData {
token: string;
intents: number;
shard?: [number, number];
properties?: {
$os?: string;
$browser?: string;
$device?: string;
};
}
// Resume Payload
export interface ResumeData {
token: string;
session_id: string;
seq: number;
}
// Message Author
export interface MessageAuthor {
user_openid?: string;
member_openid?: string;
id?: string;
username?: string;
bot?: boolean;
}
// Message Attachment
export interface MessageAttachment {
content_type: string;
filename?: string;
height?: number;
width?: number;
size?: number;
url: string;
}
// Inbound Message Event
export interface QQMessageEvent {
id: string;
author: MessageAuthor;
content: string;
timestamp: string;
group_openid?: string;
guild_id?: string;
channel_id?: string;
attachments?: MessageAttachment[];
msg_seq?: number;
}
// Access Token Response
export interface AccessTokenResponse {
access_token: string;
expires_in: string;
}
// Gateway URL Response
export interface GatewayResponse {
url: string;
}
// Send Message Request
export interface SendMessageRequest {
content?: string;
msg_type: number; // 0=text, 2=markdown, 3=ark, 4=embed, 7=media
msg_id?: string;
msg_seq?: number;
event_id?: string;
markdown?: MarkdownPayload;
keyboard?: unknown;
ark?: unknown;
media?: MediaPayload;
}
export interface MarkdownPayload {
content: string;
}
export interface MediaPayload {
file_info: string;
}
// Send Message Response
export interface SendMessageResponse {
id: string;
timestamp: string;
}
// API Error Response
export interface QQApiError {
code: number;
message: string;
data?: unknown;
}
// Intents Flags
export const Intents = {
/** 频道相关 */
GUILDS: 1 << 0,
GUILD_MEMBERS: 1 << 1,
GUILD_MESSAGES: 1 << 9,
GUILD_MESSAGE_REACTIONS: 1 << 10,
DIRECT_MESSAGE: 1 << 12,
/** 群聊 @ 机器人 */
GROUP_AT_MESSAGE_CREATE: 1 << 25,
/** 单聊消息 */
C2C_MESSAGE_CREATE: 1 << 25,
/** 公域消息 (频道 @ 机器人) */
PUBLIC_GUILD_MESSAGES: 1 << 30,
} as const;
// Common intents for bot
export const DEFAULT_INTENTS =
Intents.GUILDS |
Intents.GUILD_MEMBERS |
Intents.DIRECT_MESSAGE |
Intents.GROUP_AT_MESSAGE_CREATE |
Intents.C2C_MESSAGE_CREATE;
// Event Types
export const EventType = {
// 单聊
C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE",
// 群聊 @ 机器人
GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE",
// 频道私信
DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE",
// 频道 @ 机器人
AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE",
// 频道全量消息 (私域)
MESSAGE_CREATE: "MESSAGE_CREATE",
// 连接就绪
READY: "READY",
// 连接恢复
RESUMED: "RESUMED",
} as const;
export type EventTypeType = (typeof EventType)[keyof typeof EventType];

View File

@@ -11,7 +11,8 @@
"./cli-entry": "./dist/entry.js"
},
"bin": {
"moltbot": "dist/entry.js"
"moltbot": "dist/entry.js",
"clawdbot": "dist/entry.js"
},
"files": [
"dist/acp/**",
@@ -22,6 +23,7 @@
"dist/cli/**",
"dist/commands/**",
"dist/config/**",
"dist/compat/**",
"dist/control-ui/**",
"dist/cron/**",
"dist/channels/**",

38
pnpm-lock.yaml generated
View File

@@ -314,17 +314,17 @@ importers:
specifier: ^10.5.0
version: 10.5.0
devDependencies:
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
extensions/imessage: {}
extensions/line:
devDependencies:
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
extensions/llm-task: {}
@@ -348,17 +348,17 @@ importers:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
extensions/mattermost: {}
extensions/memory-core:
dependencies:
clawdbot:
specifier: '>=2026.1.24-3'
version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
moltbot:
specifier: '>=2026.1.26'
version: link:../..
extensions/memory-lancedb:
dependencies:
@@ -383,9 +383,9 @@ importers:
'@microsoft/agents-hosting-extensions-teams':
specifier: ^1.2.2
version: 1.2.2
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
express:
specifier: ^5.2.1
version: 5.2.1
@@ -397,9 +397,9 @@ importers:
extensions/nostr:
dependencies:
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
nostr-tools:
specifier: ^2.20.0
version: 2.20.0(typescript@5.9.3)
@@ -439,9 +439,9 @@ importers:
specifier: ^4.3.5
version: 4.3.6
devDependencies:
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
extensions/voice-call:
dependencies:
@@ -459,9 +459,9 @@ importers:
extensions/zalo:
dependencies:
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
undici:
specifier: 7.19.0
version: 7.19.0
@@ -471,9 +471,9 @@ importers:
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
clawdbot:
moltbot:
specifier: workspace:*
version: link:../../packages/clawdbot
version: link:../..
packages/clawdbot:
dependencies:

View File

@@ -10,12 +10,21 @@ trap on_error ERR
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit"
A2UI_APP_DIR="$ROOT_DIR/apps/shared/ClawdbotKit/Tools/CanvasA2UI"
# Docker builds exclude vendor/apps via .dockerignore.
# In that environment we must keep the prebuilt bundle.
if [[ ! -d "$A2UI_RENDERER_DIR" || ! -d "$A2UI_APP_DIR" ]]; then
echo "A2UI sources missing; keeping prebuilt bundle."
exit 0
fi
INPUT_PATHS=(
"$ROOT_DIR/package.json"
"$ROOT_DIR/pnpm-lock.yaml"
"$ROOT_DIR/vendor/a2ui/renderers/lit"
"$ROOT_DIR/apps/shared/ClawdbotKit/Tools/CanvasA2UI"
"$A2UI_RENDERER_DIR"
"$A2UI_APP_DIR"
)
collect_files() {
@@ -46,7 +55,7 @@ if [[ -f "$HASH_FILE" ]]; then
fi
fi
pnpm -s exec tsc -p vendor/a2ui/renderers/lit/tsconfig.json
rolldown -c apps/shared/ClawdbotKit/Tools/CanvasA2UI/rolldown.config.mjs
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
echo "$current_hash" > "$HASH_FILE"

View File

@@ -19,12 +19,17 @@ export async function copyA2uiAssets({
srcDir: string;
outDir: string;
}) {
const skipMissing = process.env.CLAWDBOT_A2UI_SKIP_MISSING === "1";
try {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
} catch (err) {
const message =
'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
if (skipMissing) {
console.warn(`${message} Skipping copy (CLAWDBOT_A2UI_SKIP_MISSING=1).`);
return;
}
throw new Error(message, { cause: err });
}
await fs.mkdir(path.dirname(outDir), { recursive: true });

View File

@@ -2,6 +2,16 @@
set -euo pipefail
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://molt.bot/install.sh}"
DEFAULT_PACKAGE="moltbot"
if [[ -z "${CLAWDBOT_INSTALL_PACKAGE:-}" && "$INSTALL_URL" == *"clawd.bot"* ]]; then
DEFAULT_PACKAGE="clawdbot"
fi
PACKAGE_NAME="${CLAWDBOT_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
if [[ "$PACKAGE_NAME" == "moltbot" ]]; then
ALT_PACKAGE_NAME="clawdbot"
else
ALT_PACKAGE_NAME="moltbot"
fi
echo "==> Pre-flight: ensure git absent"
if command -v git >/dev/null; then
@@ -18,26 +28,39 @@ export PATH="$HOME/.npm-global/bin:$PATH"
echo "==> Verify git installed"
command -v git >/dev/null
echo "==> Verify moltbot installed"
EXPECTED_VERSION="${CLAWDBOT_INSTALL_EXPECT_VERSION:-}"
if [[ -n "$EXPECTED_VERSION" ]]; then
LATEST_VERSION="$EXPECTED_VERSION"
else
LATEST_VERSION="$(npm view moltbot version)"
LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)"
fi
CMD_PATH="$(command -v moltbot || true)"
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/moltbot" ]]; then
CMD_PATH="$HOME/.npm-global/bin/moltbot"
CLI_NAME="$PACKAGE_NAME"
CMD_PATH="$(command -v "$CLI_NAME" || true)"
if [[ -z "$CMD_PATH" ]]; then
CLI_NAME="$ALT_PACKAGE_NAME"
CMD_PATH="$(command -v "$CLI_NAME" || true)"
fi
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/$PACKAGE_NAME" ]]; then
CLI_NAME="$PACKAGE_NAME"
CMD_PATH="$HOME/.npm-global/bin/$PACKAGE_NAME"
fi
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/$ALT_PACKAGE_NAME" ]]; then
CLI_NAME="$ALT_PACKAGE_NAME"
CMD_PATH="$HOME/.npm-global/bin/$ALT_PACKAGE_NAME"
fi
if [[ -z "$CMD_PATH" ]]; then
echo "moltbot not on PATH" >&2
echo "Neither $PACKAGE_NAME nor $ALT_PACKAGE_NAME is on PATH" >&2
exit 1
fi
if [[ -z "$EXPECTED_VERSION" && "$CLI_NAME" != "$PACKAGE_NAME" ]]; then
LATEST_VERSION="$(npm view "$CLI_NAME" version)"
fi
echo "==> Verify CLI installed: $CLI_NAME"
INSTALLED_VERSION="$("$CMD_PATH" --version 2>/dev/null | head -n 1 | tr -d '\r')"
echo "installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
echo "cli=$CLI_NAME installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
echo "ERROR: expected moltbot@$LATEST_VERSION, got @$INSTALLED_VERSION" >&2
echo "ERROR: expected ${CLI_NAME}@${LATEST_VERSION}, got ${CLI_NAME}@${INSTALLED_VERSION}" >&2
exit 1
fi

View File

@@ -4,15 +4,26 @@ set -euo pipefail
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://molt.bot/install.sh}"
SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
DEFAULT_PACKAGE="moltbot"
if [[ -z "${CLAWDBOT_INSTALL_PACKAGE:-}" && "$INSTALL_URL" == *"clawd.bot"* ]]; then
DEFAULT_PACKAGE="clawdbot"
fi
PACKAGE_NAME="${CLAWDBOT_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
if [[ "$PACKAGE_NAME" == "moltbot" ]]; then
ALT_PACKAGE_NAME="clawdbot"
else
ALT_PACKAGE_NAME="moltbot"
fi
echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)"
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
LATEST_VERSION="$(npm view moltbot version)"
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
VERSIONS_JSON="$(npm view moltbot versions --json)"
versions_line="$(node - <<'NODE'
VERSIONS_JSON="$(npm view "$PACKAGE_NAME" versions --json)"
PREVIOUS_VERSION="$(VERSIONS_JSON="$VERSIONS_JSON" LATEST_VERSION="$LATEST_VERSION" node - <<'NODE'
const raw = process.env.VERSIONS_JSON || "[]";
const latest = process.env.LATEST_VERSION || "";
let versions;
try {
versions = JSON.parse(raw);
@@ -25,41 +36,52 @@ if (!Array.isArray(versions)) {
if (versions.length === 0) {
process.exit(1);
}
const latest = versions[versions.length - 1];
const previous = versions.length >= 2 ? versions[versions.length - 2] : latest;
process.stdout.write(`${latest} ${previous}`);
const latestIndex = latest ? versions.lastIndexOf(latest) : -1;
if (latestIndex > 0) {
process.stdout.write(String(versions[latestIndex - 1]));
process.exit(0);
}
process.stdout.write(String(latest || versions[versions.length - 1]));
NODE
)"
LATEST_VERSION="${versions_line%% *}"
PREVIOUS_VERSION="${versions_line#* }"
fi
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
fi
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
echo "package=$PACKAGE_NAME latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
if [[ "$SKIP_PREVIOUS" == "1" ]]; then
echo "==> Skip preinstall previous (CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path)"
npm install -g "moltbot@${PREVIOUS_VERSION}"
npm install -g "${PACKAGE_NAME}@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash
echo "==> Verify installed version"
INSTALLED_VERSION="$(moltbot --version 2>/dev/null | head -n 1 | tr -d '\r')"
echo "installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
CLI_NAME="$PACKAGE_NAME"
if ! command -v "$CLI_NAME" >/dev/null 2>&1; then
if command -v "$ALT_PACKAGE_NAME" >/dev/null 2>&1; then
CLI_NAME="$ALT_PACKAGE_NAME"
LATEST_VERSION="$(npm view "$CLI_NAME" version)"
echo "==> Detected alternate CLI: $CLI_NAME"
else
echo "ERROR: neither $PACKAGE_NAME nor $ALT_PACKAGE_NAME is on PATH" >&2
exit 1
fi
fi
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
fi
INSTALLED_VERSION="$("$CLI_NAME" --version 2>/dev/null | head -n 1 | tr -d '\r')"
echo "cli=$CLI_NAME installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
echo "ERROR: expected moltbot@$LATEST_VERSION, got moltbot@$INSTALLED_VERSION" >&2
echo "ERROR: expected ${CLI_NAME}@${LATEST_VERSION}, got ${CLI_NAME}@${INSTALLED_VERSION}" >&2
exit 1
fi
echo "==> Sanity: CLI runs"
moltbot --help >/dev/null
"$CLI_NAME" --help >/dev/null
echo "OK"

View File

@@ -2,7 +2,7 @@
name: 1password
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
homepage: https://developer.1password.com/docs/cli/get-started/
metadata: {"clawdbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}}
metadata: {"moltbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}}
---
# 1Password CLI
@@ -31,9 +31,9 @@ The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, a
Example (see `tmux` skill for socket conventions, do not reuse old session names):
```bash
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/moltbot-tmux-sockets}"
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/clawdbot-op.sock"
SOCKET="$SOCKET_DIR/moltbot-op.sock"
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell

View File

@@ -1,8 +1,8 @@
---
name: apple-notes
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Clawdbot to add a note, list notes, search notes, or manage note folders.
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Moltbot to add a note, list notes, search notes, or manage note folders.
homepage: https://github.com/antoniorodr/memo
metadata: {"clawdbot":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
metadata: {"moltbot":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
---
# Apple Notes CLI

View File

@@ -2,7 +2,7 @@
name: apple-reminders
description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
homepage: https://github.com/steipete/remindctl
metadata: {"clawdbot":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
metadata: {"moltbot":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
---
# Apple Reminders CLI (remindctl)

View File

@@ -2,7 +2,7 @@
name: bear-notes
description: Create, search, and manage Bear notes via grizzly CLI.
homepage: https://bear.app
metadata: {"clawdbot":{"emoji":"🐻","os":["darwin"],"requires":{"bins":["grizzly"]},"install":[{"id":"go","kind":"go","module":"github.com/tylerwince/grizzly/cmd/grizzly@latest","bins":["grizzly"],"label":"Install grizzly (go)"}]}}
metadata: {"moltbot":{"emoji":"🐻","os":["darwin"],"requires":{"bins":["grizzly"]},"install":[{"id":"go","kind":"go","module":"github.com/tylerwince/grizzly/cmd/grizzly@latest","bins":["grizzly"],"label":"Install grizzly (go)"}]}}
---
# Bear Notes

View File

@@ -2,7 +2,7 @@
name: bird
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
homepage: https://bird.fast
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
metadata: {"moltbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
---
# bird 🐦

View File

@@ -2,7 +2,7 @@
name: blogwatcher
description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI.
homepage: https://github.com/Hyaxia/blogwatcher
metadata: {"clawdbot":{"emoji":"📰","requires":{"bins":["blogwatcher"]},"install":[{"id":"go","kind":"go","module":"github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest","bins":["blogwatcher"],"label":"Install blogwatcher (go)"}]}}
metadata: {"moltbot":{"emoji":"📰","requires":{"bins":["blogwatcher"]},"install":[{"id":"go","kind":"go","module":"github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest","bins":["blogwatcher"],"label":"Install blogwatcher (go)"}]}}
---
# blogwatcher

View File

@@ -2,7 +2,7 @@
name: blucli
description: BluOS CLI (blu) for discovery, playback, grouping, and volume.
homepage: https://blucli.sh
metadata: {"clawdbot":{"emoji":"🫐","requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}}
metadata: {"moltbot":{"emoji":"🫐","requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}}
---
# blucli (blu)

View File

@@ -1,6 +1,6 @@
---
name: bluebubbles
description: Build or update the BlueBubbles external channel plugin for Clawdbot (extension package, REST send/probe, webhook inbound).
description: Build or update the BlueBubbles external channel plugin for Moltbot (extension package, REST send/probe, webhook inbound).
---
# BlueBubbles plugin

View File

@@ -2,7 +2,7 @@
name: camsnap
description: Capture frames or clips from RTSP/ONVIF cameras.
homepage: https://camsnap.ai
metadata: {"clawdbot":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
metadata: {"moltbot":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
---
# camsnap

View File

@@ -1,6 +1,6 @@
# Canvas Skill
Display HTML content on connected Clawdbot nodes (Mac app, iOS, Android).
Display HTML content on connected Moltbot nodes (Mac app, iOS, Android).
## Overview
@@ -38,7 +38,7 @@ The canvas host server binds based on `gateway.bind` setting:
**Key insight:** The `canvasHostHostForBridge` is derived from `bridgeHost`. When bound to Tailscale, nodes receive URLs like:
```
http://<tailscale-hostname>:18793/__clawdbot__/canvas/<file>.html
http://<tailscale-hostname>:18793/__moltbot__/canvas/<file>.html
```
This is why localhost URLs don't work - the node receives the Tailscale hostname from the bridge!
@@ -55,7 +55,7 @@ This is why localhost URLs don't work - the node receives the Tailscale hostname
## Configuration
In `~/.clawdbot/clawdbot.json`:
In `~/.clawdbot/moltbot.json`:
```json
{
@@ -102,12 +102,12 @@ HTML
Check how your gateway is bound:
```bash
cat ~/.clawdbot/clawdbot.json | jq '.gateway.bind'
cat ~/.clawdbot/moltbot.json | jq '.gateway.bind'
```
Then construct the URL:
- **loopback**: `http://127.0.0.1:18793/__clawdbot__/canvas/<file>.html`
- **lan/tailnet/auto**: `http://<hostname>:18793/__clawdbot__/canvas/<file>.html`
- **loopback**: `http://127.0.0.1:18793/__moltbot__/canvas/<file>.html`
- **lan/tailnet/auto**: `http://<hostname>:18793/__moltbot__/canvas/<file>.html`
Find your Tailscale hostname:
```bash
@@ -117,7 +117,7 @@ tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//'
### 3. Find connected nodes
```bash
clawdbot nodes list
moltbot nodes list
```
Look for Mac/iOS/Android nodes with canvas capability.
@@ -130,7 +130,7 @@ canvas action:present node:<node-id> target:<full-url>
**Example:**
```
canvas action:present node:mac-63599bc4-b54d-4392-9048-b97abd58343a target:http://peters-mac-studio-1.sheep-coho.ts.net:18793/__clawdbot__/canvas/snake.html
canvas action:present node:mac-63599bc4-b54d-4392-9048-b97abd58343a target:http://peters-mac-studio-1.sheep-coho.ts.net:18793/__moltbot__/canvas/snake.html
```
### 5. Navigate, snapshot, or hide
@@ -148,9 +148,9 @@ canvas action:hide node:<node-id>
**Cause:** URL mismatch between server bind and node expectation.
**Debug steps:**
1. Check server bind: `cat ~/.clawdbot/clawdbot.json | jq '.gateway.bind'`
1. Check server bind: `cat ~/.clawdbot/moltbot.json | jq '.gateway.bind'`
2. Check what port canvas is on: `lsof -i :18793`
3. Test URL directly: `curl http://<hostname>:18793/__clawdbot__/canvas/<file>.html`
3. Test URL directly: `curl http://<hostname>:18793/__moltbot__/canvas/<file>.html`
**Solution:** Use the full hostname matching your bind mode, not localhost.
@@ -160,7 +160,7 @@ Always specify `node:<node-id>` parameter.
### "node not connected" error
Node is offline. Use `clawdbot nodes list` to find online nodes.
Node is offline. Use `moltbot nodes list` to find online nodes.
### Content not updating
@@ -171,14 +171,14 @@ If live reload isn't working:
## URL Path Structure
The canvas host serves from `/__clawdbot__/canvas/` prefix:
The canvas host serves from `/__moltbot__/canvas/` prefix:
```
http://<host>:18793/__clawdbot__/canvas/index.html → ~/clawd/canvas/index.html
http://<host>:18793/__clawdbot__/canvas/games/snake.html → ~/clawd/canvas/games/snake.html
http://<host>:18793/__moltbot__/canvas/index.html → ~/clawd/canvas/index.html
http://<host>:18793/__moltbot__/canvas/games/snake.html → ~/clawd/canvas/games/snake.html
```
The `/__clawdbot__/canvas/` prefix is defined by `CANVAS_HOST_PATH` constant.
The `/__moltbot__/canvas/` prefix is defined by `CANVAS_HOST_PATH` constant.
## Tips

View File

@@ -1,7 +1,7 @@
---
name: clawdhub
description: Use the ClawdHub CLI to search, install, update, and publish agent skills from clawdhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawdhub CLI.
metadata: {"clawdbot":{"requires":{"bins":["clawdhub"]},"install":[{"id":"node","kind":"node","package":"clawdhub","bins":["clawdhub"],"label":"Install ClawdHub CLI (npm)"}]}}
metadata: {"moltbot":{"requires":{"bins":["clawdhub"]},"install":[{"id":"node","kind":"node","package":"clawdhub","bins":["clawdhub"],"label":"Install ClawdHub CLI (npm)"}]}}
---
# ClawdHub CLI
@@ -49,5 +49,5 @@ clawdhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --
Notes
- Default registry: https://clawdhub.com (override with CLAWDHUB_REGISTRY or --registry)
- Default workdir: cwd (falls back to Clawdbot workspace); install dir: ./skills (override with --workdir / --dir / CLAWDHUB_WORKDIR)
- Default workdir: cwd (falls back to Moltbot workspace); install dir: ./skills (override with --workdir / --dir / CLAWDHUB_WORKDIR)
- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set

View File

@@ -1,7 +1,7 @@
---
name: coding-agent
description: Run Codex CLI, Claude Code, OpenCode, or Pi Coding Agent via background process for programmatic control.
metadata: {"clawdbot":{"emoji":"🧩","requires":{"anyBins":["claude","codex","opencode","pi"]}}}
metadata: {"moltbot":{"emoji":"🧩","requires":{"anyBins":["claude","codex","opencode","pi"]}}}
---
# Coding Agent (bash-first)
@@ -116,7 +116,7 @@ bash pty:true workdir:~/project background:true command:"codex --yolo 'Refactor
### Reviewing PRs
**⚠️ CRITICAL: Never review PRs in Clawdbot's own project folder!**
**⚠️ CRITICAL: Never review PRs in Moltbot's own project folder!**
Clone to temp folder or use git worktree.
```bash
@@ -227,7 +227,7 @@ git worktree remove /tmp/issue-99
6. **vanilla for reviewing** - no special flags needed
7. **Parallel is OK** - run many Codex processes at once for batch work
8. **NEVER start Codex in ~/clawd/** - it'll read your soul docs and get weird ideas about the org chart!
9. **NEVER checkout branches in ~/Projects/clawdbot/** - that's the LIVE Clawdbot instance!
9. **NEVER checkout branches in ~/Projects/moltbot/** - that's the LIVE Moltbot instance!
---
@@ -249,20 +249,20 @@ This prevents the user from seeing only "Agent failed before reply" and having n
## Auto-Notify on Completion
For long-running background tasks, append a wake trigger to your prompt so Clawdbot gets notified immediately when the agent finishes (instead of waiting for the next heartbeat):
For long-running background tasks, append a wake trigger to your prompt so Moltbot gets notified immediately when the agent finishes (instead of waiting for the next heartbeat):
```
... your task here.
When completely finished, run this command to notify me:
clawdbot gateway wake --text "Done: [brief summary of what was built]" --mode now
moltbot gateway wake --text "Done: [brief summary of what was built]" --mode now
```
**Example:**
```bash
bash pty:true workdir:~/project background:true command:"codex --yolo exec 'Build a REST API for todos.
When completely finished, run: clawdbot gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'"
When completely finished, run: moltbot gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'"
```
This triggers an immediate wake event — Skippy gets pinged in seconds, not 10 minutes.

View File

@@ -1,14 +1,14 @@
---
name: discord
description: Use when you need to control Discord from Clawdbot via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
metadata: {"clawdbot":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
description: Use when you need to control Discord from Moltbot via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
metadata: {"moltbot":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
---
# Discord Actions
## Overview
Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for Clawdbot.
Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for Moltbot.
## Inputs to collect
@@ -84,8 +84,8 @@ Message context lines include `discord message id` and `channel` fields you can
{
"action": "stickerUpload",
"guildId": "999",
"name": "clawdbot_wave",
"description": "Clawdbot waving hello",
"name": "moltbot_wave",
"description": "Moltbot waving hello",
"tags": "👋",
"mediaUrl": "file:///tmp/wave.png"
}
@@ -172,7 +172,7 @@ Use `discord.actions.*` to disable action groups:
{
"action": "sendMessage",
"to": "channel:123",
"content": "Hello from Clawdbot"
"content": "Hello from Moltbot"
}
```

View File

@@ -2,7 +2,7 @@
name: eightctl
description: Control Eight Sleep pods (status, temperature, alarms, schedules).
homepage: https://eightctl.sh
metadata: {"clawdbot":{"emoji":"🎛️","requires":{"bins":["eightctl"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/eightctl/cmd/eightctl@latest","bins":["eightctl"],"label":"Install eightctl (go)"}]}}
metadata: {"moltbot":{"emoji":"🎛️","requires":{"bins":["eightctl"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/eightctl/cmd/eightctl@latest","bins":["eightctl"],"label":"Install eightctl (go)"}]}}
---
# eightctl

View File

@@ -2,7 +2,7 @@
name: food-order
description: Reorder Foodora orders + track ETA/status with ordercli. Never confirm without explicit user approval. Triggers: order food, reorder, track ETA.
homepage: https://ordercli.sh
metadata: {"clawdbot":{"emoji":"🥡","requires":{"bins":["ordercli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
metadata: {"moltbot":{"emoji":"🥡","requires":{"bins":["ordercli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
---
# Food order (Foodora via ordercli)

View File

@@ -2,7 +2,7 @@
name: gemini
description: Gemini CLI for one-shot Q&A, summaries, and generation.
homepage: https://ai.google.dev/
metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
metadata: {"moltbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
---
# Gemini CLI

View File

@@ -2,7 +2,7 @@
name: gifgrep
description: Search GIF providers with CLI/TUI, download results, and extract stills/sheets.
homepage: https://gifgrep.com
metadata: {"clawdbot":{"emoji":"🧲","requires":{"bins":["gifgrep"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gifgrep","bins":["gifgrep"],"label":"Install gifgrep (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/gifgrep/cmd/gifgrep@latest","bins":["gifgrep"],"label":"Install gifgrep (go)"}]}}
metadata: {"moltbot":{"emoji":"🧲","requires":{"bins":["gifgrep"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gifgrep","bins":["gifgrep"],"label":"Install gifgrep (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/gifgrep/cmd/gifgrep@latest","bins":["gifgrep"],"label":"Install gifgrep (go)"}]}}
---
# gifgrep

View File

@@ -1,7 +1,7 @@
---
name: github
description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
metadata: {"clawdbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
metadata: {"moltbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
---
# GitHub Skill

View File

@@ -2,7 +2,7 @@
name: gog
description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
homepage: https://gogcli.sh
metadata: {"clawdbot":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
metadata: {"moltbot":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
---
# gog

View File

@@ -2,7 +2,7 @@
name: goplaces
description: Query Google Places API (New) via the goplaces CLI for text search, place details, resolve, and reviews. Use for human-friendly place lookup or JSON output for scripts.
homepage: https://github.com/steipete/goplaces
metadata: {"clawdbot":{"emoji":"📍","requires":{"bins":["goplaces"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/goplaces","bins":["goplaces"],"label":"Install goplaces (brew)"}]}}
metadata: {"moltbot":{"emoji":"📍","requires":{"bins":["goplaces"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/goplaces","bins":["goplaces"],"label":"Install goplaces (brew)"}]}}
---
# goplaces

View File

@@ -2,7 +2,7 @@
name: himalaya
description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)."
homepage: https://github.com/pimalaya/himalaya
metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"}]}}
metadata: {"moltbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"}]}}
---
# Himalaya Email CLI

View File

@@ -2,7 +2,7 @@
name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
homepage: https://imsg.to
metadata: {"clawdbot":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
metadata: {"moltbot":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
---
# imsg

View File

@@ -2,7 +2,7 @@
name: local-places
description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
homepage: https://github.com/Hyaxia/local_places
metadata: {"clawdbot":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
metadata: {"moltbot":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
---
# 📍 Local Places

View File

@@ -2,7 +2,7 @@
name: mcporter
description: Use the mcporter CLI to list, configure, auth, and call MCP servers/tools directly (HTTP or stdio), including ad-hoc servers, config edits, and CLI/type generation.
homepage: http://mcporter.dev
metadata: {"clawdbot":{"emoji":"📦","requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}}
metadata: {"moltbot":{"emoji":"📦","requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}}
---
# mcporter

View File

@@ -1,7 +1,7 @@
---
name: model-usage
description: Use CodexBar CLI local cost usage to summarize per-model usage for Codex or Claude, including the current (most recent) model or a full model breakdown. Trigger when asked for model-level usage/cost data from codexbar, or when you need a scriptable per-model summary from codexbar cost JSON.
metadata: {"clawdbot":{"emoji":"📊","os":["darwin"],"requires":{"bins":["codexbar"]},"install":[{"id":"brew-cask","kind":"brew","cask":"steipete/tap/codexbar","bins":["codexbar"],"label":"Install CodexBar (brew cask)"}]}}
metadata: {"moltbot":{"emoji":"📊","os":["darwin"],"requires":{"bins":["codexbar"]},"install":[{"id":"brew-cask","kind":"brew","cask":"steipete/tap/codexbar","bins":["codexbar"],"label":"Install CodexBar (brew cask)"}]}}
---
# Model usage

View File

@@ -2,7 +2,7 @@
name: nano-banana-pro
description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro).
homepage: https://ai.google.dev/
metadata: {"clawdbot":{"emoji":"🍌","requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY","install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
metadata: {"moltbot":{"emoji":"🍌","requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY","install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
---
# Nano Banana Pro (Gemini 3 Pro Image)
@@ -26,10 +26,10 @@ uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scen
API key
- `GEMINI_API_KEY` env var
- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.clawdbot/clawdbot.json`
- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.clawdbot/moltbot.json`
Notes
- Resolutions: `1K` (default), `2K`, `4K`.
- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`.
- The script prints a `MEDIA:` line for Clawdbot to auto-attach on supported chat providers.
- The script prints a `MEDIA:` line for Moltbot to auto-attach on supported chat providers.
- Do not read the image back; report the saved path only.

View File

@@ -169,7 +169,7 @@ def main():
if image_saved:
full_path = output_path.resolve()
print(f"\nImage saved: {full_path}")
# Clawdbot parses MEDIA tokens and will attach the file on supported providers.
# Moltbot parses MEDIA tokens and will attach the file on supported providers.
print(f"MEDIA: {full_path}")
else:
print("Error: No image was generated in the response.", file=sys.stderr)

View File

@@ -2,7 +2,7 @@
name: nano-pdf
description: Edit PDFs with natural-language instructions using the nano-pdf CLI.
homepage: https://pypi.org/project/nano-pdf/
metadata: {"clawdbot":{"emoji":"📄","requires":{"bins":["nano-pdf"]},"install":[{"id":"uv","kind":"uv","package":"nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (uv)"}]}}
metadata: {"moltbot":{"emoji":"📄","requires":{"bins":["nano-pdf"]},"install":[{"id":"uv","kind":"uv","package":"nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (uv)"}]}}
---
# nano-pdf

View File

@@ -2,7 +2,7 @@
name: notion
description: Notion API for creating and managing pages, databases, and blocks.
homepage: https://developers.notion.com
metadata: {"clawdbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}}
metadata: {"moltbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}}
---
# notion

View File

@@ -2,7 +2,7 @@
name: obsidian
description: Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli.
homepage: https://help.obsidian.md
metadata: {"clawdbot":{"emoji":"💎","requires":{"bins":["obsidian-cli"]},"install":[{"id":"brew","kind":"brew","formula":"yakitrak/yakitrak/obsidian-cli","bins":["obsidian-cli"],"label":"Install obsidian-cli (brew)"}]}}
metadata: {"moltbot":{"emoji":"💎","requires":{"bins":["obsidian-cli"]},"install":[{"id":"brew","kind":"brew","formula":"yakitrak/yakitrak/obsidian-cli","bins":["obsidian-cli"],"label":"Install obsidian-cli (brew)"}]}}
---
# Obsidian

View File

@@ -2,7 +2,7 @@
name: openai-image-gen
description: Batch-generate images via OpenAI Images API. Random prompt sampler + `index.html` gallery.
homepage: https://platform.openai.com/docs/api-reference/images
metadata: {"clawdbot":{"emoji":"🖼️","requires":{"bins":["python3"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY","install":[{"id":"python-brew","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python (brew)"}]}}
metadata: {"moltbot":{"emoji":"🖼️","requires":{"bins":["python3"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY","install":[{"id":"python-brew","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python (brew)"}]}}
---
# OpenAI Image Gen

View File

@@ -2,7 +2,7 @@
name: openai-whisper-api
description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper).
homepage: https://platform.openai.com/docs/guides/speech-to-text
metadata: {"clawdbot":{"emoji":"☁️","requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}}
metadata: {"moltbot":{"emoji":"☁️","requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}}
---
# OpenAI Whisper API (curl)
@@ -30,7 +30,7 @@ Defaults:
## API key
Set `OPENAI_API_KEY`, or configure it in `~/.clawdbot/clawdbot.json`:
Set `OPENAI_API_KEY`, or configure it in `~/.clawdbot/moltbot.json`:
```json5
{

View File

@@ -2,7 +2,7 @@
name: openai-whisper
description: Local speech-to-text with the Whisper CLI (no API key).
homepage: https://openai.com/research/whisper
metadata: {"clawdbot":{"emoji":"🎙️","requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}}
metadata: {"moltbot":{"emoji":"🎙️","requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}}
---
# Whisper (CLI)

View File

@@ -2,7 +2,7 @@
name: openhue
description: Control Philips Hue lights/scenes via the OpenHue CLI.
homepage: https://www.openhue.io/cli
metadata: {"clawdbot":{"emoji":"💡","requires":{"bins":["openhue"]},"install":[{"id":"brew","kind":"brew","formula":"openhue/cli/openhue-cli","bins":["openhue"],"label":"Install OpenHue CLI (brew)"}]}}
metadata: {"moltbot":{"emoji":"💡","requires":{"bins":["openhue"]},"install":[{"id":"brew","kind":"brew","formula":"openhue/cli/openhue-cli","bins":["openhue"],"label":"Install OpenHue CLI (brew)"}]}}
---
# OpenHue CLI

View File

@@ -2,7 +2,7 @@
name: oracle
description: Best practices for using the oracle CLI (prompt + file bundling, engines, sessions, and file attachment patterns).
homepage: https://askoracle.dev
metadata: {"clawdbot":{"emoji":"🧿","requires":{"bins":["oracle"]},"install":[{"id":"node","kind":"node","package":"@steipete/oracle","bins":["oracle"],"label":"Install oracle (node)"}]}}
metadata: {"moltbot":{"emoji":"🧿","requires":{"bins":["oracle"]},"install":[{"id":"node","kind":"node","package":"@steipete/oracle","bins":["oracle"],"label":"Install oracle (node)"}]}}
---
# oracle — best use

View File

@@ -2,7 +2,7 @@
name: ordercli
description: Foodora-only CLI for checking past orders and active order status (Deliveroo WIP).
homepage: https://ordercli.sh
metadata: {"clawdbot":{"emoji":"🛵","requires":{"bins":["ordercli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/ordercli","bins":["ordercli"],"label":"Install ordercli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
metadata: {"moltbot":{"emoji":"🛵","requires":{"bins":["ordercli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/ordercli","bins":["ordercli"],"label":"Install ordercli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
---
# ordercli

View File

@@ -2,7 +2,7 @@
name: peekaboo
description: Capture and automate macOS UI with the Peekaboo CLI.
homepage: https://peekaboo.boo
metadata: {"clawdbot":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
metadata: {"moltbot":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
---
# Peekaboo

View File

@@ -2,7 +2,7 @@
name: sag
description: ElevenLabs text-to-speech with mac-style say UX.
homepage: https://sag.sh
metadata: {"clawdbot":{"emoji":"🗣️","requires":{"bins":["sag"],"env":["ELEVENLABS_API_KEY"]},"primaryEnv":"ELEVENLABS_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/sag","bins":["sag"],"label":"Install sag (brew)"}]}}
metadata: {"moltbot":{"emoji":"🗣️","requires":{"bins":["sag"],"env":["ELEVENLABS_API_KEY"]},"primaryEnv":"ELEVENLABS_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/sag","bins":["sag"],"label":"Install sag (brew)"}]}}
---
# sag

View File

@@ -1,7 +1,7 @@
---
name: session-logs
description: Search and analyze your own session logs (older/parent conversations) using jq.
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq","rg"]}}}
metadata: {"moltbot":{"emoji":"📜","requires":{"bins":["jq","rg"]}}}
---
# session-logs

View File

@@ -1,7 +1,7 @@
---
name: sherpa-onnx-tts
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
metadata: {"moltbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
---
# sherpa-onnx-tts
@@ -13,7 +13,7 @@ Local TTS using the sherpa-onnx offline CLI.
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
Update `~/.clawdbot/clawdbot.json`:
Update `~/.clawdbot/moltbot.json`:
```json5
{

View File

@@ -1,14 +1,14 @@
---
name: slack
description: Use when you need to control Slack from Clawdbot via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
metadata: {"clawdbot":{"emoji":"💬","requires":{"config":["channels.slack"]}}}
description: Use when you need to control Slack from Moltbot via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
metadata: {"moltbot":{"emoji":"💬","requires":{"config":["channels.slack"]}}}
---
# Slack Actions
## Overview
Use `slack` to react, manage pins, send/edit/delete messages, and fetch member info. The tool uses the bot token configured for Clawdbot.
Use `slack` to react, manage pins, send/edit/delete messages, and fetch member info. The tool uses the bot token configured for Moltbot.
## Inputs to collect
@@ -57,7 +57,7 @@ Message context lines include `slack message id` and `channel` fields you can re
{
"action": "sendMessage",
"to": "channel:C123",
"content": "Hello from Clawdbot"
"content": "Hello from Moltbot"
}
```

View File

@@ -2,7 +2,7 @@
name: songsee
description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI.
homepage: https://github.com/steipete/songsee
metadata: {"clawdbot":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}}
metadata: {"moltbot":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}}
---
# songsee

View File

@@ -2,7 +2,7 @@
name: sonoscli
description: Control Sonos speakers (discover/status/play/volume/group).
homepage: https://sonoscli.sh
metadata: {"clawdbot":{"emoji":"🔊","requires":{"bins":["sonos"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/sonoscli/cmd/sonos@latest","bins":["sonos"],"label":"Install sonoscli (go)"}]}}
metadata: {"moltbot":{"emoji":"🔊","requires":{"bins":["sonos"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/sonoscli/cmd/sonos@latest","bins":["sonos"],"label":"Install sonoscli (go)"}]}}
---
# Sonos CLI

View File

@@ -2,7 +2,7 @@
name: spotify-player
description: Terminal Spotify playback/search via spogo (preferred) or spotify_player.
homepage: https://www.spotify.com
metadata: {"clawdbot":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
metadata: {"moltbot":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
---
# spogo / spotify_player

View File

@@ -2,7 +2,7 @@
name: summarize
description: Summarize or extract text/transcripts from URLs, podcasts, and local files (great fallback for “transcribe this YouTube/video”).
homepage: https://summarize.sh
metadata: {"clawdbot":{"emoji":"🧾","requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"}]}}
metadata: {"moltbot":{"emoji":"🧾","requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"}]}}
---
# Summarize

View File

@@ -1,8 +1,8 @@
---
name: things-mac
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdbot to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Moltbot to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
homepage: https://github.com/ossianhempel/things3-cli
metadata: {"clawdbot":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
metadata: {"moltbot":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
---
# Things 3 CLI
@@ -11,7 +11,7 @@ Use `things` to read your local Things database (inbox/today/search/projects/are
Setup
- Install (recommended, Apple Silicon): `GOBIN=/opt/homebrew/bin go install github.com/ossianhempel/things3-cli/cmd/things@latest`
- If DB reads fail: grant **Full Disk Access** to the calling app (Terminal for manual runs; `Clawdbot.app` for gateway runs).
- If DB reads fail: grant **Full Disk Access** to the calling app (Terminal for manual runs; `Moltbot.app` for gateway runs).
- Optional: set `THINGSDB` (or pass `--db`) to point at your `ThingsData-*` folder.
- Optional: set `THINGS_AUTH_TOKEN` to avoid passing `--auth-token` for update ops.

View File

@@ -1,20 +1,20 @@
---
name: tmux
description: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output.
metadata: {"clawdbot":{"emoji":"🧵","os":["darwin","linux"],"requires":{"bins":["tmux"]}}}
metadata: {"moltbot":{"emoji":"🧵","os":["darwin","linux"],"requires":{"bins":["tmux"]}}}
---
# tmux Skill (Clawdbot)
# tmux Skill (Moltbot)
Use tmux only when you need an interactive TTY. Prefer exec background mode for long-running, non-interactive tasks.
## Quickstart (isolated socket, exec tool)
```bash
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/moltbot-tmux-sockets}"
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/clawdbot.sock"
SESSION=clawdbot-python
SOCKET="$SOCKET_DIR/moltbot.sock"
SESSION=moltbot-python
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- 'PYTHON_BASIC_REPL=1 python3 -q' Enter
@@ -31,8 +31,8 @@ To monitor:
## Socket convention
- Use `CLAWDBOT_TMUX_SOCKET_DIR` (default `${TMPDIR:-/tmp}/clawdbot-tmux-sockets`).
- Default socket path: `"$CLAWDBOT_TMUX_SOCKET_DIR/clawdbot.sock"`.
- Use `CLAWDBOT_TMUX_SOCKET_DIR` (default `${TMPDIR:-/tmp}/moltbot-tmux-sockets`).
- Default socket path: `"$CLAWDBOT_TMUX_SOCKET_DIR/moltbot.sock"`.
## Targeting panes and naming

View File

@@ -20,7 +20,7 @@ socket_name=""
socket_path=""
query=""
scan_all=false
socket_dir="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
socket_dir="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/moltbot-tmux-sockets}"
while [[ $# -gt 0 ]]; do
case "$1" in

View File

@@ -2,12 +2,12 @@
name: trello
description: Manage Trello boards, lists, and cards via the Trello REST API.
homepage: https://developer.atlassian.com/cloud/trello/rest/
metadata: {"clawdbot":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}}
metadata: {"moltbot":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}}
---
# Trello Skill
Manage Trello boards, lists, and cards directly from Clawdbot.
Manage Trello boards, lists, and cards directly from Moltbot.
## Setup

View File

@@ -2,7 +2,7 @@
name: video-frames
description: Extract frames or short clips from videos using ffmpeg.
homepage: https://ffmpeg.org
metadata: {"clawdbot":{"emoji":"🎞️","requires":{"bins":["ffmpeg"]},"install":[{"id":"brew","kind":"brew","formula":"ffmpeg","bins":["ffmpeg"],"label":"Install ffmpeg (brew)"}]}}
metadata: {"moltbot":{"emoji":"🎞️","requires":{"bins":["ffmpeg"]},"install":[{"id":"brew","kind":"brew","formula":"ffmpeg","bins":["ffmpeg"],"label":"Install ffmpeg (brew)"}]}}
---
# Video Frames (ffmpeg)

View File

@@ -1,7 +1,7 @@
---
name: voice-call
description: Start voice calls via the Clawdbot voice-call plugin.
metadata: {"clawdbot":{"emoji":"📞","skillKey":"voice-call","requires":{"config":["plugins.entries.voice-call.enabled"]}}}
description: Start voice calls via the Moltbot voice-call plugin.
metadata: {"moltbot":{"emoji":"📞","skillKey":"voice-call","requires":{"config":["plugins.entries.voice-call.enabled"]}}}
---
# Voice Call
@@ -11,8 +11,8 @@ Use the voice-call plugin to start or inspect calls (Twilio, Telnyx, Plivo, or m
## CLI
```bash
clawdbot voicecall call --to "+15555550123" --message "Hello from Clawdbot"
clawdbot voicecall status --call-id <id>
moltbot voicecall call --to "+15555550123" --message "Hello from Moltbot"
moltbot voicecall status --call-id <id>
```
## Tool

View File

@@ -2,13 +2,13 @@
name: wacli
description: Send WhatsApp messages to other people or search/sync WhatsApp history via the wacli CLI (not for normal user chats).
homepage: https://wacli.sh
metadata: {"clawdbot":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/wacli","bins":["wacli"],"label":"Install wacli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
metadata: {"moltbot":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/wacli","bins":["wacli"],"label":"Install wacli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
---
# wacli
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
Do NOT use `wacli` for normal user chats; Clawdbot routes WhatsApp conversations automatically.
Do NOT use `wacli` for normal user chats; Moltbot routes WhatsApp conversations automatically.
If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party.
Safety

View File

@@ -2,7 +2,7 @@
name: weather
description: Get current weather and forecasts (no API key required).
homepage: https://wttr.in/:help
metadata: {"clawdbot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
metadata: {"moltbot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
---
# Weather

View File

@@ -1 +1 @@
1376c2e99ad07193d9ab1719200675d84ffb40db417d05128cf07c3b8283581e
b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4

View File

@@ -51,6 +51,8 @@ export function pickProbeHostForBind(
}
const SAFE_DAEMON_ENV_KEYS = [
"MOLTBOT_STATE_DIR",
"MOLTBOT_CONFIG_PATH",
"CLAWDBOT_PROFILE",
"CLAWDBOT_STATE_DIR",
"CLAWDBOT_CONFIG_PATH",

View File

@@ -36,22 +36,43 @@ describe("Nix integration (U3, U5, U9)", () => {
describe("U5: CONFIG_PATH and STATE_DIR env var overrides", () => {
it("STATE_DIR defaults to ~/.clawdbot when env not set", async () => {
await withEnvOverride({ CLAWDBOT_STATE_DIR: undefined }, async () => {
const { STATE_DIR } = await import("./config.js");
expect(STATE_DIR).toMatch(/\.clawdbot$/);
});
await withEnvOverride(
{ MOLTBOT_STATE_DIR: undefined, CLAWDBOT_STATE_DIR: undefined },
async () => {
const { STATE_DIR } = await import("./config.js");
expect(STATE_DIR).toMatch(/\.clawdbot$/);
},
);
});
it("STATE_DIR respects CLAWDBOT_STATE_DIR override", async () => {
await withEnvOverride({ CLAWDBOT_STATE_DIR: "/custom/state/dir" }, async () => {
const { STATE_DIR } = await import("./config.js");
expect(STATE_DIR).toBe(path.resolve("/custom/state/dir"));
});
await withEnvOverride(
{ MOLTBOT_STATE_DIR: undefined, CLAWDBOT_STATE_DIR: "/custom/state/dir" },
async () => {
const { STATE_DIR } = await import("./config.js");
expect(STATE_DIR).toBe(path.resolve("/custom/state/dir"));
},
);
});
it("STATE_DIR prefers MOLTBOT_STATE_DIR over legacy override", async () => {
await withEnvOverride(
{ MOLTBOT_STATE_DIR: "/custom/new", CLAWDBOT_STATE_DIR: "/custom/legacy" },
async () => {
const { STATE_DIR } = await import("./config.js");
expect(STATE_DIR).toBe(path.resolve("/custom/new"));
},
);
});
it("CONFIG_PATH defaults to ~/.clawdbot/moltbot.json when env not set", async () => {
await withEnvOverride(
{ CLAWDBOT_CONFIG_PATH: undefined, CLAWDBOT_STATE_DIR: undefined },
{
MOLTBOT_CONFIG_PATH: undefined,
MOLTBOT_STATE_DIR: undefined,
CLAWDBOT_CONFIG_PATH: undefined,
CLAWDBOT_STATE_DIR: undefined,
},
async () => {
const { CONFIG_PATH } = await import("./config.js");
expect(CONFIG_PATH).toMatch(/\.clawdbot[\\/]moltbot\.json$/);
@@ -60,24 +81,45 @@ describe("Nix integration (U3, U5, U9)", () => {
});
it("CONFIG_PATH respects CLAWDBOT_CONFIG_PATH override", async () => {
await withEnvOverride({ CLAWDBOT_CONFIG_PATH: "/nix/store/abc/moltbot.json" }, async () => {
const { CONFIG_PATH } = await import("./config.js");
expect(CONFIG_PATH).toBe(path.resolve("/nix/store/abc/moltbot.json"));
});
await withEnvOverride(
{ MOLTBOT_CONFIG_PATH: undefined, CLAWDBOT_CONFIG_PATH: "/nix/store/abc/moltbot.json" },
async () => {
const { CONFIG_PATH } = await import("./config.js");
expect(CONFIG_PATH).toBe(path.resolve("/nix/store/abc/moltbot.json"));
},
);
});
it("CONFIG_PATH prefers MOLTBOT_CONFIG_PATH over legacy override", async () => {
await withEnvOverride(
{
MOLTBOT_CONFIG_PATH: "/nix/store/new/moltbot.json",
CLAWDBOT_CONFIG_PATH: "/nix/store/legacy/moltbot.json",
},
async () => {
const { CONFIG_PATH } = await import("./config.js");
expect(CONFIG_PATH).toBe(path.resolve("/nix/store/new/moltbot.json"));
},
);
});
it("CONFIG_PATH expands ~ in CLAWDBOT_CONFIG_PATH override", async () => {
await withTempHome(async (home) => {
await withEnvOverride({ CLAWDBOT_CONFIG_PATH: "~/.clawdbot/custom.json" }, async () => {
const { CONFIG_PATH } = await import("./config.js");
expect(CONFIG_PATH).toBe(path.join(home, ".clawdbot", "custom.json"));
});
await withEnvOverride(
{ MOLTBOT_CONFIG_PATH: undefined, CLAWDBOT_CONFIG_PATH: "~/.clawdbot/custom.json" },
async () => {
const { CONFIG_PATH } = await import("./config.js");
expect(CONFIG_PATH).toBe(path.join(home, ".clawdbot", "custom.json"));
},
);
});
});
it("CONFIG_PATH uses STATE_DIR when only state dir is overridden", async () => {
await withEnvOverride(
{
MOLTBOT_CONFIG_PATH: undefined,
MOLTBOT_STATE_DIR: undefined,
CLAWDBOT_CONFIG_PATH: undefined,
CLAWDBOT_STATE_DIR: "/custom/state",
},

View File

@@ -0,0 +1,69 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createConfigIO } from "./io.js";
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-"));
try {
await run(home);
} finally {
await fs.rm(home, { recursive: true, force: true });
}
}
async function writeConfig(home: string, dirname: ".moltbot" | ".clawdbot", port: number) {
const dir = path.join(home, dirname);
await fs.mkdir(dir, { recursive: true });
const configPath = path.join(dir, "moltbot.json");
await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2));
return configPath;
}
describe("config io compat (new + legacy folders)", () => {
it("prefers ~/.moltbot/moltbot.json when both configs exist", async () => {
await withTempHome(async (home) => {
const newConfigPath = await writeConfig(home, ".moltbot", 19001);
await writeConfig(home, ".clawdbot", 18789);
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).toBe(newConfigPath);
expect(io.loadConfig().gateway?.port).toBe(19001);
});
});
it("falls back to ~/.clawdbot/moltbot.json when only legacy exists", async () => {
await withTempHome(async (home) => {
const legacyConfigPath = await writeConfig(home, ".clawdbot", 20001);
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).toBe(legacyConfigPath);
expect(io.loadConfig().gateway?.port).toBe(20001);
});
});
it("honors explicit legacy config path env override", async () => {
await withTempHome(async (home) => {
const newConfigPath = await writeConfig(home, ".moltbot", 19002);
const legacyConfigPath = await writeConfig(home, ".clawdbot", 20002);
const io = createConfigIO({
env: { CLAWDBOT_CONFIG_PATH: legacyConfigPath } as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).not.toBe(newConfigPath);
expect(io.configPath).toBe(legacyConfigPath);
expect(io.loadConfig().gateway?.port).toBe(20002);
});
});
});

View File

@@ -28,7 +28,7 @@ import { collectConfigEnvVars } from "./env-vars.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type { MoltbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
@@ -186,7 +186,12 @@ export function parseConfigJson5(
export function createConfigIO(overrides: ConfigIoDeps = {}) {
const deps = normalizeDeps(overrides);
const configPath = resolveConfigPathForDeps(deps);
const requestedConfigPath = resolveConfigPathForDeps(deps);
const candidatePaths = deps.configPath
? [requestedConfigPath]
: resolveDefaultConfigCandidates(deps.env, deps.homedir);
const configPath =
candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath;
function loadConfig(): MoltbotConfig {
try {

View File

@@ -1,7 +1,12 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveOAuthDir, resolveOAuthPath } from "./paths.js";
import {
resolveDefaultConfigCandidates,
resolveOAuthDir,
resolveOAuthPath,
resolveStateDir,
} from "./paths.js";
describe("oauth paths", () => {
it("prefers CLAWDBOT_OAUTH_DIR over CLAWDBOT_STATE_DIR", () => {
@@ -27,3 +32,21 @@ describe("oauth paths", () => {
);
});
});
describe("state + config path candidates", () => {
it("prefers MOLTBOT_STATE_DIR over legacy state dir env", () => {
const env = {
MOLTBOT_STATE_DIR: "/new/state",
CLAWDBOT_STATE_DIR: "/legacy/state",
} as NodeJS.ProcessEnv;
expect(resolveStateDir(env, () => "/home/test")).toBe(path.resolve("/new/state"));
});
it("orders default config candidates as new then legacy", () => {
const home = "/home/test";
const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home);
expect(candidates[0]).toBe(path.join(home, ".moltbot", "moltbot.json"));
expect(candidates[1]).toBe(path.join(home, ".clawdbot", "moltbot.json"));
});
});

View File

@@ -15,18 +15,30 @@ export function resolveIsNixMode(env: NodeJS.ProcessEnv = process.env): boolean
export const isNixMode = resolveIsNixMode();
const LEGACY_STATE_DIRNAME = ".clawdbot";
const NEW_STATE_DIRNAME = ".moltbot";
const CONFIG_FILENAME = "moltbot.json";
function legacyStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), LEGACY_STATE_DIRNAME);
}
function newStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), NEW_STATE_DIRNAME);
}
/**
* State directory for mutable data (sessions, logs, caches).
* Can be overridden via CLAWDBOT_STATE_DIR environment variable.
* Default: ~/.clawdbot
* Can be overridden via MOLTBOT_STATE_DIR (preferred) or CLAWDBOT_STATE_DIR (legacy).
* Default: ~/.clawdbot (legacy default for compatibility)
*/
export function resolveStateDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const override = env.CLAWDBOT_STATE_DIR?.trim();
const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
return path.join(homedir(), ".clawdbot");
return legacyStateDir(homedir);
}
function resolveUserPath(input: string): string {
@@ -43,20 +55,46 @@ export const STATE_DIR = resolveStateDir();
/**
* Config file path (JSON5).
* Can be overridden via CLAWDBOT_CONFIG_PATH environment variable.
* Default: ~/.clawdbot/moltbot.json (or $CLAWDBOT_STATE_DIR/moltbot.json)
* Can be overridden via MOLTBOT_CONFIG_PATH (preferred) or CLAWDBOT_CONFIG_PATH (legacy).
* Default: ~/.clawdbot/moltbot.json (or $*_STATE_DIR/moltbot.json)
*/
export function resolveConfigPath(
env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir),
): string {
const override = env.CLAWDBOT_CONFIG_PATH?.trim();
const override = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
if (override) return resolveUserPath(override);
return path.join(stateDir, "moltbot.json");
return path.join(stateDir, CONFIG_FILENAME);
}
export const CONFIG_PATH = resolveConfigPath();
/**
* Resolve default config path candidates across new + legacy locations.
* Order: explicit config path → state-dir-derived paths → new default → legacy default.
*/
export function resolveDefaultConfigCandidates(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string[] {
const explicit = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
if (explicit) return [resolveUserPath(explicit)];
const candidates: string[] = [];
const moltbotStateDir = env.MOLTBOT_STATE_DIR?.trim();
if (moltbotStateDir) {
candidates.push(path.join(resolveUserPath(moltbotStateDir), CONFIG_FILENAME));
}
const legacyStateDirOverride = env.CLAWDBOT_STATE_DIR?.trim();
if (legacyStateDirOverride) {
candidates.push(path.join(resolveUserPath(legacyStateDirOverride), CONFIG_FILENAME));
}
candidates.push(path.join(newStateDir(homedir), CONFIG_FILENAME));
candidates.push(path.join(legacyStateDir(homedir), CONFIG_FILENAME));
return candidates;
}
export const DEFAULT_GATEWAY_PORT = 18789;
/**
@@ -77,7 +115,7 @@ const OAUTH_FILENAME = "oauth.json";
*
* Precedence:
* - `CLAWDBOT_OAUTH_DIR` (explicit override)
* - `CLAWDBOT_STATE_DIR/credentials` (canonical server/default)
* - `$*_STATE_DIR/credentials` (canonical server/default)
* - `~/.clawdbot/credentials` (legacy default)
*/
export function resolveOAuthDir(