Compare commits
10 Commits
6d16a658e5
...
79b946584e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b946584e | ||
|
|
e4518d2271 | ||
|
|
0594ccf92a | ||
|
|
3015e11fd7 | ||
|
|
5eff33abe6 | ||
|
|
3f83afe4a6 | ||
|
|
44f9017355 | ||
|
|
7e99311e1d | ||
|
|
58640e9ecb | ||
|
|
735aea9efa |
2
.github/workflows/install-smoke.yml
vendored
2
.github/workflows/install-smoke.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"moltbot": ">=2026.1.26"
|
||||
"dependencies": {
|
||||
"moltbot": "workspace:*"
|
||||
}
|
||||
}
|
||||
38
extensions/qq/README.md
Normal file
38
extensions/qq/README.md
Normal 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。
|
||||
11
extensions/qq/clawdbot.plugin.json
Normal file
11
extensions/qq/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "qq",
|
||||
"channels": [
|
||||
"qq"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
22
extensions/qq/index.ts
Normal file
22
extensions/qq/index.ts
Normal 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;
|
||||
33
extensions/qq/package.json
Normal file
33
extensions/qq/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
114
extensions/qq/src/accounts.ts
Normal file
114
extensions/qq/src/accounts.ts
Normal 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
200
extensions/qq/src/api.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
416
extensions/qq/src/channel.ts
Normal file
416
extensions/qq/src/channel.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
41
extensions/qq/src/config-schema.ts
Normal file
41
extensions/qq/src/config-schema.ts
Normal 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>;
|
||||
};
|
||||
534
extensions/qq/src/monitor.ts
Normal file
534
extensions/qq/src/monitor.ts
Normal 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 };
|
||||
}
|
||||
46
extensions/qq/src/probe.ts
Normal file
46
extensions/qq/src/probe.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
extensions/qq/src/runtime.ts
Normal file
18
extensions/qq/src/runtime.ts
Normal 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
117
extensions/qq/src/send.ts
Normal 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
193
extensions/qq/src/types.ts
Normal 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];
|
||||
@@ -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
38
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 🐦
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
1376c2e99ad07193d9ab1719200675d84ffb40db417d05128cf07c3b8283581e
|
||||
b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
69
src/config/io.compat.test.ts
Normal file
69
src/config/io.compat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user